Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Cuando ya lo tenía hecho, las piezas que de verdad quería eran: una activació
<tr><td><b>RSS inactivo</b></td><td align="right"><code>~9 MB</code></td></tr>
<tr><td><b>Hilos</b></td><td align="right"><code>1 tokio + 1 bandeja</code></td></tr>
<tr><td><b>Deps en runtime</b></td><td align="right"><code>tokio, bytes, ureq</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>197 / 197</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>200 / 200</code></td></tr>
</table>

</td>
Expand Down Expand Up @@ -276,15 +276,15 @@ 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.

<br/>

<div align="center"><img src="docs/divider.svg" alt="" width="100%"/></div>

## 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**

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Once it existed, the parts I'd actually wanted ended up in: a real two-phase arm
<tr><td><b>Idle RSS</b></td><td align="right"><code>~9 MB</code></td></tr>
<tr><td><b>Threads</b></td><td align="right"><code>1 tokio + 1 tray</code></td></tr>
<tr><td><b>Runtime deps</b></td><td align="right"><code>tokio, bytes, ureq</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>197 / 197</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>200 / 200</code></td></tr>
</table>

</td>
Expand Down Expand Up @@ -276,15 +276,15 @@ 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.

<br/>

<div align="center"><img src="docs/divider.svg" alt="" width="100%"/></div>

## 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**

Expand Down
65 changes: 65 additions & 0 deletions src/h264.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
18 changes: 15 additions & 3 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2669,7 +2669,7 @@ <h4 id="dest-form-title">New destination</h4>
// 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 = '<span class="err">Name required</span>'; return; }
if (!name){ $('dest-form-msg').innerHTML = '<span class="err">Name required</span>'; return false; }
const body = new URLSearchParams({
name, platform, custom_egress_url:custom, enabled:enabled?'on':'off',
twitch_ingest:ingest, youtube_ingest:yingest,
Expand All @@ -2679,8 +2679,9 @@ <h4 id="dest-form-title">New destination</h4>
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 = '<span class="err">'+j.error+'</span>';
if (j.ok){ toast('Saved','ok'); cancelDestForm(); loadDestinations(); loadConfig(); return true; }
$('dest-form-msg').innerHTML = '<span class="err">'+j.error+'</span>';
return false;
}
async function toggleDest(d){
// Preserve every per-destination field on a toggle - the form
Expand Down Expand Up @@ -3221,7 +3222,18 @@ <h4 id="dest-form-title">New destination</h4>
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();
Expand Down