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 { 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));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user