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:
+60
-21
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user