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 { color: #9aa3b5; }
.num b { color: #d6dae3; font-weight: normal; } .num b { color: #d6dae3; font-weight: normal; }
.story { color: #a5b4fc; font-size: 11px; } .story { color: #a5b4fc; font-size: 11px; }
.watch { font-size: 11px; text-decoration: none; }
.watch:hover { text-decoration: underline; }
.err { color: #f87171; } .err { color: #f87171; }
/* The strip scrolls sideways rather than wrapping: a filmstrip that wraps /* The strip scrolls sideways rather than wrapping: a filmstrip that wraps
@ -91,6 +93,8 @@
<script type="module"> <script type="module">
import { buildFilmstrip, songNames, STRIP, DEFAULT_EVERY, DEFAULT_DURATION } from '/src/checks/filmstrip.js'; 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 { import {
sourceFingerprint, loadGallery, saveGallery, sourceFingerprint, loadGallery, saveGallery,
pixelsToBlob, blobToCanvas, pixelsToBlob, blobToCanvas,
@ -118,29 +122,36 @@ const grade = (row) => (row.error ? 'broken'
: row.direction < 0.15 ? 'static' : row.direction < 0.15 ? 'static'
: row.direction < 0.45 ? 'churn' : 'arc'); : row.direction < 0.45 ? 'churn' : 'arc');
function draw() { // Nine stills are an argument about a video, and the only reply to them is the
const mode = sortSel.value; // video. The app takes the bank's wav straight from `?song=`, and the seed is
const order = songNames(); // the one renderSong() uses — hashed off the name — so what plays is the take
const sorted = rows.slice().sort((a, b) => ( // the strip measured rather than a fresh draw that happens to share a title.
mode === 'name' ? order.indexOf(a.name) - order.indexOf(b.name) const watchLink = (name) =>
: mode === 'drift' ? a.drift - b.drift `/?song=${encodeURIComponent(name)}&seed=${hashString(name)}`;
: mode === 'plot' ? ((a.story?.plot || '').localeCompare(b.story?.plot || '') || a.direction - b.direction)
: a.direction - b.direction));
out.innerHTML = ''; /**
for (const row of sorted) { * 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'); const el = document.createElement('div');
el.className = `row ${grade(row)}`; el.className = `row ${grade(row)}`;
el.innerHTML = ` el.innerHTML = `
<div class="head"> <div class="head">
<span class="name">${row.name}</span> <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="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="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> <span class="story">${row.error ? `<span class="err">${row.error}</span>` : (row.storyLine || '')}</span>
</div> </div>
<div class="strip"></div>`; <div class="strip"></div>`;
const strip = el.querySelector('.strip'); const strip = el.querySelector('.strip');
(row.shots || []).forEach((shot) => { for (const shot of row.shots || row.frames || []) {
const fig = document.createElement('figure'); const fig = document.createElement('figure');
if (shot.moment === 'climax') fig.className = 'climax'; if (shot.moment === 'climax') fig.className = 'climax';
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -155,12 +166,30 @@ function draw() {
`<span class="sc">j ${shot.journey.toFixed(2)} · t ${shot.tension.toFixed(2)}</span>`; `<span class="sc">j ${shot.journey.toFixed(2)} · t ${shot.tension.toFixed(2)}</span>`;
fig.appendChild(cap); fig.appendChild(cap);
strip.appendChild(fig); 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 // Decoded lazily: seventeen songs of nine frames is 150 images and
// decoding them synchronously stalls the page. // decoding them synchronously stalls the page.
blobToCanvas(canvas, shot.blob); blobToCanvas(canvas, shot.blob);
});
out.appendChild(el);
} }
}
return el;
}
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) out.appendChild(renderRow(row));
} }
sortSel.addEventListener('change', draw); sortSel.addEventListener('change', draw);
@ -187,9 +216,19 @@ async function build() {
const total = (only || songNames()).length; const total = (only || songNames()).length;
const built = await buildFilmstrip({ const built = await buildFilmstrip({
duration, every, names: only, 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) => { onSong: (done, of, row) => {
status.textContent = `rendering ${row.name}… (${done}/${of})`;
progress.style.width = `${(done / of) * 100}%`; progress.style.width = `${(done / of) * 100}%`;
out.appendChild(renderRow(row));
}, },
}); });

View File

@ -161,13 +161,22 @@ function momentAt(story, index) {
* times for nothing. * times for nothing.
*/ */
export async function buildFilmstrip({ 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 list = names && names.length ? names : songNames();
const show = new Show({ ...STRIP }); const show = new Show({ ...STRIP });
const rows = []; const rows = [];
try { try {
for (let i = 0; i < list.length; i++) { 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; let row;
try { try {
row = renderSong(show, list[i], { duration, every }); row = renderSong(show, list[i], { duration, every });