diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca57632..0e2a086 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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`),
@@ -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
diff --git a/README.es.md b/README.es.md
index 6c5949c..41d796f 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
229 / 229
+
Tests
234 / 234
@@ -151,7 +151,7 @@ Seguramente desaparezca tu chat de twitch en OBS porque OBS detecta que no "vas
1
Pon un delay (p. ej. 15s) → Armar.
2
Mira cómo se llena el buffer. Cuando indique ARMED, pulsa Activar.
-
3
Cortar delay cuando quieras para volver al directo.
+
3
Cortar delay cuando quieras para volver al directo - o pulsa ⏱ Cortar cuando esto salga 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.
> [!TIP]
@@ -217,6 +217,8 @@ O suelta cualquier `.html` en `./overlays/` y se sirve en `/overlay/tu-archivo.h
| POST | `/activate` | | Activa el delay armado. 409 si el buffer no está listo. |
| POST | `/disarm` | | Cancela el armado. Descarta el buffer sin salir al aire. |
| POST | `/stop` | | Corta el delay y vuelve al directo. |
+| POST | `/cut-after` | | Marca el borde en vivo; auto-corta cuando haya salido en todos los destinos (409 si no hay delay activo). |
+| POST | `/cut-after/cancel` | | Descarta un corte programado pendiente sin cortar. |
| POST | `/delay` | `ms=NNN` | Atajo: arma y activa en cuanto esté listo. |
| GET | `/state` | | JSON instantáneo del estado. |
| GET | `/events` | | Flujo SSE del JSON de estado, solo push. |
@@ -279,7 +281,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, 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.
@@ -287,7 +289,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`) + 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**
diff --git a/README.md b/README.md
index 2412dfa..6274fa4 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
229 / 229
+
Tests
234 / 234
@@ -151,7 +151,7 @@ Click **Start Streaming**. The OBS pill in InstantClone turns green. Your real T
1
Type a delay (e.g. 15s) → Arm.
2
Watch the buffer fill. When it says ARMED, hit Activate.
-
3
Cut delay at any time to snap back to live.
+
3
Cut delay at any time to snap back to live - or hit ⏱ Cut after this airs right when your match reaction ends, and InstantClone auto-cuts once that moment has reached your viewers. No counting the delay in your head.
> [!TIP]
@@ -217,6 +217,8 @@ Or drop any `.html` into `./overlays/` and it's served at `/overlay/your-file.ht
| POST | `/activate` | | Activate the armed delay. 409 if the buffer isn't ready. |
| POST | `/disarm` | | Cancel arming. Drop the buffer without going live. |
| POST | `/stop` | | Cut the delay, return to live. |
+| POST | `/cut-after` | | Mark the live edge; auto-cut once it has aired on every destination (409 if no delay is active). |
+| POST | `/cut-after/cancel` | | Drop a pending scheduled cut without cutting. |
| POST | `/delay` | `ms=NNN` | One-shot: arm, auto-activate as soon as ready. |
| GET | `/state` | | One-shot JSON snapshot. |
| GET | `/events` | | Server-sent stream of state JSON. Push-only. |
@@ -279,7 +281,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, 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.
@@ -287,7 +289,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`) + 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**
diff --git a/scripts/e2e.ps1 b/scripts/e2e.ps1
index 2df3c82..6fe0f68 100644
--- a/scripts/e2e.ps1
+++ b/scripts/e2e.ps1
@@ -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"
@@ -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 ""
diff --git a/src/controller.rs b/src/controller.rs
index a0ea1ca..ac577fe 100644
--- a/src/controller.rs
+++ b/src/controller.rs
@@ -415,6 +415,20 @@ pub struct Controller {
/// streamer is already in the active state and a subsequent cut
/// shouldn't snap back to "active" via auto-activate.
auto_activate_pending: AtomicBool,
+ /// Input-timeline timestamp (u64, expand_ts domain) of a scheduled
+ /// "cut after this airs" mark. 0 = none pending. Set by
+ /// `schedule_safe_cut` to the live-edge ts at the moment the
+ /// streamer pressed the button; the pump loops watch the slowest
+ /// live consumer and fire `stop_delay` once every destination has
+ /// aired past the mark. This is the "match-end reaction" cut: the
+ /// streamer marks the moment their reaction ends (on their live
+ /// timeline) instead of counting the delay down in their head, and
+ /// nothing before the mark ever gets clipped. Cleared by
+ /// `cancel_safe_cut`, by a manual `stop_delay`, by disarm
+ /// (`arm_delay(0)`), and by `begin_publish` (the mark belongs to
+ /// the old session's timeline - a fresh publisher restarts ts near
+ /// 0, so a stale mark could never be reached).
+ safe_cut_input_ts: AtomicU64,
ingest_alive: AtomicBool,
buffer_building: AtomicBool,
publisher_token: AtomicU64,
@@ -499,6 +513,7 @@ impl Controller {
armed_delay_ms: AtomicU32::new(initial_armed_delay_ms),
target_delay_ms: AtomicU32::new(0),
auto_activate_pending: AtomicBool::new(false),
+ safe_cut_input_ts: AtomicU64::new(0),
ingest_alive: AtomicBool::new(false),
buffer_building: AtomicBool::new(false),
publisher_token: AtomicU64::new(0),
@@ -737,6 +752,9 @@ impl Controller {
// Disarm clears any pending auto-activate too - there's
// nothing to auto-activate into anymore.
self.auto_activate_pending.store(false, Ordering::Relaxed);
+ // A pending "cut after this airs" mark dies with the delay
+ // it was going to cut.
+ self.safe_cut_input_ts.store(0, Ordering::Relaxed);
} else if previous_target > 0 {
// Already active → live-update what we're delivering. This
// is NOT a fresh arm action; the streamer is mid-stream and
@@ -790,6 +808,116 @@ impl Controller {
// arm event (re-arm at a non-zero value with target = 0)
// refills it.
self.auto_activate_pending.store(false, Ordering::Relaxed);
+ // A manual cut supersedes any scheduled "cut after this airs" -
+ // the streamer chose "now" over "when the mark airs".
+ self.safe_cut_input_ts.store(0, Ordering::Relaxed);
+ }
+
+ // --- "Cut after this airs" (scheduled safe cut) -------------------
+ //
+ // The competitive-streamer workflow: a match ends on a 30 s delay,
+ // the streamer reacts, and the moment the reaction is over they
+ // want to snap back to live WITHOUT clipping the reaction off the
+ // delayed output - which today means counting the delay in their
+ // head. Instead they press one button at the safe moment; we record
+ // the live-edge input timestamp and fire the normal cut machinery
+ // once the slowest destination has aired past it.
+
+ /// Schedule a cut for the moment the CURRENT live edge has aired on
+ /// every destination. Returns the estimated wait (ms) for the UI
+ /// countdown. Only meaningful while a delay is active - refuses
+ /// otherwise so the button can't arm a mark that fires surprisingly
+ /// on some future activate.
+ pub fn schedule_safe_cut(&self) -> Result {
+ if self.target_delay_ms.load(Ordering::Relaxed) == 0 {
+ return Err("no delay is active - nothing to schedule");
+ }
+ let Some(latest) = self.ring.latest_ts() else {
+ return Err("no stream data yet");
+ };
+ // 0 is the "none pending" sentinel; a genuine ts of 0 (first
+ // tag of a session) shifts by 1 ms, which is far below the
+ // IDR-quantisation the cut lands on anyway.
+ self.safe_cut_input_ts
+ .store(latest.max(1), Ordering::Relaxed);
+ Ok(self.safe_cut_remaining_ms())
+ }
+
+ /// Drop a pending scheduled cut. No-op if none is pending.
+ pub fn cancel_safe_cut(&self) {
+ self.safe_cut_input_ts.store(0, Ordering::Relaxed);
+ }
+
+ pub fn safe_cut_pending(&self) -> bool {
+ self.safe_cut_input_ts.load(Ordering::Relaxed) != 0
+ }
+
+ /// How long until the pending mark has aired, for the dashboard
+ /// countdown. 0 when nothing is pending. When no destination is
+ /// live to measure against, fall back to the target delay - the
+ /// honest "roughly this long" number the streamer armed.
+ pub fn safe_cut_remaining_ms(&self) -> u32 {
+ let mark = self.safe_cut_input_ts.load(Ordering::Relaxed);
+ if mark == 0 {
+ return 0;
+ }
+ match self.slowest_live_consumer_ts() {
+ Some(ts) => mark.saturating_sub(ts).min(u32::MAX as u64) as u32,
+ None => self.target_delay_ms.load(Ordering::Relaxed),
+ }
+ }
+
+ /// Called from each pump's throttled cut-check. If a mark is pending
+ /// and the SLOWEST live destination has aired past it, fire the
+ /// normal cut (stop_delay → compute_delay_cut sees target 0 on the
+ /// same pump iteration and seeks to live). Gating on the slowest
+ /// consumer is what makes the promise hold per-destination: a
+ /// faster pump must not cut a slower one short of the mark. The
+ /// compare_exchange makes exactly one pump the firing pump, so the
+ /// log line and the stop_delay don't multiply across destinations.
+ pub fn maybe_fire_safe_cut(&self) {
+ let mark = self.safe_cut_input_ts.load(Ordering::Relaxed);
+ if mark == 0 {
+ return;
+ }
+ let Some(consumer_ts) = self.slowest_live_consumer_ts() else {
+ return;
+ };
+ // Strictly greater: the next-to-send tag being AT the mark
+ // means the mark's own frame hasn't gone out yet.
+ if consumer_ts <= mark {
+ return;
+ }
+ if self
+ .safe_cut_input_ts
+ .compare_exchange(mark, 0, Ordering::Relaxed, Ordering::Relaxed)
+ .is_ok()
+ {
+ self.stop_delay();
+ self.log("scheduled cut: marked moment has aired everywhere - cutting to live");
+ }
+ }
+
+ /// Input-timeline position of the slowest live destination: the ts
+ /// of the next tag it will send. `None` when no destination is
+ /// alive, or the cursor fell behind the ring front (transient -
+ /// next_or_wait realigns it). A cursor PAST the newest tag means
+ /// fully caught up, which reads as the live edge.
+ fn slowest_live_consumer_ts(&self) -> Option {
+ let min_consumer = {
+ let map = self.destinations.read().unwrap();
+ map.values()
+ .filter(|d| d.egress_alive.load(Ordering::Relaxed))
+ .map(|d| d.consumer_seq.load(Ordering::Relaxed))
+ .min()
+ }?;
+ if let Some((_, m)) = self.ring.find_by_seq(min_consumer) {
+ return Some(m.ts_ms);
+ }
+ match self.ring.latest_seq() {
+ Some(latest_seq) if min_consumer > latest_seq => self.ring.latest_ts(),
+ _ => None,
+ }
}
/// Snapshot read of the auto-activate-pending slot. Used by the
@@ -813,18 +941,11 @@ impl Controller {
let Some(latest) = self.ring.latest_ts() else {
return 0;
};
- let min_consumer = {
- let map = self.destinations.read().unwrap();
- map.values()
- .filter(|d| d.egress_alive.load(Ordering::Relaxed))
- .map(|d| d.consumer_seq.load(Ordering::Relaxed))
- .min()
- };
- match min_consumer.and_then(|c| self.ring.find_by_seq(c).map(|(_, m)| m)) {
+ match self.slowest_live_consumer_ts() {
// Clamp to u32: a u64 delta can't realistically exceed
// 600_000 ms (our hard armed-delay ceiling) but we cap to
// be safe - the UI consumes a u32 number anyway.
- Some(meta) => latest.saturating_sub(meta.ts_ms).min(u32::MAX as u64) as u32,
+ Some(ts) => latest.saturating_sub(ts).min(u32::MAX as u64) as u32,
None => 0,
}
}
@@ -1005,6 +1126,11 @@ impl Controller {
// publisher session is a clean slate; the prior session's
// arm-or-not state shouldn't leak into the new one.
self.auto_activate_pending.store(false, Ordering::Relaxed);
+ // Same for a pending "cut after this airs" mark: it's an input
+ // timestamp on the OLD session's timeline. The new session
+ // restarts near ts 0, so the mark would sit unreachable and
+ // fire hours later (or never) - clear it with the session.
+ self.safe_cut_input_ts.store(0, Ordering::Relaxed);
// Bump token so any prior egress reader knows it's stale.
let token = self.publisher_token.fetch_add(1, Ordering::SeqCst) + 1;
@@ -1554,6 +1680,11 @@ async fn pump_dest(
if due {
state.last_cut_check = Instant::now();
state.last_seen_target = target_now;
+ // Scheduled "cut after this airs": if the slowest live
+ // destination has aired past the mark, this flips target
+ // to 0 - and compute_delay_cut below reads the atomic
+ // fresh, so the cut lands on this same iteration.
+ ctrl.maybe_fire_safe_cut();
if let Some(cut) = compute_delay_cut(ctrl, &meta) {
apply_cut(ctrl, dest, &mut sink, &mut state, cut).await?;
continue;
@@ -2308,6 +2439,105 @@ mod tests {
assert_eq!(h.ctrl.target_delay_ms(), 0);
}
+ // ── "Cut after this airs" (scheduled safe cut) ───────────────────
+
+ #[test]
+ fn safe_cut_requires_active_delay() {
+ let h = harness(0);
+ // Idle: nothing to schedule against.
+ assert!(h.ctrl.schedule_safe_cut().is_err());
+ // Armed-but-not-activated is still target=0 - output is at the
+ // live edge, so a mark would fire (or hang) surprisingly.
+ h.ctrl.arm_delay(2_000);
+ feed_seconds(&h.ctrl, 0, 3, 30);
+ assert!(h.ctrl.schedule_safe_cut().is_err());
+ assert!(!h.ctrl.safe_cut_pending());
+ }
+
+ #[test]
+ fn safe_cut_schedules_and_cancels() {
+ let h = harness(0);
+ h.ctrl.arm_delay(2_000);
+ feed_seconds(&h.ctrl, 0, 3, 30);
+ h.ctrl.activate_delay().expect("buffer is past armed");
+ assert!(h.ctrl.schedule_safe_cut().is_ok());
+ assert!(h.ctrl.safe_cut_pending());
+ // No live consumer to measure against → remaining falls back to
+ // the armed target, not 0 (0 would render a lying countdown).
+ assert_eq!(h.ctrl.safe_cut_remaining_ms(), 2_000);
+ h.ctrl.cancel_safe_cut();
+ assert!(!h.ctrl.safe_cut_pending());
+ assert_eq!(h.ctrl.safe_cut_remaining_ms(), 0);
+ // Cancel must not have touched the active delay itself.
+ assert_eq!(h.ctrl.target_delay_ms(), 2_000);
+ }
+
+ #[test]
+ fn safe_cut_fires_only_after_slowest_consumer_passes_mark() {
+ let h = harness(0);
+ h.ctrl.arm_delay(2_000);
+ feed_seconds(&h.ctrl, 0, 4, 30);
+ h.ctrl.activate_delay().expect("buffer is past armed");
+
+ // A live destination whose consumer is still early in the ring.
+ let st = h.ctrl.destination_state("d1");
+ st.egress_alive.store(true, Ordering::Relaxed);
+ st.consumer_seq.store(10, Ordering::Relaxed);
+
+ h.ctrl.schedule_safe_cut().expect("delay is active");
+ let before = h.ctrl.safe_cut_remaining_ms();
+ assert!(before > 0, "mark is ahead of the consumer");
+
+ // Consumer hasn't aired the mark yet → must NOT fire.
+ h.ctrl.maybe_fire_safe_cut();
+ assert!(h.ctrl.safe_cut_pending());
+ assert_eq!(h.ctrl.target_delay_ms(), 2_000);
+
+ // More stream arrives, and the consumer advances past the mark
+ // (the newest tag's ts is beyond the mark by construction).
+ feed_seconds(&h.ctrl, 4_000, 2, 30);
+ let latest_seq = h.ctrl.ring.latest_seq().expect("ring has tags");
+ st.consumer_seq.store(latest_seq, Ordering::Relaxed);
+
+ h.ctrl.maybe_fire_safe_cut();
+ assert!(!h.ctrl.safe_cut_pending(), "mark aired - must fire");
+ assert_eq!(h.ctrl.target_delay_ms(), 0, "fire runs the normal cut");
+ // The armed value survives, same as a manual Cut - the next
+ // activate is instant.
+ assert_eq!(h.ctrl.armed_delay_ms(), 2_000);
+ }
+
+ #[test]
+ fn manual_cut_and_disarm_clear_scheduled_cut() {
+ let h = harness(0);
+ h.ctrl.arm_delay(2_000);
+ feed_seconds(&h.ctrl, 0, 3, 30);
+ h.ctrl.activate_delay().expect("buffer is past armed");
+ h.ctrl.schedule_safe_cut().expect("delay is active");
+ // Manual cut supersedes the mark.
+ h.ctrl.stop_delay();
+ assert!(!h.ctrl.safe_cut_pending());
+ // Re-activate, schedule again, then disarm - mark dies with the delay.
+ h.ctrl.activate_delay().expect("buffer still full");
+ h.ctrl.schedule_safe_cut().expect("delay is active");
+ h.ctrl.arm_delay(0);
+ assert!(!h.ctrl.safe_cut_pending());
+ }
+
+ #[tokio::test]
+ async fn new_publisher_session_clears_scheduled_cut() {
+ let h = harness(0);
+ h.ctrl.arm_delay(2_000);
+ feed_seconds(&h.ctrl, 0, 3, 30);
+ h.ctrl.activate_delay().expect("buffer is past armed");
+ h.ctrl.schedule_safe_cut().expect("delay is active");
+ // Fresh publisher: the mark's timestamp belongs to the OLD
+ // session's timeline (the new one restarts near 0), so keeping
+ // it would leave an unreachable mark pending forever.
+ h.ctrl.begin_publish("key").await.expect("slot is free");
+ assert!(!h.ctrl.safe_cut_pending());
+ }
+
#[test]
fn arm_then_empty_buffer_is_preparing() {
let h = harness(0);
diff --git a/src/web.rs b/src/web.rs
index 63c708e..5bf91b7 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -598,6 +598,8 @@ async fn route(
// Legacy one-shot endpoints (Stream Deck etc.)
("POST", "/delay") => post_delay(body, ctrl, settings, cfg_path, sysstat).await,
("POST", "/go-live") => post_stop(ctrl, settings, cfg_path, sysstat).await,
+ ("POST", "/cut-after") => post_cut_after(ctrl, settings, sysstat).await,
+ ("POST", "/cut-after/cancel") => post_cut_after_cancel(ctrl, settings, sysstat).await,
("POST", "/test-egress") => test_egress(settings).await,
("POST", "/test-webhook") => post_test_webhook(ctrl).await,
("POST", "/logs/clear") => {
@@ -722,8 +724,10 @@ fn state_json(
.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},"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}]}}"#,
+ 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},"safe_cut_pending":{scp},"safe_cut_remaining_ms":{scr},"stats":{{"tags_sent":{ts},"bytes_sent":{bs},"cuts":{cu},"ingest_disconnects":{id},"egress_reconnects":{er},"bitrate_kbps":{br}}},"destinations":[{dl}]}}"#,
ph = ctrl.phase(),
+ scp = ctrl.safe_cut_pending(),
+ scr = ctrl.safe_cut_remaining_ms(),
ad = ctrl.armed_delay_ms(),
td = ctrl.target_delay_ms(),
cd = ctrl.current_delay_ms(),
@@ -1761,6 +1765,44 @@ async fn post_disarm(
)
}
+// ---- "Cut after this airs" (scheduled safe cut) ----
+//
+// No persist_delay_state here: scheduling doesn't change armed/target,
+// and when the mark fires it goes through the same stop_delay path the
+// supervisor behaviours use - the next explicit delay action persists.
+
+async fn post_cut_after(
+ ctrl: &Arc,
+ settings: &Arc>,
+ sysstat: &Arc,
+) -> (&'static str, &'static str, String) {
+ match ctrl.schedule_safe_cut() {
+ Ok(_) => (
+ "200 OK",
+ "application/json",
+ state_json(ctrl, settings, sysstat),
+ ),
+ Err(e) => (
+ "409 Conflict",
+ "application/json",
+ format!(r#"{{"ok":false,"error":"{}"}}"#, json_escape(e)),
+ ),
+ }
+}
+
+async fn post_cut_after_cancel(
+ ctrl: &Arc,
+ settings: &Arc>,
+ sysstat: &Arc,
+) -> (&'static str, &'static str, String) {
+ ctrl.cancel_safe_cut();
+ (
+ "200 OK",
+ "application/json",
+ state_json(ctrl, settings, sysstat),
+ )
+}
+
fn persist_delay_state(
ctrl: &Controller,
settings: &Arc>,
diff --git a/web/index.html b/web/index.html
index b856691..ee1d8ff 100644
--- a/web/index.html
+++ b/web/index.html
@@ -404,6 +404,36 @@
.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}
+/* ── "Cut after this airs" + hovercard ─────────────────────────────
+ Visible only while a delay is active, sharing the CTA row at a 3:1
+ split (Cut delay : Cut after) so the control column's height never
+ changes with state. The hovercard is a pure-CSS tooltip
+ (opacity+translate on :hover / :focus-within) so keyboard users get
+ it too. It floats ABOVE the button, right-anchored with a fixed
+ width - the wrap itself is too narrow to host readable copy. */
+.cutafter-wrap{position:relative;flex:1;min-width:0;animation:pillIn .2s var(--ease-out)}
+/* display:none must survive the class's own display value - [hidden]
+ loses to any author display rule otherwise. */
+.cutafter-wrap[hidden]{display:none}
+.cp-cutafter{width:100%;height:100%;padding:13px 8px;font-size:12px;border-radius:10px;
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.cp-cutafter.pending{color:var(--accent);
+ border-color:color-mix(in oklch,var(--accent) 55%,transparent);
+ background:color-mix(in oklch,var(--accent) 10%,var(--surface-3));
+ animation:cta-pulse 1.8s var(--ease-out) infinite}
+.cutafter-card{position:absolute;bottom:calc(100% + 10px);right:0;z-index:30;
+ width:min(300px,78vw);
+ display:flex;flex-direction:column;gap:7px;padding:12px 14px;border-radius:12px;
+ background:var(--surface-3);border:1px solid var(--line-2);
+ box-shadow:0 12px 32px rgba(0,0,0,.35);
+ font-size:12px;line-height:1.5;color:var(--fg-2);text-align:left;
+ opacity:0;transform:translateY(5px);pointer-events:none;
+ transition:opacity .18s var(--ease-out),transform .18s var(--ease-out)}
+.cutafter-card b{color:var(--fg);font-size:12.5px}
+.cutafter-mini{font-size:10.5px;color:var(--accent);letter-spacing:.02em;
+ padding-top:7px;border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.cutafter-wrap:hover .cutafter-card,
+.cutafter-wrap:focus-within .cutafter-card{opacity:1;transform:none}
@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)}
50%{box-shadow:0 0 0 1px color-mix(in oklch,var(--accent) 60%,transparent),0 0 0 10px transparent}
@@ -1223,11 +1253,13 @@
padding:1px 6px;border-radius:5px;font-family:'JetBrains Mono',monospace;font-size:12px}
.tour-body ul{margin:6px 0 8px 16px;padding:0}
.tour-body li{margin:3px 0}
-.tour-body .tour-tip{display:flex;align-items:flex-start;gap:8px;
+/* Not scoped to .tour-body: the wizard reuses the same accent callout
+ for the "cut after this airs" teaser, so both stay in lockstep. */
+.tour-tip{display:flex;align-items:flex-start;gap:8px;
padding:9px 11px;background:color-mix(in oklch,var(--accent) 5%,var(--bg-2));
border:1px solid color-mix(in oklch,var(--accent) 25%,var(--line));
border-radius:8px;font-size:12px;margin-top:8px}
-.tour-body .tour-tip strong{color:var(--accent);margin-right:4px}
+.tour-tip strong{color:var(--accent);margin-right:4px}
.tour-foot{display:flex;align-items:center;gap:6px;padding:10px 16px;
border-top:1px solid var(--line);background:rgba(0,0,0,.15);
border-bottom-left-radius:14px;border-bottom-right-radius:14px}
@@ -1367,6 +1399,15 @@
Welcome to InstantClone
+
+
+ Playing ranked behind a delay? Once your delay is live, a
+ ⏱ Cut after this airs button appears next to Cut. Press it the moment
+ your match reaction ends - InstantClone lets that moment reach your viewers,
+ then cuts to live on its own. No counting seconds in your head.
+
@@ -1416,8 +1457,20 @@
Welcome to InstantClone
Save common delays in Profiles for one-click insta-arm.
-
+
+
+
+
+
+
+
@@ -2411,6 +2464,32 @@
New destination
tick();
}
+// "Cut after this airs" - mark the current live moment; the backend
+// auto-cuts once every destination has aired past it. Clicking while a
+// mark is pending cancels it. No setPending here: the phase doesn't
+// change until the mark actually fires.
+async function cutAfter(){
+ if (lastState && lastState.safe_cut_pending){
+ await fetch('/cut-after/cancel',{method:'POST'});
+ toast('Scheduled cut cancelled', 'info');
+ tick();
+ return;
+ }
+ const r = await fetch('/cut-after',{method:'POST'});
+ if (r.status === 409){
+ try{ const j = await r.json(); toast(j.error, 'err'); }catch(_){ toast('Could not schedule the cut', 'err'); }
+ } else {
+ toast('Marked. Cutting to live once this moment airs.', 'ok');
+ }
+ tick();
+}
+function cutAfterClick(ev){
+ const btn = ev.currentTarget;
+ if (btn._busy) return;
+ btn._busy = true; setTimeout(() => { btn._busy = false; }, 300);
+ cutAfter();
+}
+
function bumpDelay(d){
const inp = $('delay-input');
const cur = parseInt(inp.value || '0', 10) || 0;
@@ -2521,6 +2600,10 @@
New destination
const dir = armedMs > curMs ? 'rewinding' : 'jumping forward';
sub = `Adjusting → ${dir} to ${fmtDelay(armedMs)} (currently ${fmtDelay(curMs)})…`;
}
+ else if (ds === 'active' && s.safe_cut_pending){
+ const cd = Math.max(0, Math.round((s.safe_cut_remaining_ms || 0) / 1000));
+ sub = `Auto-cut armed - going live in ~${cd}s, everything up to your mark still airs.`;
+ }
else if (ds === 'active') sub = `Forwarded with a ${fmtDelay(s.armed_delay_ms)} buffer${s.ingest_alive ? ` · `+fmtKbps(s.stats.bitrate_kbps)+' in' : '.'}`;
setSubText($('delay-sub'), sub);
@@ -2541,12 +2624,14 @@
clearWarn();
}
}
+ renderCutAfter(ds);
+}
+
+// ── "Cut after this airs" secondary action ────────────────────────────
+// Only offered while a delay is active - there's nothing to schedule
+// otherwise (the backend refuses too, this just keeps the button from
+// teasing a dead action). Shares the CTA row at 1/4 width, so labels
+// stay compact and the full story lives in the hovercard + title.
+// While a mark is pending the button becomes a live countdown and
+// clicking it cancels. The hovercard copy swaps with the mode; guarded
+// by data-mode so the ~1 Hz state ticks don't rebuild identical DOM
+// under the user's cursor.
+function renderCutAfter(ds){
+ const wrap = $('cp-cutafter-wrap');
+ if (!wrap) return;
+ const show = !pendingPhase && !!lastState && ds === 'active';
+ wrap.hidden = !show;
+ if (!show) return;
+ const btn = $('cp-cutafter');
+ const card = $('cp-cutafter-card');
+ const pending = !!lastState.safe_cut_pending;
+ btn.classList.toggle('pending', pending);
+ if (pending){
+ const s = Math.max(0, Math.round((lastState.safe_cut_remaining_ms || 0) / 1000));
+ btn.textContent = `⏱ ~${s}s ✕`;
+ if (card && card.dataset.mode !== 'pending'){
+ card.dataset.mode = 'pending';
+ card.innerHTML = `Mark set.
+ Everything up to your press is still going out to viewers. The instant it has aired on every destination, the delay cuts itself to live.
+ tap the button again to cancel`;
+ }
+ } else {
+ btn.textContent = '⏱ Cut after';
+ if (card && card.dataset.mode !== 'idle'){
+ card.dataset.mode = 'idle';
+ card.innerHTML = `Stop counting your delay.
+ Match over? Press this the moment your reaction ends. Everything up to that press still reaches your viewers - then the delay cuts itself to live.
+ press now ▸ reaction airs ▸ auto-cut ▸ live`;
+ }
+ }
}
// ── Profile chips in hero ──────────────────────────────────────────────
@@ -4843,6 +4968,7 @@
New destination
Arm delay → starts buffering (you can still cancel)
Activate → once buffer is ready, click to cut your stream over
Cut delay → return to passthrough live
+
⏱ Cut after this airs → appears while the delay is active. Press it the second your match reaction ends: everything up to that press still airs, then the delay cuts itself to live. No more counting 30 seconds in your head.
Shortcut: press Space anywhere to fire this button.