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
36 changes: 35 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ All notable changes will land here. Format loosely follows

## [Unreleased]

### "Cut after this airs" - scheduled safe cut

**Stop counting your delay down in your head.** With a delay active, a new
**⏱ Cut after this airs** button appears under Cut in the dashboard. Press
it the moment your match reaction ends: InstantClone records that exact
live-edge timestamp, lets everything up to it reach your viewers, then
fires the normal IDR-aligned cut to live on its own. Built for competitive
streamers who finish a match on a 30 s delay and want the win/lose
reaction to air in full before snapping back - without the mental
countdown or the risk of clipping the reaction short.

How it behaves:

- The cut fires only when the **slowest** live destination has aired past
the mark, so a faster destination can never cut a slower one short of
it. Exactly one pump fires the cut (compare-exchange), and it rides the
same IDR-aligned, connection-preserving machinery as a manual Cut - the
new e2e scenario F verifies the downstream connection survives.
- While the mark is pending, the button becomes a live countdown
("auto-cut in ~27s"); tapping it again cancels without cutting. A
manual Cut, a disarm, or a fresh OBS publish session all clear the
mark (a mark on a dead session's timeline could never be reached).
- New HTTP endpoints: `POST /cut-after` (409 when no delay is active) and
`POST /cut-after/cancel`; `/state` gains `safe_cut_pending` +
`safe_cut_remaining_ms`.
- The setup wizard and the onboarding tour both explain the flow, and the
button carries a hovercard with the press → airs → auto-cut timeline.

### Tests

- Added a unit suite for the RTMP chunk-stream layer (`rtmp/chunk.rs`),
Expand All @@ -18,7 +46,13 @@ All notable changes will land here. Format loosely follows
assert an error is returned rather than a panic.
- Added pure-function tests for `trace::hex_prefix` (formatting, boundary,
and truncation-suffix behaviour).
- Test count: 210 -> 229, all green.
- Added 5 controller tests for the scheduled safe cut: refusal without an
active delay, schedule/cancel round-trip, slowest-consumer firing gate,
clearing on manual cut / disarm, and clearing on publisher reconnect.
- New e2e scenario F drives `/cut-after` against a live ffmpeg stream:
409 refusal, cancel-without-cutting, auto-fire back to passthrough, and
the sink's connection surviving the auto-cut.
- Test count: 210 -> 234, all green.

## [0.1.9] - Vertical output + VOD/EB convenience

Expand Down
10 changes: 6 additions & 4 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>229 / 229</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>234 / 234</code></td></tr>
</table>

</td>
Expand Down Expand Up @@ -151,7 +151,7 @@ Seguramente desaparezca tu chat de twitch en OBS porque OBS detecta que no "vas
<table>
<tr><td align="center" width="40"><b>1</b></td><td>Pon un delay (p. ej. <kbd>15</kbd>s) → <b>Armar</b>.</td></tr>
<tr><td align="center"><b>2</b></td><td>Mira cómo se llena el buffer. Cuando indique <kbd>ARMED</kbd>, pulsa <b>Activar</b>.</td></tr>
<tr><td align="center"><b>3</b></td><td><b>Cortar delay</b> cuando quieras para volver al directo.</td></tr>
<tr><td align="center"><b>3</b></td><td><b>Cortar delay</b> cuando quieras para volver al directo - o pulsa <b>&#9201; Cortar cuando esto salga</b> justo al acabar tu reacción de fin de partida, e InstantClone corta solo cuando ese momento ha llegado a tus viewers. Sin contar el delay de cabeza.</td></tr>
</table>

> [!TIP]
Expand Down Expand Up @@ -217,6 +217,8 @@ O suelta cualquier `.html` en `./overlays/` y se sirve en `/overlay/tu-archivo.h
| <kbd>POST</kbd> | `/activate` | | Activa el delay armado. <kbd>409</kbd> si el buffer no está listo. |
| <kbd>POST</kbd> | `/disarm` | | Cancela el armado. Descarta el buffer sin salir al aire. |
| <kbd>POST</kbd> | `/stop` | | Corta el delay y vuelve al directo. |
| <kbd>POST</kbd> | `/cut-after` | | Marca el borde en vivo; auto-corta cuando haya salido en todos los destinos (409 si no hay delay activo). |
| <kbd>POST</kbd> | `/cut-after/cancel` | | Descarta un corte programado pendiente sin cortar. |
| <kbd>POST</kbd> | `/delay` | `ms=NNN` | Atajo: arma y activa en cuanto esté listo. |
| <kbd>GET</kbd> | `/state` | | JSON instantáneo del estado. |
| <kbd>GET</kbd> | `/events` | | Flujo SSE del JSON de estado, solo push. |
Expand Down Expand Up @@ -279,15 +281,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, 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), el lector/escritor del flujo de chunks RTMP (cabeceras fmt 0-3, timestamps extendidos, fragmentación entre chunks, control Set-Chunk-Size / Window-Ack en banda, y guardias ante entrada malformada), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 229 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), el lector/escritor del flujo de chunks RTMP (cabeceras fmt 0-3, timestamps extendidos, fragmentación entre chunks, control Set-Chunk-Size / Window-Ack en banda, y guardias ante entrada malformada), la máquina de estados del corte programado ("cortar cuando esto salga"), y la descarga + verificación de checksum + intercambio del exe en disco de la auto-actualización. 234 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`) + 229 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`) + 234 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
10 changes: 6 additions & 4 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>229 / 229</code></td></tr>
<tr><td><b>Tests</b></td><td align="right"><code>234 / 234</code></td></tr>
</table>

</td>
Expand Down Expand Up @@ -151,7 +151,7 @@ Click **Start Streaming**. The OBS pill in InstantClone turns green. Your real T
<table>
<tr><td align="center" width="40"><b>1</b></td><td>Type a delay (e.g. <kbd>15</kbd>s) → <b>Arm</b>.</td></tr>
<tr><td align="center"><b>2</b></td><td>Watch the buffer fill. When it says <kbd>ARMED</kbd>, hit <b>Activate</b>.</td></tr>
<tr><td align="center"><b>3</b></td><td><b>Cut delay</b> at any time to snap back to live.</td></tr>
<tr><td align="center"><b>3</b></td><td><b>Cut delay</b> at any time to snap back to live - or hit <b>&#9201; Cut after this airs</b> right when your match reaction ends, and InstantClone auto-cuts once that moment has reached your viewers. No counting the delay in your head.</td></tr>
</table>

> [!TIP]
Expand Down Expand Up @@ -217,6 +217,8 @@ Or drop any `.html` into `./overlays/` and it's served at `/overlay/your-file.ht
| <kbd>POST</kbd> | `/activate` | | Activate the armed delay. <kbd>409</kbd> if the buffer isn't ready. |
| <kbd>POST</kbd> | `/disarm` | | Cancel arming. Drop the buffer without going live. |
| <kbd>POST</kbd> | `/stop` | | Cut the delay, return to live. |
| <kbd>POST</kbd> | `/cut-after` | | Mark the live edge; auto-cut once it has aired on every destination (409 if no delay is active). |
| <kbd>POST</kbd> | `/cut-after/cancel` | | Drop a pending scheduled cut without cutting. |
| <kbd>POST</kbd> | `/delay` | `ms=NNN` | One-shot: arm, auto-activate as soon as ready. |
| <kbd>GET</kbd> | `/state` | | One-shot JSON snapshot. |
| <kbd>GET</kbd> | `/events` | | Server-sent stream of state JSON. Push-only. |
Expand Down Expand Up @@ -279,15 +281,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, 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), the RTMP chunk-stream reader/writer (fmt 0-3 headers, extended timestamps, cross-chunk fragmentation, in-band Set-Chunk-Size / Window-Ack control, and malformed-input guards), and the self-update download + checksum-verify + on-disk exe swap. 229 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), the RTMP chunk-stream reader/writer (fmt 0-3 headers, extended timestamps, cross-chunk fragmentation, in-band Set-Chunk-Size / Window-Ack control, and malformed-input guards), the scheduled safe-cut ("cut after this airs") state machine, and the self-update download + checksum-verify + on-disk exe swap. 234 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`) + 229 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`) + 234 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
72 changes: 72 additions & 0 deletions scripts/e2e.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
# 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.
#
# F. Scheduled cut ("cut after this airs") - with a delay active,
# POST /cut-after marks the live edge; the proxy must auto-cut to
# passthrough once the mark has aired downstream, without breaking
# the sink connection. Also checks the 409 refusal when no delay
# is active and that /cut-after/cancel drops the mark without
# cutting anything.

$ErrorActionPreference = "Stop"

Expand Down Expand Up @@ -385,6 +392,71 @@ Run-Scenario "E -delay + IDR-aligned cut (arm/ready/activate)" {
}
}

# --- Scenario F: scheduled cut ("cut after this airs") ----------------

Run-Scenario "F -scheduled cut (/cut-after fires once the mark airs)" {
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 "F.sink.log" -RedirectStandardError "F.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 "F.ic.log" -RedirectStandardError "F.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 }

# No delay active -> /cut-after must refuse with 409.
$code = 0
try {
Invoke-WebRequest "http://127.0.0.1:7799/cut-after" -Method POST -UseBasicParsing -TimeoutSec 5 | Out-Null
$code = 200
} catch { $code = [int]$_.Exception.Response.StatusCode }
Assert ($code -eq 409) "POST /cut-after with no active delay returned $code (want 409)" $failed

# Live publisher for the whole scenario.
$ff = Start-FfmpegSource 25
Assert (Wait-State { param($s) $s.ingest_alive -eq $true } 15) "ingest never went live" $failed
if ($failed.Value.Count -gt 0) { return }

# Arm 2 s, wait ready, activate - same ramp as scenario E.
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" $failed
if ($failed.Value.Count -gt 0) { return }
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" $failed
if ($failed.Value.Count -gt 0) { return }

# Schedule, then cancel: the mark must drop WITHOUT cutting.
$r = Invoke-RestMethod "http://127.0.0.1:7799/cut-after" -Method POST -TimeoutSec 5
Assert ($r.safe_cut_pending -eq $true) "/cut-after did not set safe_cut_pending" $failed
Assert ($r.safe_cut_remaining_ms -gt 0) "/cut-after reported a zero countdown" $failed
Invoke-RestMethod "http://127.0.0.1:7799/cut-after/cancel" -Method POST -TimeoutSec 5 | Out-Null
$s = Invoke-RestMethod "http://127.0.0.1:7799/state" -TimeoutSec 5
Assert ($s.safe_cut_pending -eq $false) "cancel did not clear the pending mark" $failed
Assert ($s.phase -eq "active") "cancel cut the delay (phase '$($s.phase)', want 'active')" $failed

# Re-schedule and let it fire: within delay + slack the proxy must
# cut itself back to passthrough (target 0 + full buffer = 'ready').
Invoke-RestMethod "http://127.0.0.1:7799/cut-after" -Method POST -TimeoutSec 5 | Out-Null
Assert (Wait-State { param($s) $s.safe_cut_pending -eq $false -and $s.phase -eq "ready" } 12) "scheduled cut never fired (want pending=false + phase 'ready')" $failed

# The auto-cut must ride the same IDR-aligned machinery as a manual
# cut: downstream connection intact, exactly one publish accept.
Start-Sleep -Seconds 2
$sinkOut = Get-Content "F.sink.log" -Raw
$accepts = ([regex]::Matches($sinkOut, "publish accepted")).Count
Assert ($accepts -eq 1) "sink saw $accepts publish accepts (expected exactly 1 - the scheduled cut broke the downstream connection)" $failed
} finally {
Stop-Safe $ff; Stop-Safe $ic; Stop-Safe $sink
Start-Sleep -Seconds 1
}
}

# --- Report -----------------------------------------------------------

Write-Host ""
Expand Down
Loading