music-video-gen/flow-state/src/scenes/shader/droste-feedback.js
Dejvino 89e05459c0 Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.

The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.

The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.

Chasing the numbers turned up four real faults:

  * A scene that reads prev() cannot be a ground. It returns the whole
    composited frame including the layers above it, so a datamosh under a shot
    is eating it: the render stopped reproducing from a seek and two WebGL
    contexts diverged by 91/255 against a tolerance of 4.
  * Screen was the wrong operator for a shot over a bed. It lightens, so a
    median quarter of every frame clipped to paper and whole sections rendered
    100% white. Replaced by a lumakey — the shot's brightness is its alpha.
  * Feedback was an accumulator: a still image settled at 2.3x its own
    brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
    plus a highlight shoulder so the top rolls off instead of clipping.
  * useTrack never prewarmed, so a fresh Show's first frame differed from every
    later render of it — the export-breaking hazard Compositor.prime documents.

Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.

Rendered across twelve videos, middle of every section:

    painted        51% mean, darkest 0.3%  ->  87% mean, darkest 43%
    clipped white  24% median, worst 100%  ->   1% mean, worst 30%
    separation     0.10                    ->  0.44

Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:01 +02:00

83 lines
3.6 KiB
JavaScript

// Glitch family: video feedback. A camera pointed at its own monitor — the
// frame is redrawn inside itself slightly smaller and slightly turned, so a
// small motif at the centre becomes an endless corridor of copies of itself.
//
// Time Smear is the other feedback glitch and it TRANSLATES the previous frame,
// which leaves a comet trail behind a moving subject. This scales it. The
// difference is the whole scene: a translated feedback drifts off the edge and
// clears, a scaled one never clears, because everything the frame has ever
// contained is still in there, one ring further in.
//
// A live motif is drawn every frame, so the image exists before the loop has
// anything in it and comes back after a seek.
//
// No declared slow axis. What the corridor holds depends on where the loop has
// been, so two ten-second windows of the same parameters are 0.020 apart on
// their own and the persistence axis moved 0.004 — the same measurement Turing
// Bloom and Voronoi Shatter record. A feedback scene's history is its structure,
// and Phase 11 cannot separate a parameter from it.
export const drosteFeedback = {
name: 'Droste Feedback',
family: 'glitch',
kind: 'fragment',
texture: 0.5,
consumes: ['ink'],
traits: ['camera', 'style'],
params: {
zoom: { type: 'float', range: [0.93, 1.02], default: 0.975, uniform: 'u_zoomStep' },
turn: { type: 'float', range: [-0.12, 0.12], default: 0.04, uniform: 'u_turn' },
persist: { type: 'float', range: [0.9, 0.995], default: 0.985, uniform: 'u_persist' },
motif: { type: 'float', range: [0.04, 0.35], default: 0.14, uniform: 'u_motif' },
tint: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_tint' },
pace: { type: 'float', range: [0.05, 0.8], default: 0.25, uniform: 'u_pace', bias: 'motion', rate: true },
palette: { type: 'palette', count: 5 },
},
reactive: {
motif: { feature: 'beat', amount: 0.2, response: 'spike' },
},
shader: `
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_pace + u_seed;
// Folded as well as filmed: a feedback loop through a mirror is still a
// feedback loop, and the fold is the track's, not this scene's.
p = sigFolded(sigCamera(p));
// The motif. Small, live, and always drawn: this is the thing the loop eats.
vec2 at = vec2(sin(t * 0.6), cos(t * 0.47)) * 0.42;
float d = length(p - at) - u_motif;
float ring = abs(d) - u_motif * 0.35;
vec3 col = pal(0) * 0.05;
col += palRamp(0.2 + fbm(p * 2.0 + t, 3) * 0.2) * smoothstep(0.02, -0.02, ring) * (0.5 + 0.6 * 0.8);
col += pal(4) * inkStroke(ring) * (0.3 + 0.6 * 0.9);
// The loop: read the previous frame from a slightly LARGER, slightly turned
// sample of itself, so last frame's image comes back shrunk toward the
// middle. Contracting is what nests — expanding pushes every copy off the
// edge and leaves a plume instead of a corridor. Scaling about the centre in
// screen space is what keeps the copies concentric rather than sliding.
vec2 c = uv - 0.5;
c.x *= u_aspect;
c = rot(u_turn) * c / max(u_zoomStep, 0.5);
c.x /= u_aspect;
vec3 past = prev(c + 0.5);
// Each generation is tinted a step further round the palette, so depth into
// the corridor is legible as colour rather than only as size.
past = mix(past, palRamp(0.6) * (past.r + past.g + past.b) * 0.5, u_tint);
col = max(col, past * u_persist);
// Vignette, which also stops the corner content being recycled forever.
col *= 0.75 + 0.25 * exp(-dot(p, p) * 0.35);
return vec4(inkValue(col), 1.0);
}
`,
};
export default drosteFeedback;