From b940c2e9abd7d06287977b1dde68642111412ff7 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Tue, 30 Jun 2026 21:41:27 +0200 Subject: [PATCH 01/33] feat: vertical (9:16) output for non-Twitch destinations + VOD/EB convenience (v0.1.9) Reuse Twitch Dual Format's vertical canvas to simulcast 9:16 to YouTube Shorts / Kick mobile / TikTok with zero extra encoding. - config: per-destination `stream_format` (horizontal default | vertical), persisted lean, with a `wants_vertical()` guard (Twitch forced horizontal). - h264: bounds-checked SPS dimension parser + `detect_vertical_primary_track` (orientation from the wire, not Twitch's private session JSON); generalize `select_video_bytes` to `VideoEgress { Passthrough, Track(u8) }` so a vertical destination forwards only the portrait canvas, flattened, and drops the horizontal primary. - controller/main/buffer: per-dest `egress_vertical` + `vertical_primary_track` (0xFF = unresolved -> waits, others unaffected); recomputed every supervisor tick so Dual Format on/off self-heals. Seq-header replay is format-aware. - web UI: Stream format dropdown (hidden for Twitch, with a "Twitch does both" note), status JSON `stream_format` + `vertical_ready`, a card format icon with hover tooltip, a distinct "Waiting for Dual Format" status, and tour coverage. - VOD+EB: one-click "Set up VOD + EB" with a red-to-green checklist, a `--launch-eb` cold-start flag, a desktop-shortcut generator (.lnk, .cmd fallback), and a tray "Launch OBS (VOD + EB)" item. - docs: README EN/ES + CHANGELOG; test count 200 -> 205 (5 new h264 tests). YouTube/Kick have no Twitch-style single-feed multitrack; vertical maps to their normal separate-key vertical ingest, so there is no conflict. --- CHANGELOG.md | 47 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.es.md | 14 +- README.md | 14 +- src/config.rs | 57 ++++- src/controller.rs | 97 ++++++-- src/h264.rs | 525 ++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 62 ++++++ src/obs_register.rs | 83 +++++++ src/tray.rs | 20 ++ src/web.rs | 115 +++++++++- web/index.html | 174 ++++++++++++++- 13 files changed, 1150 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19547db..7c82d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,53 @@ All notable changes will land here. Format loosely follows Nothing yet. +## [0.1.9] - Vertical output + VOD/EB convenience + +### Vertical output for non-Twitch destinations (YouTube Shorts, Kick mobile, TikTok) + +**Reuse Twitch Dual Format's vertical canvas to simulcast 9:16 anywhere, +with zero extra encoding.** When you enable Twitch Dual Format (Enhanced +Broadcasting) in OBS, OBS already produces a vertical 9:16 canvas and +sends it to InstantClone alongside the horizontal one. Until now every +non-Twitch destination only ever got the horizontal primary track and the +vertical canvas was discarded. Each destination now has a **Stream format** +choice (Horizontal / Vertical); set a YouTube, Kick, or custom destination +to **Vertical** and it forwards the vertical canvas instead, flattened to a +standard single-track 9:16 RTMP feed those platforms accept natively (this +is exactly YouTube's "Dual stream, separate encoder key" path - paste the +vertical stream key). + +How it stays robust: + +- The vertical canvas is identified by **decoding each track's SPS for + orientation** (portrait = `height > width`), not by guessing track IDs - + the canvas-to-track mapping lives only in Twitch's private session JSON, + so we read what's actually on the wire instead. The SPS parser is fully + bounds-checked and never panics on partial or hostile bytes. + +- A vertical destination whose canvas isn't available yet (Dual Format + off) **waits and sends no video**, showing "Waiting for Dual Format", + while Twitch and every horizontal destination keep streaming untouched. + Detection re-runs each supervisor tick, so turning Dual Format on/off + mid-stream self-heals with no restart. + +- The control is **hidden for Twitch**, which sends both canvases natively + via Dual Format - a note explains that so the choice never confuses. + +### VOD audio + Enhanced Broadcasting: fewer manual steps + +- New **one-click "Set up VOD + EB"** writes OBS's VOD-track unlock flag, + launches OBS with the `--config-url` flag (the only path OBS honours for + Custom RTMP), and re-verifies the flag landed, reporting a red-to-green + checklist with the exact fix when a step fails (partial-success aware, + e.g. "close OBS and retry"). + +- New **"Create desktop shortcut"** generates a Desktop `.lnk` (with a + `.cmd` fallback) that cold-starts InstantClone in VOD + EB mode via the + new `--launch-eb` flag - one double-click brings up the whole setup with + no dashboard clicks. The same one-click launch is available from the + tray ("Launch OBS (VOD + EB)"). + ## [0.1.8] - VOD-audio session race fix **VOD audio could go live Source-Only with duplicate sessions.** On a diff --git a/Cargo.lock b/Cargo.lock index 55042f3..f1950b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "instantclone" -version = "0.1.8" +version = "0.1.9" dependencies = [ "bytes", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 914a41c..24f37f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "instantclone" -version = "0.1.8" +version = "0.1.9" edition = "2021" authors = ["s1moscs"] license = "GPL-3.0-only" diff --git a/README.es.md b/README.es.md index f24b2dd..a4b1cff 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 -Tests200 / 200 +Tests205 / 205 @@ -157,6 +157,9 @@ 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. +> [!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.) + @@ -276,7 +279,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, 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), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 200 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), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 205 tests, todos en verde.
@@ -284,15 +287,16 @@ 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`) + 200 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`) + 205 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** - La máquina de estados `arm → activate → cut` en dos fases, con cortes alineados a IDR y reescritura monótona de timestamps. La pieza por la que empecé este proyecto. - **Ajuste de delay en vivo**: re-armar o cambiar el delay arriba/abajo sin desarmar primero. El backend ya lo soportaba; el panel ahora lo expone como un valor escrito + CTA "↻ Adjust ↑/↓ to Ns". - **Handshake RTMP a la altura de OBS.** `connect` lleva el mismo paquete de capacidades de códec que envía librtmp (`audioCodecs=3191`, `videoCodecs=252`, `videoFunction=1`), la `fourCcList` de Enhanced-RTMP (AVC / HEVC / AV1 / VP9 / Opus / AC-3 / FLAC), `Set Chunk Size` antes del connect, `FCUnpublish → deleteStream` al cerrar, y Acknowledgement RTMP (BYTES_READ_REPORT) cruzando el umbral window/10 declarado por el peer en ingest y egress. -- **Passthrough de Enhanced Broadcasting a Twitch.** Cuando OBS activa multi-track "Auto" proxyamos la `GetClientConfiguration` de Twitch, enrutamos el egress al endpoint IVS asignado para la sesión, y reenviamos cada SPS/PPS por pista bit a bit para que la escalera transcodificada se ilumine sin depender del tier de la cuenta. Los destinos no-Twitch sólo reciben la pista primaria - los tags de escalera multi-track con `TrackId != 0` se descartan para evitar la avalancha de múltiples frames por PTS que rompía el decoder de YouTube. Los cortes con EB aterrizan en el IDR de la pista primaria (no en el de la escalera que toque ganar el partition_point), así el decoder del destino siempre tiene su ancla. -- **Pista VOD de Twitch (audio multi-pista).** Toggle por destino. Escribimos `EnableCustomServerVodTrack` en el `user.ini` de OBS 32 (con fallback a `global.ini` en instalaciones antiguas) para desbloquear el checkbox de OBS, y luego reenviamos tanto los tags single-track de Enhanced-RTMP (live, TrackId 0) como los OneTrack multi-track (VOD, TrackId 1) bit a bit a Twitch. El lector de formato de cable coincide byte a byte con el `flv_packet_audio_ex` de OBS (`AudioPacketType` en el byte 0, `TrackId` en el byte 6). Live + VOD funcionan junto con los cortes de delay; para combinarlo con EB usa el botón experimental "Launch OBS for EB + VOD" en el editor de destino (spawnea `obs64.exe --config-url ` porque el plugin `rtmp_custom` de OBS descarta cualquier URL inyectada en el `service.json` al cargar). +- **Passthrough de Enhanced Broadcasting a Twitch.** Cuando OBS activa multi-track "Auto" proxyamos la `GetClientConfiguration` de Twitch, enrutamos el egress al endpoint IVS asignado para la sesión, y reenviamos cada SPS/PPS por pista bit a bit para que la escalera transcodificada se ilumine sin depender del tier de la cuenta. Los destinos no-Twitch reciben la pista primaria horizontal por defecto - los tags de escalera multi-track con `TrackId != 0` se descartan para evitar la avalancha de múltiples frames por PTS que rompía el decoder de YouTube. Los cortes con EB aterrizan en el IDR de la pista primaria (no en el de la escalera que toque ganar el partition_point), así el decoder del destino siempre tiene su ancla. +- **Salida vertical (9:16) para destinos que no son Twitch.** Pon el **Formato de stream** de un destino YouTube / Kick / personalizado en **Vertical** y reenviará el lienzo vertical del Formato Dual de Twitch en lugar del horizontal, aplanado a un feed single-track que esas plataformas aceptan de forma nativa (YouTube Shorts, Kick móvil, TikTok). El lienzo vertical se identifica decodificando la orientación del SPS de cada pista (vertical, mayor área) en vez de depender del JSON privado de sesión de Twitch, y se auto-corrige según el Formato Dual se activa/desactiva. Sólo fluye mientras el Formato Dual / Enhanced Broadcasting está activo en OBS; si no, la tarjeta del destino muestra "Esperando Formato Dual" y nada más se ve afectado. Se oculta para Twitch, que lleva ambos lienzos de forma nativa. +- **Pista VOD de Twitch (audio multi-pista).** Toggle por destino. Escribimos `EnableCustomServerVodTrack` en el `user.ini` de OBS 32 (con fallback a `global.ini` en instalaciones antiguas) para desbloquear el checkbox de OBS, y luego reenviamos tanto los tags single-track de Enhanced-RTMP (live, TrackId 0) como los OneTrack multi-track (VOD, TrackId 1) bit a bit a Twitch. El lector de formato de cable coincide byte a byte con el `flv_packet_audio_ex` de OBS (`AudioPacketType` en el byte 0, `TrackId` en el byte 6). Live + VOD funcionan junto con los cortes de delay; para combinarlo con EB usa el botón de un clic **"Set up VOD + EB"** (escribe el flag de desbloqueo, lanza `obs64.exe --config-url ` porque el plugin `rtmp_custom` de OBS descarta cualquier URL inyectada en el `service.json` al cargar, y luego re-verifica con un checklist rojo-a-verde), o genera un **acceso directo de escritorio** que arranca todo el flujo vía `instantclone.exe --launch-eb` (también en la bandeja). - **Registro de servicio en OBS con un click.** El primer paso del wizard añade una entrada "InstantClone" al desplegable de servicios de OBS (escribe `services.json` con `.bak` previo; se refresca si cambia el puerto; surfacea "cierra OBS primero" cuando el fichero está bloqueado). - Egress multi-destino con reconexión + bitrate por destino. - **UI consciente de la capacidad del buffer**: pista en vivo "X MB → máx Ys de delay a N Mbps", se niega a armar un delay mayor de lo que cabe con una razón explícita "necesita ≥ N MB". diff --git a/README.md b/README.md index 24fce1f..e6cf3fc 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 -Tests200 / 200 +Tests205 / 205 @@ -157,6 +157,9 @@ 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. +> [!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.) + @@ -276,7 +279,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, 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), and the self-update download + checksum-verify + on-disk exe swap. 200 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), and the self-update download + checksum-verify + on-disk exe swap. 205 tests, all green.
@@ -284,15 +287,16 @@ 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`) + 200 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`) + 205 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** - The two-phase `arm → activate → cut` state machine, with IDR-aligned cuts and monotonic timestamp rewrites. The thing that would have made me build this if it didn't exist. - **Live delay adjustment**: re-arm or adjust the delay up / down without disarming first. Backend already supported it; the cockpit now exposes it as a single typed-value + "↻ Adjust ↑/↓ to Ns" CTA. - **Full OBS-parity RTMP handshake.** `connect` carries the same codec-capability bag librtmp ships (`audioCodecs=3191`, `videoCodecs=252`, `videoFunction=1`), the Enhanced-RTMP `fourCcList` (AVC / HEVC / AV1 / VP9 / Opus / AC-3 / FLAC), `Set Chunk Size` before connect, `FCUnpublish → deleteStream` on shutdown, and RTMP Acknowledgement (BYTES_READ_REPORT) at the peer-declared window/10 threshold on both ingest and egress. -- **Enhanced Broadcasting passthrough to Twitch.** When OBS hits multi-track "Auto" we proxy Twitch's `GetClientConfiguration`, route egress to the session-allocated IVS endpoint, and forward every per-track SPS/PPS bit-faithfully so the transcoded ladder lights up regardless of account tier. Non-Twitch destinations get the primary track only - multi-track ladder tags with `TrackId != 0` are dropped to avoid the multi-frame-per-PTS storm that crashes YouTube's decoder. EB cuts land on the primary track's IDR (not whichever ladder rung's keyframe happens to win the partition_point) so the destination decoder always has its anchor. -- **Twitch VOD audio track (multi-track audio).** Per-destination toggle. We write `EnableCustomServerVodTrack` to OBS 32's `user.ini` (falling back to `global.ini` on older installs) so the OBS gate unlocks, then forward both Enhanced-RTMP single-track (live, TrackId 0) and OneTrack multi-track (VOD, TrackId 1) audio tags bit-faithfully to Twitch. Wire-format reader matches OBS's `flv_packet_audio_ex` byte-for-byte (`AudioPacketType` in byte 0, `TrackId` at byte 6). Live + VOD audio works alongside delay cuts; combine with EB via the experimental "Launch OBS for EB + VOD" button in the destination editor (spawns `obs64.exe --config-url ` because OBS's `rtmp_custom` plugin discards file-injected URLs at load time). +- **Enhanced Broadcasting passthrough to Twitch.** When OBS hits multi-track "Auto" we proxy Twitch's `GetClientConfiguration`, route egress to the session-allocated IVS endpoint, and forward every per-track SPS/PPS bit-faithfully so the transcoded ladder lights up regardless of account tier. Non-Twitch destinations get the horizontal primary track by default - multi-track ladder tags with `TrackId != 0` are dropped to avoid the multi-frame-per-PTS storm that crashes YouTube's decoder. EB cuts land on the primary track's IDR (not whichever ladder rung's keyframe happens to win the partition_point) so the destination decoder always has its anchor. +- **Vertical (9:16) output for non-Twitch destinations.** Set a YouTube / Kick / custom destination's **Stream format** to **Vertical** and it forwards Twitch Dual Format's vertical canvas instead of the horizontal one, flattened to a standard single-track feed those platforms accept natively (YouTube Shorts, Kick mobile, TikTok). The vertical canvas is identified by decoding each track's SPS for orientation (portrait, largest area) rather than relying on Twitch's private session JSON, and it self-heals as Dual Format toggles on/off. It only flows while Twitch Dual Format / Enhanced Broadcasting is on in OBS; otherwise the destination card shows "Waiting for Dual Format" and nothing else is affected. Hidden for Twitch, which carries both canvases natively. +- **Twitch VOD audio track (multi-track audio).** Per-destination toggle. We write `EnableCustomServerVodTrack` to OBS 32's `user.ini` (falling back to `global.ini` on older installs) so the OBS gate unlocks, then forward both Enhanced-RTMP single-track (live, TrackId 0) and OneTrack multi-track (VOD, TrackId 1) audio tags bit-faithfully to Twitch. Wire-format reader matches OBS's `flv_packet_audio_ex` byte-for-byte (`AudioPacketType` in byte 0, `TrackId` at byte 6). Live + VOD audio works alongside delay cuts; combine with EB via the one-click **"Set up VOD + EB"** (writes the unlock flag, launches `obs64.exe --config-url ` because OBS's `rtmp_custom` plugin discards file-injected URLs at load time, then re-verifies with a red-to-green checklist), or generate a **desktop shortcut** that cold-starts the whole flow via `instantclone.exe --launch-eb` (also on the tray). - **One-click OBS service registration.** The wizard's primary onboarding path adds an "InstantClone" entry to OBS's Service dropdown (writes `services.json` with a `.bak` first; refreshes on port change; surfaces "close OBS first" when the file is locked). - Multi-destination egress with per-destination reconnect + bitrate stats. - **Capacity-aware buffer UI**: live "X MB → max Ys delay at N Mbps" hint, refuses to arm a delay larger than the buffer can hold with an explicit "needs ≥ N MB" reason. diff --git a/src/config.rs b/src/config.rs index 68cccde..1fb2230 100644 --- a/src/config.rs +++ b/src/config.rs @@ -133,6 +133,20 @@ pub struct Destination { /// and per-profile; a future OBS update could change the on-disk /// shape and break the injection. We write a `.bak` on every edit. pub vod_audio_inject_eb: bool, + /// Which canvas to forward to this destination. Only meaningful for + /// non-Twitch platforms (Twitch gets the native dual-canvas + /// passthrough). One of: + /// "horizontal" (default) - the primary 16:9 canvas (TrackId 0) + /// "vertical" - the 9:16 canvas Twitch Dual Format / + /// Enhanced Broadcasting produces, reused + /// for YouTube Shorts / Kick mobile / etc. + /// + /// Vertical only has data on the wire while Twitch Dual Format (EB) + /// is active in OBS. With EB off there is no vertical canvas, so a + /// vertical destination waits (sends no video) until one appears - + /// see `h264::detect_vertical_primary_track`. Unknown / empty values + /// fall back to "horizontal". + pub stream_format: String, } impl Destination { @@ -197,6 +211,15 @@ impl Destination { None => false, } } + + /// True when this destination should receive the vertical (9:16) + /// canvas instead of the horizontal primary. Twitch destinations + /// always get native dual-canvas passthrough, so the vertical flag + /// is meaningless there and ignored - this guard is the single + /// source of truth the egress supervisor reads. + pub fn wants_vertical(&self) -> bool { + self.platform != "twitch" && self.stream_format == "vertical" + } } impl Settings { @@ -303,6 +326,7 @@ impl Settings { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }); } // Clamp / sanitize on load - hand-edited values can otherwise @@ -487,6 +511,12 @@ impl Settings { if d.vod_audio_inject_eb { writeln!(f, "destination.{}.vod_audio_inject_eb=true", i)?; } + // Only emit when non-default ("vertical") so a downgrade to a + // pre-vertical build still parses cleanly (apply_field drops + // unknown keys), and fresh configs stay lean. + if d.stream_format == "vertical" { + writeln!(f, "destination.{}.stream_format=vertical", i)?; + } } f.sync_all()?; Ok(()) @@ -594,6 +624,7 @@ impl Settings { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }); } let d = &mut self.destinations[idx]; @@ -608,6 +639,16 @@ impl Settings { "youtube_ingest" => d.youtube_ingest = value.into(), "vod_audio" => d.vod_audio = value == "true", "vod_audio_inject_eb" => d.vod_audio_inject_eb = value == "true", + "stream_format" => { + // Only "vertical" is honored; anything else + // (incl. "horizontal", empty, or a typo) + // falls back to the safe horizontal default. + d.stream_format = if value == "vertical" { + "vertical".into() + } else { + "horizontal".into() + }; + } _ => {} } } @@ -699,7 +740,7 @@ impl Settings { } let url = d.egress_url().unwrap_or_default(); dests.push_str(&format!( - r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"stream_key_set":{ks},"egress_url_redacted":{red}}}"#, + r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"stream_format":{sf},"stream_key_set":{ks},"egress_url_redacted":{red}}}"#, id = json_str(&d.id), n = json_str(&d.name), en = d.enabled, @@ -707,6 +748,7 @@ impl Settings { cu = json_str(&d.custom_egress_url), ti = json_str(&d.twitch_ingest), yi = json_str(&d.youtube_ingest), + sf = json_str(&d.stream_format), ks = !d.stream_key.is_empty(), red = json_str(&redact_key(&url)), )); @@ -1095,6 +1137,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }; let url = d.egress_url().unwrap(); assert!(url.starts_with("rtmp://live.twitch.tv/app/")); @@ -1114,6 +1157,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }; assert_eq!( d.egress_url().as_deref(), @@ -1136,6 +1180,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }; assert_eq!( d.egress_url().as_deref(), @@ -1157,6 +1202,7 @@ mod tests { youtube_ingest: "backup".into(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }; assert_eq!( d.egress_url().as_deref(), @@ -1180,6 +1226,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }; assert!(!bad.is_well_formed()); } @@ -1229,6 +1276,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }); s.destinations.push(Destination { id: "yt1".into(), @@ -1241,6 +1289,9 @@ mod tests { youtube_ingest: "backup".into(), vod_audio: false, vod_audio_inject_eb: false, + // Non-default value so the round-trip proves stream_format + // both writes (only when != "horizontal") and reads back. + stream_format: "vertical".into(), }); s.profiles = vec![ DelayProfile { @@ -1274,6 +1325,8 @@ mod tests { assert!(loaded.destinations[0].enabled); assert!(!loaded.destinations[1].enabled); assert_eq!(loaded.destinations[1].youtube_ingest, "backup"); + assert_eq!(loaded.destinations[0].stream_format, "horizontal"); + assert_eq!(loaded.destinations[1].stream_format, "vertical"); assert_eq!(loaded.profiles.len(), 2); assert_eq!(loaded.profiles[0].delay_ms, 10_000); assert_eq!(loaded.profiles[1].name, "Long"); @@ -1314,6 +1367,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }); s.save(&path).expect("save"); @@ -1365,6 +1419,7 @@ mod tests { youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }); s.save(&path).expect("save"); diff --git a/src/controller.rs b/src/controller.rs index dd75a63..2e04adb 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -156,6 +156,20 @@ pub struct DestinationState { /// Enhanced-RTMP multi-track framing - Twitch's regular ingest /// reads the metadata but the audio renders silent. pub pass_through_multitrack_audio: AtomicBool, + /// True when this destination wants the vertical (9:16) canvas + /// instead of the horizontal primary. Set by the supervisor from the + /// destination's `stream_format == "vertical"` (non-Twitch only; + /// Twitch always gets native dual-canvas passthrough). When true the + /// pump forwards only `vertical_primary_track`, flattened. + pub egress_vertical: AtomicBool, + /// The OneTrack TrackId of the vertical-canvas primary, discovered by + /// `h264::detect_vertical_primary_track` from the per-track seq-header + /// cache and refreshed whenever that cache changes. `0xFF` means + /// "not resolved yet" (Twitch Dual Format isn't active, or no portrait + /// track has been seen): a vertical destination then sends no video + /// and surfaces a "waiting for Dual Format" status, while every other + /// destination is unaffected. + pub vertical_primary_track: AtomicU8, /// Twitch only: when our /obs/multitrack-config proxy successfully /// allocates an Enhanced Broadcasting session, Twitch's API returns /// a specific IVS ingest URL like @@ -241,11 +255,35 @@ impl DestinationState { // (the destination_state lazy-init in particular). pass_through_multitrack_video: AtomicBool::new(false), pass_through_multitrack_audio: AtomicBool::new(false), + egress_vertical: AtomicBool::new(false), + // 0xFF = unresolved until a portrait track is detected. + vertical_primary_track: AtomicU8::new(0xFF), vod_fetch_pending: AtomicBool::new(false), session_epoch: AtomicU64::new(0), } } + /// The video egress policy for this destination right now. Read by + /// both the live send path and the seq-header replay so they always + /// agree on which canvas to forward. Returns `None` when a vertical + /// destination has no resolved canvas yet (Twitch Dual Format isn't + /// active): the caller drops all video and the dest waits, leaving + /// every other destination untouched. + pub fn video_egress(&self) -> Option { + use crate::h264::VideoEgress; + if self.pass_through_multitrack_video.load(Ordering::Relaxed) { + return Some(VideoEgress::Passthrough); + } + if self.egress_vertical.load(Ordering::Relaxed) { + let track = self.vertical_primary_track.load(Ordering::Relaxed); + if track == 0xFF { + return None; + } + return Some(VideoEgress::Track(track)); + } + Some(VideoEgress::Track(0)) + } + /// Try to claim the right to fetch this destination's VOD-audio IVS /// session. Returns `true` for at most one caller per in-flight fetch. /// @@ -1680,11 +1718,14 @@ async fn pace_and_send( // simulcast: OneTrack TrackId != 0 tags are dropped to // avoid the multi-frame-per-PTS storm that crashes // YouTube's decoder. See `select_video_bytes` for the - // full rationale. Single-track tags borrow `io_buf`. - let Some(selected) = crate::h264::select_video_bytes( - io_buf, - dest.pass_through_multitrack_video.load(Ordering::Relaxed), - ) else { + // full rationale. Single-track tags borrow `io_buf`. A + // vertical destination with no resolved canvas yet + // (`video_egress` returns None) drops all video and waits. + let dropped = match dest.video_egress() { + Some(egress) => crate::h264::select_video_bytes(io_buf, egress), + None => None, + }; + let Some(selected) = dropped else { // Multi-track ladder tag deliberately dropped; advance // the consumer cursor so we don't replay it next call // but skip every per-tag side-effect (send, byte @@ -1948,30 +1989,38 @@ async fn send_sequence_headers( ); sink.send_video(ts, h).await?; } - } else { - // Non-Twitch destinations get the single-track-flattened - // form of the primary track (TrackId 0) - same behaviour - // beta.6 had via the single-Option cache. Falls back to the - // first entry the BTreeMap iterates if track 0 is missing - // (defensive - every real stream we've seen has a track 0). - if let Some(h) = v_headers - .iter() - .find(|(k, _)| *k == 0) - .or_else(|| v_headers.first()) - .map(|(_, v)| v) - { - // We pre-selected TrackId 0 (or the only entry as a - // defensive fallback), so select_video_bytes will always - // return Some for seq headers. The unwrap_or_else is just - // belt-and-suspenders for a pathological ring state. - let selected = crate::h264::select_video_bytes(h, false) - .unwrap_or(std::borrow::Cow::Borrowed(h.as_slice())); + } else if let Some(crate::h264::VideoEgress::Track(target)) = dest.video_egress() { + // Non-Twitch destinations get the single-track-flattened form of + // the canvas this destination wants: TrackId 0 for horizontal + // (the default), or the vertical-canvas primary for a vertical + // destination. Horizontal falls back to the only cached entry if + // track 0 is missing (defensive - every real stream has a + // track 0). Vertical requires an exact match: we must never + // replay a landscape header to a vertical destination. + // + // A vertical destination whose canvas isn't resolved yet has + // `video_egress() == None`, so this branch is skipped entirely + // and no stale header is sent - the header arrives once Twitch + // Dual Format is live. Audio replay below still runs. + let pick = if target == 0 { + v_headers + .iter() + .find(|(k, _)| *k == 0) + .or_else(|| v_headers.first()) + } else { + v_headers.iter().find(|(k, _)| *k == target) + }; + if let Some((_, h)) = pick { + let selected = + crate::h264::select_video_bytes(h, crate::h264::VideoEgress::Track(target)) + .unwrap_or(std::borrow::Cow::Borrowed(h.as_slice())); let bytes_out: &[u8] = &selected; crate::trace::log( "VIDEO_SEQ_HDR_SENT", &format!( - "ts=0x{:08x} flattened bytes={} hex={}", + "ts=0x{:08x} track={} flattened bytes={} hex={}", ts, + target, bytes_out.len(), crate::trace::hex_prefix(bytes_out, 64) ), diff --git a/src/h264.rs b/src/h264.rs index 4deadf7..d4ed9a5 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -387,30 +387,339 @@ pub fn is_primary_video_idr(payload: &[u8]) -> bool { payload[6] == 0 } +/// Per-destination video egress policy, computed by the egress pump from +/// the destination's platform + format settings: +/// +/// - `Passthrough`: Twitch destinations with an EB session. Multi-track +/// tags go through bit-faithfully (the simulcast ladder + every canvas). +/// - `Track(n)`: every other case. Forward only OneTrack `TrackId == n`, +/// flattened to a standard single-track tag. `Track(0)` is the +/// horizontal primary (the default for all non-Twitch destinations); +/// `Track(n)` with n>0 is the vertical-canvas primary chosen by +/// `detect_vertical_primary_track`. Single-track / legacy tags pass +/// through unchanged in either case. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoEgress { + Passthrough, + Track(u8), +} + +/// Locate the AVCDecoderConfigurationRecord inside a *sequence-header* +/// video tag payload, across the framings OBS emits. Returns `None` for +/// anything that isn't an AVC seq header we can read (legacy non-AVC, +/// HEVC/AV1 Enhanced configs, non-seq-header tags, ManyTracks bundles, +/// or truncated input). Best-effort and fully bounds-checked - never +/// panics on hostile / partial bytes. +fn avc_config_record(payload: &[u8]) -> Option<&[u8]> { + if payload.is_empty() { + return None; + } + let b0 = payload[0]; + // Legacy AVC: byte0 low nibble = CodecID (7 = AVC), byte1 = AVCPacketType + // (0 = seq header), bytes 2..5 = composition time, then the config record. + if b0 & 0x80 == 0 { + if b0 & 0x0F != 7 || payload.get(1) != Some(&0) || payload.len() < 5 { + return None; + } + return payload.get(5..); + } + let packet_type = b0 & 0x0F; + if packet_type == 6 { + // Multitrack. Only the OneTrack layout (mt_type 0) with a nested + // SequenceStart (nested_pt 0) carries a per-track config record at + // a known offset (FourCC[4] + TrackId[1] then the record). + let mt = *payload.get(1)?; + if (mt >> 4) & 0x0F != 0 || mt & 0x0F != 0 { + return None; + } + if payload.len() < 7 || payload.get(2..6) != Some(&FOURCC_AVC1) { + return None; + } + return payload.get(7..); + } + // Enhanced single-track. Only SequenceStart (packet_type 0) carries the + // config record; bytes 1..5 are the FourCC, then the record. + if packet_type != 0 || payload.len() < 5 || payload.get(1..5) != Some(&FOURCC_AVC1) { + return None; + } + payload.get(5..) +} + +/// Pull the first SPS NAL (RBSP, NAL header byte included) out of an +/// AVCDecoderConfigurationRecord. Bounds-checked; `None` on short input. +fn first_sps_nal(cfg: &[u8]) -> Option<&[u8]> { + // cfg: [version, profile, compat, level, lengthSizeMinusOne, + // numOfSPS (low 5 bits), spsLength(2 BE), sps...] + let num_sps = cfg.get(5)? & 0x1F; + if num_sps == 0 { + return None; + } + let len = u16::from_be_bytes([*cfg.get(6)?, *cfg.get(7)?]) as usize; + cfg.get(8..8 + len) +} + +/// Decode (width, height) in pixels from an AVC sequence header tag. +/// Returns `None` for non-AVC, malformed, or truncated input. Used only +/// to classify orientation (portrait vs landscape) for vertical-canvas +/// selection - it parses just enough of the SPS to reach the picture +/// dimensions and applies frame cropping. Never panics. +pub fn sps_dimensions(seq_header: &[u8]) -> Option<(u32, u32)> { + let cfg = avc_config_record(seq_header)?; + let nal = first_sps_nal(cfg)?; + // Drop the 1-byte NAL header, then strip emulation-prevention bytes + // (0x00 00 03 -> 0x00 00) before Exp-Golomb parsing. + let rbsp = strip_emulation_prevention(nal.get(1..)?); + let mut r = BitReader::new(&rbsp); + + let profile_idc = r.read_bits(8)?; + let _constraints_and_reserved = r.read_bits(8)?; + let _level_idc = r.read_bits(8)?; + let _sps_id = r.read_ue()?; + + let mut chroma_format_idc = 1u32; // 4:2:0 default for older profiles + if matches!( + profile_idc, + 100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135 + ) { + chroma_format_idc = r.read_ue()?; + if chroma_format_idc == 3 { + let _separate_colour_plane = r.read_bit()?; + } + let _bit_depth_luma = r.read_ue()?; + let _bit_depth_chroma = r.read_ue()?; + let _qpprime = r.read_bit()?; + if r.read_bit()? == 1 { + // seq_scaling_matrix_present_flag + let count = if chroma_format_idc != 3 { 8 } else { 12 }; + for i in 0..count { + if r.read_bit()? == 1 { + let size = if i < 6 { 16 } else { 64 }; + r.skip_scaling_list(size)?; + } + } + } + } + + let _log2_max_frame_num = r.read_ue()?; + let pic_order_cnt_type = r.read_ue()?; + if pic_order_cnt_type == 0 { + let _log2_max_poc_lsb = r.read_ue()?; + } else if pic_order_cnt_type == 1 { + let _delta_pic_order_always_zero = r.read_bit()?; + let _offset_for_non_ref_pic = r.read_se()?; + let _offset_for_top_to_bottom = r.read_se()?; + let cycle = r.read_ue()?; + for _ in 0..cycle { + let _offset = r.read_se()?; + } + } + let _max_num_ref_frames = r.read_ue()?; + let _gaps_allowed = r.read_bit()?; + + let pic_width_in_mbs = r.read_ue()?.checked_add(1)?; + let pic_height_in_map_units = r.read_ue()?.checked_add(1)?; + let frame_mbs_only = r.read_bit()?; + if frame_mbs_only == 0 { + let _mb_adaptive = r.read_bit()?; + } + let _direct_8x8 = r.read_bit()?; + + let mut width = pic_width_in_mbs.checked_mul(16)?; + let mut height = (2 - frame_mbs_only) + .checked_mul(pic_height_in_map_units)? + .checked_mul(16)?; + + if r.read_bit()? == 1 { + // frame_cropping_flag: subtract the cropped border, scaled by the + // chroma subsampling so the result is the real coded resolution. + let crop_left = r.read_ue()?; + let crop_right = r.read_ue()?; + let crop_top = r.read_ue()?; + let crop_bottom = r.read_ue()?; + let (sub_w, sub_h): (u32, u32) = match chroma_format_idc { + 0 => (1, 1), // monochrome + 1 => (2, 2), // 4:2:0 + 2 => (2, 1), // 4:2:2 + _ => (1, 1), // 4:4:4 + }; + let crop_unit_x = sub_w; + let crop_unit_y = sub_h * (2 - frame_mbs_only); + width = width.saturating_sub(crop_unit_x.checked_mul(crop_left + crop_right)?); + height = height.saturating_sub(crop_unit_y.checked_mul(crop_top + crop_bottom)?); + } + if width == 0 || height == 0 { + return None; + } + Some((width, height)) +} + +/// Pick the vertical-canvas primary track from the per-TrackId seq-header +/// cache. The vertical primary is the portrait track (`height > width`) +/// with the largest pixel area - if Twitch Dual Format emits a vertical +/// ladder, that is its top rung. Returns `None` when no track is portrait +/// (EB off, no vertical canvas, or only non-AVC/unparseable headers), in +/// which case a vertical destination has nothing to send and waits. +pub fn detect_vertical_primary_track( + seq_headers: &std::collections::BTreeMap>, +) -> Option { + let mut best: Option<(u8, u32)> = None; + for (&track_id, header) in seq_headers { + let Some((w, h)) = sps_dimensions(header) else { + continue; + }; + if h <= w { + continue; // landscape or square - not the vertical canvas + } + let area = w.saturating_mul(h); + let better = match best { + None => true, + Some((_, best_area)) => area > best_area, + }; + if better { + best = Some((track_id, area)); + } + } + best.map(|(track_id, _)| track_id) +} + +/// Remove H.264 emulation-prevention bytes (0x00 00 03 -> 0x00 00) so the +/// SPS RBSP can be Exp-Golomb decoded directly. +fn strip_emulation_prevention(nal: &[u8]) -> Vec { + let mut out = Vec::with_capacity(nal.len()); + let mut zeros = 0u32; + for &b in nal { + if zeros >= 2 && b == 0x03 { + zeros = 0; + continue; // drop the emulation_prevention_three_byte + } + out.push(b); + if b == 0 { + zeros += 1; + } else { + zeros = 0; + } + } + out +} + +/// Minimal MSB-first bit reader for SPS parsing. Every read is bounds +/// checked and returns `None` past the end, so a truncated SPS aborts the +/// parse cleanly instead of panicking. +struct BitReader<'a> { + data: &'a [u8], + bit_pos: usize, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, bit_pos: 0 } + } + + fn read_bit(&mut self) -> Option { + let byte = self.data.get(self.bit_pos / 8)?; + let shift = 7 - (self.bit_pos % 8); + self.bit_pos += 1; + Some(((byte >> shift) & 1) as u32) + } + + fn read_bits(&mut self, n: u32) -> Option { + // Cap at 32 so a corrupt length can't loop unboundedly. + if n > 32 { + return None; + } + let mut v = 0u32; + for _ in 0..n { + v = (v << 1) | self.read_bit()?; + } + Some(v) + } + + /// Unsigned Exp-Golomb. The leading-zero count is capped at 31 so + /// malformed input can't spin forever. + fn read_ue(&mut self) -> Option { + let mut leading_zeros = 0u32; + while self.read_bit()? == 0 { + leading_zeros += 1; + if leading_zeros > 31 { + return None; + } + } + if leading_zeros == 0 { + return Some(0); + } + let rest = self.read_bits(leading_zeros)?; + Some((1u32 << leading_zeros) - 1 + rest) + } + + /// Signed Exp-Golomb. + fn read_se(&mut self) -> Option { + let k = self.read_ue()?; + // Map 0,1,2,3,... -> 0,+1,-1,+2,... + let val = k.div_ceil(2) as i32; + Some(if k % 2 == 0 { -val } else { val }) + } + + /// Skip a scaling list per H.264 7.3.2.1.1.1 without storing it. + fn skip_scaling_list(&mut self, size: u32) -> Option<()> { + let mut last_scale = 8i32; + let mut next_scale = 8i32; + for _ in 0..size { + if next_scale != 0 { + let delta = self.read_se()?; + next_scale = (last_scale + delta + 256) % 256; + } + last_scale = if next_scale == 0 { + last_scale + } else { + next_scale + }; + } + Some(()) + } +} + pub fn select_video_bytes<'a>( payload: &'a [u8], - pass_through_multitrack: bool, + egress: VideoEgress, ) -> Option> { use std::borrow::Cow; - if pass_through_multitrack { - return Some(Cow::Borrowed(payload)); - } + let target = match egress { + VideoEgress::Passthrough => return Some(Cow::Borrowed(payload)), + VideoEgress::Track(t) => t, + }; let info = classify_video_tag(payload); if !info.is_multitrack { - return Some(Cow::Borrowed(payload)); + // Single-track / legacy tag. For the horizontal primary + // (target 0) this IS the stream, so pass it through. For a + // vertical destination (target > 0) a single-track tag is the + // horizontal primary OBS also emits for legacy ingests - the + // vertical canvas must never carry it, so drop it. The vertical + // canvas always arrives as OneTrack multi-track tags handled + // below. + if target == 0 { + return Some(Cow::Borrowed(payload)); + } + return None; } // For OneTrack layout, OBS sends one tag per track per frame. // Forwarding every track to a non-Twitch destination would deliver // N frames per PTS - decoders read that as a multi-frame storm and // either drop the connection (YouTube did, ~12 s per ladder rung) - // or render heavy artefacts. The primary stream arrives separately - // as a legacy AVC / Enhanced single-track tag, so dropping - // TrackId != 0 here is lossless from the destination's POV. + // or render heavy artefacts. We keep only the target track (0 for + // horizontal primary, the vertical-canvas primary otherwise) and + // drop the rest, which is lossless from the destination's POV. if payload.len() >= 7 && (payload[1] >> 4) & 0x0F == 0 { let track_id = payload[6]; - if track_id != 0 { + if track_id != target { return None; } + } else if target != 0 { + // ManyTracks / ManyTracksManyCodecs pack every track into one tag; + // a non-zero target can't be isolated from the bundle, so drop it + // rather than risk sending the wrong canvas. Twitch Dual Format + // uses the OneTrack layout handled above, so this only affects + // exotic encoders, and only for vertical destinations. + return None; } match flatten_multitrack_video(payload) { Some(flat) => Some(Cow::Owned(flat)), @@ -898,8 +1207,10 @@ mod tests { // pass_through flag is meaningless when there's nothing to // flatten - and the borrow path means no copy happens either. let tag = avc_single_track_keyframe_bytes(); - let twitch = select_video_bytes(&tag, true).expect("single-track always forwards"); - let youtube = select_video_bytes(&tag, false).expect("single-track always forwards"); + let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) + .expect("single-track always forwards"); + let youtube = + select_video_bytes(&tag, VideoEgress::Track(0)).expect("single-track always forwards"); assert_eq!(twitch.as_ref(), tag.as_slice()); assert_eq!(youtube.as_ref(), tag.as_slice()); assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); @@ -913,7 +1224,7 @@ mod tests { // True for every TrackId, not just the primary. for track in 0u8..=4 { let tag = enhanced_rtmp_onetrack_video_bytes_track(track); - let twitch = select_video_bytes(&tag, true) + let twitch = select_video_bytes(&tag, VideoEgress::Passthrough) .unwrap_or_else(|| panic!("twitch must forward track {track}")); assert_eq!(twitch.as_ref(), tag.as_slice()); assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); @@ -929,7 +1240,8 @@ mod tests { // as legacy, so this path rarely fires - but we want a future // encoder change not to silently blank YouTube. let tag = enhanced_rtmp_onetrack_video_bytes_track(0); - let youtube = select_video_bytes(&tag, false).expect("track 0 must be forwarded"); + let youtube = + select_video_bytes(&tag, VideoEgress::Track(0)).expect("track 0 must be forwarded"); let direct_flat = flatten_multitrack_video(&tag).expect("OneTrack layout must flatten"); assert_eq!(youtube.as_ref(), direct_flat.as_slice()); assert!(matches!(youtube, std::borrow::Cow::Owned(_))); @@ -946,7 +1258,7 @@ mod tests { for track in 1u8..=4 { let tag = enhanced_rtmp_onetrack_video_bytes_track(track); assert!( - select_video_bytes(&tag, false).is_none(), + select_video_bytes(&tag, VideoEgress::Track(0)).is_none(), "TrackId {track} must be dropped for non-Twitch", ); } @@ -969,7 +1281,8 @@ mod tests { let horizontal = enhanced_rtmp_onetrack_video_bytes_track(0); let vertical = enhanced_rtmp_onetrack_video_bytes_track(1); for tag in [&horizontal, &vertical] { - let twitch = select_video_bytes(tag, true).expect("twitch must forward both canvases"); + let twitch = select_video_bytes(tag, VideoEgress::Passthrough) + .expect("twitch must forward both canvases"); assert_eq!(twitch.as_ref(), tag.as_slice()); assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); } @@ -984,15 +1297,189 @@ mod tests { let horizontal = enhanced_rtmp_onetrack_video_bytes_track(0); let vertical = enhanced_rtmp_onetrack_video_bytes_track(1); assert!( - select_video_bytes(&horizontal, false).is_some(), + select_video_bytes(&horizontal, VideoEgress::Track(0)).is_some(), "horizontal canvas (TrackId 0) must reach non-Twitch" ); assert!( - select_video_bytes(&vertical, false).is_none(), + select_video_bytes(&vertical, VideoEgress::Track(0)).is_none(), "vertical canvas (TrackId 1) must be dropped for non-Twitch" ); } + // ── Vertical-canvas selection: SPS orientation + Track(n) routing ── + // + // A vertical destination forwards ONLY the portrait canvas's track, + // flattened to single-track, and drops the horizontal primary (both + // its legacy single-track tags and its OneTrack ladder rungs). + + /// Minimal MSB-first bit writer for building synthetic SPS test + /// vectors. Mirrors `BitReader` so the parse round-trips. + struct BitWriter { + bytes: Vec, + cur: u8, + nbits: u8, + } + impl BitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + cur: 0, + nbits: 0, + } + } + fn put_bit(&mut self, b: u32) { + self.cur = (self.cur << 1) | (b as u8 & 1); + self.nbits += 1; + if self.nbits == 8 { + self.bytes.push(self.cur); + self.cur = 0; + self.nbits = 0; + } + } + fn put_bits(&mut self, v: u32, n: u32) { + for i in (0..n).rev() { + self.put_bit((v >> i) & 1); + } + } + fn put_ue(&mut self, v: u32) { + let code = v + 1; + let bits = 32 - code.leading_zeros(); + for _ in 0..(bits - 1) { + self.put_bit(0); + } + self.put_bits(code, bits); + } + fn finish(mut self) -> Vec { + self.put_bit(1); // rbsp_stop_one_bit + while self.nbits != 0 { + self.put_bit(0); + } + self.bytes + } + } + + /// Baseline-profile SPS RBSP (no high-profile block, frame_mbs_only, + /// no cropping) sized to `w_mb` x `h_mb` macroblocks. + fn build_sps_rbsp(w_mb: u32, h_mb: u32) -> Vec { + let mut w = BitWriter::new(); + w.put_bits(66, 8); // profile_idc = baseline + w.put_bits(0, 8); // constraint flags + reserved + w.put_bits(30, 8); // level_idc + w.put_ue(0); // seq_parameter_set_id + w.put_ue(0); // log2_max_frame_num_minus4 + w.put_ue(0); // pic_order_cnt_type + w.put_ue(0); // log2_max_pic_order_cnt_lsb_minus4 + w.put_ue(0); // max_num_ref_frames + w.put_bit(0); // gaps_in_frame_num_value_allowed_flag + w.put_ue(w_mb - 1); // pic_width_in_mbs_minus1 + w.put_ue(h_mb - 1); // pic_height_in_map_units_minus1 + w.put_bit(1); // frame_mbs_only_flag + w.put_bit(0); // direct_8x8_inference_flag + w.put_bit(0); // frame_cropping_flag + w.put_bit(0); // vui_parameters_present_flag + w.finish() + } + + fn build_avc_config(w_mb: u32, h_mb: u32) -> Vec { + let mut sps = vec![0x67u8]; // NAL header: type 7 (SPS) + sps.extend(build_sps_rbsp(w_mb, h_mb)); + let mut cfg = vec![1u8, 66, 0, 30, 0xFF, 0xE1]; // version..numOfSPS(1) + cfg.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + cfg.extend_from_slice(&sps); + cfg.push(0); // numOfPictureParameterSets = 0 + cfg + } + + /// Legacy AVC sequence-header tag carrying the given dimensions. + fn legacy_avc_seq_header(w_mb: u32, h_mb: u32) -> Vec { + let mut t = vec![0x17u8, 0x00, 0, 0, 0]; // AVC seq header, CT=0 + t.extend(build_avc_config(w_mb, h_mb)); + t + } + + /// OneTrack Enhanced-RTMP sequence-header tag (avc1) for `track_id`. + fn onetrack_avc_seq_header(track_id: u8, w_mb: u32, h_mb: u32) -> Vec { + let mut t = vec![0x96u8, 0x00]; // IsEx|FrameType1|Multitrack, OneTrack|SeqStart + t.extend_from_slice(b"avc1"); + t.push(track_id); + t.extend(build_avc_config(w_mb, h_mb)); + t + } + + #[test] + fn sps_dimensions_decodes_landscape_and_portrait() { + // 40x30 MB = 640x480 landscape; 30x40 MB = 480x640 portrait. + assert_eq!( + sps_dimensions(&legacy_avc_seq_header(40, 30)), + Some((640, 480)) + ); + assert_eq!( + sps_dimensions(&legacy_avc_seq_header(30, 40)), + Some((480, 640)) + ); + // Same dims survive the OneTrack Enhanced-RTMP framing. + assert_eq!( + sps_dimensions(&onetrack_avc_seq_header(2, 30, 40)), + Some((480, 640)) + ); + } + + #[test] + fn sps_dimensions_returns_none_on_unparseable_input() { + // Empty, truncated, and non-AVC inputs must all fail cleanly with + // no panic (this runs on hostile wire bytes). + assert_eq!(sps_dimensions(&[]), None); + assert_eq!(sps_dimensions(&[0x17, 0x00]), None); + let mut truncated = legacy_avc_seq_header(40, 30); + truncated.truncate(9); // cut into the SPS + assert_eq!(sps_dimensions(&truncated), None); + // HEVC single-track seq header (hvc1) is not AVC - unparseable. + let mut hevc = vec![0x90u8]; + hevc.extend_from_slice(b"hvc1"); + hevc.extend_from_slice(&[0u8; 16]); + assert_eq!(sps_dimensions(&hevc), None); + } + + #[test] + fn detect_vertical_primary_track_picks_largest_portrait() { + let mut headers = std::collections::BTreeMap::new(); + headers.insert(0u8, legacy_avc_seq_header(120, 68)); // 1920x1088 landscape + headers.insert(3u8, onetrack_avc_seq_header(3, 34, 60)); // 544x960 portrait + headers.insert(4u8, onetrack_avc_seq_header(4, 68, 120)); // 1088x1920 portrait (bigger) + assert_eq!(detect_vertical_primary_track(&headers), Some(4)); + } + + #[test] + fn detect_vertical_primary_track_none_when_all_landscape() { + let mut headers = std::collections::BTreeMap::new(); + headers.insert(0u8, legacy_avc_seq_header(120, 68)); + headers.insert(1u8, onetrack_avc_seq_header(1, 80, 45)); + assert_eq!(detect_vertical_primary_track(&headers), None); + } + + #[test] + fn select_video_bytes_vertical_forwards_only_target_track() { + // Track(1) keeps OneTrack TrackId 1 (flattened) and drops every + // other track plus the legacy horizontal primary. + let track1 = enhanced_rtmp_onetrack_video_bytes_track(1); + let track2 = enhanced_rtmp_onetrack_video_bytes_track(2); + let legacy_horizontal = avc_single_track_keyframe_bytes(); + + let kept = select_video_bytes(&track1, VideoEgress::Track(1)) + .expect("vertical target track must forward"); + let flat = flatten_multitrack_video(&track1).expect("OneTrack flattens"); + assert_eq!(kept.as_ref(), flat.as_slice()); + + assert!( + select_video_bytes(&track2, VideoEgress::Track(1)).is_none(), + "non-target OneTrack rung must be dropped for vertical" + ); + assert!( + select_video_bytes(&legacy_horizontal, VideoEgress::Track(1)).is_none(), + "legacy horizontal primary must be dropped for a vertical destination" + ); + } + // ── is_primary_video_idr: cut-target gate for EB ladder cuts ── // // Regression test set for the v0.1.3 EB pixel-glitch fix. Before @@ -1089,8 +1576,8 @@ mod tests { // error properly. Truncated below the TrackId byte means we // can't tell which track it is; treat that as "forward". let truncated = vec![0x80 | (1u8 << 4) | 6, 0]; // 2 bytes total - let youtube = - select_video_bytes(&truncated, false).expect("truncated tags must still forward"); + let youtube = select_video_bytes(&truncated, VideoEgress::Track(0)) + .expect("truncated tags must still forward"); assert_eq!(youtube.as_ref(), truncated.as_slice()); assert!(matches!(youtube, std::borrow::Cow::Borrowed(_))); } diff --git a/src/main.rs b/src/main.rs index e59cbdc..515f86b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,6 +82,14 @@ fn main() -> std::io::Result<()> { let suppress_browser = raw_args.iter().any(|a| a == "--no-browser") || std::env::var("INSTANTCLONE_NO_BROWSER").ok().as_deref() == Some("1"); + // `--launch-eb` cold-starts straight into VOD-audio + Enhanced + // Broadcasting mode: once the web server is up we write OBS's + // VOD-track unlock flag and launch OBS with the `--config-url` flag. + // This is what the "Create desktop shortcut" button targets, so a + // double-click brings up the whole VOD+EB setup with no dashboard + // clicks. Idempotent and safe to pass on every launch. + let launch_eb = raw_args.iter().any(|a| a == "--launch-eb"); + // Sweep any `.old` / `.new` exe left over from a self-update swap. When // we got here via the relauncher, this deletes the previous version we // just replaced; otherwise it's a harmless no-op. @@ -233,6 +241,35 @@ fn main() -> std::io::Result<()> { }); } + // `--launch-eb` (the desktop-shortcut entry point): once the web + // server has had a moment to bind, write OBS's VOD-track flag and + // launch OBS pointed at our multitrack-config endpoint. Runs on a + // blocking thread so the file write + process spawn don't stall + // the async runtime, and degrades quietly (a missing OBS just logs + // a line) so a stale shortcut never crashes the app. + if launch_eb { + let web_port = settings.web_port; + let ctrl_eb = ctrl.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(600)).await; + let _ = + tokio::task::spawn_blocking(move || { + match obs_register::set_vod_audio_flag(true) { + Ok(true) => ctrl_eb.log("[--launch-eb] VOD-track flag written"), + Ok(false) => ctrl_eb + .log("[--launch-eb] OBS config not found - is OBS installed?"), + Err(e) => ctrl_eb.log(format!("[--launch-eb] flag write failed: {e}")), + } + match obs_register::launch_obs_with_eb_config(web_port) { + Ok(exe) => ctrl_eb + .log(format!("[--launch-eb] launched OBS ({})", exe.display())), + Err(e) => ctrl_eb.log(format!("[--launch-eb] OBS launch failed: {e}")), + } + }) + .await; + }); + } + // System-tray icon (Windows): hidden message-only window in its // own OS thread, signals shutdown back here via a oneshot. The // tray gives us the exit affordance that the dropped console @@ -383,7 +420,32 @@ async fn supervise_egress(mut rx: watch::Receiver, ctrl: Arc io::Result { Ok(exe) } +// ── Desktop shortcut: one-click "Start InstantClone + OBS (VOD + EB)" ── +// +// Generates a clickable launcher on the Desktop that cold-starts the app +// with `--launch-eb`, so a streamer who always wants VOD audio + Enhanced +// Broadcasting gets the whole setup from a single double-click. We prefer +// a real Windows .lnk (so it carries the app icon), created via +// PowerShell's WScript.Shell COM to avoid pulling in a COM crate. If that +// path is unavailable (PowerShell locked down, non-Windows), we fall back +// to a .cmd launcher so the user always gets something that works. + +/// Best-effort Desktop folder. `%USERPROFILE%\Desktop` covers the vast +/// majority of Windows installs; OneDrive-redirected desktops are not +/// auto-discovered (the .cmd/.lnk still lands somewhere clickable if the +/// user later points us at it). None when we can't resolve a home dir. +fn desktop_dir() -> Option { + let home = std::env::var("USERPROFILE") + .ok() + .or_else(|| std::env::var("HOME").ok())?; + Some(PathBuf::from(home).join("Desktop")) +} + +/// PowerShell single-quoted-string escaping: a literal `'` becomes `''`. +fn ps_single_quote(s: &str) -> String { + s.replace('\'', "''") +} + +/// Try to create a real Windows `.lnk` shortcut via PowerShell COM. +/// Returns Ok only when PowerShell reports success AND the file exists. +fn try_create_lnk(lnk: &Path, exe: &Path) -> io::Result<()> { + let workdir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_default(); + let script = format!( + "$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{lnk}');\ + $s.TargetPath='{exe}';$s.Arguments='--launch-eb';\ + $s.IconLocation='{exe}';$s.WorkingDirectory='{wd}';$s.Save()", + lnk = ps_single_quote(&lnk.display().to_string()), + exe = ps_single_quote(&exe.display().to_string()), + wd = ps_single_quote(&workdir.display().to_string()), + ); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .status()?; + if status.success() && lnk.exists() { + Ok(()) + } else { + Err(io::Error::other("PowerShell did not create the shortcut")) + } +} + +/// Create a Desktop shortcut that launches InstantClone in VOD+EB mode +/// (`instantclone.exe --launch-eb`). Returns the path of the file actually +/// created (a `.lnk` when possible, otherwise a `.cmd` fallback) so the UI +/// can tell the user exactly what to look for. +pub fn create_eb_shortcut() -> io::Result { + let exe = std::env::current_exe()?; + let desktop = desktop_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "could not locate your Desktop folder", + ) + })?; + // Make sure the directory exists (it always should, but a redirected + // profile mid-setup could briefly not have it). + if !desktop.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Desktop folder not found at {}", desktop.display()), + )); + } + let lnk = desktop.join("InstantClone (VOD + EB).lnk"); + if try_create_lnk(&lnk, &exe).is_ok() { + return Ok(lnk); + } + // Fallback: a plain .cmd launcher. `start "" ""` detaches so the + // console window closes immediately after spawning the app. + let cmd = desktop.join("InstantClone (VOD + EB).cmd"); + let body = format!( + "@echo off\r\nstart \"\" \"{}\" --launch-eb\r\n", + exe.display() + ); + write_or_friendly(&cmd, &body)?; + Ok(cmd) +} + /// Remove the multitrack-video-configuration-url key (matching our /// local URL) from the settings object. We match the FULL key+value /// pair plus surrounding whitespace + a trailing comma if present. diff --git a/src/tray.rs b/src/tray.rs index 32864cb..f5fd90a 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -53,6 +53,7 @@ const ID_OPEN_DASH: usize = 0x101; const ID_OPEN_DOCK: usize = 0x102; const ID_CUT_DELAY: usize = 0x103; const ID_COPY_URL: usize = 0x104; +const ID_LAUNCH_EB: usize = 0x105; const ID_QUIT: usize = 0x1FF; /// The icon bytes are generated by build.rs (cyan rounded square + white @@ -256,6 +257,21 @@ unsafe extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) let url = obs_rtmp_url(&state.settings.borrow()); let _ = set_clipboard_text(hwnd, &url); } + ID_LAUNCH_EB => { + // One-click VOD + EB from the tray: write OBS's + // VOD-track flag, then launch OBS with --config-url. + // Both are synchronous and degrade quietly (missing + // OBS just logs) so a click never wedges the tray. + let web_port = state.settings.borrow().web_port; + let _ = crate::obs_register::set_vod_audio_flag(true); + match crate::obs_register::launch_obs_with_eb_config(web_port) { + Ok(exe) => state.ctrl.log(format!( + "[tray] launched OBS for VOD + EB ({})", + exe.display() + )), + Err(e) => state.ctrl.log(format!("[tray] OBS launch failed: {e}")), + } + } ID_QUIT => { // Take the sender so a second click can't double-send. // Ignore the Err: it just means the main task already shut @@ -316,6 +332,10 @@ unsafe fn show_menu(hwnd: HWND) { AppendMenuW(menu, MF_STRING, ID_OPEN_DOCK, dock.as_ptr()); AppendMenuW(menu, MF_STRING, ID_COPY_URL, copy.as_ptr()); + AppendMenuW(menu, MF_SEPARATOR, 0, ptr::null()); + let launch_eb = wide("Launch OBS (VOD + EB)"); + AppendMenuW(menu, MF_STRING, ID_LAUNCH_EB, launch_eb.as_ptr()); + AppendMenuW(menu, MF_SEPARATOR, 0, ptr::null()); let quit = wide("Quit InstantClone"); AppendMenuW(menu, MF_STRING, ID_QUIT, quit.as_ptr()); diff --git a/src/web.rs b/src/web.rs index 6203c62..72249b1 100644 --- a/src/web.rs +++ b/src/web.rs @@ -398,6 +398,87 @@ async fn route( ), } } + ("POST", "/obs/setup-vod-eb") => { + // One-click VOD-audio + Enhanced Broadcasting setup. Runs the + // three steps in order and reports each independently so the + // dashboard can show a red-to-green checklist: a failure in one + // step (e.g. OBS still open, so the flag write is blocked) is + // surfaced with its own message instead of failing the whole + // operation silently. + let web_port = settings.borrow().web_port; + let (flag_ok, flag_msg) = match crate::obs_register::set_vod_audio_flag(true) { + Ok(true) => (true, "VOD-track flag written to OBS config".to_string()), + Ok(false) => ( + false, + "OBS config not found - is OBS installed?".to_string(), + ), + Err(e) => ( + false, + format!("could not write OBS config (close OBS and retry): {e}"), + ), + }; + let (launch_ok, launch_msg) = + match crate::obs_register::launch_obs_with_eb_config(web_port) { + Ok(exe) => { + ctrl.log("[vod-eb setup] launched OBS with --config-url"); + (true, format!("OBS launched ({})", exe.display())) + } + Err(e) => (false, e.to_string()), + }; + let verified = crate::obs_register::vod_audio_flag_set(); + let verify_msg = if verified { + "VOD-track flag confirmed in OBS config" + } else { + "VOD-track flag not present after write - close OBS and try again" + }; + let all_ok = flag_ok && launch_ok && verified; + ( + // Always 200: partial success is still a valid response; + // the per-step `ok` flags carry the detail the UI renders. + "200 OK", + "application/json", + format!( + r#"{{"ok":{ok},"steps":[{{"name":"VOD-track flag","ok":{f},"msg":"{fm}"}},{{"name":"Launch OBS (EB)","ok":{l},"msg":"{lm}"}},{{"name":"Verify flag","ok":{v},"msg":"{vm}"}}]}}"#, + ok = all_ok, + f = flag_ok, + fm = json_escape(&flag_msg), + l = launch_ok, + lm = json_escape(&launch_msg), + v = verified, + vm = json_escape(verify_msg), + ), + ) + } + ("POST", "/shortcut/create-eb") => match crate::obs_register::create_eb_shortcut() { + Ok(path) => { + ctrl.log(format!( + "created VOD+EB desktop shortcut: {}", + path.display() + )); + let kind = if path.extension().and_then(|e| e.to_str()) == Some("lnk") { + "shortcut" + } else { + "launcher (.cmd fallback)" + }; + ( + "200 OK", + "application/json", + format!( + r#"{{"ok":true,"path":"{p}","kind":"{k}"}}"#, + p = json_escape(&path.display().to_string()), + k = kind, + ), + ) + } + Err(e) => ( + "500 Internal Server Error", + "application/json", + format!( + r#"{{"ok":false,"error":"{}"}}"#, + json_escape(&e.to_string()) + ), + ), + }, ("GET", "/update-check") => { // check_update() uses blocking ureq with a ~10 s ceiling. Hand // it to a blocking thread so a slow GitHub doesn't pin the @@ -1429,6 +1510,7 @@ async fn post_config( youtube_ingest: String::new(), vod_audio: false, vod_audio_inject_eb: false, + stream_format: "horizontal".into(), }); } let d = &mut new_settings.destinations[0]; @@ -1875,6 +1957,18 @@ async fn post_destination_upsert( form.get("vod_audio_inject_eb").map(String::as_str), Some("on" | "true" | "1") ); + // Only "vertical" is honored; every other value (incl. an absent + // field, "horizontal", or a Twitch destination where the control is + // hidden) normalizes to the safe horizontal default. Twitch never + // sends vertical - it gets native dual-canvas passthrough - so we + // force horizontal there regardless of what the form carried. + let stream_format = if platform != "twitch" + && form.get("stream_format").map(String::as_str) == Some("vertical") + { + "vertical".to_string() + } else { + "horizontal".to_string() + }; if name.trim().is_empty() { return ( @@ -1897,6 +1991,7 @@ async fn post_destination_upsert( existing.youtube_ingest = youtube_ingest; existing.vod_audio = vod_audio; existing.vod_audio_inject_eb = vod_audio_inject_eb; + existing.stream_format = stream_format; } else { ns.destinations.push(config::Destination { id, @@ -1909,6 +2004,7 @@ async fn post_destination_upsert( youtube_ingest, vod_audio, vod_audio_inject_eb, + stream_format, }); } @@ -2039,8 +2135,23 @@ fn destinations_json(ctrl: &Controller, settings: &Arc>) } let url = d.egress_url().unwrap_or_default(); let (_id, alive, _seq, kbps, tags, bytes, cuts, reconnects) = stats_for(&d.id); + // Vertical destinations report whether their canvas is resolved + // yet (Twitch Dual Format live + a portrait track detected). The + // dashboard turns this into a green "Vertical" badge vs an amber + // "waiting for Dual Format" hint. Non-vertical destinations always + // report ready=true so the badge logic stays simple. + let vertical = d.wants_vertical(); + let vertical_ready = if vertical { + use std::sync::atomic::Ordering; + ctrl.destination_state(&d.id) + .vertical_primary_track + .load(Ordering::Relaxed) + != 0xFF + } else { + true + }; out.push_str(&format!( - r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"vod_audio":{va},"vod_audio_inject_eb":{vie},"stream_key_set":{ks},"url_redacted":{ur},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{ct},"reconnects":{rc}}}"#, + r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"vod_audio":{va},"vod_audio_inject_eb":{vie},"stream_format":{sf},"vertical_ready":{vr},"stream_key_set":{ks},"url_redacted":{ur},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{ct},"reconnects":{rc}}}"#, id = json_escape_quoted(&d.id), n = json_escape_quoted(&d.name), en = d.enabled, @@ -2054,6 +2165,8 @@ fn destinations_json(ctrl: &Controller, settings: &Arc>) yi = json_escape_quoted(&d.youtube_ingest), va = d.vod_audio, vie = d.vod_audio_inject_eb, + sf = json_escape_quoted(&d.stream_format), + vr = vertical_ready, ks = !d.stream_key.is_empty(), ur = json_escape_quoted(&redact_url(&url)), al = alive, diff --git a/web/index.html b/web/index.html index 432674f..7127690 100644 --- a/web/index.html +++ b/web/index.html @@ -473,7 +473,8 @@ font-family:'JetBrains Mono',monospace} .dcard-status{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:500; padding:4px 8px;border-radius:999px;background:var(--surface-3);border:1px solid var(--line); - color:var(--fg-3);flex:0 0 auto} + color:var(--fg-3);flex:0 0 auto; + transition:color .2s ease,background .2s ease,border-color .2s ease} .dcard-status .d-dot{width:5px;height:5px;border-radius:999px;background:var(--fg-4)} .dcard-status.s-ready{color:var(--accent);border-color:color-mix(in oklch,var(--accent) 30%,var(--line)); background:color-mix(in oklch,var(--accent) 8%,var(--surface-3))} @@ -482,6 +483,27 @@ background:color-mix(in oklch,var(--live) 8%,var(--surface-3))} .dcard-status.s-live .d-dot{background:var(--live);box-shadow:0 0 6px var(--live); animation:hpulse 1.6s infinite;--sc:var(--live)} +/* "Waiting for Dual Format": a vertical destination that holds a + connection but deliberately sends no video until OBS produces the 9:16 + canvas. Amber + a slow pulse so it reads as "attention" not "error". */ +.dcard-status.s-wait{color:var(--warn,#d6a23a);border-color:color-mix(in oklch,var(--warn,#d6a23a) 32%,var(--line)); + background:color-mix(in oklch,var(--warn,#d6a23a) 9%,var(--surface-3))} +.dcard-status.s-wait .d-dot{background:var(--warn,#d6a23a);box-shadow:0 0 6px var(--warn,#d6a23a); + animation:hpulse 2.4s infinite;--sc:var(--warn,#d6a23a)} +/* Format indicator: a small outline glyph in the card head. Hover (or + focus) surfaces the full format + the Dual Format requirement via the + native tooltip - no extra layout, reads at a glance. */ +.dcard-format{display:inline-flex;align-items:center;justify-content:center; + width:26px;height:26px;border-radius:7px;flex:0 0 auto;color:var(--fg-3); + background:var(--surface-3);border:1px solid var(--line);cursor:help; + transition:transform .14s ease,color .14s ease,border-color .14s ease} +.dcard-format:hover{transform:translateY(-1px) scale(1.06);color:var(--fg); + border-color:color-mix(in oklch,var(--fg-3) 45%,var(--line))} +.dcard-format svg{width:15px;height:15px;display:block} +.dcard-format.fmt-vertical{color:var(--dc,var(--accent)); + border-color:color-mix(in oklch,var(--dc,var(--accent)) 32%,var(--line))} +.dcard-format.fmt-wait{color:var(--warn,#d6a23a); + border-color:color-mix(in oklch,var(--warn,#d6a23a) 36%,var(--line))} .dcard-graph{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px; position:relative;overflow:hidden} .dcard-graph-overlay{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px} @@ -1312,6 +1334,22 @@

New destination

+ + + @@ -1356,10 +1394,17 @@

New destination

Use this only if you want EB + VOD audio at the same time. Launches OBS with the --config-url CLI flag - the only path OBS honours for Custom RTMP (its plugin discards service.json injections at load time). Close OBS first; the flag is per-launch, so reopening OBS normally will lose EB until you click again.
-
- +
+ +
+ + +
+ + One double-click cold-starts InstantClone + OBS in VOD + EB mode. +
@@ -2482,6 +2527,45 @@

New destination

$('dest-sub').textContent = `${lastDests.length} configured · ${lastDests.filter(d => d.alive).length} forwarding live`; } +// Outline glyph for the card's format indicator. 'horizontal' = a wide +// rect, 'vertical' = a tall rect, 'both' = a wide + a tall side by side +// (Twitch Dual Format). Stroke uses currentColor so the CSS classes tint it. +function formatIconSvg(kind){ + const open = ''; + if (kind === 'vertical') return open + ''; + if (kind === 'both') return open + ''; + return open + ''; // horizontal +} + +// Set the card's format icon + hover tooltip from the destination's +// platform/format/readiness. Twitch shows "both" (native Dual Format); +// non-Twitch shows horizontal or vertical, with an amber-tinted, distinct +// tooltip while a vertical canvas is still waiting on Dual Format. +function updateFormatIcon(r, d){ + if (!r.format) return; + let kind, title, cls = ''; + if (d.platform === 'twitch'){ + kind = 'both'; + title = 'Twitch Dual Format: sends both horizontal (16:9) and vertical (9:16) automatically when Dual Format / Enhanced Broadcasting is on in OBS.'; + } else if (d.stream_format === 'vertical'){ + kind = 'vertical'; + if (d.vertical_ready === false){ + cls = 'fmt-wait'; + title = 'Vertical (9:16): waiting for Twitch Dual Format. Turn on Dual Format / Enhanced Broadcasting in OBS to produce the 9:16 canvas this destination sends.'; + } else { + cls = 'fmt-vertical'; + title = 'Vertical (9:16): reusing Twitch Dual Format\'s vertical canvas (YouTube Shorts / Kick mobile / TikTok).'; + } + } else { + kind = 'horizontal'; + title = 'Horizontal (16:9): the standard landscape feed.'; + } + if (r.fmtKind !== kind){ r.format.innerHTML = formatIconSvg(kind); r.fmtKind = kind; } + const wantClass = 'dcard-format' + (cls ? ' ' + cls : ''); + if (r.format.className !== wantClass) r.format.className = wantClass; + if (r.format.title !== title){ r.format.title = title; r.format.setAttribute('aria-label', title); } +} + function buildDestCard(d){ const el = document.createElement('div'); el.className = 'dcard'; @@ -2489,6 +2573,7 @@

New destination

+
@@ -2513,6 +2598,8 @@

New destination

icon: el.querySelector('.dcard-icon'), name: el.querySelector('.dcard-name'), host: el.querySelector('.dcard-host'), + format: el.querySelector('.dcard-format'), + fmtKind: '', statL: el.querySelector('.dcard-status-l'), statE: el.querySelector('.dcard-status'), rate: el.querySelector('.dcard-rate'), @@ -2543,20 +2630,32 @@

New destination

r.icon.textContent = PLATFORM_INITIAL[d.platform] || (d.platform || '?').charAt(0).toUpperCase(); } if (r.name.textContent !== d.name) r.name.textContent = d.name; + // Host stays clean (just the platform). The format is conveyed by the + // icon + hover tooltip set below, not by appending text here. const platLabel = PLATFORM_LABEL[d.platform] || d.platform; if (r.host.textContent !== platLabel) r.host.textContent = platLabel; + updateFormatIcon(r, d); // alive = TCP connected. bitrate>0 = actually forwarding frames. The // sink/Twitch session can be CONNECTED but receiving nothing (OBS hasn't // sent the first IDR yet) - UI used to call that "Streaming" which is // misleading. Split into three states: Streaming (green pulse), Connected // (accent, just sitting on a healthy TCP connection), Idle (gray). - const isStreaming = d.alive && (d.bitrate_kbps || 0) > 0; - const stat = !d.enabled ? 'off' : isStreaming ? 'live' : d.alive ? 'ready' : 'off'; + // A vertical destination whose canvas isn't resolved yet (Twitch Dual + // Format off) is a fourth state: it holds a connection but deliberately + // sends no video, so call it out as "Waiting for Dual Format" rather + // than the misleading "Connected". + const verticalWaiting = d.enabled && d.stream_format === 'vertical' && d.vertical_ready === false; + const isStreaming = d.alive && (d.bitrate_kbps || 0) > 0 && !verticalWaiting; + const stat = !d.enabled ? 'off' : verticalWaiting ? 'wait' : isStreaming ? 'live' : d.alive ? 'ready' : 'off'; r.statE.className = 'dcard-status s-' + stat; r.statL.textContent = !d.enabled ? 'Disabled' + : verticalWaiting ? 'Waiting for Dual Format' : isStreaming ? 'Streaming' : d.alive ? 'Connected' : 'Idle'; + r.statE.title = verticalWaiting + ? 'Turn on Twitch Dual Format (Enhanced Broadcasting) in OBS to produce the 9:16 canvas this destination sends.' + : ''; // rate + history const rate = d.alive ? (d.bitrate_kbps || 0) : 0; r.history.push(rate); if (r.history.length > HISTORY_LEN) r.history.shift(); @@ -2596,6 +2695,7 @@

New destination

$('dest-form-custom').value = d ? d.custom_egress_url : ''; $('dest-form-twitch-ingest').value = (d && d.twitch_ingest) || ''; $('dest-form-youtube-ingest').value = (d && d.youtube_ingest) || ''; + $('dest-form-format').value = (d && d.stream_format === 'vertical') ? 'vertical' : 'horizontal'; $('dest-form-key').value = ''; $('dest-form-key').placeholder = d && d.stream_key_set ? 'leave blank to keep current' : 'paste stream key'; document.getElementById('dest-form-key-hint').textContent = d && d.stream_key_set ? '(currently set)' : ''; @@ -2640,6 +2740,11 @@

New destination

$('dest-form-custom-wrap').hidden = sel.value !== 'custom'; $('dest-form-twitch-wrap').hidden = sel.value !== 'twitch'; $('dest-form-youtube-wrap').hidden = sel.value !== 'youtube'; + // Format choice is meaningless for Twitch (native dual canvas) - + // hide it there and show the "Twitch does both" note instead. + const isTwitch = sel.value === 'twitch'; + $('dest-form-format-wrap').hidden = isTwitch; + $('dest-form-twitch-native-note').hidden = !isTwitch; renderKeyHelp(sel.value, 'dest-form-key-help'); updateVodVisibility(); }); @@ -2669,11 +2774,14 @@

New destination

// enabled it then switched platform without the change handler // running. const vodAudio = platform === 'twitch' && $('dest-form-vod-audio').checked; + // Vertical is a non-Twitch concept; Twitch always gets both canvases. + const format = platform !== 'twitch' ? $('dest-form-format').value : 'horizontal'; if (!name){ $('dest-form-msg').innerHTML = 'Name required'; return false; } const body = new URLSearchParams({ name, platform, custom_egress_url:custom, enabled:enabled?'on':'off', twitch_ingest:ingest, youtube_ingest:yingest, vod_audio: vodAudio ? 'on' : 'off', + stream_format: format, }); if (id) body.set('id', id); if (key) body.set('stream_key', key); @@ -2691,6 +2799,7 @@

New destination

const body = new URLSearchParams({id:d.id, name:d.name, platform:d.platform, custom_egress_url:d.custom_egress_url, twitch_ingest:d.twitch_ingest||'', youtube_ingest:d.youtube_ingest||'', vod_audio: d.vod_audio ? 'on' : 'off', + stream_format: d.stream_format || 'horizontal', enabled:d.enabled?'off':'on'}); await fetch('/destinations',{method:'POST', body}); loadDestinations(); @@ -3250,6 +3359,60 @@

New destination

} btn.disabled = false; } +// One-click VOD + EB: saves the destination (so the VOD flag is written), +// then runs the server-side setup and renders a red-to-green checklist so +// the user can see exactly which step needs attention. +async function setupVodEb(){ + const btn = $('obs-eb-setup-btn'); + const list = $('vod-eb-checklist'); + if (!btn || btn.disabled) return; + btn.disabled = true; + if (list){ list.hidden = false; list.innerHTML = '
  • Saving destination…
  • '; } + if (!(await saveDestForm())){ + if (list) list.innerHTML = '
  • Fix the destination form, then try again.
  • '; + btn.disabled = false; + return; + } + try { + const r = await (await fetch('/obs/setup-vod-eb', {method:'POST'})).json(); + if (list && r.steps){ + list.innerHTML = r.steps.map(s => + `
  • + ${s.ok?'✓':'✗'} + ${s.name} - ${s.msg} +
  • `).join(''); + } + toast(r.ok ? 'VOD + EB ready' : 'VOD + EB needs attention', r.ok ? 'ok' : 'err'); + } catch(e){ + if (list) list.innerHTML = '
  • Request failed: '+String(e)+'
  • '; + toast('Request failed','err'); + } + btn.disabled = false; +} + +// Create a Desktop shortcut that cold-starts the whole VOD + EB flow. +async function createEbShortcut(){ + const btn = $('eb-shortcut-btn'); + const msg = $('eb-shortcut-msg'); + if (!btn || btn.disabled) return; + btn.disabled = true; + if (msg) msg.textContent = 'Creating shortcut…'; + try { + const r = await (await fetch('/shortcut/create-eb', {method:'POST'})).json(); + if (r.ok){ + if (msg) msg.textContent = 'Created '+r.kind+': '+r.path; + toast('Desktop shortcut created','ok'); + } else { + if (msg) msg.textContent = r.error || 'Failed'; + toast(r.error || 'Failed','err'); + } + } catch(e){ + if (msg) msg.textContent = String(e); + toast('Request failed','err'); + } + btn.disabled = false; +} + async function refreshObsEbLaunchAvailability(){ const btn = $('obs-eb-launch-btn'); const msg = $('obs-eb-launch-msg'); @@ -4375,6 +4538,7 @@

    New destination

    body: `

    Add as many platforms as you want - each gets its own card with a coloured left stripe (Twitch purple, YouTube rose, Kick mint…), live bitrate sparkline, and per-destination status pill.

    Stream keys are per-destination. OBS uses a simple stream identifier, the real platform keys live here.

    +

    Horizontal or vertical? Every card has a small format icon next to its status - hover it to see whether that destination sends horizontal (wide), vertical 9:16, or both (Twitch, via Dual Format). Set any non-Twitch destination to Vertical to reuse Twitch's 9:16 canvas for YouTube Shorts / Kick mobile - it only flows while Dual Format / Enhanced Broadcasting is on in OBS, and the card shows Waiting for Dual Format until then.

    Streaming vs Connected: Streaming = bytes flowing. Connected = TCP up but OBS hasn't sent a frame yet.
    `, }, From dccc35c16da15712db13c55d51e3ded103fa18f3 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Tue, 30 Jun 2026 23:51:25 +0200 Subject: [PATCH 02/33] docs(web): clarify Twitch Dual Format note + link Twitch setup guide --- web/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 7127690..4d6210c 100644 --- a/web/index.html +++ b/web/index.html @@ -1344,11 +1344,11 @@

    New destination

    - Vertical needs Twitch Dual Format (Enhanced Broadcasting) turned ON in OBS - that is what produces the 9:16 canvas this reuses. With it off, this destination simply waits and sends nothing. For YouTube, paste the vertical stream key from YouTube's Dual stream, Select key, Encoder. + Vertical reuses the 9:16 canvas that Twitch Dual Format produces, so you must have Twitch Dual Format running in OBS (it needs Enhanced Broadcasting set up first). With it off, this destination just waits and sends nothing. How to set up Dual Format & Enhanced Broadcasting →
    For YouTube, paste the vertical stream key from YouTube's Dual stream → Select key → Encoder.
    From f73eb612c27eba313ead90a61c261db354d6de5c Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Tue, 30 Jun 2026 23:52:48 +0200 Subject: [PATCH 03/33] docs(web): link Twitch Dual Format guide + explain it as the vertical standard --- web/index.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 4d6210c..536f97e 100644 --- a/web/index.html +++ b/web/index.html @@ -1344,7 +1344,7 @@

    New destination

    - Vertical reuses the 9:16 canvas that Twitch Dual Format produces, so you must have Twitch Dual Format running in OBS (it needs Enhanced Broadcasting set up first). With it off, this destination just waits and sends nothing. How to set up Dual Format & Enhanced Broadcasting →
    For YouTube, paste the vertical stream key from YouTube's Dual stream → Select key → Encoder. + Vertical reuses the 9:16 canvas that Twitch Dual Format produces. InstantClone standardizes on that one canvas as its vertical source: set Twitch Dual Format (Enhanced Broadcasting) up once in OBS and every vertical destination here reuses it - no second scene, no extra encoder. With Dual Format off there is no vertical canvas, so this destination just waits and sends nothing. How to set up Twitch Dual Format & Enhanced Broadcasting →
    For YouTube, paste the vertical stream key from YouTube's Dual stream → Select key → Encoder.
    - OBS- - Egress- - - kbps + + + + OBSOffline + + + + + EgressOff + + + Idle + @@ -2462,15 +2479,45 @@

    New destination

    // ── Header chips ─────────────────────────────────────────────────────── function renderHeader(s){ - // OBS pill - const obs = $('hdr-obs'); obs.className = 'ic-pill ' + (s.ingest_alive ? 'live ok' : ''); - $('hdr-obs-v').textContent = s.ingest_alive ? 'in' : '-'; - // Egress - const eg = $('hdr-egress'); eg.className = 'ic-pill ' + ((s.destinations_alive || 0) > 0 ? 'ok' : ''); - $('hdr-egress-v').textContent = (s.destinations_total || 0) > 0 - ? `${s.destinations_alive || 0}/${s.destinations_total || 0}` : '-'; - // Bitrate - animateNumber('hdr-rate', s.stats.bitrate_kbps || 0, 400); + // OBS pill - plain-language state + a tooltip that tells the user what + // to do when it's not connected yet. + const obs = $('hdr-obs'); + obs.className = 'ic-pill ' + (s.ingest_alive ? 'live ok' : ''); + $('hdr-obs-v').textContent = s.ingest_alive ? 'Live' : 'Offline'; + obs.title = s.ingest_alive + ? 'OBS is connected and publishing to InstantClone.' + : 'Waiting for OBS. In OBS set Service to Custom, Server to rtmp://127.0.0.1:1935/live, then Start Streaming.'; + // Egress pill - how many destinations are actually connected. + const eg = $('hdr-egress'); + const total = s.destinations_total || 0; + const live = s.destinations_alive || 0; + eg.className = 'ic-pill ' + (live > 0 ? 'ok' : ''); + if (total === 0){ + $('hdr-egress-v').textContent = 'No targets'; + eg.title = 'No destinations yet. Add Twitch / YouTube / Kick in the Destinations tab.'; + } else { + $('hdr-egress-v').textContent = `${live}/${total} live`; + eg.title = live > 0 + ? `${live} of ${total} destination${total>1?'s':''} connected and receiving.` + : `${total} destination${total>1?'s':''} configured, none connected yet.`; + } + // Bitrate pill - "Idle" when nothing flows, auto kbps -> Mbps otherwise. + const kbps = s.stats.bitrate_kbps || 0; + const ratePill = $('hdr-rate-pill'); + const rateU = $('hdr-rate-u'); + if (kbps <= 0){ + $('hdr-rate').textContent = 'Idle'; + rateU.textContent = ''; + ratePill.classList.remove('ok'); + } else if (kbps >= 1000){ + $('hdr-rate').textContent = (kbps / 1000).toFixed(2); + rateU.textContent = 'Mbps'; + ratePill.classList.add('ok'); + } else { + $('hdr-rate').textContent = Math.round(kbps); + rateU.textContent = 'kbps'; + ratePill.classList.add('ok'); + } // Codec chip const haveV = s.video_codec && s.video_codec !== '?' && s.video_codec !== ''; const haveA = s.audio_codec && s.audio_codec !== '?' && s.audio_codec !== ''; From bf3ed3f09b88fea665fbc5815fbfbe39bccd60bd Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Wed, 1 Jul 2026 11:12:44 +0200 Subject: [PATCH 05/33] feat(web): profiles fix-it action, stats polish, dynamic format icon, clearer copy --- src/web.rs | 15 +++- web/index.html | 181 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 151 insertions(+), 45 deletions(-) diff --git a/src/web.rs b/src/web.rs index 72249b1..958ed65 100644 --- a/src/web.rs +++ b/src/web.rs @@ -765,7 +765,7 @@ fn platforms_json() -> String { r#"[ {"slug":"twitch","label":"Twitch","key_url":"https://dashboard.twitch.tv/u/_/settings/stream","key_help":"Twitch Creator Dashboard → Settings → Stream → Primary Stream Key","tip":"Twitch's transcoded quality ladder (1080p / 720p / 480p / 360p / 160p) is account-tier gated - non-Affiliates get Source-Only at any bitrate, Affiliate / Partner get the ladder. In Source-Only mode every viewer must decode your full source bitrate, and above ~8 Mbps mobile devices may fail (Error #1000 / black screen with audio). Stay ≤ 8 Mbps if your audience includes mobile and you're not sure your account gets transcoded."}, {"slug":"youtube","label":"YouTube Live","key_url":"https://studio.youtube.com/channel/UC/livestreaming","key_help":"YouTube Studio → Go live → Stream tab → Stream key","tip":"First-time live: YouTube requires a 24h verification window after enabling live streaming."}, - {"slug":"kick","label":"Kick","key_url":"https://kick.com/dashboard/settings/stream","key_help":"Kick Creator Dashboard → Settings → Stream","tip":"Kick runs on AWS IVS - DISABLE B-frames in OBS (Output → Advanced → x264/NVENC) or the stream will be dropped. Keep bitrate ≤ 8500 kbps and keyframe interval 1-2 s."}, + {"slug":"kick","label":"Kick","key_url":"https://kick.com/dashboard/settings/stream","key_help":"Kick Creator Dashboard → Settings → Stream","tip":"Kick runs on AWS IVS - DISABLE B-frames in OBS (Output → Advanced → x264/NVENC) or the stream will be dropped. Keep bitrate ≤ 8500 kbps and keyframe interval 1-2 s. Multistreaming Kick with Twitch/YouTube? OBS encodes once and InstantClone fans it out, so set B-frames to 0 for everyone - Twitch and YouTube accept no-B-frames just fine, only Kick is strict. Delay and cuts work either way."}, {"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} @@ -2141,17 +2141,25 @@ fn destinations_json(ctrl: &Controller, settings: &Arc>) // "waiting for Dual Format" hint. Non-vertical destinations always // report ready=true so the badge logic stays simple. let vertical = d.wants_vertical(); - let vertical_ready = if vertical { + // A portrait canvas is present on the wire right now (Twitch Dual + // Format is live). Detected globally and stored on every dest each + // tick, so it's meaningful for Twitch cards too - that's what lets + // the format icon show "both" only when Dual Format is actually on, + // not just because the destination is Twitch. + let vertical_canvas_present = { use std::sync::atomic::Ordering; ctrl.destination_state(&d.id) .vertical_primary_track .load(Ordering::Relaxed) != 0xFF + }; + let vertical_ready = if vertical { + vertical_canvas_present } else { true }; out.push_str(&format!( - r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"vod_audio":{va},"vod_audio_inject_eb":{vie},"stream_format":{sf},"vertical_ready":{vr},"stream_key_set":{ks},"url_redacted":{ur},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{ct},"reconnects":{rc}}}"#, + r#"{{"id":{id},"name":{n},"enabled":{en},"platform":{p},"custom_egress_url":{cu},"twitch_ingest":{ti},"youtube_ingest":{yi},"vod_audio":{va},"vod_audio_inject_eb":{vie},"stream_format":{sf},"vertical_ready":{vr},"vertical_canvas_present":{vcp},"stream_key_set":{ks},"url_redacted":{ur},"alive":{al},"bitrate_kbps":{br},"tags_sent":{ts},"bytes_sent":{bs},"cuts":{ct},"reconnects":{rc}}}"#, id = json_escape_quoted(&d.id), n = json_escape_quoted(&d.name), en = d.enabled, @@ -2167,6 +2175,7 @@ fn destinations_json(ctrl: &Controller, settings: &Arc>) vie = d.vod_audio_inject_eb, sf = json_escape_quoted(&d.stream_format), vr = vertical_ready, + vcp = vertical_canvas_present, ks = !d.stream_key.is_empty(), ur = json_escape_quoted(&redact_url(&url)), al = alive, diff --git a/web/index.html b/web/index.html index 9353e87..e8c9185 100644 --- a/web/index.html +++ b/web/index.html @@ -93,6 +93,16 @@ background:color-mix(in oklch,var(--accent) 18%,transparent); border-bottom-color:var(--accent)} .ic-brand-sub a:visited{color:var(--accent)} +/* Unified inline content links: accent colour + animated underline, so + every in-body link (help text, tour, notes) matches the app instead of + falling back to the default browser blue. */ +.ic-link,.muted a,.tour-body a,.dest-form-help a,#dest-form-format-help a,#dest-form-twitch-native-note a{ + color:var(--accent);text-decoration:none;font-weight:600; + border-bottom:1px solid color-mix(in oklch,var(--accent) 32%,transparent); + transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +.ic-link:hover,.muted a:hover,.tour-body a:hover,#dest-form-format-help a:hover,#dest-form-twitch-native-note a:hover{ + color:var(--fg);border-color:var(--fg)} +.ic-link:visited,.muted a:visited,.tour-body a:visited{color:var(--accent)} .ic-brand-dot{display:inline-block;width:5px;height:5px;border-radius:999px;background:var(--fg-4);margin:0 6px 2px} .ic-header-right{display:flex;gap:6px;align-items:center;margin-left:auto;flex-wrap:wrap;justify-content:flex-end} .ic-pill{display:inline-flex;align-items:center;gap:8px;padding:6px 11px;border-radius:999px; @@ -549,8 +559,14 @@ /* ── Stats ───────────────────────────────────── */ .stats-big{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px} @media (max-width:880px){.stats-big{grid-template-columns:1fr}} -.stat-card{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:14px} +.stat-card{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:14px;cursor:help; + transition:border-color .18s var(--ease-out),transform .18s var(--ease-out),box-shadow .18s var(--ease-out)} +.stat-card:hover{border-color:color-mix(in oklch,var(--accent) 32%,var(--line-2));transform:translateY(-2px); + box-shadow:0 6px 20px -10px color-mix(in oklch,var(--accent) 40%,transparent)} .stat-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px} +.stat-label{display:inline-flex;align-items:center;gap:7px} +.stat-ic{width:14px;height:14px;color:var(--fg-3);opacity:.85;flex:0 0 auto;transition:color .18s,opacity .18s} +.stat-card:hover .stat-ic{color:var(--accent);opacity:1} .stat-now{font-size:22px;font-weight:500;color:var(--fg);letter-spacing:-.02em; font-family:'JetBrains Mono',monospace;font-variant-numeric:tabular-nums} .stat-unit{color:var(--fg-3);font-size:12px;margin-left:4px;font-family:'JetBrains Mono',monospace} @@ -558,13 +574,20 @@ .stats-small{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px} @media (max-width:980px){.stats-small{grid-template-columns:repeat(3,1fr)}} @media (max-width:540px){.stats-small{grid-template-columns:repeat(2,1fr)}} -.metric-mini{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px; - transition:border-color .15s var(--ease-out)} -.metric-mini:hover{border-color:var(--line-2)} -.metric-mini-v{display:flex;align-items:baseline;gap:4px;margin-top:4px} +.metric-mini{background:var(--bg-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:help; + transition:border-color .18s var(--ease-out),transform .18s var(--ease-out),background .18s var(--ease-out)} +.metric-mini:hover{border-color:color-mix(in oklch,var(--accent) 35%,var(--line-2));transform:translateY(-2px); + background:color-mix(in oklch,var(--accent) 4%,var(--bg-2))} +.metric-mini-top{display:flex;align-items:center;gap:6px} +.metric-ic{width:13px;height:13px;color:var(--fg-3);opacity:.8;flex:0 0 auto;transition:color .18s,opacity .18s} +.metric-mini:hover .metric-ic{color:var(--accent);opacity:1} +.metric-mini-v{display:flex;align-items:baseline;gap:4px;margin-top:5px} .metric-mini-v .mono{font-size:18px;font-weight:500;color:var(--fg);letter-spacing:-.01em; font-variant-numeric:tabular-nums} .metric-unit{color:var(--fg-3);font-size:11px;font-family:'JetBrains Mono',monospace} +/* Sparkline strokes draw in on first paint for a touch of life. */ +.stat-spark path[class$="-line"],.stat-spark path[id$="-line"]{ + transition:stroke .2s} /* ── OBS tab ─────────────────────────────────── */ .obs-grid{display:grid;gap:12px;grid-template-columns:minmax(0,1fr) minmax(0,1fr)} @@ -912,11 +935,25 @@ background:color-mix(in oklch,var(--accent) 15%,var(--surface-3))} .profile-name{flex:1;font-weight:500;color:var(--fg);font-size:13.5px} .profile-secs{color:var(--accent);font-size:13px;font-weight:500;font-family:'JetBrains Mono',monospace} -.profile-row.too-big{opacity:.5;cursor:not-allowed} -.profile-row.too-big:hover{border-color:var(--line)} -.profile-row.too-big .profile-secs{color:var(--fg-4)} -.profile-row .profile-warn{font-size:10.5px;color:var(--danger);margin-left:auto;margin-right:8px;white-space:nowrap} -.cp-profile-chip.too-big{opacity:.45;cursor:not-allowed;border-color:var(--line)} +/* Too-big = the current buffer can't hold this delay. Don't fully grey it + out (the "fix" button must stay legible); dim the label, amber-tint the + lock, and offer a one-click jump to raise the buffer. */ +.profile-row.too-big{cursor:pointer} +.profile-row.too-big .profile-name,.profile-row.too-big .profile-secs{color:var(--fg-4)} +.profile-row.too-big:hover{border-color:color-mix(in oklch,var(--warn,#d6a23a) 40%,var(--line))} +.profile-row.too-big .profile-bolt{color:var(--warn,#d6a23a); + border-color:color-mix(in oklch,var(--warn,#d6a23a) 35%,transparent)} +.profile-fix{margin-left:auto;display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:600; + color:var(--warn,#d6a23a);border:1px solid color-mix(in oklch,var(--warn,#d6a23a) 40%,var(--line)); + border-radius:999px;padding:3px 9px;white-space:nowrap;cursor:pointer;transition:.15s var(--ease-out)} +.profile-fix:hover{background:color-mix(in oklch,var(--warn,#d6a23a) 16%,transparent);transform:translateY(-1px)} +.ic-input.flash{animation:inputflash 1.2s var(--ease-out)} +@keyframes inputflash{ + 0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--accent) 55%,transparent);border-color:var(--accent)} + 100%{box-shadow:0 0 0 7px transparent} +} +.cp-profile-chip.too-big{opacity:.6;cursor:pointer;border-color:color-mix(in oklch,var(--warn,#d6a23a) 30%,var(--line))} +.cp-profile-chip.too-big:hover{opacity:.85;border-color:color-mix(in oklch,var(--warn,#d6a23a) 45%,var(--line))} .profile-tag{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase; padding:3px 7px;border-radius:999px;background:color-mix(in oklch,var(--accent) 18%,transparent);color:var(--accent)} .profile-new{background:transparent;border:1px dashed var(--line-2);margin-top:8px;cursor:default; @@ -1365,7 +1402,7 @@

    New destination

    @@ -1442,19 +1479,19 @@

    New destination

    -
    +
    - Bitrate in - - kbps + Bitrate in + - kbps
    -
    +
    - CPU + CPU -%
    @@ -1464,12 +1501,30 @@

    New destination

    -
    Frames sent
    0
    -
    Data sent
    0
    -
    IDR cuts
    0
    -
    OBS drops
    0
    -
    Egress retry
    0
    -
    Memory
    -
    +
    +
    Frames sent
    +
    0
    +
    +
    +
    Data sent
    +
    0
    +
    +
    +
    IDR cuts
    +
    0
    +
    +
    +
    OBS drops
    +
    0
    +
    +
    +
    Egress retry
    +
    0
    +
    +
    +
    Memory
    +
    -
    +
    @@ -1526,10 +1581,12 @@

    New destination

    Streaming to Kick?
    Disable B-frames
    Kick / AWS IVS rejects them. where?
    - Where to disable B-frames in OBS + Where to disable B-frames in OBS (and does it hurt Twitch?)

    OBS → Settings → Output → switch Output Mode to Advanced:

    x264: B-frames0
    NVENC: Max B-frames0

    +

    Multistreaming Kick + Twitch/YouTube? OBS encodes once and InstantClone fans that one feed out, so you can't have B-frames for Twitch but not Kick. Good news: disabling B-frames is totally safe for Twitch and YouTube - they accept it fine (a hair less compression efficiency, invisible in practice). Kick is the strict one, so set B-frames to 0 and every destination is happy.

    +

    Delay and cuts work the same either way - InstantClone cuts on keyframes (IDRs), which B-frames don't affect. No B-frames actually makes cuts marginally cleaner.

    @@ -2460,19 +2517,18 @@

    New destination

    // the live-adjust path. Only the "arming" mid-fill state and the // explicit current profile are disabled. b.disabled = isCur || ds === 'arming'; + const needMB = tooBig ? requiredMBForMs(p.delay_ms) : 0; if (tooBig){ - const needMB = requiredMBForMs(p.delay_ms); - b.title = `${p.name}: buffer too small - needs ≥ ${needMB} MB at the current bitrate (raise in System tab).`; + b.title = `${p.name}: buffer too small - needs ≥ ${needMB} MB at the current bitrate. Click to raise it.`; } else if (ds === 'active' && !isCur){ b.title = `Adjust to ${p.name}`; } else { b.title = `Arm: ${p.name}`; } - b.innerHTML = `${fmtDelay(p.delay_ms)}`; - b.onclick = () => { - $('delay-input').value = String(Math.round(p.delay_ms / 1000)); - arm(p.delay_ms); - }; + b.innerHTML = `${tooBig ? '🔒' : '⚡'}${fmtDelay(p.delay_ms)}`; + b.onclick = tooBig + ? () => jumpToBufferSetting(needMB) + : () => { $('delay-input').value = String(Math.round(p.delay_ms / 1000)); arm(p.delay_ms); }; c.appendChild(b); }); } @@ -2536,7 +2592,16 @@

    New destination

    if (rateHistory.length > HISTORY_LEN) rateHistory.shift(); cpuHistory.push(s.cpu_pct || 0); if (cpuHistory.length > HISTORY_LEN) cpuHistory.shift(); - animateNumber('stat-rate', s.stats.bitrate_kbps || 0, 500); + // Bitrate in: count up, auto kbps -> Mbps so a live stream reads "6.20 + // Mbps" instead of "6200 kbps". + const rkbps = s.stats.bitrate_kbps || 0; + if (rkbps >= 1000){ + animateNumber('stat-rate', rkbps, 500, v => (v/1000).toFixed(2)); + $('stat-rate-u').textContent = 'Mbps'; + } else { + animateNumber('stat-rate', rkbps, 500, v => Math.round(v).toString()); + $('stat-rate-u').textContent = 'kbps'; + } animateNumber('stat-cpu', s.cpu_pct || 0, 500, v => v.toFixed(1)); updateSpark('spark-rate-line', 'spark-rate-fill', rateHistory, 760, 70); updateSpark('spark-cpu-line', 'spark-cpu-fill', cpuHistory, 360, 48); @@ -2585,15 +2650,21 @@

    New destination

    } // Set the card's format icon + hover tooltip from the destination's -// platform/format/readiness. Twitch shows "both" (native Dual Format); -// non-Twitch shows horizontal or vertical, with an amber-tinted, distinct -// tooltip while a vertical canvas is still waiting on Dual Format. +// *actual* live state, not an assumed config. Twitch reads horizontal by +// default (the normal case) and only flips to "both" when a vertical canvas +// is really on the wire (Dual Format live). Non-Twitch shows horizontal, or +// vertical with a distinct amber state while it waits on Dual Format. function updateFormatIcon(r, d){ if (!r.format) return; let kind, title, cls = ''; if (d.platform === 'twitch'){ - kind = 'both'; - title = 'Twitch Dual Format: sends both horizontal (16:9) and vertical (9:16) automatically when Dual Format / Enhanced Broadcasting is on in OBS.'; + if (d.vertical_canvas_present){ + kind = 'both'; cls = 'fmt-vertical'; + title = 'Dual Format active: Twitch is receiving both a horizontal (16:9) and a vertical (9:16) canvas. InstantClone forwards both.'; + } else { + kind = 'horizontal'; + title = 'Horizontal (16:9) - a normal Twitch stream (Enhanced Broadcasting is horizontal too). Turn on Twitch Dual Format in OBS to also send a vertical 9:16 canvas.'; + } } else if (d.stream_format === 'vertical'){ kind = 'vertical'; if (d.vertical_ready === false){ @@ -2881,12 +2952,19 @@

    New destination

    const tag = document.createElement('span'); tag.className = 'profile-tag'; tag.textContent = ds; b.appendChild(tag); } + const needMB = tooBig ? requiredMBForMs(p.delay_ms) : 0; if (tooBig){ - const needMB = requiredMBForMs(p.delay_ms); - const warn = document.createElement('span'); warn.className = 'profile-warn'; - warn.textContent = `needs ≥ ${needMB} MB`; - b.appendChild(warn); - b.title = `Buffer too small at the current bitrate - raise buffer in System tab to ≥ ${needMB} MB to use this profile.`; + // Swap the bolt for a lock and offer a one-click fix instead of a + // dead red "needs X MB" label the user has to act on manually. + b.querySelector('.profile-bolt').innerHTML = + ''; + const fix = document.createElement('span'); + fix.className = 'profile-fix'; + fix.textContent = `Raise buffer → ${needMB} MB`; + fix.title = `This delay needs a ≥ ${needMB} MB buffer at the current bitrate (you have ${lastConfig?lastConfig.buffer_mb:'?'} MB ≈ ${fmtDelay(cap)}). Click to set it in System → Buffer, then Save + restart.`; + fix.onclick = (e) => { e.stopPropagation(); jumpToBufferSetting(needMB); }; + b.appendChild(fix); + b.title = `Buffer too small for ${fmtDelay(p.delay_ms)} at the current bitrate - click to raise it.`; } else if (ds === 'active' && !isCur){ b.title = `Adjust live delay to ${p.name}`; } else if (ds === 'armed' && !isCur){ @@ -2895,7 +2973,9 @@

    New destination

    const x = document.createElement('span'); x.className = 'ic-btn-tiny'; x.textContent = '✕'; x.onclick = (e) => { e.stopPropagation(); delProfile(p.name); }; b.appendChild(x); - b.onclick = () => arm(p.delay_ms); // arm() handles capacity refusal + active/armed live update + // Too-big rows jump to the buffer setting (the only thing that unblocks + // them); usable rows arm the delay. + b.onclick = tooBig ? () => jumpToBufferSetting(needMB) : () => arm(p.delay_ms); c.appendChild(b); }); } @@ -2931,6 +3011,23 @@

    New destination

    await fetch('/profiles/delete',{method:'POST',body:new URLSearchParams({name})}); loadProfiles(); } +// Jump to System -> Buffer with a target size prefilled, so a profile that +// "needs more MB" is one click from being fixable. Buffer changes need a +// restart (the DiskRing is immutable once mapped) - the restart banner +// already communicates that after Save. +function jumpToBufferSetting(mb){ + showTab('system'); + showSubTab('system', 'buffer'); + const el = $('s-bmb'); + if (el){ + if (mb) el.value = mb; + renderBufferHint(); + el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); + el.focus(); + el.scrollIntoView({behavior:'smooth', block:'center'}); + } + toast('Set the buffer size, hit Save, then restart to use this delay.', 'ok'); +} // ── Config / Settings ───────────────────────────────────────────────── async function loadConfig(){ From e28854c0fa1317fb5a5b25722c1308ea4fb23649 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Wed, 1 Jul 2026 11:25:01 +0200 Subject: [PATCH 06/33] fix(web): quieter profiles fix-it link; correct Kick B-frames guidance (IVS low-latency accepts them) --- README.es.md | 2 +- README.md | 2 +- src/web.rs | 2 +- web/index.html | 89 +++++++++++++++++++++++++------------------------- 4 files changed, 48 insertions(+), 47 deletions(-) diff --git a/README.es.md b/README.es.md index a4b1cff..ae3f0ea 100644 --- a/README.es.md +++ b/README.es.md @@ -300,7 +300,7 @@ Sin npm, sin submódulos, sin SDK de plataforma. El HTML del panel se minifica y - **Registro de servicio en OBS con un click.** El primer paso del wizard añade una entrada "InstantClone" al desplegable de servicios de OBS (escribe `services.json` con `.bak` previo; se refresca si cambia el puerto; surfacea "cierra OBS primero" cuando el fichero está bloqueado). - Egress multi-destino con reconexión + bitrate por destino. - **UI consciente de la capacidad del buffer**: pista en vivo "X MB → máx Ys de delay a N Mbps", se niega a armar un delay mayor de lo que cabe con una razón explícita "necesita ≥ N MB". -- **Avisos por plataforma**: riesgo de fallo de decodificación en móvil por encima de 8 Mbps en Twitch Source-Only, requerimiento de no-B-frames de Kick (AWS IVS), enlaces directos al dashboard de claves de cada plataforma - todo expuesto en el wizard y el formulario de destino para no aprender cada gotcha en directo. +- **Avisos por plataforma**: riesgo de fallo de decodificación en móvil por encima de 8 Mbps en Twitch Source-Only, reglas de ingesta AWS IVS de Kick (CBR + keyframe de 2 s; los B-frames en realidad van bien en su RTMP de baja latencia), enlaces directos al dashboard de claves de cada plataforma - todo expuesto en el wizard y el formulario de destino para no aprender cada gotcha en directo. - Icono de bandeja con estado en vivo + corte de un click, pre-flight de puertos que identifica el proceso conflictivo por PID + exe. - Cobertura de tests sobre la máquina de estados, detección IDR (AVC + Enhanced RTMP + flatten multi-track), codec AMF0 incluyendo Strict Array, evicción del ring con protección de lecturas en vuelo, y la promoción del wrap de timestamps que evita el bug de los 49,7 días. diff --git a/README.md b/README.md index e6cf3fc..6ee2c43 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ No npm. No submodules. No platform SDKs. The dashboard HTML is minified + gzippe - **One-click OBS service registration.** The wizard's primary onboarding path adds an "InstantClone" entry to OBS's Service dropdown (writes `services.json` with a `.bak` first; refreshes on port change; surfaces "close OBS first" when the file is locked). - Multi-destination egress with per-destination reconnect + bitrate stats. - **Capacity-aware buffer UI**: live "X MB → max Ys delay at N Mbps" hint, refuses to arm a delay larger than the buffer can hold with an explicit "needs ≥ N MB" reason. -- **Platform-specific warnings**: Twitch mobile-decoder risk above 8 Mbps under Source-Only, Kick's no-B-frames requirement (AWS IVS), per-platform stream-key dashboard links - all surfaced in the wizard / destination form so streamers don't have to learn each platform's gotchas the hard way. +- **Platform-specific warnings**: Twitch mobile-decoder risk above 8 Mbps under Source-Only, Kick's AWS IVS ingest rules (CBR + 2 s keyframe; B-frames actually fine on its low-latency RTMP), per-platform stream-key dashboard links - all surfaced in the wizard / destination form so streamers don't have to learn each platform's gotchas the hard way. - Tray icon with live status + one-click cut, port-conflict pre-flight that names the offending process by PID + exe. - Test coverage covers the state machine, AVC + Enhanced RTMP IDR detection + multi-track flatten, AMF0 codec including Strict Array, ring eviction with in-flight-read protection, and the timestamp-wrap promotion that prevents the 49.7-day bug. diff --git a/src/web.rs b/src/web.rs index 958ed65..cab9e1a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -765,7 +765,7 @@ fn platforms_json() -> String { r#"[ {"slug":"twitch","label":"Twitch","key_url":"https://dashboard.twitch.tv/u/_/settings/stream","key_help":"Twitch Creator Dashboard → Settings → Stream → Primary Stream Key","tip":"Twitch's transcoded quality ladder (1080p / 720p / 480p / 360p / 160p) is account-tier gated - non-Affiliates get Source-Only at any bitrate, Affiliate / Partner get the ladder. In Source-Only mode every viewer must decode your full source bitrate, and above ~8 Mbps mobile devices may fail (Error #1000 / black screen with audio). Stay ≤ 8 Mbps if your audience includes mobile and you're not sure your account gets transcoded."}, {"slug":"youtube","label":"YouTube Live","key_url":"https://studio.youtube.com/channel/UC/livestreaming","key_help":"YouTube Studio → Go live → Stream tab → Stream key","tip":"First-time live: YouTube requires a 24h verification window after enabling live streaming."}, - {"slug":"kick","label":"Kick","key_url":"https://kick.com/dashboard/settings/stream","key_help":"Kick Creator Dashboard → Settings → Stream","tip":"Kick runs on AWS IVS - DISABLE B-frames in OBS (Output → Advanced → x264/NVENC) or the stream will be dropped. Keep bitrate ≤ 8500 kbps and keyframe interval 1-2 s. Multistreaming Kick with Twitch/YouTube? OBS encodes once and InstantClone fans it out, so set B-frames to 0 for everyone - Twitch and YouTube accept no-B-frames just fine, only Kick is strict. Delay and cuts work either way."}, + {"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} diff --git a/web/index.html b/web/index.html index e8c9185..9acca0c 100644 --- a/web/index.html +++ b/web/index.html @@ -935,25 +935,25 @@ background:color-mix(in oklch,var(--accent) 15%,var(--surface-3))} .profile-name{flex:1;font-weight:500;color:var(--fg);font-size:13.5px} .profile-secs{color:var(--accent);font-size:13px;font-weight:500;font-family:'JetBrains Mono',monospace} -/* Too-big = the current buffer can't hold this delay. Don't fully grey it - out (the "fix" button must stay legible); dim the label, amber-tint the - lock, and offer a one-click jump to raise the buffer. */ -.profile-row.too-big{cursor:pointer} -.profile-row.too-big .profile-name,.profile-row.too-big .profile-secs{color:var(--fg-4)} -.profile-row.too-big:hover{border-color:color-mix(in oklch,var(--warn,#d6a23a) 40%,var(--line))} -.profile-row.too-big .profile-bolt{color:var(--warn,#d6a23a); - border-color:color-mix(in oklch,var(--warn,#d6a23a) 35%,transparent)} -.profile-fix{margin-left:auto;display:inline-flex;align-items:center;gap:5px;font-size:10.5px;font-weight:600; - color:var(--warn,#d6a23a);border:1px solid color-mix(in oklch,var(--warn,#d6a23a) 40%,var(--line)); - border-radius:999px;padding:3px 9px;white-space:nowrap;cursor:pointer;transition:.15s var(--ease-out)} -.profile-fix:hover{background:color-mix(in oklch,var(--warn,#d6a23a) 16%,transparent);transform:translateY(-1px)} +/* Too-big = the current buffer can't hold this delay. Keep it quiet: + muted label, not armable. A small inline "Raise buffer" link is the + only affordance - it takes you to the setting, nothing auto-changes. */ +.profile-row.too-big{cursor:default} +.profile-row.too-big:hover{border-color:var(--line)} +.profile-row.too-big .profile-bolt,.profile-row.too-big .profile-name,.profile-row.too-big .profile-secs{color:var(--fg-4)} +.profile-row.too-big:hover .profile-bolt{color:var(--fg-4);border-color:var(--line)} +.profile-warn{margin-left:auto;display:inline-flex;align-items:center;gap:10px;font-size:10.5px; + color:var(--warn,#d6a23a);white-space:nowrap} +.profile-warn a{color:var(--accent);cursor:pointer;font-weight:600; + border-bottom:1px solid color-mix(in oklch,var(--accent) 32%,transparent); + transition:color .15s var(--ease-out),border-color .15s var(--ease-out)} +.profile-warn a:hover{color:var(--fg);border-color:var(--fg)} .ic-input.flash{animation:inputflash 1.2s var(--ease-out)} @keyframes inputflash{ 0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--accent) 55%,transparent);border-color:var(--accent)} 100%{box-shadow:0 0 0 7px transparent} } -.cp-profile-chip.too-big{opacity:.6;cursor:pointer;border-color:color-mix(in oklch,var(--warn,#d6a23a) 30%,var(--line))} -.cp-profile-chip.too-big:hover{opacity:.85;border-color:color-mix(in oklch,var(--warn,#d6a23a) 45%,var(--line))} +.cp-profile-chip.too-big{opacity:.45;cursor:not-allowed;border-color:var(--line)} .profile-tag{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase; padding:3px 7px;border-radius:999px;background:color-mix(in oklch,var(--accent) 18%,transparent);color:var(--accent)} .profile-new{background:transparent;border:1px dashed var(--line-2);margin-top:8px;cursor:default; @@ -1578,15 +1578,16 @@

    New destination

    Output → Video → Keyframe Interval
    2 s
    Controls how fast delay cuts can happen.
    Output → Encoder
    x264 / NVENC
    Anything modern - InstantClone passes through untouched.
    Bitrate
    ≤ 80% of upload
    Leave headroom or the "Upload bottleneck" pill lights up.
    -
    Streaming to Kick?
    Disable B-frames
    Kick / AWS IVS rejects them. where?
    +
    Streaming to Kick?
    CBR + 2 s keyframe
    The rules that actually matter for Kick. details
    - Where to disable B-frames in OBS (and does it hurt Twitch?) + Kick requirements (and the B-frames myth)
    -

    OBS → Settings → Output → switch Output Mode to Advanced:

    -

    x264: B-frames0
    NVENC: Max B-frames0

    -

    Multistreaming Kick + Twitch/YouTube? OBS encodes once and InstantClone fans that one feed out, so you can't have B-frames for Twitch but not Kick. Good news: disabling B-frames is totally safe for Twitch and YouTube - they accept it fine (a hair less compression efficiency, invisible in practice). Kick is the strict one, so set B-frames to 0 and every destination is happy.

    -

    Delay and cuts work the same either way - InstantClone cuts on keyframes (IDRs), which B-frames don't affect. No B-frames actually makes cuts marginally cleaner.

    +

    Kick ingests on AWS IVS (low-latency). The rules it actually enforces: H.264, CBR rate control, keyframe interval 2 s, bitrate ≤ 8000 kbps, up to 60 fps.

    +

    B-frames: despite what a lot of older guides say, Kick's normal RTMP ingest (IVS low-latency, Main profile) accepts B-frames. The strict "no B-frames" rule is AWS IVS real-time / WHIP, a different ingest Kick doesn't use for standard OBS streaming. So you usually don't need to touch B-frames at all.

    +

    If Kick does reject your stream, set B-frames to 0 in OBS → Settings → Output (Advanced): x264 B-frames0, NVENC Max B-frames0. That's safe for Twitch and YouTube too. Note InstantClone forwards one encode without re-encoding, so B-frames can't be stripped for Kick alone - it's an OBS-side toggle that applies to every destination.

    +

    Using Twitch Enhanced Broadcasting? Twitch picks the encode settings for you (including B-frames), so your manual OBS B-frames box may not apply while EB is on.

    +

    Delay and cuts work the same either way - InstantClone cuts on keyframes (IDRs), which B-frames don't affect.

    @@ -2516,19 +2517,19 @@

    New destination

    // profiles without disarming first - arm() routes the request to // the live-adjust path. Only the "arming" mid-fill state and the // explicit current profile are disabled. - b.disabled = isCur || ds === 'arming'; - const needMB = tooBig ? requiredMBForMs(p.delay_ms) : 0; + // Too-big chips are quietly disabled with an explaining tooltip - the + // Profiles tab is where the "raise buffer" link lives. + b.disabled = isCur || ds === 'arming' || tooBig; if (tooBig){ - b.title = `${p.name}: buffer too small - needs ≥ ${needMB} MB at the current bitrate. Click to raise it.`; + const needMB = requiredMBForMs(p.delay_ms); + b.title = `${p.name}: buffer too small - needs ≥ ${needMB} MB at the current bitrate. Raise it in the Profiles or System tab.`; } else if (ds === 'active' && !isCur){ b.title = `Adjust to ${p.name}`; } else { b.title = `Arm: ${p.name}`; } - b.innerHTML = `${tooBig ? '🔒' : '⚡'}${fmtDelay(p.delay_ms)}`; - b.onclick = tooBig - ? () => jumpToBufferSetting(needMB) - : () => { $('delay-input').value = String(Math.round(p.delay_ms / 1000)); arm(p.delay_ms); }; + b.innerHTML = `${fmtDelay(p.delay_ms)}`; + b.onclick = () => { $('delay-input').value = String(Math.round(p.delay_ms / 1000)); arm(p.delay_ms); }; c.appendChild(b); }); } @@ -2954,17 +2955,16 @@

    New destination

    } const needMB = tooBig ? requiredMBForMs(p.delay_ms) : 0; if (tooBig){ - // Swap the bolt for a lock and offer a one-click fix instead of a - // dead red "needs X MB" label the user has to act on manually. - b.querySelector('.profile-bolt').innerHTML = - ''; - const fix = document.createElement('span'); - fix.className = 'profile-fix'; - fix.textContent = `Raise buffer → ${needMB} MB`; - fix.title = `This delay needs a ≥ ${needMB} MB buffer at the current bitrate (you have ${lastConfig?lastConfig.buffer_mb:'?'} MB ≈ ${fmtDelay(cap)}). Click to set it in System → Buffer, then Save + restart.`; - fix.onclick = (e) => { e.stopPropagation(); jumpToBufferSetting(needMB); }; - b.appendChild(fix); - b.title = `Buffer too small for ${fmtDelay(p.delay_ms)} at the current bitrate - click to raise it.`; + // Quiet: "needs X MB" with a small inline link to the buffer setting. + // Nothing auto-changes; the row itself isn't armable. + const warn = document.createElement('span'); warn.className = 'profile-warn'; + warn.append(`needs ≥ ${needMB} MB`); + const link = document.createElement('a'); + link.textContent = 'Raise buffer →'; + link.onclick = (e) => { e.stopPropagation(); jumpToBufferSetting(needMB); }; + warn.appendChild(link); + b.appendChild(warn); + b.title = `Buffer too small for ${fmtDelay(p.delay_ms)} at the current bitrate - needs ≥ ${needMB} MB (you have ${lastConfig?lastConfig.buffer_mb:'?'} MB ≈ ${fmtDelay(cap)}).`; } else if (ds === 'active' && !isCur){ b.title = `Adjust live delay to ${p.name}`; } else if (ds === 'armed' && !isCur){ @@ -2973,9 +2973,9 @@

    New destination

    const x = document.createElement('span'); x.className = 'ic-btn-tiny'; x.textContent = '✕'; x.onclick = (e) => { e.stopPropagation(); delProfile(p.name); }; b.appendChild(x); - // Too-big rows jump to the buffer setting (the only thing that unblocks - // them); usable rows arm the delay. - b.onclick = tooBig ? () => jumpToBufferSetting(needMB) : () => arm(p.delay_ms); + // Usable rows arm the delay; too-big rows do nothing on click (the + // inline link is the only action). + b.onclick = tooBig ? null : () => arm(p.delay_ms); c.appendChild(b); }); } @@ -3020,13 +3020,14 @@

    New destination

    showSubTab('system', 'buffer'); const el = $('s-bmb'); if (el){ - if (mb) el.value = mb; - renderBufferHint(); + // Navigate + highlight only - never overwrite the user's value. We + // just point them at the setting and tell them the target. el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); el.focus(); el.scrollIntoView({behavior:'smooth', block:'center'}); } - toast('Set the buffer size, hit Save, then restart to use this delay.', 'ok'); + toast(mb ? `This delay needs a ≥ ${mb} MB buffer. Set it here, Save, then restart.` + : 'Raise the buffer size here, Save, then restart.', 'ok'); } // ── Config / Settings ───────────────────────────────────────────────── From 4a8cab799703724c9f3e55066a186aa4113ec88a Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Wed, 1 Jul 2026 11:30:01 +0200 Subject: [PATCH 07/33] feat(web): grouped destination editor with close button; strip stray em dashes --- README.es.md | 2 +- README.md | 2 +- web/index.html | 32 ++++++++++++++++++++++++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.es.md b/README.es.md index ae3f0ea..91720c7 100644 --- a/README.es.md +++ b/README.es.md @@ -189,7 +189,7 @@ Un panel de 280×340 con el indicador, los controles de armar/activar/desarmar/c ### Overlays como browser-source -La pestaña **Overlay** es un Studio sin código. Elige un overlay ya hecho, copia su URL y métela en OBS — o abre cualquiera en el Studio para rediseñarlo (colores por estado, widgets, animaciones) y darle a **Save** o **Save as new**. +La pestaña **Overlay** es un Studio sin código. Elige un overlay ya hecho, copia su URL y métela en OBS - o abre cualquiera en el Studio para rediseñarlo (colores por estado, widgets, animaciones) y darle a **Save** o **Save as new**.
    http://127.0.0.1:7799/overlay/whisper.html
    diff --git a/README.md b/README.md index 6ee2c43..5605d37 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ A 280×340 panel with the readout, arm / activate / disarm / cut controls, and l ### Browser-source overlays -The **Overlay** tab is a no-code Studio. Pick a ready-made overlay, copy its URL, and drop it into OBS — or open any in the Studio to redesign it (per-state colours, widgets, animations) and **Save** or **Save as new**. +The **Overlay** tab is a no-code Studio. Pick a ready-made overlay, copy its URL, and drop it into OBS - or open any in the Studio to redesign it (per-state colours, widgets, animations) and **Save** or **Save as new**.
    http://127.0.0.1:7799/overlay/whisper.html
    diff --git a/web/index.html b/web/index.html index 9acca0c..9ac07a1 100644 --- a/web/index.html +++ b/web/index.html @@ -545,10 +545,26 @@ background:var(--surface-3);border:1px solid var(--line-2);font-size:22px;line-height:1} /* Destination edit form (slides in below grid) */ -.dest-form{margin-top:14px;padding:18px;background:linear-gradient(180deg,var(--bg-2),var(--surface)); +.dest-form{margin-top:14px;padding:20px 22px;background:linear-gradient(180deg,var(--bg-2),var(--surface)); border:1px solid color-mix(in oklch,var(--accent) 40%,var(--line));border-radius:var(--radius); - animation:paneIn .25s var(--ease-out) both;display:flex;flex-direction:column;gap:10px} -.dest-form h4{margin:0 0 4px;font-size:14px;font-weight:600} + box-shadow:0 12px 40px -18px color-mix(in oklch,var(--accent) 40%,transparent); + animation:paneIn .25s var(--ease-out) both;display:flex;flex-direction:column;gap:9px; + max-width:620px} +/* Header row: title + one-click close so editing feels like a focused + panel you can dismiss without scrolling to the Cancel button. */ +.dest-form-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:2px} +.dest-form h4{margin:0;font-size:15px;font-weight:600} +.dest-form-hint{font-size:11.5px;color:var(--fg-3);margin-top:3px;line-height:1.4} +.dest-form-x{flex:0 0 auto;width:28px;height:28px;border-radius:8px;border:1px solid var(--line); + background:var(--surface-3);color:var(--fg-3);font-size:13px;cursor:pointer;line-height:1; + transition:color .15s,background .15s,border-color .15s,transform .15s var(--ease-out)} +.dest-form-x:hover{color:var(--fg);background:color-mix(in oklch,var(--danger) 14%,var(--surface-3)); + border-color:color-mix(in oklch,var(--danger) 40%,var(--line));transform:rotate(90deg)} +/* Section separator: a quiet uppercase micro-label with a rule, so the + form reads as scannable groups instead of one long list. */ +.dfg-sep{display:flex;align-items:center;gap:10px;margin-top:12px;font-size:9.5px;font-weight:700; + letter-spacing:.16em;text-transform:uppercase;color:var(--fg-4)} +.dfg-sep::after{content:"";flex:1;height:1px;background:var(--line)} .dest-form label{display:block;margin-top:6px;font-size:11px;color:var(--fg-3);text-transform:uppercase; letter-spacing:.12em;font-weight:600} .dest-form .row{display:flex;gap:8px;margin-top:8px;align-items:center} @@ -1367,12 +1383,19 @@

    Welcome to InstantClone