The filmstrip fills in as it renders

A full build is a couple of minutes of GPU work, and the page spent all of it
showing an empty screen and then everything at once. The interesting failure —
a strip whose frames could be shuffled without anyone noticing — is visible in
the first row, so waiting for the seventeenth to look at the first is a
needlessly slow way to find it out.

Rows are appended as they land, in bank order, and re-sorted once at the end
when there is finally something to sort by. One row renderer handles both raw
pixels, which is what exists mid-build, and the compressed blobs the cache
holds afterwards, so what you watch appear is what you keep.

The song is announced BEFORE its render rather than only after it, too:
synthesising and rendering one is tens of seconds, and a page that says nothing
until the first one lands looks broken for exactly as long as that takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-20 09:02:10 +02:00
parent 89e05459c0
commit 3a0cf26e6c
2 changed files with 83 additions and 35 deletions

View File

@ -45,6 +45,8 @@
.num { color: #9aa3b5; }
.num b { color: #d6dae3; font-weight: normal; }
.story { color: #a5b4fc; font-size: 11px; }
.watch { font-size: 11px; text-decoration: none; }
.watch:hover { text-decoration: underline; }
.err { color: #f87171; }
/* The strip scrolls sideways rather than wrapping: a filmstrip that wraps
@ -91,6 +93,8 @@
<script type="module">
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js';
import { blit } from '/src/checks/blit.js';
import { hashString } from '/src/engine/rng.js';
import {
sourceFingerprint, loadGallery, saveGallery,
pixelsToBlob, blobToCanvas,
@ -118,6 +122,63 @@ const grade = (row) => (row.error ? 'broken'
: row.direction < 0.15 ? 'static'
: row.direction < 0.45 ? 'churn' : 'arc');
// Nine stills are an argument about a video, and the only reply to them is the
// video. The app takes the bank's wav straight from `?song=`, and the seed is
// the one renderSong() uses — hashed off the name — so what plays is the take
// the strip measured rather than a fresh draw that happens to share a title.
const watchLink = (name) =>
`/?song=${encodeURIComponent(name)}&seed=${hashString(name)}`;
/**
* One song's row.
*
* Shots arrive as raw pixels while the build is running and as compressed blobs
* once it has finished (and on every later sort, out of the cache). Both are
* drawn here rather than in two places, because the whole point of painting
* during the build is that what you see then is what you keep.
*/
function renderRow(row) {
const el = document.createElement('div');
el.className = `row ${grade(row)}`;
el.innerHTML = `
<div class="head">
<span class="name">${row.name}</span>
<a class="watch" href="${watchLink(row.name)}" target="_blank"
title="play this song in the app, on the seed this strip was rendered with">watch ▸</a>
<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');
for (const shot of row.shots || row.frames || []) {
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);
if (shot.pixels) {
// Straight from the render, mid-build. Synchronous and cheap — it is
// one song's nine frames, not the whole bank's.
blit(canvas, shot.pixels, STRIP.width, STRIP.height);
} else {
// Decoded lazily: seventeen songs of nine frames is 150 images and
// decoding them synchronously stalls the page.
blobToCanvas(canvas, shot.blob);
}
}
return el;
}
function draw() {
const mode = sortSel.value;
const order = songNames();
@ -128,39 +189,7 @@ function draw() {
: 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);
}
for (const row of sorted) out.appendChild(renderRow(row));
}
sortSel.addEventListener('change', draw);
@ -187,9 +216,19 @@ async function build() {
const total = (only || songNames()).length;
const built = await buildFilmstrip({
duration, every, names: only,
// Painted as they land, in bank order, rather than after the whole run.
// A full build is a couple of minutes of GPU work and the interesting
// failure — a strip whose frames could be shuffled — is visible in the
// first row. Waiting for the seventeenth to look at the first is a
// needlessly slow way to find that out. Re-sorted once at the end, when
// there is finally something to sort by.
onSongStart: (done, of, name) => {
status.textContent = `rendering ${name}… (${done}/${of})`;
progress.style.width = `${((done - 1) / of) * 100}%`;
},
onSong: (done, of, row) => {
status.textContent = `rendering ${row.name}… (${done}/${of})`;
progress.style.width = `${(done / of) * 100}%`;
out.appendChild(renderRow(row));
},
});

View File

@ -161,13 +161,22 @@ function momentAt(story, index) {
* times for nothing.
*/
export async function buildFilmstrip({
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, onSong = null,
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = 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 });