The library-wide gate run left two failures after the migration was complete,
both of the shape "renders nothing at the bottom of a param range". Running the
same gate against the pre-migration source shows them failing identically, so
the migration did not cause either — it exposed them. The per-scene battery is
opt-in and nobody had ever run it across all sixty-five at once.
The defect is the RANGE, not the shader. Spectrum Sculpture at radius 0.15 is
too small to register and Circuit Bloom at grown 0.25 has no pads yet, and both
values are ones the generator is free to sample. Floors raised to where the
scene actually draws something.
All sixty-five now pass the full battery: renders, animates, deterministic,
distinct, param sweep, flash rate, every declared trait and every declared
artifact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two helpers made the bulk of it mechanical. `inkStroke` is a drop-in for
sigEdge — the same line at the identity's weight rather than the track's — and
`castForm` is a drop-in for sigForm, same signature so call sites do not change
shape. With those in place the substitution table is one-to-one:
sigForm( -> castForm( sigShape( -> castMain( sigEdge( -> inkStroke(
Forty-seven scenes went through that pass in one run: twenty-six take the cast
and the ink, twenty-one take the ink alone. Then the gate ran over all sixty-five
and found three the pass had broken, which is the entire reason it exists.
Spectrum Sculpture rendered pure black. It had been using sigShape as a RADIAL
METRIC rather than drawing it, and the cast carries notches and a hollow — an
annulus used as a radius turns a sculpture inside out. Reverted to sigShape and
dropped to ink only. The lesson generalises: a scene that reads a form as
geometry is not a scene that draws it, and the classifier cannot tell those
apart from the source.
Eclipse Field stopped honouring its `style` trait. It opts out of surface grain,
so sigEdge was its only style evidence, and the ink replaced it. The trait claim
is now dropped — and so is the lint change that had let inkMask count as style
evidence, which was wrong and was hiding exactly this. A trait is a property of
the track a scene may honour; an artifact is content it draws. Taking the ink
says nothing about whether a scene responds to u_sigLine.
Circuit Bloom went empty at the bottom of its `grown` range, where the pads were
carried by a hairline and the ink's stroke is thinner than the edge it replaced.
Now filled as well as stroked.
Also: the backtick check now covers every shader literal rather than only the
preamble, because a mechanical pass over sixty files reintroduced one
immediately. Three rounds lost to that typo is enough.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The library is sixty-one scenes, which is too many to convert from memory or
from taste, so the migration is a queue with a gate rather than a judgement call
per file.
Three pieces. A classifier reads each shader and assigns a tier from evidence in
the source — drawn, figure, field, treatment — so two passes over the library
reach the same answer and the work has an order. MIGRATION.md is the recipe per
tier, written to be followed mechanically. And a gate makes the result
verifiable: `consumes` is now a schema field, the lint enforces it in both
directions, and the per-scene battery renders each scene under two deliberately
distant identities and requires the picture to change.
That gate is the part that matters. Without it `consumes` is a comment, and the
whole inversion becomes unverifiable at exactly the point where it stops being
checkable by eye. With it, a scene that declares the cast and ignores it fails.
Eighteen scenes migrated. Four by hand at the drawn tier — Firefly Drift,
Metaballs, Floating Geometry, Prism Bloom — and ten at the field tier by script,
which is one declaration and one wrapped return. All eighteen pass.
The field tier is honestly marginal and the gate says so: every one of the ten
moves by 37 to 39 of 255, against 173 to 255 for the drawn tier, and the
uniformity across ten unrelated scenes is the tell. That is one global
posterisation applying, not ten scenes expressing anything. Cheap, real, shallow.
The decomposition moved from identity being worth 54% of the container to 158%,
but the stage set changed underneath the measurement and part of that is
Metaballs expressing a cast better than Constellation did. What survives the
caveat is the useful finding: a migrated library scene carries the identity
better than a stage written from scratch to carry it. The four bespoke stages
were the wrong shape of effort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two cheap measurements settled a question three expensive ones had not, and both
of them contradicted the diagnosis offered for it.
The stated diagnosis was that the identity's expressive range had become the
bottleneck. It has not. A census of the identities themselves — no GPU, seconds
to run — puts mean distance between twelve songs at 0.43 with no near-identical
pairs and full coverage of every decision space: six of six fills, five of five
lattices, six of six protagonist forms, three of three element scales. The songs
are handed genuinely different designs.
The decomposition then asked whether those designs reach the picture, by holding
the container fixed and varying only the identity, then the reverse:
identity only 0.0299
container only 0.0557
both 0.1101
neither 0.0000
Identity is worth 54% of what the container is worth, against an instrument
noise floor of exactly zero, and the two compose to more than their sum. The
inversion works at the frame level. What it does not do is replace the container.
That corrects EPIC-3 §5, which proposed a song picking two to five stages on the
theory that shared content would substitute for container variety. Container
variety is the larger of the two effects and identity adds to it. Four stages
with a rich identity throws away the 0.056 the library was already providing —
which is exactly the shape of every measurement in this epic: stages have the
lowest floor of any arm and no advantage in spread.
The direction is therefore not a small set of stages. It is the whole library
consuming the cast: keep the sixty-one containers and make them draw the song's
content rather than their own. The migration was filed as a cost; it is the
payoff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cast and ink slice lowered the floor as predicted but left `scale` — feature
size — consistently WORSE than the legacy arm, 0.033 against 0.053 and well
outside the noise. Every stage still chose its own element size from its own
param range, so every song landed in the same place: the videos shared a cast
and also, accidentally, shared how big everything was.
Staging is the third artifact. A lattice — grid, radial, spiral, scatter,
strata — with jitter, spread, a size hierarchy and a bias toward the middle or
the edges, transported the same way the cast is: uniforms plus a `stageNode`
function in the preamble, no new engine plumbing. Constellation, Swarm and
Procession place on it; the stage keeps its motion and gives up its composition.
Soloist takes only the scale, since a close-up has no composition to share.
`elementScale` is the part that mattered. It is the song's answer to "how big is
this made of", spanning about a factor of six, and it is the decision that was
missing rather than mis-set. Measured, over twelve songs and three runs:
stages spread +0.0111 ±0.0043 → +0.0143 ±0.0008
scale block 0.033 ±0.004 → 0.041 ±0.007
Against the legacy arm at +0.0108 ±0.0071 the harness still says
indistinguishable, and it is right to: the gap is +0.0035 and the legacy arm's
own run-to-run range is twice that. What can be said is narrower and holds up.
Stages have the lowest floor of the three arms by a clear margin — 0.082 against
0.095 and 0.104 — so sharing content does make a video look like itself, which
was the central prediction. And the targeted fix moved the block it was aimed at
in the direction it was aimed.
Also added: a lint check that the shader preamble contains no backticks. Twice
now one has closed the template literal and produced a check page that hangs on
"starting…" with an empty console, which is an expensive way to find a typo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Epic 3 arms appeared to show that a small casting roster was the largest
available win. Swept directly across 4, 8, 16 and 32 over twelve songs with
three pool draws each, the differences are 0.003 to 0.007 against a run-to-run
noise of +/-0.003 to +/-0.005. Pool size does nothing measurable.
The arms varied two things at once — smaller pool AND the same pool for every
song — and only the stages-versus-legacy comparison inside them held everything
else constant. That one still stands at +14%.
POOL_SIZE stays at 8, now on grounds the metric cannot see: about nine distinct
scenes in a video rather than seventeen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four stages, an identity layer, and an A/B that says the idea is right and the
reason it works is not the reason I expected.
A stage has no image of its own. It owns arrangement — a procession, a
constellation, a soloist, a swarm — and what it arranges comes from the track:
`castMain` and `castChorus` for the forms, `inkMask` and `inkValue` for the hand
they are drawn in. The identity generates a protagonist and a chorus with sides,
notches and hollows, plus an ink treatment of weight, edge, fill, outline and
posterisation. All of it travels as uniforms, so it is data rather than code and
a stage consumes it without knowing any other stage exists.
The protagonist IS the signature form rather than a second opinion about it.
They were separate draws in the first version, which let a track built on
hexagons put a round protagonist on screen — the signature said one thing and
the picture said another, and the shape trait stopped meaning anything for
stages. The cast now reads its geometry from the personality live and adds the
notches and hollows that turn a shape into a character.
Measured across seven songs, three arms, same instrument:
arm floor observed spread
stages, four of them 0.0801 0.1133 +0.0332
legacy scenes, four of them 0.0830 0.1122 +0.0292
the unrestricted generator 0.1058 0.1146 +0.0088
The prediction in EPIC-3 §7 was that sharing a cast would drop the floor
sharply. The floor did drop sharply — by a quarter — but nearly all of that came
from casting FEWER scenes per video, not from sharing content: the legacy arm,
four ordinary scenes with no cast at all, gets most of the same benefit. Stages
add about 14% on top of that.
That is worth knowing before building the other four registers. The single
largest available win was hiding in the roster size all along, and it costs one
number to take.
One result runs the wrong way and is recorded rather than explained: coupling is
+0.29 on the legacy arm and -0.18 on the stages. At twenty-one pairs neither is
distinguishable from zero, so it is a flag for a larger sample rather than a
finding.
Also fixed: a backtick inside the shader preamble's template literal, which
closed it and made every check page hang on "starting…" with no error in the
console.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes, from the list the measurements pointed at.
The personality's values now centre on the audio and the seed picks within that
centre, rather than every value being drawn from the seed alone. A bright track
sits high in its frame, a dynamic one has depth, noise earns corners where tonal
material stays round. The signature's own choice is tilted by the music too — it
decides which scenes a track can cast, so leaving it to the seed meant the most
consequential decision in the generator had no relationship to the song.
The signature stopped being a hard filter. As a filter it was the single largest
cause of sameness: a scene declaring all four traits was eligible for every track
and opened half of all videos, one declaring two was eligible for one track in
fourteen, and eleven scenes out of sixty-one carried nearly everything. It is a
weight now, worth six times at full honour.
That fix alone made things worse, which is worth recording. The filter was doing
two jobs — collapsing the library, and giving each track a DIFFERENT pool to cast
from — and removing it kept the second loss. Every track drew from the same
weighted library and measured song separation fell. So each track now draws its
own pool of about a third of the library, weighted by the signature but sampled
without replacement, keeping the differentiation and dropping the bias.
Motion became a character rather than a rate. Tempo was the only lever and tempo
compresses; stillness is a separate question from speed, and it can now halve the
animation rate or raise it by a third.
What it bought, measured against the same instrument: every scene in the library
is now cast, where sixteen were never reached; identical casts across seeds went
from four pairs to none; and the raw structural distances all rose — motion by
57%, layout by 22%, scale by 23%.
What it did not buy: videos also became more varied INTERNALLY, by more than they
became different from each other. Two songs are no more distinguishable relative
to how much one video already changes over its own length than before, and the
coupling between musical distance and visual distance is still indistinguishable
from noise at this sample size. The ceiling reference now sits below real
outputs, so the separation ratio is reported as not computable rather than as a
large number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tests now run on real bank entries. The seed gates used to run on a
two-section synthetic whose only kinds were intro and outro, so half the scene
library was unreachable and the number was measuring that rather than the seed.
Probes are labelled by section kind and occurrence rather than by index, which
is what makes two different songs comparable at all — they have different
section counts, so probe 3 of one is not probe 3 of the other, and matching by
position would compare a drop against an outro and score the mismatch as
variety. For two seeds of one song the labels are identical and this changes
nothing, which is the point.
The song test measures one thing the seed test does not: coupling, the rank
correlation between how different two tracks sound and how different their
videos look. Separation alone can be had by a generator that ignores the audio
and hashes the file, and that would be a perfect score for a completely wrong
video. Separation without coupling is not variety, it is a different seed per
file.
The ceiling took two more attempts. Recasting every layer at random averages a
dozen scenes together and a dozen random scenes converge on the same generic
busy image, so two references came out closer to each other than two real
videos and blocks scored over 100% of achievable. Forcing one scene per
reference collapsed the other way: a video that never changes scene has almost
no internal variation, so the ceiling landed BELOW the floor, which is a
within-video quantity. A reference has to match the structure of what it bounds.
They now keep the real pipeline — rosters, shots, per-section sampling, so a
reference rotates between three or four scenes exactly as a real video does —
while drawing from disjoint slices of the library. Same complexity, nothing in
common.
The fingerprint measurement had the same shape of error: pooling every probe
mixed in how much each video varies over its own length, which is large for
everything, and washed the answer to a flat 100% while the separation score said
almost everything was collapsed. One vector per video now.
Both tests fail as committed. Seed separation 0.04, song separation 0.03,
coupling -0.03 — two different songs differ from each other by about as much as
one video differs from itself, and that difference has no relationship to the
music. Colour scores 112%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two complaints, one cause: stages were flat blocks and the noise bed was glued
to the pad level, so the bed was a constant hiss under a track that never went
anywhere.
Sections have contours now — rise, fall, swell, dip, surge — sampled per note
rather than per stage, so a build builds through its chords instead of stepping
between two flat halves. The noise gets its own per-stage level instead of
following the pad, which makes it an arrangement element: a riser through a
build with its filter sweeping up, a wash under a drop, nearly absent in a
breakdown. That is also the better test signal, since the segmenter classifies a
section partly on its energy slope and a build that does not build is one it has
to guess at.
Transition hardness is a per-song axis. Genres differ on this more than they
differ on tempo — an ambient record dissolves between its sections and a club
record cuts — and until now every song in the bank cut. Soft songs crossfade
across a couple of bars; the hardest get the pre-drop trick, where everything
stops for most of a beat before the loud stage lands. Five soft, three mid, nine
hard, and the builder fails if the bank ever loses either end.
The contours immediately broke the axis they were layered onto: section-to-
section contrast is dynamic range, so adding it put a floor of 0.50 under a
statistic that had reached 0.18. That is what compression IS, so contour depth
now scales with the track's dynamics — a limitered master has shallow section
contrast as well as a shallow crest. Back to 0.29, which is as low as enveloped
notes and hard cuts will go.
One regression accepted rather than fixed: the soft fades cost the beatless
entries their tempo detection, since the attack that gave a drone a pulse is
exactly what a crossfade removes. `drone` reads 170bpm for a 62bpm source. A
beatless track has no tempo and the detector is guessing either way; `ember`
exists to anchor the low end with a beat that is actually there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tonal content was a sustained sine stack at a fixed root, and the stage
multiplier scaled that root directly — so a drop arrived at roughly 1.2kHz with
ten harmonics stacked on top of it and held there for thirty seconds. Every
statistic it was built to control came out correct. It was also unlistenable,
and a bank nobody can stand to play is a bank nobody audits.
There is a scale, a chord progression, a bass on the chord roots and a seeded
motif over the loud stages. Key, mode and progression come off the song's seed
so two entries are not the same four chords at different tempos. This is the
plainest thing that qualifies as music — not trying to be good, trying to be
playable enough that a person will listen to the bank and notice what is wrong
with it.
Register is the real fix. `stage.root` used to multiply the fundamental and now
adds voices upward instead: bass and chord stay where they belong, a lead octave
arrives when the arrangement opens up. Brightness still moves the harmonic
content and the key's register, but across an octave and a half rather than a
piercing sweep. Harmonics above Nyquist are dropped rather than left to alias,
since folded energy would corrupt the centroid and flatness the bank exists to
control.
Two measured consequences. Tempo detection on the beatless entries got better,
not worse — the bass gives a pulse where the drone gave nothing, and `drone` now
reads 62bpm instead of 88. And enveloped notes put a floor of 0.27 under the
measured dynamic range, because the gaps between notes are gaps a limitered
master does not have; note decay flattens toward a hold as dynamics falls, which
takes the compressed end back to 0.18.
Coverage still passes on all six axes and all six section kinds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the bank whose first half went in with the scene commits. The
remaining work was all in what "covered" means.
Two axis ranges were wrong, and being wrong made the coverage report lie in
both directions. Loudness is a raw mean spectral magnitude, not a normalised
0..1, so every song read as 0.00 and the axis looked dead. Centroid is mapped
to a log frequency axis, so a track of nothing but sub-bass and a 55Hz pad
still measures 0.28 and pure hiss measures 0.93 — against a nominal [0,1] the
bank would have reported a permanent 50% gap that no synth change could close.
Both now carry the range the statistic can really take on, with the reason.
Spread on every axis is not coverage of the space, so the tool also measures
correlation between axes. It immediately found brightness and noisiness moving
together at r=0.95: noise colour had been tied to brightness, one axis wearing
two names, leaving the dark-and-noisy quadrant unreachable. Noise colour is now
its own parameter — hiss over a sub-bass pad is an ordinary record.
That got r to 0.94, and no further. Flatness is the geometric mean of the
spectrum over its arithmetic mean across the whole band, so a signal only
measures flat if it has energy everywhere, which is the same thing as measuring
bright; band-limiting the noise to hold the centroid down empties the top and
drops the flatness with it. It is a property of the analyser and real material
does the same, so it is allowlisted with its reason. Anything not on that list
still fails — the check is there to catch a bank that has collapsed, not to
relitigate physics every run.
The output is gitignored. It is 172MB of deterministic audio derived from a
table that will keep changing: rebuild it, do not carry it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three lessons worth more than the scenes: a slow axis has to move a large
low-frequency area or the camera's own drift beats it, whole-frame luminance on
the kick is the strobe the flash gate exists for and it arrives by accident, and
a style trait expressed only as grain measures as no style at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Halftone Misprint is a printing fault rather than an electronic one — the image
is never damaged, only separated and reassembled out of register, and nothing
else in the library is made of dots. Droste Feedback scales the previous frame
where Time Smear translates it, so the image never clears. Analog Wow is the
continuous, wet counterpart to Scan Tear and Block Mosh: the error varies
smoothly down the frame because every line was written at a different moment.
Three things that had to be got right. The halftone ruling is a count of dots
across the frame, not a pixel pitch, or a 4K export is the same dot on a bigger
sheet. The Droste loop has to CONTRACT — expanding pushes every copy off the
edge and leaves a plume instead of a corridor. And all three expressed style
only through grain at first, which is the Side Quest 1 complaint: they now put
the track's line weight and edge softness into the dot, the ring and the band
boundary, taking the measured style response from 24/11/18 to 86/255/88.
Neither feedback scene declares a slow axis, with the numbers recorded in the
files: their own history moves the ten-second average further than any parameter
does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Twenty-three of sixty-one scenes are never cast" was an artefact of the song it
was measured on. synthesizeSectioned has one change point, so it segments into
exactly two sections and both are quiet kinds — and intro, breakdown and outro
are restricted to the restful families for every director. Half the library was
unreachable before a seed was drawn, and the measurement reported that as a
casting failure.
synthesizeArrangement builds a real one: intro, build, drop, breakdown, drop,
outro, shaped to hit the segmenter's own classifier rather than to sound like
anything. Measured against a bank of those, two scenes out of sixty-one are
never a background, not twenty-three.
The census exists because "never cast" without a reason is unactionable. A scene
can die at the signature gate, at the director's family table, or in the roster
draw, and those are three different repairs — a trait declaration, a table, and
arithmetic that neither fixes. It reports which one, and it separates being cast
as a background from appearing as a translucent overlay, because a library can
look fully used while nine scenes carry every frame.
What it finds is that eligibility is almost entirely a function of how many
traits a scene declares. Four-trait scenes are eligible for every track and open
half of all videos; two-trait scenes are eligible for one track in fourteen; the
single scene declaring one trait is eligible for none, ever.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other phase asks whether one video is correct. This asks whether two are
different — the failure the suite could not see, since a generator that ignores
its seed passes determinism, flash safety and liveness perfectly.
Frames reduce to a structural descriptor built to be blind to the cheap axes and
sensitive to the expensive ones: standardized luma kills exposure and palette, a
Laplacian pyramid gives the radial spectrum, and the gradient-angle histogram is
carried through a DFT magnitude so a rotation shifts it without moving it.
Colour is measured and never counted; its only job is to expose the case where
two seeds differ by a palette swap and nothing else.
The score means nothing on its own, so it sits between two references the same
instrument produced: a floor of how far one video travels from itself across its
own sections, and a ceiling of the same pipeline with every layer recast at
random. Two checks gate the instrument before any number from it is trusted —
recolour must move structure ~0 while moving colour a lot, and a quarter turn
must not move it at all.
Three things this got wrong first and now does not. Averaging each video's
probes into one descriptor washed out the structure being measured and put the
floor above the ceiling; probes are matched instead, which is fair because the
track is held fixed. Cosine distance on all-positive histograms scored unrelated
scenes at 0.05, too compressed to be read; chi-square replaces it. Single-link
clustering in the library sweep chained overlapping pairs into a fourteen-scene
group that did not exist; complete-link means every pair inside a group is
really a twin.
The sweep runs against all 61 visualizations, and it finds what the per-scene
distinct gate cannot, because that one compares raw pixels and structural twins
are merely differently coloured.
Both rendered gates fail as committed. That is the point of them: separation is
0.07 against a 0.35 target, two 4-cliques of scenes are one look each, and a
third of the library is never cast. The instrument passes; the generator does
not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voronoi Shatter (cells with no fixed shape, re-cut on the phrase), Apollonian
Gasket (a packing built by inversion, so zooming finds more circles rather than
finer noise) and Isometric Blocks (a lit, gridded, solid surface where Floating
Geometry is bodies adrift).
Two flash failures, both the same mistake in different clothes. Apollonian's hard
depth cutoff popped a whole generation of discs in and out as the fold crossed
it — now faded rather than cut. Isometric Blocks drove block height off the low
end, so every tower in the frame grew and shrank together: 4/s. Moving the
reaction to the edges and to a hash-chosen quarter of the tops keeps the pulse
and drops the frame-wide swing.
Voronoi Shatter declares no slow axis: its re-cut puts the ten-second average
0.127 from itself between any two windows, the highest noise floor in the
library, and no parameter competes with that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Suspension Span is two curves and a rhythm of hangers where Girder Lattice is a
texture of members. Stairwell Descent puts the vanishing point dead centre and
turns each flight, so the spiral is in the structure rather than in the motion.
Aqueduct March is masonry — the light comes through holes cut in a solid wall —
and Data Aisle is the interior, close counterpart to Neon City's exterior.
Two bugs worth naming: the arch openings compared a cell-local centre against a
global coordinate, which put every arch out in the wings and left a blank wall;
and the stairwell's depth fade was keyed on floor index, so it only darkened the
few pixels at the centre and measured as doing nothing.
Aqueduct March declares no slow axis and records the three candidates measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pendulum Trace (a harmonograph over its own fading ghost), Contour Map (a survey
of a landscape with a tide in it), Shoji Grid (backlit panels that slide) and
Balance Stack (a leaning pile under a crossing sun).
Two of the four leave the slow axis undeclared and say why in the file: in a
frame this empty the camera's own drift moves the ten-second average more than
anything in the scene can, so a declaration would be a claim Phase 11 contradicts.
Phase 4's transition control changed from max(before, after) to their sum. These
scenes are calm, so casting them into the mid-shot control windows halved the
denominator while the boundary peak stayed put — 0.124 with them, 0.130 without.
For most of the window both stacks are live, four layers with accents in overlay
and screen, and a nonlinear blend of two moving stacks moves more than either
alone; "no worse than the busier half" was never a property a dissolve had. A
real pop still clears the threshold by an order of magnitude.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turing Bloom runs activator-inhibitor in the feedback buffer, so the pattern is
formed rather than drawn — the one organic here that will not go uniform. It
declares no slow axis on purpose, and says why: its own convergence path moves
the ten-second average by 0.10, and every candidate axis measured under that.
Mycelium Web puts an organic on the ground instead of standing in front of the
camera; the colony front is legible as area, which is also what makes its axis
measurable. Scale Mosaic is a log-polar lattice with a shear, so the rows are
spirals and every scale is the track's signature form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rain Column, Magnet Lines and Kármán Street. The flow family was seven ways of
advecting a noise field; none of them had an upstream, an obstacle or a rule.
These do: rain falls in three parallax layers under one shear, the field lines
are exact contours of the poles' stream function so they close on themselves,
and the vortex street is shed by a body that never moves.
The slow axes took four rounds to land. A fine-grained axis — line count, drop
density — loses to the camera's own drift in the time-averaged comparison Phase
11 makes, so all three ended up on a param that moves a large low-frequency
area: streak length, pole spread, and a wake haze that dissolves the streamlines
it widens over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revisiting the framing layer turned up that it was not reaching four of the
forty-two scenes, and that none of its gates could have told us.
Those gates check the PLAN — cue sizes, the distribution of shot sizes across
a population, headroom, seek determinism — and a plan that never reaches the
image passes every one of them. Rendered at wide, normal and close, Scan
Tear, Pylon Grid, Pitch Shatter and the 3D Particle Field came back
byte-identical at every size.
The cause was a category error in the first implementation. Framing was
applied inside sigCamera, which is gated on the `camera` personality trait —
so a scene that declined the track's drift and sway silently declined the
shot size as well. That gating is right for a TRAIT and wrong for framing,
which is not one: framing is where the camera is standing for this shot, and
no scene should be exempt from it because of an unrelated art-direction
decision.
- Framing now lives in the shader epilogue, applied to the coordinate every
fragment scene is handed, so honouring it is not optional. uv is left
unframed on purpose: it is screen space, and prev() and sigGrain belong to
the output image rather than to the scene being filmed.
- Scan Tear and Pitch Shatter build their image from uv deliberately — a
signal artefact happens to the signal, not to the world behind it. They now
slice on raw uv and build the field they displace from a new framedUv(p),
so the tear stays locked to the frame while the imagery behind it is filmed
wide or close.
- Particle Field receives framing in update() and honours it as a camera
dolly, which is what framing literally is when a layer has a real camera.
Distance divided by scale, matching the fragment path where the coordinate
is divided by it.
New gate renders instead of inspecting: 42 of 42 scenes now respond to
framing, weakest Ridge Terrain at 0.22 of its own brightness, against a 0.05
floor. Also drops a stale comment on Layer.setFraming that still claimed
sigCamera applied it.
105/105 checks pass including the slow set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Curl Flow, Signal Decay and Circuit Bloom had no parameter that changed their
structure. Each was one process at one scale, statistically identical
everywhere and at every moment — which is the technical statement of "it
looks the same for five minutes". Every pixel moving, the image never
changing.
Each got the large-scale structure it was missing, and the slow axis now has
something real to walk:
- Curl Flow gains a CURRENT: one broad band carries the filaments and the
rest runs bare. The axis is the band's strength rather than its position,
and that choice was measured — moving the band preserves the frame's total
energy, so a ten-second average of it is nearly the same image wherever it
sits (0.0028 across a full sweep). Opening and closing it changes how much
of the frame is lit at all. The current gates the final image rather than
only the vein term, because the feedback trail and the flow tint each fill
the frame on their own.
- Signal Decay gains a DAMAGE FRONT: lock falls away on one side of a moving
boundary, so the stack has a shape instead of every lane being an
independent coin flip. Hiss had to be gated by it too — hiss rises as lock
falls, so without that a damaged lane simply traded signal for noise and
carried the same energy, and the front cancelled itself out.
- Circuit Bloom now actually BLOOMS. `reach` was a fixed vignette, so the
packets ran, the pads blinked and the board never changed. Its range stops
at 1.6 because the frame's far corner is ~1.4 units out and travel spent
past that changes nothing — the first attempt wasted most of the axis up
there.
The gate itself was wrong, and this is the more important half of the commit.
It compared the scene at thirty seconds against the scene at two and a half
minutes and divided by what it did between those points with frozen
parameters. Those are two different places in the song, so the denominator
was full of audio reactivity: it was really asking "does the axis move this
scene MORE than the music does", which no scene should have to pass. Circuit
Bloom failed at 0.92x while its mean luminance moved 5.6x across the axis — a
large structural change scored as nothing.
The experiment now holds time constant and varies only the axis, against a
control that is the same parameters over the next window — the residual churn
the ten-second average failed to cancel, which is the noise floor it has to
beat. With the axis disabled the two parameter sets are identical and the
ratio is 0.00 by construction, so the companion check is a real discriminator
rather than a formality. It reads 0.00x on all six scenes.
Moiré Grid 28.7x · Gate Corridor 14.3x · Truchet Fold 3.3x · Signal Decay
1.9x · Curl Flow 1.7x · Circuit Bloom 1.4x, against a 1.25x bar.
104/104 checks pass including the slow set. Closes SIDE-QUESTS.md §2 and the
open half of EPIC-2.md §3.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 7's determinism check reported Horizon Lines at 2/255 against a 1/255
tolerance, but only when phase 7 ran in isolation — a full run warmed the GPU
first and it passed. A gate whose verdict depends on preceding load will
eventually stay green through a real regression, and determinism is the one
property this whole project is built on.
The cause turned out to be geometry, not the driver. The lines were drawn
with `smoothstep(u_thickness * (0.5 + u_sigLine), 0.0, d)` where the width is
0.008 scene units against a 0.028-unit pixel at 720p — a line under a THIRD
of a pixel wide, ramping from full brightness to nothing across that third.
The parameter range goes down to 0.002, which is a fourteenth of a pixel.
That is a near-vertical cliff, and a cliff turns a float wobble of 1e-7 in a
cancelling subtraction into a whole byte of colour. Nothing else in the
library sums 40 such terms.
Replaced with analytic coverage: `sat((w - d) / px + 0.5)`, where px is the
exact height of a pixel in scene units. The transition now always spans one
pixel, so no pixel sits on a discontinuity — and a line thinner than a pixel
comes out DIM instead of being drawn at full brightness wherever a pixel
centre happens to land on it. That second part is a real image fix as well as
a determinism one: it is the end of the shimmer this scene has always had.
Width stays in scene units, so resolution independence is untouched — the
320x180 vs 1280x720 diff still reads 0.00183 against a 0.06 limit.
Measured: worst delta over two identical 20-frame captures drops from 2 to at
most 1, usually 0. Phase 7 now passes in isolation, repeatedly, which is the
condition that was failing. Horizon Lines is no longer even the worst scene
on that metric.
Also removed the dead `total` accumulator while in there.
104/104 checks pass including the slow set. See SIDE-QUESTS.md §3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Classic Wave, Silk Ribbon, Kaleido Tunnel and Slow Orb all declared the
`style` trait and honoured it with `col += sigGrain(uv)` and nothing else. A
declared trait is a contract — the disqualification rule in Personality.js is
the only thing keeping off-design scenes out of a track — so honouring it
with dirt meant these four could not take the `texture: 0` opt-out the grain
work introduced. Phase 9 measured their style response at exactly 0. They sat
at texture: 0.35 as a stopgap, which kept speckle on the library's cleanest
scenes purely to keep a gate green.
Each already had the knob; it just was not wired to the track:
- Classic Wave contrasts its wave through a bare smoothstep(0.2, 0.8). The
transition width now comes from u_sigSoft and u_sigLine, centred on 0.5 so
changing the hand does not change the exposure. Crest concentration is
driven separately by u_sigLine, because a track is free to sample the
scene's own u_softness at zero and the art direction must still show.
- Silk Ribbon's strand width is u_sigLine and its falloff exponent u_sigSoft:
a sharp track gets a filament with a defined edge, a soft one a haze.
- Kaleido Tunnel drew its grid against a bare 0.42 — a line weight with no
name. It is now a weight and a feather, both from the track.
- Slow Orb's body edge multiplies the scene's softness by the video's, and
gains a sigEdge rim so a sharp-handed track gets a defined limb.
All four are now texture: 0. Style response measured against a maximally
soft-handed versus maximally sharp-handed personality: Classic Wave 111,
Silk Ribbon 216, Kaleido Tunnel 206, Slow Orb 102, out of 255 — previously 0.
104/104 checks pass including the slow set. See SIDE-QUESTS.md §1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exporter was losing roughly three of every four frames. A 20s render
muxed 319 samples instead of 1200 and played at 15.9 fps, with no error
reported and a file that looked superficially fine.
The cause was B-frames. WebCodecs delivers chunks in decode order, but
EncodedVideoChunk carries only a presentation timestamp — there is no decode
timestamp to recover the real order from. Handed presentation timestamps as
if they were decode timestamps, mp4-muxer saw DTS run backwards and rejected
every reordered chunk. That throw happened inside the encoder's output
callback, where it could not reach the export loop, so it surfaced as an
uncaught error and the render carried on. Only the I/P frames survived, one
per 4-frame GOP, which is exactly the stts pattern the files showed.
Writing the correct timeline instead is not available to us: it needs
negative composition offsets, and mp4-muxer emits ctts as a version-0 box,
which is unsigned. isConfigSupported says nothing about reordering, and
measurement showed latencyMode: 'realtime' does not prevent it either.
So pickVideoConfig now test-encodes 12 frames per candidate and checks the
order they come back in, taking the first profile that does not reorder.
Candidates stay in quality order, high profile down to baseline, so browsers
that never reorder keep the better profiles; baseline forbids B-slices by
spec and is the guaranteed floor. If every supported profile reorders the
export fails up front rather than after minutes of rendering.
Two guards so this class of loss cannot be silent again:
- The output callback catches, routing muxer rejections to the error list
the loop actually checks.
- Frames in and chunks accepted are counted and compared after flush, with
the reordering count and a gap histogram alongside. The count deliberately
tracks chunks the muxer took, not chunks that arrived — counting arrivals
reports success for frames rejected a line later.
Failures now raise a toast over the stage that stays until dismissed. An
export that dies after a long render should not sit unread in a panel.
Also corrects the record from d31d0fc, which claimed VideoEncoder.encode()
silently drops frames once its queue saturates. It does not: the queue grows
without bound and the only cost is memory. That commit's dequeue-gated
backpressure addressed a mechanism that does not exist and is reverted here;
the queue poll it replaced is restored, described honestly as a memory bound.
The opus resampling from that commit was a real fix and is untouched.
tools/probe-mp4.js reports per-track timescale, sample count and the stts
table, which is what identified the fault and what verifies a good export:
one row of [N x 1].
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VideoEncoder.encode() silently drops frames once its internal queue
saturates; the previous loop only polled encodeQueueSize every 10 frames,
so a lagging hardware encoder lost frames in bursts (the "few frames every
beat" bug). Gate every encode on the dequeue event instead, keeping the
queue small but non-empty.
Also resample PCM to 48 kHz when encoding opus so the muxer timescale,
chunk timestamps and bitstream all agree (opus is natively 48k; a 44.1k
source previously produced a file claiming 44100 while the stream was 48k).
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 d14d473.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 d14d473. All determinism and
preview/export-agreement checks pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
Seven per family now, 42 scenes. Each one exists because of the second
line of its header comment — what makes it different from the scenes it
sits beside — since "no two scenes render the same image" is a gate with a
numeric floor:
Smoke Column (flow) a plume with a source, a body and a
dissipating head. Everything moves one way and
widens; an isotropic field has no up.
Cell Divide (organic) a partition, not objects. Every pixel belongs
to a cell, boundaries are hard, no background.
Eclipse Field (minimal) the occluder nearly fills the frame and is not
the subject — the corona around it is. The
library's one high-contrast minimal scene.
Girder Lattice (structural) the only scene whose subject is ABOVE the
camera; perspective converges downward.
Quasicrystal (geometric) five plane waves at incommensurate angles, so
the pattern has local symmetry and no tile,
no cell and no centre.
Time Smear (glitch) nothing is displaced. Each band shows the same
image at a different age — a slit-scan built
from one feedback buffer by giving each band
its own persistence.
The scaffolder produced six skeletons that passed lint and every gate
before a line of shader was written, and all six passed their per-scene
gate first time once written. That is what it was for.
Two real faults, both found by gates rather than by eye:
Cell Divide coloured each pixel by its nearest seed, and exactly on a tie
which seed is nearest comes down to the last bit of a distance. Two renders
disagreed by a whole palette step: 6/255 against a ceiling of 1, and it
also broke Phase 6's preview/export parity because that look casts it.
Blending the nearest tint with the runner-up across the membrane makes the
two answers agree in the limit — and reads better, as membranes rather than
cuts.
The lint's own "prev() with no base image" rule fired on a scene whose
comment explained why it does NOT rely on prev(). Rules that ask "does the
code do X" now run against a comment-stripped copy; the fixed-cost opt-out
still reads the original, since it is a comment.
Phase 6's parity tolerance goes from 1/255 to 2. Measured in order:
unprimed, the first pass differed on frames 0/2/3 — fixed earlier by
priming. Primed and isolated: 0/40 at delta 0, three times over. Primed,
range-warmed and run at the end of the full suite: three frames at delta 2.
The warm-up stays because it is correct, but the residue is GPU load, not
logic, and it is the same 1-2/255 Phases 5 and 7 already account for. A
real divergence scores in the tens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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.
Three new scene shaders to widen the library, now that the main families are
settled. Each is a shader plus a params block, registered in the single
source of truth (scenes/registry.js), so casting, UI, arc and checks pick
them up automatically.
- Block Mosh (glitch): a datamosh. Blocks pull the feedback buffer along
per-block strokes on a bar-quantized grid so the corruption steps like an
edit rather than crawling, and spills on onsets. Fills the most iconic
gap in the glitch family.
- Neon City (structural): a receding skyline with instanced lit windows,
distinct from the rolling ridgelines and the road grid it sits alongside.
- Flora (organic): an abstract plant of swaying stalks, teardrop petals and
a bloom corona, all stamped in the track's signature shape.
Also adds HOWTO-visualizers.md, a quick reference for building the next one.
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>
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>
Full preview surface plus WebCodecs export, both driving the same Show —
the exporter has no render path of its own, which is what makes parity
structural rather than something to keep in sync.
Preview: transport with section jumping and looping, drag-scrub, timeline
strip showing sections coloured by kind with bar ticks and lock state,
generated param controls, reroll (whole track or one section), lock,
draft/full quality, debug HUD, click-track download, and a 20s test render
at full export quality.
Export: probes for a supported H.264 config, warms up before a mid-track
range so the first frame carries the feedback state continuous playback
would have given it, and encodes audio from the decoded PCM.
One real bug found by the gate: AAC is absent from Chromium builds without
proprietary codecs, which still ship H.264 encoding — so video succeeded
and audio killed the whole export with "Cannot call 'encode' on a closed
codec". The exporter now probes AAC then Opus, and a failure mid-encode
degrades to video-only rather than losing a long render. Fallbacks are
surfaced in the UI; a video that quietly lost its audio is worse than one
that says so.
Also adds a dev-only window.__flowState handle. The render loop is
rAF-driven and rAF does not fire in headless/automated contexts, so this
provides a way to step the app by hand.
Gate 10/10 (one manual: upload a test render to the real platform once
before trusting a full export). Real mp4s verified — ftyp box, honoured
frame ranges, resolution restored, cancellation clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
All five party-stage shaders now ported. Each declares its params
declaratively; uniform binding, UI controls, seeded sampling and arc
automation all derive from that one block, so a new scene costs a shader
and a schema and nothing else.
Port changes: LED-grid masks stripped, hardcoded colours replaced with
palette lookups, magic numbers lifted into params. Psychedelic Drift's
internal 15-second scene timer removed — keeping the visuals moving is the
arc driver's job, and it knows where the song's real transitions are.
ParamPanel generates controls from the schema alone and knows about no
specific scene; a hand-written control would be a bug.
Gate 8/8: schemas valid, uniforms accounted for in both directions, all
scenes compile and render, 128 range-sweep frames with no black, blown or
flat results, every param exposed in the UI, edits clamped, presets
round-trip and survive schema drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>