New shared component src/ui/preview.js (appFrameUrl + previewLink): song + seed + frame + venue/venueSel deep-links. App honors ?frame= (warm seek on bank-song load) and ?venueSel= via shared pinVenueShell helper (src/venues/pin.js), which filmstrip and venue-gallery now also use. Filmstrip figures and venue-gallery cards wrap canvases; gallery cells stay unlinked (isolated scene probes, not reproducible in-app).
346 lines
16 KiB
HTML
346 lines
16 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; }
|
|
.row.agitated { border-color: #f97316; background: #1a1210; }
|
|
.agit { color: #9aa3b5; font-size: 11px; }
|
|
.agit .meter { display: inline-block; width: 96px; height: 7px; background: #1a1e28; border-radius: 4px; vertical-align: middle; margin-right: 6px; }
|
|
.agit .meter i { display: block; height: 100%; border-radius: 4px 0 0 4px; }
|
|
.agit .meter i.low { background: #22c55e; }
|
|
.agit .meter i.mid { background: #eab308; }
|
|
.agit .meter i.high { background: #f97316; }
|
|
.agit b.agitated { color: #f97316; }
|
|
.agit b.calm { color: #22c55e; }
|
|
.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; }
|
|
.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
|
|
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; }
|
|
a.preview-link canvas { cursor: zoom-in; }
|
|
a.preview-link:hover canvas { outline: 1px solid #7dd3fc; outline-offset: -1px; }
|
|
.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="venueSel" title="pin one venue in every row, or census"></select>
|
|
<select id="sort">
|
|
<option value="direction">sort: least direction first</option>
|
|
<option value="drift">sort: least drift first</option>
|
|
<option value="agitation">sort: most agitating 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 { venues } from '/src/venues/registry.js';
|
|
import { blit } from '/src/checks/blit.js';
|
|
import { hashString } from '/src/engine/rng.js';
|
|
import { previewLink } from '/src/ui/preview.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;
|
|
const venueEnabled = params.get('venue') === '1';
|
|
const venueSelParam = params.get('venueSel') || '';
|
|
|
|
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 venueSel = document.getElementById('venueSel');
|
|
const progress = document.querySelector('#progress i');
|
|
const rebuildBtn = document.getElementById('rebuild');
|
|
|
|
venueSel.innerHTML = `<option value="">all venues (census)</option>` +
|
|
venues.map((v) => `<option value="${v.name}">${v.name} (${v.archetype})</option>`).join('');
|
|
if ([...venueSel.options].some((o) => o.value === venueSelParam)) venueSel.value = venueSelParam;
|
|
venueSel.addEventListener('change', () => {
|
|
const p = new URLSearchParams(location.search);
|
|
if (venueSel.value) p.set('venueSel', venueSel.value);
|
|
else p.delete('venueSel');
|
|
history.replaceState(null, '', `${location.pathname}?${p}`);
|
|
build();
|
|
});
|
|
|
|
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 AGIT_CEILING = 0.70;
|
|
const agitClass = (v) => (v == null ? 'low' : v >= AGIT_CEILING ? 'high' : v >= 0.50 ? 'mid' : 'low');
|
|
const grade = (row) => {
|
|
if (row.error) return 'broken';
|
|
if ((row.agitation ?? 0) >= AGIT_CEILING) return 'agitated';
|
|
return 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.
|
|
// The venue flag and pin ride along too, so a venue strip replays its room.
|
|
const watchLink = (name) => {
|
|
const p = new URLSearchParams();
|
|
p.set('song', name);
|
|
p.set('seed', String(hashString(name)));
|
|
if (venueSel.value || venueEnabled) p.set('venue', '1');
|
|
if (venueSel.value) p.set('venueSel', venueSel.value);
|
|
return `/?${p}`;
|
|
};
|
|
|
|
// Deep link for one figure: song + seed + the frame it shows, so clicking a
|
|
// still opens the exact image in the app. Built by the shared preview
|
|
// component (src/ui/preview.js) — every preview page links the same way.
|
|
const shotSpec = (rowName, shot) => ({
|
|
song: rowName,
|
|
seed: hashString(rowName),
|
|
frame: shot.frame,
|
|
venue: venueEnabled || !!venueSel.value,
|
|
venueSel: venueSel.value || null,
|
|
});
|
|
|
|
/**
|
|
* 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)}`;
|
|
const agit = row.agitation ?? 0;
|
|
const agitLabel = row.error ? '' :
|
|
` <span class="agit" title="worst 1s window in busiest section — max(flash/3, motion/0.32, struct/0.12). Whole-screen reserved for specials; region motion fine. Ceiling ${AGIT_CEILING.toFixed(2)}">` +
|
|
`<span class="meter"><i class="${agitClass(agit)}" style="width:${Math.min(100, agit*100)}%"></i></span> ` +
|
|
`agit <b class="${agit >= AGIT_CEILING ? 'agitated' : 'calm'}">${agit.toFixed(2)}</b> · flash ${(row.agitationFlash ?? 0).toFixed(1)}/s · motion ${((row.agitationMotion ?? 0)*100).toFixed(0)}% · struct ${(row.agitationStructPeak ?? row.agitationStruct ?? row.peakStructure ?? 0).toFixed(2)}` +
|
|
`</span>`;
|
|
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>${agitLabel}</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);
|
|
// Clicking a still opens the exact frame in the app.
|
|
if (Number.isFinite(shot.frame)) previewLink(canvas, shotSpec(row.name, shot));
|
|
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();
|
|
const sorted = rows.slice().sort((a, b) => (
|
|
mode === 'agitation' ? (b.agitation ?? 0) - (a.agitation ?? 0)
|
|
: 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);
|
|
|
|
function summarise(built) {
|
|
const flat = rows.filter((r) => !r.error && r.direction < 0.15).length;
|
|
const validatedNote = rows.some((r) => r.validatedNote) ? ' · ' + rows.filter((r) => r.validatedNote).length + ' re-tuned' : '';
|
|
const agited = rows.filter((r) => (r.agitation ?? 0) >= AGIT_CEILING).length;
|
|
const meanDir = rows.length
|
|
? rows.reduce((a, r) => a + (r.direction || 0), 0) / rows.length : 0;
|
|
const meanAgit = rows.length
|
|
? rows.reduce((a, r) => a + (r.agitation ?? 0), 0) / rows.length : 0;
|
|
const venueNote = venueSel.value ? ` · venue ${venueSel.value}` : (venueEnabled ? ' · venues from census' : '');
|
|
return `${rows.length} songs · every ${every}s of ${duration}s${venueNote} · ` +
|
|
`mean direction ${meanDir.toFixed(2)} · ${flat} with no direction` +
|
|
` · mean agit ${meanAgit.toFixed(2)} · ${agited} agitated (≥${AGIT_CEILING.toFixed(2)})${validatedNote}` +
|
|
(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'}:v${venueEnabled ? 1 : 0}:${venueSel.value || 'auto'}:${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, venueEnabled, venueSel: venueSel.value || null,
|
|
// 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) => {
|
|
progress.style.width = `${(done / of) * 100}%`;
|
|
out.appendChild(renderRow(row));
|
|
},
|
|
});
|
|
|
|
// 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, frame: f.frame, 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>
|