Every shot stands on something, and the frame has two ends

A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.

The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.

The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.

Chasing the numbers turned up four real faults:

  * A scene that reads prev() cannot be a ground. It returns the whole
    composited frame including the layers above it, so a datamosh under a shot
    is eating it: the render stopped reproducing from a seek and two WebGL
    contexts diverged by 91/255 against a tolerance of 4.
  * Screen was the wrong operator for a shot over a bed. It lightens, so a
    median quarter of every frame clipped to paper and whole sections rendered
    100% white. Replaced by a lumakey — the shot's brightness is its alpha.
  * Feedback was an accumulator: a still image settled at 2.3x its own
    brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
    plus a highlight shoulder so the top rolls off instead of clipping.
  * useTrack never prewarmed, so a fresh Show's first frame differed from every
    later render of it — the export-breaking hazard Compositor.prime documents.

Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.

Rendered across twelve videos, middle of every section:

    painted        51% mean, darkest 0.3%  ->  87% mean, darkest 43%
    clipped white  24% median, worst 100%  ->   1% mean, worst 30%
    separation     0.10                    ->  0.44

Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-20 09:02:01 +02:00
co-authored by Claude Opus 5
parent 7d151be6e1
commit 89e05459c0
68 changed files with 9896 additions and 293 deletions
+60 -21
View File
@@ -126,6 +126,7 @@
<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>
@@ -152,7 +153,10 @@
<div id="out"></div>
<script type="module">
import { buildGallery, galleryContexts, THUMB, MIN_VARIETY, blit } from '/src/checks/gallery.js';
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
import { measureLibrary, metadataDrift, writeMetadata, metadataIsFresh } from '/src/checks/metadata.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';
import {
sourceFingerprint, loadGallery, saveGallery, clearGallery,
pixelsToBlob, blobToCanvas,
@@ -167,13 +171,16 @@ 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 = [];
// Graded against the bar rather than against taste: below it is a failure, and
// the band just above it is where a scene is passing by too little to trust.
const grade = (v, err) => (err ? 'broken' : v < MIN_VARIETY ? 'flat' : v < MIN_VARIETY * 2 ? 'thin' : 'good');
// 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');
function draw() {
const mode = sortSel.value;
@@ -184,17 +191,7 @@ function draw() {
: a.variety - b.variety));
out.innerHTML = '';
let drewCut = false;
for (const row of sorted) {
// One line across the whole gallery, where the sort crosses the bar.
// Everything above it repeats itself between songs.
if (mode === 'variety' && !drewCut && row.variety >= MIN_VARIETY) {
drewCut = true;
const cut = document.createElement('div');
cut.className = 'cutline';
cut.innerHTML = `<span>minimum variety ${MIN_VARIETY.toFixed(3)} — everything above repeats itself between songs</span>`;
out.appendChild(cut);
}
const el = document.createElement('div');
el.className = `row ${grade(row.variety, row.error)}`;
const blocks = Object.entries(row.byBlock || {})
@@ -203,9 +200,9 @@ function draw() {
<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 ${row.surface}" title="paints ${((row.coverage || 0) * 100).toFixed(0)}% of the frame">${row.surface || '?'} ${((row.coverage || 0) * 100).toFixed(0)}%</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><b style="left:${MIN_VARIETY * 500}%" title="minimum ${MIN_VARIETY}"></b></span>
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
${row.variety.toFixed(3)}
</span>
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
@@ -231,13 +228,18 @@ function draw() {
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 flat = rows.filter((r) => r.variety < MIN_VARIETY && !r.error).length;
const broken = rows.filter((r) => r.error).length;
return `${rows.length} scenes · ${flat} below the ${MIN_VARIETY} bar` +
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.coverage || 0) < 0.3).length} paint under 30% of the frame` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
}
async function build(fingerprint) {
@@ -432,14 +434,14 @@ async function renderSweep() {
// 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 < MIN_VARIETY ? ' weak' : '');
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 · bar ${MIN_VARIETY}</small></div>
<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>
@@ -512,6 +514,43 @@ async function main() {
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());