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 |
| Hilos | 1 tokio + 1 bandeja |
| Deps en runtime | tokio, bytes, ureq |
-| Tests | 197 / 197 |
+| Tests | 200 / 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 |
| Threads | 1 tokio + 1 tray |
| Runtime deps | tokio, bytes, ureq |
-| Tests | 197 / 197 |
+| Tests | 200 / 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 |
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 @@
// 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 @@
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 @@
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();