From a0707a33c3c064f157c2dc7e737cdb22302f7230 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Fri, 26 Jun 2026 15:11:38 +0200 Subject: [PATCH 1/2] test: confirm Twitch Dual Format dual-canvas passthrough Dual Format (horizontal + vertical canvas over one Enhanced Broadcasting RTMP connection) already works through the proxy with no code changes: canvas_index lives only in the GetClientConfiguration JSON, and on the wire the vertical canvas is just additional TrackIds that the existing pass_through_multitrack path forwards bit-faithfully. Verified in production against real Twitch (landscape x4 + portrait x3, HEVC vertical primary, cuts stable). Add regression tests pinning the behavior: - forwards both canvases to Twitch - keeps horizontal, drops vertical for non-Twitch - documents that cuts anchor to the horizontal canvas (the one invariant a future canvas-aware cut would have to revisit) Bump test count 197 -> 200 (EN + ES). --- README.es.md | 6 ++--- README.md | 6 ++--- src/h264.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/README.es.md b/README.es.md index 58935a0..f24b2dd 100644 --- a/README.es.md +++ b/README.es.md @@ -49,7 +49,7 @@ Cuando ya lo tenía hecho, las piezas que de verdad quería eran: una activació RSS inactivo~9 MB Hilos1 tokio + 1 bandeja Deps en runtimetokio, bytes, ureq -Tests197 / 197 +Tests200 / 200 @@ -276,7 +276,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. 197 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, 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.
@@ -284,7 +284,7 @@ Sin npm, sin submódulos, sin SDK de plataforma. El HTML del panel se minifica y ## Estado -**Listo para uso diario en Windows.** Lo uso en mis propios directos. CI corre fmt + clippy (con `-D warnings`) + 197 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`) + 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). **Lo que está sólido** diff --git a/README.md b/README.md index 4325ebe..24fce1f 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Once it existed, the parts I'd actually wanted ended up in: a real two-phase arm Idle RSS~9 MB Threads1 tokio + 1 tray Runtime depstokio, bytes, ureq -Tests197 / 197 +Tests200 / 200 @@ -276,7 +276,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. 197 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, 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.
@@ -284,7 +284,7 @@ No npm. No submodules. No platform SDKs. The dashboard HTML is minified + gzippe ## Status -**Daily-driver ready on Windows.** I use it on my own streams. CI runs fmt + clippy (with `-D warnings`) + 197 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`) + 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). **What's solid** diff --git a/src/h264.rs b/src/h264.rs index 92f5145..4deadf7 100644 --- a/src/h264.rs +++ b/src/h264.rs @@ -952,6 +952,47 @@ mod tests { } } + // ── Twitch Dual Format (dual canvas) forwarding ────────────────── + // + // Dual Format = a horizontal (canvas 0) and a vertical (canvas 1) + // layout sent together over the one Enhanced Broadcasting RTMP + // connection. `canvas_index` lives only in the GetClientConfiguration + // JSON; on the wire the canvases are just more TrackIds. So the + // existing per-track passthrough already carries the vertical canvas: + // these tests pin that down explicitly with dual-format framing. + + #[test] + fn select_video_bytes_forwards_dual_canvas_to_twitch() { + // Horizontal primary (TrackId 0) and a vertical-canvas rung + // (TrackId 1) both reach Twitch byte-for-byte. Nothing about a + // second canvas changes the Twitch passthrough contract. + 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"); + assert_eq!(twitch.as_ref(), tag.as_slice()); + assert!(matches!(twitch, std::borrow::Cow::Borrowed(_))); + } + } + + #[test] + fn select_video_bytes_keeps_horizontal_drops_vertical_for_non_twitch() { + // A non-Twitch destination (YouTube/Kick) running alongside a + // Dual Format Twitch session keeps the horizontal primary and + // drops the vertical canvas - it has no concept of a second + // canvas, and TrackId != 0 is exactly the vertical rungs. + 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(), + "horizontal canvas (TrackId 0) must reach non-Twitch" + ); + assert!( + select_video_bytes(&vertical, false).is_none(), + "vertical canvas (TrackId 1) must be dropped for non-Twitch" + ); + } + // ── is_primary_video_idr: cut-target gate for EB ladder cuts ── // // Regression test set for the v0.1.3 EB pixel-glitch fix. Before @@ -997,6 +1038,30 @@ mod tests { } } + #[test] + fn is_primary_video_idr_anchors_dual_format_cuts_to_horizontal_canvas() { + // Dual Format: the cut-point index is built only from the + // horizontal primary (TrackId 0); the vertical canvas's primary + // (a nonzero TrackId) is NOT a cut anchor. That means a Cut lands + // cleanly on the vertical canvas ONLY IF OBS aligned both + // canvases' keyframes to the same input timestamp - which it does + // when both encoders share keyint, but that is an OBS behaviour we + // can't prove from inside this crate (it needs one real capture). + // This test pins our half of the contract: cuts are anchored to + // the horizontal canvas. If a future change must make cuts + // canvas-aware, this is the regression guard that has to flip. + let horizontal_idr = enhanced_rtmp_onetrack_video_bytes_track(0); + let vertical_idr = enhanced_rtmp_onetrack_video_bytes_track(1); + assert!( + is_primary_video_idr(&horizontal_idr), + "horizontal canvas IDR must be a cut anchor" + ); + assert!( + !is_primary_video_idr(&vertical_idr), + "vertical canvas IDR must not (yet) be an independent cut anchor" + ); + } + #[test] fn is_primary_video_idr_rejects_enhanced_p_frame() { // Enhanced-RTMP single-track inter-frame: IsEx | FrameType=2 | From 628008e82ee4f796004a71ac951f2b2f80d1a1f0 Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Fri, 26 Jun 2026 15:11:38 +0200 Subject: [PATCH 2/2] fix(web): save destination before launching OBS for EB+VOD The "Launch OBS for EB + VOD" button's visibility is driven by the live VOD checkbox, but VOD only takes effect on save: saving is what writes OBS's EnableCustomServerVodTrack gate (user.ini) and flips InstantClone's VOD-track forwarding. Clicking Launch on unsaved state spawned OBS with EB but no VOD - a silent mismatch. launchObsWithEb() now persists the form first and only launches if the save succeeded; saveDestForm() returns a success boolean for that gate. --- web/index.html | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/web/index.html b/web/index.html index f7897f1..432674f 100644 --- a/web/index.html +++ b/web/index.html @@ -2669,7 +2669,7 @@

New destination

// enabled it then switched platform without the change handler // running. const vodAudio = platform === 'twitch' && $('dest-form-vod-audio').checked; - if (!name){ $('dest-form-msg').innerHTML = 'Name required'; return; } + if (!name){ $('dest-form-msg').innerHTML = 'Name required'; return false; } const body = new URLSearchParams({ name, platform, custom_egress_url:custom, enabled:enabled?'on':'off', twitch_ingest:ingest, youtube_ingest:yingest, @@ -2679,8 +2679,9 @@

New destination

if (key) body.set('stream_key', key); const r = await fetch('/destinations', {method:'POST', body}); const j = await r.json(); - if (j.ok){ toast('Saved','ok'); cancelDestForm(); loadDestinations(); loadConfig(); } - else $('dest-form-msg').innerHTML = ''+j.error+''; + if (j.ok){ toast('Saved','ok'); cancelDestForm(); loadDestinations(); loadConfig(); return true; } + $('dest-form-msg').innerHTML = ''+j.error+''; + return false; } async function toggleDest(d){ // Preserve every per-destination field on a toggle - the form @@ -3221,7 +3222,18 @@

New destination

const btn = $('obs-eb-launch-btn'); const msg = $('obs-eb-launch-msg'); if (!btn || btn.disabled) return; + // Persist the form before launching. The VOD toggle only takes effect + // on save: that's what writes OBS's `EnableCustomServerVodTrack` gate + // (user.ini) and flips InstantClone's VOD-track forwarding. Launching + // on unsaved state spawns OBS with EB but no VOD - the silent mismatch + // this button used to allow. Save first, and only launch if it stuck. btn.disabled = true; + if (msg) msg.textContent = 'Saving destination…'; + if (!(await saveDestForm())){ + if (msg) msg.textContent = 'Fix the destination form, then launch.'; + btn.disabled = false; + return; + } if (msg) msg.textContent = 'Launching…'; try { const r = await (await fetch('/obs/launch-with-eb', {method:'POST'})).json();