From fcb28920fda025da091ae66ecb4410f73de7cc89 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 5 Jul 2026 00:40:02 +0200 Subject: [PATCH] feat: Local test sink destination - rehearse the pipeline with zero risk The in-binary RTMP sink existed only as a CLI tool, so "just try the app" still meant either a real stream key or a second terminal. Now "Local test sink" is a destination type: pick it in the Destinations tab and InstantClone spawns its own receiver on this PC and streams to it. No platform, no key, nothing leaves the machine - arm/activate/cut (and vertical) can be tested end to end before touching a real key. Details: - config: platform "sink" resolves to a fixed local URL on new high ports (rtmp :19350, player :19351) so manual sink runs on the documented :1936/:1937 defaults never collide with the managed one. Validation treats it as self-contained (no key required). - main: the egress supervisor owns one child process for all sink destinations - spawned on enable (before the pump dials, so the receiver is listening first), killed on disable/remove, reaped and respawned (10s throttle) if it dies, kill_on_drop so app exit takes it down, CREATE_NO_WINDOW so no console flashes. Its "[sink]" log lines are forwarded into the dashboard's Logs tab. - web: /platforms gains the sink entry with an inline explainer (open-by-default disclosure in the destination form). - UI: the form hides the stream-key field for sink; the card gets a "Watch output" link to the sink's built-in live player, its own stripe color, and a house icon. The first-run wizard deliberately does not offer sink - it writes the legacy single-destination config and its job is wiring the real platform. Verified live: app spawns the child with the destination, ffmpeg publish flows proxy -> sink ("publish accepted" in the dashboard log), the card reports alive + bitrate, the player answers on :19351, and disabling the destination kills the child and frees the ports. 235 tests green; fmt + clippy -D warnings clean. --- CHANGELOG.md | 26 +++++++++++ README.es.md | 8 ++-- README.md | 8 ++-- src/config.rs | 55 ++++++++++++++++++++++- src/main.rs | 118 +++++++++++++++++++++++++++++++++++++++++++++++++ src/web.rs | 5 ++- web/index.html | 44 +++++++++++++++--- 7 files changed, 247 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e2a086..d3c8e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ All notable changes will land here. Format loosely follows ## [Unreleased] +### Local test sink destination + +**Try the whole pipeline with zero risk.** The in-binary RTMP sink +(`instantclone sink`, until now a CLI-only tool) is available as a +destination: pick **Local test sink** in the Destinations tab and +InstantClone spawns its own tiny receiver on this PC and streams to it. +No platform account, no stream key, nothing leaves your machine - test +arm / activate / cut (and vertical) end to end before touching a real +key. + +- The managed child process follows the destination's lifecycle: spawned + when a sink destination is enabled (one child serves any number of + them), killed when the last one is disabled or removed, reaped and + respawned if it dies, and taken down with the app. It lives on fixed + high ports (rtmp :19350, player :19351) so manual `instantclone sink` + runs on the documented defaults (:1936/:1937) never collide with it. +- The destination card gets a **▶ Watch output** link to the sink's + built-in live player - watch exactly what a platform would receive, + delay and cuts included. +- The sink's own log lines (`publish accepted`, the 1 Hz stat windows) + are forwarded into the dashboard's Logs tab, so the feedback loop is + visible without a terminal. +- The destination form hides the stream-key field for it (any key works) + and explains the feature inline; the first-run wizard deliberately + does not offer it - the wizard wires your real platform. + ### "Cut after this airs" - scheduled safe cut **Stop counting your delay down in your head.** With a delay active, a new diff --git a/README.es.md b/README.es.md index 41d796f..ae1ab8c 100644 --- a/README.es.md +++ b/README.es.md @@ -49,7 +49,7 @@ Cuando ya lo tenía hecho, las piezas que de verdad quería eran: una activació RSS inactivo~9 MB Hilos1 tokio + 1 bandeja Deps en runtimetokio, bytes, ureq -Tests234 / 234 +Tests235 / 235 @@ -155,7 +155,7 @@ Seguramente desaparezca tu chat de twitch en OBS porque OBS detecta que no "vas > [!TIP] -> Haz fan-out de un único feed de OBS a varios destinos a la vez. Añade Twitch, YouTube y un endpoint RTMP personalizado, activa cada uno por separado y mira su bitrate en vivo por destino. +> Haz fan-out de un único feed de OBS a varios destinos a la vez. Añade Twitch, YouTube y un endpoint RTMP personalizado, activa cada uno por separado y mira su bitrate en vivo por destino. También hay un destino **Local test sink**: InstantClone lanza su propio receptor diminuto en tu PC y emite hacia él, para ensayar armar/activar/cortar de punta a punta - sin stream key, sin que nada salga de tu máquina, y con un enlace **Watch output** que muestra exactamente lo que recibiría una plataforma. > [!TIP] > **Vertical gratis.** Activa el **Formato Dual** de Twitch (Enhanced Broadcasting) en OBS y pon el **Formato de stream** de cualquier destino que no sea Twitch en **Vertical**: InstantClone reutiliza el lienzo 9:16 que OBS ya genera para Twitch y lo envía a YouTube Shorts, Kick móvil o cualquier RTMP personalizado, sin codificación extra. El vertical solo fluye mientras el Formato Dual está activo; hasta entonces el destino muestra "Esperando Formato Dual" y nada más se ve afectado. (Twitch gestiona ambos lienzos por su cuenta, así que ahí la opción se oculta.) @@ -281,7 +281,7 @@ cargo build --release Sin npm, sin submódulos, sin SDK de plataforma. El HTML del panel se minifica y comprime con gzip en tiempo de compilación desde `build.rs` (usa `flate2`, solo build-dep) y se embebe en el binario; en runtime se sirve con `Content-Encoding: gzip`. -`cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR en AVC + Enhanced RTMP, codec AMF0 incluyendo Strict Array (la `fourCcList` de Enhanced-RTMP) + guardia de recursión, round-trip de settings, evicción del ring-buffer con protección de lecturas en vuelo, parsing HTTP, política CSRF, pre-flight de puertos, la negociación de contenido `accepts_gzip`, la caché de cabeceras de secuencia por-pista de Enhanced Broadcasting + selección de tags consciente del TrackId, el audio multi-pista de Enhanced-RTMP (pista de VOD de Twitch), el filtro de IDR de pista primaria para que los cortes con EB no glitcheen las escaleras, el parseo de orientación del SPS para la selección de lienzo vertical (9:16), la resolución de `user.ini` / `global.ini` en OBS 32, el parcheado de `services.json` de OBS, el parser de releases de GitHub + comparador SemVer-ish para el chequeo de actualizaciones, la implementación propia de SHA-256 (vectores NIST), el lector/escritor del flujo de chunks RTMP (cabeceras fmt 0-3, timestamps extendidos, fragmentación entre chunks, control Set-Chunk-Size / Window-Ack en banda, y guardias ante entrada malformada), la máquina de estados del corte programado ("cortar cuando esto salga"), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 234 tests, todos en verde. +`cargo test --release` cubre la máquina de estados (`arm → preparing → ready → active → cut`), detección de IDR en AVC + Enhanced RTMP, codec AMF0 incluyendo Strict Array (la `fourCcList` de Enhanced-RTMP) + guardia de recursión, round-trip de settings, evicción del ring-buffer con protección de lecturas en vuelo, parsing HTTP, política CSRF, pre-flight de puertos, la negociación de contenido `accepts_gzip`, la caché de cabeceras de secuencia por-pista de Enhanced Broadcasting + selección de tags consciente del TrackId, el audio multi-pista de Enhanced-RTMP (pista de VOD de Twitch), el filtro de IDR de pista primaria para que los cortes con EB no glitcheen las escaleras, el parseo de orientación del SPS para la selección de lienzo vertical (9:16), la resolución de `user.ini` / `global.ini` en OBS 32, el parcheado de `services.json` de OBS, el parser de releases de GitHub + comparador SemVer-ish para el chequeo de actualizaciones, la implementación propia de SHA-256 (vectores NIST), el lector/escritor del flujo de chunks RTMP (cabeceras fmt 0-3, timestamps extendidos, fragmentación entre chunks, control Set-Chunk-Size / Window-Ack en banda, y guardias ante entrada malformada), la máquina de estados del corte programado ("cortar cuando esto salga"), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 235 tests, todos en verde.
@@ -289,7 +289,7 @@ Sin npm, sin submódulos, sin SDK de plataforma. El HTML del panel se minifica y ## Estado -**Listo para uso diario en Windows.** Lo uso en mis propios directos. CI corre fmt + clippy (con `-D warnings`) + 234 tests en cada push, y un tag dispara la build + publicación automática de la release con su `SHA256SUMS.txt` al lado (todavía no hay certificado de firma de código, así que el sistema operativo puede avisar en el primer lanzamiento). +**Listo para uso diario en Windows.** Lo uso en mis propios directos. CI corre fmt + clippy (con `-D warnings`) + 235 tests en cada push, y un tag dispara la build + publicación automática de la release con su `SHA256SUMS.txt` al lado (todavía no hay certificado de firma de código, así que el sistema operativo puede avisar en el primer lanzamiento). **Lo que está sólido** diff --git a/README.md b/README.md index 6274fa4..c25e555 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Once it existed, the parts I'd actually wanted ended up in: a real two-phase arm Idle RSS~9 MB Threads1 tokio + 1 tray Runtime depstokio, bytes, ureq -Tests234 / 234 +Tests235 / 235 @@ -155,7 +155,7 @@ Click **Start Streaming**. The OBS pill in InstantClone turns green. Your real T > [!TIP] -> Fan out one OBS feed to several destinations at once. Add Twitch, YouTube, and a custom RTMP endpoint, toggle each on independently, watch their per-destination bitrate live. +> Fan out one OBS feed to several destinations at once. Add Twitch, YouTube, and a custom RTMP endpoint, toggle each on independently, watch their per-destination bitrate live. There is also a **Local test sink** destination: InstantClone spawns its own tiny receiver on your PC and streams to it, so you can rehearse arm/activate/cut end to end - stream key not needed, nothing leaves your machine, and a **Watch output** link shows exactly what a platform would receive. > [!TIP] > **Go vertical for free.** Turn on Twitch **Dual Format** (Enhanced Broadcasting) in OBS and set any non-Twitch destination's **Stream format** to **Vertical** - InstantClone reuses the 9:16 canvas OBS is already making for Twitch and sends it to YouTube Shorts, Kick mobile, or any custom RTMP target, with no extra encoding. Vertical only flows while Dual Format is on; until then the destination shows "Waiting for Dual Format" and nothing else is affected. (Twitch handles both canvases itself, so the option is hidden there.) @@ -281,7 +281,7 @@ cargo build --release No npm. No submodules. No platform SDKs. The dashboard HTML is minified + gzipped at build time by `build.rs` (uses `flate2`, build-only) and embedded into the binary; at runtime it's served with `Content-Encoding: gzip`. -`cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced RTMP IDR detection, AMF0 codec including Strict Array (Enhanced-RTMP `fourCcList`) + recursion guard, settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, `accepts_gzip` content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, Enhanced-RTMP multi-track audio (Twitch's VOD audio track), primary-track IDR gate so EB cuts don't pixel-glitch ladder rungs, SPS orientation parsing for vertical-canvas (9:16) selection, OBS 32 `user.ini` / legacy `global.ini` path resolution, the OBS services.json patcher, the GitHub releases update-check parser + SemVer-ish comparator, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer (fmt 0-3 headers, extended timestamps, cross-chunk fragmentation, in-band Set-Chunk-Size / Window-Ack control, and malformed-input guards), the scheduled safe-cut ("cut after this airs") state machine, and the self-update download + checksum-verify + on-disk exe swap. 234 tests, all green. +`cargo test --release` covers the state machine (`arm → preparing → ready → active → cut`), AVC + Enhanced RTMP IDR detection, AMF0 codec including Strict Array (Enhanced-RTMP `fourCcList`) + recursion guard, settings round-trip, ring-buffer eviction with in-flight-read protection, HTTP parsing, CSRF policy, port pre-flight, `accepts_gzip` content negotiation, Enhanced Broadcasting per-track seq-header cache + TrackId-aware tag selection, Enhanced-RTMP multi-track audio (Twitch's VOD audio track), primary-track IDR gate so EB cuts don't pixel-glitch ladder rungs, SPS orientation parsing for vertical-canvas (9:16) selection, OBS 32 `user.ini` / legacy `global.ini` path resolution, the OBS services.json patcher, the GitHub releases update-check parser + SemVer-ish comparator, the hand-rolled SHA-256 (NIST vectors), the RTMP chunk-stream reader/writer (fmt 0-3 headers, extended timestamps, cross-chunk fragmentation, in-band Set-Chunk-Size / Window-Ack control, and malformed-input guards), the scheduled safe-cut ("cut after this airs") state machine, and the self-update download + checksum-verify + on-disk exe swap. 235 tests, all green.
@@ -289,7 +289,7 @@ No npm. No submodules. No platform SDKs. The dashboard HTML is minified + gzippe ## Status -**Daily-driver ready on Windows.** I use it on my own streams. CI runs fmt + clippy (with `-D warnings`) + 234 tests on every push, and a tagged commit auto-builds + publishes a release artifact with a `SHA256SUMS.txt` checksum file alongside (no code-signing certificate yet, so the OS may warn on first launch). +**Daily-driver ready on Windows.** I use it on my own streams. CI runs fmt + clippy (with `-D warnings`) + 235 tests on every push, and a tagged commit auto-builds + publishes a release artifact with a `SHA256SUMS.txt` checksum file alongside (no code-signing certificate yet, so the OS may warn on first launch). **What's solid** diff --git a/src/config.rs b/src/config.rs index 1fb2230..b980fee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,15 @@ const MAX_PROFILES: usize = 256; /// hand-edited config sets `buffer_mb=0`. const MIN_BUFFER_MB: u64 = 50; +/// Ports for the built-in "Local test sink" destination (platform +/// `"sink"`). The egress supervisor spawns `instantclone sink` as a +/// child process on these when such a destination is enabled. Fixed +/// (not configurable) on purpose: the sink's own CLI defaults (1936 / +/// 1937) are what people use for MANUAL sink runs and the e2e script, +/// so the managed instance stays out of their way on a high pair. +pub const SINK_RTMP_PORT: u16 = 19350; +pub const SINK_WEB_PORT: u16 = 19351; + #[derive(Debug, Clone)] pub struct Settings { pub ingest_port: u16, @@ -92,7 +101,9 @@ pub struct Destination { pub id: String, // stable across renames; UI key pub name: String, // user label ("Twitch", "Backup", etc.) pub enabled: bool, - pub platform: String, // "twitch" | "youtube" | "kick" | "trovo" | "restream" | "custom" + // "twitch" | "youtube" | "kick" | "trovo" | "restream" | "custom" + // | "sink" (the built-in local test sink - see SINK_RTMP_PORT) + pub platform: String, pub stream_key: String, pub custom_egress_url: String, // used when platform == "custom" /// Only meaningful when platform == "twitch". Empty = auto-route via @@ -154,6 +165,14 @@ impl Destination { /// rules as the top-level single-dest path: if a custom URL already has /// app+key, the key field is ignored; otherwise the key is appended. pub fn egress_url(&self) -> Option { + // Local test sink: fully self-contained. The URL is fixed (the + // supervisor spawns the matching `instantclone sink` child on + // SINK_RTMP_PORT) and the stream key is ignored - the sink + // accepts any key, so requiring one would just add a fake + // field to fill in. + if self.platform == "sink" { + return Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT)); + } if self.platform == "custom" { let base = non_empty(&self.custom_egress_url)? .trim_end_matches('/') @@ -811,6 +830,8 @@ impl Settings { errs.push(format!("{}: custom URL must start with rtmp://", d.name)); continue; } + } else if d.platform == "sink" { + // Self-contained: fixed local URL, no key needed. } else if platform_base(&d.platform).is_none() { errs.push(format!("{}: unknown platform '{}'", d.name, d.platform)); continue; @@ -1124,6 +1145,38 @@ mod tests { assert!(is_path_safe(&PathBuf::from("/home/user/buf"))); } + #[test] + fn sink_destination_is_self_contained() { + // No stream key, no custom URL - the local test sink needs + // neither. The URL is the fixed managed-child endpoint, and + // validate() must not demand a key for it. + let d = Destination { + id: "x".into(), + name: "Test sink".into(), + enabled: true, + platform: "sink".into(), + stream_key: String::new(), + custom_egress_url: String::new(), + twitch_ingest: String::new(), + youtube_ingest: String::new(), + vod_audio: false, + vod_audio_inject_eb: false, + stream_format: "horizontal".into(), + }; + assert_eq!( + d.egress_url().as_deref(), + Some(format!("rtmp://127.0.0.1:{}/live/test", SINK_RTMP_PORT).as_str()) + ); + assert!(d.is_well_formed()); + let mut s = Settings::defaults(); + s.destinations.push(d); + assert!( + s.validate().is_empty(), + "sink destination must validate without a key: {:?}", + s.validate() + ); + } + #[test] fn destination_egress_url_for_twitch_appends_key() { let d = Destination { diff --git a/src/main.rs b/src/main.rs index 5ec0754..0673084 100644 --- a/src/main.rs +++ b/src/main.rs @@ -369,6 +369,14 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc = None; + let mut sink_last_spawn: Option = None; + // Behaviour-toggle state. Auto-arm uses the `prev_ingest_alive` // edge detector (fires once per publisher session, on the false -> // true transition). Auto-activate reads the controller's @@ -386,6 +394,13 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc = { rx.borrow().active_destinations() }; + // Keep the managed test-sink child in sync with the desired set + // BEFORE the pump diff below, so a freshly enabled sink + // destination has its receiver listening by the time its pump + // dials 127.0.0.1. + let wants_sink = desired.iter().any(|(d, _)| d.platform == "sink"); + manage_test_sink(&mut sink_child, &mut sink_last_spawn, wants_sink, &ctrl).await; + // 1) Stop any pump whose dest is no longer desired (or whose URL changed). let desired_ids: std::collections::HashSet = desired.iter().map(|(d, _)| d.id.clone()).collect(); @@ -712,6 +727,109 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc, + last_spawn: &mut Option, + want: bool, + ctrl: &Arc, +) { + // Reap first: a child that died (port conflict, crash) must not + // count as running, or we would never respawn it. + if let Some(c) = child.as_mut() { + if let Ok(Some(status)) = c.try_wait() { + ctrl.log(format!("[test-sink] exited ({status})")); + *child = None; + } + } + if !want { + if let Some(mut c) = child.take() { + let _ = c.kill().await; + ctrl.log("[test-sink] stopped - no sink destination enabled"); + } + return; + } + if child.is_some() { + return; + } + if let Some(t) = last_spawn { + if t.elapsed() < Duration::from_secs(10) { + return; + } + } + *last_spawn = Some(std::time::Instant::now()); + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + ctrl.log(format!("[test-sink] can't locate own exe: {e}")); + return; + } + }; + let mut cmd = tokio::process::Command::new(exe); + cmd.args([ + "sink", + "--port", + &config::SINK_RTMP_PORT.to_string(), + "--web-port", + &config::SINK_WEB_PORT.to_string(), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + // The runtime dropping the supervisor task (app quit) takes the + // child with it - no orphaned sink holding the ports. + .kill_on_drop(true); + // The parent runs without a console (windows_subsystem = windows); + // without this flag the child would flash one open. + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + match cmd.spawn() { + Ok(mut c) => { + if let Some(out) = c.stdout.take() { + tokio::spawn(forward_sink_lines(out, ctrl.clone())); + } + if let Some(err) = c.stderr.take() { + tokio::spawn(forward_sink_lines(err, ctrl.clone())); + } + ctrl.log(format!( + "[test-sink] started - rtmp :{}, live player http://127.0.0.1:{}/", + config::SINK_RTMP_PORT, + config::SINK_WEB_PORT + )); + *child = Some(c); + } + Err(e) => ctrl.log(format!("[test-sink] failed to spawn: {e}")), + } +} + +/// Pipe one of the sink child's output streams into the dashboard log. +/// Only `[sink...]`-prefixed lines pass - the startup banner box art and +/// blank lines stay out of the 512-entry log ring. +async fn forward_sink_lines( + stream: impl tokio::io::AsyncRead + Unpin, + ctrl: Arc, +) { + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.starts_with("[sink") { + ctrl.log(line); + } + } +} + async fn supervise_web( mut rx: watch::Receiver, ctrl: Arc, diff --git a/src/web.rs b/src/web.rs index 5bf91b7..c80d240 100644 --- a/src/web.rs +++ b/src/web.rs @@ -779,8 +779,9 @@ fn platforms_json() -> String { {"slug":"kick","label":"Kick","key_url":"https://kick.com/dashboard/settings/stream","key_help":"Kick Creator Dashboard → Settings → Stream","tip":"Kick ingests on AWS IVS (low-latency). What it actually enforces: H.264, CBR, keyframe interval 2 s, bitrate ≤ 8000 kbps, up to 60 fps. B-frames: contrary to a lot of older guides, Kick's normal RTMP ingest accepts them - the strict no-B-frames rule is AWS IVS real-time/WHIP, which Kick doesn't use for OBS streaming, so you usually don't need to change anything. If Kick ever rejects your stream, set B-frames to 0 in OBS (Output → Advanced); that's safe for Twitch/YouTube too. InstantClone forwards one encode without re-encoding, so B-frames can't be stripped for Kick alone. And with Twitch Enhanced Broadcasting on, Twitch chooses the encode settings (including B-frames) for you."}, {"slug":"trovo","label":"Trovo","key_url":"https://studio.trovo.live/channel/myinfo","key_help":"Trovo Studio → Channel → My Info → Stream Key","tip":null}, {"slug":"restream","label":"Restream.io","key_url":"https://app.restream.io/channel-settings","key_help":"Restream → Channel Settings → Stream Key","tip":"Restream relays your single stream to multiple platforms - per-platform limits apply on the downstream side, not here."}, - {"slug":"custom","label":"Custom RTMP URL","key_url":null,"key_help":null,"tip":null} -]"#.to_string() + {"slug":"custom","label":"Custom RTMP URL","key_url":null,"key_help":null,"tip":null}, + {"slug":"sink","label":"Local test sink (nothing leaves your PC)","key_url":null,"key_help":null,"tip":"InstantClone runs its own tiny RTMP receiver on this PC and streams to it - test arm / activate / cut end to end with zero risk: no real platform, no stream key, nothing leaves your machine. While it's receiving, open http://127.0.0.1:SINK_WEB_PORT/ to watch exactly what a platform would get (including the delay)."} +]"#.replace("SINK_WEB_PORT", &crate::config::SINK_WEB_PORT.to_string()) } /// Serve the multi-track-video config endpoint OBS calls when its diff --git a/web/index.html b/web/index.html index ee1d8ff..5d2aaeb 100644 --- a/web/index.html +++ b/web/index.html @@ -532,6 +532,10 @@ .dcard.platform-trovo {--dc:#67e8f9} .dcard.platform-restream {--dc:#5eead4} .dcard.platform-custom {--dc:#fcd34d} +.dcard.platform-sink {--dc:#93c5fd} +.dcard-player{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dc,var(--accent)); + text-decoration:none;margin-left:auto} +.dcard-player:hover{text-decoration:underline;text-underline-offset:2px} .dcard-head{display:flex;align-items:center;gap:9px} .dcard-icon{width:34px;height:34px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center; background:color-mix(in oklch,var(--dc,var(--fg-3)) 16%,var(--surface-3)); @@ -1564,7 +1568,7 @@

New destination

Nothing to set here - a normal Twitch stream just works, and so does regular Enhanced Broadcasting (that's horizontal too); InstantClone simply forwards whatever OBS sends. Only Twitch's new Dual Format (a beta add-on to Enhanced Broadcasting) also sends a vertical 9:16 canvas - when it's on, InstantClone forwards both automatically, no toggle needed. How to enable Twitch Dual Format →
Stream key & status
- +