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>
This commit is contained in:
Dejvino
2026-08-19 06:48:13 +02:00
co-authored by Claude Opus 5
parent eda8ac3810
commit 1ca9fa2f1f
69 changed files with 2686 additions and 149 deletions
+134 -10
View File
@@ -21,10 +21,11 @@ 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 { sampleValues, clampValue, canBackground } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';
import { reachFor, CURVE_NAMES } from '../look/Camera.js';
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
@@ -188,11 +189,11 @@ check(11, 'every scene in the library is reachable', () => {
}
}
// Accents are cast by role rather than by family and are always eligible.
const missing = scenes.filter((m) => m.role !== 'accent' && !reachable.has(m.name));
const missing = scenes.filter((m) => canBackground(m) && !reachable.has(m.name));
return expect(missing.length === 0,
missing.length ? `unreachable: ${missing.map((m) => m.name).join(', ')}`
: `all ${reachable.size} non-accent scenes reachable across ${DIRECTORS.length} directors`);
: `all ${reachable.size} castable scenes reachable across ${DIRECTORS.length} directors`);
});
check(11, 'no director starves a section kind', () => {
@@ -204,7 +205,7 @@ check(11, 'no director starves a section kind', () => {
for (const [kind, families] of Object.entries(d.families)) {
const pool = new Set();
for (const f of families) {
for (const m of scenesInFamily(f)) if (m.role !== 'accent') pool.add(m.name);
for (const m of scenesInFamily(f)) if (canBackground(m)) pool.add(m.name);
}
if (pool.size < 12) problems.push(`${d.name}/${kind}: only ${pool.size}`);
}
@@ -301,7 +302,7 @@ check(11, 'a track shows more of the library than it used to', () => {
(section.variants || [section.layers]).forEach((v) => cast.add(v[0].module.name)));
}
});
const pool = scenes.filter((m) => m.role !== 'accent').length;
const pool = scenes.filter(canBackground).length;
return expect(cast.size >= pool * 0.4,
`${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`);
});
@@ -721,8 +722,21 @@ check(11, 'framing stays inside the library\'s headroom', () => {
if (f.scale < 0.55 || f.scale > 2.2) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: scale ${f.scale.toFixed(2)}`);
}
if (Math.abs(f.shift[0]) > 0.3 || Math.abs(f.shift[1]) > 0.3) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: shift ${f.shift.map((v) => v.toFixed(2))}`);
// The gaze is live now — it is not on cue.framing, it is
// wherever the camera has travelled to by a given frame —
// so the bound has to be measured at frames rather than
// read off the plan. Its ceiling is the reach the shot's
// own size allows: see Camera.reachFor, which is why a
// close-up is permitted further off centre than a wide.
const ceiling = look.camera
? reachFor(f.scale, look.camera) + 1e-6 : 0.31;
for (const fr of [cue.startFrame, (cue.startFrame + cue.endFrame) >> 1,
cue.endFrame - 1]) {
const shift = arc._framingAt(cue.index, fr).shift;
if (Math.hypot(shift[0], shift[1]) > ceiling) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}@${fr}: `
+ `|shift| ${Math.hypot(shift[0], shift[1]).toFixed(3)} > ${ceiling.toFixed(3)}`);
}
}
}
} finally {
@@ -751,9 +765,20 @@ check(11, 'framing is identical on a seek and on playback', () => {
for (let i = 0; i < a.cues.length; i++) {
const fa = a.cues[i].framing;
const fb = b.cues[i].framing;
if (fa && fb && (fa.scale !== fb.scale
|| fa.shift[0] !== fb.shift[0] || fa.shift[1] !== fb.shift[1])) {
problems.push(`cue ${i}: ${JSON.stringify(fa)} vs ${JSON.stringify(fb)}`);
if (fa && fb && fa.scale !== fb.scale) {
problems.push(`cue ${i}: scale ${fa.scale} vs ${fb.scale}`);
}
// The gaze is a position in time, so agreeing on the plan is not
// enough — two drivers have to agree on where the camera IS at a
// frame. This is the property a seek depends on.
const cue = a.cues[i];
for (const fr of [cue.startFrame, (cue.startFrame + cue.endFrame) >> 1,
cue.endFrame - 1]) {
const sa = a._framingAt(i, fr).shift;
const sb = b._framingAt(i, fr).shift;
if (sa[0] !== sb[0] || sa[1] !== sb[1]) {
problems.push(`cue ${i}@${fr}: gaze ${sa} vs ${sb}`);
}
}
}
} finally {
@@ -765,6 +790,105 @@ check(11, 'framing is identical on a seek and on playback', () => {
: `${a.cues.length} cues carry the same framing in both drivers`);
});
/**
* Everything the camera exists to do, measured on one sweep of the battery.
*
* Stated as numbers because the device this replaces LOOKED right in the source
* and did nothing on screen: a recentre of a median 0.029 of a half-frame, at a
* fresh random angle every shot so successive offsets cancelled. Nothing in the
* old gates caught that, because the only bound was a ceiling — and a device
* doing nothing passes a ceiling comfortably.
*
* So each of these has a FLOOR. That is the lesson from the old one.
*/
function gazeStats() {
const offsets = []; const jumps = []; const travels = [];
const cameras = new Set(); const curves = new Set();
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 21400 + s * 5077 });
if (!look.camera) continue;
cameras.add(look.camera.name);
const arc = new ArcDriver(look, t);
try {
let prevEnd = null;
arc.cues.forEach((cue, i) => {
curves.add(arc.gaze[i].curve);
const start = arc._framingAt(i, cue.startFrame).shift;
const end = arc._framingAt(i, cue.endFrame - 1).shift;
offsets.push(Math.hypot(end[0], end[1]));
travels.push(Math.hypot(end[0] - start[0], end[1] - start[1]));
if (prevEnd) {
jumps.push(Math.hypot(start[0] - prevEnd[0], start[1] - prevEnd[1]));
}
prevEnd = end;
});
} finally {
arc.dispose();
}
}
}
const median = (a) => {
const v = a.slice().sort((x, y) => x - y);
return v.length ? v[v.length >> 1] : 0;
};
return {
offsets, jumps, travels, cameras, curves,
medOffset: median(offsets), medJump: median(jumps), medTravel: median(travels),
};
}
check(11, 'the camera moves far enough at a cut to be seen', () => {
// The floor is the whole point. The device this replaces had a median jump
// of 0.014 of a half-frame — present in every video, visible in none.
//
// Measured over the REFRAMES rather than over every cut. A match cut is a
// deliberate zero and there are enough of them to drag a plain median down;
// averaging the cuts that chose not to move together with the ones that did
// would let the reframes shrink to nothing without the number noticing,
// which is the exact failure this whole gate exists to catch.
const r = gazeStats();
const reframes = r.jumps.filter((j) => j >= 0.01);
const sorted = reframes.slice().sort((a, b) => a - b);
const med = sorted.length ? sorted[sorted.length >> 1] : 0;
return expect(med > 0.09 && reframes.length > 0,
`median reframe ${med.toFixed(3)} of a half-frame across ${reframes.length} reframes ` +
`· median offset ${r.medOffset.toFixed(3)} ` +
`(the old device measured 0.014 jump / 0.029 offset)`);
});
check(11, 'the camera moves DURING a shot, not only at cuts', () => {
// Framing used to be constant within a shot by design. The recentre is not
// framing — a camera that only ever steps is a slideshow of stills.
const r = gazeStats();
const moving = r.travels.filter((t) => t > 0.02).length;
const share = moving / Math.max(1, r.travels.length);
return expect(r.medTravel > 0.02 && share > 0.5,
`${moving}/${r.travels.length} shots travel (${(share * 100).toFixed(0)}%) · ` +
`median travel ${r.medTravel.toFixed(3)}`);
});
check(11, 'the camera cuts through as well as jumping', () => {
// A camera that relocates at EVERY cut is as much a single rule as one that
// never does. The match cut — where the gaze walks through the change and
// two scenes read as one place — has to survive as a real minority.
const r = gazeStats();
const matched = r.jumps.filter((j) => j < 0.01).length;
const share = matched / Math.max(1, r.jumps.length);
return expect(share > 0.04 && share < 0.6,
`${matched}/${r.jumps.length} cuts are match cuts (${(share * 100).toFixed(0)}%)`);
});
check(11, 'the library uses more than one camera and more than one curve', () => {
// Same argument directors.js makes about families: one camera applied to
// every track is how a library ends up with one look.
const r = gazeStats();
return expect(r.cameras.size >= 3 && r.curves.size >= 3,
`${r.cameras.size} cameras (${[...r.cameras].join(', ')}) · ` +
`${r.curves.size}/${CURVE_NAMES.length} curves used`);
});
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