HOWTO-visualizers.md gets a scene working. Nothing said how to make one look
different from song to song, which is the thing the library is worst at and the
thing every measurement this epic has been about.
Written as evidence rather than advice: each claim carries the number behind it,
so a later change can contradict it. Several already contradict things believed
earlier in the same week.
It records the failures at least as carefully as the wins, because they were more
informative and each looked obviously right beforehand — effects as parameters
inflating a score without changing a picture, concentrating disturbance instead
of adding it, the roster-size theory that a direct sweep found to be nothing, and
reading a form as a metric rather than drawing it. It also documents what the
descriptor cannot see, since half of "why is my score low" is there: brightness,
colour, rotation, quality, and layout for anything that fills the frame.
And it is explicit about which numbers to trust. Direct render comparisons have a
noise floor of zero; aggregate ratios swing enough to have produced four
withdrawn conclusions in one epic.
Ongoing by design, with the open questions listed: coupling has never moved off
zero, fixed-geometry scenes have no known route to variety, and the 0.04 bar was
set against scores that were depressed by a measurement bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked for, built, and honest about the result: the focal point costs this scene
a quarter of its variety and I could not make it pay for itself.
uniform scatter, no focus 0.0490
focus, trading scatter for it 0.0374
focus, added on top of it 0.0365
The first version spent the scatter budget near the focus and calmed the rest,
which put most of the field back on the rigid lattice and took the orientation
variety with it, 0.164 down to 0.105. That diagnosis looked right, so the second
version made the focus additive — every cell keeps its full scatter and the ones
near the point get more. It changed nothing: 0.0365, orientation still 0.104.
So the diagnosis was wrong and I do not have the real one. The remaining suspect
is the height modulation, which lifts blocks toward the focus and may be
flattening the skyline's variety between songs by dominating it — but that is a
guess, and two guesses have already been wrong here.
The feature is committed rather than reverted because the metric does not
measure what was asked for. A scatter point is a compositional idea, and the
gallery scores how much a scene changes BETWEEN songs, which is a different
question — Apollonian Gasket makes the same point from the other direction. This
may well look better and score worse. It needs eyes on it before the number
decides.
`gather` is a parameter and seed-driven like any other, so the amount varies per
song as asked; the focal points themselves come from the identity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Edge-to-edge textures have no composition to vary. Two songs of Voronoi Shatter
measured 0.004 apart in the layout block, and that was honest rather than a
metric failure — the cells changed and the arrangement did not, because there
was no arrangement.
So the identity now decides a FOCUS: one to three points, a reach, and a pull
that either draws the field in and densifies it or opens a void. The points come
off the same lattice everything else is placed on, so a scene using them is
composing in the song's terms rather than inventing a centre of its own.
focusField and focusWarp are in the contract, available to any field scene that
wants somewhere to be about.
Voronoi Shatter is the pilot. It tiles in the warped coordinate, so cells crowd
toward the focus or pull away from it, and its seams tighten where the field
gathers so the effect reads as a change in the shatter rather than a brightness
blob laid over one. 0.0463 to 0.0588, comfortably clear of the bar.
It did NOT work the way the hypothesis said. The gain is in orientation, 0.140
to 0.194, and in texture; the layout block moved from 0.004 to 0.007, which is
still nothing. Warping where the cells sit changes what they look like without
changing where the energy is, because the field still covers the frame corner to
corner. Layout stays blind to this family until a field is allowed to actually
fall away from its focus and stop being full-frame — which is a bigger change to
what these scenes are, and worth deciding rather than sliding into.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Investigating three pieces of feedback turned up a measurement bug underneath
all of them. The gallery built four of the descriptor's five structural blocks
and never rendered a second frame, so `motion` came back 0.000 for every scene
in the library and dragged every score down by a fifth. It read as a property of
the scenes; it was a missing render. Every number in the gallery so far,
including the ones the 0.04 bar was calibrated against, was depressed by it.
Plasma Bloom 0.0532 -> 0.0624
Voronoi Shatter 0.0431 -> 0.0463
Apollonian Gasket 0.0340 -> 0.0388
The Plasma Bloom versus Voronoi Shatter oddity is real and is the metric's
limit rather than a mistake. Plasma Bloom is a centred subject whose position
moves between songs, so its layout distance is 0.096; Voronoi Shatter is
edge-to-edge cells, and a full-frame texture has the same layout however its
cells fall, so its layout distance is 0.004. The descriptor cannot see "the
cells are different" as composition, and for full-frame work the layout block
contributes almost nothing. Worth knowing before the bar is used to judge that
family.
Apollonian Gasket scoring low with good-looking frames is the gallery working:
it measures how much a scene changes between songs, not how good it looks. A
scene can be beautiful six times and identical six times.
Isometric Blocks gets the displacement it was asked for. Every block sat exactly
on its lattice slot, so the plan of the field was the same plan in every song
and only the heights moved — a layout distance of 0.005. `scatter` steps each
cell off its slot by a fixed amount of its own, bounded under half a cell so
blocks do not cross and break the isometric read, and it drives the block's
height and side wall as well as its ground position. 0.034 to 0.049, clearing
the bar, with orientation variety nearly doubling as the rigid lattice softens.
Its layout distance stayed at 0.004, for the same reason Voronoi's did: the
field fills the frame either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A visualizer that looks the same wherever it appears does not just fail to vary
— it leaks between songs, because it is cast into many of them and the viewer
who watches two videos ends up recognising the shot rather than the song. That
makes the floor a property of the scene, so the gallery now states it and the
scene has to clear it.
0.04 to start with, and honestly a starting bar rather than a derived one: set
where the flat cluster measurably sits, to be raised as scenes are lifted to it.
Drawn twice, because the two readings are different questions. Every meter
carries a red marker at the bar, so a single row can be judged on its own. And
in the default sort the list is cut by one red line where the scores cross it,
which answers the browsing question — everything above that line repeats itself
between songs.
Also restored progressive drawing, which the caching rewrite had quietly lost:
every draw had moved to the end of the build, leaving three minutes of spinner
and nothing to look at. The first rows are the interesting ones, since the sort
puts the repetitive scenes on top.
Verified by seeding the cache with rows spanning the bar and reloading: four
below, four above, the line between them, the marker at 20% of each meter, and
the grade colours switching at the right place.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forty-nine scenes carried their own `glow`, every one of the sixty-five added
its own grain, and a handful had bloom, chroma, haze, smear or trail besides —
all of which the post chain already does, with an envelope and a threshold no
scene can see. Two uncoordinated effect layers is the smaller half of the
problem. The larger half is that an effect knob sampled per song makes a scene
look varied across parameter draws while its structure never moves, so the
gallery and the variety harness were both being told a scene was original when
only its halo had changed.
Removed in three shapes. Scene grain goes entirely — the post chain owns it.
Statements that ADD a falloff term scaled by the effect are bloom by another
name and are deleted. What remains is an intensity on something already drawn,
so the uniform is pinned to its default and the knob deleted: the picture
survives, the fake variety does not. Reactive entries driving those params went
too, since an effect modulated by the audio was the most convincing fake of the
lot.
That broke the style trait, which is the interesting part. Surface treatment had
been leaving the scenes for two commits — sigGrain to post, sigEdge to
inkStroke — and removing the last of it left `style` with almost nothing to
express: a style+shape signature had two eligible scenes in the whole library.
The trait is not obsolete, it has moved, so inkStroke and inkMask now read
u_sigLine and u_sigSoft alongside the ink's own weight and edge. A scene drawing
in the song's hand honours the track's line weight by construction, and the
runtime probe confirms it rather than taking it on trust. This reverses a call
made two commits ago for a reason that only became true now.
One check needed re-aiming rather than fixing. Two independently built shows are
two WebGL contexts, and engine/hash.js says at the top that bit-exactness is a
same-context guarantee; the check had been demanding it anyway and getting away
with it because the scenes it happened to cast were bit-stable. A casting change
put smooth-gradient scenes in frame and the last bit moved. Measured before
touching the check: three levels out of 255 across 2.6% of pixels, invisible.
The cross-context comparison is now a distance with a ceiling of four, and
bit-exactness within one show is still demanded by the check below it.
89/89 checks, 11/11 tests, all static gates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three minutes of GPU work is fine once and intolerable on every page load, so
the gallery now builds once and comes back from IndexedDB after that.
The key is the part worth getting right. A cache you clear by hand is worse than
no cache: it will eventually show last week's pixels while you are judging this
morning's change, and it will look like a render, so you will believe it. Here
the key is a fingerprint of every file under src/, collected through
import.meta.glob so it covers the shaders, the identity, the look generator, the
engine and the descriptors without naming any of them — the file that
invalidates a render is exactly the one nobody remembers to list. Edit anything
and the entry stops matching and the page rebuilds without being asked.
Thumbnails are WebP blobs rather than raw pixels: sixty-five scenes at six
frames of 256x144 is 57MB raw, and measured, the compressed form is 4.1MB for
the whole library. Saving a build evicts every other one, so the store cannot
grow without bound.
The spinner is a CSS animation on purpose. Building blocks the main thread in
bursts and a JS-driven spinner would freeze mid-turn, which reads as a hang
exactly when the page most needs to look alive.
Verified rather than assumed, six behaviours: the fingerprint is stable across
calls; a build round-trips through storage and decodes to a real image (51%
non-black); a different fingerprint misses; saving a new key evicts the old; and
appending one comment to one scene file moved the fingerprint from 125-f842b05e
to 125-c056d7b9, which is the invalidation working end to end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sixty-five visualizers, six renders each, one per song — each carrying that
song's whole identity: its cast, ink, lattice, palette and a fresh parameter
draw. Side by side, a scene that cannot be changed by the song is obvious in a
way no aggregate could show, which is the complaint this answers.
It scores as well as shows. Each row carries the mean structural distance
between its own six frames, on the same descriptor the variety harness uses and
with colour excluded, so six palettes cannot disguise one image. Sorted
least-varied first, because browsing sixty-five scenes hunting for the
repetitive ones is precisely what a sort order should do for you.
The result names names. Thirteen scenes barely change across six songs, and they
fall into two groups that were already known separately. The ink-only
migrations — Analog Wow, Halftone Misprint, Pitch Shatter, Block Mosh, Scan
Tear — are the shallow tier flagged in MIGRATION.md, where the whole migration
was one posterisation. And Moiré Grid, Isometric Blocks, Quasicrystal, Truchet
Fold, Voronoi Shatter and Apollonian Gasket are the structural twin cliques the
library sweep found weeks of measurement ago, arriving here by a completely
different route: the sweep compared scenes to each other, the gallery compares a
scene to itself, and they agree on the same offenders.
debug.html collects the tools, since there are now enough of them that knowing
which to open is its own problem. It also carries the two things a newcomer
would otherwise learn the hard way: which measurements are safe to steer by
(direct render comparisons) and which are not (aggregate ratios), and what to do
when a page renders perfectly and does nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A file called `clicktrack.js` matches the click-tracking telemetry patterns that
EasyPrivacy and similar lists block by substring. In a browser with a content
blocker the request never completes, and because it is a module import that
takes the entire graph with it: main.js never runs, no handler is ever bound,
and every control in the UI sits there looking correct and doing nothing.
The failure is unusually expensive to diagnose because everything else looks
healthy. The dev server returns 200 with the right MIME type, curl fetches it
fine, node imports it fine, and the app loads perfectly in any browser without a
blocker — which is how it passed every check here. Only the console names it,
and only as one line about a module that failed to load.
Renamed to metronome.js, which is also the better name for what it does. The
button id went with it, since cosmetic filter rules can hit ids too.
The general rule, recorded at the top of the file: anything shipped to a browser
and named like tracking will be treated as tracking. Avoid click, track,
analytics, pixel, beacon and ad in filenames and URL paths, however honest the
code behind them is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 kept its own list of contract uniforms and never learned about the
identity artifacts, so Prism Bloom reading u_castSides looked like an undeclared
uniform. It now takes the list from the contract itself.
Phase 9 demanded that every cast scene honour the track's signature, which was
true when the signature was a hard filter and stopped being true when it became
a weight. The check was asserting the old contract against the new generator.
Rather than delete it, it now checks the claim that actually matters. A track may
reach outside its signature — that change was made deliberately, because the
filter was disqualifying a third of the library and funnelling eleven scenes into
half of all videos — but it must still LEAN on it. Honouring scenes have to
carry the majority of the cast against a chance baseline near 25%, and section
anchors, which open a section and return most often, have to honour it almost
always.
89/89 checks pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three runs of the post-migration song variety measurement give separation 0.314,
0.555 and 0.065 — mean 0.311, half-range 0.245. The 0.38 quoted from a single
run was meaningless.
The cause is structural rather than a sample-size accident. Separation is
(observed - floor) / (ceiling - floor), and here the numerator is 0.0089 while
floor and observed each carry an error bar of 0.015 to 0.019. Dividing one small
difference by another amplifies the noise in both. The metric cannot resolve the
thing it was built to report.
What survives: the ceiling now sits above the floor on every run, with zero
variance, where before the epic it landed underneath and the ratio was not
computable at all. That is a change in kind and it is solid. The magnitude is
not.
The decomposition is the measurement to steer by — noise floor of exactly zero,
effects an order of magnitude above any drift — because it compares renders
directly instead of dividing differences of aggregates. Documented as such.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Song separation went from not computable — the reference sat below the floor —
to 0.38, with the floor falling from 0.1449 to 0.1185 and the gap widening.
Every structural block moved: scale 40 to 59%, orient 65 to 89%, layout 60 to
111%, texture 89 to 81%, motion 53 to 65% of the reference.
Container variety rose throughout, 0.0557 to 0.0815 to 0.0901, which is the stop
condition holding: the library did not homogenise as it converged on shared
content.
Coupling did not move and is recorded as such. Whether a song looks different in
proportion to how it sounds different is still unsolved, and the migration was
never aimed at it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Even spread across families is not useful spread — casting disqualifies any
scene that misses a signature trait, so space and shape are worth more than
another entry in a full family. Each scene is specced against the neighbour it
must not resemble, because that is the gate with the numeric floor.
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>
_audioprobe, _captureprobe, _decodetest, _encprobe, _exporttest, _muxprobe
and _playtest were one-off harnesses for debugging the decode/encode/mux
path. They were untracked working-tree files until `git add -A` in 17a583a
swept them into that commit — my mistake, not a deliberate decision.
Removed, and _*.html added to .gitignore so a broad `git add` cannot pick up
the next batch either.
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>
All three were debts taken on knowingly, with the reason recorded at the
time. Writing them down so that picking one up does not mean reconstructing
why it exists — each gets the measurement that justifies it, a concrete
approach naming the actual files and lines, the existing gate that says when
it is done, and the wrong fix it must not reach for.
1. Four scenes (Classic Wave, Silk Ribbon, Kaleido Tunnel, Slow Orb) declare
the style trait and honour it with `col += sigGrain(uv)` and nothing else,
so they cannot decline the track's grain the way other scenes now can.
Each already has an edge-weight or softness knob to route u_sigLine and
u_sigSoft into. Parked at texture: 0.35, which keeps grain on the
library's cleanest scenes purely to keep a gate green.
2. Curl Flow, Signal Decay and Circuit Bloom have no parameter that changes
their structure — 1.31x, 1.21x and 1.10x against a 1.25x bar. They are
statistically identical everywhere and at all times, which is what "it
looks the same for five minutes" means technically. Needs structure, not
motion; they already move too much.
3. Horizon Lines reports a 2/255 determinism delta when phase 7 runs alone
and passes in a full run. Invisible as an image, but a gate whose verdict
depends on preceding GPU load will eventually stay green through a real
regression on the one property this whole project is built on. Prime
suspects are named: 40 accumulated line terms, and a smoothstep edge as
thin as 0.002 evaluated on a cancelling subtraction.
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>
Written after watching the output rather than after building it. Records what
was measured — the cut metronome, the frozen palette, the 39% of the library
that either churns without developing or barely moves, the constant
kind-to-family table, five or six distinct images per track — and turns it
into five workstreams with gates and a sequence.
Also records what NOT to touch: the production-design layer measures as
strong as parameter variation, so the unifying mechanism is working and is
explicitly out of scope.
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>