Repository navigation
feat(vision mixer): report take morphs and per-input media age - #803
Conversation
|
Noted as a draft, so I am holding off on a full review until it is marked ready for |
7308808 to
314cbdd
Compare
…ma change Eyevinn#806 made trigger_transition's indices optional; the tests Eyevinn#803 adds still passed bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4da92ae to
82bc7be
Compare
…usal tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#803's two FTB-refusal tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment. The mechanism is right and the BUFFER probe meets the CLAUDE.md constraints. What holds this back from Approve is CI: this repo has never run the two GPU-path guards. Both skip green on Linux, and the macOS job that would render them did not run.
Requested changes
- Maintainer: add the
ci:macoslabel so thatvision_mixer_fx_testrenders at this head. The alternative is to merge #809 first, which makes the Linux job render GL on every PR, and then rebase this one. Either way,gpu_mixer_reports_media_age_per_inputandmaster_fx_take_out_of_a_pip_reports_the_effectthen execute instead of returning early.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The probe does no lock, allocation or formatting per buffer | CONFIRMED |
The probe body (see Diagnosis) calls only backend/src/blocks/builtin/vision_mixer/overlay.rs:244 — let ms = self.instant_base.elapsed().as_millis() as u64; plus one relaxed store. It reads the clock through Instant, not SystemTime |
| The closure forms no reference cycle | CONFIRMED |
It captures only an Arc<VisionMixerOverlayState>, and that struct has no gst:: field. The setup closure keeps the existing dist_weak.upgrade() pattern |
| An unknown name is refused before FTB is cancelled | CONFIRMED |
backend/src/gst/pipeline/effects/take.rs:76 — `let trans_type = transition_type.parse::().map_err( |
| The GPU builder installs the probes, and a master-FX take out of a PiP reports the effect | UNVERIFIED |
backend/tests/vision_mixer_fx_test.rs:712 — async fn gpu_mixer_reports_media_age_per_input() { and :626 both return early when there is no GL. In job 108793044875 the whole file reports 4 passed ... finished in 0.06s, which is the skip signature. .github/workflows/ci.yml:689 — STROM_REQUIRE_GL: 1 is set on the macOS job only, and Build (macOS) is skipping |
Diagnosis. The probe is installed per input:
backend/src/blocks/builtin/vision_mixer/activity.rs:40 — pad.add_probe(gst::PadProbeType::BUFFER, move |_pad, _info| {
Coverage is ABSOLUTE for arrival: the stamp sits on the dist compositor's sink_i, so every input type is covered, including hidden ones. Frames the compositor discards after a backwards timestamp jump are the one blind spot, and the PR body and the field doc both state it.
Radius. SHARED. Every vision mixer in every flow now carries num_inputs BUFFER probes, on both builders. actual_transition_type has also gained a new value. A client that flags a downgrade whenever actual != requested will now flag every PiP fade that ran as morph. The bundled page was updated for this. External clients were not, and nothing in this repo can find them.
Overlaps
- #806 changes the same prologue of
trigger_transition. It adds an index check that also runs before the FTB cancel, and it rewrites theold_pgm/from_inputfallback right next to the parse block this PR removes. The two will conflict textually. #806 is older and its semantics are independent, so land it first and rebase this one. - #809: see requested change 1.
- #887 (registry keyed by block id) is the known gap the body names. This PR does not settle it.
Tests & CI. At 2993a45, Check (Linux), both Build (Linux) jobs, WASM, API Contract Check and Static JS Tests are green. The CPU guards executed: media_age_separates_live_stalled_and_unlinked_inputs, punch_in_taken_to_plain_input_reports_morph, the four take::tests, and both vision_mixer::tests probe tests.
Confidence: HIGH
…ma change Eyevinn#806 made trigger_transition's indices optional; the tests Eyevinn#803 adds still passed bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 7972650)
…usal tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#803's two FTB-refusal tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> (cherry picked from commit d20b5d9)
… flags Integration fix-up: upstream Eyevinn#789 adds two structured-logging flags to EventBroadcaster::new and AppState::new. Tests from Eyevinn#797, Eyevinn#803, Eyevinn#809, Eyevinn#810 and Eyevinn#844 still call the old signatures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ma change Eyevinn#806 made trigger_transition's indices optional; the tests Eyevinn#803 adds still passed bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 7972650)
…usal tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#803's two FTB-refusal tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> (cherry picked from commit d20b5d9)
… flags Integration fix-up: upstream Eyevinn#789 adds two structured-logging flags to EventBroadcaster::new and AppState::new. Tests from Eyevinn#797, Eyevinn#803, Eyevinn#809, Eyevinn#810 and Eyevinn#844 still call the old signatures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ma change Eyevinn#806 made trigger_transition's indices optional; the tests Eyevinn#803 adds still passed bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 7972650)
…usal tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#803's two FTB-refusal tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> (cherry picked from commit d20b5d9)
… flags Integration fix-up: upstream Eyevinn#789 adds two structured-logging flags to EventBroadcaster::new and AppState::new. Tests from Eyevinn#797, Eyevinn#803, Eyevinn#809, Eyevinn#810 and Eyevinn#844 still call the old signatures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2993a45 to
a437f8a
Compare
c15b597 to
ac097dd
Compare
ac097dd to
8e02af3
Compare
srperens
left a comment
There was a problem hiding this comment.
Supersedes my review of 2026-09-29 at 2993a45. That review's verdict was Comment. It is now Approve, because the blocker it raised has been resolved.
Verdict: Approve. At 8e02af3 this is a rebase onto dcccdd7 plus one test-only commit. That commit adapts the guards to the per-flow overlay registry (#1015) and to either trigger_transition index signature. The mechanism is unchanged from what I confirmed last time, and the GPU guards that I left UNVERIFIED have now executed in this repo's CI.
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The probe does no lock, allocation or formatting per buffer | CONFIRMED |
backend/src/blocks/builtin/vision_mixer/activity.rs:40 — pad.add_probe(gst::PadProbeType::BUFFER, move calls only backend/src/blocks/builtin/vision_mixer/overlay.rs:271 — let ms = self.instant_base.elapsed().as_millis() as u64; and one relaxed store. The captured Arc<VisionMixerOverlayState> still has no gst:: field, so it forms no reference cycle. |
| An unknown name is refused before FTB is cancelled | CONFIRMED |
backend/src/gst/pipeline/effects/take.rs:76 — `let trans_type = transition_type.parse::().map_err( |
| The GPU builder installs the probes, and a master-FX take out of a PiP reports the effect | CONFIRMED |
#977 made the Linux job render GL: .github/workflows/ci.yml:174 — STROM_REQUIRE_GL: 1. With that variable set, gl_available asserts instead of skipping: backend/tests/common/mod.rs:165 — strom_types::env::var_opt("STROM_REQUIRE_GL").is_none(),. In Check (Linux) job 113912231249, master_fx_take_out_of_a_pip_reports_the_effect ... ok and gpu_mixer_reports_media_age_per_input ... ok ran, and the file took finished in 4.97s instead of showing the old 0.06 s skip signature. |
Radius. SHARED, unchanged from my last review. Every vision mixer gets num_inputs BUFFER probes, on both builders, and actual_transition_type gains the value morph. The bundled page handles morph. External clients cannot be found from this repo.
Overlaps
- #806: the
idx()helper (backend/tests/vision_mixer_fx_test.rs:573—fn idx<T: From<usize>>(i: usize) -> T {) compiles against either signature, andgit merge-treeof the two heads is clean. Either can land first. - #809, #1056: textual conflicts only. Against #809,
pipeline_gpu.rshas adjacentletlines. Against #1056,vision-mixer.htmlhas the version string. Whichever PR lands second resolves its conflict in one line. - #887, which the body lists as a known gap, has been closed by #1015, and this head already tests against it. The PR body's last line is stale.
Tests & CI. At 8e02af3, Check (Linux), both Build (Linux) jobs, WASM, API Contract Check and Static JS Tests all pass. These guards executed: the four take::tests, both vision_mixer::tests probe tests, all four tests in vision_mixer_signals_test, and both new GPU tests. Build (macOS) and Build (Windows) were skipped, but the diff contains no platform-specific code. The base is 7 commits behind main. git merge-tree against origin/main is clean, and the only change main makes to openapi.json is the version line, which is disjoint from this diff's schema hunk.
Confidence: HIGH
|
Since this PR was opened, main gained shared test setup in This PR adds three hand-built
Each becomes
Correction: I wrote here that this compiles against main as it is. It did not: #935 removed the imports those copies use, so Posted by Claude (AI) for the maintainers. |
The PiP-aware take path animates pads, not pictures. A source that appears in both the outgoing and incoming composition is treated as moving, so it slides and scales into its new box while only the sources exclusive to one side cross-fade. Taking a four-up to that same participant full frame therefore reads on air as a zoom, not a dissolve. The response reported "fade" for every non-cut take in this path, so a caller asking for a dissolve was told it got one. Report "morph" when any PGM pad moves. Behaviour is unchanged; only actual_transition_type is now accurate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When an input stops delivering, the compositor repeats its last frame indefinitely. A participant whose connection drops therefore stays on air as a still picture, indistinguishable from one sitting motionless, and stays there after the ingest session is reaped. The program output cannot tell the two apart. Stamp each input's last buffer arrival on the dist compositor's sink pads and report the age in the vision mixer state as input_media_age_ms: tens of milliseconds for a live source, growing without bound for a frozen one, null for an input that has never delivered a frame. Stamping at the mixer's own pads covers every input type, not just WHIP seats, and measures exactly what decides the picture. These are per-buffer probes. The callback reads the monotonic clock and stores one relaxed atomic, with the overlay state captured as an Arc rather than looked up per buffer. The state is not a GStreamer object, so the closure forms no reference cycle with the pipeline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The frozen-seat figure is also sharper: the counter puts the last frame about a quarter of a second after the publisher dies, where frame capture alone could only bound it at two seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The accessor test stamps the state directly, so it passes whether or not the probes are attached to anything. This one runs a real compositor and asserts the age is stamped from its buffers, and fails if the probes are installed on the wrong pad names or not at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A PiP-aware take animates the crop of a source present on both sides as well as its box. Taking a punch-in back to the plain source keeps the box full frame and eases the crop out, which reads on air as a zoom-out, but the classifier compared boxes only and reported "fade". Count a shared source whose crop differs between the two sides as a morph. The actual_transition_type description now names "morph" and says it is a report only, not an accepted request type. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The existing unit tests exercise the classifier and the probe helper directly, so they still pass if the take response stops using the classifier or the mixer builder stops installing the probes. Both tests here build a CPU vision mixer flow through PipelineManager: one checks that a live, a stalled and an unlinked input read young, old and null, the other that a punch-in taken through trigger_transition reports "morph". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A source that only sends a frame when its picture changes, such as a web graphic on a static page, ages for seconds between frames while healthy. A fixed one-second rule would have an operator pull it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A glitch, flash or whip take out of a PiP runs through the PiP-aware path, which lays the full-frame envelope over the pad morph. The take reported the morph (before that, "fade"), so a control surface labelled an on-air glitch as a move. Report the effect when its envelope was applied, as the plain-input path already does. The guard renders through GL, so it runs on the macOS CI job, not Linux. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mixer state is registered per block id, and the other tests in vision_mixer_fx_test run in parallel under the shared id. Sharing it let either test replace the other's mixer state: the new test saw zero PiPs, or the letterbox wipe test failed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The PiP-aware take ran a name it could not parse as a fade and reported it as one, where the plain-input path refuses it. "morph", which the take only reports, was accepted as a request this way. Both paths now return the same error, and the buses stay where they were. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CPU test cannot see the GPU builder's install call. This one runs a real GPU mixer with a live, a stalled and an unlinked input, and fails if the GPU builder stops installing the probes. It renders through GL, so it runs on the macOS CI job and skips on Linux. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An input whose timestamps jump backwards freezes on air for the length of the jump, because the mixer discards frames older than the one it holds, while its age still reads live. The guide and the field doc now say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A take cancelled fade-to-black and reset the FX slots before it parsed the transition name. A refused name then left the program black with FTB marked off, so the operator's next FTB press did nothing visible. The name is now checked once, first, for both the PiP and plain paths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On the macOS CI runner frames queued past the valve kept reaching the mixer for about 1.4 s, so the fixed 2.5 s wait read the stalled input at 1.1 s. Poll for the 1.5 s gap, up to 10 s, instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A take between compositions that share a source reports morph. The page labelled every report that differed from the request as a downgrade, so an ordinary fade read as one. Name the move plainly instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only a fade that morphed is an ordinary take between compositions that share a source. A slide, push or wipe that morphed did not run, and the page said "ran as a move" for those too. They now read as downgraded to morph. The note no longer repeats the server message it is appended to. The note is a pure function, tested from the page source. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A punch-in taken to or from its plain source reports morph, but the box stays full frame and only the crop changes, so the audience sees a zoom. The note said the source slid into its new box. It now says it moved or zoomed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The mixer takes in frames only as fast as it puts them out, so a stalled program consumer ages every input together. Say so in the guide and the field doc, so a dashboard does not read it as every source dropping out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Eyevinn#935 moved PipelineManager setup into common::manager and removed the imports the hand-built copies used, so the two new tests in vision_mixer_fx_test no longer compiled against main. They, and the start helper in vision_mixer_signals_test, now use common::manager::build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8e02af3 to
cf48e77
Compare
What
Vision mixer changes from exercising the producer switching workflow:
actual_transition_typereportsmorphwhen a take moved or re-cropped a sharedsource instead of dissolving.
input_media_age_ms, so a frozen input can be told apartfrom a motionless one.
PiP path too (400, as between plain inputs).
Why
The take reported a move as a dissolve. The PiP-aware path animates pads, not
pictures: a source in both compositions moves and re-crops into its new box, and only
sources exclusive to one side cross-fade. Taking a four-up to that participant full
frame reads as a zoom-in; taking a punch-in back to the plain source reads as a zoom-out.
That is deliberate, but the path reported
fadefor every non-cut take, so a callerasking for a dissolve was told it got one. Behaviour is unchanged; only the report is
accurate. A
slide_*request on this path is still downgraded, and now reportsmorphrather than
fadewhen a source is shared.morphis a report only, not a request type.An effect take (glitch, flash, whip) out of a PiP lays its envelope over the pad move and
reported the move; it now reports the effect, as the plain-input path does. A
transition name the mixer does not know is now refused on this path with the same error
as between plain inputs; before, it ran as a dissolve and was reported as one. On both
paths the refusal now comes before the take cancels fade-to-black, which left a black
program marked as not faded, so the next FTB press did nothing visible. The bundled
control page no longer calls a fade that ran as a
morpha downgrade; a slide, pushor wipe that ran as one still reads as downgraded. Known gap: a take between two buses
that both show the same PiP still reports
fade.A dropped participant stays on air as a still frame. The compositor repeats an
input's last frame indefinitely, so a lost connection looks exactly like someone sitting
motionless and persists after the ingest session is reaped.
input_media_age_msgivesmilliseconds since each input last delivered a frame:
Stamping at the mixer's own sink pads covers every input type. It measures arrival, not
display: when an input's timestamps jump backwards (an encoder restarting its clock), the
mixer discards frames older than the one it holds, so the tile freezes for the length of
the jump while its age reads live. The age has to be read against the source's own frame
interval: a source that only sends frames when its picture changes (a web graphic on a
static page) ages for seconds while healthy. Every input ageing together means the
mixer's output stalled, not that every source stopped: the mixer takes in frames only as
fast as it puts them out. The guide and the field doc say all three. On a five-seat WHIP rig, killing one publisher
produced 1.8 s → 29.9 s on that input while three live neighbours stayed under 30 ms, and
the age returned to 25 ms on rejoin. That rig run predates the take fix; the probe code is unchanged since.
Risk the reviewer should weigh
These are BUFFER probes, the hottest path in the pipeline. The callback reads the
monotonic clock and stores one relaxed atomic: no lock, no allocation, no formatting, no
state lookup. The overlay state is captured as an
Arc, not looked up per buffer, and isnot a GStreamer object, so the closure forms no reference cycle with the pipeline. Five
inputs at 30 fps is 150 stores per second. The cost was not A/B measured.
Tests
Each guard below fails when its behaviour is reverted (the revert in brackets).
take.rsunit tests: moved or re-cropped shared source →morph; disjoint, or sharedand still →
fade[classifier ignoring moves; ignoring crop].vision_mixer/tests.rs: age accessors, and the probe helper on a barecompositor[no stamp; wrong pad names].
vision_mixer_signals_test(real CPU mixer): live / stalled / unlinked ages [CPU buildernot installing probes]; punch-in to its plain source reports
morph[response hardcodingfade];morphrequested out of a PiP is refused, buses and fade-to-black unchanged[unknown name run as a fade; FTB cancelled before the check]; the same refusal between
plain inputs keeps fade-to-black [FTB cancelled before the check].
vision_mixer_fx_test(real GPU mixer):glitch_cutout of a punch-in reportsglitch_cut[effect name dropped]; live / stalled / unlinked ages, waiting up to 10 sfor the stalled input to age [GPU builder not installing probes]. These render through GL; CI runs them on macOS and on Linux
under Xvfb, both with
STROM_REQUIRE_GL=1.backend/static/tests/vision_mixer_take_note.test.js: the page's take status note[every morph called a plain move; a punch-in described as a slide].
Run on macOS:
cargo test --lib(690),vision_mixer_signals_testandvision_mixer_fx_testwithSTROM_REQUIRE_GL=1(5 parallel runs each undertaskpolicy -b),openapi_test,node --test backend/static/tests,cargo fmt --check,cargo clippy --all-targets -D warnings. The OpenAPI snapshot changed intentionally: one additive optional field and theactual_transition_typedescription.Not run locally: the rest of the integration suite. CI on Linux runs it.
Known gap, not from this PR: two flows whose mixers share a block id read each other's
state, including these ages (#887).
🤖 Generated with Claude Code