Skip to content

feat(vision-mixer): stinger transitions - #755

Closed
wagenet wants to merge 11 commits into
Eyevinn:mainfrom
wagenet:feat/stinger-transitions
Closed

wagenet wants to merge 11 commits into
Eyevinn:mainfrom
wagenet:feat/stinger-transitions

Conversation

@wagenet

@wagenet wagenet commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The vision mixer has no stinger: a keyed clip played over the program while the cut or transition happens beneath it, at a point in the clip chosen so the artwork covers the switch.

Change

A stinger is triggered through the existing transition endpoint as type stinger, naming a Media Player block that declares itself a stinger clip source and is wired to a keyed (DSK) input. Cut point, the transition beneath, and its duration are Media Player properties, since where a clip covers follows from the artwork; a take names only its clip. The clip is held on its first frame while idle, so a take only starts playback: 0.5 ms at 4K against 28.4 ms unparked. Declaration is opt-in, so a looping graphic on a keyed input is never parked or unlooped.

The cut is anchored to the clip, not timed. A one-shot probe holds the mixer's output on the frame before the clip's cut point until the take is applied, however far the output trails the clock. A wall-clock cut lands on whichever frame the mixer is on, which on a loaded machine is often before the clip is on air.

A cut the clip may not cover is reported as StingerFailed with still_running: true: the clip frame is late at the cut; the clip never reaches the mixer by cut point + 500 ms (the keyed input is taken off air and the cut made plainly); or the mixer output stalls for 5 s (the cut is made on wall clock).

A finished stinger releases the mixer only after the mixer's output passes the clip's last frame, because videoaggregator keeps showing an expired frame while the next take's first frame is queued. A take's mixer claim carries a token, so a take from a stopped flow cannot drive its replacement.

A declared source plays unpaced, and its video output waits instead of dropping. It ignores the Media Player's sync and playout_delay_ms, so the clip runs ahead of the mixer. Its video appsrc is non-leaky and blocking, so when the bridge's queues are full the clip waits rather than losing frames. Audio stays leaky: an audio output may be left unlinked, and a blocking audio appsrc that never drains stalled the player, and with it the next take's re-arm, for good.

A waiting push parks the Media Player's internal streaming thread, and pausing, seeking, switching file and shutting the player down all wait for that thread. So each first lifts the waiting appsrc's limits (changing max-bytes wakes the push) and the last overlapping call to return restores them: a take re-arms its clip from its own task while an operator may seek or stop it. Without this, a flow whose mixer stopped pulling the clip, or whose declared clip was played before its video was wired, could not be stopped.

Frames reaching the mixer, and how far the newest clip frame reaches past the cut frame at the cut check (5 takes each, macOS):

Feed 320x180 1080p 4K margin
Paced, as #945 made every Media Player all frames -26 to +2 ms (24 takes); cut and repeat tests fail every run
Unpaced, leaky appsrc all frames 14-16 of 45 8-11 of 30 erratic; most HD cuts reported
Unpaced, blocking video appsrc (this PR) all frames all all 133 to 165 ms
Rejected: keep #945's pacing and give the clip a fixed head start

This PR opts stinger sources out of #945's pacing. The alternative stamps a paced clip a fixed lead ahead of its release. At 100 ms in a scratch build every frame arrived at all three sizes, with 72 to 166 ms at the check, but:

  • the graphic goes on air one lead after the take: a full take took 1762 ms at 1080p against 1662 ms unpaced;
  • the lead is a number to tune: large enough for a loaded machine, small enough that the frames held ahead fit the bridge's queues at 4K;
  • it changes the timing module every Media Player uses, live streams included;
  • that naive version cut 2 frames early on 2-3 of 12 takes; not investigated.

The unpaced route reuses the existing sync = false path, keeps the appsrc memory bound, and has no lead to tune. Switching to the paced route would touch only the Media Player builder and timing module.

Evidence

  • Unpaced stinger source: reverting cc831ee's builder change (paced, with playout delay) fails the_cut_lands_on_the_frame_the_cut_point_names (take 1 landed on clip frame 1) and a_repeat_stinger_plays_forward_from_the_start ("coverage was [3, 3, 3, 3, 6, 10, …]"), 3 of 3 runs.
  • Blocking video appsrc: making it leaky again fails a_clip_longer_than_the_output_queue_plays_whole: "every clip frame must reach the mixer", 160 of 240.
  • Releasing a waiting clip: skipping the lift fails a_clip_the_mixer_stops_taking_can_still_be_stopped ("stopping the player must return while the mixer takes nothing") and an_unwired_stinger_clip_can_still_be_stopped ("seeking a clip nothing takes must return"). Both run on a runtime that is abandoned rather than joined, so a hung take fails instead of never finishing.
  • Overlapping calls: letting each call save and restore the limits for itself fails overlapping_player_calls_keep_the_clip_queue_bounded ("the clip's video queue limits must be back as built"), 3 of 3 runs. Unguarded, the queue stayed unbounded: 630 MB for a 3 s 1080p take.
  • Cut anchored to the mixer: replacing the anchor with a wall-clock sleep fails the_cut_lands_on_the_frame_the_cut_point_names (10 of 12 takes on clip frame 16, not 15).
  • End-of-clip wait: skipping wait_for_mixer_to_pass fails a_repeat_stinger_plays_forward_from_the_start ("coverage was [100, 3, 6, …]": the previous clip's last frame on air).
  • Opt-in: arming every Media Player fails undeclared_source_on_keyed_input_is_left_alone.
  • Stale take leaves a restarted flow alone: a_stinger_outliving_its_flow_leaves_the_next_one_alone guards the claim released on stop. It still passes with the token check in run_take removed, so the token check itself is not guarded.
  • Late-clip-frame report: not guarded by a deterministic test. Forcing it would need a test-only delay in the clip path.
  • Scratch runs, not committed: 20 back-to-back takes each delivered every frame; a 10 s clip with an audio track delivered every frame with audio_out unlinked and with it on a synced sink; stopping a flow mid-clip at 1080p returned in 21-23 ms, 5 of 5; a 45-frame 1080p clip delivered 45 of 45 on 3 takes.

Tests

Linux CI will likely fail a_clip_longer_than_the_output_queue_plays_whole with 239 of 240 frames, in about half of runs (10 of 21 so far). The cause is a bug in CI's GStreamer 1.24.2, not in this change. avdec_ffv1 with frame threading drops a clip's last frame at end of stream when its output lags its input, and logs Couldn't get codec frame !. Upstream fixed it in 1.24.4, so production (1.26.6) and Windows and macOS CI are not affected. It reproduces on Ubuntu 24.04 with a plain gst-launch-1.0 pipeline, without Strom. This PR makes the lag more likely, because the stinger clip's output now waits for room instead of dropping frames. The test stays strict instead of excusing a missing last frame on old GStreamer; #964 proposes running Linux tests on 1.26.

Ran at 743c8b2, macOS, STROM_REQUIRE_GL=1 STROM_REQUIRE_GST_PLUGINS=1:

  • cargo test --test stinger_test: 16/16 in 2 runs. On Linux, Check (Linux) passed at 743c8b2.
  • cargo test --lib mediaplayer 30/30; cargo test --test pipeline_lifecycle_test 4/4.
  • cargo test --test stinger_alpha_test 4/4, including alpha_survives_gl_videomixer with a real GL context; cargo test --test openapi_test.
  • cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check.

Skipped in CI: alpha_survives_gl_videomixer returns early in Check (Linux), which sets no STROM_REQUIRE_GL (the alpha binary finished in 0.07 s), and Build (macOS) is label-gated. The GL alpha half has run only locally.

Not run locally: cargo test --workspace.

For the reviewer

  • Whether stinger sources should be exempt from feat(mediaplayer): play live streams, extra tracks and URLs #945's pacing (above).
  • One segfault in the compositor's blend (blend_pads, ORC-generated code) appeared in about 80 heavy-load runs across both arms of an A/B, and not in 40 targeted runs. Not attributed to this change.

Out of scope

  • Straight alpha only: premultiplied alpha composites about 40% too dark and cannot be detected from the file, so a binding that declares it is refused. No fill+key pairs.
  • The stinger does not route the clip's audio. A clip's audio plays out of the Media Player's audio output if that is wired.
  • One stinger source wired to two mixers (say program and a clean feed): the claim is per mixer, so a second take on the other mixer is accepted, and the first take's re-arm rewinds the shared clip under it.
  • The take works out the clip's end one frame early: it takes the clip's timing from frame 1, because frame 0 is already waiting at the mixer before the take. The keyed input still comes off after the real end (the mixer was 1.6 to 2.8 frames past it at StingerCompleted in 11 CI runs), so nothing on air changes. A fix would change when every take ends, so it is left for a follow-up.
  • A take, and StingerCompleted, run as late as the mixer's output does. On a machine where the mixer ran at about a quarter of real time, completion came several seconds after the clip ended on the clock.

🤖 Generated with Claude Code

@wagenet
wagenet force-pushed the feat/stinger-transitions branch 2 times, most recently from b3326be to cd9b383 Compare September 3, 2026 22:02
@srperens srperens mentioned this pull request Sep 8, 2026
@srperens

Copy link
Copy Markdown
Collaborator

Noted as a draft, so I am holding off on a full review until it is marked ready for
review. If you want one before then, say so here or request me as a reviewer.

@wagenet
wagenet force-pushed the feat/stinger-transitions branch 10 times, most recently from 0896d8c to f0aa999 Compare September 12, 2026 01:37
@wagenet
wagenet marked this pull request as ready for review September 12, 2026 01:41
@wagenet
wagenet force-pushed the feat/stinger-transitions branch 4 times, most recently from fafe2bd to ddff280 Compare September 12, 2026 01:49
@wagenet
wagenet force-pushed the feat/stinger-transitions branch 3 times, most recently from a47cb11 to 641cd70 Compare September 14, 2026 01:30
@wagenet

wagenet commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Written by Claude (AI).

Update this PR once #809 lands. #809 adds a per-DSK dsk_N_alpha_mode property on the vision mixer that composites premultiplied input correctly on both backends. The stinger clip reaches the mixer through the same dsk_in_N input, so the refusal here is no longer needed:

  • Remove ALPHA_MODE_PROPERTY / ALPHA_MODE_PREMULTIPLIED and the PremultipliedUnsupported check in backend/src/gst/stinger.rs, along with the test that asserts the rejection. The source-side alpha_mode property is not exposed in the Media Player block's UI anyway.
  • Update docs/STINGER_TRANSITIONS.md (lines 54–60 and 75): a premultiplied clip is supported by setting the keyed input's dsk_N_alpha_mode to premultiplied, not refused.

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes — the mechanism is sound and unusually well argued, but the wall-clock fallback path releases the mixer hold in the wrong order, and the one test covering the GL compositing path cannot run in this repo's CI.

Requested changes

  1. Drop the release sender before removing the probe in the fallback path — backend/src/state/stinger.rs, the _ => arm at line 501: insert drop(release_tx); ahead of pad.remove_probe(probe);. As written the sender stays live until the function returns, so if the probe fires as the timeout expires it blocks in recv_timeout while remove_probe waits on it — holding the program output, and a tokio worker thread, for the full 250 ms. The comment directly above already states the intended order.

  2. Either install a GL stack in .github/workflows/ci.yml (gstreamer1.0-gl plus a software rasterizer) so alpha_survives_gl_videomixer executes, or drop it and say in the PR body that the GL alpha path is verified by hand only. CLAUDE.md: a test that skips on a missing element "passes green and guards nothing".

Claims

Claim Verdict Evidence
Every buffer before the target costs one timestamp compare CONFIRMED backend/src/state/stinger.rs:463 — if pts.nseconds() < hold_from_pts short-circuits the || before fired.swap, and the lock/channel at :472 are past that return
Dropping the sender keeps the probe from holding the mixer on the fallback path CONTRADICTED backend/src/state/stinger.rs:504 — pad.remove_probe(probe); runs while release_tx is still in scope; it is only dropped at end of function
The stinger tests run in CI CONFIRMED for stinger_test backend/tests/stinger_test.rs:389 — panic!("{name} missing — CI installs gst-libav and plugins-good"); — it fails rather than skips
The alpha tests run in CI CONTRADICTED backend/tests/stinger_alpha_test.rs:215 — eprintln!("skipping: no usable GL environment"); and .github/workflows/ci.yml:104-107 installs no gstreamer1.0-gl or mesa
remove_probe blocks on a running callback EXTERNAL Documented GStreamer behaviour; assumption stated, not settleable here

Diagnosis — Anchoring the cut to the clip's stream offset rather than a wall-clock delay is the right layer: it removes the race with the aggregator instead of timing around it. Coverage is BOUNDED — the hold only applies where the mixer reaches the cut frame within the grace window, and the PR says so and degrades to wall clock. The BUFFER probe respects the CLAUDE.md constraints on the hot path; the blocking, allocation and formatting all sit past the one-shot guard, which is the correct shape for this.

Radius — SHARED. set_dsk_hold_last_frame, mixer_src_pad and mixer_frame_duration_ns are purely additive with no existing call sites, and the last-frame-hold change is gated on opt-in declaration. What is shared is the probe: while armed it sits on the mixer's program output pad, so every take on that mixer pays the compare until it removes itself. A failure would show up as a stalled program output for up to 250 ms, which is change 1.

Tests & CI — Check (Linux), Build (Linux x86_64/ARM64), Check & Build (WASM), API Contract Check all green at 641cd70. Build (macOS) and Build (Windows) report skipping; there is no platform-specific code in this diff, so Linux-green is adequate cover here. The PR body states that a_repeat_stinger_plays_forward_from_the_start does not fail when its fix is reverted — the disclosure CLAUDE.md asks for, taken at face value.

Design record — backend/src/state/mod.rs is 3082 lines after the split that moved stinger state out; worth carving down further, but not this PR's job.

Confidence: HIGH

@wagenet

wagenet commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Written by Claude (AI).

Both addressed in ed84f67, rebased on main.

1. Release order. Fixed: the fallback arm drops release_tx before pad.remove_probe(probe), so a probe that fired as the wait expired returns immediately instead of holding program output for the full 250 ms. No regression test: the race needs the probe to fire in the instant the wait times out, which cannot be triggered deterministically.

2. GL test in CI. I took the route the repo already uses rather than installing a GL stack. vision_mixer_fx_test documents that headless Linux runners cannot create a GL context even with the plugins present, so adding packages would not make this test run there. Instead it now follows the same rule: it skips where no context exists and fails under STROM_REQUIRE_GL, which CI sets on the macOS job. Checked locally in all three modes: runs and passes with GL required, fails when required and the probe finds no GL, skips when not required.

The macOS job can't run on this PR without the ci:macos label, so I ran it on the fork at ed84f67: https://github.com/wagenet/strom/actions/runs/35233093100. Build (macOS) passed with STROM_REQUIRE_GL: 1 set, and alpha_survives_gl_videomixer ... ok. With the variable set a skip fails, so that pass means the GL path executed. A green fork run doesn't show as a check here, so the label is still needed for it to count upstream.

The PR body previously claimed this test executed on Linux CI. It did not; the body now says what each guard covers.

Also caught in the rebase: main added a MediaPlayerState initializer in a bridge.rs test, which failed to compile against this branch's new field. Textually clean, so only cargo test --lib showed it.

#809 is still open, so the premultiplied refusal stays for now.

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Comment — supersedes my Request changes of 2026-09-16 (review-5223777646, head 641cd708), which no longer stands: both requested changes are in. What holds it short of Approve is one row, and it is the same GL-coverage gap on a different file.

Claims

Claim Verdict Evidence
The probe-teardown race I asked about is fixed CONFIRMED backend/src/state/stinger.rs:506 — drop(release_tx); now precedes backend/src/state/stinger.rs:507 — pad.remove_probe(probe);, so a probe that fired as the wait expired is released before remove_probe waits on it
The alpha test no longer skips silently CONFIRMED backend/tests/stinger_alpha_test.rs:244 — strom_types::env::var_opt("STROM_REQUIRE_GL").is_none(), — adopts the precedent already on main in vision_mixer_fx_test.rs and shader_validation_test.rs
The four alpha tests executed in this repo's CI UNVERIFIED .github/workflows/ci.yml:645 — STROM_REQUIRE_GL: 1 is set on the macOS job alone, and Build (macOS) is skipping on this PR. In Check (Linux) job 105241909348 the binary reports 4 passed ... finished in 0.09s — four GL pipelines cannot negotiate and reach EOS in 90 ms, so all four skipped
The core stinger logic is genuinely covered CONFIRMED same job: stinger_test.rs reports 12 passed; 0 failed ... finished in 18.87s

Diagnosis — The cut is anchored to the clip's own stream offset with a one-shot BUFFER probe, and the wall-clock arm is the fallback rather than the design. Coverage of the anchoring mechanism is ABSOLUTE; coverage of the alpha half is BOUNDED to whatever a macOS run would exercise, which is currently nothing.

Repo rules — The BUFFER probe at backend/src/state/stinger.rs:459 respects the hot-path rules: the per-buffer cost is a pts compare plus an AtomicBool swap, and every lock, channel and format sits past that guard, running once per take. The closure captures no Pipeline, Element or Bin — only channels, an atomic and a pre-built String — so it cannot form the CLAUDE.md reference cycle. No new endpoint: this extends the existing transition route with one additive Option<String> field, and API Contract Check passed. The three new StromEvent variants are additions, not modifications.

Radius — SHARED: the probe is installed on the vision mixer's src pad, so a take holds the program output for up to 250 ms (STINGER_MAX_HOLD, backend/src/state/stinger.rs:46). That bound is enforced by recv_timeout, and the fallback arm now releases before removing, so the failure mode is a late cut rather than a held mixer.

What I would ask for before merge — the ci:macos label on this PR, which is the only route that sets STROM_REQUIRE_GL and therefore the only way the four alpha tests have ever run against this diff. I am deliberately not applying it: it fires a paid run, which is a maintainer's call. Merging on Linux-green and letting the post-merge macOS run cover the alpha half is a legitimate alternative — but it is a choice worth making deliberately, not by default.

Confidence: HIGH

@srperens
srperens dismissed their stale review September 18, 2026 08:06

Superseded by my review at head ed84f67: both requested changes are in, so this CHANGES_REQUESTED sits at a dead head and misreports the PR.

@wagenet

wagenet commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Claude (AI), on behalf of @wagenet.

Rebased onto main. Conflicts and what changed beyond picking both sides:

Ran on macOS with STROM_REQUIRE_GL=1 STROM_REQUIRE_GST_PLUGINS=1: fmt, cargo clippy --workspace --all-targets -D warnings, frontend wasm32 clippy, cargo check --workspace --all-targets on the first commit, stinger_test 12/12 in 3 runs, stinger_alpha_test, openapi_test, bus_signal_watch_balance_test, failed_start_teardown_test, the vision mixer fx/cpu/source-resize tests, shader_validation_test, and workspace lib tests matching mediaplayer/stinger/transition/mixer_ops/vision_mixer. All pass. Not run: stinger_preroll_bench (ignored harness), the full suite, the taskpolicy -b load runs, Linux.

wagenet and others added 4 commits October 2, 2026 10:09
A stinger plays a keyed clip over the program while another transition
runs beneath it. It is a transition of type "stinger" naming a Media
Player block that declares itself a stinger clip source and feeds a keyed
(DSK) input. Declaration is opt-in, so a looping graphic on a keyed input
is left alone.

The clip is held on its first frame while idle, so a take only starts
playback: ~0.5 ms at any resolution, against up to 30.9 ms at 4K unparked.

Cut point, the transition beneath and its duration are block properties,
since where a clip covers follows from the artwork. The duration beneath
is shortened to fit the clip, and a clip that cannot play still runs the
transition beneath.

The cut is anchored to the clip: its stream offset gives the output frame
containing the cut point, and the mixer's output is held on the frame
before it until the take is applied. Setting pad properties from another
thread otherwise races the aggregator, by a different amount per layout.

Keyed inputs fed by a declared source do not hold their last frame, or
the frame a clip ended on would be composited when the input is next
revealed. A take's claim on the mixer carries a token, so a take whose
flow was stopped cannot act on the pipeline that replaced it.

Straight alpha only: premultiplied alpha cannot be read from the file and
no compositor operator corrects it, so a binding that declares it is
refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a starved process the clip occasionally misses an output frame, and
a stinger's keyed pad does not repeat its last frame, so that frame
carries no clip pixels. When it was the frame the program changed on,
the coverage-to-frame arithmetic underflowed and panicked.

Such a take now counts as unreadable instead. At most half the takes
may be, so a clip that never reaches the program at the cut, which
would be unreadable on every take, still fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A repeat stinger fired right after the previous one completed put that
clip's last frame on air before its own first frame: one frame at normal
load, two or three on a starved machine.

The keyed pad is set not to repeat its last frame, but videoaggregator
only expires that frame on an output frame where the pad has nothing
queued. The next take queues its first frame ahead of time, so the stale
frame stays current until the new one is due, and revealing the keyed
input shows it. A finished stinger now waits for the mixer's output to
pass the clip's end before re-arming and releasing the mixer. The wait
ends early if the take is superseded, and gives up after the mixer's
latency plus 500 ms.

The repeat test fires the second take as soon as the first completes,
keeps every program frame, and judges frames by timestamp against the
mixer position at the take, so a stale frame cannot pass as one the
appsink held from before it. Frames with no clip on them, a late clip
frame on a starved machine, are skipped rather than read as coverage
falling.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a loaded machine the mixer's output trails the clock. A take gave up
on the anchor at cut point + 500 ms of wall clock and applied the cut to
whatever frame the mixer was on, often before the clip was on air, so
the program switched with nothing covering it.

A take now waits for the mixer to reach the cut frame however late that
is, giving up only if the output stops advancing for 5 s. The keyed
input comes off air once the mixer passes the clip's end rather than on
a wall-clock timer, so a clip that started late plays out.

A cut the clip may not cover is reported as StingerFailed with
still_running true: the clip never reached the mixer (the keyed input is
hidden and the cut made plainly), the mixer stalled, or the clip's frame
for the cut point had not reached the mixer when the cut was released.
The last is read from a per-take probe on the keyed pad that records the
latest clip frame to arrive.

A stinger's keyed input now repeats its last frame for two mixer frames
instead of none, so a single late clip frame freezes the graphic rather
than blanking it.

The cut test no longer absorbs a cut with no clip on it: a take either
reports, or must cut on the named frame (or read one early).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@wagenet
wagenet marked this pull request as draft October 2, 2026 20:53
The Media Player now paces every buffer through clocksync and holds it
for playout_delay_ms (500 by default, Eyevinn#945) before it reaches the flow.
Both break a stinger clip.

The delay: a clip is parked on a decoded first frame so a take goes to
air at once. With the delay the take waited half a second, and the
parked frame showed for a few frames and blinked out before the rest of
the clip arrived. a_repeat_stinger_plays_forward_from_the_start fails
on that: 11 blank frames between the first clip frame and the rest.

The pacing: each frame reached the mixer exactly when it was due, so
when the mixer was held on the frame before the cut, the clip frame
carrying the cut point was often not there yet. Measured at that check
over 24 takes, the clip led the cut frame by -26 to +2 ms; unpaced it
leads by 133 to 166 ms. the_cut_lands_on_the_frame_the_cut_point_names
reported most takes as uncovered, on every Linux CI run and on 6 of 8
local runs.

A player declared as a stinger clip source now ignores sync and
playout_delay_ms. Its frames keep their spacing from the first one after
a take and wait in the mixer's input queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet
wagenet force-pushed the feat/stinger-transitions branch from 2551084 to cc831ee Compare October 2, 2026 21:16
wagenet and others added 5 commits October 2, 2026 15:51
An unpaced stinger clip runs ahead of the mixer and fills the Media
Player's video appsrc, which is leaky: once 20 MB is queued it drops
every new buffer. A 1080p BGRA frame is about 8 MB, so a 1080p clip put
about a third of its frames on air and most cuts were reported as
uncovered; a 320x180 clip longer than about 5 s lost frames with no
report.

A declared stinger source's video appsrc is now non-leaky and blocking,
so the clip waits for the mixer instead. Video only: a take needs the
clip wired to the mixer, which always consumes. An audio output may be
left unlinked, and a blocking audio appsrc that never drains stalls the
player, and with it the next take's re-arm, for good. Audio stays leaky.

a_clip_longer_than_the_output_queue_plays_whole plays a 240-frame clip
and fails, with 160 of 240 frames, when the video appsrc is leaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The whole-clip guard reports how many frame times the arrived frames
span, so a failure shows whether frames were lost at the ends of the
clip or in between. Reverting the blocking video appsrc now reads 157
frames spanning 157: the tail is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stinger clip's video appsrc waits for room, which parks the Media
Player's internal streaming thread in push_sample while the flow takes
nothing from it: a keyed input the mixer stopped pulling, or a clip
played before its video is wired. Pausing, seeking, switching file and
shutting down the internal pipeline all wait for that thread, so they
never returned, and neither did stopping the flow.

Each of them now lifts the waiting appsrc's limits first. The parked
push goes through and frees the thread; the limits come back when the
call ends. Changing max-bytes is what wakes the waiting push.

a_clip_the_mixer_stops_taking_can_still_be_stopped and
an_unwired_stinger_clip_can_still_be_stopped fail without the lift:
"stopping the player must return while the mixer takes nothing" and
"seeking a clip nothing takes must return". They run on a runtime that
is abandoned rather than joined, so a hung take reports as a failure
instead of a test that never ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A take re-arms its clip from its own task while an operator may pause,
seek or stop it, and the API does not serialize player calls. Each call
saved the video appsrc limits it found and put them back when done, so
a call that started inside another saved the lifted values (no limit)
and could restore them last. The clip's video queue then stayed
unbounded until the flow restarted: an unpaced clip decoded whole into
memory every take (630 MB for 3 s at 1080p).

The first call in now saves the limits and the last one out restores
them, under one lock, so no call puts them back while another still
waits on the streaming thread.

overlapping_player_calls_keep_the_clip_queue_bounded races 20 re-arms
against 20 seeks and fails with "the clip's video queue limits must be
back as built" when each call restores for itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a_clip_longer_than_the_output_queue_plays_whole failed once in CI with
239 of 240 frames arriving in an unbroken run, so an end frame was
missing at the moment the take completed. The failure message now also
gives the count 500 ms later and any StingerFailed the take reported,
which tells a frame the mixer moved past apart from one never sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a_clip_longer_than_the_output_queue_plays_whole keeps finding 239 of
240 clip frames at the keyed pad in CI, still 239 half a second after
the take completes, with no StingerFailed. The test now also counts
frames where the media player hands them to the flow, and how many
reached the keyed pad before the take. The failure message gives both
counts and the frame range each saw, which tells a frame the player
never sent from one lost on the way or one that passed before the take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens

srperens commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

@wagenet, thank you for this PR. It shaped #1010, which we have just opened: feat(vision-mixer): stinger transitions: classic, track matte and mask only. That PR takes several of your ideas:

  • opt-in stinger sources;
  • parking the clip on its first frame;
  • the lifecycle events;
  • falling back to a plain cut when a clip cannot play.

It also goes further, and we propose to go with it instead of this PR:

  • All three stinger kinds.

    • Classic, as here.
    • Track matte: graphic and matte in one file, side by side or stacked, in the OBS layout most stinger packs use. The matte switches the program pixel by pixel.
    • Mask only.

    The matte kinds need the GPU mixer and play as classic on the CPU mixer.

  • A take is planned, not chased. The Media Player's first frame is pinned to a chosen running time. The whole take is then programmed as control-binding keyframes on that timeline before the clip starts, so nothing holds the mixer's output.

  • The stinger library is the source's playlist, with cue, per-clip settings, automatic layout and cut-point detection, a take report (frames delivered, lateness, margin), and example clips Strom renders itself.

Could you test #1010 with your clips and setup? Mixed results, odd formats, and anything this PR handled that #1010 does not, would all be useful to hear about. If #1010 covers what you need, we would close this PR in its favour, with credit to you; it already credits you in the PR description and the commit.

🤖 Generated with Claude Code

@srperens srperens mentioned this pull request Oct 6, 2026
3 of 9 tasks
srperens added a commit that referenced this pull request Oct 6, 2026
…k only

A stinger plays a clip over the program while the source changes beneath
it. Three variants, after ATEM, vMix and OBS:

- Classic: a graphic with alpha; the program cuts or mixes beneath it at
  a cut point (by default where the graphic covers most, found by
  analysing the clip).
- Track matte: graphic and matte in one file, side by side or stacked
  (the OBS layout). The matte switches the program pixel by pixel.
- Mask only: the clip is a matte, an animated wipe.

The matte variants need the GPU mixer and play as classic on the CPU one.

The source is a Media Player in stinger mode wired to the mixer's new
stinger input; its playlist is the library and its block holds per-clip
settings. A cue parks a clip on its first frame. A take pins the running
time its first frame lands at, programs the whole take into the mixer as
control-binding keyframes on that timeline, and only then lets the clip
go, so nothing waits on wall clock or holds the mixer's output. The
matte composites inside glvideomixer: the matte pad takes the matte off
the destination alpha (reverse-subtract) and the incoming source blends
by it for the clip's frames.

Clip frames leave their clocksync ahead of time and are stamped half an
output frame into their frame, so a millisecond-rounding container cannot
move one into its neighbour's. A stinger source asks for system memory,
so a hardware decoder keeps alpha (VideoToolbox gives AYUV64 for ProRes
4444), and the stinger input converts what GL upload cannot take.

Operator panel: STING type, the clip library with cue, per-clip settings,
take reports, and example clips Strom renders itself (one per variant).
API: stinger state, cue, take, clip settings, examples; takes also run
through the transition endpoint. Events: StingerCued, StingerStarted,
StingerCompleted (with measurements), StingerFailed.

Builds on ideas from #755 by wagenet: opt-in sources, parking, events,
degrading to a cut when a clip cannot play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wagenet

wagenet commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@srperens thanks! I will definitely take a look

@wagenet

wagenet commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

@srperens I'm inclined to go ahead with #1010 since it looks like it covers more. Thanks!

@wagenet wagenet closed this Oct 6, 2026
srperens added a commit that referenced this pull request Oct 7, 2026
…k only

A stinger plays a clip over the program while the source changes beneath
it. Three variants, after ATEM, vMix and OBS:

- Classic: a graphic with alpha; the program cuts or mixes beneath it at
  a cut point (by default where the graphic covers most, found by
  analysing the clip).
- Track matte: graphic and matte in one file, side by side or stacked
  (the OBS layout). The matte switches the program pixel by pixel.
- Mask only: the clip is a matte, an animated wipe.

The matte variants need the GPU mixer and play as classic on the CPU one.

The source is a Media Player in stinger mode wired to the mixer's new
stinger input; its playlist is the library and its block holds per-clip
settings. A cue parks a clip on its first frame. A take pins the running
time its first frame lands at, programs the whole take into the mixer as
control-binding keyframes on that timeline, and only then lets the clip
go, so nothing waits on wall clock or holds the mixer's output. The
matte composites inside glvideomixer: the matte pad takes the matte off
the destination alpha (reverse-subtract) and the incoming source blends
by it for the clip's frames.

Clip frames leave their clocksync ahead of time and are stamped half an
output frame into their frame, so a millisecond-rounding container cannot
move one into its neighbour's. A stinger source asks for system memory,
so a hardware decoder keeps alpha (VideoToolbox gives AYUV64 for ProRes
4444), and the stinger input converts what GL upload cannot take.

Operator panel: STING type, the clip library with cue, per-clip settings,
take reports, and example clips Strom renders itself (one per variant).
API: stinger state, cue, take, clip settings, examples; takes also run
through the transition endpoint. Events: StingerCued, StingerStarted,
StingerCompleted (with measurements), StingerFailed.

Builds on ideas from #755 by wagenet: opt-in sources, parking, events,
degrading to a cut when a clip cannot play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Oct 7, 2026
…k only

A stinger plays a clip over the program while the source changes beneath
it. Three variants, after ATEM, vMix and OBS:

- Classic: a graphic with alpha; the program cuts or mixes beneath it at
  a cut point (by default where the graphic covers most, found by
  analysing the clip).
- Track matte: graphic and matte in one file, side by side or stacked
  (the OBS layout). The matte switches the program pixel by pixel.
- Mask only: the clip is a matte, an animated wipe.

The matte variants need the GPU mixer and play as classic on the CPU one.

The source is a Media Player in stinger mode wired to the mixer's new
stinger input; its playlist is the library and its block holds per-clip
settings. A cue parks a clip on its first frame. A take pins the running
time its first frame lands at, programs the whole take into the mixer as
control-binding keyframes on that timeline, and only then lets the clip
go, so nothing waits on wall clock or holds the mixer's output. The
matte composites inside glvideomixer: the matte pad takes the matte off
the destination alpha (reverse-subtract) and the incoming source blends
by it for the clip's frames.

Clip frames leave their clocksync ahead of time and are stamped half an
output frame into their frame, so a millisecond-rounding container cannot
move one into its neighbour's. A stinger source asks for system memory,
so a hardware decoder keeps alpha (VideoToolbox gives AYUV64 for ProRes
4444), and the stinger input converts what GL upload cannot take.

Operator panel: STING type, the clip library with cue, per-clip settings,
take reports, and example clips Strom renders itself (one per variant).
API: stinger state, cue, take, clip settings, examples; takes also run
through the transition endpoint. Events: StingerCued, StingerStarted,
StingerCompleted (with measurements), StingerFailed.

Builds on ideas from #755 by wagenet: opt-in sources, parking, events,
degrading to a cut when a clip cannot play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Oct 8, 2026
…k only

A stinger plays a clip over the program while the source changes beneath
it. Three variants, after ATEM, vMix and OBS:

- Classic: a graphic with alpha; the program cuts or mixes beneath it at
  a cut point (by default where the graphic covers most, found by
  analysing the clip).
- Track matte: graphic and matte in one file, side by side or stacked
  (the OBS layout). The matte switches the program pixel by pixel.
- Mask only: the clip is a matte, an animated wipe.

The matte variants need the GPU mixer and play as classic on the CPU one.

The source is a Media Player in stinger mode wired to the mixer's new
stinger input; its playlist is the library and its block holds per-clip
settings. A cue parks a clip on its first frame. A take pins the running
time its first frame lands at, programs the whole take into the mixer as
control-binding keyframes on that timeline, and only then lets the clip
go, so nothing waits on wall clock or holds the mixer's output. The
matte composites inside glvideomixer: the matte pad takes the matte off
the destination alpha (reverse-subtract) and the incoming source blends
by it for the clip's frames.

Clip frames leave their clocksync ahead of time and are stamped half an
output frame into their frame, so a millisecond-rounding container cannot
move one into its neighbour's. A stinger source asks for system memory,
so a hardware decoder keeps alpha (VideoToolbox gives AYUV64 for ProRes
4444), and the stinger input converts what GL upload cannot take.

Operator panel: STING type, the clip library with cue, per-clip settings,
take reports, and example clips Strom renders itself (one per variant).
API: stinger state, cue, take, clip settings, examples; takes also run
through the transition endpoint. Events: StingerCued, StingerStarted,
StingerCompleted (with measurements), StingerFailed.

Builds on ideas from #755 by wagenet: opt-in sources, parking, events,
degrading to a cut when a clip cannot play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Oct 8, 2026
…k only

A stinger plays a clip over the program while the source changes beneath
it. Three variants, after ATEM, vMix and OBS:

- Classic: a graphic with alpha; the program cuts or mixes beneath it at
  a cut point (by default where the graphic covers most, found by
  analysing the clip).
- Track matte: graphic and matte in one file, side by side or stacked
  (the OBS layout). The matte switches the program pixel by pixel.
- Mask only: the clip is a matte, an animated wipe.

The matte variants need the GPU mixer and play as classic on the CPU one.

The source is a Media Player in stinger mode wired to the mixer's new
stinger input; its playlist is the library and its block holds per-clip
settings. A cue parks a clip on its first frame. A take pins the running
time its first frame lands at, programs the whole take into the mixer as
control-binding keyframes on that timeline, and only then lets the clip
go, so nothing waits on wall clock or holds the mixer's output. The
matte composites inside glvideomixer: the matte pad takes the matte off
the destination alpha (reverse-subtract) and the incoming source blends
by it for the clip's frames.

Clip frames leave their clocksync ahead of time and are stamped half an
output frame into their frame, so a millisecond-rounding container cannot
move one into its neighbour's. A stinger source asks for system memory,
so a hardware decoder keeps alpha (VideoToolbox gives AYUV64 for ProRes
4444), and the stinger input converts what GL upload cannot take.

Operator panel: STING type, the clip library with cue, per-clip settings,
take reports, and example clips Strom renders itself (one per variant).
API: stinger state, cue, take, clip settings, examples; takes also run
through the transition endpoint. Events: StingerCued, StingerStarted,
StingerCompleted (with measurements), StingerFailed.

Builds on ideas from #755 by wagenet: opt-in sources, parking, events,
degrading to a cut when a clip cannot play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens added a commit that referenced this pull request Oct 8, 2026
…k only (#1010)

* feat(vision-mixer): stinger transitions: classic, track matte and mask only

A stinger plays a clip over the program while the source changes beneath
it. Three variants, after ATEM, vMix and OBS:

- Classic: a graphic with alpha; the program cuts or mixes beneath it at
  a cut point (by default where the graphic covers most, found by
  analysing the clip).
- Track matte: graphic and matte in one file, side by side or stacked
  (the OBS layout). The matte switches the program pixel by pixel.
- Mask only: the clip is a matte, an animated wipe.

The matte variants need the GPU mixer and play as classic on the CPU one.

The source is a Media Player in stinger mode wired to the mixer's new
stinger input; its playlist is the library and its block holds per-clip
settings. A cue parks a clip on its first frame. A take pins the running
time its first frame lands at, programs the whole take into the mixer as
control-binding keyframes on that timeline, and only then lets the clip
go, so nothing waits on wall clock or holds the mixer's output. The
matte composites inside glvideomixer: the matte pad takes the matte off
the destination alpha (reverse-subtract) and the incoming source blends
by it for the clip's frames.

Clip frames leave their clocksync ahead of time and are stamped half an
output frame into their frame, so a millisecond-rounding container cannot
move one into its neighbour's. A stinger source asks for system memory,
so a hardware decoder keeps alpha (VideoToolbox gives AYUV64 for ProRes
4444), and the stinger input converts what GL upload cannot take.

Operator panel: STING type, the clip library with cue, per-clip settings,
take reports, and example clips Strom renders itself (one per variant).
API: stinger state, cue, take, clip settings, examples; takes also run
through the transition endpoint. Events: StingerCued, StingerStarted,
StingerCompleted (with measurements), StingerFailed.

Builds on ideas from #755 by wagenet: opt-in sources, parking, events,
degrading to a cut when a clip cannot play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(stinger): widen the example blade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): keep the example blade's trails out of its matte

The blade's glow and trails were drawn unclipped, so near the right edge
they painted into the matte half of the side-by-side frame, and the
trails stayed on screen after the blade had left. Clip the graphic to its
half, and scale the trails with the blade's speed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(stinger): library API on the mixer, file guards, take ids

What an outside client needs to drive stingers without knowing the Media
Player behind them:

- POST/DELETE .../stinger/clips: add a clip to the library (idempotent,
  a local file must exist) or remove one with its settings, on the mixer.
- Calls that address a clip by index (cue, take, settings, remove) take
  an optional `file`; when the library has changed under the client, the
  call fails with 409 instead of acting on another clip.
- Every take has an id: in the take response and in StingerStarted,
  StingerCompleted (report) and StingerFailed. The take response also
  names the file played.
- from_input/to_input are optional in the transition request: a stinger
  always takes PGM to PVW; other transitions still require them (400).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): detect a clip's layout from the clip alone

Side by side and stacked were read against the program's aspect, so the
same file could detect differently on a 4:3 or vertical production. A
track-matte clip is two standard frames (16:9, 4:3 or 9:16) next to or
above each other, whatever the program runs; the analysis cache no
longer keys on the program either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stinger): render only the edge frames in the blade spill test

Every frame of the 3840x1080 example in a debug build took seconds of CPU
beside the lib's timing-sensitive tests. The spill can only happen as the
blade nears the right edge, so check those frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): warm the mixer up at cue; count late matte frames

On Linux CI (GStreamer 1.24.2, llvmpipe) a take that switched to a clip
of another size showed the matte one frame late: the stinger branch
renegotiated and the matte shader recompiled on the take's first frames.
A cue now sends one blank frame in the clip's format through the
consumer, so that happens at cue; the stinger pads are hidden between
takes, so it never shows.

The take report counted lateness on the graphic pad only; the matte pad,
with a shader pass of its own, now counts too. The tests read the clip's
frame count from its analysis, since 1.24.2's FFV1 decoder drops the
last frame of a stream, and print the whole take when a frame is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stinger): test clips in PNG, not FFV1

GStreamer 1.24.2's FFV1 decoder (Linux CI) drops a stream's last frame
now and then, so a clip's analysis and its playback could disagree on
its length and a take ran a frame longer than the test expected. PNG
frames in QuickTime are lossless, keep alpha, and decode through libpng
without frame threads. The FFV1 examples' own test allows the missing
frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stinger): show the mixer's pads mid-take when a matte frame is off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stinger): add caps, geometry and shader uniforms to the mid-take pad dump

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(stinger): show the program's middle row half way through a matte take

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): keep the matte shader out of FX passthrough

make_glshader now starts every glshader in passthrough (#1007). The matte
is not an FX slot and must always render: in passthrough the clip reached
the mixer as it is, and its own alpha became the matte. The GPU stinger
test fails without this.

Also report the take with the CPU test's cut assertion, and drop the
pad-dump eprintln (the dump stays in the failure message).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): place step keyframes half a frame before their frame

The mixer stamps output frames from a rounded origin, so a frame's
timestamp can be a nanosecond either side of FrameGrid's. A step key
placed exactly on the frame then acted on that frame or the next by
chance: depending on the take's phase, the graphic went on air a frame
late while the cut landed on time. cpu_stingers_play_classic failed 6 of
8 local runs with 'cut at frame 13'; with the keys half a frame early it
passed 8 of 8. The linear mix ramp keeps its exact times.

The test rig now collects frames over a span of stream time, not wall
time: on a slow runner the debug-build mixer falls behind real time and
the window ended before the clip did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): look up overlay state by flow and block id after #1015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): re-cue when another file lands on the parked index

A stinger source counted itself parked on a playlist index, not on a file.
Removing the cued clip, or a playlist PUT that put another file at the
parked index, left the old clip's first frame parked: the panel showed the
new clip ready and the next take played the old one with the new clip's
settings.

The player now records the playlist entry its internal pipeline holds.
`is_parked_on` compares it with the entry at the index, and a cue reloads
when they differ, so both the library edit and the playlist endpoint
re-cue the right file.

Guard tests (stinger_api_test): removing_the_cued_clip_parks_its_successor
and a_playlist_put_over_the_parked_index_recues_it check what airs, and
both fail with the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): leave a restarted flow's new run alone

A stop clears a take's claim, but the take did not look again after its cue
and analysis awaits. After a stop and start in that window,
`pipelines.get(flow)` returned the new run, and the old take programmed its
mixer, then (its clip source gone) aborted and cut the new run's program.
The finish step had the same gap after its wait loop.

The take now re-checks its claim, and that its clip source is still the
registered one, under the `pipelines` read lock before it programs the
mixer, before it aborts on a clip that would not play, and before it
settles the pads at the end. A stop clears every claim before it takes
the pipeline away, so a claim held under that lock means the run the take
started on. A take that lost its claim fails without touching anything and
without "cut instead".

Guard test (stinger_test):
a_take_outlived_by_a_restart_leaves_the_new_run_alone. Timing a restart
into a real cue or analysis is not reliable with the test's small clips,
so a test-only hook (`hold_takes_for_tests`, per flow) holds take A at the
point right before programming while the flow restarts. The test then
checks that A fails with program_changed false, the new run's PGM is
untouched, and a classic take on the new run lands frame by frame. With
the re-check removed it fails ("clip 1 could not play ..., cut instead").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): report a late cut frame instead of a clean take

A take's cut keyframes fire at the cut point whether or not the clip has
reached the graphic pad. A clip running late airs the new source with no
graphic over it, yet the take reported `frames_arrived` n/n (every frame
that reached the pad, including those the mixer had already passed) and
a plain StingerCompleted.

The counting probe now counts a graphic frame as arrived only when it is
in time, and records whether the classic clip's frame due at the cut point
(the newest clip frame that starts before the cut's output frame ends)
arrived in time. Still atomics only in the per-buffer probe: one range
check and one fetch_or. When that frame was late, the take report carries
a new `warning` field and the backend logs it. StingerTakeReport is new in
this PR, so the optional field breaks no client; StingerCompleted and
StingerFailed keep their fields. openapi.json updated.

Guard test (stinger_test): a_late_cut_frame_is_reported holds the take's
first clip frame for a second before the graphic pad, and requires a
warning and fewer than n frames arrived. With the probe and report change
reverted it fails (warning None, 30/30 arrived). The classic take helper
now also requires a clean take to carry no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): undo a take that fails part way through programming

`program_stinger` cancels a fade-to-black, resets the pads and then keys
alphas, z-orders and blend modes one property at a time. A failure after
the first of those left the mixer half programmed: the keyframes already
set could change the program on air with no graphic, the error reported
`program_changed: false`, and the ended fade-to-black was never broadcast.
A failed `spawn_blocking` join around `play_at` returned early in the same
state, without the abort the play error path does.

`program_stinger` now aborts the take itself (old source alone on air,
stinger pads down) when a pad change fails, and its error carries whether
it ended a fade-to-black. The take broadcasts VisionMixerFtbChanged for
that, and also when it aborts after a clip that would not play. A failed
join now goes down the same abort and "cut instead" path as a play error.

Guard test (stinger_test):
a_take_that_fails_to_program_leaves_the_old_source_on_air. No real
keyframe failure can be caused on demand, so a test-only hook
(`fail_stinger_programming_for_tests`, per flow) fails programming after
every pad change is made. From fade-to-black, the test requires the old
source alone on air through where the clip and cut would have aired, an
FTB-ended event, and a normal classic take afterwards. Without the abort
it fails (the cut airs); without the broadcast it fails (no FTB event).
The join-failure path has no guard: it needs a panic inside `play_at`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): honour cut and end the mix with the clip for a downgraded mask

A mask-only clip downgraded to classic on a CPU mixer took the operator's
cut point but then mixed for the full mix length whatever `beneath` said.
It now cuts when `beneath` is `cut`, and clamps the mix to the rest of
the clip like the classic branch, so the fade never outlasts the clip.

Guards: a_downgraded_mask_with_a_cut_point_honours_cut and
a_downgraded_mask_mix_ends_with_the_clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): pin a take's start to the clip's first video frame

Audio and video of a stinger clip source share one Timing, and a take's
pinned start was taken from whichever stream reached the bridge first.
For a clip whose audio starts before or after its video (an MP4 edit list,
B-frames, audio priming) the first video frame then aired off the frame
the mixer was programmed for, by the difference.

The park probe on the video clocksync now also keeps the last buffer's PTS
(one more atomic store). play_at turns the parked frame's PTS into its
running time from the clocksync's segment and pins the baseline to it, so
the first video frame lands on start_at and audio keeps its offset from
video. Without a parked frame the old behaviour stays. An ordinary player
never pins, so its timing is unchanged.

Guards:
- a_pinned_start_lands_the_first_video_frame_when_audio_comes_first
  (timing.rs unit test, audio placed first).
- a_clip_with_early_audio_lands_its_first_video_frame_on_the_take
  (stinger_test): a classic clip whose PCM audio starts 200 ms before its
  video, CPU mixer, checked frame by frame on the program output. Which
  stream reaches the bridge first is a race, so the test holds the take's
  first video sample for 30 ms through a new #[doc(hidden)]
  hold_bridge_for_tests hook; the bridge checks it with one relaxed load
  per sample. Fails 4/4 with the pin taken as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): share the running clip analysis with a take

A clip taken straight after it was added decoded twice: once in the
background run the add started, and again in the take, whose inline
analyze_cached did not look at that run.

analyze_cached now runs once at a time per file. A caller that comes while
a run is going waits for it and shares its result (a per-file slot with a
Condvar), and a guard hands an error to the waiters if the run stops
without one, so nobody waits forever. With no run going it analyses as
before. The take still re-checks that the flow was not restarted after
the analysis await.

Guard: a_clip_taken_straight_after_it_was_added_is_analysed_once
(stinger_test) adds a 1080p clip and takes it at once, then checks a new
#[doc(hidden)] per-file counter of analysis runs is 1. With the old
analyze_cached it counts 2 (5/5 runs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(stinger): park probe fires once per cue, not on every buffer

The park probe on a stinger clip source's video clocksync stayed on the
pad for good, counting every buffer of every take, though only the first
frame after a cue matters: it tells the cue the clip is parked and gives
the take that frame's PTS to pin the start to video.

The probe is now one-shot. It records the parked frame's PTS, bumps the
parked-frame count and returns Remove. StingerPlayback::arm_park_probe
installs a fresh one each time a frame is expected to park:
- The bridge arms it whenever it builds the first video chain of a stinger
  source. Loading a clip removes the bridge chains, so a cue that loads
  finds no chain to arm on; the first buffer into the new chain is the
  loaded clip's first frame. Arming on every new chain also covers the
  builder-seeded first clip, whose chain appears before or after its cue.
  A relinked chain (an HLS pad taking over) is not armed.
- A cue that rewinds a loaded clip arms on the existing chain before the
  seek.
The probe id is kept in the player with the parked-frame count it was
armed at; re-arming removes a previous probe that has not fired yet. The
lock is taken once per arm, never in the probe. last_pts is renamed
parked_pts, and the take reads the segment from the named park clocksync.

The bridge's test-hold check in new_sample is gated by a bool captured
when the callback is built, so an ordinary Media Player never loads the
BRIDGE_HOLDS_ARMED flag.

Guard: a_take_plays_with_no_park_probe_on_its_path (stinger_test) takes a
30-frame clip and checks the parked-frame count moves at most twice over
the take and the re-cue after it. With the probe returning Ok again it
moves 31 and the test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): restart a stinger source output that stopped on an error

A stinger cue also restarts a source output that stopped on an error,
with a flush local to its branches, so a clip some consumer cannot take
costs that clip and not every take after it. The GL input adaptation
that made a ProRes 4444 clip after another format play at all is on
main since #1033; the tests here cover the switch between formats on
the stinger path and the recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(stinger): broadcast-grade example clips and a stacked track matte

Redraw the example stingers so they look like a stinger pack rather than
test patterns, and add a fourth clip so every layout has an example.

- strom-sweep (classic): pink, cyan and navy slanted panels whoosh in with
  glowing edges, light streaks and a lens flare; the navy panel covers the
  whole frame with an extruded, glowing STROM wordmark whose letters fly
  in with overshoot and leave after a small pull back.
- strom-blade (track matte, side by side): a white-hot blade with a
  chromatic fringe, crackling arcs, speed lines and sparks; the matte's
  soft switch sits just behind the light.
- strom-ribbons (track matte, stacked, new): seven brush ribbons with
  bristle texture, shading and drop shadows fan out from the middle; the
  matte reveals the new source behind their heads.
- strom-shards (mask only): hexagonal tiles flip open in a staggered wave
  through grey to white.

Moving clips average sub-frame samples for motion blur; mattes are drawn
once per frame. Randomness comes from a seeded generator. The examples
render in parallel, and examples.rs is split into one module per clip.

Tests check full cover of the classic, a matte that is never painted
into, goes strictly from 0 to 255 and never back, a clean frame at both
ends, a monotonic mask, and the layout, cut point and matte marks of
every encoded example, the stacked one included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(stinger): modern ink, ultraviolet and volt palette for the sweep and ribbons examples

The sweep's royal blue panel and the ribbons' rainbow of light gradient ends
read as pastel. Both now share one palette: ink and graphite, an
ultraviolet-to-magenta family and a single acid volt accent, dark at the
tail and saturated at the head, with a lighter top highlight on the ribbons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(stinger): reload the library from disk, and a denser clip list

A clip file rewritten under the same name kept playing its old content:
the player only compared file names. The stinger clip source now records
the loaded file's modification time and length, and a parked clip whose
file changed since is not parked (is_parked_on is false), so the panel
shows it not ready and a plain cue or take loads it again. A file gone
from disk keeps the parked clip.

POST .../stinger/reload analyses every clip whose file has no analysis
for its current content, loads and parks the cued clip again when its
file changed, flags clips whose files are gone (new `missing` field on
StingerClip) and returns the stinger state. Refused with 409 while a
stinger is on air. The analysis cache keys on the same stamp, now at
nanosecond resolution.

Operator panel: clip tiles in up to four columns at two rows' height,
a scrollbar that stays visible on macOS, a clip count, the cued clip
kept in view, MISSING and analysing badges, and a RELOAD button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): load a clip again when it cannot seek back to its start

A stinger clip from a source that cannot seek (an HTTP server without
range requests) played its first take, but the re-park after it failed
with "Seek failed": cue() returned the error and left the clip neither
parked nor reloaded, and every later cue and take failed the same way
until the flow restarted. When pausing and rewinding fails, the cue now
loads the clip again, which starts it from its first frame; the bridge
arms the park probe on the new chain, and the parked-frame count is read
again before the load. A seek that works behaves as before.

The guard test serves a Matroska clip from a local HTTP server with
`Accept-Ranges: none` and takes it twice; both takes must deliver every
frame. CI installs libsoup-3.0-0 explicitly so souphttpsrc loads. The
operator guide says stinger clips are meant to be local files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): park a clip's first frame even before the flow runs

A stinger source parks its first clip while the flow is still starting.
The clocksync pacing probe drops every buffer that arrives before the
flow has a running time (so a live stream is never paced against its raw
timeline), and the internal pipeline decodes on meanwhile, so the frame
that parked was whichever came after the flow reached PLAYING: frame 3,
or frame 17 on a loaded machine. Every take of that clip then started
that many frames in, and its report counted fewer frames than the clip
has.

A stinger clip's parked frame needs no offset from the flow: the take
sets the clocksyncs and pins the start from that frame. Let it through
and remove the probe instead of dropping it. An ordinary player keeps
dropping as before.

This was the flake in stinger_prores_test (frames_arrived 29 or 27 of
30, or the graphic on air for 16 frames, on the first take of a clip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(openapi): regenerate the snapshot after rebasing onto the media download API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(stinger): address review findings on takes, the clip source and the library

- A mask-only clip downgraded to classic on the CPU mixer no longer raises
  its grey frames over the program; the cut-frame check skips a clip with
  no graphic.
- Fade-to-black is refused while a stinger is on air, so the take's end no
  longer brings the program back under a flagged fade.
- Classic takes and fade-to-black claim the mixer while they program the
  pads, closing the window between the on-air check and the pipeline lock.
- The Media Player playlist, control, seek and goto endpoints answer 409
  while the player is playing a stinger take.
- Stinger library edits are serialised and refused during a take, so
  clips added together all land.
- A mix beneath a classic take ends with the clip when the cut point moves
  up to the output grid (take timing moved to crate::stinger::take_times).
- The cue's warm-up frame is skipped when the consumer already took one in
  the same format, and an unlinked output is not flushed on every cue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: install gsettings-desktop-schemas for the HTTP stinger test

libsoup asks GIO for the system proxy, and GIO's proxy lookup aborts the
whole test process when the org.gnome.system.proxy schema is missing:
stinger_api_test died with SIGTRAP on Linux CI, twice on 038a224.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: compile the GSettings schemas after restoring cached packages

cache-apt-pkgs-action restores package files without running triggers,
so gschemas.compiled never gains org.gnome.system.proxy on a cache hit,
and the HTTP stinger test's process aborts in GIO's proxy lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants