Epic 2.4: a slow axis, and an honest account of what it fixes

Eleven scenes changed as much in half a second as in two minutes. The cause
was that everything moving in them was cyclic: ArcDriver's drift is a 20-70
second LFO, and an LFO returns, so ten cycles of it over five minutes is not
five minutes of anything. The eye adapts in about two seconds and then there
is nothing left to find — violently animated, and reads as static.

ArcDriver._slowAxisFor adds the missing timescale: a param that travels ONE
WAY across the whole track, keyed on the module so a scene returning in the
last section arrives further along rather than resetting. Rate params are
excluded for the reason schema.js already gives.

Two things were learned by getting them wrong first, and both are recorded
where the next person will hit them.

The first version chose the param at random from everything eligible and
measured as doing NOTHING — identical structural change with the axis applied
and with it disabled. Which param you move decides everything: sweeping Moiré
Grid's `width` moves its time-averaged structure by 0.110 and its `offset` by
0.002, and a random draw finds the second kind almost every time. So the axis
is now DECLARED, `slowAxis: true`, validated by the schema (and rejected on
rate params, where walking one would jump the animation phase).

The second is that single-frame distance cannot measure this at all. A
churning scene's consecutive frames are already ~0.6 apart, so every pair of
its frames scores the same whether the structure moved or not — the metric is
saturated by the churn it exists to see through, and the first gate passed
while the mechanism was provably inert. The gate now compares TEN-SECOND
time-averaged frames. The window was measured rather than guessed: at one
second Moiré Grid's frozen control still reads 0.024, at ten it reads 0.0095
while the signal holds at 0.037.

Per EPIC-2.md §4, the metric is verified by breaking what it should catch:
a second check runs the identical measurement with the axis disabled and
requires it to read ~1.0. It reads exactly 1.00x on all three scenes.

The honest scope, measured across ten candidates and written into EPIC-2.md
§3.4: the axis works on Moiré Grid (3.93x), Gate Corridor (2.88x) and Truchet
Fold (1.40x). Curl Flow, Signal Decay and Circuit Bloom have NO parameter that
changes their structure and still need shader work — parameter automation
cannot substitute for structure a scene does not have. Four more already
develop on their own and were never the problem. The flag is set only where it
is proven, so it means something.

69/70 on phases 3,4,7-11 slow; the one failure is the known load-dependent
Horizon Lines flake recorded in d14d473.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-06 07:54:35 +02:00
parent acc33cedd7
commit 189328587d
14 changed files with 381 additions and 11 deletions

View File

@ -126,6 +126,38 @@ but it is the only thing that fixes the specific failure of "animated and yet st
The five nearly-static scenes get the opposite treatment, and Phase 7's movement gate already
describes what "enough" means.
**Status after the first pass: mechanism delivered, most of the scene work still open.**
The mechanism exists and is proven. `ArcDriver._slowAxisFor` walks a declared param across the
whole track, monotonically — the existing drift is a 20-70 second LFO, and an LFO returns,
which is precisely why ten cycles of it over five minutes reads as static.
Two things were learned the hard way and are worth not relearning:
*Which param you move decides everything.* The first version chose at random from everything
eligible and measured as doing **nothing whatsoever** — identical structural change with the
axis applied and with it disabled. Sweeping Moiré Grid's `width` moves its time-averaged
structure by 0.110 and its `offset` by 0.002; a random draw finds the second kind almost every
time. Hence `slowAxis: true` as a declaration rather than a heuristic.
*Single-frame distance cannot measure this.* 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 gate measures **ten-second time-averaged** frames, which cancels the churn and leaves the
structure. The window was measured: at one second, Moiré Grid's frozen-parameter control still
reads 0.024; at ten it reads 0.0095 while the axis-driven signal stays at 0.037.
Measured across ten candidate scenes (ratio of axis-driven structural change to what the scene
does on its own):
| works | Moiré Grid 3.93× · Gate Corridor 2.88× · Truchet Fold 1.40× |
|---|---|
| **no parameter helps** | Curl Flow 1.31× · Signal Decay 1.21× · Circuit Bloom 1.10× |
| **already develops; never the problem** | Firefly Drift · Vortex Drift · Kaleido Tunnel · Plasma Bloom |
The middle row is the remaining work, and it is **not** mechanical: those scenes have no
parameter that changes their structure, so they need shader changes that introduce one.
Parameter automation cannot substitute for structure a scene does not have.
### 3.5 A framing layer
The one that raises the ceiling rather than the floor. A shared zoom / crop / scale envelope

View File

@ -19,6 +19,11 @@ 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';
import { Engine } from '../engine/Engine.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { sampleValues, clampValue } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
@ -435,3 +440,217 @@ check(11, 'a moved palette is the same on a seek as on playback', () => {
return expect(worst === 0,
`worst channel difference between seeked and played colours: ${worst}`);
});
// --- the slow axis ---------------------------------------------------------
// EPIC-2.md §3.4. Eleven scenes changed as much in half a second as in two
// minutes: everything moving in them was cyclic, so the eye adapted in about
// two seconds. Drift is a 20-70s LFO and an LFO returns; the slow axis is
// monotonic across the whole track. See ArcDriver._slowAxisFor.
check(11, 'every scene gets a slow axis with real travel', () => {
// Structural half of the gate, so a scene added later cannot quietly end up
// with nothing to evolve.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 20250 });
const arc = new ArcDriver(look, t);
const problems = [];
try {
for (const module of scenes) {
const axis = arc._slowAxisFor(module);
const eligible = Object.entries(module.params || {}).filter(([, d]) =>
d.type !== 'palette' && d.type !== 'bool' && !d.fixed && !d.rate && !d.noDrift && d.range);
if (!eligible.length) continue; // nothing it could legally move
if (!axis.length) { problems.push(`${module.name}: no axis`); continue; }
for (const item of axis) {
const [lo, hi] = item.def.range;
const fraction = Math.abs(item.travel) / (hi - lo);
if (fraction < 0.2) problems.push(`${module.name}.${item.name}: travels only ${(fraction * 100).toFixed(0)}%`);
if (item.def.rate) problems.push(`${module.name}.${item.name}: rate param on the axis`);
}
}
} finally {
arc.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${scenes.length} scenes all carry a slow axis travelling 20%+ of range`);
});
check(11, 'the slow axis is a journey rather than a cycle', () => {
// The counter-check. An axis that returned to where it started would satisfy
// "params move" and would leave the churn exactly as it was — which is what
// the existing drift LFO already did.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 31337 });
const arc = new ArcDriver(look, t);
const problems = [];
try {
const cue = arc.cues[0];
const at = (time) => arc._paramsAt(cue, 0, time, t.at(Math.round(time * 60)));
const spec = arc._specFor(cue.sectionIndex, cue.variant, 0);
const axis = arc._slowAxisFor(spec.module);
const start = at(t.duration * 0.05);
const mid = at(t.duration * 0.5);
const end = at(t.duration * 0.95);
for (const item of axis) {
const [lo, hi] = item.def.range;
const span = hi - lo;
const a = start[item.name], m = mid[item.name], z = end[item.name];
// Monotonic in the sense that matters: the end is further from the
// start than the middle is, in the direction of travel.
const total = Math.abs(z - a) / span;
if (total < 0.12) {
problems.push(`${spec.module.name}.${item.name}: start ${a.toFixed(3)} ` +
`mid ${m.toFixed(3)} end ${z.toFixed(3)} — only ${(total * 100).toFixed(0)}% travelled`);
}
}
if (!axis.length) problems.push('no axis on the opening scene');
} finally {
arc.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : 'the opening scene ends the track somewhere else');
});
/**
* Structure, with the churn averaged out.
*
* Single-frame distance cannot answer "did this develop?" for exactly the
* scenes that fail it: 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. Averaging ten seconds of frames cancels the churn and leaves
* the structure and is much closer to what a viewer perceives over seconds
* than any single frame is.
*
* Ten seconds was measured, not guessed. At a one-second window Moiré Grid's
* frozen-parameter control still read 0.024; at ten it reads 0.0095, while the
* axis-driven change stays at 0.037. The window has to be wide enough that the
* control collapses and the signal does not.
*/
function averagedFrame(engine, look, module, params, frame0, n = 90, step = 7) {
engine.setLayerSpecs([{ module, params, seed: 9, opacity: 1, blend: 'normal',
palette: look.palette, personality: look.personality }]);
engine.prime(frame0);
engine.compositor.reset();
let acc = null;
for (let k = 0; k < n; k++) {
const px = engine.readPixels(engine.renderFrame(frame0 + k * step));
if (!acc) acc = new Float64Array(px.length);
for (let i = 0; i < px.length; i++) acc[i] += px[i];
}
for (let i = 0; i < acc.length; i++) acc[i] /= n;
return acc;
}
function meanAbs(a, b) {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
return sum / a.length / 255;
}
/** How much the declared axis moves a scene, against how much it moves anyway. */
function axisRatio(engine, arc, look, track, module, { withAxis }) {
const paramsAt = (seconds) => {
const out = sampleValues(module, new Rng(77), look.sections[0].bias,
look.personality.temperament);
if (!withAxis) return out;
const p = Math.min(1, seconds / track.duration);
const journey = p * p * (3 - 2 * p);
for (const item of arc._slowAxisFor(module)) {
if (typeof out[item.name] !== 'number') continue;
out[item.name] = clampValue(item.def, out[item.name] + item.travel * (journey - 0.5));
}
return out;
};
// The control is the same scene with its parameters held: whatever it does
// on its own between these two points in the track.
const frozenA = averagedFrame(engine, look, module,
sampleValues(module, new Rng(77), look.sections[0].bias, look.personality.temperament), 1800);
const frozenB = averagedFrame(engine, look, module,
sampleValues(module, new Rng(77), look.sections[0].bias, look.personality.temperament), 9000);
const movedA = averagedFrame(engine, look, module, paramsAt(30), 1800);
const movedB = averagedFrame(engine, look, module, paramsAt(150), 9000);
const own = meanAbs(frozenA, frozenB);
const moved = meanAbs(movedA, movedB);
return { own, moved, ratio: moved / Math.max(1e-6, own) };
}
check(11, 'a declared slow axis actually changes the scene', () => {
// EPIC-2.md §3.4, and the honest scope of it. A scene that declares an axis
// is claiming that walking that param is what it looks like changing, so
// the claim is measured: the structural change with the axis has to beat
// what the scene does on its own by a real margin.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 5150 });
const engine = new Engine({ width: 128, height: 72 });
engine.timeline.setDuration(t.duration);
engine.setFeatureProvider(featureProviderFor(t));
const arc = new ArcDriver(look, t);
const problems = [];
const detail = [];
try {
const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
for (const module of declared) {
const r = axisRatio(engine, arc, look, t, module, { withAxis: true });
detail.push(`${module.name} ${r.ratio.toFixed(2)}x`);
if (r.ratio < 1.25) {
problems.push(`${module.name}: axis moved ${r.moved.toFixed(4)} against ` +
`${r.own.toFixed(4)} on its own — only ${r.ratio.toFixed(2)}x`);
}
}
if (!declared.length) problems.push('no scene declares a slow axis');
} finally {
arc.dispose();
engine.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : `${detail.join(', ')}`);
}, { slow: true });
check(11, 'the axis measurement would notice if the axis stopped working', () => {
// EPIC-2.md §4 names this failure mode by name: a gate that measures the
// wrong thing. This one has already happened once here — the first version
// of the check above used single-frame distance, which is saturated on
// churning scenes, and passed while the axis was provably doing nothing.
//
// So the metric is verified by breaking what it is supposed to catch. Run
// the identical measurement with the axis disabled; it must come back at
// about 1.0 and fail the threshold the real check applies.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 5150 });
const engine = new Engine({ width: 128, height: 72 });
engine.timeline.setDuration(t.duration);
engine.setFeatureProvider(featureProviderFor(t));
const arc = new ArcDriver(look, t);
const problems = [];
const detail = [];
try {
const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
for (const module of declared) {
const off = axisRatio(engine, arc, look, t, module, { withAxis: false });
detail.push(`${module.name} ${off.ratio.toFixed(2)}x`);
if (off.ratio >= 1.25) {
problems.push(`${module.name}: reads ${off.ratio.toFixed(2)}x with the axis ` +
`DISABLED — the measurement is not tracking the axis`);
}
}
} finally {
arc.dispose();
engine.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `axis disabled reads ${detail.join(', ')} — the measurement tracks the axis`);
}, { slow: true });

View File

@ -167,14 +167,112 @@ export class ArcDriver {
return plan;
}
/**
* The scene's SLOW AXIS: one or two params that travel one way across the
* whole track.
*
* Drift above is an LFO with a 20-70 second period, and an LFO returns.
* Measured over the library, that is exactly what several scenes' problem
* was: they change as much in half a second as in two minutes, because
* everything moving in them is cyclic, so the eye adapts in about two
* seconds and then there is nothing left to find. Violently animated and
* read as static. Ten cycles of a 30-second wobble is not five minutes of
* anything.
*
* So this is deliberately monotonic. Where drift is the wobble, this is the
* journey: the frame at four minutes has a different STRUCTURE density,
* scale, count from the frame at thirty seconds, and no amount of
* per-frame reactivity substitutes for that.
*
* Keyed on the module rather than on the section, so a scene that comes back
* in the last section arrives further along its own axis rather than
* resetting. Rate params are excluded for the reason schema.js gives: they
* multiply absolute time, so moving one jumps the phase.
*/
_slowAxisFor(module) {
if (!this._slowAxes) this._slowAxes = new Map();
const cached = this._slowAxes.get(module.name);
if (cached) return cached;
// Stable per (track, scene): the same scene evolves the same way
// wherever it appears in this video, and differently in the next one.
let h = (this.look.seed || 1) >>> 0;
for (let i = 0; i < module.name.length; i++) {
h = (Math.imul(h ^ module.name.charCodeAt(i), 0x01000193) >>> 0);
}
const rng = new Rng(h);
const eligible = Object.entries(module.params || {}).filter(([, def]) =>
def.type !== 'palette' && def.type !== 'bool' && !def.fixed
&& !def.rate && !def.noDrift && def.range);
// A param the scene DECLARES as its axis wins outright, and travels
// much further than a guessed one.
//
// The first version of this picked at random from everything eligible
// and measured as doing nothing whatsoever: the time-averaged image at
// thirty seconds and at two and a half minutes differed by the same
// amount with the axis applied as without it. The reason is that which
// param you move decides everything. Sweeping Moiré Grid's `width`
// moves its averaged structure by 0.110 and its `offset` by 0.002, and
// a random draw finds the second kind almost every time.
const declared = eligible.filter(([, def]) => def.slowAxis);
const axis = [];
if (declared.length) {
for (const [name, def] of declared) {
const [lo, hi] = def.range;
axis.push({
name,
def,
declared: true,
// Most of the range. This param was chosen because moving it
// is what the scene looks like changing, so a timid walk
// wastes the one lever that works.
travel: (hi - lo) * rng.range(0.45, 0.7) * (rng.bool() ? 1 : -1),
});
}
} else {
// Nothing declared: fall back to a guess. Worth keeping — it costs
// nothing and occasionally lands on something structural — but it is
// not what makes this mechanism work, and no gate should rely on it.
const count = Math.min(eligible.length, rng.bool(0.45) ? 2 : 1);
const pool = rng.shuffle(eligible.slice());
for (let i = 0; i < count; i++) {
const [name, def] = pool[i];
const [lo, hi] = def.range;
axis.push({
name,
def,
declared: false,
travel: (hi - lo) * rng.range(0.25, 0.5) * (rng.bool() ? 1 : -1),
});
}
}
this._slowAxes.set(module.name, axis);
return axis;
}
/**
* Base params for a section at a given time: the look's sampled values, plus
* drift, plus the lookahead ramp toward whatever comes next.
* the slow axis, plus drift, plus the lookahead ramp toward what comes next.
*/
_paramsAt(cue, slot, time, features) {
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
const out = { ...spec.params };
// --- slow axis ------------------------------------------------------
// Eased rather than linear, so the travel is slowest at the head and
// tail. A video should not open mid-move.
const duration = Math.max(1e-6, this.track.duration);
const p = Math.max(0, Math.min(1, time / duration));
const journey = p * p * (3 - 2 * p);
for (const item of this._slowAxisFor(spec.module)) {
const base = out[item.name];
if (typeof base !== 'number') continue;
out[item.name] = clampValue(item.def, base + item.travel * (journey - 0.5));
}
for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
const base = out[item.name];
if (typeof base !== 'number') continue;

View File

@ -30,6 +30,18 @@ export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];
// `u_time * (u_speed + u_bandLow)` is not.
export const RATE_FLAG = 'rate';
// A param flagged `slowAxis: true` is the one the arc driver walks from one end
// of its range to the other across the WHOLE track — the scene's long journey,
// as opposed to the drift LFO's wobble. See ArcDriver._slowAxisFor.
//
// It has to be declared rather than guessed. Measured across the churning
// scenes, which param you pick decides everything: moving Moiré Grid's `width`
// changes its time-averaged structure by 0.110, and moving its `offset` by
// 0.002. A randomly chosen param is overwhelmingly likely to be the second kind,
// which is why the first version of the slow axis measured as doing nothing at
// all.
export const SLOW_AXIS_FLAG = 'slowAxis';
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
export const REACTIVE_FEATURES = [
'loudness', 'rms',
@ -256,6 +268,15 @@ export function validateModule(module) {
}
}
if (def.bias && typeof def.bias !== 'string') errors.push(`${where}: \`bias\` must be a key name`);
if (def[SLOW_AXIS_FLAG]) {
if (def[RATE_FLAG]) {
errors.push(`${where}: cannot be both \`rate\` and \`slowAxis\` — walking a rate ` +
`param jumps the animation phase (see the RATE_FLAG note above)`);
}
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`);
}
}
}
for (const [name, r] of Object.entries(module.reactive || {})) {

View File

@ -19,7 +19,7 @@ export const gateCorridor = {
traits: ['shape', 'camera', 'space', 'style'],
params: {
gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density' },
gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density' , slowAxis: true },
aperture: { type: 'float', range: [0.15, 0.9], default: 0.45, uniform: 'u_aperture' },
thickness:{ type: 'float', range: [0.02, 0.3], default: 0.09, uniform: 'u_thickness' },
travel: { type: 'float', range: [0.02, 0.7], default: 0.2, uniform: 'u_travel', bias: 'motion', rate: true },

View File

@ -21,7 +21,7 @@ export const moireGrid = {
// Named u_lineWidth, not u_width: the shader contract already declares
// `uniform float u_width` for stereo width, and a colliding name is a
// redefinition error that renders the scene as a black frame.
width: { type: 'float', range: [0.06, 0.5], default: 0.2, uniform: 'u_lineWidth' },
width: { type: 'float', range: [0.06, 0.5], default: 0.2, uniform: 'u_lineWidth' , slowAxis: true },
warp: { type: 'float', range: [0, 1], default: 0.25, uniform: 'u_warp' },
glow: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_glow', bias: 'energy' },
palette: { type: 'palette', count: 4 },

View File

@ -17,7 +17,7 @@ export const truchetFold = {
params: {
cells: { type: 'float', range: [1.5, 12], default: 4, uniform: 'u_cells', bias: 'density' },
weight: { type: 'float', range: [0.04, 0.3], default: 0.12, uniform: 'u_weight' },
weight: { type: 'float', range: [0.04, 0.3], default: 0.12, uniform: 'u_weight' , slowAxis: true },
radius: { type: 'float', range: [0.3, 0.7], default: 0.5, uniform: 'u_radius' },
churn: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_churn' },
glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' },