Commit Graph

13 Commits

Author SHA1 Message Date
Dejvino
ef1d24e692 Director cuts between palettes; the look panel shows which one is live
One palette for a whole song reads as one colour once five minutes
have passed. The track now keeps 2-4 audio-tilted palettes (forced
scheme/hue diversity so switches are legible) and the director owns
how they are traversed per cue — sequential/pong/kindLed/storyLed/
contrast — with cut or OKLCH blend timed to the cue crossfade. The
fine OKLCH paletteArc drift still rides on top of whichever base is
active.

The look tab renders the full set as a list; the row matching
ArcDriver.paletteIndexAt(frame) gets an active border/glow and
follows playback/scrub live, with a blend badge while two palettes
interpolate.

Co-Authored-By: internal-model
2026-08-20 12:45:42 +02:00
Dejvino
89e05459c0 Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

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

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

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

Chasing the numbers turned up four real faults:

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

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

Rendered across twelve videos, middle of every section:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:01 +02:00
Dejvino
1ca9fa2f1f Let scenes layer on each other, and give the camera somewhere to look
Two devices that were built, wired up, and doing nothing.

OVERLAYS. buildStack gated layering on `surfaceOf(m) === 'composable'`, and
no scene in the library declared `surface` — so the only thing that could ever
sit on top was the one scene declaring `role: 'accent'`, and the overlay roster
excluded accents by construction. Empty intersection, every time: 0/144 stacks
carried an overlay. Every layered frame in every song was the same particle
field.

Labelled the library from the phase 12 coverage gate rather than by eye: the
37 scenes painting under 30% of the frame are composable. Particle Field loses
its privileged slot and becomes one of them, keeping only `background: false`,
which is the honest part — points in empty space cannot carry a section alone.
The reserved accent slot is gone; one roster, two passes at it. 1 distinct
overlay scene becomes 27, and the rate lands at 34% of stacks after trimming a
base chance that had been tuned while the branch was dead.

THE CAMERA. framing.shift moved the frame by a median of 0.029 of a half-frame
at a fresh random angle every shot, so successive offsets cancelled and the
median jump at a cut was 0.014. Present in every frame, visible in none.

look/Camera.js is the director's camera department: the story says tension,
order and which act a section is in, and this turns that into where the frame
looks and how it travels there. Each director names a camera. Jump distance
follows tension and act, speed follows energy, and the curve is one of four.
Cuts choose between reframing and matching, so two scenes can still read as one
place. Median offset 0.190, median reframe 0.135, and 94% of shots now move
during the shot rather than only at the cut.

Four new gates, each with a FLOOR — the device this replaces passed every
existing check while doing nothing, because the only bound was a ceiling.

Also lands the in-progress Epic 4 story layer it builds on: Story.js, phase 13,
and the direction statistic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:48:13 +02:00
Dejvino
4217cca3d3 Rename clicktrack.js — a content blocker was eating it and killing the app
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>
2026-08-18 07:01:23 +02:00
Dejvino
b7c6fd1c5a Fix export frame loss: reject codec profiles that reorder frames
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>
2026-08-06 13:10:24 +02:00
Dejvino
14d1204e82 Make grain a treatment, tempo a governor, and params commit harder
Grain was in every video. It was added twice unconditionally — every scene
called sigGrain, and the grade added its own on top — so the only thing that
varied between two tracks was how much of it there was. That makes grain the
renderer's fingerprint rather than a decision about one video.

It is now described rather than dialled (look/grain.js): a mode (off /
constant / swell / sections / transient), a cell size in pixels, a refresh
rate in frames, a mask (uniform, shadows, highlights, edges, bands) and a
chroma amount. Roughly 45% of tracks get none at all. The non-constant modes
carry a per-frame envelope computed in Show._postAt from frame and features
only, so preview and export still agree. Scene-side grain is gated the same
way, and a module can decline it outright with `texture: 0` — crisp line work
should stay crisp. The post tab grew a real grain block so any of it can be
forced per track.

Slow songs got fast scenes. `motion` bias was mostly section energy with
tempo as a small correction, so a 70bpm track's drop asked for nearly as much
speed as a 150bpm one. Motion is now tempo-dominated, and every `rate: true`
param is additionally scaled by a per-track rateScale — measured, 84bpm now
samples its rate params at 0.276 of range against 148bpm's 0.571.

Parameter sampling also commits harder: extremity starts at 0.45 rather than
0.25 and shapes the draw more aggressively. This was first pushed to 0.82 and
backed off to 0.72, because the gates caught the overshoot — seeds began
collapsing onto the same range ends and a sparse scene sampled at its low end
rendered effectively black.

Fallout worth recording: turning the default grade grain off exposed two
scenes that were never really animating. Dust Chamber and Eclipse Field
passed the Phase 7 movement gate only because per-pixel noise was moving
underneath them; both now breathe on their own fixed clock, and Dust Chamber
needed a brightness floor as well. Four scenes (Classic Wave, Silk Ribbon,
Kaleido Tunnel, Slow Orb) express the style trait ONLY through grain and so
cannot opt out yet; they hold a reduced share at 0.35 pending real edge and
softness response.

Phase 3's look-space check now measures its closest pair relative to image
brightness, the same correction Phase 10 already documents for sparse scenes:
the absolute number was being propped up by grain rather than by look-space
width. Five new Phase 10 checks cover grain distribution, treatment variety,
envelope range, the texture opt-out, and tempo. 93/93 pass.

Two transport bugs, both stale state surfacing in the UI:

- Loading a track replaces audio.src, which stops playback silently, so
  state.playing stayed true and the play button stayed on pause — the first
  click after a track change only flipped the flag back. Added stopPlayback().
- The controls row wrapped mid-song because two readouts that change length
  while playing were sized by their content: the clock crossing ten minutes
  and the section label whenever a scene name is long or a crossfade appears.
  The clock is now fixed-width with tabular figures and the section label is
  the row's only flexible item, laying out at zero width and ellipsising into
  whatever space is left. The two spacers competed with it for that space and
  are gone; its own text-align does their job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 07:04:27 +02:00
Dejvino
2e4e1d7613 Add OSD with song name 2026-08-05 21:30:46 +02:00
Dejvino
ca5a68eb84 Phases 8 and 9: shots, and a production design per track
Both phases come out of the manual gate — watching whole tracks — and both
fix something no automated check was looking for.

Phase 8: shots. A section is a STAGE of the song and can run ninety
seconds; one scene held that long reads as a still image with a wobble on
it. Each section kind now gets a roster of three or four stage visuals
instead of one scene, and each section is cut into shots that rotate
between them on phrase lines, never holding past 22s. The roster stays per
kind, so a track's drops still cut between the same images and the video
keeps its identity; the anchor opens each section and the rotation returns
to it, and when a companion is due it is the least recently shown one.

The arc driver stopped working in sections and started working in cues, one
per shot, so a shot cut and a section change take the same code path and
differ only in transition length. The default transition is a slow
dissolve — two bars calm, one loud; a straight cut is reserved for
sections above the energy threshold, because on calm material a cut reads
as a glitch rather than as an edit.

Phase 9: production design. With cuts every fifteen seconds the next
problem was that the images being cut between shared nothing but the
palette. What a music video actually shares across shots is a location, a
cast, a camera operator and an art direction, so each track now generates a
personality in four traits (shape, camera, space, style) off the look seed.
The traits reach shaders as uniforms plus four helpers in the contract, and
each scene expresses them its own way: Classic Wave's rings take the
signature polygon, Metaballs merge as one, Floating Geometry no longer
picks between a box and a circle because the production already decided.

The part that makes it a design rather than a filter: scenes DECLARE which
traits they honour, a track is built on one or two, and a scene that does
not honour all of them is not cast in that track. The library shrinks per
track on purpose.

Two gates keep the declaration honest — lint greps each shader for evidence
of every trait it claims, and a render check measures that each declared
trait actually moves the image (41 scene/trait pairs, weakest response 64
of 255). A layer with no personality renders bit-identically to before,
which is what keeps every earlier sweep and regression valid.

Checks changed rather than added:
  - P4 scene-change and drift checks now measure per shot, not per section;
    the crossfade check reads its length off the cue.
  - P5 flash sweep runs per shot, so the visuals that only appear
    mid-section are measured too.
  - P6 preview/export parity primes first (as both real paths do) and
    compares at the one-LSB tolerance Phase 7 already uses. Measured over
    four consecutive shows: 3 frames at delta 1, then bit-exact — GPU
    variance on first render, not a divergence.
  - P2's contract-uniform list is derived from the contract instead of
    retyped, so the signature uniforms cannot fall out of sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:04:33 +02:00
Dejvino
8a94a3f3a5 Phase 6: preview UI and export
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>
2026-08-05 18:20:36 +02:00
Dejvino
40188493ac Phase 6 WIP refactor: migrate track analysis UI from overlay to sidebar header 2026-08-05 12:39:44 +02:00
Dejvino
d0f93e5c55 Phase 6 WIP working 2026-08-05 12:20:55 +02:00
Dejvino
968101c532 Phase 6 WIP 2026-08-05 11:56:52 +02:00
Dejvino
7e31c19d6e Phase 0: determinism spine
Engine core: Timeline (fixed dt, audio-mastered in realtime), seeded Rng,
Renderer, Layer/ShaderLayer/SceneLayer, Compositor with blend modes,
feedback and post chain.

Shader scenes are compiled against a fixed uniform contract and define only
`vec4 scene(vec2 uv, vec2 p)`, so adding a scene costs a shader plus a
params block. Deep Nebula ported from party-stage as the first one.

Gate passes, 7/7 in checks.html:
- 300 frames rendered twice are bit-identical
- a fresh Engine reproduces the same frames
- simulated dropped frames change nothing (proves dt is fixed)
- seek matches sequential playback
- 320x180 vs 1280x720 agree within 0.010 (limit 0.06)
- seeded rng reproducible, forked streams independent
- compositor reset clears feedback history

Static gates: no wall-clock or unseeded randomness in deterministic
directories; scene schemas and shader sources agree in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:59:30 +02:00