diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ea934..48f28b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: name: test + clippy runs-on: windows-2022 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: install rust stable uses: dtolnay/rust-toolchain@stable @@ -70,7 +70,7 @@ jobs: runs-on: windows-2022 timeout-minutes: 10 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: download binary uses: actions/download-artifact@v8 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8fc8db8..6eab177 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -44,7 +44,7 @@ jobs: language: [rust] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03be9d8..8658765 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: test: runs-on: windows-2022 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt @@ -40,7 +40,7 @@ jobs: needs: test runs-on: windows-2022 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 19547db..2f9c78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,84 @@ 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. + +- Each destination card shows the **detected resolution + codec** of the + track it forwards (e.g. `1080x1920 · H.264`), so you can confirm the 9:16 + canvas is really flowing - and a **Dual Format** header pill lights up + whenever a vertical canvas is on the wire. + +- The vertical AVC feed is forwarded as **legacy AVC framing**, matching the + horizontal primary. YouTube's vertical ingest is viewable but flaky with + Enhanced-RTMP `avc1` (it aborts the connection on a ~11 s cycle); rewriting + the vertical track to legacy AVC keeps the connection stable. A vertical + destination also **leads with its own keyframe** after every (re)connect or + cut, so viewers get a clean picture instead of a mid-GOP glitch. + +### Clearer vertical / Dual Format status in the dashboard + +- The **Stream format** picker now **disables Vertical when no enabled Twitch + destination exists** (with an inline reason + a link to Twitch's guide). + Vertical has no source without Twitch Enhanced Broadcasting, so the choice + is no longer a silent dead-end. A destination already set to vertical is + never force-flipped - the option stays available in case its source returns. + +- A waiting vertical destination now says **why** it isn't live instead of a + generic "waiting": **Needs Twitch EB** (no Twitch source at all), **No + vertical canvas** (Enhanced Broadcasting is live but the 9:16 canvas isn't + on the wire), or **Waiting for Dual Format** (nothing streaming yet). The + copy makes clear Dual Format is **Enhanced Broadcasting + Aitum Vertical** + (per Twitch's guide), not a single OBS toggle, so the setup path is honest. + +- The per-destination status endpoint now parses each cached video sequence + header **once per poll** instead of once per destination - less work on the + frequently-polled dashboard path. + +### 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..8b702e2 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@ CI release GPL-3.0 -Binario +Binario Solo Windows @@ -45,11 +45,11 @@ Cuando ya lo tenía hecho, las piezas que de verdad quería eran: una activació - + - +
Binario1.2 MB
Binario1.3 MB
RSS inactivo~9 MB
Hilos1 tokio + 1 bandeja
Deps en runtimetokio, bytes, ureq
Tests200 / 200
Tests210 / 210
@@ -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.) + @@ -186,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
@@ -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. 210 tests, todos en verde.
@@ -284,19 +287,20 @@ 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`) + 210 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". -- **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 24fce1f..e4a5ec3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ CI release GPL-3.0 -Binary +Binary Windows only @@ -45,11 +45,11 @@ Once it existed, the parts I'd actually wanted ended up in: a real two-phase arm - + - +
Binary1.2 MB
Binary1.3 MB
Idle RSS~9 MB
Threads1 tokio + 1 tray
Runtime depstokio, bytes, ureq
Tests200 / 200
Tests210 / 210
@@ -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.) + @@ -186,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
@@ -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. 210 tests, all green.
@@ -284,19 +287,20 @@ 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`) + 210 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. -- **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/scripts/e2e.ps1 b/scripts/e2e.ps1 index 3ad5177..2df3c82 100644 --- a/scripts/e2e.ps1 +++ b/scripts/e2e.ps1 @@ -26,6 +26,14 @@ # include the keys the dashboard reads (phase, ingest_alive, # destinations, stats). Catches accidental endpoint removals # and dashboard-server contract drift. +# +# E. Delay + IDR-aligned cut - arm a 2 s delay against a LIVE stream, +# wait for the ring to fill (phase 'ready'), then activate. The +# delay must engage (phase 'active', current_delay_ms > 0) and the +# cut must NOT tear down the downstream: the sink keeps receiving +# frames past the cut on the SAME connection (exactly one publish +# accept). This is the core delay+cut feature the other scenarios +# run with delay=0 and never exercise on the wire. $ErrorActionPreference = "Stop" @@ -67,6 +75,20 @@ function Stop-Safe($proc) { try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {} } +# Poll GET /state until $predicate (a scriptblock taking the parsed state +# object) returns true, or the timeout elapses. Returns $true on match. +function Wait-State($predicate, $timeoutSec) { + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt $timeoutSec) { + try { + $s = Invoke-RestMethod "http://127.0.0.1:7799/state" -TimeoutSec 2 + if (& $predicate $s) { return $true } + } catch {} + Start-Sleep -Milliseconds 300 + } + return $false +} + function Push-FfmpegSource($durationSec, $bitrateK = 400) { # Synthetic source: 320x240 @ 15 fps, GOP 15 (1 IDR/sec), # H.264 + AAC, FLV container, RTMP. Returns the exit code. @@ -82,6 +104,21 @@ function Push-FfmpegSource($durationSec, $bitrateK = 400) { return $ff.ExitCode } +function Start-FfmpegSource($durationSec, $bitrateK = 400) { + # Non-blocking twin of Push-FfmpegSource: returns the process handle so a + # scenario can arm/activate while the stream is still running. + $ffArgs = @( + "-loglevel","error","-re", + "-f","lavfi","-i","testsrc=size=320x240:rate=15:duration=$durationSec", + "-f","lavfi","-i","sine=frequency=440:duration=$durationSec", + "-c:v","libx264","-preset","ultrafast","-g","15","-b:v","${bitrateK}k", + "-c:a","aac","-b:a","64k", + "-f","flv","rtmp://127.0.0.1:1935/live/stream" + ) + return Start-Process -FilePath "ffmpeg" -ArgumentList $ffArgs -PassThru -NoNewWindow ` + -RedirectStandardOutput "ffsrc.out" -RedirectStandardError "ffsrc.err" +} + # Track per-scenario results. Each scenario appends { name; failed[] }. $script:report = @() @@ -290,6 +327,64 @@ Run-Scenario "D -HTTP API smoke (arm/state/disarm/reset)" { } } +# --- Scenario E: delay + IDR-aligned cut ------------------------------ + +Run-Scenario "E -delay + IDR-aligned cut (arm/ready/activate)" { + param([ref]$failed) + Write-Config @(@{ id="e2e"; name="Sink"; platform="custom"; url="rtmp://127.0.0.1:1936/live"; key="stream" }) + $sink = Start-Process -FilePath $exe -ArgumentList "sink","--port","1936","--web-port","0","--temp","--max-mb","50" ` + -RedirectStandardOutput "E.sink.log" -RedirectStandardError "E.sink.err" -PassThru -NoNewWindow + $env:INSTANTCLONE_NO_BROWSER = "1" + $env:CONFIG_PATH = (Join-Path (Get-Location) "instantclone.config.json") + $ic = Start-Process -FilePath $exe -ArgumentList "--no-browser" ` + -RedirectStandardOutput "E.ic.log" -RedirectStandardError "E.ic.err" -PassThru -NoNewWindow + $ff = $null + try { + Assert (Wait-Port 1936 15) "sink never opened :1936" $failed + Assert (Wait-Port 1935 15) "instantclone never opened :1935" $failed + Assert (Wait-Http "http://127.0.0.1:7799/state" 15) "web UI never came up on :7799" $failed + if ($failed.Value.Count -gt 0) { return } + + # Continuous publisher for the whole scenario (18 s, non-blocking) so + # we can arm + activate while the stream is live. + $ff = Start-FfmpegSource 18 + Assert (Wait-State { param($s) $s.ingest_alive -eq $true } 15) "ingest never went live" $failed + if ($failed.Value.Count -gt 0) { return } + + # Arm a 2 s delay; the ring must fill to that depth -> phase 'ready'. + Invoke-RestMethod "http://127.0.0.1:7799/arm" -Method POST -Body "ms=2000" -ContentType "application/x-www-form-urlencoded" -TimeoutSec 5 | Out-Null + Assert (Wait-State { param($s) $s.phase -eq "ready" } 15) "phase never reached 'ready' after arming 2 s (ring did not fill)" $failed + if ($failed.Value.Count -gt 0) { return } + + # Snapshot how many 1 s window reports the sink has emitted so far. + Start-Sleep -Milliseconds 500 + $reportsBefore = ([regex]::Matches((Get-Content "E.sink.log" -Raw), '\(\d+ IDR\)')).Count + + # Activate: performs the first IDR-aligned cut (~2 s back in the ring). + Invoke-RestMethod "http://127.0.0.1:7799/activate" -Method POST -TimeoutSec 5 | Out-Null + Assert (Wait-State { param($s) $s.phase -eq "active" -and $s.current_delay_ms -gt 0 } 10) "delay never engaged after /activate (want phase 'active' + current_delay_ms > 0)" $failed + if ($failed.Value.Count -gt 0) { return } + + # Let the delayed stream flow well past the cut. + Start-Sleep -Seconds 4 + $sinkOut = Get-Content "E.sink.log" -Raw + $reportsAfter = ([regex]::Matches($sinkOut, '\(\d+ IDR\)')).Count + $accepts = ([regex]::Matches($sinkOut, "publish accepted")).Count + + # The cut must NOT tear down the downstream connection: a cut that + # shipped a referenceless P-frame would drop the sink -> reconnect, + # showing up as a second publish accept. + Assert ($accepts -eq 1) "sink saw $accepts publish accepts (expected exactly 1 - the cut broke the downstream connection)" $failed + # The delayed stream must keep flowing past the cut (more windows). + Assert ($reportsAfter -gt $reportsBefore) "sink stopped receiving after the cut ($reportsBefore -> $reportsAfter windows): the delayed stream stalled" $failed + # And real video (IDRs) must still be arriving, not just audio. + Assert ([regex]::IsMatch($sinkOut, "\([1-9]\d* IDR\)")) "sink saw no IDR windows across the delayed stream" $failed + } finally { + Stop-Safe $ff; Stop-Safe $ic; Stop-Safe $sink + Start-Sleep -Seconds 1 + } +} + # --- Report ----------------------------------------------------------- Write-Host "" 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..a0ea1ca 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. /// @@ -1550,6 +1588,13 @@ struct EgressState { // --- cut-check throttling --- last_cut_check: Instant, last_seen_target: u32, + /// Vertical egress only: after a (re)seed we may be pointed mid-GOP of + /// the vertical canvas (the cut/seed index is built from the HORIZONTAL + /// primary's keyframes). Emitting the vertical canvas's P-frames before + /// its first IDR makes strict ingests (YouTube) drop the stream ~10 s in, + /// waiting for a keyframe our GOP never leads with. While this is set we + /// hold vertical video until its first IDR, then stream normally. + awaiting_keyframe: bool, } impl EgressState { @@ -1565,6 +1610,7 @@ impl EgressState { last_publisher_token: 0, last_cut_check: now, last_seen_target: 0, + awaiting_keyframe: true, } } } @@ -1638,6 +1684,17 @@ async fn pace_and_send( // wire-format bug. match meta.kind { 8 => { + // A vertical destination whose 9:16 canvas isn't on the wire yet + // (Dual Format off) has `video_egress() == None`. Drop its AUDIO + // too - otherwise we'd feed the platform an audio-only stream + // with no video, which reads as a broken/black broadcast. It + // should send nothing until the canvas appears. + if dest.video_egress().is_none() { + state.consumer_seq = meta.seq + 1; + dest.consumer_seq + .store(state.consumer_seq, Ordering::Relaxed); + return Ok(()); + } // Mirror the per-destination video selection. Twitch // destinations get multi-track audio passthrough (VOD-audio // session); non-Twitch destinations drop OneTrack TrackId @@ -1680,11 +1737,48 @@ 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 egress = dest.video_egress(); + let dropped = match egress { + Some(e) => crate::h264::select_video_bytes(io_buf, e), + None => None, + }; + // Vertical keyframe-lead: after a (re)seed on the horizontal IDR + // index, hold this vertical canvas's P-frames until its first + // IDR, so YouTube et al. always get a keyframe-led stream and + // don't drop the socket ~10 s in. Only vertical tracks (t != 0) + // gate; the horizontal primary already seeds on its own IDR. + if state.awaiting_keyframe { + match egress { + // Vertical canvas: hold its P-frames until the first IDR. + Some(crate::h264::VideoEgress::Track(t)) if t != 0 => { + if dropped.is_some() { + // `meta.is_idr` is the any-track classification + // (set from classify_video_tag on ingest), which + // is exactly what we need here - it's true for the + // vertical track's own IDR, not just track 0. + if meta.is_idr { + state.awaiting_keyframe = false; + } else { + state.consumer_seq = meta.seq + 1; + dest.consumer_seq + .store(state.consumer_seq, Ordering::Relaxed); + return Ok(()); + } + } + // Not our track: falls through, dropped below. + } + // Horizontal (seeds on its own IDR) or Twitch passthrough: + // nothing to hold. + Some(_) => state.awaiting_keyframe = false, + // Vertical canvas not resolved yet: keep waiting; the + // video is dropped below regardless. + 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 @@ -1876,6 +1970,9 @@ async fn apply_cut( state.wall_anchor_input_ts = cut.target.ts_ms; state.consumer_seq = cut.target.seq; state.last_sent_input_ts = cut.target.ts_ms; + // The cut target is a horizontal-primary IDR; a vertical dest must + // re-lead with its own canvas keyframe before resuming (see EgressState). + state.awaiting_keyframe = true; // Update the per-dest atomic immediately so the ingest-side trim // sees the new (potentially backward) position right away and can't // evict tags we just rewound to. @@ -1948,30 +2045,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) ), @@ -2093,6 +2198,9 @@ fn reseed_after_publisher_change(state: &mut EgressState, new_idr: TagMeta) { state.wall_anchor_input_ts = new_idr.ts_ms; state.consumer_seq = new_idr.seq; state.last_sent_input_ts = new_idr.ts_ms; + // We reseed on a horizontal-primary IDR; a vertical dest must re-lead + // with its own canvas's keyframe before streaming (see EgressState). + state.awaiting_keyframe = true; } /// Resolve the next tag at `seq`. If the producer hasn't reached `seq` yet, diff --git a/src/h264.rs b/src/h264.rs index 4deadf7..e1ff12f 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -266,22 +266,75 @@ pub fn flatten_multitrack_video(payload: &[u8]) -> Option> { } } -/// Per-destination video-tag selection. Decides which bytes to put on -/// the wire given the destination's Enhanced Broadcasting policy: +/// Convert an Enhanced-RTMP `OneTrack` **AVC** video tag into a *legacy* +/// AVC FLV tag (`0x17`/`0x27` ...). Some ingests - notably YouTube's +/// vertical / secondary ingest - accept legacy AVC rock-solid but are +/// flaky with Enhanced-RTMP `avc1` framing: the stream is viewable yet +/// the socket is aborted on a ~11 s cycle. The horizontal primary reaches +/// those ingests as legacy AVC already (OBS emits it that way), so a +/// vertical destination - which only exists as a multi-track tag - is the +/// one that hits the E-RTMP path. Rewriting it to legacy AVC gives the +/// vertical feed the exact framing YouTube already ingests happily. /// -/// - `pass_through_multitrack = true`: Twitch destinations. Multi-track -/// tags go through bit-faithfully so Twitch's edge can populate the -/// transcoded ladder from the simulcast. -/// - `pass_through_multitrack = false`: every other RTMP ingest we -/// know of. Multi-track tags get flattened down to the primary -/// track (matching what beta.6 emitted from the ingest-side flatten), -/// single-track tags pass through unchanged. -/// -/// Single-track tags ALWAYS pass through unchanged regardless of the -/// flag - there's nothing to flatten and no destination policy that -/// would want them changed. Returns a borrow when no copy is needed -/// (the common case) and an owned `Vec` only when a flatten was -/// actually performed. +/// Returns `None` (caller falls back to the generic E-RTMP flatten) when +/// the tag is not a OneTrack AVC coded-frame / seq-header tag we can +/// safely convert - non-AVC codecs (HEVC/AV1 can't be legacy-framed), +/// ManyTracks bundles, metadata/colour packets, or truncated input. +fn onetrack_avc_to_legacy(payload: &[u8]) -> Option> { + // [b0][mt_header][FourCC(4)][TrackId(1)][body..] - at least 8 bytes. + if payload.len() < 8 { + return None; + } + let b0 = payload[0]; + if (b0 & 0x80) == 0 || (b0 & 0x0F) != 6 { + return None; // not an Enhanced-RTMP multi-track tag + } + let mt_header = payload[1]; + if (mt_header >> 4) & 0x0F != 0 { + return None; // only the OneTrack layout carries a single track here + } + if payload[2..6] != FOURCC_AVC1 { + return None; // legacy framing only covers AVC + } + let frame_type = (b0 >> 4) & 0x07; + let nested_pt = mt_header & 0x0F; + let body = &payload[7..]; + // FrameType 1 = keyframe -> 0x17, anything else -> inter -> 0x27. + let legacy_head = if frame_type == 1 { 0x17u8 } else { 0x27u8 }; + let mut out = Vec::with_capacity(body.len() + 5); + match nested_pt { + // SequenceStart: body IS the AVCDecoderConfigurationRecord. + 0 => { + out.push(0x17); // seq header is always sent on a keyframe head + out.push(0x00); // AVCPacketType: sequence header + out.extend_from_slice(&[0, 0, 0]); // composition time = 0 + out.extend_from_slice(body); + } + // CodedFrames: body = [CompositionTime(3)][length-prefixed NALs]. + 1 => { + if body.len() < 3 { + return None; + } + out.push(legacy_head); + out.push(0x01); // AVCPacketType: NALU + out.extend_from_slice(&body[0..3]); // preserve composition time + out.extend_from_slice(&body[3..]); + } + // CodedFramesX: body = [length-prefixed NALs], composition time 0. + 3 => { + out.push(legacy_head); + out.push(0x01); + out.extend_from_slice(&[0, 0, 0]); + out.extend_from_slice(body); + } + // Metadata / colour info (nested_pt 4) and anything else have no + // legacy AVC equivalent - drop rather than mix E-RTMP into an + // otherwise-legacy stream. + _ => return None, + } + Some(out) +} + /// Best-effort extraction of the Enhanced-RTMP TrackId carried by an /// Enhanced-RTMP `OneTrack`-layout multi-track video tag (the format /// OBS uses for Enhanced Broadcasting seq-headers, where each track's @@ -387,30 +440,403 @@ 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); + // checked_add on the crop pairs too: a malformed SPS can carry + // near-u32::MAX Exp-Golomb values, and a plain `+` would panic in + // debug / wrap in release, breaking the "never panics" contract. + width = width.saturating_sub(crop_unit_x.checked_mul(crop_left.checked_add(crop_right)?)?); + height = + height.saturating_sub(crop_unit_y.checked_mul(crop_top.checked_add(crop_bottom)?)?); + } + if width == 0 || height == 0 { + return None; + } + Some((width, height)) +} + +/// Best-effort codec of a sequence-header tag, across the framings OBS +/// emits (legacy AVC, Enhanced single-track, Enhanced OneTrack multi-track). +/// Used for the per-destination "1080x1920 · H.264" readout. Returns +/// `Unknown` for anything unrecognised or truncated; never panics. +pub fn seq_header_codec(payload: &[u8]) -> VideoCodec { + if payload.is_empty() { + return VideoCodec::Unknown; + } + let b0 = payload[0]; + if b0 & 0x80 == 0 { + // Legacy: low nibble is the CodecID; 7 = AVC is the only one we map. + return if b0 & 0x0F == 7 { + VideoCodec::Avc + } else { + VideoCodec::Unknown + }; + } + // Enhanced-RTMP: OneTrack multi-track carries the FourCC at bytes 2..6 + // (after the mt header), single-track at bytes 1..5. + let fourcc = if b0 & 0x0F == 6 { + payload.get(2..6) + } else { + payload.get(1..5) + }; + match fourcc { + Some(f) if f == FOURCC_AVC1 => VideoCodec::Avc, + Some(f) if f == FOURCC_HVC1 || f == FOURCC_HEV1 => VideoCodec::Hevc, + Some(f) if f == FOURCC_AV01 => VideoCodec::Av1, + Some(f) if f == FOURCC_VP09 => VideoCodec::Vp9, + _ => VideoCodec::Unknown, + } +} + +/// 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(()) + } +} + +/// Decide what video bytes to put on the wire for a destination given its +/// [`VideoEgress`] policy: +/// +/// - [`VideoEgress::Passthrough`] (Twitch): every tag goes through +/// bit-faithfully so Twitch's edge gets the full EB bundle it negotiated. +/// - [`VideoEgress::Track(n)`]: forward only OneTrack `TrackId == n` +/// (`0` = horizontal primary, `n>0` = the vertical-canvas primary), +/// dropping every other track. An AVC track is rewritten to *legacy* AVC +/// (see [`onetrack_avc_to_legacy`]); non-AVC codecs and ManyTracks +/// bundles fall back to the generic E-RTMP flatten. Single-track / legacy +/// tags pass through for `Track(0)` and are dropped for a vertical target. +/// +/// Returns `None` when the tag must be skipped on this destination, a +/// borrow when no copy is needed, and an owned `Vec` only when a rewrite or +/// flatten actually happened. 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. - if payload.len() >= 7 && (payload[1] >> 4) & 0x0F == 0 { + // 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. + let is_onetrack = payload.len() >= 7 && (payload[1] >> 4) & 0x0F == 0; + if is_onetrack { 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; + } + // Prefer legacy AVC framing for a OneTrack AVC track. YouTube's vertical + // ingest (and other secondary ingests) accept legacy AVC reliably but + // abort Enhanced-RTMP `avc1` on a ~11 s cycle; the horizontal primary + // already arrives as legacy, so this gives the vertical feed the same + // framing. Coded frames / seq headers convert; metadata / colour packets + // return None and are dropped rather than smuggling E-RTMP into an + // otherwise-legacy stream. Non-AVC codecs and ManyTracks bundles fall + // through to the generic E-RTMP flatten below. + if is_onetrack && payload[2..6] == FOURCC_AVC1 { + return onetrack_avc_to_legacy(payload).map(Cow::Owned); } match flatten_multitrack_video(payload) { Some(flat) => Some(Cow::Owned(flat)), @@ -898,8 +1324,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 +1341,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 +1357,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 +1375,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 +1398,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 +1414,326 @@ 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 sps_dimensions_huge_crop_returns_none_not_panic() { + // A malformed SPS can carry near-u32::MAX frame-crop Exp-Golomb + // values; the crop math must stay checked so this returns None + // instead of overflow-panicking in debug builds. + let mut w = BitWriter::new(); + w.put_bits(66, 8); // baseline profile (skips the high-profile block) + w.put_bits(0, 8); + w.put_bits(30, 8); + w.put_ue(0); // sps_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(39); // pic_width_in_mbs_minus1 + w.put_ue(21); // 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(1); // frame_cropping_flag + w.put_ue(4_000_000_000); // crop_left + w.put_ue(4_000_000_000); // crop_right -> left + right overflows u32 + w.put_ue(0); + w.put_ue(0); + w.put_bit(0); // vui_parameters_present_flag + let mut sps = vec![0x67u8]; + sps.extend(w.finish()); + let mut cfg = vec![1u8, 66, 0, 30, 0xFF, 0xE1]; + cfg.extend_from_slice(&(sps.len() as u16).to_be_bytes()); + cfg.extend_from_slice(&sps); + cfg.push(0); + let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; + tag.extend(cfg); + assert_eq!(sps_dimensions(&tag), None); + } + + #[test] + fn sps_and_selection_never_panic_on_fuzz() { + // Deterministic pseudo-random byte blobs, wrapped as AVC seq + // headers, pushed through the parser + selector. These run on + // hostile wire input, so the invariant is simply: never panic. + let mut seed: u32 = 0x9e3779b9; + for _ in 0..3000 { + seed = seed.wrapping_mul(1664525).wrapping_add(1013904223); + let n = (seed as usize % 48) + 1; + let mut blob = Vec::with_capacity(n); + let mut x = seed; + for _ in 0..n { + x = x.wrapping_mul(1103515245).wrapping_add(12345); + blob.push((x >> 16) as u8); + } + // Legacy AVC framing so the bytes reach the SPS Exp-Golomb path. + let mut tag = vec![0x17u8, 0x00, 0, 0, 0]; + tag.extend_from_slice(&blob); + let _ = sps_dimensions(&tag); + let _ = select_video_bytes(&tag, VideoEgress::Track((seed & 0xFF) as u8)); + let mut m = std::collections::BTreeMap::new(); + m.insert((seed & 0x7) as u8, tag); + let _ = detect_vertical_primary_track(&m); + } + } + + #[test] + fn seq_header_codec_detects_framings() { + assert_eq!( + seq_header_codec(&legacy_avc_seq_header(40, 30)), + VideoCodec::Avc + ); + assert_eq!( + seq_header_codec(&onetrack_avc_seq_header(1, 30, 40)), + VideoCodec::Avc + ); + // Enhanced single-track HEVC seq header (0x90 = IsEx|key|SequenceStart). + let mut hevc = vec![0x90u8]; + hevc.extend_from_slice(b"hvc1"); + assert_eq!(seq_header_codec(&hevc), VideoCodec::Hevc); + assert_eq!(seq_header_codec(&[]), VideoCodec::Unknown); + } + + #[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" + ); + } + + #[test] + fn select_video_bytes_vertical_avc_rewrites_to_legacy() { + // A OneTrack avc1 vertical track must reach a non-Twitch dest as + // LEGACY AVC (0x17/0x27 ...), not Enhanced-RTMP - YouTube's vertical + // ingest is flaky with E-RTMP avc1 and aborts on a ~11 s cycle. + + // Sequence header -> legacy AVC seq header, config record intact. + let seq = onetrack_avc_seq_header(1, 30, 40); + let out = select_video_bytes(&seq, VideoEgress::Track(1)) + .expect("vertical avc seq header forwards"); + assert_eq!(out[0], 0x17, "legacy AVC seq-header frame byte"); + assert_eq!(out[1], 0x00, "AVCPacketType: sequence header"); + assert_eq!(&out[2..5], &[0, 0, 0], "composition time zero"); + assert_eq!(&out[5..], &build_avc_config(30, 40)[..]); + + // CodedFramesX keyframe (no composition time) -> legacy 0x17 NALU. + let mut kf = vec![0x96u8, 0x03]; // key|Multitrack, OneTrack|CodedFramesX + kf.extend_from_slice(b"avc1"); + kf.push(1); + kf.extend_from_slice(&[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); // len-prefixed IDR + let out = select_video_bytes(&kf, VideoEgress::Track(1)).expect("keyframe forwards"); + assert_eq!(out[0], 0x17, "legacy keyframe head"); + assert_eq!(out[1], 0x01, "AVCPacketType: NALU"); + assert_eq!( + &out[2..5], + &[0, 0, 0], + "CodedFramesX carries composition time 0" + ); + assert_eq!(&out[5..], &[0, 0, 0, 5, 0x65, 1, 2, 3, 4]); + + // CodedFrames inter frame preserves the 3-byte composition time. + let mut inter = vec![0xA6u8, 0x01]; // inter|Multitrack, OneTrack|CodedFrames + inter.extend_from_slice(b"avc1"); + inter.push(1); + inter.extend_from_slice(&[0x00, 0x00, 0x10]); // composition time + inter.extend_from_slice(&[0, 0, 0, 3, 0x41, 9, 9]); // len-prefixed P NAL + let out = select_video_bytes(&inter, VideoEgress::Track(1)).expect("inter forwards"); + assert_eq!(out[0], 0x27, "legacy inter head"); + assert_eq!(out[1], 0x01); + assert_eq!( + &out[2..5], + &[0x00, 0x00, 0x10], + "composition time preserved" + ); + assert_eq!(&out[5..], &[0, 0, 0, 3, 0x41, 9, 9]); + + // A OneTrack avc1 metadata/colour packet has no legacy equivalent + // and is dropped rather than mixing E-RTMP into a legacy stream. + let mut meta = vec![0x96u8, 0x04]; // OneTrack | Metadata(4) + meta.extend_from_slice(b"avc1"); + meta.push(1); + meta.extend_from_slice(&[1, 2, 3, 4]); + assert!( + select_video_bytes(&meta, VideoEgress::Track(1)).is_none(), + "avc1 metadata packet must be dropped for a legacy vertical stream" + ); + } + // ── 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 +1830,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..5ec0754 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,53 @@ 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..63c708e 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 @@ -634,8 +715,14 @@ fn state_json( ) }).collect::>().join(","); + // A portrait canvas is present on the wire (Twitch Dual Format is live). + // Drives the header "Dual Format" pill. + let vertical_present = + crate::h264::detect_vertical_primary_track(&ctrl.ring.video_seq_headers.lock().unwrap()) + .is_some(); + format!( - r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"buffer_capacity_ms_est":{bc},"ingest_alive":{ia},"egress_alive":{ea},"destinations_alive":{dla},"destinations_total":{dlt},"buffer_building":{bb},"configured":{cfg},"obs_url":"{ou}","webhook_set":{ws},"video_codec":"{vc}","audio_codec":"{ac}","multitrack_video":{mtv},"multitrack_audio":{mta},"cpu_pct":{cp:.2},"rss_bytes":{rb},"publisher_token":{pt},"consumer_lag":{cl},"backpressure":{bp},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br}}},"destinations":[{dl}]}}"#, + r#"{{"phase":"{ph}","armed_delay_ms":{ad},"target_delay_ms":{td},"current_delay_ms":{cd},"buffer_fill_ms":{bf},"buffer_target_ms":{btm},"buffer_capacity_ms_est":{bc},"ingest_alive":{ia},"egress_alive":{ea},"destinations_alive":{dla},"destinations_total":{dlt},"buffer_building":{bb},"configured":{cfg},"obs_url":"{ou}","webhook_set":{ws},"video_codec":"{vc}","audio_codec":"{ac}","multitrack_video":{mtv},"multitrack_audio":{mta},"vertical_present":{vp},"cpu_pct":{cp:.2},"rss_bytes":{rb},"publisher_token":{pt},"consumer_lag":{cl},"backpressure":{bp},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br}}},"destinations":[{dl}]}}"#, ph = ctrl.phase(), ad = ctrl.armed_delay_ms(), td = ctrl.target_delay_ms(), @@ -661,6 +748,7 @@ fn state_json( ac = ctrl.audio_codec().label(), mtv = ctrl.multitrack_video(), mta = ctrl.multitrack_audio(), + vp = vertical_present, cp = cpu_pct, rb = rss_bytes, pt = ctrl.publisher_token(), @@ -684,7 +772,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 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} @@ -1429,6 +1517,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 +1964,8 @@ async fn post_destination_upsert( form.get("vod_audio_inject_eb").map(String::as_str), Some("on" | "true" | "1") ); + let stream_format = + normalize_stream_format(&platform, form.get("stream_format").map(String::as_str)); if name.trim().is_empty() { return ( @@ -1897,6 +1988,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 +2001,7 @@ async fn post_destination_upsert( youtube_ingest, vod_audio, vod_audio_inject_eb, + stream_format, }); } @@ -2032,6 +2125,27 @@ fn destinations_json(ctrl: &Controller, settings: &Arc>) .cloned() .unwrap_or_else(|| (id.into(), false, 0, 0, 0, 0, 0, 0)) }; + // Parse each cached video seq-header once per poll, not once per + // destination: the res/codec readout depends only on which TrackId a + // dest forwards, and horizontal dests all share track 0 while vertical + // dests share the one detected portrait primary. Keeps this + // frequently-polled endpoint off a per-dest lock + Exp-Golomb parse. + let readouts: std::collections::BTreeMap = { + let headers = ctrl.ring.video_seq_headers.lock().unwrap(); + headers + .iter() + .map(|(&track, h)| { + let res = crate::h264::sps_dimensions(h) + .map(|(w, hh)| format!("{}x{}", w, hh)) + .unwrap_or_default(); + let codec = match crate::h264::seq_header_codec(h) { + crate::h264::VideoCodec::Unknown => String::new(), + c => c.label().to_string(), + }; + (track, (res, codec)) + }) + .collect() + }; let mut out = String::from("["); for (i, d) in s.destinations.iter().enumerate() { if i > 0 { @@ -2039,8 +2153,43 @@ 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(); + // 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. + use std::sync::atomic::Ordering; + let vertical_canvas_present = ctrl + .destination_state(&d.id) + .vertical_primary_track + .load(Ordering::Relaxed) + != 0xFF; + let vertical_ready = if vertical { + vertical_canvas_present + } else { + true + }; + // Resolution + codec of the track this destination actually forwards + // (track 0 for horizontal/Twitch, the detected portrait primary for + // vertical), pulled from the per-poll readout map. Empty when the + // track isn't cached yet (non-AVC we can't measure, or vertical + // canvas not resolved -> target 0xFF isn't a key). + let target = if vertical { + ctrl.destination_state(&d.id) + .vertical_primary_track + .load(Ordering::Relaxed) + } else { + 0 + }; + let (video_res, video_codec) = readouts.get(&target).cloned().unwrap_or_default(); 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},"vertical_canvas_present":{vcp},"video_res":{vres},"video_codec":{vcod},"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 +2203,11 @@ 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, + vcp = vertical_canvas_present, + vres = json_escape_quoted(&video_res), + vcod = json_escape_quoted(&video_codec), ks = !d.stream_key.is_empty(), ur = json_escape_quoted(&redact_url(&url)), al = alive, @@ -2091,6 +2245,18 @@ fn generate_dest_id() -> String { format!("d{:x}", nanos as u64) } +/// Normalize the destination form's `stream_format` field. Only "vertical" +/// on a non-Twitch platform is honored; everything else (absent, "horizontal", +/// a typo, or ANY value on Twitch - which gets native dual-canvas passthrough) +/// resolves to the safe horizontal default. +fn normalize_stream_format(platform: &str, raw: Option<&str>) -> String { + if platform != "twitch" && raw == Some("vertical") { + "vertical".to_string() + } else { + "horizontal".to_string() + } +} + // ---------------------------------------------------------------------- // Pluggable overlays - files under settings.overlays_dir // ---------------------------------------------------------------------- @@ -3119,6 +3285,39 @@ mod tests { // 6a3990b (default flipped to off). Invisible until users // actually tried to enable the toggle on a fresh install. + #[test] + fn normalize_stream_format_rules() { + // Vertical honored on non-Twitch platforms. + assert_eq!( + normalize_stream_format("youtube", Some("vertical")), + "vertical" + ); + assert_eq!( + normalize_stream_format("kick", Some("vertical")), + "vertical" + ); + assert_eq!( + normalize_stream_format("custom", Some("vertical")), + "vertical" + ); + // Twitch is always horizontal (native dual-canvas), even if the form + // somehow carried "vertical". + assert_eq!( + normalize_stream_format("twitch", Some("vertical")), + "horizontal" + ); + // Everything else falls back to horizontal. + assert_eq!( + normalize_stream_format("youtube", Some("horizontal")), + "horizontal" + ); + assert_eq!(normalize_stream_format("youtube", None), "horizontal"); + assert_eq!( + normalize_stream_format("youtube", Some("garbage")), + "horizontal" + ); + } + #[test] fn apply_field_str_persists_tracing_enabled_value() { // The dispatch in post_config gates which keys reach this diff --git a/web/dock.html b/web/dock.html index 69c98dd..16310fa 100644 --- a/web/dock.html +++ b/web/dock.html @@ -1,67 +1,126 @@ InstantClone
-
- Idle - OBS +
+ Idle + Offline +
+
+
0.0s
+
-
0.0s
-
-
No source.
+
+
Waiting for your encoder…
@@ -83,6 +142,9 @@ $('d').addEventListener('input',renderCta); function toast(msg,kind){const t=$('toast');t.className='show '+(kind||'');t.textContent=msg;clearTimeout(t._h);t._h=setTimeout(()=>t.className='',1800)} function setPending(p){pending=p;setTimeout(()=>pending=null,1500);tick()} +function fmtRate(k){if(!k||k<=0)return '';return k>=1000?(k/1000).toFixed(2)+' Mbps':Math.round(k)+' kbps'} +// Crossfade helper so the sub-tip swaps smoothly instead of snapping. +function setTip(text){const el=$('sub');if(el.textContent===text)return;el.classList.add('fade');clearTimeout(el._t);el._t=setTimeout(()=>{el.textContent=text;el.classList.remove('fade')},140)} async function arm(ms){ setPending('arming'); const r=await fetchJ('/arm',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'ms='+ms}); @@ -145,28 +207,28 @@ cancel.hidden=false; } else { const ms=(+$('d').value||0)*1000; - if(!ingest){m.textContent='Waiting for OBS…';m.disabled=true} - else if(ms<=0){m.textContent='Set delay above';m.disabled=true} + if(!ingest){m.textContent='Waiting for encoder…';m.disabled=true} + else if(ms<=0){m.textContent='Set a delay above';m.disabled=true} else {m.innerHTML=` Arm ${(ms/1000).toFixed(0)}s`;m.classList.add('primary')} } } function applyState(j){ s=j; const ds=PHASE_TO_STATE[s.phase]||'off'; - // Phase pill - distinguishes idle vs passthrough vs arming/armed/active + // Phase chip - distinguishes idle vs passthrough vs arming/armed/active const phMap={off:s.ingest_alive?['live','Live']:['off','Idle'],arming:['arming','Buffering'],armed:['armed','Armed'],active:['active','Active']}; const [cls,txt]=phMap[ds]; $('ph').className='phase '+cls; $('ph').textContent=txt; - // OBS pill - alive vs not - $('obs').className='dot '+(s.ingest_alive?'on':'bad'); - $('obs-lbl').textContent=s.ingest_alive?'OBS':'no signal'; + // Source pill - encoder connected or not (any RTMP encoder, not just OBS) + $('src').className='pill '+(s.ingest_alive?'on':'bad'); + $('src-lbl').textContent=s.ingest_alive?'Live':'Offline'; // Current delay const nv=fmt(s.current_delay_ms); if(nv!==lastVal){$('v').textContent=nv;lastVal=nv; $('v').parentElement.classList.add('changing');setTimeout(()=>$('v').parentElement.classList.remove('changing'),350); } - // Bar + // Progress bar const target=s.armed_delay_ms||s.buffer_target_ms||0; const fill=$('bar'); fill.classList.remove('ready','active'); @@ -176,18 +238,28 @@ fill.style.width=pct+'%'; if(pct>=99)fill.classList.add('ready'); } else {fill.style.width='0%'} - // Sub-tip - $('sub').textContent = - !s.ingest_alive ? 'Point OBS to rtmp://…/live · stream key: live' : + // Egress glance: where the feed is going + live bitrate. Only meaningful + // once a source is connected. + const eg=$('eg'); + if(s.ingest_alive){ + const alive=s.destinations_alive||0, tot=s.destinations_total||0; + const rate=fmtRate(s.stats&&s.stats.bitrate_kbps); + let html=`${alive}/${tot||0} live`; + if(rate) html+=`·${rate} in`; + eg.innerHTML=html; eg.classList.remove('hide'); + } else { eg.classList.add('hide'); } + // Sub-tip (crossfaded) + setTip( + !s.ingest_alive ? 'Point your encoder at rtmp://…/live' : ds==='active' ? `Live ${(s.current_delay_ms/1000).toFixed(1)}s behind real time.` : ds==='armed' ? 'Buffer full. Hit Activate to go live with delay.' : ds==='arming' ? 'Filling buffer… you can cancel any time.' : - 'Passthrough - no delay applied.'; + 'Passthrough - no delay applied.'); renderCta(); } async function tick(){ const r=await fetchJ('/state'); - if(!r.ok||!r.j){$('obs').className='dot bad';$('obs-lbl').textContent='offline';return} + if(!r.ok||!r.j){$('src').className='pill bad';$('src-lbl').textContent='Offline';return} applyState(r.j); } // Prefer Server-Sent Events for state updates - one long-lived connection diff --git a/web/index.html b/web/index.html index 432674f..b856691 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; @@ -109,15 +119,31 @@ background:color-mix(in oklch,var(--danger) 8%,var(--surface-3))} .ic-pill.bad .dot{background:var(--danger)} .ic-pill.live .dot{background:var(--live);animation:pulse-ring 1.8s var(--ease-out) infinite} +/* Header status pills: muted directional icon + bold label + a value that + brightens when the pill is active, so "OBS Live" / "Egress 2/3 live" + reads at a glance and a dead state stays visually quiet. */ +.ic-pill-ic{width:13px;height:13px;flex:0 0 auto;opacity:.65;margin:0 -2px 0 -1px;transition:opacity .2s,color .2s} +.ic-pill.ok .ic-pill-ic{opacity:.9} +.ic-pill-k{font-weight:600} +.ic-pill-v{color:var(--fg-3);font-variant-numeric:tabular-nums;transition:color .2s} +.ic-pill.ok .ic-pill-v{color:var(--fg)} +/* Fixed width so "Idle" <-> "6.20 Mbps" doesn't resize the pill and shove + the neighbouring header pills sideways on connect/disconnect. */ +.ic-pill-rate{gap:0;min-width:86px;justify-content:center} +.ic-pill-rate .ic-pill-v{font-family:'JetBrains Mono',monospace;font-weight:500} +.ic-pill-rate .unit{color:var(--fg-3);font-size:11px;margin-left:1px} +/* Conditional header pills (codec / EB / backpressure / cap / update) are + toggled via [hidden]. When shown, display flips none->inline-flex, which + replays this entrance animation once - so they fade+scale in instead of + popping. (Exit stays instant; the appearance is the jarring part.) */ +#hdr-codec,#hdr-ebroadcast,#hdr-dualformat,#hdr-backpressure,#hdr-twitch-cap,#hdr-update{ + animation:pillIn .22s var(--ease-out)} +@keyframes pillIn{from{opacity:0;transform:scale(.82)}to{opacity:1;transform:none}} @keyframes pulse-ring{ 0%{box-shadow:0 0 0 0 color-mix(in oklch,var(--live) 60%,transparent)} 70%{box-shadow:0 0 0 8px color-mix(in oklch,var(--live) 0%,transparent)} 100%{box-shadow:0 0 0 0 color-mix(in oklch,var(--live) 0%,transparent)} } -.ic-bitrate{display:inline-flex;align-items:baseline;gap:6px;padding:6px 11px;border-radius:999px; - background:var(--surface-3);border:1px solid var(--line); - font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--fg);font-variant-numeric:tabular-nums} -.ic-bitrate .unit{color:var(--fg-3);font-size:11px} /* ── Card / labels / buttons / inputs (atoms) ───────────── */ .ic-card{background:linear-gradient(180deg,var(--surface-2),var(--surface)); @@ -179,7 +205,9 @@ .ic-check.on{background:var(--accent);border-color:transparent} /* Toast */ -.ic-toast-rail{position:fixed;right:24px;bottom:24px;z-index:500;display:flex;flex-direction:column;gap:8px;pointer-events:none} +/* Above every overlay (modals 1000, tour 300, studio 400) so a toast + fired while the destination modal is open isn't hidden behind it. */ +.ic-toast-rail{position:fixed;right:24px;bottom:24px;z-index:2000;display:flex;flex-direction:column;gap:8px;pointer-events:none} .ic-toast{background:var(--surface-2);border:1px solid var(--line-2);border-radius:10px; padding:10px 14px 10px 12px;display:flex;align-items:center;gap:10px; box-shadow:0 10px 30px rgba(0,0,0,.4);pointer-events:auto; @@ -325,7 +353,9 @@ .hero-delay-num.d-active{color:var(--live)} .hero-delay-unit{font-size:32px;color:var(--fg-3);font-weight:400} .hero-delay-of{font-size:22px;color:var(--fg-3);margin-left:6px} -.hero-delay-sub{color:var(--fg-3);font-size:13px;min-height:1.4em} +.hero-delay-sub{color:var(--fg-3);font-size:13px;min-height:1.4em; + transition:opacity .15s var(--ease-out),transform .15s var(--ease-out)} +.hero-delay-sub.subfade{opacity:0;transform:translateY(2px)} .arm-bar{height:4px;width:100%;background:var(--surface-3);border-radius:999px;overflow:hidden; margin-top:-4px;position:relative} .arm-bar-fill{height:100%;background:linear-gradient(90deg,color-mix(in oklch,var(--warn) 60%,transparent),var(--warn)); @@ -338,8 +368,11 @@ .cp-head{display:flex;align-items:baseline;justify-content:space-between} .cp-cap{color:var(--fg-4);font-size:11px} -.cp-cap-warn{margin-top:8px;padding:8px 10px;border:1px solid var(--danger);border-radius:6px;background:color-mix(in oklch,var(--danger) 8%,var(--bg-2));color:var(--fg-2);font-size:11.5px;line-height:1.45} +.cp-cap-warn{margin-top:8px;padding:8px 10px;border:1px solid var(--danger);border-radius:6px;background:color-mix(in oklch,var(--danger) 8%,var(--bg-2));color:var(--fg-2);font-size:11.5px;line-height:1.45;animation:softDrop .2s var(--ease-out)} .cp-cap-warn b{color:var(--danger);font-weight:600} +/* Shared soft entrance for elements that appear on state change (capacity + warning, restart banner) so they slide+fade in instead of popping. */ +@keyframes softDrop{from{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:none}} .cp-input.field-error,.cp-input-row.field-error{border-color:var(--danger);color:var(--danger)} .cp-input-row{display:flex;align-items:center;gap:6px;background:var(--bg-2); border:1px solid var(--line);border-radius:10px;padding:4px} @@ -369,6 +402,7 @@ border-color:color-mix(in oklch,var(--accent) 55%,transparent);color:var(--accent)} .cp-profile-chip:disabled{opacity:.4;cursor:not-allowed} .cp-cta{width:100%;padding:13px 18px;font-size:14px;border-radius:10px} +#cp-cancel{animation:pillIn .2s var(--ease-out)} .cp-cta-pulse{animation:cta-pulse 1.8s var(--ease-out) infinite} @keyframes cta-pulse{ 0%{box-shadow:0 0 0 1px color-mix(in oklch,var(--accent) 60%,transparent),0 0 0 0 var(--accent-glow)} @@ -446,34 +480,49 @@ /* ── Destinations ────────────────────────────── */ .dest-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(330px,1fr))} -.dcard{background:var(--bg-2);border:1px solid var(--line);border-radius:12px;padding:16px; - display:flex;flex-direction:column;gap:14px; - transition:border-color .2s var(--ease-out),transform .2s var(--ease-out); +.dcard{background:var(--bg-2);border:1px solid var(--line);border-radius:14px;padding:15px; + display:flex;flex-direction:column;gap:13px; + transition:border-color .2s var(--ease-out),box-shadow .2s var(--ease-out),opacity .25s var(--ease-out); position:relative;overflow:hidden} +/* Platform-coloured left edge (landing style): subtle when idle, solid + + glowing when the feed is live. */ .dcard::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px; - background:color-mix(in oklch,var(--dc,var(--fg-3)) 60%,transparent);opacity:0; - transition:opacity .2s var(--ease-out)} -.dcard:hover{border-color:var(--line-2)} -.dcard:hover::before{opacity:.85} -.dcard.alive::before{opacity:1;box-shadow:0 0 14px var(--dc,var(--accent))} -.dcard.off{opacity:.65} + background:var(--dc,var(--fg-3));opacity:.5; + transition:opacity .2s var(--ease-out),box-shadow .2s var(--ease-out)} +.dcard:hover{border-color:color-mix(in oklch,var(--dc,var(--accent)) 35%,var(--line-2)); + box-shadow:0 16px 40px -22px color-mix(in oklch,var(--dc,var(--accent)) 70%,transparent)} +.dcard:hover::before{opacity:.9} +.dcard.alive{border-color:color-mix(in oklch,var(--dc,var(--accent)) 28%,var(--line))} +.dcard.alive::before{opacity:1;box-shadow:0 0 16px var(--dc,var(--accent))} +.dcard.off{opacity:.55} +.dcard.off::before{opacity:.2} .dcard.platform-twitch {--dc:#a78bfa} .dcard.platform-youtube {--dc:#fda4af} .dcard.platform-kick {--dc:#86efac} .dcard.platform-trovo {--dc:#67e8f9} .dcard.platform-restream {--dc:#5eead4} .dcard.platform-custom {--dc:#fcd34d} -.dcard-head{display:flex;align-items:center;gap:12px} -.dcard-icon{width:36px;height:36px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center; - background:color-mix(in oklch,var(--dc,var(--fg-3)) 14%,var(--surface-3)); - border:1px solid color-mix(in oklch,var(--dc,var(--fg-3)) 35%,transparent); +.dcard-head{display:flex;align-items:center;gap:9px} +.dcard-icon{width:34px;height:34px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center; + background:color-mix(in oklch,var(--dc,var(--fg-3)) 16%,var(--surface-3)); + border:1px solid color-mix(in oklch,var(--dc,var(--fg-3)) 38%,transparent); color:var(--dc,var(--fg-3));font-weight:700;font-size:14px;flex:0 0 auto} -.dcard-name{font-size:14px;font-weight:600;color:var(--fg)} -.dcard-host{font-size:11px;color:var(--fg-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px; +.dcard-id{flex:1;min-width:0} +.dcard-name{font-size:14px;font-weight:600;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dcard-host{font-size:11px;color:var(--fg-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px; 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} +/* Enable/disable toggle - platform-coloured when on, matching the landing. */ +.dcard-switch{appearance:none;width:34px;height:19px;background:var(--surface-3);border:1px solid var(--line); + border-radius:999px;position:relative;cursor:pointer;flex:0 0 auto;padding:0; + transition:background .2s var(--ease-out),border-color .2s var(--ease-out)} +.dcard-switch>span{position:absolute;top:1px;left:1px;width:15px;height:15px;border-radius:999px; + background:var(--fg-3);transition:transform .2s var(--ease-spring),background .2s var(--ease-out);display:block} +.dcard-switch.on{background:color-mix(in oklch,var(--dc,var(--accent)) 80%,transparent);border-color:transparent} +.dcard-switch.on>span{transform:translateX(15px);background:#fff} +.dcard-status{display:inline-flex;align-items:center;justify-content:center;gap:5px;font-size:11px;font-weight:500; + padding:3px 8px;border-radius:999px;background:var(--surface-3);border:1px solid var(--line); + color:var(--fg-3);flex:0 0 auto;min-width:84px; + 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,20 +531,57 @@ 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)} -.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} -.dcard-rate-row{display:flex;align-items:baseline;gap:4px} -.dcard-rate{font-size:22px;font-weight:500;color:var(--fg);letter-spacing:-.02em; +/* "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)} +/* Orientation indicator: small glyph in the head; hover/focus shows the + full format + Dual Format requirement via the native tooltip. */ +.dcard-format{display:inline-flex;align-items:center;justify-content:center; + width:24px;height:24px;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:scale(1.1);color:var(--fg); + border-color:color-mix(in oklch,var(--fg-3) 45%,var(--line))} +.dcard-format svg{width:14px;height:14px;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))} +/* Inset "screen": a darker panel holding the live bitrate + trace, exactly + like the landing cards. The sparkline bleeds to the screen's edges. */ +.dcard-screen{background:var(--bg-1);border:1px solid color-mix(in oklch,var(--line) 65%,transparent); + border-radius:11px;padding:12px 14px 0;overflow:hidden;position:relative} +.dcard-body{display:flex;align-items:baseline;justify-content:space-between;gap:10px} +.dcard-rate-row{display:flex;align-items:baseline;gap:5px} +.dcard-rate{font-size:24px;font-weight:600;color:var(--fg);letter-spacing:-.02em;line-height:1; font-family:'JetBrains Mono',monospace;font-variant-numeric:tabular-nums} .dcard-rate-u{font-size:11px;color:var(--fg-3);font-family:'JetBrains Mono',monospace} -.dcard-spark{width:100%;height:54px;display:block} -.dcard-foot{display:flex;align-items:center;gap:8px;padding-top:10px;border-top:1px solid var(--line)} -.dcard-foot-meta{font-size:11px;color:var(--fg-4);font-family:'JetBrains Mono',monospace} -.dcard-link{appearance:none;background:transparent;border:0;display:inline-flex;align-items:center;gap:5px; - color:var(--fg-3);font-size:11.5px;cursor:pointer;padding:4px 6px;border-radius:6px; - transition:all .15s var(--ease-out)} -.dcard-link:hover{color:var(--danger);background:color-mix(in oklch,var(--danger) 8%,transparent)} +.dcard-foot-meta{font-size:10px;color:var(--fg-4);font-family:'JetBrains Mono',monospace;white-space:nowrap} +/* Detected resolution + codec of the forwarded track (e.g. 1080x1920 · H.264). */ +.dcard-res{font-size:10px;color:var(--fg-4);font-family:'JetBrains Mono',monospace;margin-top:3px;letter-spacing:.02em} +.dcard-spark{display:block;width:calc(100% + 28px);height:40px;margin:6px -14px 0} +/* Edit / remove: subtle glass buttons over the trace, revealed on hover. */ +/* Reveal via slide + visibility, NOT opacity. Animating opacity on a + backdrop-filter element makes Chromium skip the blur mid-transition and + snap it in at the end (the "pop"); keeping opacity fixed means the glass + blur is live the whole time. Revealed on card hover, or when an action + button itself is focused (keyboard) - deliberately NOT the card's + :focus-within, so clicking the toggle switch doesn't pin them open. */ +.dcard-actions{position:absolute;right:9px;bottom:9px;display:flex;gap:5px;z-index:2; + transform:translateY(calc(100% + 14px));pointer-events:none; + transition:transform .22s var(--ease-out)} +.dcard:hover .dcard-actions,.dcard-actions:focus-within{transform:none;pointer-events:auto} +.dcard-act{width:27px;height:27px;border-radius:8px;border:1px solid var(--line);cursor:pointer; + display:inline-flex;align-items:center;justify-content:center;font-size:12px;color:var(--fg-2); + background:color-mix(in oklch,var(--bg-1) 52%,transparent); + backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px); + transition:color .15s,background .15s,border-color .15s} +.dcard-act:hover{color:var(--fg);border-color:var(--line-2);background:color-mix(in oklch,var(--bg-1) 78%,transparent)} +.dcard-act.dcard-del:hover{color:var(--danger);border-color:color-mix(in oklch,var(--danger) 45%,var(--line))} .dest-add{appearance:none;background:transparent;border:1px dashed var(--line-2);border-radius:12px; color:var(--fg-3);font-size:13px;font-weight:500;cursor:pointer; display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px; @@ -505,11 +591,53 @@ .dest-add-icon{width:36px;height:36px;border-radius:999px;display:inline-flex;align-items:center;justify-content:center; 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)); +/* Destination editor - a centred floating modal (see .ic-modal shell). + Scrolls internally when the VOD/EB section makes it tall. */ +.dest-form{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 24px 60px -20px rgba(0,0,0,.6); + animation:modalPop .2s var(--ease-out) both;display:flex;flex-direction:column;gap:9px; + position:relative;width:min(600px,100%);max-height:min(90vh,860px);overflow-y:auto} +@keyframes modalPop{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}} +/* 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)} +/* VOD audio + EB block - kept calm and progressive (details behind + disclosures) so it reads as one toggle with an optional extra. */ +.dest-vod{margin-top:12px;padding:13px 15px;border:1px solid var(--line);border-radius:11px;background:var(--bg-1)} +/* NOTE: `.dest-form label` (uppercase, letter-spaced, block, muted) is more + specific than a bare `.dest-vod-toggle`, so these resets are scoped with + `.dest-form` to win, and title/desc reset explicitly too. Without this the + whole block rendered UPPERCASE and stacked. */ +.dest-form .dest-vod-toggle{display:flex;align-items:flex-start;gap:10px;margin:0;cursor:pointer; + text-transform:none;letter-spacing:normal;font-weight:400;font-size:13px;color:var(--fg-2)} +.dest-form .dest-vod-toggle input{margin-top:2px;flex:0 0 auto;cursor:pointer} +.dest-vod-title{font-weight:600;color:var(--fg);font-size:13px;text-transform:none;letter-spacing:normal} +.dest-vod-title .muted{font-weight:400} +.dest-vod-desc{font-size:11.5px;margin-top:3px;line-height:1.5;color:var(--fg-3);text-transform:none;letter-spacing:normal} +.dest-vod details{margin-top:10px} +.dest-vod details>summary{font-size:11px;color:var(--accent);cursor:pointer;user-select:none;list-style:none} +.dest-vod details>summary::-webkit-details-marker{display:none} +.dest-vod details>summary::before{content:"▸ ";color:var(--fg-4)} +.dest-vod details[open]>summary::before{content:"▾ "} +.dest-vod details ol{margin:8px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--fg-2)} +.dest-vod details>div{margin-top:6px;font-size:11px;line-height:1.6;color:var(--fg-2)} +.dest-eb{margin-top:13px;padding-top:13px;border-top:1px solid var(--line)} +.dest-eb-title{font-weight:600;color:var(--fg);font-size:12.5px;text-transform:none;letter-spacing:normal} +.dest-eb-title .tag{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--warn,#d6a23a);margin-left:7px} +.dest-eb-desc{font-size:11.5px;margin-top:4px;line-height:1.5;color:var(--fg-3);text-transform:none;letter-spacing:normal} .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} @@ -520,8 +648,16 @@ /* ── 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),background .18s var(--ease-out),box-shadow .18s var(--ease-out)} +/* Elevation via shadow + border only - no position change, so the element + never moves out from under the cursor (no hover flicker). */ +.stat-card:hover{border-color:color-mix(in oklch,var(--accent) 32%,var(--line-2)); + box-shadow:0 10px 26px -14px color-mix(in oklch,var(--accent) 55%,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} @@ -529,13 +665,21 @@ .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),background .18s var(--ease-out),box-shadow .18s var(--ease-out)} +.metric-mini:hover{border-color:color-mix(in oklch,var(--accent) 35%,var(--line-2)); + background:color-mix(in oklch,var(--accent) 4%,var(--bg-2)); + box-shadow:0 8px 20px -14px color-mix(in oklch,var(--accent) 55%,transparent)} +.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)} @@ -883,10 +1027,24 @@ 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} +/* 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-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} +.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:.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)} @@ -944,7 +1102,8 @@ .restart-banner{padding:10px 14px;border-radius:8px; background:color-mix(in oklch,var(--warn) 8%,var(--bg-2)); border:1px solid color-mix(in oklch,var(--warn) 30%,transparent); - color:var(--warn);font-size:12.5px;display:flex;align-items:center;gap:10px;justify-content:space-between} + color:var(--warn);font-size:12.5px;display:flex;align-items:center;gap:10px;justify-content:space-between; + animation:softDrop .25s var(--ease-out)} .restart-banner.hidden{display:none} /* Unsaved-changes cue on the Studio Save button. */ .st-save-dirty{box-shadow:0 0 0 2px color-mix(in oklch,var(--accent) 55%,transparent);font-weight:700} @@ -1124,11 +1283,22 @@ made by s1moscs
- OBS- - Egress- - - kbps + + + + OBSOffline + + + + + EgressOff + + + Idle + + @@ -1290,13 +1460,22 @@

Welcome to InstantClone

-