Skip to content

feat(vision-mixer): shader FX engine — custom GLSL looks, wipes and master FX takes (GPU) - #626

Merged
srperens merged 16 commits into
mainfrom
feat/vision-mixer-shader-fx
Jun 5, 2026
Merged

feat(vision-mixer): shader FX engine — custom GLSL looks, wipes and master FX takes (GPU)#626
srperens merged 16 commits into
mainfrom
feat/vision-mixer-shader-fx

Conversation

@srperens

@srperens srperens commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

What

A custom-GLSL effects engine for the vision mixer, GPU backend only (block property Shader FX, default on). Previously stacked on #625 (now merged) — rebased onto main, so the PR now contains only the FX work.

Four glshader FX slot kinds in the GPU pipeline

Slot Where Purpose
fx_look_{i} before each input tee persistent per-source looks — follow the source everywhere (PGM, PVW, thumbnails, PiPs)
fx_take_{i} dist branch, before the mixer wipe masks during shader takes (PGM only)
fx_pgm after the mixer persistent master (PGM) look
fx_pgm_take after fx_pgm full-frame master-FX take envelopes

The PGM output mirrors the per-input look/take split: looks and takes run on independent slots everywhere, so a master-FX take plays on top of the master look and the look stays on afterwards (it used to be evicted).

Effects

  • Looks (14): chroma key, pixelate, blur, duotone, vignette, VHS, old film, edge glow, CRT, halftone, thermal, night vision, posterize, underwater — parameterized, applied per input or on the PGM master via POST .../effect
  • Wipe transitions (19): directional wipe_left/right/up/down, iris_open/close, barn_doors, luma_wipe, clock_wipe, blinds, checker_wipe, noise_dissolve, melt, heart_iris, star_wipe, pinwheel, crosshatch, hex_dissolve, warp_wipe
  • Master-FX takes (11): glitch_cut, flash_dissolve, whip_pan_left/right, punch_zoom, pixelate_take, zoom_blur, spin, tv_roll, negative_flash, ripple — full-frame envelope over a delayed cut / fade / push

All shader transitions downgrade to Fade on the CPU backend or with Shader FX off; PiP-aware takes keep master envelopes on top of the fade.

Animation model

Shaders are self-animating: glshader provides a time uniform (buffer PTS) per frame; Rust programs u_start/u_duration once per take and the shader computes its own progress — frame-accurate at composite time, zero per-frame CPU, no GstController involvement.

Hard-won correctness details (each found against real deployments)

  • Runtime shader swaps require answering the create-shader signal — glshader's fragment property is inert once a shader exists. The attached handler compiles on the GL thread and keeps the previous shader on compile failure (a runtime swap can never kill the pipeline).
  • Wipe orientation is geometry-aware: outgoing-covers-incoming → inverted mask on the outgoing branch (start-flash impossible by construction); otherwise (letterboxed sources) → classic mask on the incoming branch with a 2-frame alpha lead and remnant fade.
  • Wipe timing is latched from the branch's own first buffer PTS (one-shot self-removing probe, weak refs only) — SRT/TS sources carry PCR-derived timestamps far from the mixer timeline, which froze wipes into hard switches until latched per branch.
  • u_invert participates in the uniform-only neutral reset — uniform values persist on the GL program and a leftover inverted mask would black out the branch.

Operator page

The pickers are tiered: production staples (directional/iris/barn/luma wipes, flash take, chroma key / blur / pixelate / vignette looks) are visible by default; novelty shapes and the remaining master FX sit behind a MORE toggle (persisted per browser), stylization looks under a "Stylized" optgroup. The full catalog stays available — and CI-validated — but the default view reads like a production switcher.

LOOKS panel with live parameter controls (param-only changes swap uniforms without recompiling), FX UI hidden when the engine is unavailable, state sync via /state + new VisionMixerEffectChanged WS event.

Testing

  • shader_validation_test: every composed fragment compiles and runs in a real GL pipeline (llvmpipe-compatible, runs in CI); pixel-checked regression test that runtime fragment swaps actually take effect
  • vision_mixer_fx_test: end-to-end GPU pipeline — FX slots, looks (clamping, param-only updates, invalid input rejection), wipe + master takes, master look asserted to survive a master-FX take, pipeline stays PLAYING; letterbox repro test pixel-verifies both wipe orientations animate and land on the right source
  • pipeline_lifecycle_test green (the FX slots add elements with signal handlers — no leak regressions)
  • Full workspace suite green; openapi snapshot updated intentionally
  • Verified live on two GPU machines (NVIDIA) against SRT and local sources

🤖 Generated with Claude Code

Base automatically changed from feat/vision-mixer-zone-borders to main June 5, 2026 09:54
srperens and others added 16 commits June 5, 2026 11:56
VideoEffect (chroma key, pixelate, blur, duotone, vignette, VHS, old
film, edge glow) with serde defaults, parameter clamping and color
validation; EffectTarget (input/master) and request/response types.
VisionMixerState gains fx_available + current effects; new
VisionMixerEffectChanged WS event. DEFAULT_ENABLE_FX block default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composable GLES2-style fragment library: prelude (glshader's built-in
tex/time/width/height uniforms), shared helpers (luma, hash, soft
reveal), timing block (u_start/u_duration evaluated against buffer-PTS
time — animation lands at composite time with zero per-frame CPU),
8 looks, 8 wipe masks, 6 master envelopes.

glshader only compiles its fragment property while it has no shader;
runtime swaps require answering the create-shader signal. The attached
handler compiles the element's current fragment on the GL thread and
keeps the previous shader on compile failure (a runtime swap can never
kill the pipeline).

Validation tests run every fragment through a real GL pipeline
(llvmpipe-compatible) and assert that a runtime swap actually changes
rendered pixels — the regression test for the silent-no-op swap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three glshader FX slots in the GPU pipeline (enable_fx, default on):
fx_look_{i} before each input tee (persistent per-source looks that
follow the source everywhere), fx_take_{i} on the dist branch (wipe
masks, PGM only), fx_pgm after the mixer (master looks + take
envelopes).

New shader transitions: wipe_left/right/up/down, clock_wipe,
iris_open/close, blinds, checker_wipe, noise_dissolve, luma_wipe,
ripple (mask on the incoming source + step-off on the outgoing), and
master-FX takes glitch_cut, flash_dissolve, whip_pan_left/right,
punch_zoom, pixelate_take, film_burn (delayed cut/fade/push underneath
a full-frame envelope). All downgrade to Fade without the FX engine;
PiP-aware takes keep master envelopes on top of the fade.

POST /blocks/{id}/effect applies looks (param-only changes swap
uniforms without recompiling); state endpoint reports fx_available +
current effects; VisionMixerEffectChanged broadcasts changes.
End-to-end test drives the GPU pipeline through looks, wipes and
master takes on software GL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WIPE and FX transition bases with shape/kind sub-pickers, LOOKS panel
with per-source + master effect selectors and live parameter controls
(throttled, uniform-only updates on the backend). FX controls are
hidden when the engine is unavailable (CPU backend); state syncs via
/state and the VisionMixerEffectChanged WS event. Page version 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oks rows

The 12 wipe-shape buttons squeezed into one flex row and overlapped;
wipe and master-FX pickers are now 6/4-column grids with uniform
buttons. Looks panel rows align on a label/select/params grid with
styled controls. Transition panel widened to fit the grid. Page
version v2.29-shader-fx-ui.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the mask

Wipe masks render upstream of the mixer while pad alpha applies inside
it: when the incoming pad turned opaque, in-flight frames rendered
before the shader swap (mask fully open) flashed the destination for a
frame or two before the masked frames arrived.

Wipes now run inverted on the OUTGOING branch: the incoming source
sits underneath at full alpha and the outgoing source's mask eats away
on top. Every pre-swap in-flight frame then renders as 'PGM unchanged'
— the race is structurally impossible this way around.

The take-start neutral reset must also zero u_invert: uniform values
persist on the GL program, and a leftover inverted mask at p=1 would
turn 'fully revealed' into 'fully transparent' and black out the
branch on its next non-wipe take. from==to takes are now no-ops in the
shader paths (an inverted self-wipe would wipe PGM to black).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s broke inverted wipes

The inverted wipe relies on the outgoing source covering the incoming
one. With a letterboxed outgoing source (e.g. 2.39:1 on a 16:9 canvas)
the incoming pad at full alpha underneath showed instantly in the
uncovered bands — wipes between mixed-aspect sources read as hard
switches (observed in production: 1280x534/546 vs 1920x1080 inputs).

shader_wipe_take now picks orientation by geometry:
- outgoing rect covers incoming -> inverted mask on the outgoing
  branch (glitch-free by construction), as before
- otherwise -> classic mask on the incoming branch above, with pad
  alpha turned on two-three frames late so pre-swap in-flight buffers
  (mask fully open) drain unseen, and outgoing remnants outside the
  incoming rect fading over the wipe (the slide transition's rule)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sources froze wipes

The shader's time uniform is the RAW buffer PTS of the branch it runs
on, but u_start was the mixer's output position. Local sources happen
to share that timebase; SRT/TS sources carry PCR-derived timestamps
that sit far from it, so progress clamped to 0/1 and wipes read as
hard switches whenever the masked branch was an SRT source (matching
the observed pattern: wipes only animated when the masked side was a
local test source, and long durations showed only the tail end).

The wipe fragment is now installed with parked uniforms (u_start far
in the future = p=0: opaque for inverted, hidden for upright) and a
self-removing one-shot pad probe latches u_start from the branch's
first buffer PTS — correct in every source's own timebase. Mixer-side
cleanup steps got a small end grace so they land after the mask
completes.

The letterbox repro test now drives both orientations through real
pixel checks (mid-wipe must show both sources, ends must land on the
right one) and persists the PGM/PVW swap between takes the way the
API handler does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ tuning

New wipes: melt (Doom-style column drip), barn_doors, heart_iris,
star_wipe, pinwheel, crosshatch, hex_dissolve, warp_wipe (smeared
directional edge). New master FX: zoom_blur (radial streaks), spin,
tv_roll (sync-loss roll), negative_flash, and ripple reworked as a
master FX — on the PGM slot the expanding ring distorts BOTH pictures
through a crossfade (as a wipe mask it could only ripple one branch).
New looks: CRT (barrel + scanlines + grille), halftone, thermal,
night_vision, posterize, underwater.

Tuning from operator review: checker_wipe runs a denser 20x11 grid;
noise_dissolve uses smooth two-octave value noise (organic blobs
instead of hard grain); glitch_cut/punch_zoom/pixelate_take get
wide-topped envelopes and stronger peaks so the midpoint cut hides
behind the effect; film_burn removed.

All 40 fragments CI-validated through real GL pipelines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hind MORE

A transition picker that leads with hearts and pinwheels reads as a
prosumer toy to the broadcast engineers evaluating the mixer. The full
catalog stays (CI-validated, zero runtime cost) but the default view now
shows only the production staples:

- Wipe grid: directional arrows, iris open/close, barn doors, luma wipe
  visible; the 11 novelty shapes (clock, blinds, checker, noise, melt,
  heart, star, pinwheel, crosshatch, hex, warp) sit behind a MORE toggle.
- Master FX grid: flash dissolve visible (and now the default kind,
  replacing glitch_cut so the default selection is never hidden); the
  other 10 takes behind MORE.
- Looks dropdown: chroma key, blur, pixelate, vignette listed directly;
  the stylization looks (VHS, CRT, thermal, ...) under a "Stylized"
  optgroup.

MORE expansion persists browser-wide via localStorage (same vm-* pattern
as the other UI prefs). Collapsing a grid while a novelty selection is
active falls back to the tier default so the picker never shows a state
it is hiding. API surface unchanged — all types remain accepted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ded pickers

The 250px panel squeezed the six type buttons, and an expanded FX grid
pushed the duration row past the controls bar's 200px max-height cap,
clipping it (overflow: hidden).

- Transition panel min-width 250px -> 340px
- FX grid 4 -> 6 columns (expanded FX = 2 rows instead of 3), matching
  the wipe grid
- Controls bar cap 200px -> 280px; it is a cap, not a fixed height, so
  the bar only grows when a picker is expanded (4 wipe rows = ~270px
  worst case)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Heart: the implicit heart curve is taller above the origin (lobes ~1.2,
tip -1.0), so its visual center sits above y=0 — and the old "nudge
toward optical center" shifted the rendered shape the wrong way on top
of that, leaving the heart ~0.2 frame-heights too high. Shift the
evaluation point up (+0.1) so the shape drops onto the optical center.

Warp: the smear was a single-tap 0.12 UV offset confined to the alpha
soft band — barely visible. Replace it with a one-sided 9-tap
directional blur (same idiom as master_whip) whose strength eases out
over a band ~3x wider than the alpha edge, so the picture reads as
being dragged along with the sweep. Parked / fully-revealed pixels
take an early single-tap exit, so the installed-but-idle shader does
not pay for the blur taps.

Validated via shader_validation_test (compile + runtime swap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The looks panel reuses .pip-config-panel, whose 22vh cap (plus the
controls bar's own cap) forced a scrollbar after a handful of source
rows. Reuse the PiP layout editor's mechanism: an open looks panel sets
.editing on itself (panel cap -> 70vh) and fx-panel-open on body
(controls cap -> 70vh), so all rows are visible. Both caps snap back
when the panel closes; extreme input counts still scroll within 70vh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…takes

A master-FX take used to program its envelope onto fx_pgm, the same
glshader that carries the persistent master look, evicting the look
permanently (documented, but a UX wart and an asymmetry: per-input
looks already survive wipes because inputs have separate look/take
slots).

Mirror the per-input design on the PGM output:

    mixer -> fx_pgm (master look) -> fx_pgm_take (take envelope) -> ...

- apply_master_envelope() targets fx_pgm_take and no longer clears
  master_effect state
- reset_take_fx() always neutralizes fx_pgm_take and never touches the
  look slots (the master_is_look special case is gone)
- e2e test now asserts the vignette look still sits on fx_pgm after a
  glitch take, with the envelope on fx_pgm_take
- operator guide + looks-panel hint updated (the "replaced by FX
  takes" caveat is gone)

Cost: one extra identity glshader pass on PGM, matching what every
input already pays for its look/take pair.

Tests: vision_mixer_fx_test, shader_validation_test,
pipeline_lifecycle_test all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ullet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@srperens
srperens force-pushed the feat/vision-mixer-shader-fx branch from e617410 to 3f18931 Compare June 5, 2026 09:58
@srperens
srperens merged commit e894975 into main Jun 5, 2026
6 of 7 checks passed
@srperens
srperens deleted the feat/vision-mixer-shader-fx branch June 5, 2026 10:08
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.

1 participant