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
+187
View File
@@ -0,0 +1,187 @@
// The filmstrip: every song in the bank, sampled across its own length.
//
// The gallery answers "does this scene look the same in every song". This
// answers the question one level up and one axis over: does a SONG look the
// same as itself, half an hour of playback later.
//
// Nothing else shows that. The variety harness reduces a video to a number, the
// app shows one video at the speed of the song, and the phase gates never look
// at two moments of the same track side by side. So the failure this exists to
// catch — a video that is doing plenty and going nowhere — has until now been
// something you could only notice by watching four minutes and remembering what
// the first minute looked like.
//
// One frame every thirty seconds, straight across. A strip whose frames could
// be shuffled without anyone noticing is a video with no arc, whatever its
// drift score says. See look/Story.js for the layer meant to fix that, and
// checks/variety/signature.js directionOf for the same question as a number.
//
// Everything renders through the whole normal pipeline — Show, ArcDriver, post,
// the lot — rather than through a bare Engine as the gallery does, because the
// arc IS the subject here.
import { Show, FADE_SECONDS } from '../Show.js';
import { generateLook } from '../look/LookGenerator.js';
import { song, SONGS } from '../audio/songbank.js';
import { hashString } from '../engine/rng.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
import { describeStory } from '../look/Story.js';
export const STRIP = { width: 256, height: 144 };
/** Seconds between frames. Thirty is a section or two at most tempos. */
export const DEFAULT_EVERY = 30;
/**
* How long a song is synthesised for.
*
* Long enough to have an arc to sample, short enough that the whole bank
* finishes in one sitting: every extra thirty seconds is another frame for each
* of seventeen songs, and each frame costs its warm-up. `?duration=240` for the
* fuller version when a specific song is in question.
*/
export const DEFAULT_DURATION = 180;
/**
* Frames of warm-up before each probe.
*
* Feedback and any stateful layer need to be converged or the probe measures
* the trail of a black frame — a structure every probe shares, which would make
* the whole strip look more alike than it is. Half of what the variety harness
* uses, because this is 17 songs deep and the cost is linear.
*/
const WARMUP = 24;
export const songNames = () => SONGS.map((s) => s.name);
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0);
function blockMean(byBlock) {
const values = STRUCTURAL.map((b) => byBlock[b]).filter((v) => v !== undefined);
return mean(values);
}
/**
* Render one song's strip.
*
* @returns {{name, frames: Array, drift: number, direction: number, look}}
*/
export function renderSong(show, name, { duration, every }) {
const { track } = song(name, { duration });
// Seeded off the song's name, so a strip is stable between rebuilds and two
// songs are not accidentally handed the same look.
const look = generateLook(track, { seed: hashString(name) });
show.useTrack(track, look);
const frames = [];
const descriptors = [];
// Pulled clear of the opening and closing fades. A probe at t=0 reads a
// black frame — correct output, useless sample — and the same at the very
// end, so the first and last cells sit just inside the video proper. The
// caption shows the time actually sampled rather than the nominal one.
const inside = (t) => Math.min(
Math.max(t, FADE_SECONDS + 0.5),
Math.max(0, track.duration - FADE_SECONDS - 0.5));
for (let nominal = 0; nominal < track.duration - 0.5; nominal += every) {
const t = inside(nominal);
const frame = Math.min(track.frameCount - 2, Math.round(t * track.fps));
show.warmUp(frame, WARMUP);
const pixels = Uint8Array.from(show.readPixels(show.renderFrame(frame)));
// A second frame five later, so the motion block is measured rather
// than silently scored zero — the same omission the gallery had.
const moved = Uint8Array.from(show.readPixels(show.renderFrame(frame + 5)));
const state = show.arc.state;
const sectionIndex = track.sectionIndexAt(frame);
const section = look.sections[sectionIndex] || {};
frames.push({
time: t,
frame,
pixels,
kind: state.kind || section.kind || '',
scene: state.sceneName || '',
act: state.act || '',
tension: state.tension ?? 0.5,
journey: state.journey ?? 0,
moment: momentAt(look.story, sectionIndex),
});
const still = frameDescriptor(pixels, STRIP.width, STRIP.height);
const motion = motionDescriptor(pixels, moved, STRIP.width, STRIP.height);
descriptors.push({ ...still, motion: motion.scale.concat(motion.layout) });
}
// The two numbers that say what the strip shows, on the same descriptor the
// variety harness uses: how far this video gets from itself, and whether
// that distance grows with time or is just churn.
const gaps = [];
const dists = [];
for (let i = 0; i < descriptors.length; i++) {
for (let j = i + 1; j < descriptors.length; j++) {
gaps.push(frames[j].time - frames[i].time);
dists.push(blockMean(descriptorDistance(descriptors[i], descriptors[j])));
}
}
return {
name,
frames,
drift: mean(dists),
direction: directionOf(gaps, dists),
// How much the FIRST half of the video resembles the last — the reading
// that separates a video which ends somewhere new from one that has
// come back on purpose. A recap should show up here and nowhere else.
endsApart: dists.length ? dists[dists.length - 1] : 0,
story: look.story,
storyLine: describeStory(look.story),
director: look.director,
bpm: Math.round(track.summary.bpm),
sections: look.sections.length,
};
}
function momentAt(story, index) {
if (!story || !story.moments) return '';
const m = story.moments;
if (index === m.climax) return 'climax';
if (index === m.turn) return 'turn';
if (index === m.arrival) return 'arrival';
if (index === m.resolution) return 'resolution';
return '';
}
/**
* Build every strip, reporting each song as it lands.
*
* One Show for the whole run: `useTrack` replaces the track and rebuilds the
* arc, and constructing a Show per song would rebuild the GL context seventeen
* times for nothing.
*/
export async function buildFilmstrip({
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, onSong = null,
} = {}) {
const list = names && names.length ? names : songNames();
const show = new Show({ ...STRIP });
const rows = [];
try {
for (let i = 0; i < list.length; i++) {
let row;
try {
row = renderSong(show, list[i], { duration, every });
} catch (err) {
row = { name: list[i], frames: [], drift: 0, direction: 0, error: err.message };
}
rows.push(row);
if (onSong) onSong(i + 1, list.length, row);
// Yield, so a row paints as it lands instead of the page freezing
// for the whole run and then showing everything at once.
await new Promise((r) => setTimeout(r, 0));
}
} finally {
show.dispose();
}
return rows;
}