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
co-authored by Claude Opus 5
parent acc33cedd7
commit 189328587d
14 changed files with 381 additions and 11 deletions
+99 -1
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;