Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
260 lines
12 KiB
JavaScript
260 lines
12 KiB
JavaScript
// 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 { pinVenueShell } from '../venues/pin.js';
|
|
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
|
|
import { descriptorDistance, directionOf, STRUCTURAL } from './variety/signature.js';
|
|
import { describeStory } from '../look/Story.js';
|
|
import { frameLuminance } from '../engine/hash.js';
|
|
import { agitationScore, AGITATION_CEILING } from '../engine/agitation.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.
|
|
*/
|
|
/** Frames of warm-up behind every probe. Carried in preview links (?warm=)
|
|
* so the app lands on identically converged state. See src/ui/preview.js. */
|
|
export 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, venueEnabled = null, venueSel = null }) {
|
|
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 venueFlag = venueEnabled !== null ? !!venueEnabled : (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venue') === '1');
|
|
const look = generateLook(track, { seed: hashString(name), venueEnabled: venueFlag });
|
|
// Pin one venue in every row (same shell-swap as the venue gallery): the
|
|
// VenueSpec population stays the census one, only the shell is forced.
|
|
const sel = venueSel || (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venueSel')) || null;
|
|
if (sel) pinVenueShell(look, sel);
|
|
// The file name the app shows in its title plate (OSD): without it the
|
|
// probe renders a blank plate and no deep-link can match the app's
|
|
// default view. See src/ui/preview.js.
|
|
show.fileName = name;
|
|
show.useTrack(track, look);
|
|
// Prime once per song: programs link asynchronously and a draw against an
|
|
// unlinked program is wrong output — invisible in a sweep, wrong in an
|
|
// export, and fatal to preview deep-links, which replay these frames in
|
|
// the app (primed there too). See src/ui/preview.js.
|
|
show.prime();
|
|
|
|
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])));
|
|
}
|
|
}
|
|
|
|
// Agitation: dense 60 fps burst in the busiest section, so the filmstrip is
|
|
// also the agitation audit. The 30 s probes are 30 s apart and cannot see
|
|
// beat-locked whole-screen motion — the metric needs consecutive frames.
|
|
// A 120-frame (2 s) burst at the drop/busiest section captures the worst
|
|
// 1 s floating window at its most energetic, mirroring gallery.js which
|
|
// samples its 60-frame strip at the busiest context. Whole-screen
|
|
// flash/coherent motion reserved for special cases, region motion fine.
|
|
let score = { agitation: 0, peakFlashRate: 0, peakMotion: 0, meanMotion: 0, peakStructure: 0, meanStructure: 0 };
|
|
let agitated = false;
|
|
try {
|
|
const busiest = look.sections.reduce(
|
|
(best, s) => ((s.bias?.energy ?? 0) > (best.bias?.energy ?? 0) ? s : best),
|
|
look.sections[0] || { startFrame: 0, endFrame: Math.min(track.frameCount, 120) },
|
|
);
|
|
const probe = busiest.startFrame + Math.floor((busiest.endFrame - busiest.startFrame) * 0.5);
|
|
const STRIP_AGIT = 120; // 2 s @ 60 fps → 1 s window worst
|
|
show.warmUp(probe, WARMUP);
|
|
const agFrames = [];
|
|
const agLums = [];
|
|
for (let f = probe; f < probe + STRIP_AGIT; f++) {
|
|
const ff = Math.min(track.frameCount - 1, f);
|
|
const px = Uint8Array.from(show.readPixels(show.renderFrame(ff)));
|
|
agFrames.push(px);
|
|
agLums.push(frameLuminance(px));
|
|
}
|
|
score = agitationScore(agLums, agFrames, 60, { width: STRIP.width, height: STRIP.height });
|
|
agitated = score.agitation >= AGITATION_CEILING;
|
|
} catch {
|
|
// leave agitation at 0 — row still renders, just grey
|
|
}
|
|
|
|
return {
|
|
name,
|
|
frames,
|
|
look,
|
|
drift: mean(dists),
|
|
direction: directionOf(gaps, dists),
|
|
// Agitation over the strip: worst 1 s floating window, max(flash/3,
|
|
// motion/0.55) → 0..1. The filmstrip already renders the whole video,
|
|
// so this is the natural audit for "how much of the view moves/flashes
|
|
// together". Gallery's agitation is per-scene; this one is per-song.
|
|
agitation: score.agitation,
|
|
agitationFlash: score.peakFlashRate,
|
|
agitationMotion: score.peakMotion,
|
|
agitationMean: score.meanMotion,
|
|
agitationStructPeak: score.peakStructure,
|
|
agitationStruct: score.meanStructure,
|
|
agitated,
|
|
// 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, venueEnabled = null,
|
|
venueSel = null,
|
|
onSong = null, onSongStart = null,
|
|
} = {}) {
|
|
const list = names && names.length ? names : songNames();
|
|
const show = new Show({ ...STRIP });
|
|
const rows = [];
|
|
try {
|
|
for (let i = 0; i < list.length; i++) {
|
|
// Announced BEFORE the work, not only after it. Synthesising and
|
|
// rendering one song is tens of seconds, and a page that says
|
|
// nothing until the first one lands looks broken for exactly as
|
|
// long as the first one takes.
|
|
if (onSongStart) {
|
|
onSongStart(i + 1, list.length, list[i]);
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
let row;
|
|
try {
|
|
row = renderSong(show, list[i], { duration, every, venueEnabled, venueSel });
|
|
} catch (err) {
|
|
row = { name: list[i], frames: [], shots: [], 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;
|
|
}
|