Skip to content

fix(vision mixer): adapt GPU inputs to any pixel format, converting only while needed - #1033

Merged
srperens merged 2 commits into
mainfrom
fix/vision-mixer-input-format
Oct 8, 2026
Merged

srperens merged 2 commits into
mainfrom
fix/vision-mixer-input-format

Conversation

@srperens

@srperens srperens commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #674

Problem

A GPU Vision Mixer input starts with glupload, which uploads a fixed list of system-memory pixel formats. A source in any other format was refused:

  • A flow link from a capsfilter pinned to such a format (AYUV64, GBR_10LE) was never made. The linker's caps check failed, the link stayed pending, and the source stopped with not-linked. The flow never reached PLAYING.
  • A format change in the middle of a stream (a Media Player moving on to a ProRes 4444 clip, which decodes to AYUV64 on macOS) failed the producer with not-negotiated.

On the CPU backend, #674's cases already work on main: each input has its own converter. Nothing guarded that, though.

Change

GPU path (gst/gl_input_front.rs, gst/video_adapt.rs)

  • decide() gets a rule for Consumer::GlUpload(&caps): raw video in system memory only, in no format glupload uploads, needs [VideoConvert]. The upload formats come from glupload's sink template (gl_upload_system_caps()), so they follow the installed GStreamer. This part comes from the stinger work on feat/stinger.
  • The converter is transient. It goes in and comes out on the input's streaming thread, in an EVENT_DOWNSTREAM probe on the front queue's source pad. The probe acts on CAPS events only, never on buffers, and makes its change as the CAPS event leaves the queue. GStreamer reads the pad's peer after the probes have run, so the event and every frame after it go to the new chain. Frames queued in the old format have already gone through the old one. The converter comes out as soon as the caps are something glupload takes directly, or CUDA memory. Unlinking it, relinking around it, setting it to NULL and removing it from the bin all happen in that probe. Only that thread pushes on that link, so no IDLE wait is needed.
  • No CPU round trip for CUDA memory. When CUDA memory follows a converted format, the same probe takes the converter out and then splices cudadownload (CUDA-GL interop) directly in front of glupload. It never lands in front of the converter. While the converter is in, a CUDA query does not trigger the query-time cudadownload splice. Inputs that never needed the converter keep the existing query-time cudadownload splice (IDLE probe), unchanged.
  • Queries before the CAPS event are answered for the input as it will be after it:
    • An ACCEPT_CAPS for a format only the converter takes is accepted while the converter is out.
    • An ACCEPT_CAPS for caps the input takes without the converter is answered by the element after the converter while the converter is in. For CUDA memory with cudadownload not yet in, it is answered from cudadownload's sink template.
  • CAPS-query answer. While the converter is in, the answer lists, in this order: what the element behind the converter takes directly (glupload's GL and uploadable system-memory entries), the CUDA alternative of those, and only then what the converter adds. While the converter is out, raw video in system memory in any format is appended last, and only when the query's filter offers nothing the input takes directly or there is no filter (the linker's link check). That append is what lets the flow link be made. A filtered query that offers something the input takes directly gets the same answer as before.
  • GStreamer before 1.24.13. glupload there answers a CAPS query with only the caps of the upload method it is using. With the converter in, that is the GL memory method, so an unfiltered query got GL memory only: no system-memory formats, so no CUDA alternative. On 1.24.2, NVDEC into an input that had the converter in then decoded to system memory, one CPU round trip per frame. takes_directly() asks glupload a second time with a system-memory filter, which makes it list every method, and merges the answers. 1.24.13 and later list every method anyway, so nothing changes there.
  • All state shared by the probes holds only weak references (WeakRef<Element>, WeakRef<Pad>).

CPU path: no code change. The per-input converter and capsfilter_in on main already handle #674's cases. The new tests guard them.

Rejected:

  • Splicing the converter at query time and leaving it in, as on feat/stinger. A cudadownload spliced later lands in front of it, and the converter's CAPS answer offers every CPU format.
  • Removing the converter at query time. Frames already queued in the old format would reach glupload directly and fail.
  • A fixed BGRA capsfilter, as the triage suggested. It forces a CPU conversion on every input and drops GL/CUDA memory.
  • autovideoconvert, per BLOCK_GUIDELINES.

Evidence

  • GPU input takes AYUV64 and GBR_10LE from a pinned capsfilter, with the converter in: gpu_ayuv64, gpu_gbr_10le. Both fail with decide()'s VideoConvert rule removed (the main-equivalent behaviour) and with the staged feat/stinger front: start vision mixer pipeline: StateChange(... current: Paused, pending: Playing), from a link the linker never made. They also fail with the any-format entry left out of the CAPS-query answer.
  • The converter comes out when the format switches back (I420, then GBR_10LE, NV12, AYUV64, I420), and glupload then receives the source's own format: gpu_converter_comes_out_when_the_format_switches_back. It fails with the removal disabled: NV12: glupload takes it, but the input still runs ["videoconvert"].
  • CUDA memory after a converted format bypasses the converter (AYUV64, then CUDA, AYUV64, CUDA, through a cudadownload stand-in), and while the converter is in, CUDA memory is offered ahead of the converter's formats: cuda_after_a_converted_format_bypasses_the_converter. It fails:
    • with the removal disabled (adapters ["videoconvert", ...]);
    • with the answer reordering reverted to the staged behaviour (the CUDA entry after the converter's entries);
    • with the staged front.
  • An unpinned source told only "raw video" while the converter is in settles on a format glupload uploads, and the converter comes out: second half of gpu_converter_answers_with_direct_formats_first. It fails with the removal disabled. Its first half (direct entries before converter-only ones) also passes with the reordering reverted. videoconvert's own answer already puts passthrough caps first, so for GL/system memory it documents the behaviour and does not guard it. The CUDA ordering is guarded by the CUDA test above.
  • CPU backend takes Vision mixer inputs require BGRA, but the requirement isn't negotiated and videoformat can't select it #674's cases (direct, via videoconvert, I420, NV12, AYUV64, GBR_10LE): cpu_*. With the per-input converter removed from pipeline_cpu.rs, cpu_ayuv64 fails; the other five pass, because the compositor takes those formats itself.
  • Answer ordering as a unit: gl_input_front::tests::with_the_converter_in_direct_paths_come_first.
  • NVDEC into an input that had the converter in stays on the GPU: nvdec_cuda_after_a_converted_format_stays_on_the_gpu (#[ignore], NVIDIA only), run on an NVIDIA L4 with GStreamer 1.24.2. nvh264dec src: video/x-raw(memory:CUDAMemory), format=NV12; glupload sink: video/x-raw(memory:GLMemory), format=NV12; adapters ["cudadownload"], no videoconvert left. Without the 1.24 fix it fails: the decoder's frames stay in system memory.
  • The 1.24 fix: with it reverted, on 1.24.2 gpu_converter_answers_with_direct_formats_first fails (the answer lists nothing only the converter takes) and so does cuda_after_a_converted_format_bypasses_the_converter (does not offer CUDA memory, the error the first CI run on this PR showed). On 1.28 it cannot be made to fail, since glupload there already lists every method; CI's 1.24.2 is what guards it.

Tests

Ran on macOS (GStreamer 1.28.6, native GL), with STROM_REQUIRE_GL=1 STROM_REQUIRE_GST_PLUGINS=1:

  • vision_mixer_input_format_test: 14 passed (run 4 times)
  • vision_mixer_input_format_cuda_test: 1 passed, 1 ignored (the NVDEC test)
  • vision_mixer_cpu_test 7, vision_mixer_fx_test 2, vision_mixer_source_resize_test 8, pipeline_lifecycle_test 4, failed_start_teardown_test 3, shader_validation_test 2, gl_memory_link_test 7, thumbnail_tap_cuda_test 3, video_input_bridge_test 5, video_input_bridge_gl_test 4, vision_mixer_cuda_input_test 2, vision_mixer_cuda_missing_test 1, openapi_test 1: all passed
  • cargo test --workspace --lib: 900 + 28 + 101 passed, 5 ignored
  • cargo clippy --all-targets --features efp -- -D warnings, cargo fmt --check: clean

Skipped: none. Every GL test ran with a GL context.

On Linux with an NVIDIA L4 (GStreamer 1.24.2, headless EGL), after the 1.24 fix:

  • vision_mixer_input_format_test: 14 passed
  • vision_mixer_input_format_cuda_test: cuda_after_a_converted_format_bypasses_the_converter skips where the real cudadownload is installed; with nvcodec hidden (as on CI) it ran and passed
  • vision_mixer_input_format_cuda_test -- --ignored nvdec: passed (encodes with nvcudah264enc; the legacy nvh264enc fails with Selected preset not supported on that driver)
  • vision_mixer_cuda_input_test: 2 passed

Not run:

  • The full cargo test --workspace integration suite.
  • Windows. Linux without NVIDIA only through CI. The format choice was checked against 1.24.2's GST_GL_MEMORY_VIDEO_FORMATS_STR: AYUV64 and GBR_10LE are missing there too, while A444_10LE uploads on 1.24.2, so it is not used.

For the reviewer

  • On a host with real nvcodec, cuda_after_a_converted_format_bypasses_the_converter skips. Its appsrc pushes system memory labelled as CUDA memory, which the real cudadownload cannot take. The ignored NVDEC test covers that host.
  • Unfiltered CAPS queries on a GPU input now end with one extra system-memory entry. Producers that fixate on the first entry are unaffected. A producer that intersects in zig-zag order could now pick a format only the converter takes. No such case was seen; please consider it.
  • Converter insertion and removal run on the input's streaming thread, including set_state(Null) and bin.remove() of the removed converter. This follows the dynamic-pipeline pattern, and pipeline_lifecycle_test passes.

Out of scope

🤖 Generated with Claude Code

…nly while needed

A GPU Vision Mixer input starts with glupload, which uploads a fixed list
of system-memory formats. A source in any other format (AYUV64, which a
ProRes 4444 clip decodes to on macOS, or GBR_10LE) was refused: a flow
link from a capsfilter in such a format was not even made, and a format
change mid-stream failed the producer with not-negotiated.

The GL input front now adapts pixel formats as well as CUDA memory.
video_adapt::decide() calls for a videoconvert when the producer offers
raw video in system memory in no format glupload uploads (from the
change made for the stinger on feat/stinger).

The converter is transient. It goes in and comes out on the input's
streaming thread, in a probe on the front queue's source pad, as a CAPS
event leaves the queue, so frames queued in the old format still go
through the old chain. It comes out as soon as the caps are something
glupload takes directly, or CUDA memory; cudadownload (CUDA-GL interop)
then goes in directly in front of glupload, never in front of the
converter, so NVDEC output never takes a CPU round trip.

Until the CAPS event, queries are answered for the input as it will be:
an ACCEPT_CAPS for a format only the converter takes is accepted, one
for caps the input takes without it is answered by what follows the
converter. While the converter is in, the answer to a CAPS query lists
what glupload or cudadownload takes directly, CUDA memory included,
ahead of what only the converter takes, so a decoder still picks GPU
memory. Raw video in system memory in any format is added last to the
answer only when a query offers nothing the input takes directly, or
nothing at all (the linker's link check); other answers are unchanged.

The CPU backend already converts on each input; the new tests show
#674's cases pass there, and guard it.

Fixes #674. Part of #969's direction (consumer-side adapters decided by
decide(), no autovideoconvert); follows #951.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@srperens srperens mentioned this pull request Oct 7, 2026
… 1.24

Before GStreamer 1.24.13, glupload answers a CAPS query with only the caps
of the upload method it is using, whenever those meet the filter. With the
input's videoconvert in, the converter writes into glupload's own pool, the
method is the GL memory one, and an unfiltered query gets GL memory only.
The answer built while the converter is in then had no system-memory
formats, so no CUDA alternative, and listed nothing only the converter
takes. On 1.24.2, NVDEC into an input that had the converter in decoded to
system memory and took a CPU round trip.

Ask glupload a second time with a system-memory filter, which misses the
GL memory method and makes it list every method, and merge the two
answers. An unfiltered answer while the converter is in also lists the
converter's system-memory caps, last, as it does while the converter is
out.

Tests:
- The CAPS-answer assertions print the answer.
- gpu_converter_answers_with_direct_formats_first counts CUDA memory as
  taken directly, since cudadownload takes it with no conversion.
- The NVDEC test encodes with the first working NVENC H.264 encoder
  (nvcudah264enc, nvautogpuh264enc, nvh264enc): the legacy nvh264enc
  fails with "Selected preset not supported" on some driver and GPU
  combinations.

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

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verdict: Comment. The design is right and follows BLOCK_GUIDELINES. I found no defect in the code. CI has not run at this head, which blocks approval.

Needed before merge

  1. CI at 6581f3d. Run 37642337039 was created 2026-10-07T15:06Z, is still queued and has no jobs. Re-trigger: gh run cancel 37642337039 && gh run rerun 37642337039, or push an empty commit. The previous head dc4d16a was red in Check (Linux), on cuda_after_a_converted_format_bypasses_the_converter (does not offer CUDA memory, vision_mixer_input_format_cuda_test.rs:323). 6581f3d fixes that. cargo test stops at the first failing binary, so vision_mixer_input_format_test has never run in CI at any head. pipeline_lifecycle_test ran and passed at dc4d16a.

Claims

Claim Verdict Evidence
Probes fire per event or query, never per buffer CONFIRMED backend/src/gst/gl_input_front.rs:304 — front_src.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, move. No BUFFER probe is added
Shared probe state holds weak references only CONFIRMED backend/src/gst/gl_input_front.rs:221 — upload: glib::WeakRef<gst::Element>, and front_src likewise
An ACCEPT_CAPS for a convertible-only format is accepted before the converter is in CONFIRMED backend/src/gst/gl_input_front.rs:343 — q.set_result(convert_sink_caps().is_some());
The new tests guard the change UNVERIFIED Revert rows are on the author's macOS and an L4 rig. No CI run has executed them at this head (above)

Diagnosis. The cause is right: glupload uploads a fixed format list, and before this PR the input front never adapted pixel format. Coverage is BOUNDED to Vision Mixer GPU inputs, the only caller of install(). The deprecated Compositor still feeds glupload with no front: backend/src/blocks/builtin/compositor.rs:347 — let upload = gst::ElementFactory::make("glupload"). Putting the converter in and out from the queue's own src thread, as the CAPS event leaves, is sound. Nothing else pushes on that link, and the converter has no thread of its own to stop. That makes set_state(Null) and bin.remove() from inside the probe safe.

Behaviour change on every GPU input. Every Vision Mixer GPU input changes, including ones that never need a converter. An unfiltered CAPS query now ends with "any system-memory raw video": backend/src/gst/gl_input_front.rs:489 — if filter.is_none() || offers_only_convertible {. The author flags zig-zag intersection as the open risk. SPECULATIVE (not verified): a producer that intersects that way could now take a CPU conversion it did not need.

Radius. SHARED: one block's GPU inputs. video_adapt::decide() changes its Consumer::GlUpload signature, and gl_input_front.rs is its only caller. The new Adapter::VideoConvert variant breaks no exhaustive match: build_elements and installed both handle it, and no other open PR touches video_adapt.rs.

Overlaps.

  • #674 owns this decision. My triage there recommended a fixed BGRA capsfilter (Option 1), and its Ask is still open. This PR rejects that option, and it is right to. A fixed BGRA capsfilter forces a CPU conversion on every input and drops GL/CUDA memory, which BLOCK_GUIDELINES' memory-format rule forbids. That rule postdates my triage. The PR comes from someone who may decide, so I read it as the answer to #674. Merging it closes that Ask.
  • #969 keeps the CPU path's VideoConvertMode. It is out of scope here, as the PR says.

Tests & CI. No checks at 6581f3d (above). macOS and Windows are skipped on pull requests. The platform-specific evidence (ProRes 4444 on VideoToolbox) comes from the author's macOS run only. Merging on Linux green and letting main's macOS run cover it is reasonable.

Confidence: MED

@srperens srperens closed this Oct 8, 2026
@srperens srperens reopened this Oct 8, 2026
@srperens
srperens merged commit c96c27f into main Oct 8, 2026
9 checks passed
srperens added a commit that referenced this pull request Oct 8, 2026
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>
srperens added a commit that referenced this pull request Oct 8, 2026
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>
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>
@srperens
srperens deleted the fix/vision-mixer-input-format branch October 8, 2026 09:51
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.

Vision mixer inputs require BGRA, but the requirement isn't negotiated and videoformat can't select it

1 participant