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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user