music-video-gen/flow-state/filmstrip.html
Dejvino 1ca9fa2f1f 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>
2026-08-19 06:48:13 +02:00

241 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · filmstrip</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 20px 24px 60px;
background: #0b0d12; color: #d6dae3;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}
h1 { font-size: 15px; letter-spacing: .12em; text-transform: uppercase; color: #7d8698; margin: 0 0 4px; }
a { color: #7dd3fc; }
.lede { color: #8b94a7; max-width: 84ch; margin: 0 0 14px; }
.bar { position: sticky; top: 0; z-index: 5; background: #0b0d12; padding: 10px 0 12px; border-bottom: 1px solid #1c2030; margin-bottom: 16px; }
button, select {
background: #171b24; color: #d6dae3; border: 1px solid #2a3040;
padding: 5px 10px; font: inherit; border-radius: 3px; cursor: pointer;
}
button:hover:not(:disabled) { border-color: #4a5468; }
button:disabled { opacity: .5; cursor: default; }
#status { color: #8b94a7; margin-left: 10px; }
#status.cached { color: #4ade80; }
.spinner {
display: none; width: 13px; height: 13px; vertical-align: -2px;
border: 2px solid #2a3040; border-top-color: #7dd3fc; border-radius: 50%;
animation: spin .8s linear infinite; margin-right: 6px;
}
.building .spinner { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
#progress { height: 2px; background: #1a1e28; margin-top: 8px; border-radius: 2px; overflow: hidden; display: none; }
.building #progress { display: block; }
#progress i { display: block; height: 100%; width: 0; background: #7dd3fc; transition: width .2s; }
.row { margin-bottom: 22px; border-left: 3px solid #232838; padding-left: 12px; }
.row.static { border-color: #ef4444; }
.row.churn { border-color: #eab308; }
.row.arc { border-color: #22c55e; }
.row.broken { border-color: #a855f7; background: #150e1c; }
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
.name { font-size: 14px; color: #e6eaf2; }
.meta { color: #6b7280; font-size: 11px; }
.num { color: #9aa3b5; }
.num b { color: #d6dae3; font-weight: normal; }
.story { color: #a5b4fc; font-size: 11px; }
.err { color: #f87171; }
/* The strip scrolls sideways rather than wrapping: a filmstrip that wraps
stops being a filmstrip, and reading it left to right IS the measurement. */
.strip { display: flex; gap: 4px; overflow-x: auto; padding-bottom: 6px; }
.strip figure { margin: 0; flex: 0 0 auto; width: 168px; }
.strip canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; }
.strip figcaption { font-size: 10px; color: #565f70; margin-top: 3px; line-height: 1.35; }
.strip .t { color: #8b94a7; }
.strip .sc { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.moment { color: #fbbf24; }
/* The climax is the one frame worth finding at a glance. */
figure.climax canvas { outline: 2px solid #fbbf24; outline-offset: -2px; }
</style>
</head>
<body>
<h1>flow-state · filmstrip</h1>
<p class="lede">
Every song in the bank, one frame every <span id="everyLabel">30</span> seconds,
left to right. The gallery asks whether a scene looks the same in every song;
this asks whether a song looks the same as <em>itself</em> four minutes later.
A strip whose frames could be shuffled without anyone noticing is a video with
no arc, however busy it is.
<strong>drift</strong> is how far the video gets from itself and
<strong>direction</strong> is whether that distance grows with time — churn
scores the first and not the second. The climax frame is outlined.
<a href="/debug.html">← all debug tools</a>
</p>
<div class="bar" id="bar">
<span class="spinner"></span>
<button id="rebuild">rebuild</button>
<select id="sort">
<option value="direction">sort: least direction first</option>
<option value="drift">sort: least drift first</option>
<option value="name">sort: bank order</option>
<option value="plot">sort: by plot</option>
</select>
<span id="status">checking cache…</span>
<div id="progress"><i></i></div>
</div>
<div id="out"></div>
<script type="module">
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
import {
sourceFingerprint, loadGallery, saveGallery,
pixelsToBlob, blobToCanvas,
} from '/src/checks/gallery-cache.js';
const params = new URLSearchParams(location.search);
const every = Number(params.get('every')) || DEFAULT_EVERY;
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
const only = params.get('song') ? params.get('song').split(',') : null;
document.getElementById('everyLabel').textContent = every;
const bar = document.getElementById('bar');
const out = document.getElementById('out');
const status = document.getElementById('status');
const sortSel = document.getElementById('sort');
const progress = document.querySelector('#progress i');
const rebuildBtn = document.getElementById('rebuild');
let rows = [];
// Read against the arcless reference the variety report prints: around zero is
// a video that is as unlike itself after ten seconds as after four minutes.
const grade = (row) => (row.error ? 'broken'
: row.direction < 0.15 ? 'static'
: row.direction < 0.45 ? 'churn' : 'arc');
function draw() {
const mode = sortSel.value;
const order = songNames();
const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? order.indexOf(a.name) - order.indexOf(b.name)
: mode === 'drift' ? a.drift - b.drift
: mode === 'plot' ? ((a.story?.plot || '').localeCompare(b.story?.plot || '') || a.direction - b.direction)
: a.direction - b.direction));
out.innerHTML = '';
for (const row of sorted) {
const el = document.createElement('div');
el.className = `row ${grade(row)}`;
el.innerHTML = `
<div class="head">
<span class="name">${row.name}</span>
<span class="meta">${row.bpm ?? '?'} bpm · ${row.sections ?? '?'} sections · ${row.director || ''}</span>
<span class="num">direction <b>${(row.direction ?? 0).toFixed(2)}</b> · drift <b>${(row.drift ?? 0).toFixed(3)}</b></span>
<span class="story">${row.error ? `<span class="err">${row.error}</span>` : (row.storyLine || '')}</span>
</div>
<div class="strip"></div>`;
const strip = el.querySelector('.strip');
(row.shots || []).forEach((shot) => {
const fig = document.createElement('figure');
if (shot.moment === 'climax') fig.className = 'climax';
const canvas = document.createElement('canvas');
fig.appendChild(canvas);
const cap = document.createElement('figcaption');
const mm = Math.floor(shot.time / 60);
const ss = String(Math.round(shot.time % 60)).padStart(2, '0');
cap.innerHTML =
`<span class="t">${mm}:${ss}</span> ${shot.kind}` +
(shot.moment ? ` <span class="moment">${shot.moment}</span>` : '') +
`<span class="sc">${shot.scene}</span>` +
`<span class="sc">j ${shot.journey.toFixed(2)} · t ${shot.tension.toFixed(2)}</span>`;
fig.appendChild(cap);
strip.appendChild(fig);
// Decoded lazily: seventeen songs of nine frames is 150 images and
// decoding them synchronously stalls the page.
blobToCanvas(canvas, shot.blob);
});
out.appendChild(el);
}
}
sortSel.addEventListener('change', draw);
function summarise(built) {
const flat = rows.filter((r) => !r.error && r.direction < 0.15).length;
const meanDir = rows.length
? rows.reduce((a, r) => a + (r.direction || 0), 0) / rows.length : 0;
return `${rows.length} songs · every ${every}s of ${duration}s · ` +
`mean direction ${meanDir.toFixed(2)} · ${flat} with no direction` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
}
// The cache key carries the sampling parameters as well as the source, so
// changing `?every=` does not silently show you the previous spacing's pixels.
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:${sourceFingerprint()}`;
async function build() {
bar.classList.add('building');
rebuildBtn.disabled = true;
rows = [];
out.innerHTML = '';
const total = (only || songNames()).length;
const built = await buildFilmstrip({
duration, every, names: only,
onSong: (done, of, row) => {
status.textContent = `rendering ${row.name}… (${done}/${of})`;
progress.style.width = `${(done / of) * 100}%`;
},
});
// Compress once, here, rather than holding 150 raw frames: the strips are
// stored and redrawn from these blobs on every sort.
for (const row of built) {
rows.push({
...row,
frames: undefined,
shots: await Promise.all((row.frames || []).map(async (f) => ({
time: f.time, kind: f.kind, scene: f.scene, act: f.act,
tension: f.tension, journey: f.journey, moment: f.moment,
blob: await pixelsToBlob(f.pixels, STRIP.width, STRIP.height),
}))),
});
}
bar.classList.remove('building');
rebuildBtn.disabled = false;
const stamp = Date.now();
status.textContent = summarise(stamp);
status.className = '';
draw();
await saveGallery(cacheKey(), { built: stamp, rows });
return total;
}
async function load() {
const cached = await loadGallery(cacheKey());
if (cached && cached.rows && cached.rows.length) {
rows = cached.rows;
status.textContent = summarise(cached.built);
status.className = 'cached';
draw();
return;
}
await build();
}
rebuildBtn.addEventListener('click', () => build());
// Dev handle, so a headless run can wait for the build and read the numbers
// without scraping the DOM.
window.__FILMSTRIP__ = { get rows() { return rows; } };
load().then(() => { window.__FILMSTRIP_DONE__ = true; });
</script>
</body>
</html>