Whole-screen flashing/coherent motion is now a gated comfort signal rather than a hunt-and-tweak. Three-arm agitation (flash/3, tile-motion/0.32, structure/0.12) over a 1s floating window, worst window wins — engine/agitation.js. Policy centralised in look/comfort.js: director comfort appetite, per-scene agitationOf proxy, agitationWeight lean and stackAgitation. Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth) so one contract change tames every full-frame scene; lint warns above the cap. Selection now filters before casting (look/scene-filter.js, 12+ ranged metrics from checks/scene-metrics.js, SCHEMA 7) and validates after stacking (look/composition-validator.js) with a deterministic retune: drop overlay → swap quiet composable → reselect filtered shot. Fix validator attempt-3 ground-layer bug that crashed ?song=dusk (LookGenerator now passes module, not layer). Eight canvas shaders window their beat/flux (focusField + per-seed hash mask) and report the scaled amount to Layer so the cap stays authoritative: scan-tear, gate-corridor, analog-wow, block-mosh, metaballs, pitch-shatter, scale-mosaic, tide-rings. Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and fingerprint over agitation/comfort sources. main.js surfaces COMPOSITION_REFUSED as a user-facing error. Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks. Build passes. metadata.json refresh deferred to gallery → Refresh metadata (SCHEMA 7 writer in vite.config). Co-Authored-By: Claude <noreply@anthropic.com>
606 lines
30 KiB
HTML
606 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>flow-state · gallery</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: 78ch; 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; }
|
||
.ctx { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 0; font-size: 11px; color: #6b7280; }
|
||
.ctx span { background: #11141b; padding: 3px 7px; border-radius: 3px; }
|
||
|
||
/* Building blocks the main thread in bursts, so the spinner is a CSS
|
||
animation — it keeps turning through the GPU work where a JS-driven one
|
||
would freeze and look like a hang. */
|
||
.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.flat { border-color: #ef4444; }
|
||
.row.thin { border-color: #eab308; }
|
||
.row.good { border-color: #22c55e; }
|
||
.row.broken { border-color: #a855f7; background: #150e1c; }
|
||
.row.agitated { border-color: #f97316; }
|
||
.comfort { color: #9aa3b5; font-size: 11px; }
|
||
.comfort .meter i.low { background: #22c55e; }
|
||
.comfort .meter i.mid { background: #eab308; }
|
||
.comfort .meter i.high { background: #ef4444; }
|
||
.comfort .meter i.hot { background: #f97316; }
|
||
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
|
||
.name { font-size: 14px; color: #e6eaf2; }
|
||
.fam { color: #6b7280; }
|
||
.score { color: #9aa3b5; }
|
||
.meter { position: relative; display: inline-block; width: 130px; height: 7px; background: #1a1e28; border-radius: 4px; vertical-align: middle; }
|
||
.meter i { display: block; height: 100%; background: #4ade80; border-radius: 4px 0 0 4px; }
|
||
/* The bar, on every meter, so a row can be read on its own. */
|
||
.meter b { position: absolute; top: -2px; bottom: -2px; width: 2px; background: #ef4444; }
|
||
.row.flat .meter i { background: #ef4444; }
|
||
.row.thin .meter i { background: #eab308; }
|
||
.blocks { color: #5c6577; font-size: 11px; }
|
||
/* Coverage sits next to the score because the two explain each other: a
|
||
scene painting 2% of the frame scores well for variety and is thin to
|
||
watch alone. */
|
||
.surf { font-size: 11px; padding: 1px 6px; border-radius: 3px; }
|
||
.surf.canvas { background: #16232b; color: #7dd3fc; }
|
||
.surf.composable { background: #2a1f30; color: #d8b4fe; }
|
||
.thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
|
||
.thumbs figure { margin: 0; }
|
||
.thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; }
|
||
.thumbs figcaption { font-size: 10px; color: #565f70; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.err { color: #f87171; }
|
||
.cutline { position: relative; border-top: 2px solid #ef4444; margin: 26px 0 24px; }
|
||
.cutline span {
|
||
position: absolute; top: -9px; left: 12px; background: #0b0d12;
|
||
padding: 0 8px; color: #ef4444; font-size: 11px; letter-spacing: .04em;
|
||
}
|
||
@media (max-width: 1100px) { .thumbs { grid-template-columns: repeat(3, 1fr); } }
|
||
|
||
/* ---- single-scene sweep --------------------------------------------- */
|
||
.name a { color: inherit; text-decoration: none; border-bottom: 1px dotted #3a4256; }
|
||
.name a:hover { color: #7dd3fc; }
|
||
.btns { display: inline-flex; gap: 4px; margin-left: 4px; }
|
||
.btns button.on { border-color: #7dd3fc; color: #7dd3fc; }
|
||
.sweep { display: grid; gap: 5px; align-items: center; margin-top: 8px; }
|
||
.sweep .colhead { font-size: 10px; color: #565f70; text-align: center; }
|
||
.sweep .rowlabel {
|
||
font-size: 11px; color: #8b94a7; text-align: right;
|
||
padding-right: 8px; white-space: nowrap;
|
||
}
|
||
.sweep .cell { position: relative; line-height: 0; }
|
||
.sweep canvas {
|
||
width: 100%; display: block; background: #05070a;
|
||
border-radius: 2px; aspect-ratio: 16 / 9;
|
||
}
|
||
/* The two cells that measured closest — on a grid this wide they are the
|
||
only pair an eye has no chance of finding on its own. */
|
||
.sweep .cell.twin canvas { outline: 2px solid #ef4444; outline-offset: 1px; }
|
||
.sweep .cell.twin::after {
|
||
content: 'twin'; position: absolute; top: 2px; left: 3px;
|
||
font-size: 9px; color: #ef4444; line-height: 1;
|
||
}
|
||
.axes { display: flex; gap: 18px; flex-wrap: wrap; margin: 10px 0 2px; }
|
||
.axes div { font-size: 12px; }
|
||
.axes b { color: #e6eaf2; font-weight: normal; }
|
||
.axes .weak b { color: #ef4444; }
|
||
.axes small { color: #6b7280; display: block; font-size: 11px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>flow-state · gallery</h1>
|
||
<p class="lede">
|
||
Every visualizer rendered six times, once per song, each with that song's
|
||
complete identity — its cast, ink, lattice, palette and sampled parameters.
|
||
A scene whose six frames are interchangeable is one the song cannot change.
|
||
Sorted least-varied first, scored on the same structural descriptor the
|
||
variety harness uses, with colour excluded so six palettes cannot disguise
|
||
one image.
|
||
<strong>Coverage</strong> is shown beside each score because the two explain
|
||
each other: the highest-scoring scenes are often a few bright elements on
|
||
black, which is varied and thin to watch on its own. Those are
|
||
<em>composable</em> — they are meant to sit on top of a <em>canvas</em>.
|
||
<strong>Click a scene's name</strong> to open it on its own, across a much
|
||
wider grid of songs and seeds — six frames ranks a library and is far too
|
||
few to study one scene.
|
||
<a href="/debug.html">← all debug tools</a>
|
||
</p>
|
||
|
||
<div class="bar" id="bar">
|
||
<span class="spinner"></span>
|
||
<span id="libctl">
|
||
<button id="rebuild">rebuild</button>
|
||
<button id="refresh" title="re-measure the library and write src/scenes/metadata.json — the git-tracked numbers the generator composes with">refresh metadata</button>
|
||
<select id="sort">
|
||
<option value="variety">sort: least varied first</option>
|
||
<option value="name">sort: by name</option>
|
||
<option value="family">sort: by family</option>
|
||
<option value="coverage">sort: sparsest first</option>
|
||
<option value="agitation">sort: most agitating first</option>
|
||
</select>
|
||
</span>
|
||
<span id="sweepctl" hidden>
|
||
<button id="back">← all scenes</button>
|
||
<select id="scenesel"></select>
|
||
<span class="btns" id="sizes"></span>
|
||
<span class="btns">
|
||
<button id="prevseeds" title="the same grid on the previous block of seeds">← seeds</button>
|
||
<button id="nextseeds" title="the same grid on the next block of seeds — the answer to "is it flat, or was that ten unlucky draws"">seeds →</button>
|
||
<button id="rerender" title="render this grid again">re-render</button>
|
||
</span>
|
||
</span>
|
||
<span id="status">checking cache…</span>
|
||
<div class="ctx" id="ctx"></div>
|
||
<div id="progress"><i></i></div>
|
||
</div>
|
||
|
||
<div id="sweepinfo"></div>
|
||
<div id="out"></div>
|
||
|
||
<script type="module">
|
||
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
|
||
import { measureLibrary, metadataDrift, writeMetadata, metadataIsFresh } from '/src/checks/metadata.js';
|
||
import { AGITATION_CEILING } from '/src/engine/agitation.js';
|
||
// A gallery showing numbers the generator is not using is a trap, so say so.
|
||
const STALE_NOTE = metadataIsFresh() ? '' : ' · metadata is STALE — press refresh';
|
||
const AGITATION_CEILING_FALLBACK = 0.70;
|
||
const agitationCeiling = (typeof AGITATION_CEILING !== 'undefined' ? AGITATION_CEILING : AGITATION_CEILING_FALLBACK);
|
||
import {
|
||
sourceFingerprint, loadGallery, saveGallery, clearGallery,
|
||
pixelsToBlob, blobToCanvas,
|
||
} from '/src/checks/gallery-cache.js';
|
||
import { sweepGrid, sweepScene, prepareSongs, SWEEP_SIZES } from '/src/checks/scene-sweep.js';
|
||
import { Engine } from '/src/engine/Engine.js';
|
||
import { scenes } from '/src/scenes/registry.js';
|
||
|
||
const bar = document.getElementById('bar');
|
||
const out = document.getElementById('out');
|
||
const status = document.getElementById('status');
|
||
const sortSel = document.getElementById('sort');
|
||
const progress = document.querySelector('#progress i');
|
||
const rebuildBtn = document.getElementById('rebuild');
|
||
const refreshBtn = document.getElementById('refresh');
|
||
|
||
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
|
||
let contexts = [];
|
||
|
||
// There is no bar any more — see checks/gallery.js for why the 0.1 red line
|
||
// went. What is left is a broken/working distinction and a relative shading, so
|
||
// the eye can still find the flattest scenes in a sorted list without anyone
|
||
// pretending a threshold decides whether a scene is allowed to exist.
|
||
const grade = (v, err) => (err ? 'broken' : v < 0.08 ? 'thin' : 'good');
|
||
const agitationGrade = (a) => (a >= agitationCeiling ? 'high' : a >= 0.50 ? 'mid' : 'low');
|
||
|
||
function draw() {
|
||
const mode = sortSel.value;
|
||
const sorted = rows.slice().sort((a, b) => (
|
||
mode === 'agitation' ? (b.agitation || 0) - (a.agitation || 0)
|
||
: mode === 'name' ? a.name.localeCompare(b.name)
|
||
: mode === 'coverage' ? (a.coverage || 0) - (b.coverage || 0)
|
||
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
|
||
: a.variety - b.variety));
|
||
|
||
out.innerHTML = '';
|
||
for (const row of sorted) {
|
||
const el = document.createElement('div');
|
||
el.className = `row ${grade(row.variety, row.error)}${(row.agitation || 0) >= agitationCeiling ? ' agitated' : ''}`;
|
||
const blocks = Object.entries(row.byBlock || {})
|
||
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
|
||
const agit = row.agitation || 0;
|
||
const flashPeak = row.flashPeak || 0;
|
||
const motionPeak = row.motionPeak || 0;
|
||
// structPeak present after refresh; falls back to 0 on cached rows
|
||
el.innerHTML = `
|
||
<div class="head">
|
||
<span class="name"><a href="?scene=${encodeURIComponent(row.name)}" title="open this one on a wide grid of songs and seeds">${row.name}</a></span>
|
||
<span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span>
|
||
<span class="surf ${surfaceFor(row)}" title="paints ${((row.coverage || 0) * 100).toFixed(0)}% of the frame across the six songs — this is what decides the label">${surfaceFor(row)} ${((row.coverage || 0) * 100).toFixed(0)}%</span>
|
||
<span class="score">
|
||
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
|
||
${row.variety.toFixed(3)}
|
||
</span>
|
||
<span class="comfort" title="worst 1s window — max(flash/3, motion/0.32, struct/0.12). Whole-screen reserved for specials; region motion fine. Tiled rings / full pattern now count via struct. Ceiling ${agitationCeiling.toFixed(2)}">
|
||
<span class="meter"><i class="${agitationGrade(agit)}" style="width:${Math.min(100, agit * 100)}%"></i></span>
|
||
agit ${agit.toFixed(2)} · flash ${flashPeak.toFixed(1)}/s · motion ${(motionPeak * 100).toFixed(0)}% · struct ${(row.structPeak ?? 0).toFixed(2)} · <a class="metrics-toggle" href="#" onclick="event.preventDefault(); this.closest('.row').querySelector('.metrics-drawer').hidden = !this.closest('.row').querySelector('.metrics-drawer').hidden;">metrics ▸</a>
|
||
</span>
|
||
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
|
||
</div>
|
||
<div class="thumbs"></div>`;
|
||
const grid = el.querySelector('.thumbs');
|
||
(row.blobs || []).forEach((blob, i) => {
|
||
const fig = document.createElement('figure');
|
||
const canvas = document.createElement('canvas');
|
||
fig.appendChild(canvas);
|
||
const cap = document.createElement('figcaption');
|
||
cap.textContent = contexts[i] ? contexts[i].name : '';
|
||
cap.title = contexts[i] ? contexts[i].identity : '';
|
||
fig.appendChild(cap);
|
||
grid.appendChild(fig);
|
||
// Decoded lazily and in the background; sixty-five rows of six is
|
||
// 390 images and decoding them all synchronously stalls the page.
|
||
blobToCanvas(canvas, blob);
|
||
});
|
||
const drawer = document.createElement('div');
|
||
drawer.className = 'metrics-drawer';
|
||
drawer.hidden = true;
|
||
drawer.style.cssText = 'font-size:10px;color:#8b94a7;margin-top:6px;display:flex;gap:6px;flex-wrap:wrap;align-items:center';
|
||
if (row.metrics) {
|
||
const order = ['agitation','structPeak','motionPeak','flashPeak','coverage','luminance','variance','drift','scaleEnergy','layoutEnergy','textureDensity'];
|
||
for (const k of order) {
|
||
const r = row.metrics[k];
|
||
if (!r) continue;
|
||
const chip = document.createElement('span');
|
||
chip.title = `${k}: mean ${r.mean.toFixed(3)} \u00b7 min ${r.min.toFixed(3)} \u00b7 max ${r.max.toFixed(3)} \u00b7 std ${r.std.toFixed(3)} — max drives filtering (some songs just won't use this scene)`;
|
||
chip.textContent = `${k} ${r.max.toFixed(3)}`;
|
||
const hot = (k === 'agitation' && r.max >= agitationCeiling) || (k === 'structPeak' && r.max > 0.16) || (k === 'motionPeak' && r.max > 0.38);
|
||
chip.style.cssText = `background:${hot ? '#3a1510' : '#1a1e28'};color:${hot ? '#f87171' : '#8b94a7'};padding:2px 6px;border-radius:3px;white-space:nowrap`;
|
||
drawer.appendChild(chip);
|
||
}
|
||
} else {
|
||
drawer.textContent = 'ranged metrics not yet measured \u2014 rebuild + Refresh metadata (SCHEMA 7)';
|
||
drawer.style.color = '#eab308';
|
||
}
|
||
el.appendChild(drawer);
|
||
out.appendChild(el);
|
||
}
|
||
}
|
||
|
||
sortSel.addEventListener('change', draw);
|
||
|
||
// The label a scene WOULD carry from this build's numbers, which is not
|
||
// necessarily the one the checked-in metadata gives it — that is the whole
|
||
// point of showing it here.
|
||
const surfaceFor = (row) => ((row.coverage || 0) >= 0.5 ? 'canvas' : 'composable');
|
||
|
||
function summarise(built) {
|
||
const broken = rows.filter((r) => r.error).length;
|
||
const canvases = rows.filter((r) => surfaceFor(r) === 'canvas').length;
|
||
return `${rows.length} scenes · ${canvases} canvas / ${rows.length - canvases} composable` +
|
||
(broken ? ` · ${broken} BROKEN` : '') +
|
||
` · ${rows.filter((r) => (r.agitation || 0) >= agitationCeiling).length} agitated (≥${agitationCeiling.toFixed(2)})` +
|
||
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
|
||
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
|
||
}
|
||
|
||
async function build(fingerprint) {
|
||
bar.classList.add('building');
|
||
rebuildBtn.disabled = true;
|
||
rows = [];
|
||
out.innerHTML = '';
|
||
|
||
status.className = '';
|
||
status.textContent = 'analysing six songs…';
|
||
await new Promise((r) => setTimeout(r, 0));
|
||
|
||
contexts = galleryContexts(6).map((c) => ({ ...c }));
|
||
document.getElementById('ctx').innerHTML = contexts
|
||
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
|
||
|
||
const started = Date.now();
|
||
await buildGallery({
|
||
contexts,
|
||
onScene: async (done, total, name, row) => {
|
||
status.textContent = `rendering ${done}/${total} · ${name}`;
|
||
progress.style.width = `${(done / total) * 100}%`;
|
||
// Show rows as they land. The caching rewrite moved every draw to
|
||
// the end, which left three minutes of spinner and nothing to look
|
||
// at — and the first scenes are the interesting ones, since the
|
||
// sort puts the repetitive ones on top.
|
||
rows.push({
|
||
name: row.module.name, family: row.module.family,
|
||
role: row.module.role || 'stage', variety: row.variety,
|
||
coverage: row.coverage, surface: row.surface,
|
||
byBlock: row.byBlock, error: row.error,
|
||
agitation: row.agitation || 0, flashPeak: row.flashPeak || 0, motionPeak: row.motionPeak || 0, motionMean: row.motionMean || 0, structPeak: row.structPeak || 0, structMean: row.structMean || 0, metrics: row.metrics || null,
|
||
blobs: await Promise.all(row.thumbs.map(
|
||
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))),
|
||
});
|
||
if (done % 8 === 0 || done === total) draw();
|
||
},
|
||
});
|
||
|
||
const payload = {
|
||
fingerprint,
|
||
built: Date.now(),
|
||
contexts: contexts.map((c) => ({ name: c.name, identity: c.identity })),
|
||
rows,
|
||
};
|
||
const stored = await saveGallery(fingerprint, payload);
|
||
|
||
bar.classList.remove('building');
|
||
rebuildBtn.disabled = false;
|
||
draw();
|
||
status.textContent = `${summarise(payload.built)} in ${((Date.now() - started) / 1000).toFixed(0)}s` +
|
||
(stored ? '' : ' · not cached (storage unavailable)');
|
||
window.__GALLERY__ = rows.map((r) => ({ name: r.name, variety: r.variety, error: r.error }));
|
||
}
|
||
|
||
// ---- single-scene sweep -------------------------------------------------
|
||
// One scene over a grid of songs (rows) and seeds (columns). See
|
||
// src/checks/scene-sweep.js for what the two axes are for.
|
||
|
||
const sweepInfo = document.getElementById('sweepinfo');
|
||
const sceneSel = document.getElementById('scenesel');
|
||
const sizesEl = document.getElementById('sizes');
|
||
|
||
/** The sweep's whole state lives in the URL, so every button is a back button. */
|
||
function sweepState() {
|
||
const p = new URLSearchParams(location.search);
|
||
return {
|
||
scene: p.get('scene'),
|
||
songs: Number(p.get('songs')) || 6,
|
||
seeds: Number(p.get('seeds')) || 6,
|
||
offset: Number(p.get('offset')) || 0,
|
||
};
|
||
}
|
||
|
||
function goSweep(patch) {
|
||
const next = { ...sweepState(), ...patch };
|
||
const p = new URLSearchParams({
|
||
scene: next.scene, songs: next.songs, seeds: next.seeds, offset: next.offset,
|
||
});
|
||
history.pushState(null, '', `?${p}`);
|
||
renderSweep();
|
||
}
|
||
|
||
let sweepRun = 0; // cancels an in-flight grid when the buttons move on
|
||
|
||
async function renderSweep() {
|
||
const state = sweepState();
|
||
const module = scenes.find((m) => m.name === state.scene);
|
||
const run = ++sweepRun;
|
||
|
||
document.getElementById('libctl').hidden = true;
|
||
document.getElementById('sweepctl').hidden = false;
|
||
document.getElementById('ctx').innerHTML = '';
|
||
out.innerHTML = '';
|
||
// The page's own lede describes the library view, which is the wrong thing
|
||
// to be reading over a grid of one scene.
|
||
document.querySelector('.lede').innerHTML =
|
||
'One visualizer over a grid of songs and seeds. <strong>Rows are songs</strong> — ' +
|
||
'different audio, so different features, sections and biases. ' +
|
||
'<strong>Columns are seeds</strong> — the same audio with a different roll of ' +
|
||
'the identity, palette and parameters. A flat row means the seed does nothing ' +
|
||
'here; a flat column means the song does nothing; the two scores below say ' +
|
||
'which. Frames are the busiest section of each look, where scenes are most ' +
|
||
'likely to converge. <a href="?">← all scenes</a> · ' +
|
||
'<a href="/debug.html">all debug tools</a>';
|
||
|
||
if (!module) {
|
||
sweepInfo.innerHTML = `<p class="err">no scene named "${state.scene}"</p>`;
|
||
return;
|
||
}
|
||
|
||
sceneSel.value = module.name;
|
||
for (const b of sizesEl.children) {
|
||
b.classList.toggle('on', Number(b.dataset.songs) === state.songs
|
||
&& Number(b.dataset.seeds) === state.seeds);
|
||
}
|
||
|
||
bar.classList.add('building');
|
||
status.className = '';
|
||
sweepInfo.innerHTML = '';
|
||
|
||
// Analysed one at a time so the count moves; cached, so only the first grid
|
||
// of a session waits for it.
|
||
await prepareSongs(state.songs, (done, total, name) => {
|
||
status.textContent = `${module.name}: analysing songs ${done}/${total} · ${name}`;
|
||
progress.style.width = `${(done / total) * 100}%`;
|
||
});
|
||
if (run !== sweepRun) return;
|
||
|
||
const { cells, songNames, seedLabels } = sweepGrid({
|
||
songs: state.songs, seeds: state.seeds, seedOffset: state.offset,
|
||
});
|
||
if (run !== sweepRun) return;
|
||
|
||
// The grid is laid out before anything is rendered, so the shape of what is
|
||
// coming is visible immediately and each cell fills in where it belongs.
|
||
const grid = document.createElement('div');
|
||
grid.className = 'sweep';
|
||
grid.style.gridTemplateColumns = `auto repeat(${seedLabels.length}, 1fr)`;
|
||
grid.appendChild(document.createElement('span'));
|
||
for (const label of seedLabels) {
|
||
const h = document.createElement('span');
|
||
h.className = 'colhead';
|
||
h.textContent = label;
|
||
grid.appendChild(h);
|
||
}
|
||
const canvases = [];
|
||
cells.forEach((cell, i) => {
|
||
if (i % seedLabels.length === 0) {
|
||
const label = document.createElement('span');
|
||
label.className = 'rowlabel';
|
||
label.textContent = cell.song;
|
||
grid.appendChild(label);
|
||
}
|
||
const holder = document.createElement('span');
|
||
holder.className = 'cell';
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = THUMB.width; canvas.height = THUMB.height;
|
||
holder.title = `${cell.name} · seed ${cell.seed}\n${cell.identity}`;
|
||
holder.appendChild(canvas);
|
||
grid.appendChild(holder);
|
||
canvases.push({ canvas, holder });
|
||
});
|
||
out.appendChild(grid);
|
||
|
||
const started = Date.now();
|
||
const engine = new Engine({ ...THUMB });
|
||
let result;
|
||
try {
|
||
result = await sweepScene({
|
||
engine, module, cells,
|
||
onCell: async (i, total, pixels) => {
|
||
if (run !== sweepRun) return;
|
||
blit(canvases[i].canvas, pixels, THUMB.width, THUMB.height);
|
||
status.textContent = `${module.name}: ${i + 1}/${total} cells`;
|
||
progress.style.width = `${((i + 1) / total) * 100}%`;
|
||
// Yield every row, so the grid fills in visibly instead of
|
||
// freezing the page for a hundred and seventy renders.
|
||
if (i % seedLabels.length === seedLabels.length - 1) {
|
||
await new Promise((r) => setTimeout(r, 0));
|
||
}
|
||
},
|
||
});
|
||
} finally {
|
||
engine.dispose();
|
||
}
|
||
if (run !== sweepRun) return;
|
||
|
||
if (result.closest) {
|
||
canvases[result.closest.a].holder.classList.add('twin');
|
||
canvases[result.closest.b].holder.classList.add('twin');
|
||
}
|
||
|
||
// Three numbers rather than one: which AXIS is flat is the thing the
|
||
// library gallery cannot tell you, and it decides what to go and fix.
|
||
const weak = (v) => (v < 0.08 ? ' weak' : '');
|
||
const twin = result.closest
|
||
? `${cells[result.closest.a].name} ≈ ${cells[result.closest.b].name} at ${result.closest.distance.toFixed(3)}`
|
||
: '—';
|
||
sweepInfo.innerHTML = `
|
||
<div class="axes">
|
||
<div class="${weak(result.variety)}">overall <b>${result.variety.toFixed(3)}</b>
|
||
<small>every cell against every other</small></div>
|
||
<div class="${weak(result.bySeed)}">across seeds <b>${result.bySeed.toFixed(3)}</b>
|
||
<small>same song, different draw — low means the scene ignores the identity</small></div>
|
||
<div class="${weak(result.bySong)}">across songs <b>${result.bySong.toFixed(3)}</b>
|
||
<small>same draw, different music — low means the scene ignores the song</small></div>
|
||
<div>closest pair <b>${twin}</b>
|
||
<small>outlined in the grid</small></div>
|
||
</div>
|
||
<div class="blocks">${Object.entries(result.byBlock)
|
||
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ')}</div>`;
|
||
|
||
bar.classList.remove('building');
|
||
status.textContent = `${module.name} · ${cells.length} cells ` +
|
||
`(${songNames.length} songs × ${seedLabels.length} seeds, from ${seedLabels[0]}) ` +
|
||
`in ${((Date.now() - started) / 1000).toFixed(0)}s`;
|
||
window.__SWEEP__ = {
|
||
scene: module.name, variety: result.variety,
|
||
bySeed: result.bySeed, bySong: result.bySong,
|
||
};
|
||
}
|
||
|
||
function initSweepControls() {
|
||
// Alphabetical, not registry order: the registry is chronological — the
|
||
// order scenes were added — which is meaningless to anyone hunting for one
|
||
// by name in a list of sixty-seven.
|
||
sceneSel.innerHTML = scenes.filter((m) => m.kind === 'fragment')
|
||
.map((m) => m.name).sort((a, b) => a.localeCompare(b))
|
||
.map((name) => `<option value="${name}">${name}</option>`).join('');
|
||
sceneSel.addEventListener('change', () => goSweep({ scene: sceneSel.value, offset: 0 }));
|
||
|
||
sizesEl.innerHTML = SWEEP_SIZES.map((s) =>
|
||
`<button data-songs="${s.songs}" data-seeds="${s.seeds}" ` +
|
||
`title="${s.songs} songs × ${s.seeds} seeds = ${s.songs * s.seeds} renders">${s.label}</button>`).join('');
|
||
sizesEl.addEventListener('click', (e) => {
|
||
const b = e.target.closest('button');
|
||
if (b) goSweep({ songs: Number(b.dataset.songs), seeds: Number(b.dataset.seeds) });
|
||
});
|
||
|
||
document.getElementById('nextseeds').addEventListener('click',
|
||
() => goSweep({ offset: sweepState().offset + sweepState().seeds }));
|
||
document.getElementById('prevseeds').addEventListener('click',
|
||
() => goSweep({ offset: Math.max(0, sweepState().offset - sweepState().seeds) }));
|
||
document.getElementById('rerender').addEventListener('click', renderSweep);
|
||
document.getElementById('back').addEventListener('click', () => { location.search = ''; });
|
||
window.addEventListener('popstate', renderSweep);
|
||
}
|
||
|
||
async function main() {
|
||
if (sweepState().scene) {
|
||
initSweepControls();
|
||
await renderSweep();
|
||
return;
|
||
}
|
||
|
||
const fingerprint = sourceFingerprint();
|
||
const cached = await loadGallery(fingerprint);
|
||
|
||
if (cached) {
|
||
rows = cached.rows;
|
||
contexts = cached.contexts;
|
||
document.getElementById('ctx').innerHTML = contexts
|
||
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
|
||
draw();
|
||
status.className = 'cached';
|
||
status.textContent = `cached · ${summarise(cached.built)}`;
|
||
window.__GALLERY__ = rows.map((r) => ({ name: r.name, variety: r.variety, error: r.error }));
|
||
return;
|
||
}
|
||
// No entry for this source. Something under src/ changed since the last
|
||
// build — or there has never been one — so rebuild without being asked.
|
||
await build(fingerprint);
|
||
}
|
||
|
||
// ---- metadata refresh ---------------------------------------------------
|
||
// The gallery is where the library gets measured, so it is where the measured
|
||
// facts are written back. The numbers go to src/scenes/metadata.json — tracked
|
||
// in git, read by the look generator, and stamped with a fingerprint of the
|
||
// scenes and the metric definitions so a stale file is detectable rather than
|
||
// merely old. See src/checks/metadata.js.
|
||
refreshBtn.addEventListener('click', async () => {
|
||
refreshBtn.disabled = true;
|
||
rebuildBtn.disabled = true;
|
||
bar.classList.add('building');
|
||
const started = Date.now();
|
||
try {
|
||
const fresh = measureLibrary({
|
||
onScene: (done, total, name) => {
|
||
status.textContent = `measuring ${done}/${total} · ${name}`;
|
||
progress.style.width = `${(done / total) * 100}%`;
|
||
},
|
||
});
|
||
const { moved, gone } = metadataDrift(fresh);
|
||
await writeMetadata(fresh);
|
||
const changes = moved.slice(0, 6).map((m) => `${m.name} ${m.note}`).join(' · ');
|
||
status.textContent =
|
||
`metadata written in ${((Date.now() - started) / 1000).toFixed(0)}s · ` +
|
||
`${Object.keys(fresh.scenes).length} scenes · ` +
|
||
(moved.length ? `${moved.length} moved — ${changes}` : 'nothing moved') +
|
||
(gone.length ? ` · dropped ${gone.join(', ')}` : '');
|
||
} catch (err) {
|
||
status.textContent = `metadata refresh failed: ${err.message} ` +
|
||
'(the write endpoint only exists under npm run dev)';
|
||
} finally {
|
||
bar.classList.remove('building');
|
||
refreshBtn.disabled = false;
|
||
rebuildBtn.disabled = false;
|
||
progress.style.width = '0%';
|
||
}
|
||
});
|
||
|
||
rebuildBtn.addEventListener('click', async () => {
|
||
await clearGallery();
|
||
await build(sourceFingerprint());
|
||
});
|
||
|
||
main();
|
||
</script>
|
||
</body>
|
||
</html>
|