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:
parent
89e05459c0
commit
3a0cf26e6c
@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user