Repository navigation
fix(vision mixer): keep a source's new shape when it changes size - #894
Conversation
The GPU mixer's per-input glshader (fx_look, fx_take) takes its output size from the first entry of downstream's caps. After the first negotiation the compositor answers with the size it already has, so a source that changes size is stretched to its first shape: a camera switched to portrait, or a new publisher on a reused WHIP slot. The mixer never sees a caps change and never re-fits the aspect. Open width and height in the caps answer on each filter's peer pad so the filter stays at its input size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The caps probe queues a geometry refresh on the main loop, which runs on its own thread. A pad stores caps only after its probes return, so the refresh could run first, read the old size, find nothing changed and leave the input in its old shape. Requeue the refresh until the pad holds the caps that triggered it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh an input's geometry from the pad's notify::caps, which fires after the pad stores new caps, instead of a caps event probe that fires before. A refresh queued from the probe could read the old size when the streaming thread was held up before the store, and the source stayed stretched until its next shape change. This replaces the bounded requeue. Fall back to the block's configured PGM size, not 1920x1080, when the mixer output has not negotiated yet. The first refresh runs before it has, so a source present at start was fitted to a 1080p canvas on any other PGM size and stayed misplaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-fitting an input clears the pad's control bindings, so a source that changed size during a fade or wipe ended the transition as a cut. The GPU mixer now reaches that path on every resize, because the effect shaders pass the new size through. The geometry refresh now leaves an animating compositor alone, marks the layout pending, and runs again after the last keyframe. FTB off fits the program pads to their sources' current shapes before they fade back in, so a source that changed shape during FTB no longer comes back stretched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FTB wiped its alpha keyframes on a wall-clock timer, duration + 100 ms. The keyframes are in the mixer's stream time, so a mixer running slower than real time had its fade wiped part-way and stayed there: never reaching black, or coming back dimmed. The same timer wiped the fade-in of an FTB off pressed during the FTB-on fade. The cleanup now waits until the mixer's position passes the fade's end, and skips a control source a later FTB or take has reprogrammed. FTB off also re-applies a PiP source's crop, which is in pixels of the source and was left sized for a source that changed shape during FTB. The fade and FTB tests now time their checks by the mixer's clock instead of the wall clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ize tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#894's resize tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ize tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#894's resize tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vision_mixer_fx_test printed SKIP and passed when the GPU vision mixer failed to build or start, even after the GL probe had proven the environment renders and even under STROM_REQUIRE_GL. A GPU mixer that stopped building passed green on the macOS job. Those paths now fail. Missing GL elements were folded into "no GL context", which the Linux jobs treat as a legitimate skip. Every CI job installs the GL plugins, so both GL test files now fail on a missing GL element under STROM_REQUIRE_GST_PLUGINS, like every other missing element, and also under STROM_REQUIRE_GL, since a platform that must render cannot do it without them. The two fx tests shared block ID "vmfx". The vision mixer's overlay state is process-global and keyed by block ID, and the tests run concurrently, so the letterbox test now uses its own ID. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both GL test files hand-rolled the STROM_REQUIRE_GST_PLUGINS check that common::require_or_skip already owns. common::gl_elements_available adds the one thing that helper lacks, failing under STROM_REQUIRE_GL too, and both files now call it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ize tests Integration fix-up: Eyevinn#806 makes trigger_transition's input indices optional, and Eyevinn#894's resize tests still pass bare integers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er signature Eyevinn#806 changes trigger_transition's input indices from usize to Option<usize>. idx() converts into whichever the method takes, so these tests compile whether Eyevinn#806 lands before or after this PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
srperens
left a comment
There was a problem hiding this comment.
Verdict: Comment. The mechanism is right and the lifetimes are clean. But none of the eight guards ran in this repo's CI: on headless Linux they skip and report ok.
Requested changes
- Maintainer: add the
ci:macoslabel. That job setsSTROM_REQUIRE_GL, so there the guards fail instead of skipping (.github/workflows/ci.yml:689—STROM_REQUIRE_GL: 1). - Land #917 first, then drop the local skip helper for #917's
common::gl_elements_available. The copy here folds missing GL elements into "no context", which is the case #917 makes fail:backend/tests/vision_mixer_source_resize_test.rs:67—eprintln!("SKIP: GL environment unavailable (no context or GL elements missing)");
Claims
| Claim | Verdict | Evidence |
|---|---|---|
| The tests guard the fix in CI | UNVERIFIED |
Check (Linux) at 754b839: vision_mixer_source_resize_test 8 passed in 0.06 s, i.e. all skipped. The fork's Build (macOS) ran them for real (8 passed, 16.11 s), but at ad05cff; compare shows one commit since, 8/2 lines in the test file only |
| Opening the size in the caps answer is safe for every consumer | CONFIRMED |
fx_look's peer is the tee (backend/src/blocks/builtin/vision_mixer/builder/pipeline_gpu.rs:404 — ElementPadRef::pad(&tee_id, "sink"),), not a compositor. Every tee branch ends in a queue into a dist or mv_comp GL compositor pad, and those take any size. The else path has no glshader, so needs nothing |
| No new strong references to pipeline objects | CONFIRMED |
The connect_linked and probe closures capture nothing; refit_after and wipe_ftb_fade_once_played hold WeakRefs. This also removes a strong timer capture: main's mixer_ops.rs had let cleanup_mixer = mixer.clone(); |
Diagnosis. Right layer: glshader fixates to the first entry of downstream's answer, so the mixer pad never saw a size change. The notify::caps swap closes the store-after-probe race, and the PGM-size fallback fixes the first refresh. Coverage is BOUNDED to GPU inputs with enable_fx, plus the shared refresh and FTB paths. The body says the CPU mid-flow stretch remains.
Both new loops are CLOSED: refit_after stops when no keyframe lies ahead, and the FTB wipe re-arms only while backend/src/gst/pipeline/effects/mixer_ops.rs:1067 — if pos < end {. A mixer held in PAUSED re-arms about every 50 ms until it plays or is dropped. That's cheap, but it is a poll.
Radius. SHARED. refresh_geometry and the FTB path serve both backends, so the hold-while-animating applies on CPU too. The probe is QUERY_DOWNSTREAM | PULL on the tee's sink pad and fires per caps query, never per buffer. One behaviour change: the final wipe now clears keyframes (backend/src/gst/pipeline/effects/mixer_ops.rs:1080 — cs.unset_all();) rather than removing the binding. That a binding with no points leaves direct writes alone is EXTERNAL (GstController).
Overlaps.
- #917 owns the GL skip policy; see change 2.
- #893: the author asks for both to land together. The two are independent code paths.
- #755 (stingers), #809, #803 and #797 edit
mixer_ops.rs/pipeline_gpu.rs. SPECULATIVE (not verified): if #755 animates pads withoutInterpolationControlSourcekeyframes,animation_remainingcannot see a stinger, and a resize mid-stinger re-fits at once. Whichever lands second should check.
Confidence: HIGH
|
Posted by Claude (AI) on behalf of @wagenet. 1. Guards at the current head. I can't apply 2. #917 skip helper. Agreed. Once #917 merges I'll rebase and replace the local #755 overlap. Checked against #755's diff; I don't think this applies:
|
…itch This branch now uses common::gl_elements_available from Eyevinn#917. Once Eyevinn#917 is on main, its commits drop out of this PR's diff.
… helper The resize tests had their own copy of the GL skip check, which folded missing GL elements into "no GL context". Linux CI skips that case, so a dropped GL package would turn all eight guards green without running them. Check the elements with common::gl_elements_available from Eyevinn#917 first, and keep the context probe for the legitimate headless skip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Ran on macOS (aarch64, GStreamer 1.28.6) with
Pushed to this branch: main and #917 merged in, and the review's change 2. The resize tests now check GL elements through The first Linux run failed in Merging. #893's fix lands through #957. Claude (AI), on behalf of @srperens. |
clippy::chunks_exact_to_as_chunks is new in 1.98 and flags the two chunks_exact(4) calls added in Eyevinn#894 after this branch was cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build: bump Rust to 1.98.1 and cargo-zigbuild to 0.23.4 On macOS 27, binaries built with the SDK 27 linker and stripped by Rust 1.97.1's rust-objcopy fail to load: dyld rejects them with "mis-aligned LINKEDIT string pool". That rust-objcopy leaves the string pool 4-byte aligned when the indirect symbol table has an odd entry count. Every build that strips (release, or dev with debug = 0) can hit it, including proc-macro dylibs such as sqlx_macros, so the build itself fails. Rust 1.98.1's rust-objcopy pads the pool correctly. 1.98 also passes --fix-cortex-a53-843419 to the linker on aarch64 Linux. cargo-zigbuild filters that flag out from 0.23.0, so the ARM64 zigbuild job no longer hands it to zig 0.13. The one new 1.98 lint, clippy::chunks_exact_to_as_chunks, fires in vision_mixer_fx_test. clippy::result_large_err was already fixed in #678. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(vision mixer): use as_chunks in source resize test for Rust 1.98 clippy::chunks_exact_to_as_chunks is new in 1.98 and flags the two chunks_exact(4) calls added in #894 after this branch was cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Per Enstedt <per.enstedt@eyevinn.se>
Opened by Claude (AI), on behalf of @wagenet.
Current behaviour
On the GPU (OpenGL) vision mixer, a source that changes frame size mid-flow keeps its first shape. Seen from WHIP: a phone that switches camera, or a second publisher with a different camera on a reused slot, shows stretched to the first source's aspect. A 720x1280 portrait source after a 1280x720 one fills the whole 16:9 program instead of being pillarboxed.
A source present when the flow starts is fitted to a 1920x1080 canvas whatever
pgm_resolutionis. On a 1280x720 program a portrait source starts off-centre and cropped, and stays there until it changes shape.A source that changes shape during Fade to Black comes back stretched when FTB is turned off.
On a mixer running slower than real time, Fade to Black stops part-way: the program never reaches black, or comes back dimmed. Turning FTB off while FTB on is still fading also leaves the program dimmed.
Fix
glshaderslots (fx_look_N,fx_take_N) now stay at their input size, so the new size reaches the mixer pad and the aspect-fit refresh runs.notify::caps, which fires after the pad stores new caps, instead of a caps event probe, which fires before. A refresh queued from the probe could read the old size if the streaming thread was held up before the store.Mechanisms
glshaderis a GstGLFilter: it can scale, and fixes its output size from the first entry of downstream's caps answer (gst_gl_filter_fixate_caps: "dimensions already set to 1280x720, not fixating"). A GL mixer sink pad answers a caps query with the mixer's output caps, because onlyGstVideoAggregatorConvertPads widen width/height (gstvideoaggregator.c,gst_video_aggregator_pad_sink_getcaps). Before the first negotiation that answer is open, so the filter keeps the source size. After it, the answer leads with the running 1280x720, and every later size is scaled into it. The mixer pad's caps never change, sogeometry.rsnever re-fits.keep_input_sizeopens width and height in that answer. It has to be on the filter's peer pad:gst_base_transform_find_transformcallsgst_pad_query_capson the peer directly, so a probe on the filter's own src pad never sees the query. The peer is only known once linked, henceconnect_linked. The probe runs per caps query, never per buffer.notify::capsis emitted fromstore_sticky_eventafter the caps are stored, with the pad unlocked. It also fires when caps are cleared on deactivation; the handler skips a pad with no caps.Keyframes are in the mixer's stream time, which falls behind the wall clock whenever the mixer runs slower than real time; the old
duration + 100 mstimer then wiped a fade the mixer had not yet played.A running animation is detected from the pads'
InterpolationControlSourcekeyframes: the last one after the compositor's current position is when the refresh runs again. FTB holds the program black with no keyframes left, so it is checked throughftb_active, and FTB off places the pads itself.How to review
keep_input_sizeinvision_mixer/elements.rs, and its two call sites inbuilder/pipeline_gpu.rs. The CPU backend has noglshader.install_caps_probes,refresh_geometryandanimation_remaininginvision_mixer/geometry.rs, shared by both backends.fade_to_blackandwipe_ftb_fade_once_playedineffects/mixer_ops.rs.Tests
New
backend/tests/vision_mixer_source_resize_test.rsbuilds the real GPU vision mixer throughPipelineManagerand measures PGM pixels (white fraction 0.32 = pillarboxed portrait on 1280x720). Each test fails when its fix is reverted:source_switched_to_portrait_is_pillarboxed(1280x720 → 720x1280)keep_input_sizenot installedsource_switch_fits_when_caps_are_stored_late(5 ms sleep in a caps probe on the mixer pad)source_portrait_from_the_start_is_pillarboxedsource_resized_during_a_fade_is_fitted_after_it(4 s fade, the incoming source turns 4:3 about 800 ms in)source_switched_during_ftb_is_pillarboxed_after_itpip_crop_follows_a_source_resized_during_ftbftb_plays_out_on_a_slow_mixer(mixer slowed to about 40% of real time)ftb_off_during_the_fade_out_restores_pgmThe fade and FTB tests time their checks by the mixer's clock, not the wall clock: 15 of 15 runs at background CPU priority, 12 of 12 four at a time.
STROM_REQUIRE_GL=1) runs them, but only with theci:macoslabel. On the fork at the head commit, all eight ran and passed: https://github.com/wagenet/strom/actions/runs/36518169734pipeline_lifecycle_testwithSTROM_REQUIRE_GL=1; clippy./player/whip-ingestpage, fresh server per run, with fix(whip): keep a slot's video PTS moving forward frame to frame #893 applied: portrait after the switch fitted at 404x720, and a later 640x480 publisher at 960x720, in 4 of 4 runs on the second of four commits. Not repeated since: the rig runs no takes or FTB.🤖 Generated with Claude Code