Epic 2.3: let the colour move across a track

The palette was generated once and pushed to every layer of every section for
the whole runtime. Five minutes, one scheme, no movement — and colour is the
strongest perceptual variable the system has, so freezing it wasted the
biggest lever available for making a long video feel like it is going
somewhere.

Deliberately not "a new palette per section". A track has one identity and
the palette is most of it; replacing it mid-video reads as a different video.
What moves is the palette ITSELF — rotated, warmed, opened up — so at four
minutes the image is somewhere the first minute implied. Four modes: static
(one in six or so, a held colour is a legitimate choice), drift (slow hue
travel across the whole track), sections (each kind gets its own offset, so
the colour tells you where you are), and lift (saturation and lightness
rising into a drop).

Movement happens in OKLCH, which needed the inverse of the existing
conversion: rotating hue in RGB changes brightness as a side effect, and that
artefact is the reason this project picked OKLCH in the first place. The
rotation is applied to every colour equally, so the scheme and the spread
that made the palette a palette survive the move.

Bounded on purpose at ±34° hue, ±35% saturation, ±0.07 lightness. A full
rotation would destroy the identity as surely as a new palette; the movement
has to be the kind you notice on a rewatch, not the kind you notice as an
effect.

Four new Phase 11 checks, two of which are the counter-checks that keep this
honest. Colour must actually move (weakest 0.232 channel distance across 18
moving tracks) AND must stay inside its identity. The contrast floor is
asserted at 1620 sampled points across the movement rather than at the two
ends, because a saturation lift can flatten a perfectly good palette
somewhere in the middle. The moved palette is memoised on a rounded shift,
and a fourth check proves a seeked frame gets bit-identical colours to a
played one rather than merely similar — that would have been an export-only
determinism fault.

47/48 on phases 7-11 slow; the one failure is the known load-dependent
Horizon Lines flake already recorded in d14d473. All determinism and
preview/export-agreement checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-06 07:45:12 +02:00
co-authored by Claude Opus 5
parent d14d473974
commit acc33cedd7
5 changed files with 346 additions and 2 deletions
+139
View File
@@ -16,6 +16,9 @@ import { synthesizeSectioned } from '../audio/synth.js';
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js';
import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js';
import { scenes, scenesInFamily } from '../scenes/registry.js';
import { paletteShiftAt, MAX_HUE_ROTATION } from '../look/paletteArc.js';
import { shiftPalette, paletteContrast } from '../look/palette.js';
import { ArcDriver } from '../look/ArcDriver.js';
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
@@ -296,3 +299,139 @@ check(11, 'a track shows more of the library than it used to', () => {
return expect(cast.size >= pool * 0.4,
`${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`);
});
// --- colour movement -------------------------------------------------------
// EPIC-2.md §3.3. The palette was generated once and pushed to every layer of
// every section for the whole runtime — five minutes, one scheme, no movement,
// with colour being the strongest perceptual variable available.
check(11, 'the palette moves across a track', () => {
// The complaint as a number. Sample the arc from first frame to last and
// require the colours to actually end up somewhere else.
const moved = [];
const held = [];
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 8; s++) {
const look = generateLook(t, { seed: 7700 + s * 6841 });
const arc = look.paletteArc;
let worst = 0;
for (const kind of ['intro', 'drop', 'breakdown', 'outro']) {
for (const p of [0, 0.5, 1]) {
const shift = paletteShiftAt(arc, {
progress: p, sectionKind: kind,
features: { sectionEnergy: p, buildSlope: 0 },
});
const shifted = shiftPalette(look.palette, shift);
for (let i = 0; i < look.palette.length; i++) {
const a = look.palette[i], b = shifted[i];
worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]));
}
}
}
(arc.mode === 'static' ? held : moved).push(worst);
}
}
const weak = moved.filter((d) => d < 0.03).length;
const ratio = moved.length / (moved.length + held.length);
return expect(weak === 0 && ratio > 0.6,
`${moved.length} moving / ${held.length} held · weakest move ` +
`${(moved.length ? Math.min(...moved) : 0).toFixed(3)} channel distance (floor 0.03)`);
});
check(11, 'colour movement never breaks the contrast floor', () => {
// The Phase 3 palette gate proves the palette is usable when it is
// generated. That says nothing about where the arc takes it — a saturation
// lift or a lightness shift can flatten a perfectly good set. So the floor
// is asserted at every sampled point of the movement rather than only at
// the two ends, which is where this would otherwise be silently violated.
const problems = [];
let sampled = 0;
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 8800 + s * 15485863 });
const base = paletteContrast(look.palette).luminanceSpread;
for (const kind of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
for (let p = 0; p <= 1.001; p += 0.25) {
for (const energy of [0, 0.5, 1]) {
sampled++;
const shift = paletteShiftAt(look.paletteArc, {
progress: p, sectionKind: kind,
features: { sectionEnergy: energy, buildSlope: energy },
});
const spread = paletteContrast(
shiftPalette(look.palette, shift)).luminanceSpread;
// Held against the palette's OWN spread, not an absolute:
// the arc must not degrade what generation achieved.
if (spread < Math.min(0.18, base * 0.75)) {
problems.push(`seed ${s} ${kind}@${p.toFixed(2)}/e${energy}: ` +
`spread ${spread.toFixed(3)} from base ${base.toFixed(3)}`);
}
}
}
}
}
}
return expect(problems.length === 0,
problems.length ? `${problems.length} of ${sampled} points muddy — ${problems[0]}`
: `${sampled} points across the movement all clear the contrast floor`);
});
check(11, 'colour movement stays inside its identity', () => {
// The counter-check. A palette that moves far enough is a different
// palette, and then the track has no colour identity at all — which would
// pass the movement gate above with room to spare.
const problems = [];
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 8; s++) {
const look = generateLook(t, { seed: 9900 + s * 7919 });
for (const kind of ['intro', 'drop', 'breakdown']) {
for (const p of [0, 0.5, 1]) {
const shift = paletteShiftAt(look.paletteArc, {
progress: p, sectionKind: kind,
features: { sectionEnergy: p, buildSlope: p },
});
if (Math.abs(shift.hue) > MAX_HUE_ROTATION + 1e-6) {
problems.push(`seed ${s}: hue ${shift.hue.toFixed(2)} past the ceiling`);
}
}
}
}
}
return expect(problems.length === 0,
problems.length ? problems[0]
: `hue travel stays within ±${(MAX_HUE_ROTATION * 57.3).toFixed(0)}° of the track's palette`);
});
check(11, 'a moved palette is the same on a seek as on playback', () => {
// The movement is memoised on a rounded shift. Rounding is what keeps a
// seeked frame bit-identical to a played one rather than merely close, and
// getting that wrong would be a determinism fault that only shows up in an
// export.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 4321 });
const a = new ArcDriver(look, t);
const b = new ArcDriver(look, t);
const probes = [0, 900, 4500, 9000, 12000];
let worst = 0;
try {
// One driver plays through; the other jumps straight to each probe.
for (let f = 0; f <= 12000; f += 30) a.update(f, t.at(f));
for (const f of probes) {
const seq = a._paletteAt(f, t.at(f));
const jump = b._paletteAt(f, t.at(f));
for (let i = 0; i < seq.length; i++) {
for (let c = 0; c < 3; c++) worst = Math.max(worst, Math.abs(seq[i][c] - jump[i][c]));
}
}
} finally {
a.dispose(); b.dispose();
}
return expect(worst === 0,
`worst channel difference between seeked and played colours: ${worst}`);
});