905c217193
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
19bfff8651 | Epic 2.5.1: basis for framing layer | ||
|
|
189328587d |
Epic 2.4: a slow axis, and an honest account of what it fixes
Eleven scenes changed as much in half a second as in two minutes. The cause
was that everything moving in them was cyclic: ArcDriver's drift is a 20-70
second LFO, and an LFO returns, so ten cycles of it over five minutes is not
five minutes of anything. The eye adapts in about two seconds and then there
is nothing left to find — violently animated, and reads as static.
ArcDriver._slowAxisFor adds the missing timescale: a param that travels ONE
WAY across the whole track, keyed on the module so a scene returning in the
last section arrives further along rather than resetting. Rate params are
excluded for the reason schema.js already gives.
Two things were learned by getting them wrong first, and both are recorded
where the next person will hit them.
The first version chose the param at random from everything eligible and
measured as doing NOTHING — identical structural change with the axis applied
and with it disabled. Which param you move decides everything: sweeping Moiré
Grid's `width` moves its time-averaged structure by 0.110 and its `offset` by
0.002, and a random draw finds the second kind almost every time. So the axis
is now DECLARED, `slowAxis: true`, validated by the schema (and rejected on
rate params, where walking one would jump the animation phase).
The second is that single-frame distance cannot measure this at all. A
churning scene's consecutive frames are already ~0.6 apart, so every pair of
its frames scores the same whether the structure moved or not — the metric is
saturated by the churn it exists to see through, and the first gate passed
while the mechanism was provably inert. The gate now compares TEN-SECOND
time-averaged frames. The window was measured rather than guessed: at one
second Moiré Grid's frozen control still reads 0.024, at ten it reads 0.0095
while the signal holds at 0.037.
Per EPIC-2.md §4, the metric is verified by breaking what it should catch:
a second check runs the identical measurement with the axis disabled and
requires it to read ~1.0. It reads exactly 1.00x on all three scenes.
The honest scope, measured across ten candidates and written into EPIC-2.md
§3.4: the axis works on Moiré Grid (3.93x), Gate Corridor (2.88x) and Truchet
Fold (1.40x). Curl Flow, Signal Decay and Circuit Bloom have NO parameter that
changes their structure and still need shader work — parameter automation
cannot substitute for structure a scene does not have. Four more already
develop on their own and were never the problem. The flag is set only where it
is proven, so it means something.
69/70 on phases 3,4,7-11 slow; the one failure is the known load-dependent
Horizon Lines flake recorded in
|
||
|
|
acc33cedd7 |
Epic 2.3: let the colour move across a track
The palette was generated once and pushed to every layer of every section for
the whole runtime. Five minutes, one scheme, no movement — and colour is the
strongest perceptual variable the system has, so freezing it wasted the
biggest lever available for making a long video feel like it is going
somewhere.
Deliberately not "a new palette per section". A track has one identity and
the palette is most of it; replacing it mid-video reads as a different video.
What moves is the palette ITSELF — rotated, warmed, opened up — so at four
minutes the image is somewhere the first minute implied. Four modes: static
(one in six or so, a held colour is a legitimate choice), drift (slow hue
travel across the whole track), sections (each kind gets its own offset, so
the colour tells you where you are), and lift (saturation and lightness
rising into a drop).
Movement happens in OKLCH, which needed the inverse of the existing
conversion: rotating hue in RGB changes brightness as a side effect, and that
artefact is the reason this project picked OKLCH in the first place. The
rotation is applied to every colour equally, so the scheme and the spread
that made the palette a palette survive the move.
Bounded on purpose at ±34° hue, ±35% saturation, ±0.07 lightness. A full
rotation would destroy the identity as surely as a new palette; the movement
has to be the kind you notice on a rewatch, not the kind you notice as an
effect.
Four new Phase 11 checks, two of which are the counter-checks that keep this
honest. Colour must actually move (weakest 0.232 channel distance across 18
moving tracks) AND must stay inside its identity. The contrast floor is
asserted at 1620 sampled points across the movement rather than at the two
ends, because a saturation lift can flatten a perfectly good palette
somewhere in the middle. The moved palette is memoised on a rounded shift,
and a fourth check proves a seeked frame gets bit-identical colours to a
played one rather than merely similar — that would have been an export-only
determinism fault.
47/48 on phases 7-11 slow; the one failure is the known load-dependent
Horizon Lines flake already recorded in
|
||
|
|
d14d473974 |
Epic 2.2: cast a director per track
FAMILY_BY_KIND was a module constant, identical for every track ever generated: an intro was always minimal/flow/organic, a drop always geometric/glitch/structural, and intro and outro were literally the same list. Every video made the same genre decisions before a single seeded draw happened — cross-track sameness hiding inside something that looked like configuration. Measured, twenty-nine of forty-two scenes were cast in none of twelve tracks: the library was not too small, most of it was unreachable. There are now five directors, each a coherent point of view about what a song looks like — ambient, brutalist, organicist, corrupt, geometer — and a track casts one, seeded, with the audio tilting the odds and never deciding. Twelve tracks now reach 25 of 41 scenes, up from 13 of 42. The first draft of this got a real thing wrong, and the existing gates caught it. Applying a point of view to every kind meant `corrupt` opened on glitch and `geometer` answered a breakdown with a dense pattern — which broke a Phase 7 invariant that has held since the minimal family existed. That invariant is right: an intro that opens strobing is not bold, it is the exact mistake the family coupling was introduced to prevent, and the viewer meets it fifteen seconds in. So intro, breakdown and outro are restricted to minimal/flow/organic for every director, and the identity lives in build, drop and sustain plus which restful family a director leads with. The constraint is now asserted against the mappings directly, so a sixth director cannot reintroduce it without tripping a gate that names the reason. Four new Phase 11 checks: every scene reachable, no kind starved, no loud family in a quiet section, every director castable. Unrelated pre-existing flake worth recording: Phase 7's "every scene is deterministic" reports Horizon Lines at 2/255 against a 1/255 tolerance when phase 7 runs in isolation, and passes in a full run. Verified present on the previous commit, so it is load-dependent GPU precision rather than anything in this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f050eaaad1 |
Epic 2.1: cut on a phrase, not on a metronome
planShots picked a bar count once per section and then divided the section into that many equal pieces. Measured, a five-minute track at 90 BPM came out as sixteen shots of 19.3, 18.7, 18.7, 18.7 ... 18.7, 19.3 seconds — every cut for five minutes landing on the same pulse. No amount of variety in what the shots contain fixes that, because the fatigue is in the timing. A section now carries a repeating RHYTHM PATTERN in bars — [8,8,16], [4,4,4,8] and friends, picked by energy — walked in order and repeated. The same track now cuts 10.7, 10.7, 21.3, 10.7, 10.7, 21.3: two quick shots answered by a hold. At 150 BPM the louder second section moves to 6.4, 6.4, 6.4, 12.8. Repeating rather than random is the whole point, and it is why the gate comes in two halves. Random shot lengths would satisfy "lengths must vary" and look worse than a metronome, because the ear is following an eight-bar structure and the eye would not be. So one check demands spread and a second demands that the lengths come from a small recurring set. The ceiling is applied by scaling the whole pattern rather than clamping each entry: at 90 BPM a 16-bar hold is 42s and a 4-bar one is 10s, and clamping both to 22 restores the metronome the pattern exists to break. Downbeat snapping is now bounded to cuts that keep the shot legal — snapping to the merely-nearest line pushed a 22s shot to 22.75s, over the ceiling the pattern was fitted to respect. 366 of 370 cuts still land within three quarters of a beat of a downbeat. New Phase 11 gate (EPIC-2.md §4): metronome, phrasing, floor/ceiling, grid. 97/97 checks pass including the slow set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
14d1204e82 |
Make grain a treatment, tempo a governor, and params commit harder
Grain was in every video. It was added twice unconditionally — every scene called sigGrain, and the grade added its own on top — so the only thing that varied between two tracks was how much of it there was. That makes grain the renderer's fingerprint rather than a decision about one video. It is now described rather than dialled (look/grain.js): a mode (off / constant / swell / sections / transient), a cell size in pixels, a refresh rate in frames, a mask (uniform, shadows, highlights, edges, bands) and a chroma amount. Roughly 45% of tracks get none at all. The non-constant modes carry a per-frame envelope computed in Show._postAt from frame and features only, so preview and export still agree. Scene-side grain is gated the same way, and a module can decline it outright with `texture: 0` — crisp line work should stay crisp. The post tab grew a real grain block so any of it can be forced per track. Slow songs got fast scenes. `motion` bias was mostly section energy with tempo as a small correction, so a 70bpm track's drop asked for nearly as much speed as a 150bpm one. Motion is now tempo-dominated, and every `rate: true` param is additionally scaled by a per-track rateScale — measured, 84bpm now samples its rate params at 0.276 of range against 148bpm's 0.571. Parameter sampling also commits harder: extremity starts at 0.45 rather than 0.25 and shapes the draw more aggressively. This was first pushed to 0.82 and backed off to 0.72, because the gates caught the overshoot — seeds began collapsing onto the same range ends and a sparse scene sampled at its low end rendered effectively black. Fallout worth recording: turning the default grade grain off exposed two scenes that were never really animating. Dust Chamber and Eclipse Field passed the Phase 7 movement gate only because per-pixel noise was moving underneath them; both now breathe on their own fixed clock, and Dust Chamber needed a brightness floor as well. Four scenes (Classic Wave, Silk Ribbon, Kaleido Tunnel, Slow Orb) express the style trait ONLY through grain and so cannot opt out yet; they hold a reduced share at 0.35 pending real edge and softness response. Phase 3's look-space check now measures its closest pair relative to image brightness, the same correction Phase 10 already documents for sparse scenes: the absolute number was being propped up by grain rather than by look-space width. Five new Phase 10 checks cover grain distribution, treatment variety, envelope range, the texture opt-out, and tempo. 93/93 pass. Two transport bugs, both stale state surfacing in the UI: - Loading a track replaces audio.src, which stops playback silently, so state.playing stayed true and the play button stayed on pause — the first click after a track change only flipped the flag back. Added stopPlayback(). - The controls row wrapped mid-song because two readouts that change length while playing were sized by their content: the clock crossing ten minutes and the section label whenever a scene name is long or a crossfade appears. The clock is now fixed-width with tabular figures and the section label is the row's only flexible item, laying out at zero width and ellipsising into whatever space is left. The two spacers competed with it for that space and are gone; its own text-align does their job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2806ef1386 |
Phase 10: variety, twelve scenes, and tooling to write the next one
Watching several finished tracks side by side turned up the problem neither Phase 8 (too few cuts) nor Phase 9 (no through-line) addressed: the same scene cast in two different videos looked like the same footage twice. Section bias is nearly identical between two tracks' drops, so both sampled their parameters around the same centre, and the library's own averageness did the rest. Three answers, none of them a new scene: Temperament — a per-track hand on every parameter dial: intensity, pace, detail, and an extremity that decides how far toward the ends of a range the track is willing to sample. Bias comes from the section and is shared between tracks; temperament comes from the track and is not. Overlays — sometimes a second full scene composited over the shot, from a different family, in a blend that preserves what is underneath and never above 0.6 opacity. Not always: a stack that always doubled up would read as permanently cluttered rather than as occasionally layered. A wider palette — hue now derives from SPECTRAL TILT, the log ratio of treble to body. The centroid is a number most masters sit in the middle of, and the plain body/(body+treble) fraction is worse: low frequencies carry most of the energy in all music, so it read 0.98-1.00 for everything and four different battery tracks came out within 0.02 of each other. The ratio is multiplicative, so its logarithm is what spreads — the same four measure -9.3, -5.0, -4.1, -3.8. Also both ways round the wheel (violet, magenta and pink were unreachable by construction), four new schemes, and seeded chroma profile and lightness curve. Closest battery pair went from 0.005 to 0.113. Twelve scenes take the library to 36, six per family: Aurora Veil, Vortex Drift, Tide Rings, Ink Bleed, Dust Chamber, Salt Flat, Cargo Belt, Gate Corridor, Circuit Bloom, Truchet Fold, Signal Decay, Storm Rift. Weighted toward the 'space' and 'shape' traits, which were thinnest and so the signatures most likely to run a track out of cast — the Phase 9 casting rule means the pool a track draws from is smaller than the library. Also fixes a real one in shots.js: heavy LRU weighting was not enough to make a section reach its whole roster, and a five-shot section still came out 0,2,0,2,0 about a fifth of the time. An unseen companion now wins outright; which one is still free, so only the coverage is guaranteed. Block Mosh declared the camera trait, assigned sigCamera(p) to a p it then never read, and passed the lint's evidence grep. The Phase 9 render gate measured its response to the camera at exactly zero. --- tooling --- Adding a scene was mostly boilerplate and round-trips, which is expensive in both senses. The irreducible cost is the shader body; everything around it is now mechanical: npm run new:scene -- "Name" --family=... --traits=... writes the module, registers it, and leaves a skeleton that already passes every gate, with name-derived constants so two skeletons are not twins. The lint grew the rules that previously needed a GPU to catch: the dead camera above, prev() with no base image, and large loops with no early break (with a `// lint: fixed-cost` opt-out for a genuinely fixed-cost sampling loop). checks.html?scene=Name runs the per-scene acceptance battery for one scene — ten lines and a verdict instead of rendering the whole library to find out whether one shader is alive. The same procedure is a repo skill under .claude/skills/build-visualizer/. --- checks changed, with the measurements --- P5 determinism compared two WebGL CONTEXTS, which is not what it is for. Measured: one context is bit-exact over 40 frames with feedback at 0.6; two contexts disagree by up to 2/255 whether feedback is on or off. It now asserts generation is byte-identical (hard) and rendering within 2/255, since feedback compounds single-level variance. P10's cross-track comparison measures distance RELATIVE to how much image there is. Most scenes are mostly dark, so two genuinely different renders — 25 bars against 53 — scored under 0.02 absolute purely because the black background agrees with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ee7a6a3b3 |
Diversify palette by timbre, tame the grain
Palette hue was pinned to the spectral centroid, which is essentially a bass-vs-treble number most mastered pop lands mid-range on, so different songs converged on the same blue/green/purple wedge and warm red/yellow was unreachable. Derive the base hue from a timbre signature instead: the track's spectral mass in the body (sub/low/mid) against the trebles (high/air), mapped onto a cool(blue)->warm(red) ramp, with BPM+tonality +dynamics driving vibrance. Energy and timbre are now two orthogonal axes, and palettes vary meaningfully between tracks. Grain was also overused: every scene adds its own surface grain and the grade adds another on top. Drop the per-scene texture floor, the grade's grain range, and the default, so tonal tracks read clean. |
||
|
|
ca5a68eb84 |
Phases 8 and 9: shots, and a production design per track
Both phases come out of the manual gate — watching whole tracks — and both
fix something no automated check was looking for.
Phase 8: shots. A section is a STAGE of the song and can run ninety
seconds; one scene held that long reads as a still image with a wobble on
it. Each section kind now gets a roster of three or four stage visuals
instead of one scene, and each section is cut into shots that rotate
between them on phrase lines, never holding past 22s. The roster stays per
kind, so a track's drops still cut between the same images and the video
keeps its identity; the anchor opens each section and the rotation returns
to it, and when a companion is due it is the least recently shown one.
The arc driver stopped working in sections and started working in cues, one
per shot, so a shot cut and a section change take the same code path and
differ only in transition length. The default transition is a slow
dissolve — two bars calm, one loud; a straight cut is reserved for
sections above the energy threshold, because on calm material a cut reads
as a glitch rather than as an edit.
Phase 9: production design. With cuts every fifteen seconds the next
problem was that the images being cut between shared nothing but the
palette. What a music video actually shares across shots is a location, a
cast, a camera operator and an art direction, so each track now generates a
personality in four traits (shape, camera, space, style) off the look seed.
The traits reach shaders as uniforms plus four helpers in the contract, and
each scene expresses them its own way: Classic Wave's rings take the
signature polygon, Metaballs merge as one, Floating Geometry no longer
picks between a box and a circle because the production already decided.
The part that makes it a design rather than a filter: scenes DECLARE which
traits they honour, a track is built on one or two, and a scene that does
not honour all of them is not cast in that track. The library shrinks per
track on purpose.
Two gates keep the declaration honest — lint greps each shader for evidence
of every trait it claims, and a render check measures that each declared
trait actually moves the image (41 scene/trait pairs, weakest response 64
of 255). A layer with no personality renders bit-identically to before,
which is what keeps every earlier sweep and regression valid.
Checks changed rather than added:
- P4 scene-change and drift checks now measure per shot, not per section;
the crossfade check reads its length off the cue.
- P5 flash sweep runs per shot, so the visuals that only appear
mid-section are measured too.
- P6 preview/export parity primes first (as both real paths do) and
compares at the one-LSB tolerance Phase 7 already uses. Measured over
four consecutive shows: 3 frames at delta 1, then bit-exact — GPU
variance on first render, not a divergence.
- P2's contract-uniform list is derived from the contract instead of
retyped, so the signature uniforms cannot fall out of sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9d15c3cf49 |
Phase 7: grow the scene library to 16
Ten new scenes, with 'minimal' first: the family was empty, so intros and breakdowns fell through to flow/organic and every track opened at full density. Quiet sections now land on a restful family 48/48 times across 24 seeds, 28 of them minimal. New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow (flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid (geometric); Ridge Terrain (structural); Scan Tear (glitch). Four real bugs, three of which the existing gates could not have caught: 1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses KHR_parallel_shader_compile, so draws against an unlinked program render wrong. The heaviest scene had its first TEN frames differ from every later render of the same frames. Preview hides this entirely; export renders each frame once, so those frames would ship broken. Added Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame — and the exporter now primes before encoding. Rendering a throwaway frame and reading it back is NOT sufficient; measured, it left 3-5 frames wrong. 2. Moiré Grid declared a param on u_width, which the shader contract already uses for stereo width. GLSL redefinition, and the only symptom was a black frame. Lint now rejects any param uniform colliding with the contract. 3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured in turns meant bar-crossing frequency was bars x rate (82 bars put a slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made every bar switch band simultaneously. Rotation is now in segment units so the rate IS the crossing frequency, bands interpolate, and the range is capped where the flash meter measures zero. 4. Particle Field was being chosen as a primary background despite being mostly empty by design. Scenes now declare role: 'accent'; those are never primary and are judged on variance rather than luminance. Three checks were themselves wrong and were rebuilt: mean-distance metrics unfairly fail sparse scenes for being tasteful rather than static, so "animates" and "no duplicates" now use max channel delta. PLAN.md §1 gains two refinements: programs must be primed before the first frame, and even same-machine the heaviest shaders vary by one LSB under differing GPU load — so the per-scene criterion is max delta <= 1 rather than an identical hash. A real bug scores in the tens there. Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms, worst flash rate 0/s, worst determinism delta 1/255. Adds README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
59180948d0 |
Phase 5: compositing depth ("C" complete)
Multi-layer stacks with blend modes, feedback, post chain, and a 3D particle layer proving the compositor is genuinely hybrid. Looks now generate accent layers from a different family, composited additively at low opacity, weighted by section energy so intros stay sparse. Adds flash-rate safety (engine/flash.js), which was not in the original plan and should have been. This generates beat-reactive video for publication, and rapid light-dark cycling is the photosensitive-epilepsy trigger; WCAG 2.3.1 caps it at three flashes per second. Classic Wave measured 7-8/s at every output resolution from 96x54 to 1920x1080, so it was a real hazard rather than a sampling artefact. Root cause was general, not one bad shader: `u_time * u_speed` where speed is reactively modulated. Phase is elapsed*rate, so changing the rate at time T jumps phase by T*delta — sixty seconds in, a 0.05 wobble throws the phase three whole units between consecutive frames, and it worsens as the track runs. Fixed by introducing rate params: - schema flag `rate: true` documents and marks them - Layer.resolveParams skips reactivity on them - ArcDriver skips drift on them - validateModule rejects a reactive entry on one - lint-scenes greps shaders for `u_time * u_X` and fails if X is unmarked, so no future scene can reintroduce it Every rate param across the six scenes is now marked. Two checks were needed to find this: a per-look flash check, and a per-SCENE sweep at aggressive params, since the look generator only samples part of the space and a scene can hide an unsafe region for a long time. Gate 10/10. Worst flash rate now 1/s. Feedback stable over 10,000 frames (luminance 0.17-0.59, no saturation or decay). 0.17ms/frame at 1280x720. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5f25437b89 |
Phase 4: arc driver ("C" brain)
Three timescales now stack: per-frame reactivity, per-section seeded LFO drift, and whole-song scene changes with lookahead. Layer instances are cached per section and reused across crossfades — rebuilding them per frame would recompile shaders every transition. Crossfades run forward from a boundary: the outgoing scene holds while the incoming one fades in over it. Three real bugs, each found by a check that had to be rewritten first: 1. A pop exactly at every transition. buildSlope is discontinuous by construction (~1 before a boundary, 0 after), and the outgoing layer is still on screen when it flips — collapsing its lookahead ramp in one frame. It now holds the slope it had entering the boundary. 2. FeatureTrack.at() returns a REUSED row object, and _boundarySlope() called at() again mid-render, rewriting the features the layer was about to read. Symptom: a frame correct on every repeat and wrong the first time — invisible to fresh-vs-fresh comparison, and wrong in every export, since export renders each frame exactly once. Now indexes the typed array directly, with the aliasing hazard documented on at(), and a new check covers the whole bug class. 3. Warm-up converged to 1%, leaving a visible 0.015 difference at heavy feedback settings. Now targets 0.1%. Two checks were themselves wrong and were rebuilt: a raw delta threshold and an outlier-vs-local-median test both flag beat flashes as pops, and a control window taken from a different scene reads an ordinary busy scene as a 9x spike. The working formulation A/Bs each boundary against the interior of the two scenes adjacent to it. PLAN.md §6 corrected: boundary seeks are NOT exact for free. Layer state is re-seeded there but the feedback buffer is global and carries across. Clearing it at boundaries would buy exactness for a visible flash at every transition; warm-up is the better trade and applies everywhere. Gate 9/9. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
022c267888 |
Phase 3: look generation ("A" complete)
A track now yields a complete, coherent look with no input: palette, per-section scene assignments, parameter sets, post and feedback settings. Seeded from a hash of the decoded PCM, so a file always renders identically. - palette.js builds in OKLCH, not HSL. HSL lightness is not perceptual, so evenly-stepped HSL palettes have colours that vanish and colours that dominate — which matters when nobody is supervising the choice. Regenerates until the contrast floor is cleared. - Scenes are assigned per section KIND, not per section: a track's drops share a scene and the video reads as one piece instead of a shuffle. - Family preference per kind keeps breakdowns off strobing glitch scenes. - Section bias (energy/density/motion) carries track character into params without scenes knowing anything about audio. - PaletteSource is the seam for cover art later; no scene would change. Gate 9/9, including the look-space spread measurement (mean pairwise distance 0.168 against a 0.08 floor) — the one check that catches a generator that is deterministic and valid but visually collapsed. Known gap, not a regression: all four battery tracks currently choose the same two scenes. There are no 'minimal' family scenes yet, so intro and outro sections fall through to flow/organic. Differentiation is presently carried by palette alone. Phase 7 grows the library to fix it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |