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:
parent
7d151be6e1
commit
89e05459c0
@ -394,3 +394,191 @@ do it once the numbers were understood, and they now are.
|
|||||||
written — direction clears the arcless reference by 0.26, which is hard to explain without
|
written — direction clears the arcless reference by 0.26, which is hard to explain without
|
||||||
a story — but it remains the only thing that would attribute the floor rise (0.1697 →
|
a story — but it remains the only thing that would attribute the floor rise (0.1697 →
|
||||||
0.2130) to this work rather than to everything else that changed alongside it.
|
0.2130) to this work rather than to everything else that changed alongside it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Composition — the frame has a floor and a ceiling
|
||||||
|
|
||||||
|
Not part of the original plan. It comes out of the same place §12 did: the layer stack was
|
||||||
|
being asked to carry the video, and half of what it was stacking was nothing.
|
||||||
|
|
||||||
|
**The floor.** Two thirds of the library is `composable` — sparse by design, elements ON
|
||||||
|
something. `canBackground` let almost all of it anchor a section anyway, so a section's
|
||||||
|
bottom layer was regularly a scene painting 2% of the frame with black behind it. Measured
|
||||||
|
across six videos, sampling the middle of every section: **9 of 40 frames were under 20%
|
||||||
|
painted, the darkest at 0.3%**. That is a minute and a half of a few bright things on black,
|
||||||
|
and no gate could see it, because every gate on the stack was a limit rather than a floor.
|
||||||
|
|
||||||
|
Every section now stands on a GROUND: a canvas painting at least half the frame, cast per
|
||||||
|
section kind so a section's shot cuts change the shot and not the world. When the shot is
|
||||||
|
itself a full canvas it *is* the ground — two canvases stacked is two pictures fighting. The
|
||||||
|
same measurement now reads **mean 92% painted, darkest 63%, nothing under 30%**, and phase 12
|
||||||
|
gates it in the image, not in the spec.
|
||||||
|
|
||||||
|
**The ceiling.** Layering had a rate but no budget, so how full a frame got was a die roll
|
||||||
|
that knew nothing about the song. A stack now has a coverage budget — director appetite
|
||||||
|
(`crowd`, 1.05 brutalist to 1.7 corrupt), times the section's energy and density, times where
|
||||||
|
the story is — capped at **200%**, two frames' worth of material. The ground and the shot are
|
||||||
|
paid for first; the budget governs what may be stacked on top, and the odds of an overlay
|
||||||
|
fall off as the headroom does rather than only at the wall.
|
||||||
|
|
||||||
|
**What it cost, and what it bought.** Same instrument, same six seeds:
|
||||||
|
|
||||||
|
```
|
||||||
|
baseline with grounds
|
||||||
|
separation 0.10 0.44
|
||||||
|
direction 0.06 0.21 (arcless reference 0.02 → 0.08)
|
||||||
|
library 80% 97% of what the library can express
|
||||||
|
scale 42% 56%
|
||||||
|
motion 68% 96%
|
||||||
|
texture 74% 90%
|
||||||
|
```
|
||||||
|
|
||||||
|
The ceiling *fell* (0.2334 → 0.2031) while observed rose, so part of that separation gain is
|
||||||
|
the reference arm coming down: videos that share a ground vocabulary are less unalike even
|
||||||
|
when they share no shots. The honest reading is that the floor rose 0.009, direction tripled,
|
||||||
|
and the ceiling moved toward the floor — the numbers are up, and not all of the rise is
|
||||||
|
signal.
|
||||||
|
|
||||||
|
**A scene that reads the previous frame cannot be a ground.** `prev()` returns the whole
|
||||||
|
composited frame, *including whatever is layered on top of this scene*, so a datamosh under a
|
||||||
|
shot is not grounding it, it is eating it. Measured the moment one became a bed: the render
|
||||||
|
stopped reproducing from a seek, and two WebGL contexts diverged by 91/255 against a ceiling
|
||||||
|
of 4 — phase 4 caught both. `readsHistory` is derived from the shader source rather than
|
||||||
|
declared, and costs the ground pool six of twenty scenes, five of them glitch.
|
||||||
|
|
||||||
|
`scenes/coverage.js` is the measured coverage of every scene, pasted back out of the phase 12
|
||||||
|
gate, which re-measures and fails on drift over a tenth of a frame. A new scene has no entry
|
||||||
|
and therefore cannot ground anything until someone renders it.
|
||||||
|
|
||||||
|
### 14.1 The measurements moved into the repo
|
||||||
|
|
||||||
|
Everything in §14 depends on a number — how much of the frame a scene paints — and the
|
||||||
|
first version of it was a table pasted into a source file by hand. That is the same mistake
|
||||||
|
`surface` already was: a measured fact written down by a person, correct on the day and
|
||||||
|
wrong after the next shader edit.
|
||||||
|
|
||||||
|
**`src/scenes/metadata.json`** is now generated, tracked in git, and holds every measured
|
||||||
|
fact about every visualizer: coverage as a shot, coverage as a bed, the variety score, the
|
||||||
|
per-block scores, and the mean structural profile. Nothing in it is typed by a human.
|
||||||
|
`surface` is derived from it (canvas at 50%, composable below) and declaring `surface:` in a
|
||||||
|
scene file is now a lint error.
|
||||||
|
|
||||||
|
It is refreshed from **gallery.html → refresh metadata**, which re-renders the library and
|
||||||
|
writes the file through a dev-only endpoint in `vite.config.js`. The file carries a
|
||||||
|
fingerprint of everything that can move a number in it — the scenes, the shader contract,
|
||||||
|
the identities and palettes they are handed, the descriptor definitions — and phase 12 fails
|
||||||
|
when that stops matching. A stale metadata file does not produce a stale report, it produces
|
||||||
|
wrong videos, so it has to be detectable.
|
||||||
|
|
||||||
|
**Measuring at the wrong bias measures nothing.** Coverage is mostly a function of a scene's
|
||||||
|
parameters, and the first measurement took one number per scene at the busiest section of
|
||||||
|
six songs. Metaballs measures 69% there and painted **6%** as an intro's ground. Three
|
||||||
|
things came out of chasing that, in order of how much each was worth:
|
||||||
|
|
||||||
|
* A bed is drawn with a **moderate hand**. `extremity` pushes parameters toward the ends of
|
||||||
|
their ranges, and half of what decides coverage is a parameter with no `bias` key at all —
|
||||||
|
Metaballs' `threshold` — so extremity is the only thing moving it and one end is an empty
|
||||||
|
frame. Grounds sample at `extremity * 0.2`. The extremes belong to the shot.
|
||||||
|
* Energy and density are **floors** for a ground, not reductions. Calming the bed by
|
||||||
|
lowering energy is how to empty it: scenes bias their fill against `energy` as often as
|
||||||
|
against `density`.
|
||||||
|
* Coverage is measured **twice**, once as a shot and once as a bed, at the same bias and
|
||||||
|
temperament the generator will use. `GROUND_BIAS` and `groundTemperamentFrom` live in
|
||||||
|
`scenes/surface.js` and are imported by both the generator and the measuring pass, so the
|
||||||
|
two cannot drift apart.
|
||||||
|
|
||||||
|
Rendered result across six videos, sampling the middle of every section: **mean 87% painted,
|
||||||
|
darkest 30%**, against 51%/0.3% before any of this.
|
||||||
|
|
||||||
|
**Eleven scenes can ground a section** — five organic, five geometric, one structural. There
|
||||||
|
is no `minimal`, `flow` or `glitch` canvas in the library that paints half the frame without
|
||||||
|
reading `prev()`, so every quiet section in every video stands on one of five organic beds.
|
||||||
|
That is the largest remaining hole and it is library work, not generator work: the fix is
|
||||||
|
writing dense, self-contained canvases in the three thin families.
|
||||||
|
|
||||||
|
### 14.2 The red line is gone
|
||||||
|
|
||||||
|
`MIN_VARIETY` — 0.1, drawn across the gallery, "below this a scene is the same picture
|
||||||
|
wherever it appears" — was right when a section was one scene and is wrong now. A section is
|
||||||
|
a ground, a shot on it and sometimes a pass over that, so what a viewer sees is a
|
||||||
|
combination, and a scene that is reliably itself is a good ingredient in one. Nineteen
|
||||||
|
scenes were failing a bar for being consistent.
|
||||||
|
|
||||||
|
What replaced it is a level up, and it is the reason the profiles are in the metadata: the
|
||||||
|
generator weights every layering choice by **structural distance** — how unalike two scenes
|
||||||
|
measured, on the same descriptor the variety harness compares videos with. Family labels and
|
||||||
|
the render disagree often enough to matter; two `geometric` scenes can be 0.31 apart and a
|
||||||
|
`flow` and an `organic` scene 0.04, and stacking the second pair is one picture at double
|
||||||
|
density. Measured over 436 stacked pairs: **mean distance 0.268, zero near-twins**.
|
||||||
|
|
||||||
|
### 14.3 The other end of the frame
|
||||||
|
|
||||||
|
The floor had a ceiling missing. Measured across twelve videos, sampling the middle of every
|
||||||
|
section: **a median 24% of every frame was clipped to pure white, and whole sections
|
||||||
|
rendered at 100%.** The videos were washed out, and no gate could see it because every guard
|
||||||
|
on the frame asked whether there was enough in it.
|
||||||
|
|
||||||
|
Three causes, all introduced or exposed by §14, in the order they were found:
|
||||||
|
|
||||||
|
**The shot was screened over its ground.** Screen is a lightening operator — correct for a
|
||||||
|
few bright elements over a bed, and over a filled canvas it drives everything toward white.
|
||||||
|
Replaced with a **lumakey** blend (`passes.js`): the shot's own brightness is its alpha, so
|
||||||
|
it replaces the ground where it paints and leaves it where it does not, keeping its own
|
||||||
|
colour instead of adding it to the bed's. Median clipping 24% → 11%.
|
||||||
|
|
||||||
|
**The feedback loop was an accumulator.** `cur + hist * decay * amount` settles a still
|
||||||
|
image at `1/(1 - decay*amount)` times its drawn brightness — **2.3x** at the settings the
|
||||||
|
generator hands out. Survivable when a frame was a few bright things on black; fatal the
|
||||||
|
moment every section stood on a filled ground. Turning feedback off took a blown frame from
|
||||||
|
100% to 34% mean luminance, which is the whole diagnosis in one number. It now divides by
|
||||||
|
the same gain, at 0.6 rather than 1.0: full normalisation took the lift out along with the
|
||||||
|
blowout — the median frame fell from 95% painted to 71% and fourteen sections dropped
|
||||||
|
through the black-frame floor.
|
||||||
|
|
||||||
|
**Nothing rolled off.** The grade clipped. A **highlight shoulder** now compresses everything
|
||||||
|
above 0.75 toward but never to 1. Clipped white is not brightness, it is missing information:
|
||||||
|
every difference inside it has been deleted.
|
||||||
|
|
||||||
|
Rendered, after all three — 74 sections, twelve videos:
|
||||||
|
|
||||||
|
```
|
||||||
|
before after
|
||||||
|
painted 51% mean 88% mean, darkest 43% (was darkest 0.3%)
|
||||||
|
clipped white 24% median 0% median, worst 21% (was worst 100%)
|
||||||
|
luminance 77% median 42% median, worst 80%
|
||||||
|
```
|
||||||
|
|
||||||
|
The gate is now two-ended: `composition · a rendered section is neither black nor blown out`.
|
||||||
|
|
||||||
|
**A ground has to survive the song's identity.** Chasing the dark end turned up the same
|
||||||
|
class of error as §14.1: a ground measured at 61% painted **2%** in one particular video,
|
||||||
|
because that song's ink treatment is `hollow` — outlines, no fill. Two changes came out of
|
||||||
|
it. The world is now drawn solid and the song's hand is kept for the subject
|
||||||
|
(`groundPersonalityFrom`), and eligibility takes the **worst** identity into account as well
|
||||||
|
as the mean: a ground fills the frame on average and never vanishes. Holding the worst case
|
||||||
|
to the full 50% would leave *four* castable beds in the library, which is a worse video than
|
||||||
|
an occasionally dim intro.
|
||||||
|
|
||||||
|
**Seven scenes can ground a section** — five geometric, two organic. Every quiet section of
|
||||||
|
every video stands on one of two beds. Restated from §14.1 because it got worse, not better:
|
||||||
|
this is the library's largest hole.
|
||||||
|
|
||||||
|
### 14.4 Blazing on purpose
|
||||||
|
|
||||||
|
Screening the shot over its ground and letting the two brightnesses sum is a real effect — a
|
||||||
|
drop that goes to paper for eight bars reads as the song peaking. The mistake in §14.3 was
|
||||||
|
not the effect, it was that the effect was the **default**: every section did it, so a median
|
||||||
|
quarter of every frame in every video was clipped, and nothing about that said "peak".
|
||||||
|
|
||||||
|
It is a decision now. Each director declares a `blaze` appetite — brutalist 0.05 (mass does
|
||||||
|
not glow), corrupt 0.5 (overload is the subject) — and a section still has to earn one: loud,
|
||||||
|
and late in the story. Quiet kinds never blaze, because a breakdown that goes white is not a
|
||||||
|
choice, it is a bug with a rationale. The default composite is the lumakey, which keeps the
|
||||||
|
shot's own colour.
|
||||||
|
|
||||||
|
The ceiling gate matches the intent rather than banning brightness. Per section, only a hard
|
||||||
|
cap — past about half the frame at full white there is no picture left to read. Across the
|
||||||
|
population, **no more than a fifth of sections may be hot at all**, which is the number that
|
||||||
|
actually distinguishes a director choosing to peak from a pipeline with no headroom.
|
||||||
|
Measured now: **1 of 74 sections blazing**, worst 30% clipped.
|
||||||
|
|||||||
@ -28,16 +28,23 @@ checks.html?scene=X the per-scene gate, including `consumes:` lines
|
|||||||
```
|
```
|
||||||
|
|
||||||
The gallery score is the mean structural distance between a scene's own six
|
The gallery score is the mean structural distance between a scene's own six
|
||||||
frames, on the same descriptor the variety harness uses. **Minimum bar: 0.1**
|
frames, on the same descriptor the variety harness uses. High means the song
|
||||||
(`MIN_VARIETY` in `checks/gallery.js`), drawn as a red line. Below it a scene is
|
changes this scene a lot; low means it looks like itself wherever it is cast.
|
||||||
the same picture wherever it appears, and because it is cast into many songs
|
|
||||||
that sameness leaks between them — the viewer recognises the shot rather than
|
|
||||||
the song.
|
|
||||||
|
|
||||||
Most of the library does not clear it. That is deliberate: a bar set where the
|
**There is no bar.** There used to be one — 0.1, drawn across the gallery as a
|
||||||
work already is measures nothing.
|
red line — and it was removed when composition arrived. A section is no longer
|
||||||
|
one scene: it is a ground, a shot standing on it, and sometimes a pass over
|
||||||
|
that, so what a viewer sees is a COMBINATION. A scene that is reliably itself is
|
||||||
|
a perfectly good ingredient in one, and the old line failed it for being
|
||||||
|
consistent.
|
||||||
|
|
||||||
Some reference points, measured:
|
What replaced it is a level up. The interesting quantity is how unalike the
|
||||||
|
scenes in one stack are, and the generator now weights its choices by exactly
|
||||||
|
that — see `structuralDistance` in `scenes/surface.js`. Your scene does not have
|
||||||
|
to be varied on its own. It has to be unlike the things it will be stacked with,
|
||||||
|
and that is measured for you.
|
||||||
|
|
||||||
|
Some reference points, measured across six songs:
|
||||||
|
|
||||||
```
|
```
|
||||||
0.324 Droste Feedback varies a lot
|
0.324 Droste Feedback varies a lot
|
||||||
@ -47,6 +54,29 @@ Some reference points, measured:
|
|||||||
0.047 Moiré Grid after a subject; 0.031 before
|
0.047 Moiré Grid after a subject; 0.031 before
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The measured metadata
|
||||||
|
|
||||||
|
`src/scenes/metadata.json` is generated, tracked in git, and holds every
|
||||||
|
measured fact about every scene: coverage, variety, per-block scores and the
|
||||||
|
structural profile the distance above is computed from. Nothing in it is
|
||||||
|
hand-written, and two decisions read it:
|
||||||
|
|
||||||
|
* **`surface`** — canvas at 50% coverage or more, composable below. It used to
|
||||||
|
be declared per scene and it drifted; nine scenes claimed `canvas` while
|
||||||
|
painting under a third of the frame. Declaring `surface:` in a scene file is
|
||||||
|
now an error.
|
||||||
|
* **which scenes may be a GROUND** — a canvas that does not call `prev()`, fills the frame
|
||||||
|
when sampled as a bed, and does not vanish under any of the six identities. Coverage is
|
||||||
|
measured twice, once as a shot and once as a bed, because a scene paints wildly different
|
||||||
|
amounts at different parameters: Metaballs is 69% as a drop's shot and 6% as an intro's
|
||||||
|
ground.
|
||||||
|
|
||||||
|
Refresh it from **gallery.html → refresh metadata** after changing a shader or a
|
||||||
|
metric. The file carries a fingerprint of the scenes, the shader contract, the
|
||||||
|
identities and palettes, and the descriptor definitions; when that stops
|
||||||
|
matching, phase 12 fails and tells you to re-measure. The write goes through a
|
||||||
|
dev-only endpoint in `vite.config.js`, so it only works under `npm run dev`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What the descriptor can and cannot see
|
## What the descriptor can and cannot see
|
||||||
|
|||||||
@ -126,6 +126,7 @@
|
|||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
<span id="libctl">
|
<span id="libctl">
|
||||||
<button id="rebuild">rebuild</button>
|
<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">
|
<select id="sort">
|
||||||
<option value="variety">sort: least varied first</option>
|
<option value="variety">sort: least varied first</option>
|
||||||
<option value="name">sort: by name</option>
|
<option value="name">sort: by name</option>
|
||||||
@ -152,7 +153,10 @@
|
|||||||
<div id="out"></div>
|
<div id="out"></div>
|
||||||
|
|
||||||
<script type="module">
|
<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 {
|
import {
|
||||||
sourceFingerprint, loadGallery, saveGallery, clearGallery,
|
sourceFingerprint, loadGallery, saveGallery, clearGallery,
|
||||||
pixelsToBlob, blobToCanvas,
|
pixelsToBlob, blobToCanvas,
|
||||||
@ -167,13 +171,16 @@ const status = document.getElementById('status');
|
|||||||
const sortSel = document.getElementById('sort');
|
const sortSel = document.getElementById('sort');
|
||||||
const progress = document.querySelector('#progress i');
|
const progress = document.querySelector('#progress i');
|
||||||
const rebuildBtn = document.getElementById('rebuild');
|
const rebuildBtn = document.getElementById('rebuild');
|
||||||
|
const refreshBtn = document.getElementById('refresh');
|
||||||
|
|
||||||
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
|
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
|
||||||
let contexts = [];
|
let contexts = [];
|
||||||
|
|
||||||
// Graded against the bar rather than against taste: below it is a failure, and
|
// There is no bar any more — see checks/gallery.js for why the 0.1 red line
|
||||||
// the band just above it is where a scene is passing by too little to trust.
|
// went. What is left is a broken/working distinction and a relative shading, so
|
||||||
const grade = (v, err) => (err ? 'broken' : v < MIN_VARIETY ? 'flat' : v < MIN_VARIETY * 2 ? 'thin' : 'good');
|
// 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() {
|
function draw() {
|
||||||
const mode = sortSel.value;
|
const mode = sortSel.value;
|
||||||
@ -184,17 +191,7 @@ function draw() {
|
|||||||
: a.variety - b.variety));
|
: a.variety - b.variety));
|
||||||
|
|
||||||
out.innerHTML = '';
|
out.innerHTML = '';
|
||||||
let drewCut = false;
|
|
||||||
for (const row of sorted) {
|
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');
|
const el = document.createElement('div');
|
||||||
el.className = `row ${grade(row.variety, row.error)}`;
|
el.className = `row ${grade(row.variety, row.error)}`;
|
||||||
const blocks = Object.entries(row.byBlock || {})
|
const blocks = Object.entries(row.byBlock || {})
|
||||||
@ -203,9 +200,9 @@ function draw() {
|
|||||||
<div class="head">
|
<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="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="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="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)}
|
${row.variety.toFixed(3)}
|
||||||
</span>
|
</span>
|
||||||
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
|
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
|
||||||
@ -231,13 +228,18 @@ function draw() {
|
|||||||
|
|
||||||
sortSel.addEventListener('change', 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) {
|
function summarise(built) {
|
||||||
const flat = rows.filter((r) => r.variety < MIN_VARIETY && !r.error).length;
|
|
||||||
const broken = rows.filter((r) => 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` : '') +
|
(broken ? ` · ${broken} BROKEN` : '') +
|
||||||
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
|
` · ${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) {
|
async function build(fingerprint) {
|
||||||
@ -432,14 +434,14 @@ async function renderSweep() {
|
|||||||
|
|
||||||
// Three numbers rather than one: which AXIS is flat is the thing the
|
// 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.
|
// 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
|
const twin = result.closest
|
||||||
? `${cells[result.closest.a].name} ≈ ${cells[result.closest.b].name} at ${result.closest.distance.toFixed(3)}`
|
? `${cells[result.closest.a].name} ≈ ${cells[result.closest.b].name} at ${result.closest.distance.toFixed(3)}`
|
||||||
: '—';
|
: '—';
|
||||||
sweepInfo.innerHTML = `
|
sweepInfo.innerHTML = `
|
||||||
<div class="axes">
|
<div class="axes">
|
||||||
<div class="${weak(result.variety)}">overall <b>${result.variety.toFixed(3)}</b>
|
<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>
|
<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>
|
<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>
|
<div class="${weak(result.bySong)}">across songs <b>${result.bySong.toFixed(3)}</b>
|
||||||
@ -512,6 +514,43 @@ async function main() {
|
|||||||
await build(fingerprint);
|
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 () => {
|
rebuildBtn.addEventListener('click', async () => {
|
||||||
await clearGallery();
|
await clearGallery();
|
||||||
await build(sourceFingerprint());
|
await build(sourceFingerprint());
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { generateLook, rerollLook, rerollSection } from './look/LookGenerator.js
|
|||||||
import { ArcDriver } from './look/ArcDriver.js';
|
import { ArcDriver } from './look/ArcDriver.js';
|
||||||
import { grainEnvelope } from './look/grain.js';
|
import { grainEnvelope } from './look/grain.js';
|
||||||
import { hashSamples } from './engine/rng.js';
|
import { hashSamples } from './engine/rng.js';
|
||||||
|
import { subjectOf } from './look/stack.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The opening and closing fade to black. Exported because anything SAMPLING a
|
* The opening and closing fade to black. Exported because anything SAMPLING a
|
||||||
@ -45,8 +46,12 @@ export class Show {
|
|||||||
* Decode, analyse, and generate a look. `onProgress(stage, fraction)` is
|
* Decode, analyse, and generate a look. `onProgress(stage, fraction)` is
|
||||||
* called throughout; analysis is CPU-bound and will block the main thread
|
* called throughout; analysis is CPU-bound and will block the main thread
|
||||||
* for a second or two on a long track.
|
* for a second or two on a long track.
|
||||||
|
*
|
||||||
|
* `seed` replaces the audio-derived one. Only the debug pages pass it, and
|
||||||
|
* only so that watching a song reproduces the look they measured — the
|
||||||
|
* filmstrip seeds off the song's name, not off its samples.
|
||||||
*/
|
*/
|
||||||
async load(file, onProgress = null) {
|
async load(file, onProgress = null, { seed = null } = {}) {
|
||||||
const report = (stage, p) => onProgress && onProgress(stage, p);
|
const report = (stage, p) => onProgress && onProgress(stage, p);
|
||||||
|
|
||||||
report('decoding', 0);
|
report('decoding', 0);
|
||||||
@ -64,7 +69,9 @@ export class Show {
|
|||||||
|
|
||||||
report('look', 0.97);
|
report('look', 0.97);
|
||||||
const samples = monoSamples(audioBuffer);
|
const samples = monoSamples(audioBuffer);
|
||||||
this.setLook(generateLook(this.track, { samples }));
|
this.setLook(seed === null
|
||||||
|
? generateLook(this.track, { samples })
|
||||||
|
: generateLook(this.track, { seed }));
|
||||||
|
|
||||||
this.engine.timeline.setDuration(this.track.duration);
|
this.engine.timeline.setDuration(this.track.duration);
|
||||||
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
||||||
@ -76,12 +83,22 @@ export class Show {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attach an already-analysed track. Used by the check harness and by tests. */
|
/**
|
||||||
|
* Attach an already-analysed track. Used by the check harness and by tests.
|
||||||
|
*
|
||||||
|
* Prewarms, like every other path that installs a look. It was the one that
|
||||||
|
* did not, and that was invisible while most sections were a single layer:
|
||||||
|
* with a ground under every shot there are always at least two programs to
|
||||||
|
* link, and the first frame a fresh Show rendered came out different from
|
||||||
|
* every later render of it — the exact hazard Compositor.prime documents,
|
||||||
|
* caught by the phase 4 first-render check.
|
||||||
|
*/
|
||||||
useTrack(track, look) {
|
useTrack(track, look) {
|
||||||
this.track = track;
|
this.track = track;
|
||||||
this.engine.timeline.setDuration(track.duration);
|
this.engine.timeline.setDuration(track.duration);
|
||||||
this.engine.setFeatureProvider(featureProviderFor(track));
|
this.engine.setFeatureProvider(featureProviderFor(track));
|
||||||
this.setLook(look || generateLook(track, { seed: 1 }));
|
this.setLook(look || generateLook(track, { seed: 1 }));
|
||||||
|
this.prewarm();
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,7 +133,7 @@ export class Show {
|
|||||||
setSectionParam(sectionIndex, name, value) {
|
setSectionParam(sectionIndex, name, value) {
|
||||||
const section = this.look.sections[sectionIndex];
|
const section = this.look.sections[sectionIndex];
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
section.layers[0].params[name] = value;
|
subjectOf(section.layers).params[name] = value;
|
||||||
this._lastLayers = null;
|
this._lastLayers = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
flow-state/src/checks/blit.js
Normal file
21
flow-state/src/checks/blit.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* WebGL readback into a canvas, the right way up.
|
||||||
|
*
|
||||||
|
* Its own module because two debug pages need it and neither should pull in the
|
||||||
|
* other's dependencies to get it. It lived in checks/gallery.js, and importing
|
||||||
|
* it from the filmstrip dragged the entire gallery — the engine, the song bank,
|
||||||
|
* the look generator, the whole scene library — into a page that wanted ten
|
||||||
|
* lines of pixel copying.
|
||||||
|
*/
|
||||||
|
export function blit(canvas, pixels, width, height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const image = ctx.createImageData(width, height);
|
||||||
|
const row = width * 4;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const src = (height - 1 - y) * row;
|
||||||
|
image.data.set(pixels.subarray(src, src + row), y * row);
|
||||||
|
}
|
||||||
|
ctx.putImageData(image, 0, 0);
|
||||||
|
}
|
||||||
@ -16,8 +16,9 @@
|
|||||||
// looking for the repetitive ones is exactly the job a sort order should do.
|
// looking for the repetitive ones is exactly the job a sort order should do.
|
||||||
|
|
||||||
import { Engine } from '../engine/Engine.js';
|
import { Engine } from '../engine/Engine.js';
|
||||||
|
import { blit } from './blit.js';
|
||||||
import { scenes } from '../scenes/registry.js';
|
import { scenes } from '../scenes/registry.js';
|
||||||
import { sampleValues, surfaceOf } from '../params/schema.js';
|
import { sampleValues } from '../params/schema.js';
|
||||||
import { Rng, hashString } from '../engine/rng.js';
|
import { Rng, hashString } from '../engine/rng.js';
|
||||||
import { featureProviderFor } from '../audio/FeatureTrack.js';
|
import { featureProviderFor } from '../audio/FeatureTrack.js';
|
||||||
import { songBank } from '../audio/songbank.js';
|
import { songBank } from '../audio/songbank.js';
|
||||||
@ -29,28 +30,23 @@ import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
|
|||||||
|
|
||||||
const THUMB = { width: 256, height: 144 };
|
const THUMB = { width: 256, height: 144 };
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* The floor a visualizer has to clear: how different its own six frames must be
|
* There used to be a MIN_VARIETY floor here — 0.1, drawn as a red line across
|
||||||
* from each other, across six songs' identities.
|
* the gallery — on the theory that a scene which looks the same in every song
|
||||||
|
* leaks that sameness between videos.
|
||||||
*
|
*
|
||||||
* Below this a scene is the same picture wherever it appears, and because it is
|
* That was true when a section was ONE scene. It is not any more: a section is
|
||||||
* cast into many songs that repetition leaks between them — the viewer who
|
* a ground, a shot over it and sometimes a pass over that, so what a viewer
|
||||||
* watches two videos recognises the shot rather than the song. It is a property
|
* sees is a combination, and a scene that is reliably itself is a perfectly
|
||||||
* of the scene, not of the generator, so it is the scene that has to be lifted.
|
* good ingredient in one. Held against a floor, such scenes were failing for
|
||||||
|
* being consistent.
|
||||||
*
|
*
|
||||||
* 0.1, and deliberately a target rather than a description of where the library
|
* The score is still measured, still reported, and still what the gallery sorts
|
||||||
* currently sits. Most of it does not clear this — of the scenes measured after
|
* by — it is genuinely the right question to ask about a scene you are working
|
||||||
* the region block went in, only Droste Feedback at 0.324 is comfortably over,
|
* on. It is no longer a bar anything has to clear, and the interesting quantity
|
||||||
* with Plasma Bloom at 0.082 and Voronoi Shatter at 0.062 still short. A bar set
|
* moved up a level: how unalike the scenes in one stack are. That lives in
|
||||||
* where the work already is measures nothing.
|
* scenes/metadata.json and is read by the look generator.
|
||||||
*
|
|
||||||
* It has moved twice for instrument reasons rather than taste — the motion block
|
|
||||||
* was reading zero for the whole library, and adding the region block made the
|
|
||||||
* total a mean over six rather than five — and both raised every score. Expect
|
|
||||||
* to re-read it off a fresh gallery whenever the descriptor changes, rather than
|
|
||||||
* carrying the old number forward.
|
|
||||||
*/
|
*/
|
||||||
export const MIN_VARIETY = 0.1;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Six contexts, one per song: everything a scene is handed when it is cast.
|
* Six contexts, one per song: everything a scene is handed when it is cast.
|
||||||
@ -78,20 +74,6 @@ export function galleryContexts(count = 6) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom-up WebGL pixels into a canvas the right way up. */
|
|
||||||
function blit(canvas, pixels, width, height) {
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const image = ctx.createImageData(width, height);
|
|
||||||
const row = width * 4;
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
const src = (height - 1 - y) * row;
|
|
||||||
image.data.set(pixels.subarray(src, src + row), y * row);
|
|
||||||
}
|
|
||||||
ctx.putImageData(image, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render one scene across every context.
|
* Render one scene across every context.
|
||||||
*
|
*
|
||||||
@ -175,9 +157,14 @@ export function renderScene(engine, module, contexts) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
thumbs,
|
thumbs,
|
||||||
|
// The six per-context descriptors, so a caller can average them into a
|
||||||
|
// profile rather than re-render the library to get one. See
|
||||||
|
// checks/metadata.js — this function is the only place the scenes are
|
||||||
|
// rendered under real identities, and everything measured about a scene
|
||||||
|
// comes out of here.
|
||||||
|
descriptors,
|
||||||
variety: pairs ? total / pairs : 0,
|
variety: pairs ? total / pairs : 0,
|
||||||
coverage: covered,
|
coverage: covered,
|
||||||
surface: surfaceOf(module),
|
|
||||||
byBlock,
|
byBlock,
|
||||||
error: dead
|
error: dead
|
||||||
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
|
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
|
||||||
|
|||||||
217
flow-state/src/checks/metadata.js
Normal file
217
flow-state/src/checks/metadata.js
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
// Measuring every visualizer, and writing the answers back into the repo.
|
||||||
|
//
|
||||||
|
// The library has always had two kinds of fact about a scene. Declared ones —
|
||||||
|
// family, traits, `consumes` — which say what the scene is FOR, and measured
|
||||||
|
// ones, which say what it actually does when rendered. Declared facts belong in
|
||||||
|
// the scene file. Measured facts do not: hand-written, they drift the moment a
|
||||||
|
// shader changes, and nine scenes declaring `surface: 'canvas'` while painting
|
||||||
|
// under a third of the frame is what that drift looks like.
|
||||||
|
//
|
||||||
|
// So the measured half lives in scenes/metadata.json, generated from here,
|
||||||
|
// tracked in git, and stamped with a fingerprint of everything that could
|
||||||
|
// change it. When the fingerprint stops matching, the numbers are stale and the
|
||||||
|
// phase 12 gate says so — the file is a cache of a render, and a cache nobody
|
||||||
|
// can tell is stale is worse than no cache.
|
||||||
|
//
|
||||||
|
// The measurement is the GALLERY's: six songs, sampled parameters, real
|
||||||
|
// identities and palettes — a scene as it is actually cast, not as it renders
|
||||||
|
// at default parameters. The difference is not academic. Salt Flat paints 65%
|
||||||
|
// of the frame at defaults and 32% across six real songs, and the generator
|
||||||
|
// chooses grounds with this number.
|
||||||
|
|
||||||
|
import { Engine } from '../engine/Engine.js';
|
||||||
|
import { scenes } from '../scenes/registry.js';
|
||||||
|
import { galleryContexts, renderScene, THUMB } from './gallery.js';
|
||||||
|
import { readsHistory } from '../params/schema.js';
|
||||||
|
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
|
||||||
|
import metadata from '../scenes/metadata.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What invalidates the measurements.
|
||||||
|
*
|
||||||
|
* Deliberately NOT every file under src/, which is what the gallery cache
|
||||||
|
* fingerprints: that changes when the UI changes, and it would mark the
|
||||||
|
* metadata stale for edits that cannot move a single number. What can move one
|
||||||
|
* is the scenes themselves, the contract they are compiled against, the
|
||||||
|
* identities and palettes they are handed, and the metric definitions — so
|
||||||
|
* those, and nothing else.
|
||||||
|
*
|
||||||
|
* Globbed rather than listed wherever a whole directory qualifies, because the
|
||||||
|
* file that invalidates a measurement is exactly the one nobody remembers to
|
||||||
|
* add to a list.
|
||||||
|
*/
|
||||||
|
const SOURCES = {
|
||||||
|
...import.meta.glob('/src/scenes/**/*.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/checks/variety/descriptors.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/checks/variety/signature.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/checks/gallery.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/checks/metadata.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/engine/shader-contract.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
...import.meta.glob('/src/audio/songbank.js', { query: '?raw', import: 'default', eager: true }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The version of the measurement itself. Bump to force a refresh of everything. */
|
||||||
|
export const SCHEMA = 4;
|
||||||
|
|
||||||
|
export function metricsFingerprint() {
|
||||||
|
let h = 0x811c9dc5 >>> 0;
|
||||||
|
const mix = (str) => {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mix(`schema:${SCHEMA}`);
|
||||||
|
for (const path of Object.keys(SOURCES).sort()) {
|
||||||
|
mix(path);
|
||||||
|
mix(SOURCES[path]);
|
||||||
|
}
|
||||||
|
return h.toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the checked-in metadata was measured from the code that is here now. */
|
||||||
|
export function metadataIsFresh() {
|
||||||
|
return metadata.fingerprint === metricsFingerprint();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anything a viewer would read as painted rather than as backdrop. */
|
||||||
|
function litFraction(pixels) {
|
||||||
|
let lit = 0;
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 90) lit++;
|
||||||
|
}
|
||||||
|
return lit / (pixels.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = (x, places = 4) => Number(x.toFixed(places));
|
||||||
|
|
||||||
|
/** Mean of the six per-context descriptors, block by block. */
|
||||||
|
function meanProfile(descriptors) {
|
||||||
|
const out = {};
|
||||||
|
for (const block of Object.keys(descriptors[0])) {
|
||||||
|
const length = descriptors[0][block].length;
|
||||||
|
const acc = new Array(length).fill(0);
|
||||||
|
for (const d of descriptors) {
|
||||||
|
for (let i = 0; i < length; i++) acc[i] += d[block][i] / descriptors.length;
|
||||||
|
}
|
||||||
|
out[block] = acc.map((v) => round(v));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the whole library.
|
||||||
|
*
|
||||||
|
* Per scene: how much frame it paints, how much it changes between songs, and
|
||||||
|
* its mean structural profile — the descriptor the variety harness compares
|
||||||
|
* videos with, averaged over the six renders. The profile is what makes this
|
||||||
|
* more than a list of numbers: two profiles can be compared, so the generator
|
||||||
|
* can ask whether a shot and the thing under it are the same picture twice.
|
||||||
|
*
|
||||||
|
* @returns {object} the metadata file's contents
|
||||||
|
*/
|
||||||
|
export function measureLibrary({ onScene = null, contexts = null } = {}) {
|
||||||
|
const ctx = contexts || galleryContexts(6);
|
||||||
|
// The same six songs, sampled the way a BED is. Coverage is mostly a
|
||||||
|
// function of a scene's parameters, so "how much does this paint" has two
|
||||||
|
// answers and the generator needs both: one for the budget, and one for
|
||||||
|
// whether it may be a ground at all. See GROUND_BIAS.
|
||||||
|
const bedCtx = ctx.map((c) => ({
|
||||||
|
...c,
|
||||||
|
bias: { ...c.bias, ...GROUND_BIAS },
|
||||||
|
personality: {
|
||||||
|
...groundPersonalityFrom(c.personality),
|
||||||
|
temperament: groundTemperamentFrom(c.personality.temperament),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const engine = new Engine({ ...THUMB });
|
||||||
|
const out = {};
|
||||||
|
try {
|
||||||
|
const list = scenes.filter((m) => m.kind === 'fragment');
|
||||||
|
for (const module of list) {
|
||||||
|
const { thumbs, variety, byBlock, descriptors, error } = renderScene(engine, module, ctx);
|
||||||
|
const bed = renderScene(engine, module, bedCtx);
|
||||||
|
out[module.name] = {
|
||||||
|
coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
|
||||||
|
// The WORST of the six, not the mean. What a ground has to
|
||||||
|
// promise is a filled frame in the video it lands in, and the
|
||||||
|
// spread across identities is enormous: a track whose ink
|
||||||
|
// treatment is `hollow` draws outlines instead of fills, so a
|
||||||
|
// scene that paints 61% averaged over six songs paints 2% in
|
||||||
|
// the one that asked for outlines — measured, and it is how a
|
||||||
|
// section with a ground under it still rendered near-black.
|
||||||
|
// A mean cannot make a promise; a minimum can.
|
||||||
|
bedCoverage: round(Math.min(...bed.thumbs.map(litFraction)), 3),
|
||||||
|
bedCoverageMean: round(
|
||||||
|
bed.thumbs.reduce((s, px) => s + litFraction(px), 0) / bed.thumbs.length, 3),
|
||||||
|
variety: round(variety, 3),
|
||||||
|
blocks: Object.fromEntries(
|
||||||
|
Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
|
||||||
|
// Declared, not measured, and carried here anyway: it is a fact
|
||||||
|
// about the scene that the composition rules read, and having
|
||||||
|
// every compositional input in one file is the point.
|
||||||
|
readsHistory: readsHistory(module),
|
||||||
|
profile: meanProfile(descriptors),
|
||||||
|
...(error ? { error } : {}),
|
||||||
|
};
|
||||||
|
if (onScene) onScene(Object.keys(out).length, list.length, module.name);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
engine.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: metricsFingerprint(),
|
||||||
|
schema: SCHEMA,
|
||||||
|
measured: new Date().toISOString().slice(0, 10),
|
||||||
|
contexts: ctx.map((c) => c.name),
|
||||||
|
scenes: out,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What moved against the checked-in file. */
|
||||||
|
export function metadataDrift(fresh) {
|
||||||
|
// Tolerates a missing or half-written file on purpose: this runs on the way
|
||||||
|
// to REPLACING it, and refusing to report because the thing being replaced
|
||||||
|
// is malformed is the least useful moment to be strict.
|
||||||
|
const previous = (metadata && metadata.scenes) || {};
|
||||||
|
const moved = [];
|
||||||
|
for (const [name, row] of Object.entries(fresh.scenes)) {
|
||||||
|
const was = previous[name];
|
||||||
|
if (!was) {
|
||||||
|
moved.push({ name, note: 'new' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const delta = row.coverage - was.coverage;
|
||||||
|
if (Math.abs(delta) > 0.02) {
|
||||||
|
moved.push({
|
||||||
|
name, note: `coverage ${(was.coverage * 100).toFixed(0)}% → ${(row.coverage * 100).toFixed(0)}%`,
|
||||||
|
delta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
|
||||||
|
return {
|
||||||
|
moved: moved.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0)),
|
||||||
|
gone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the dev server to write the file back into the source tree.
|
||||||
|
*
|
||||||
|
* Dev-only by construction — the endpoint is a middleware in vite.config.js. A
|
||||||
|
* built page has no source tree to write to, and failing there is correct.
|
||||||
|
*/
|
||||||
|
export async function writeMetadata(fresh) {
|
||||||
|
const response = await fetch('/__metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(fresh, null, 2) + '\n',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
@ -37,6 +37,7 @@ import { frameDistance, frameLuminance } from '../engine/hash.js';
|
|||||||
import { battery as timbreBattery } from './phase3.js';
|
import { battery as timbreBattery } from './phase3.js';
|
||||||
import { grainEnvelope } from '../look/grain.js';
|
import { grainEnvelope } from '../look/grain.js';
|
||||||
import { signatureUniforms } from '../look/Personality.js';
|
import { signatureUniforms } from '../look/Personality.js';
|
||||||
|
import { subjectOf, overlaysOf } from '../look/stack.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Several tracks that genuinely differ in what they sound like — the population
|
* Several tracks that genuinely differ in what they sound like — the population
|
||||||
@ -124,8 +125,8 @@ check(10, 'one scene looks different in two different videos', () => {
|
|||||||
// track's temperament — the question is what this scene WOULD
|
// track's temperament — the question is what this scene WOULD
|
||||||
// look like in that video, and falling back to defaults would
|
// look like in that video, and falling back to defaults would
|
||||||
// compare two identical parameter sets and prove nothing.
|
// compare two identical parameter sets and prove nothing.
|
||||||
const stack = stacksOf(look).find((s) => s[0].module === module);
|
const stack = stacksOf(look).find((s) => subjectOf(s).module === module);
|
||||||
const params = stack ? stack[0].params : sampleValues(
|
const params = stack ? subjectOf(stack).params : sampleValues(
|
||||||
module,
|
module,
|
||||||
new Rng(look.seed ^ 0x51ed270b),
|
new Rng(look.seed ^ 0x51ed270b),
|
||||||
look.sections[0].bias,
|
look.sections[0].bias,
|
||||||
@ -187,7 +188,7 @@ check(10, 'overlays happen sometimes and not always', () => {
|
|||||||
const look = generateLook(track, { seed: 900 + s * 5231 });
|
const look = generateLook(track, { seed: 900 + s * 5231 });
|
||||||
for (const stack of stacksOf(look)) {
|
for (const stack of stacksOf(look)) {
|
||||||
stacks++;
|
stacks++;
|
||||||
const overlay = stack[1];
|
const overlay = overlaysOf(stack)[0];
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
withOverlay++;
|
withOverlay++;
|
||||||
blends.add(overlay.blend);
|
blends.add(overlay.blend);
|
||||||
@ -214,7 +215,7 @@ check(10, 'an overlay never hides the shot underneath it', () => {
|
|||||||
for (const { track } of battery()) {
|
for (const { track } of battery()) {
|
||||||
for (let s = 0; s < 4; s++) {
|
for (let s = 0; s < 4; s++) {
|
||||||
for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) {
|
for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) {
|
||||||
for (const layer of stack.slice(1)) {
|
for (const layer of overlaysOf(stack)) {
|
||||||
if (layer.opacity > 0.6) {
|
if (layer.opacity > 0.6) {
|
||||||
problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`);
|
problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { Rng } from '../engine/rng.js';
|
|||||||
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
||||||
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';
|
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';
|
||||||
import { reachFor, CURVE_NAMES } from '../look/Camera.js';
|
import { reachFor, CURVE_NAMES } from '../look/Camera.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
|
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
|
||||||
let cached = null;
|
let cached = null;
|
||||||
@ -273,7 +274,7 @@ check(11, 'two tracks do not agree on what a section kind looks like', () => {
|
|||||||
directors.add(look.director);
|
directors.add(look.director);
|
||||||
look.sections.forEach((section) => {
|
look.sections.forEach((section) => {
|
||||||
const set = seen.get(section.kind) || new Set();
|
const set = seen.get(section.kind) || new Set();
|
||||||
(section.variants || [section.layers]).forEach((v) => set.add(v[0].module.family));
|
(section.variants || [section.layers]).forEach((v) => set.add(subjectOf(v).module.family));
|
||||||
seen.set(section.kind, set);
|
seen.set(section.kind, set);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -299,7 +300,7 @@ check(11, 'a track shows more of the library than it used to', () => {
|
|||||||
for (let s = 0; s < 4; s++) {
|
for (let s = 0; s < 4; s++) {
|
||||||
const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 });
|
const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 });
|
||||||
look.sections.forEach((section) =>
|
look.sections.forEach((section) =>
|
||||||
(section.variants || [section.layers]).forEach((v) => cast.add(v[0].module.name)));
|
(section.variants || [section.layers]).forEach((v) => cast.add(subjectOf(v).module.name)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const pool = scenes.filter(canBackground).length;
|
const pool = scenes.filter(canBackground).length;
|
||||||
@ -492,8 +493,11 @@ check(11, 'the slow axis is a journey rather than a cycle', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const cue = arc.cues[0];
|
const cue = arc.cues[0];
|
||||||
const at = (time) => arc._paramsAt(cue, 0, time, t.at(Math.round(time * 60)));
|
// The shot, not the bed under it — the slow axis is a claim about the
|
||||||
const spec = arc._specFor(cue.sectionIndex, cue.variant, 0);
|
// scene the section is about.
|
||||||
|
const slot = arc._subjectSlot(cue);
|
||||||
|
const at = (time) => arc._paramsAt(cue, slot, time, t.at(Math.round(time * 60)));
|
||||||
|
const spec = arc._specFor(cue.sectionIndex, cue.variant, slot);
|
||||||
const axis = arc._slowAxisFor(spec.module);
|
const axis = arc._slowAxisFor(spec.module);
|
||||||
|
|
||||||
const start = at(t.duration * 0.05);
|
const start = at(t.duration * 0.05);
|
||||||
|
|||||||
@ -20,10 +20,14 @@ import { song } from '../audio/songbank.js';
|
|||||||
import { Show } from '../Show.js';
|
import { Show } from '../Show.js';
|
||||||
import { generateLook } from '../look/LookGenerator.js';
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
import { scenes } from '../scenes/registry.js';
|
import { scenes } from '../scenes/registry.js';
|
||||||
import { surfaceOf, canBackground } from '../params/schema.js';
|
import { canBackground } from '../params/schema.js';
|
||||||
import { Engine } from '../engine/Engine.js';
|
import {
|
||||||
import { defaultValues } from '../params/schema.js';
|
GROUND_MIN, canGround, isMeasured, surfaceOf, METADATA,
|
||||||
import { featureProviderFor } from '../audio/FeatureTrack.js';
|
coverageOf as declaredCoverage, structuralDistance,
|
||||||
|
} from '../scenes/surface.js';
|
||||||
|
import { metadataIsFresh, metricsFingerprint } from './metadata.js';
|
||||||
|
import { RESTFUL_FAMILIES } from '../look/directors.js';
|
||||||
|
import { stackCoverage } from '../look/stack.js';
|
||||||
import { frameDescriptor, rotate90, recolour } from './variety/descriptors.js';
|
import { frameDescriptor, rotate90, recolour } from './variety/descriptors.js';
|
||||||
import { descriptorDistance, signatureDistance } from './variety/signature.js';
|
import { descriptorDistance, signatureDistance } from './variety/signature.js';
|
||||||
import {
|
import {
|
||||||
@ -185,62 +189,249 @@ check(12, 'seed variety · different seeds render structurally different videos'
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much of the frame a scene actually paints.
|
* The metadata is measured from the code that is actually here.
|
||||||
*
|
*
|
||||||
* A composable scene has to leave room for what it sits on. One that claims to
|
* scenes/metadata.json is a cache of a render — how much frame each scene
|
||||||
* and covers the frame anyway will hide its background completely, which is the
|
* paints, how much it changes between songs, what it looks like structurally —
|
||||||
* failure the declaration exists to prevent — and it is not something you can
|
* and the generator composes with it: which scenes may ground a section, and
|
||||||
* see from the source, only from the render.
|
* which pairs are unalike enough to be worth stacking. A stale file therefore
|
||||||
|
* does not produce a stale REPORT, it produces wrong videos, silently.
|
||||||
|
*
|
||||||
|
* So the file carries a fingerprint of everything that can move a number in it
|
||||||
|
* — the scenes, the shader contract, the identities and palettes they are
|
||||||
|
* handed, and the metric definitions — and this fails when it stops matching.
|
||||||
|
* The fix is not code: open gallery.html and press "refresh metadata".
|
||||||
*/
|
*/
|
||||||
function coverageOf(engine, module) {
|
check(12, 'metadata · the measurements match the code that produced them', () => {
|
||||||
engine.setLayerSpecs([{
|
const fresh = metadataIsFresh();
|
||||||
module, params: defaultValues(module), seed: 4242,
|
const rows = Object.keys(METADATA.scenes || {}).length;
|
||||||
opacity: 1, blend: 'normal',
|
const unmeasured = scenes
|
||||||
palette: [[0.05, 0.05, 0.1], [0.9, 0.3, 0.5], [0.3, 0.8, 0.9], [0.95, 0.9, 0.4]],
|
.filter((m) => m.kind === 'fragment' && !isMeasured(m))
|
||||||
}]);
|
.map((m) => m.name);
|
||||||
engine.compositor.reset();
|
|
||||||
for (let f = 594; f < 600; f++) engine.renderFrame(f);
|
return expect(fresh && unmeasured.length === 0,
|
||||||
const px = Uint8Array.from(engine.readPixels(engine.renderFrame(600)));
|
(fresh ? '' : `stale: measured against ${METADATA.fingerprint}, code is ${metricsFingerprint()} — ` +
|
||||||
let lit = 0;
|
'rebuild it from gallery.html → refresh metadata · ') +
|
||||||
for (let i = 0; i < px.length; i += 4) {
|
(unmeasured.length ? `never measured: ${unmeasured.slice(0, 5).join(', ')} · ` : '') +
|
||||||
// Anything a viewer would read as painted rather than as backdrop.
|
`${rows} scenes measured ${METADATA.measured} · ` +
|
||||||
if (px[i] + px[i + 1] + px[i + 2] > 90) lit++;
|
`${scenes.filter(canGround).length} can ground a section`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The labels the measurements produce are usable ones.
|
||||||
|
*
|
||||||
|
* Not a re-measurement — the check above covers staleness. This asks whether
|
||||||
|
* the library, as labelled, can still cast a video: every section needs a
|
||||||
|
* ground, quiet sections are held to the restful families, so a threshold that
|
||||||
|
* left one ground in the whole library would pass every other gate here and
|
||||||
|
* make every video identical underneath.
|
||||||
|
*/
|
||||||
|
check(12, 'metadata · the measured labels leave enough grounds to cast with', () => {
|
||||||
|
const grounds = scenes.filter(canGround);
|
||||||
|
const byFamily = {};
|
||||||
|
for (const m of grounds) byFamily[m.family] = (byFamily[m.family] || 0) + 1;
|
||||||
|
const restful = RESTFUL_FAMILIES.reduce((n, f) => n + (byFamily[f] || 0), 0);
|
||||||
|
|
||||||
|
const problems = [];
|
||||||
|
// Low bars, and they are where the library actually is rather than where it
|
||||||
|
// ought to be. Seven scenes can ground a section — five geometric, two
|
||||||
|
// organic — and the two organic ones carry every quiet section of every
|
||||||
|
// video, because no minimal or flow canvas in the library fills half the
|
||||||
|
// frame without reading prev(). That is the library's largest hole and it
|
||||||
|
// is scene work, not generator work. The numbers are printed on every run
|
||||||
|
// so it stays visible instead of becoming the way things are.
|
||||||
|
if (grounds.length < 6) problems.push(`only ${grounds.length} grounds in the library`);
|
||||||
|
if (restful < 2) problems.push(`only ${restful} restful grounds for quiet sections`);
|
||||||
|
|
||||||
|
return expect(problems.length === 0,
|
||||||
|
(problems.length ? problems.join(' · ') + ' — ' : '') +
|
||||||
|
`${grounds.length} grounds · ` +
|
||||||
|
Object.entries(byFamily).map(([f, n]) => `${f} ${n}`).join(' · '));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPOSITION — what every section's frame is made of.
|
||||||
|
*
|
||||||
|
* Two rules, and they are the two ends of the same idea: a section always has a
|
||||||
|
* filled picture underneath it, and it never stacks up more than two frames'
|
||||||
|
* worth of material on top of that. Between them they rule out both failures
|
||||||
|
* the layer stack can produce — a shot floating on black, and four passes of
|
||||||
|
* texture over each other.
|
||||||
|
*
|
||||||
|
* Measured across the song bank rather than one track, because both rules are
|
||||||
|
* decided per director, per kind and per point in the story, and a single song
|
||||||
|
* exercises one director's opinion about six sections.
|
||||||
|
*/
|
||||||
|
check(12, 'composition · every section stands on a canvas, under a budget', () => {
|
||||||
|
const problems = [];
|
||||||
|
const budgets = [];
|
||||||
|
let stacks = 0;
|
||||||
|
let grounded = 0;
|
||||||
|
|
||||||
|
for (const name of ['centre', 'drone', 'lattice']) {
|
||||||
|
const track = song(name).track;
|
||||||
|
for (let s = 0; s < 4; s++) {
|
||||||
|
const look = generateLook(track, { seed: 4000 + s * 7717 });
|
||||||
|
for (const section of look.sections) {
|
||||||
|
for (const stack of section.variants || [section.layers]) {
|
||||||
|
stacks++;
|
||||||
|
const base = stack[0];
|
||||||
|
const cover = declaredCoverage(base.module);
|
||||||
|
if (surfaceOf(base.module) !== 'canvas' || cover < GROUND_MIN) {
|
||||||
|
problems.push(`${section.kind} stands on ${base.module.name} at ${(cover * 100).toFixed(0)}%`);
|
||||||
|
} else {
|
||||||
|
grounded++;
|
||||||
|
}
|
||||||
|
if (base.opacity < 1 || base.blend !== 'normal') {
|
||||||
|
problems.push(`${section.kind} ground ${base.module.name} is ${base.blend} at ${base.opacity.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
const total = stackCoverage(stack);
|
||||||
|
budgets.push(total);
|
||||||
|
if (total > 2.0001) {
|
||||||
|
problems.push(`${section.kind} paints ${(total * 100).toFixed(0)}% — over budget`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lit / (px.length / 4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
check(12, 'surface · a composable scene leaves room for what it sits on', () => {
|
const mean = budgets.reduce((a, b) => a + b, 0) / Math.max(1, budgets.length);
|
||||||
const engine = new Engine({ width: 128, height: 72 });
|
const max = Math.max(...budgets);
|
||||||
const track = varietyTrack();
|
return expect(problems.length === 0,
|
||||||
engine.timeline.setDuration(track.duration);
|
(problems.length ? problems.slice(0, 4).join(' · ') + ' — ' : '') +
|
||||||
engine.setFeatureProvider(featureProviderFor(track));
|
`${grounded}/${stacks} grounded · coverage mean ${(mean * 100).toFixed(0)}% ` +
|
||||||
|
`· peak ${(max * 100).toFixed(0)}% of the 200% ceiling`);
|
||||||
|
});
|
||||||
|
|
||||||
const wrong = [];
|
/**
|
||||||
const measured = [];
|
* The rendered frame, at BOTH ends.
|
||||||
|
*
|
||||||
|
* The spec-level check above says the generator intended a filled frame. This
|
||||||
|
* renders the middle of every section of twelve videos and looks at what came
|
||||||
|
* out, which is the only statement that matters to someone watching.
|
||||||
|
*
|
||||||
|
* Two failures, and they are opposites that arrive by the same route:
|
||||||
|
*
|
||||||
|
* too black a section that is a few bright things on nothing. Measured at
|
||||||
|
* 9 of 40 sampled frames under 20% painted before grounds, the
|
||||||
|
* darkest at 0.3%.
|
||||||
|
* too white a section clipped to paper. Measured at a median 24% of pixels
|
||||||
|
* at full white and entire sections at 100% when the shot was
|
||||||
|
* screened over its ground and the feedback loop was still an
|
||||||
|
* accumulator. Clipped white is not brightness, it is missing
|
||||||
|
* information: every difference inside it has been deleted.
|
||||||
|
*
|
||||||
|
* `ink` is counted at a low threshold on purpose — the question is whether the
|
||||||
|
* frame has anything IN it, not whether it is bright, and a legitimately dark
|
||||||
|
* scene is not a failure. `blown` is counted at near-full white on all three
|
||||||
|
* channels, which no grade should produce over a quarter of a frame.
|
||||||
|
*/
|
||||||
|
check(12, 'composition · a rendered section is neither black nor blown out', () => {
|
||||||
|
const dark = [];
|
||||||
|
const white = [];
|
||||||
|
const ink = [];
|
||||||
|
const blown = [];
|
||||||
|
const lum = [];
|
||||||
|
|
||||||
|
for (const name of ['centre', 'drone', 'lattice', 'glare', 'murk', 'runner']) {
|
||||||
|
const track = song(name).track;
|
||||||
|
for (const seed of [4001, 9931]) {
|
||||||
|
const show = new Show({ width: 160, height: 90 });
|
||||||
try {
|
try {
|
||||||
for (const module of scenes) {
|
show.useTrack(track, generateLook(track, { seed }));
|
||||||
if (module.kind !== 'fragment') continue;
|
for (const section of show.look.sections) {
|
||||||
const cover = coverageOf(engine, module);
|
const f = Math.round(section.startFrame
|
||||||
const surface = surfaceOf(module);
|
+ (section.endFrame - section.startFrame) * 0.5);
|
||||||
measured.push({ name: module.name, surface, cover });
|
show.engine.compositor.reset();
|
||||||
// A composable scene painting most of the frame hides its
|
for (let k = Math.max(0, f - 12); k < f; k++) show.renderFrame(k);
|
||||||
// background; a canvas leaving it nearly empty is a canvas in name
|
const px = Uint8Array.from(show.readPixels(show.renderFrame(f)));
|
||||||
// only and will read as a black frame when nothing is under it.
|
|
||||||
if (surface === 'composable' && cover > 0.55) wrong.push(`${module.name} claims composable but covers ${(cover * 100).toFixed(0)}%`);
|
let inked = 0, clipped = 0, light = 0;
|
||||||
if (surface === 'canvas' && cover < 0.08) wrong.push(`${module.name} claims canvas but covers only ${(cover * 100).toFixed(0)}%`);
|
const n = px.length / 4;
|
||||||
|
for (let i = 0; i < px.length; i += 4) {
|
||||||
|
const r = px[i], g = px[i + 1], b = px[i + 2];
|
||||||
|
const l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
if (l > 13) inked++;
|
||||||
|
if (r > 238 && g > 238 && b > 238) clipped++;
|
||||||
|
light += l;
|
||||||
|
}
|
||||||
|
const where = `${name}/${seed.toString(16)} ${section.kind}`;
|
||||||
|
ink.push(inked / n);
|
||||||
|
blown.push(clipped / n);
|
||||||
|
lum.push(light / n / 255);
|
||||||
|
if (inked / n < 0.3) dark.push(`${where} only ${((inked / n) * 100).toFixed(0)}% painted`);
|
||||||
|
// Per section: the hard cap only. A deliberate blaze is
|
||||||
|
// allowed to be bright; nothing is allowed to be gone.
|
||||||
|
if (clipped / n > 0.5) white.push(`${where} ${((clipped / n) * 100).toFixed(0)}% clipped white`);
|
||||||
|
if (light / n / 255 > 0.9) white.push(`${where} mean luminance ${((light / n / 255) * 100).toFixed(0)}%`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
engine.dispose();
|
show.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reported whatever the verdict, because the useful output of this check is
|
// A BLAZE is allowed; blazing by default is not.
|
||||||
// the list itself — it is how the library gets labelled in the first place.
|
//
|
||||||
const sorted = measured.sort((a, b) => a.cover - b.cover);
|
// The ceiling is therefore two numbers rather than one. No single section
|
||||||
window.__COVERAGE__ = sorted;
|
// may be wholly gone — past about half the frame at full white there is no
|
||||||
const sparse = sorted.filter((m) => m.cover < 0.3).length;
|
// picture left to read — and only a minority of them may be hot at all. The
|
||||||
|
// second is the one that matters: screening every shot over its ground blew
|
||||||
|
// a median quarter of EVERY frame, which is not a director choosing to peak,
|
||||||
|
// it is a pipeline with no headroom. See blazeOf in look/directors.js.
|
||||||
|
const hot = blown.filter((b) => b > 0.25).length;
|
||||||
|
const hotShare = hot / Math.max(1, blown.length);
|
||||||
|
if (hotShare > 0.2) {
|
||||||
|
white.push(`${(hotShare * 100).toFixed(0)}% of sections clipped past 25% — blazing is the default, not a choice`);
|
||||||
|
}
|
||||||
|
|
||||||
return expect(wrong.length === 0,
|
const problems = [...dark, ...white];
|
||||||
(wrong.length ? wrong.slice(0, 4).join(' · ') + ' — ' : '') +
|
const mean = (a) => a.reduce((x, y) => x + y, 0) / Math.max(1, a.length);
|
||||||
`${measured.length} scenes · ${sparse} paint under 30% of the frame ` +
|
return expect(problems.length === 0,
|
||||||
`· sparsest ${sorted[0].name} at ${(sorted[0].cover * 100).toFixed(0)}%`);
|
(problems.length ? problems.slice(0, 4).join(' · ') + ' — ' : '') +
|
||||||
|
`${ink.length} sections · painted mean ${(mean(ink) * 100).toFixed(0)}% ` +
|
||||||
|
`darkest ${(Math.min(...ink) * 100).toFixed(0)}% · ` +
|
||||||
|
`clipped mean ${(mean(blown) * 100).toFixed(0)}% worst ${(Math.max(...blown) * 100).toFixed(0)}% · ` +
|
||||||
|
`${hot} of ${blown.length} sections blazing`);
|
||||||
}, { slow: true });
|
}, { slow: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stack is two things happening, not one thing twice.
|
||||||
|
*
|
||||||
|
* The point of the measured profiles: family labels say a `flow` scene and an
|
||||||
|
* `organic` scene are different, and the render can disagree — two of them can
|
||||||
|
* sit 0.04 apart, which stacked is one texture at double density. This asks
|
||||||
|
* whether the generator's stacks are actually made of unalike material.
|
||||||
|
*
|
||||||
|
* A mean rather than a per-stack floor, because a lean is what the generator
|
||||||
|
* applies. Demanding every pair clear a bar would be demanding a particular
|
||||||
|
* draw, and the seed is supposed to be able to make an ordinary choice.
|
||||||
|
*/
|
||||||
|
check(12, 'composition · stacked layers are structurally unalike', () => {
|
||||||
|
const pairs = [];
|
||||||
|
for (const name of ['centre', 'drone', 'lattice']) {
|
||||||
|
const track = song(name).track;
|
||||||
|
for (let s = 0; s < 4; s++) {
|
||||||
|
const look = generateLook(track, { seed: 5200 + s * 3931 });
|
||||||
|
for (const section of look.sections) {
|
||||||
|
for (const stack of section.variants || [section.layers]) {
|
||||||
|
for (let i = 0; i < stack.length; i++) {
|
||||||
|
for (let j = i + 1; j < stack.length; j++) {
|
||||||
|
const d = structuralDistance(stack[i].module, stack[j].module);
|
||||||
|
if (d !== null) pairs.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mean = pairs.reduce((a, b) => a + b, 0) / Math.max(1, pairs.length);
|
||||||
|
const twins = pairs.filter((d) => d < 0.03).length;
|
||||||
|
// Against the library's own median distance, so this measures the CHOOSING
|
||||||
|
// rather than the library — a bar in absolute units would drift every time
|
||||||
|
// a scene was added.
|
||||||
|
return expect(pairs.length > 0 && mean > 0.08 && twins / pairs.length < 0.05,
|
||||||
|
`${pairs.length} stacked pairs · mean distance ${mean.toFixed(3)} · ` +
|
||||||
|
`${twins} near-twins (${((twins / Math.max(1, pairs.length)) * 100).toFixed(0)}%)`);
|
||||||
|
});
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { FeatureTrack } from '../audio/FeatureTrack.js';
|
|||||||
import { synthesizeSectioned } from '../audio/synth.js';
|
import { synthesizeSectioned } from '../audio/synth.js';
|
||||||
import { song } from '../audio/songbank.js';
|
import { song } from '../audio/songbank.js';
|
||||||
import { storyStateAt, STORY_VARS, PLOT_NAMES } from '../look/Story.js';
|
import { storyStateAt, STORY_VARS, PLOT_NAMES } from '../look/Story.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bank songs rather than the two-section synthetics the other phases use.
|
* Bank songs rather than the two-section synthetics the other phases use.
|
||||||
@ -107,7 +108,7 @@ check(13, 'the second time a kind happens is not the first time again', () => {
|
|||||||
if (list.length < 2) continue;
|
if (list.length < 2) continue;
|
||||||
const first = list[0];
|
const first = list[0];
|
||||||
const last = list[list.length - 1];
|
const last = list[list.length - 1];
|
||||||
const sceneChanged = first.layers[0].module.name !== last.layers[0].module.name;
|
const sceneChanged = subjectOf(first.layers).module.name !== subjectOf(last.layers).module.name;
|
||||||
const pushed = Math.abs(last.story.tension - first.story.tension);
|
const pushed = Math.abs(last.story.tension - first.story.tension);
|
||||||
seen.push({ name, kind, sceneChanged, pushed });
|
seen.push({ name, kind, sceneChanged, pushed });
|
||||||
}
|
}
|
||||||
@ -210,7 +211,7 @@ check(13, 'a recapitulation actually recapitulates', () => {
|
|||||||
// residue on everything else that makes the ending a return rather
|
// residue on everything else that makes the ending a return rather
|
||||||
// than a rewind.
|
// than a rewind.
|
||||||
rows.push({
|
rows.push({
|
||||||
same: intro.layers[0].module.name === outro.layers[0].module.name,
|
same: subjectOf(intro.layers).module.name === subjectOf(outro.layers).module.name,
|
||||||
moved: Math.max(...['journey', 'reveal', 'tension'].map((k) =>
|
moved: Math.max(...['journey', 'reveal', 'tension'].map((k) =>
|
||||||
Math.abs(outro.story[k] - intro.story[k]))),
|
Math.abs(outro.story[k] - intro.story[k]))),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { generateLook, rerollSection, rerollLook, describeLook } from '../look/L
|
|||||||
import { paletteContrast, relativeLuminance } from '../look/palette.js';
|
import { paletteContrast, relativeLuminance } from '../look/palette.js';
|
||||||
import { frameDistance, frameLuminance, frameVariance } from '../engine/hash.js';
|
import { frameDistance, frameLuminance, frameVariance } from '../engine/hash.js';
|
||||||
import { testTrack } from './phase1.js';
|
import { testTrack } from './phase1.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
/** Four deliberately different tracks: the differentiation gate needs real spread. */
|
/** Four deliberately different tracks: the differentiation gate needs real spread. */
|
||||||
let cachedBattery = null;
|
let cachedBattery = null;
|
||||||
@ -34,15 +35,22 @@ export function battery() {
|
|||||||
|
|
||||||
export function renderLookFrame(engine, track, look, frame) {
|
export function renderLookFrame(engine, track, look, frame) {
|
||||||
const section = look.sections[track.sectionIndexAt(frame)] || look.sections[0];
|
const section = look.sections[track.sectionIndexAt(frame)] || look.sections[0];
|
||||||
const layer = section.layers[0];
|
// The WHOLE stack, not the subject alone.
|
||||||
engine.setLayerSpecs([{
|
//
|
||||||
|
// This rendered `layers[0]` back when that was the section's only layer,
|
||||||
|
// and rendering one layer of a composed section answers a question nobody
|
||||||
|
// asked: a composable shot on its own is a few bright things on black,
|
||||||
|
// which is exactly what it is not supposed to be shown as. Measured, it
|
||||||
|
// reported a dead frame — luminance 0.0006 — for a section that renders
|
||||||
|
// perfectly well with the ground it was built with underneath it.
|
||||||
|
engine.setLayerSpecs(section.layers.map((layer) => ({
|
||||||
module: layer.module,
|
module: layer.module,
|
||||||
params: layer.params,
|
params: layer.params,
|
||||||
seed: layer.seed,
|
seed: layer.seed,
|
||||||
opacity: layer.opacity,
|
opacity: layer.opacity,
|
||||||
blend: layer.blend,
|
blend: layer.blend,
|
||||||
palette: look.palette,
|
palette: look.palette,
|
||||||
}]);
|
})));
|
||||||
engine.compositor.setPost(look.post).setFeedback(look.feedback);
|
engine.compositor.setPost(look.post).setFeedback(look.feedback);
|
||||||
engine.compositor.reset();
|
engine.compositor.reset();
|
||||||
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
|
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
|
||||||
@ -55,9 +63,10 @@ check(3, 'the same audio always produces the same look', () => {
|
|||||||
const b = generateLook(track, { samples });
|
const b = generateLook(track, { samples });
|
||||||
|
|
||||||
if (a.seed !== b.seed) return expect(false, `seeds differ: ${a.seed} vs ${b.seed}`);
|
if (a.seed !== b.seed) return expect(false, `seeds differ: ${a.seed} vs ${b.seed}`);
|
||||||
const sameScenes = a.sections.every((s, i) => s.layers[0].module.name === b.sections[i].layers[0].module.name);
|
const sameScenes = a.sections.every((s, i) =>
|
||||||
const sameParams = JSON.stringify(a.sections.map((s) => s.layers[0].params))
|
subjectOf(s.layers).module.name === subjectOf(b.sections[i].layers).module.name);
|
||||||
=== JSON.stringify(b.sections.map((s) => s.layers[0].params));
|
const sameParams = JSON.stringify(a.sections.map((s) => subjectOf(s.layers).params))
|
||||||
|
=== JSON.stringify(b.sections.map((s) => subjectOf(s.layers).params));
|
||||||
const samePalette = JSON.stringify(a.palette) === JSON.stringify(b.palette);
|
const samePalette = JSON.stringify(a.palette) === JSON.stringify(b.palette);
|
||||||
|
|
||||||
return expect(sameScenes && sameParams && samePalette,
|
return expect(sameScenes && sameParams && samePalette,
|
||||||
@ -144,7 +153,7 @@ check(3, 'different tracks get different looks at the same seed', () => {
|
|||||||
const palettes = looks.map(({ look }) => look.palette.map(relativeLuminance).join(','));
|
const palettes = looks.map(({ look }) => look.palette.map(relativeLuminance).join(','));
|
||||||
const uniquePalettes = new Set(palettes).size;
|
const uniquePalettes = new Set(palettes).size;
|
||||||
const sceneSets = looks.map(({ look }) =>
|
const sceneSets = looks.map(({ look }) =>
|
||||||
[...new Set(look.sections.map((s) => s.layers[0].module.name))].sort().join('+'));
|
[...new Set(look.sections.map((s) => subjectOf(s.layers).module.name))].sort().join('+'));
|
||||||
|
|
||||||
return expect(uniquePalettes === looks.length,
|
return expect(uniquePalettes === looks.length,
|
||||||
`${uniquePalettes}/${looks.length} distinct palettes at a fixed seed · ` +
|
`${uniquePalettes}/${looks.length} distinct palettes at a fixed seed · ` +
|
||||||
@ -173,7 +182,7 @@ check(3, 'every generated look renders a live frame on every track', () => {
|
|||||||
const lum = frameLuminance(pixels);
|
const lum = frameLuminance(pixels);
|
||||||
const variance = frameVariance(pixels);
|
const variance = frameVariance(pixels);
|
||||||
if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) {
|
if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) {
|
||||||
problems.push(`${name} s${s} ${section.kind}/${section.layers[0].module.name}: ` +
|
problems.push(`${name} s${s} ${section.kind}/${subjectOf(section.layers).module.name}: ` +
|
||||||
`lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
|
`lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,7 +203,7 @@ check(3, 'sections of the same kind share a scene', () => {
|
|||||||
const byKind = new Map();
|
const byKind = new Map();
|
||||||
let violations = 0;
|
let violations = 0;
|
||||||
for (const s of look.sections) {
|
for (const s of look.sections) {
|
||||||
const name = s.layers[0].module.name;
|
const name = subjectOf(s.layers).module.name;
|
||||||
if (byKind.has(s.kind) && byKind.get(s.kind) !== name) violations++;
|
if (byKind.has(s.kind) && byKind.get(s.kind) !== name) violations++;
|
||||||
byKind.set(s.kind, name);
|
byKind.set(s.kind, name);
|
||||||
}
|
}
|
||||||
@ -205,14 +214,14 @@ check(3, 'sections of the same kind share a scene', () => {
|
|||||||
check(3, 'reroll changes a section and respects locks', () => {
|
check(3, 'reroll changes a section and respects locks', () => {
|
||||||
const track = testTrack();
|
const track = testTrack();
|
||||||
const look = generateLook(track, { seed: 555 });
|
const look = generateLook(track, { seed: 555 });
|
||||||
const before = JSON.stringify(look.sections[0].layers[0].params);
|
const before = JSON.stringify(subjectOf(look.sections[0].layers).params);
|
||||||
|
|
||||||
rerollSection(look, track, 0, 1);
|
rerollSection(look, track, 0, 1);
|
||||||
const afterUnlocked = JSON.stringify(look.sections[0].layers[0].params);
|
const afterUnlocked = JSON.stringify(subjectOf(look.sections[0].layers).params);
|
||||||
|
|
||||||
look.sections[0].locked = true;
|
look.sections[0].locked = true;
|
||||||
rerollSection(look, track, 0, 2);
|
rerollSection(look, track, 0, 2);
|
||||||
const afterLocked = JSON.stringify(look.sections[0].layers[0].params);
|
const afterLocked = JSON.stringify(subjectOf(look.sections[0].layers).params);
|
||||||
|
|
||||||
return expect(before !== afterUnlocked && afterUnlocked === afterLocked,
|
return expect(before !== afterUnlocked && afterUnlocked === afterLocked,
|
||||||
`changed when unlocked: ${before !== afterUnlocked}, held when locked: ${afterUnlocked === afterLocked}`);
|
`changed when unlocked: ${before !== afterUnlocked}, held when locked: ${afterUnlocked === afterLocked}`);
|
||||||
@ -222,12 +231,12 @@ check(3, 'a whole-track reroll preserves locked sections', () => {
|
|||||||
const track = testTrack();
|
const track = testTrack();
|
||||||
const look = generateLook(track, { seed: 777 });
|
const look = generateLook(track, { seed: 777 });
|
||||||
look.sections[0].locked = true;
|
look.sections[0].locked = true;
|
||||||
const lockedScene = look.sections[0].layers[0].module.name;
|
const lockedScene = subjectOf(look.sections[0].layers).module.name;
|
||||||
const lockedParams = JSON.stringify(look.sections[0].layers[0].params);
|
const lockedParams = JSON.stringify(subjectOf(look.sections[0].layers).params);
|
||||||
|
|
||||||
const next = rerollLook(look, track, 888);
|
const next = rerollLook(look, track, 888);
|
||||||
return expect(
|
return expect(
|
||||||
next.sections[0].layers[0].module.name === lockedScene &&
|
subjectOf(next.sections[0].layers).module.name === lockedScene &&
|
||||||
JSON.stringify(next.sections[0].layers[0].params) === lockedParams,
|
JSON.stringify(subjectOf(next.sections[0].layers).params) === lockedParams,
|
||||||
`locked section survived a full reroll (${lockedScene})`);
|
`locked section survived a full reroll (${lockedScene})`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { peakFlashRate } from '../engine/flash.js';
|
|||||||
import { particleField } from '../scenes/layers3d/particles.js';
|
import { particleField } from '../scenes/layers3d/particles.js';
|
||||||
import { nebula } from '../scenes/shader/nebula.js';
|
import { nebula } from '../scenes/shader/nebula.js';
|
||||||
import { BLEND_MODES } from '../engine/Layer.js';
|
import { BLEND_MODES } from '../engine/Layer.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
const PALETTE = [
|
const PALETTE = [
|
||||||
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
||||||
@ -192,7 +193,7 @@ check(5, 'generated looks stay within the flash-rate ceiling', () => {
|
|||||||
}
|
}
|
||||||
const rate = peakFlashRate(luminance, 60);
|
const rate = peakFlashRate(luminance, 60);
|
||||||
const section = show.look.sections[cue.sectionIndex];
|
const section = show.look.sections[cue.sectionIndex];
|
||||||
const scene = (section.variants[cue.variant] || section.layers)[0].module.name;
|
const scene = subjectOf(section.variants[cue.variant] || section.layers).module.name;
|
||||||
const label = `seed ${s} ${section.kind}/${scene}`;
|
const label = `seed ${s} ${section.kind}/${scene}`;
|
||||||
if (rate > worst) { worst = rate; worstLabel = label; }
|
if (rate > worst) { worst = rate; worstLabel = label; }
|
||||||
if (rate > 3) problems.push(`${label}: ${rate}/s`);
|
if (rate > 3) problems.push(`${label}: ${rate}/s`);
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
|||||||
import { synthesizeSectioned } from '../audio/synth.js';
|
import { synthesizeSectioned } from '../audio/synth.js';
|
||||||
import { generateLook } from '../look/LookGenerator.js';
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
import { frameDistance, frameMaxDelta, frameLuminance, frameVariance } from '../engine/hash.js';
|
import { frameDistance, frameMaxDelta, frameLuminance, frameVariance } from '../engine/hash.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
const PALETTE = [
|
const PALETTE = [
|
||||||
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
||||||
@ -247,7 +248,7 @@ check(7, 'quiet sections now get minimal scenes', () => {
|
|||||||
for (const section of look.sections) {
|
for (const section of look.sections) {
|
||||||
if (!(section.kind in kinds)) continue;
|
if (!(section.kind in kinds)) continue;
|
||||||
total++;
|
total++;
|
||||||
const family = section.layers[0].module.family;
|
const family = subjectOf(section.layers).module.family;
|
||||||
if (restful.has(family)) restfulCount++;
|
if (restful.has(family)) restfulCount++;
|
||||||
if (family === 'minimal') minimalCount++;
|
if (family === 'minimal') minimalCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { synthesizeSectioned } from '../audio/synth.js';
|
|||||||
import { generateLook } from '../look/LookGenerator.js';
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS, HARD_CUT_ENERGY } from '../look/shots.js';
|
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS, HARD_CUT_ENERGY } from '../look/shots.js';
|
||||||
import { frameDistance } from '../engine/hash.js';
|
import { frameDistance } from '../engine/hash.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
let cached = null;
|
let cached = null;
|
||||||
function track8() {
|
function track8() {
|
||||||
@ -131,7 +132,7 @@ check(8, 'the same section kind reuses the same roster', () => {
|
|||||||
for (const look of looks()) {
|
for (const look of looks()) {
|
||||||
const byKind = new Map();
|
const byKind = new Map();
|
||||||
for (const section of look.sections) {
|
for (const section of look.sections) {
|
||||||
const roster = (section.variants || [section.layers]).map((v) => v[0].module.name).join('+');
|
const roster = (section.variants || [section.layers]).map((v) => subjectOf(v).module.name).join('+');
|
||||||
const seen = byKind.get(section.kind);
|
const seen = byKind.get(section.kind);
|
||||||
if (seen && seen !== roster) problems.push(`${section.kind}: ${seen} vs ${roster}`);
|
if (seen && seen !== roster) problems.push(`${section.kind}: ${seen} vs ${roster}`);
|
||||||
byKind.set(section.kind, roster);
|
byKind.set(section.kind, roster);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { Rng } from '../engine/rng.js';
|
|||||||
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
||||||
import { synthesizeSectioned } from '../audio/synth.js';
|
import { synthesizeSectioned } from '../audio/synth.js';
|
||||||
import { frameMaxDelta } from '../engine/hash.js';
|
import { frameMaxDelta } from '../engine/hash.js';
|
||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
|
|
||||||
const PALETTE = [
|
const PALETTE = [
|
||||||
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
||||||
@ -40,7 +41,7 @@ function looks(count = 8) {
|
|||||||
return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 3000 + i * 6841 }));
|
return Array.from({ length: count }, (_, i) => generateLook(track, { seed: 3000 + i * 6841 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const castOf = (look) => look.sections.flatMap((s) => (s.variants || [s.layers]).map((v) => v[0].module));
|
const castOf = (look) => look.sections.flatMap((s) => (s.variants || [s.layers]).map((v) => subjectOf(v).module));
|
||||||
|
|
||||||
/** Two personalities differing in exactly one trait, for the "does it show" checks. */
|
/** Two personalities differing in exactly one trait, for the "does it show" checks. */
|
||||||
function pairDifferingIn(trait) {
|
function pairDifferingIn(trait) {
|
||||||
@ -140,7 +141,7 @@ check(9, 'casting leans hard on the track\'s signature', () => {
|
|||||||
}
|
}
|
||||||
for (const section of look.sections) {
|
for (const section of look.sections) {
|
||||||
anchors++;
|
anchors++;
|
||||||
if (sceneHonours(section.layers[0].module, signature)) anchorsHonoured++;
|
if (sceneHonours(subjectOf(section.layers).module, signature)) anchorsHonoured++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const share = total ? honoured / total : 0;
|
const share = total ? honoured / total : 0;
|
||||||
|
|||||||
@ -35,6 +35,8 @@ import { Rng } from '../../engine/rng.js';
|
|||||||
import { videoSignature, signatureDistance, STRUCTURAL } from './signature.js';
|
import { videoSignature, signatureDistance, STRUCTURAL } from './signature.js';
|
||||||
import { songBank } from '../../audio/songbank.js';
|
import { songBank } from '../../audio/songbank.js';
|
||||||
import { hashString } from '../../engine/rng.js';
|
import { hashString } from '../../engine/rng.js';
|
||||||
|
import { subjectOf, isGround } from '../../look/stack.js';
|
||||||
|
import { canGround } from '../../scenes/surface.js';
|
||||||
|
|
||||||
const RENDER = { width: 160, height: 90 };
|
const RENDER = { width: 160, height: 90 };
|
||||||
|
|
||||||
@ -70,11 +72,17 @@ export function signatureForChaos(track, seed, options = {}) {
|
|||||||
const look = generateLook(track, { seed: seed >>> 0 });
|
const look = generateLook(track, { seed: seed >>> 0 });
|
||||||
const rng = new Rng((seed * 2246822519) >>> 0);
|
const rng = new Rng((seed * 2246822519) >>> 0);
|
||||||
const pool = scenes.filter(canBackground);
|
const pool = scenes.filter(canBackground);
|
||||||
|
// The ground keeps its JOB when its identity is thrown away. Recasting
|
||||||
|
// it from the whole library would give the reference videos thin,
|
||||||
|
// half-black frames no real video can have any more, and a ceiling
|
||||||
|
// measured on those is a ceiling for a pipeline that does not exist —
|
||||||
|
// it fell below the floor the first time this ran.
|
||||||
|
const groundPool = scenes.filter(canGround);
|
||||||
const temperament = look.personality && look.personality.temperament;
|
const temperament = look.personality && look.personality.temperament;
|
||||||
for (const section of look.sections) {
|
for (const section of look.sections) {
|
||||||
for (const variant of section.variants) {
|
for (const variant of section.variants) {
|
||||||
for (const layer of variant) {
|
for (const layer of variant) {
|
||||||
layer.module = rng.pick(pool);
|
layer.module = rng.pick(isGround(layer) ? groundPool : pool);
|
||||||
layer.params = sampleValues(layer.module, rng, section.bias, temperament);
|
layer.params = sampleValues(layer.module, rng, section.bias, temperament);
|
||||||
layer.seed = rng.int(0, 0x7fffffff);
|
layer.seed = rng.int(0, 0x7fffffff);
|
||||||
}
|
}
|
||||||
@ -435,7 +443,7 @@ export function measureSpecDiversity(track, { seeds = 32, seed0 = 0x5eed } = {})
|
|||||||
grain: entropy(looks.map((l) => l.grain.mode)),
|
grain: entropy(looks.map((l) => l.grain.mode)),
|
||||||
framing: entropy(looks.map((l) => l.framing.mode)),
|
framing: entropy(looks.map((l) => l.framing.mode)),
|
||||||
paletteArc: entropy(looks.map((l) => l.paletteArc.mode)),
|
paletteArc: entropy(looks.map((l) => l.paletteArc.mode)),
|
||||||
anchorScenes: entropy(looks.map((l) => l.sections.map((s) => s.layers[0].module.name).join('>'))),
|
anchorScenes: entropy(looks.map((l) => l.sections.map((s) => subjectOf(s.layers).module.name).join('>'))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,23 @@ void main() {
|
|||||||
else if (u_mode == 3) result = mix(base.rgb, base.rgb * src.rgb, a); // multiply
|
else if (u_mode == 3) result = mix(base.rgb, base.rgb * src.rgb, a); // multiply
|
||||||
else if (u_mode == 4) result = mix(base.rgb, blendOverlay(base.rgb, src.rgb), a);
|
else if (u_mode == 4) result = mix(base.rgb, blendOverlay(base.rgb, src.rgb), a);
|
||||||
else if (u_mode == 5) result = mix(base.rgb, blendSoftLight(base.rgb, src.rgb), a);
|
else if (u_mode == 5) result = mix(base.rgb, blendSoftLight(base.rgb, src.rgb), a);
|
||||||
|
// LUMAKEY — the source's own brightness is its alpha.
|
||||||
|
//
|
||||||
|
// For a layer that is a PICTURE with black around it, which is what a
|
||||||
|
// composable scene is: it replaces the base where it paints and leaves it
|
||||||
|
// where it does not. Screen was doing this job and screen is a lightening
|
||||||
|
// operator — over a filled ground it drives everything toward white, which
|
||||||
|
// measured as a median 24% of the frame clipped and whole sections at
|
||||||
|
// 100%. This keeps the shot's own colour instead of adding it to the bed's.
|
||||||
|
//
|
||||||
|
// The key is smoothstepped rather than raw luma so a dark-but-present shot
|
||||||
|
// does not dissolve into the ground, and gamma-ish weighted to keep thin
|
||||||
|
// bright lines opaque.
|
||||||
|
else if (u_mode == 6) {
|
||||||
|
float key = dot(src.rgb, vec3(0.2126, 0.7152, 0.0722));
|
||||||
|
key = smoothstep(0.02, 0.32, key);
|
||||||
|
result = mix(base.rgb, src.rgb, key * a);
|
||||||
|
}
|
||||||
else result = mix(base.rgb, src.rgb, a); // normal
|
else result = mix(base.rgb, src.rgb, a); // normal
|
||||||
|
|
||||||
gl_FragColor = vec4(result, max(base.a, a));
|
gl_FragColor = vec4(result, max(base.a, a));
|
||||||
@ -65,7 +82,26 @@ void main() {
|
|||||||
vec3 hist = texture2D(u_history, clamp(warped, 0.0, 1.0)).rgb;
|
vec3 hist = texture2D(u_history, clamp(warped, 0.0, 1.0)).rgb;
|
||||||
// Decay strictly below 1 keeps the loop convergent; the 10k-frame stability
|
// Decay strictly below 1 keeps the loop convergent; the 10k-frame stability
|
||||||
// check in tools/ verifies it neither saturates to white nor dies to black.
|
// check in tools/ verifies it neither saturates to white nor dies to black.
|
||||||
vec3 outC = cur + hist * u_decay * u_amount;
|
//
|
||||||
|
// NORMALISED, which it was not. cur + hist * decay * amount is an
|
||||||
|
// accumulator: a static image settles at 1/(1 - decay*amount) times its own
|
||||||
|
// brightness, which is 2.3x at the settings the generator hands out. That
|
||||||
|
// was survivable while a frame was a few bright things on black and stopped
|
||||||
|
// being survivable the moment every section stood on a filled ground —
|
||||||
|
// measured, entire sections rendered as pure white, and turning feedback off
|
||||||
|
// took the same frame from 100% blown to 34% mean luminance.
|
||||||
|
//
|
||||||
|
// Dividing by the gain keeps the trail — moving content still smears, which
|
||||||
|
// is the whole point — instead of stacking exposures.
|
||||||
|
//
|
||||||
|
// PARTIALLY, at 0.6, rather than all the way. Full normalisation is the
|
||||||
|
// mathematically tidy answer and it takes the lift out with the blowout:
|
||||||
|
// measured over 74 sections, the median frame went from 95% painted to 71%
|
||||||
|
// and fourteen fell under the black-frame floor. Feedback contributing SOME
|
||||||
|
// brightness is part of what the looks were built around. At 0.6 a still
|
||||||
|
// frame settles about 1.2x its drawn brightness instead of 2.3x.
|
||||||
|
float gain = u_decay * u_amount;
|
||||||
|
vec3 outC = (cur + hist * gain) / (1.0 + gain * 0.6);
|
||||||
gl_FragColor = vec4(min(outC, vec3(4.0)), 1.0);
|
gl_FragColor = vec4(min(outC, vec3(4.0)), 1.0);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@ -155,6 +191,19 @@ void main() {
|
|||||||
float v = 1.0 - u_vignette * dot(dir, dir) * 2.0;
|
float v = 1.0 - u_vignette * dot(dir, dir) * 2.0;
|
||||||
col *= clamp(v, 0.0, 1.0);
|
col *= clamp(v, 0.0, 1.0);
|
||||||
|
|
||||||
|
// HIGHLIGHT SHOULDER — the top end rolls off instead of clipping.
|
||||||
|
//
|
||||||
|
// Everything under the knee is untouched, so the image keeps its contrast;
|
||||||
|
// above it, values compress toward but never reach 1. Without this, bloom
|
||||||
|
// plus a filled ground plus a bright palette clips large areas to pure
|
||||||
|
// white — measured at 38% of a drop's frame — and clipped white is not
|
||||||
|
// bright, it is missing: every difference inside it is gone.
|
||||||
|
//
|
||||||
|
// Cheap, per channel, and deliberately not a full filmic curve. The job is
|
||||||
|
// to stop the frame flattening out at the top, not to grade it.
|
||||||
|
vec3 over = max(col - 0.75, vec3(0.0));
|
||||||
|
col = min(col, vec3(0.75)) + over / (1.0 + over * 4.0);
|
||||||
|
|
||||||
// Deterministic grain: keyed on frame index, never on a random source.
|
// Deterministic grain: keyed on frame index, never on a random source.
|
||||||
//
|
//
|
||||||
// Cell size and refresh rate are separate on purpose. Fine-and-boiling is
|
// Cell size and refresh rate are separate on purpose. Fine-and-boiling is
|
||||||
@ -204,4 +253,5 @@ void main() { gl_FragColor = texture2D(u_tex, vUv); }
|
|||||||
|
|
||||||
export const BLEND_MODE_IDS = {
|
export const BLEND_MODE_IDS = {
|
||||||
normal: 0, add: 1, screen: 2, multiply: 3, overlay: 4, softlight: 5,
|
normal: 0, add: 1, screen: 2, multiply: 3, overlay: 4, softlight: 5,
|
||||||
|
lumakey: 6,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import { shiftPalette } from './palette.js';
|
|||||||
import { frameShot, neutralFraming } from './framing.js';
|
import { frameShot, neutralFraming } from './framing.js';
|
||||||
import { planGaze, gazeAt } from './Camera.js';
|
import { planGaze, gazeAt } from './Camera.js';
|
||||||
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
|
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
|
||||||
|
import { subjectIndexOf, isGround } from './stack.js';
|
||||||
|
import { groundPersonalityFrom } from '../scenes/surface.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives the look across the song.
|
* Drives the look across the song.
|
||||||
@ -597,7 +599,7 @@ export class ArcDriver {
|
|||||||
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
||||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||||
layer.setPalette(palette);
|
layer.setPalette(palette);
|
||||||
layer.setPersonality(personality);
|
layer.setPersonality(isGround(spec) ? groundPersonalityFrom(personality) : personality);
|
||||||
layer.setFraming(outgoingFraming);
|
layer.setFraming(outgoingFraming);
|
||||||
layers.push(layer);
|
layers.push(layer);
|
||||||
}
|
}
|
||||||
@ -610,7 +612,7 @@ export class ArcDriver {
|
|||||||
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
||||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||||
layer.setPalette(palette);
|
layer.setPalette(palette);
|
||||||
layer.setPersonality(personality);
|
layer.setPersonality(isGround(spec) ? groundPersonalityFrom(personality) : personality);
|
||||||
layer.setFraming(framing);
|
layer.setFraming(framing);
|
||||||
layers.push(layer);
|
layers.push(layer);
|
||||||
}
|
}
|
||||||
@ -622,7 +624,11 @@ export class ArcDriver {
|
|||||||
variant: cue.variant,
|
variant: cue.variant,
|
||||||
kind: section.kind,
|
kind: section.kind,
|
||||||
crossfade: fading ? eased : 0,
|
crossfade: fading ? eased : 0,
|
||||||
sceneName: this._specFor(cue.sectionIndex, cue.variant, 0).module.name,
|
// The SHOT's name, not the ground's. Every stack starts with a bed
|
||||||
|
// now, and the HUD naming it would report the same handful of
|
||||||
|
// canvases for every section of every video.
|
||||||
|
sceneName: this._specFor(
|
||||||
|
cue.sectionIndex, cue.variant, this._subjectSlot(cue)).module.name,
|
||||||
buildSlope: features ? features.buildSlope || 0 : 0,
|
buildSlope: features ? features.buildSlope || 0 : 0,
|
||||||
// Where the story is, for the HUD and the checks. A video that is
|
// Where the story is, for the HUD and the checks. A video that is
|
||||||
// supposed to be going somewhere should be able to say where.
|
// supposed to be going somewhere should be able to say where.
|
||||||
@ -636,10 +642,18 @@ export class ArcDriver {
|
|||||||
return layers;
|
return layers;
|
||||||
}
|
}
|
||||||
|
|
||||||
_stackSize(cue) {
|
/** Which slot of a cue's stack is the shot. See look/stack.js. */
|
||||||
|
_subjectSlot(cue) {
|
||||||
|
return subjectIndexOf(this._stackFor(cue));
|
||||||
|
}
|
||||||
|
|
||||||
|
_stackFor(cue) {
|
||||||
const section = this.look.sections[cue.sectionIndex];
|
const section = this.look.sections[cue.sectionIndex];
|
||||||
const stack = (section.variants && section.variants[cue.variant]) || section.layers;
|
return (section.variants && section.variants[cue.variant]) || section.layers;
|
||||||
return stack.length;
|
}
|
||||||
|
|
||||||
|
_stackSize(cue) {
|
||||||
|
return this._stackFor(cue).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Layers changed identity — the compositor needs the new list. */
|
/** Layers changed identity — the compositor needs the new list. */
|
||||||
|
|||||||
@ -7,13 +7,20 @@
|
|||||||
import { Rng, hashSamples } from '../engine/rng.js';
|
import { Rng, hashSamples } from '../engine/rng.js';
|
||||||
import { AudioPalette, generateUsablePalette } from './palette.js';
|
import { AudioPalette, generateUsablePalette } from './palette.js';
|
||||||
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
|
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
|
||||||
import { sampleValues, defaultValues, surfaceOf, canBackground } from '../params/schema.js';
|
import { sampleValues, defaultValues, canBackground } from '../params/schema.js';
|
||||||
|
import {
|
||||||
|
canGround, surfaceOf, structuralDistance, groundBiasFrom, groundTemperamentFrom,
|
||||||
|
coverageOf as sceneCoverage, GROUND_MIN,
|
||||||
|
} from '../scenes/surface.js';
|
||||||
|
import { GROUND, subjectOf } from './stack.js';
|
||||||
import { planShots } from './shots.js';
|
import { planShots } from './shots.js';
|
||||||
import {
|
import {
|
||||||
generatePersonality, sceneHonours, signatureWeight, describePersonality,
|
generatePersonality, sceneHonours, signatureWeight, describePersonality,
|
||||||
} from './Personality.js';
|
} from './Personality.js';
|
||||||
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
|
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
|
||||||
import { pickDirector, directorByName } from './directors.js';
|
import {
|
||||||
|
pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
|
||||||
|
} from './directors.js';
|
||||||
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
|
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
|
||||||
import { deriveFramingStyle, describeFraming } from './framing.js';
|
import { deriveFramingStyle, describeFraming } from './framing.js';
|
||||||
import { deriveCamera, describeCamera } from './Camera.js';
|
import { deriveCamera, describeCamera } from './Camera.js';
|
||||||
@ -208,6 +215,120 @@ function rosterSizeFor(kind) {
|
|||||||
return (KIND_ENERGY[kind] ?? 0.5) > 0.5 ? 4 : 3;
|
return (KIND_ENERGY[kind] ?? 0.5) > 0.5 ? 4 : 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most painted frame a stack is allowed to add up to.
|
||||||
|
*
|
||||||
|
* 1.0 is one filled picture. 2.0 is two of them stacked, which is where the
|
||||||
|
* compositor's blends stop producing depth and start producing mud — past it
|
||||||
|
* the layers are no longer readable as separate things, so nothing is gained
|
||||||
|
* by the third pass except cost.
|
||||||
|
*/
|
||||||
|
const MAX_COVERAGE = 2.0;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How full THIS section's frame is allowed to get, in painted coverage.
|
||||||
|
*
|
||||||
|
* Three inputs, in the order they matter:
|
||||||
|
*
|
||||||
|
* the director — how much this point of view lets happen at once. A
|
||||||
|
* brutalist video is one large thing everywhere in it; a
|
||||||
|
* corrupt one is everything over everything. See crowdOf.
|
||||||
|
* the song — a loud, dense section carries more than a quiet one.
|
||||||
|
* the stage — where the story is. `population` is literally how crowded
|
||||||
|
* this point in the video wants to be, and layering is the
|
||||||
|
* one lever on it that needs no cooperation from the scenes.
|
||||||
|
*
|
||||||
|
* The floor is GROUND_MIN because the ground is not optional: a section always
|
||||||
|
* pays for its bed first, and the budget governs what may be stacked on it.
|
||||||
|
*/
|
||||||
|
function coverageBudgetFor(bias, story, director) {
|
||||||
|
const stage = story ? story.population * 0.6 + story.tension * 0.4 : 0.5;
|
||||||
|
const want = 0.6 + bias.energy * 0.5 + bias.density * 0.2 + (stage - 0.5) * 0.5;
|
||||||
|
return Math.max(GROUND_MIN, Math.min(MAX_COVERAGE, want * crowdOf(director)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The budget a KIND is planned against, before a section's own bias exists. */
|
||||||
|
function kindBudget(kind, director) {
|
||||||
|
return coverageBudgetFor(
|
||||||
|
{ energy: KIND_ENERGY[kind] ?? 0.5, density: 0.5 }, null, director);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GROUND a kind's sections stand on: a canvas that paints at least half the
|
||||||
|
* frame, cast once per kind so a section's cuts change the shot without moving
|
||||||
|
* the video to another world.
|
||||||
|
*
|
||||||
|
* Most of the library cannot do this job and is not supposed to — two thirds of
|
||||||
|
* it is composable, which means it reads as elements ON something and has
|
||||||
|
* nothing of its own behind them. Those scenes were being cast as backgrounds
|
||||||
|
* anyway, which is why a section could be a few bright things on black for
|
||||||
|
* ninety seconds. The ground is what they are on.
|
||||||
|
*
|
||||||
|
* Chosen against the kind's budget rather than at random: a scene that paints
|
||||||
|
* 98% of the frame is a legitimate ground for a drop and the wrong bed for an
|
||||||
|
* intro, because everything the intro puts on it has to remain visible.
|
||||||
|
*/
|
||||||
|
function castGround(kind, roster, rng, signature, director, used) {
|
||||||
|
const pool = scenes.filter(canGround);
|
||||||
|
if (!pool.length) return null;
|
||||||
|
|
||||||
|
const families = director.families[kind] || Object.keys(FAMILIES);
|
||||||
|
const quiet = QUIET_KINDS.includes(kind);
|
||||||
|
// What the shots standing on it will paint, so the ground leaves room for
|
||||||
|
// the section it is under.
|
||||||
|
const reserve = roster.length
|
||||||
|
? roster.reduce((sum, m) => sum + sceneCoverage(m), 0) / roster.length
|
||||||
|
: 0.2;
|
||||||
|
const headroom = kindBudget(kind, director) - reserve;
|
||||||
|
|
||||||
|
const weights = pool.map((m) => {
|
||||||
|
const at = families.indexOf(m.family);
|
||||||
|
// Off-family grounds stay reachable — the ground is a bed, not the
|
||||||
|
// director's statement — but the director still leads.
|
||||||
|
let w = at >= 0 ? families.length - at : 0.35;
|
||||||
|
// The quiet-kind rule applies to the floor as well. An intro standing on
|
||||||
|
// a strobing glitch canvas is the mistake that rule exists to prevent,
|
||||||
|
// and it is worse underneath than on top because nothing hides it.
|
||||||
|
if (quiet && !RESTFUL_FAMILIES.includes(m.family)) w *= 0.15;
|
||||||
|
w *= signatureWeight(m, signature);
|
||||||
|
// Overshooting the budget is allowed and discouraged: the ground is
|
||||||
|
// mandatory, so an oversized one is spent frame the shot cannot use.
|
||||||
|
w /= 1 + 4 * Math.max(0, sceneCoverage(m) - headroom);
|
||||||
|
// The bed has to be unlike the things standing on it, or the section is
|
||||||
|
// one texture at double density. Measured against the whole roster,
|
||||||
|
// because every member of it will be shot against this ground.
|
||||||
|
w *= contrastWeight(m, roster.map((r) => ({ module: r })));
|
||||||
|
// A video returns to its world rather than visiting six of them.
|
||||||
|
if (used.has(m.name)) w *= 3;
|
||||||
|
// Nothing stands on itself. If the kind's own anchor is groundable it
|
||||||
|
// will be its own ground in buildStack, and this pick is for the rest.
|
||||||
|
if (roster.some((r) => r.name === m.name)) w *= 0.1;
|
||||||
|
return Math.max(1e-4, w);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ground = rng.pickWeighted(pool, weights);
|
||||||
|
used.add(ground.name);
|
||||||
|
return ground;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One ground per section kind. See castGround. */
|
||||||
|
function assignGroundsByKind(rosterByKind, rng, signature, director) {
|
||||||
|
const grounds = new Map();
|
||||||
|
const used = new Set();
|
||||||
|
// Loud kinds first, for the same reason rosters are assigned that way: they
|
||||||
|
// are what the video is remembered for, so they choose their world first.
|
||||||
|
const priority = ['drop', 'sustain', 'build', 'breakdown', 'intro', 'outro'];
|
||||||
|
const kinds = [...rosterByKind.keys()]
|
||||||
|
.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));
|
||||||
|
for (const kind of kinds) {
|
||||||
|
grounds.set(kind, castGround(
|
||||||
|
kind, rosterByKind.get(kind) || [], rng.fork(`ground:${kind}`),
|
||||||
|
signature, director, used));
|
||||||
|
}
|
||||||
|
return grounds;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scenes are chosen per section KIND, not per section — and a kind gets a
|
* Scenes are chosen per section KIND, not per section — and a kind gets a
|
||||||
* ROSTER of two or three, not one.
|
* ROSTER of two or three, not one.
|
||||||
@ -341,10 +462,49 @@ function derivePost(summary, rng, grain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One layer stack: a background scene, and sometimes one or two composable
|
* How much a candidate would add to a stack, structurally.
|
||||||
* scenes composited over it.
|
|
||||||
*
|
*
|
||||||
* background — the shot. Always present, always opaque.
|
* The question a stack has to answer is not "are these two scenes different
|
||||||
|
* things" but "will a viewer see two things". Those come apart: the measured
|
||||||
|
* distance between two scenes' structural profiles is what a viewer reads, and
|
||||||
|
* it does not follow the family labels. So a candidate is weighted by how far
|
||||||
|
* it sits from everything already in the stack, taking the CLOSEST such
|
||||||
|
* distance — one near-twin in the stack is enough to make the addition read as
|
||||||
|
* more of the same, however unlike the other layers it is.
|
||||||
|
*
|
||||||
|
* A lean, not a filter, and for the same reason the signature weighting is:
|
||||||
|
* measured distances are a description of the library as it is today, and a
|
||||||
|
* generator that obeyed them exactly would cast the same handful of contrasts
|
||||||
|
* in every video. Unmeasured scenes score neutral rather than zero — never
|
||||||
|
* having been rendered is not evidence of sameness.
|
||||||
|
*/
|
||||||
|
const CONTRAST_NEUTRAL = 0.12;
|
||||||
|
|
||||||
|
function contrastWeight(candidate, stack) {
|
||||||
|
let closest = Infinity;
|
||||||
|
for (const layer of stack) {
|
||||||
|
const d = structuralDistance(candidate, layer.module);
|
||||||
|
if (d !== null) closest = Math.min(closest, d);
|
||||||
|
}
|
||||||
|
if (closest === Infinity) closest = CONTRAST_NEUTRAL;
|
||||||
|
// 0.02 apart (twins) → 0.25; 0.12 (typical) → 1.0; 0.30 (unalike) → 2.1.
|
||||||
|
return Math.max(0.15, Math.min(2.5, 0.15 + (closest / CONTRAST_NEUTRAL) * 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One layer stack: the ground, the shot standing on it, and sometimes a pass
|
||||||
|
* or two composited over both.
|
||||||
|
*
|
||||||
|
* ground — a canvas painting at least half the frame. Always present,
|
||||||
|
* always opaque, and usually NOT the scene the section is about:
|
||||||
|
* two thirds of the library is composable, and a composable
|
||||||
|
* scene on its own is a few bright things on black. It is the
|
||||||
|
* one layer the section does not choose freely — see castGround.
|
||||||
|
* When the shot is itself a full canvas it IS the ground, because
|
||||||
|
* two canvases stacked is two pictures fighting.
|
||||||
|
* shot — what the section is about. Screened over the ground rather
|
||||||
|
* than replacing it, so what it does not paint is the ground
|
||||||
|
* rather than black. `subjectOf` finds it; see look/stack.js.
|
||||||
* overlay — a composable scene at partial opacity. Not always: this is the
|
* overlay — a composable scene at partial opacity. Not always: this is the
|
||||||
* variation valve, and a stack that always doubled up would read
|
* variation valve, and a stack that always doubled up would read
|
||||||
* as permanently cluttered rather than as occasionally layered.
|
* as permanently cluttered rather than as occasionally layered.
|
||||||
@ -360,14 +520,80 @@ function derivePost(summary, rng, grain) {
|
|||||||
*
|
*
|
||||||
* Quiet material mostly goes without any — an intro is supposed to be sparse.
|
* Quiet material mostly goes without any — an intro is supposed to be sparse.
|
||||||
*/
|
*/
|
||||||
function buildStack(module, overlayRoster, bias, rng, temperament, story = null) {
|
function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||||
const layers = [{
|
{ ground = null, director = null, kind = null } = {}) {
|
||||||
|
const sectionKind = kind;
|
||||||
|
// A shot that fills the frame by itself is its own ground; anything else
|
||||||
|
// gets one under it.
|
||||||
|
const standsAlone = canGround(module) || !ground;
|
||||||
|
|
||||||
|
// --- the blaze ------------------------------------------------------
|
||||||
|
// Whether THIS section is one the director lets bloom out: the shot added
|
||||||
|
// to its ground rather than keyed onto it, so the two brightnesses sum and
|
||||||
|
// the highlights go to paper.
|
||||||
|
//
|
||||||
|
// A decision, and a rationed one. Screening every shot over its ground is
|
||||||
|
// how a median quarter of every frame in every video ended up clipped —
|
||||||
|
// the effect was not wrong, being the default was. It has to be earned:
|
||||||
|
// the director's appetite, times a loud section, times a late point in the
|
||||||
|
// story. Quiet kinds never blaze; a breakdown that goes white is not a
|
||||||
|
// decision, it is a bug with a rationale.
|
||||||
|
const blaze = !standsAlone
|
||||||
|
&& !QUIET_KINDS.includes(sectionKind)
|
||||||
|
&& rng.bool(blazeOf(director) * clamp01(bias.energy * 1.2)
|
||||||
|
* (story ? 0.4 + story.tension * 0.9 : 0.7));
|
||||||
|
|
||||||
|
// The shot is sampled first and from the caller's rng, so a stack draws the
|
||||||
|
// same shot it always did and the ground arrives underneath it rather than
|
||||||
|
// in front of it in the seed stream.
|
||||||
|
const shot = {
|
||||||
module,
|
module,
|
||||||
params: sampleValues(module, rng, bias, temperament),
|
params: sampleValues(module, rng, bias, temperament),
|
||||||
seed: rng.int(0, 0x7fffffff),
|
seed: rng.int(0, 0x7fffffff),
|
||||||
|
// Keyed over the ground on its own brightness by default: what the shot
|
||||||
|
// leaves unpainted is then the ground rather than black, which is the
|
||||||
|
// whole point of standing it on one, and what it DOES paint stays its
|
||||||
|
// own colour. 'screen' is the blaze — see above, and passes.js.
|
||||||
|
blend: standsAlone ? 'normal' : (blaze ? 'screen' : 'lumakey'),
|
||||||
|
// Carried so the HUD, the checks and a later pass over the look can all
|
||||||
|
// tell a deliberate bloom-out from a broken one.
|
||||||
|
blaze,
|
||||||
|
opacity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- ground ---------------------------------------------------------
|
||||||
|
// Sampled calmer and sparser than it would be as a shot, because a bed the
|
||||||
|
// shot cannot be read against is not a bed.
|
||||||
|
const layers = [];
|
||||||
|
if (!standsAlone) {
|
||||||
|
const groundRng = rng.fork(`ground:${ground.name}`);
|
||||||
|
layers.push({
|
||||||
|
module: ground,
|
||||||
|
role: GROUND,
|
||||||
|
// Calmed, but NOT thinned — see GROUND_BIAS in scenes/surface.js,
|
||||||
|
// which is also the bias the ground was MEASURED at. The first
|
||||||
|
// version subtracted 0.3 from density here, which is exactly
|
||||||
|
// backwards for a bed: an intro is already biased sparse, so the
|
||||||
|
// ground came out at density zero and the section was thin again
|
||||||
|
// for a new reason. Three of forty rendered sections fell under 30%
|
||||||
|
// painted with a ground under every one of them.
|
||||||
|
params: sampleValues(ground, groundRng, groundBiasFrom(bias),
|
||||||
|
groundTemperamentFrom(temperament)),
|
||||||
|
seed: groundRng.int(0, 0x7fffffff),
|
||||||
blend: 'normal',
|
blend: 'normal',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
}];
|
});
|
||||||
|
}
|
||||||
|
layers.push(shot);
|
||||||
|
|
||||||
|
// --- the budget -----------------------------------------------------
|
||||||
|
// What is on the frame so far, and how much more this section is allowed
|
||||||
|
// to put on it. See coverageBudgetFor: the ground and the shot are not
|
||||||
|
// negotiable, so the budget governs the passes over them — a quiet intro
|
||||||
|
// spends everything on its bed and stacks nothing, a crowded drop under a
|
||||||
|
// director with an appetite for it gets two passes.
|
||||||
|
const budget = coverageBudgetFor(bias, story, director);
|
||||||
|
let spent = layers.reduce((sum, l) => sum + sceneCoverage(l.module), 0);
|
||||||
|
|
||||||
// --- overlay --------------------------------------------------------
|
// --- overlay --------------------------------------------------------
|
||||||
// Roughly a third of stacks on busy material, rarely on quiet material, and
|
// Roughly a third of stacks on busy material, rarely on quiet material, and
|
||||||
@ -410,10 +636,26 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null)
|
|||||||
Math.min(0.25, overlayChance * bias.energy * 0.5),
|
Math.min(0.25, overlayChance * bias.energy * 0.5),
|
||||||
];
|
];
|
||||||
for (const [pass, chance] of chances.entries()) {
|
for (const [pass, chance] of chances.entries()) {
|
||||||
if (!available.length || !rng.bool(chance)) break;
|
// The budget is a wall, and it is also a lean: as the frame fills up
|
||||||
// Prefer a different family so the images argue instead of blurring.
|
// the odds of adding to it fall away before the wall is reached, so a
|
||||||
|
// stack that is already nearly full rarely gets a token last pass.
|
||||||
|
const headroom = budget - spent;
|
||||||
|
const fits = available.filter((m) => sceneCoverage(m) <= headroom);
|
||||||
|
if (!fits.length || !rng.bool(chance * clamp01(headroom / 0.35))) break;
|
||||||
|
available = fits;
|
||||||
|
// Prefer a different family so the images argue instead of blurring —
|
||||||
|
// and then, within that, prefer the ones that MEASURE different.
|
||||||
|
//
|
||||||
|
// Family is a label somebody typed; structural distance is what the
|
||||||
|
// gallery saw when it rendered the two scenes under the same six songs.
|
||||||
|
// They disagree often enough to matter: two 'geometric' scenes can be
|
||||||
|
// 0.31 apart and a 'flow' and an 'organic' scene 0.04, and stacking the
|
||||||
|
// second pair is one picture at double density rather than a picture
|
||||||
|
// with something happening in it. See scenes/surface.js.
|
||||||
const offFamily = available.filter((m) => m.family !== module.family);
|
const offFamily = available.filter((m) => m.family !== module.family);
|
||||||
const overlay = rng.pick(offFamily.length ? offFamily : available);
|
const pool = offFamily.length ? offFamily : available;
|
||||||
|
const overlay = rng.pickWeighted(pool, pool.map((m) => contrastWeight(m, layers)));
|
||||||
|
spent += sceneCoverage(overlay);
|
||||||
available = available.filter((m) => m.name !== overlay.name
|
available = available.filter((m) => m.name !== overlay.name
|
||||||
&& m.family !== overlay.family);
|
&& m.family !== overlay.family);
|
||||||
// Screen and add keep the background readable underneath; softlight and
|
// Screen and add keep the background readable underneath; softlight and
|
||||||
@ -486,6 +728,10 @@ export function generateLook(track, {
|
|||||||
const rosterByKind = assignRostersByKind(
|
const rosterByKind = assignRostersByKind(
|
||||||
track.sections, rng.fork('scenes'), personality.signature, director, pool);
|
track.sections, rng.fork('scenes'), personality.signature, director, pool);
|
||||||
if (story.recap) applyRecap(rosterByKind);
|
if (story.recap) applyRecap(rosterByKind);
|
||||||
|
// What each kind's sections stand on. After the recap, so the outro is
|
||||||
|
// grounded against the roster it actually ends up with.
|
||||||
|
const groundByKind = assignGroundsByKind(
|
||||||
|
rosterByKind, rng.fork('grounds'), personality.signature, director);
|
||||||
// The grain treatment: usually none, and when present described rather than
|
// The grain treatment: usually none, and when present described rather than
|
||||||
// dialled. See look/grain.js.
|
// dialled. See look/grain.js.
|
||||||
const grain = deriveGrain(summary, rng.fork('grain'));
|
const grain = deriveGrain(summary, rng.fork('grain'));
|
||||||
@ -523,9 +769,12 @@ export function generateLook(track, {
|
|||||||
// so the last occurrence of a kind samples further out than the first.
|
// so the last occurrence of a kind samples further out than the first.
|
||||||
const temperament = temperamentFor(personality.temperament, state);
|
const temperament = temperamentFor(personality.temperament, state);
|
||||||
|
|
||||||
|
const ground = groundByKind.get(section.kind) || null;
|
||||||
|
|
||||||
const variants = roster.map((module, v) => buildStack(
|
const variants = roster.map((module, v) => buildStack(
|
||||||
module, overlayRoster, bias,
|
module, overlayRoster, bias,
|
||||||
sectionRng.fork(`variant:${section.index}:${v}`), temperament, state,
|
sectionRng.fork(`variant:${section.index}:${v}`), temperament, state,
|
||||||
|
{ ground, director, kind: section.kind },
|
||||||
));
|
));
|
||||||
|
|
||||||
const shots = planShots(
|
const shots = planShots(
|
||||||
@ -579,7 +828,8 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
|||||||
|
|
||||||
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
|
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
|
||||||
const signature = (look.personality && look.personality.signature) || [];
|
const signature = (look.personality && look.personality.signature) || [];
|
||||||
const families = directorByName(look.director).families[section.kind] || Object.keys(FAMILIES);
|
const director = directorByName(look.director);
|
||||||
|
const families = director.families[section.kind] || Object.keys(FAMILIES);
|
||||||
// A reroll re-draws this section's cast from the same kind of pool the track
|
// A reroll re-draws this section's cast from the same kind of pool the track
|
||||||
// was built with, weighted by the signature rather than filtered by it.
|
// was built with, weighted by the signature rather than filtered by it.
|
||||||
let candidates = families.flatMap((f) => scenesInFamily(f))
|
let candidates = families.flatMap((f) => scenesInFamily(f))
|
||||||
@ -602,9 +852,14 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
|||||||
// A reroll changes what this section is made of. Where it sits in the story
|
// A reroll changes what this section is made of. Where it sits in the story
|
||||||
// is a property of the song, so it survives untouched.
|
// is a property of the song, so it survives untouched.
|
||||||
const state = section.story || NEUTRAL_STATE;
|
const state = section.story || NEUTRAL_STATE;
|
||||||
|
// The section is re-cast, so its ground is re-cast with it — a reroll that
|
||||||
|
// kept the old bed under new shots would be answering half the question.
|
||||||
|
const ground = castGround(
|
||||||
|
section.kind, roster, rng.fork('ground'), signature, director, new Set());
|
||||||
section.variants = roster.map((module, v) => buildStack(
|
section.variants = roster.map((module, v) => buildStack(
|
||||||
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
||||||
temperamentFor(look.personality && look.personality.temperament, state), state,
|
temperamentFor(look.personality && look.personality.temperament, state), state,
|
||||||
|
{ ground, director, kind: section.kind },
|
||||||
));
|
));
|
||||||
section.shots = planShots(
|
section.shots = planShots(
|
||||||
section, track, section.bias, section.variants.length, rng.fork('shots'), state,
|
section, track, section.bias, section.variants.length, rng.fork('shots'), state,
|
||||||
@ -633,7 +888,7 @@ function applyOverrides(look, overrides) {
|
|||||||
overrides.sections.forEach((o, i) => {
|
overrides.sections.forEach((o, i) => {
|
||||||
if (!look.sections[i]) return;
|
if (!look.sections[i]) return;
|
||||||
if (o.locked !== undefined) look.sections[i].locked = o.locked;
|
if (o.locked !== undefined) look.sections[i].locked = o.locked;
|
||||||
if (o.params) Object.assign(look.sections[i].layers[0].params, o.params);
|
if (o.params) Object.assign(subjectOf(look.sections[i].layers).params, o.params);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return look;
|
return look;
|
||||||
@ -641,7 +896,7 @@ function applyOverrides(look, overrides) {
|
|||||||
|
|
||||||
/** Compact description, used by the HUD and by check output. */
|
/** Compact description, used by the HUD and by check output. */
|
||||||
export function describeLook(look) {
|
export function describeLook(look) {
|
||||||
const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`);
|
const kinds = look.sections.map((s) => `${s.kind}:${subjectOf(s.layers).module.name}`);
|
||||||
return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
|
return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
|
||||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||||
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
||||||
|
|||||||
@ -35,7 +35,34 @@
|
|||||||
* when it is being quiet.
|
* when it is being quiet.
|
||||||
*/
|
*/
|
||||||
export const RESTFUL_FAMILIES = ['minimal', 'flow', 'organic'];
|
export const RESTFUL_FAMILIES = ['minimal', 'flow', 'organic'];
|
||||||
const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
export const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How full a director lets the frame get.
|
||||||
|
*
|
||||||
|
* Scales the coverage budget in LookGenerator — the painted-area ceiling a
|
||||||
|
* section's stack is built against, where 1.0 is one filled frame and 2.0 is
|
||||||
|
* the hard cap. A point of view about what a song looks like includes how much
|
||||||
|
* is allowed to be happening at once, and it is the difference between a
|
||||||
|
* brutalist drop (one big thing) and a corrupt one (everything, over
|
||||||
|
* everything). Absent, a director is read as 1.2.
|
||||||
|
*/
|
||||||
|
const DEFAULT_CROWD = 1.2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How willing a director is to let the frame BLAZE.
|
||||||
|
*
|
||||||
|
* A blaze is the shot composited additively over its ground instead of keyed
|
||||||
|
* onto it, so the two brightnesses sum and the highlights bloom out. It is a
|
||||||
|
* real effect and worth having — a drop that goes white for eight bars reads as
|
||||||
|
* the song peaking — and it was, until it was made a decision, simply what
|
||||||
|
* every section did: screen over a filled ground blew a median quarter of every
|
||||||
|
* frame to paper, all the time, in every video.
|
||||||
|
*
|
||||||
|
* So it is rationed. This is the appetite; the section still has to be loud and
|
||||||
|
* late in the story to earn one. Absent, a director is read as 0.15.
|
||||||
|
*/
|
||||||
|
const DEFAULT_BLAZE = 0.15;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each director maps every section kind to three families, most-preferred
|
* Each director maps every section kind to three families, most-preferred
|
||||||
@ -45,6 +72,11 @@ const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
|||||||
export const DIRECTORS = [
|
export const DIRECTORS = [
|
||||||
{
|
{
|
||||||
name: 'ambient',
|
name: 'ambient',
|
||||||
|
// Space, not glare. A blaze here is the exception that proves it.
|
||||||
|
blaze: 0.1,
|
||||||
|
// Space is the subject, so the frame stays owed to it: one
|
||||||
|
// picture, and a pass over it only when the song is at its loudest.
|
||||||
|
crowd: 1.1,
|
||||||
// Patient camera to match: long moves, mostly along one line. See
|
// Patient camera to match: long moves, mostly along one line. See
|
||||||
// look/Camera.js — a director's point of view now includes how it
|
// look/Camera.js — a director's point of view now includes how it
|
||||||
// shoots, not only what it points at.
|
// shoots, not only what it points at.
|
||||||
@ -63,6 +95,11 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'brutalist',
|
name: 'brutalist',
|
||||||
|
// Mass does not glow. Almost never.
|
||||||
|
blaze: 0.05,
|
||||||
|
// One large thing, seen whole. Layering hides mass, which is the
|
||||||
|
// only thing this director is interested in.
|
||||||
|
crowd: 1.05,
|
||||||
// Holds, then commits to one large move. Architecture is looked AT.
|
// Holds, then commits to one large move. Architecture is looked AT.
|
||||||
camera: 'deliberate',
|
camera: 'deliberate',
|
||||||
// Everything is architecture. Quiet means empty rather than soft, so it
|
// Everything is architecture. Quiet means empty rather than soft, so it
|
||||||
@ -79,6 +116,11 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'organicist',
|
name: 'organicist',
|
||||||
|
// Light through leaves — bloom belongs to this world.
|
||||||
|
blaze: 0.2,
|
||||||
|
// Growth accumulates. Things overlap here because that is what
|
||||||
|
// living material does — nothing in this world is a single clean plate.
|
||||||
|
crowd: 1.45,
|
||||||
// Never settles, because nothing here is ever finished settling.
|
// Never settles, because nothing here is ever finished settling.
|
||||||
camera: 'roaming',
|
camera: 'roaming',
|
||||||
// Nothing is ever built; things grow and dissolve. Deliberately never
|
// Nothing is ever built; things grow and dissolve. Deliberately never
|
||||||
@ -95,6 +137,11 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'corrupt',
|
name: 'corrupt',
|
||||||
|
// Overload is the subject. Half its drops go white.
|
||||||
|
blaze: 0.5,
|
||||||
|
// Everything over everything. The damage is the subject and it is
|
||||||
|
// never confined to one plate.
|
||||||
|
crowd: 1.7,
|
||||||
// Cuts with the camera already moving.
|
// Cuts with the camera already moving.
|
||||||
camera: 'kinetic',
|
camera: 'kinetic',
|
||||||
// The signal is damaged and the damage is the subject — everywhere the
|
// The signal is damaged and the damage is the subject — everywhere the
|
||||||
@ -112,6 +159,11 @@ export const DIRECTORS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'geometer',
|
name: 'geometer',
|
||||||
|
// Exact, and occasionally exact and incandescent.
|
||||||
|
blaze: 0.15,
|
||||||
|
// Pattern on pattern is a moiré, which is a pattern. Layers are
|
||||||
|
// welcome as long as they are exact.
|
||||||
|
crowd: 1.3,
|
||||||
// Small, exact, always arrives — the pattern is the subject and the
|
// Small, exact, always arrives — the pattern is the subject and the
|
||||||
// camera does not editorialise about it.
|
// camera does not editorialise about it.
|
||||||
camera: 'precise',
|
camera: 'precise',
|
||||||
@ -156,6 +208,16 @@ export function pickDirector(summary, rng) {
|
|||||||
return rng.pickWeighted(DIRECTORS, weights);
|
return rng.pickWeighted(DIRECTORS, weights);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How full this director lets the frame get. See DEFAULT_CROWD. */
|
||||||
|
export function crowdOf(director) {
|
||||||
|
return (director && director.crowd) || DEFAULT_CROWD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How willing this director is to let a section blaze. See DEFAULT_BLAZE. */
|
||||||
|
export function blazeOf(director) {
|
||||||
|
return (director && director.blaze !== undefined) ? director.blaze : DEFAULT_BLAZE;
|
||||||
|
}
|
||||||
|
|
||||||
export function directorByName(name) {
|
export function directorByName(name) {
|
||||||
return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0];
|
return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0];
|
||||||
}
|
}
|
||||||
|
|||||||
55
flow-state/src/look/stack.js
Normal file
55
flow-state/src/look/stack.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// What a layer stack is made of, and how to ask it questions.
|
||||||
|
//
|
||||||
|
// A stack used to be "the scene, then whatever was composited over it", so
|
||||||
|
// `layers[0]` meant the section's scene everywhere in the program. It no longer
|
||||||
|
// does: every stack now starts with a GROUND — a filled canvas the shot happens
|
||||||
|
// on — and the scene the section is ABOUT sits above it.
|
||||||
|
//
|
||||||
|
// Everything that used to reach for `layers[0]` wants the subject, not the
|
||||||
|
// ground: the param panel edits it, the HUD names it, and the variety report
|
||||||
|
// counts it. Those all go through `subjectOf` now. Reading the ground as the
|
||||||
|
// section's scene would be actively wrong for the measurements — grounds come
|
||||||
|
// from a twenty-scene pool, so a report that counted them would show a library
|
||||||
|
// three times smaller than the one actually on screen.
|
||||||
|
|
||||||
|
import { coverageOf } from '../scenes/surface.js';
|
||||||
|
|
||||||
|
/** Layers carrying this role are the bed, not the shot. */
|
||||||
|
export const GROUND = 'ground';
|
||||||
|
|
||||||
|
export function isGround(layer) {
|
||||||
|
return !!layer && layer.role === GROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the layer the section is about. */
|
||||||
|
export function subjectIndexOf(stack) {
|
||||||
|
const at = stack.findIndex((l) => !isGround(l));
|
||||||
|
return at < 0 ? 0 : at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layer the section is about — the shot, as opposed to what it stands on. */
|
||||||
|
export function subjectOf(stack) {
|
||||||
|
return stack[subjectIndexOf(stack)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ground under a stack, or null if the subject is its own ground. */
|
||||||
|
export function groundOf(stack) {
|
||||||
|
return stack.find(isGround) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything above the subject: the passes composited over the shot. */
|
||||||
|
export function overlaysOf(stack) {
|
||||||
|
return stack.slice(subjectIndexOf(stack) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much painted frame a stack adds up to, in units of one filled frame.
|
||||||
|
*
|
||||||
|
* Deliberately a SUM and not a union: two layers each painting 60% do not add
|
||||||
|
* up to 120% of a screen, but they do add up to two things happening at once,
|
||||||
|
* and that is the quantity the budget is about. 2.0 is the ceiling — see
|
||||||
|
* MAX_COVERAGE in LookGenerator.
|
||||||
|
*/
|
||||||
|
export function stackCoverage(stack) {
|
||||||
|
return stack.reduce((sum, l) => sum + coverageOf(l.module), 0);
|
||||||
|
}
|
||||||
@ -7,6 +7,8 @@ import { toHex } from './look/palette.js';
|
|||||||
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
|
import { applyGrainToPost, describeGrain, GRAIN_MASKS, GRAIN_MODES } from './look/grain.js';
|
||||||
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
|
import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
|
||||||
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';
|
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported } from './export/Exporter.js';
|
||||||
|
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
|
||||||
|
import { coverageOf as sceneCoverage } from './scenes/surface.js';
|
||||||
|
|
||||||
const QUALITY = {
|
const QUALITY = {
|
||||||
draft: 0.5, // half resolution — for scrubbing heavy stacks
|
draft: 0.5, // half resolution — for scrubbing heavy stacks
|
||||||
@ -56,7 +58,7 @@ const paramPanel = new ParamPanel(document.createElement('div'), onParamChange);
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- loading
|
// ---------------------------------------------------------------- loading
|
||||||
|
|
||||||
async function loadFile(file) {
|
async function loadFile(file, { seed = null } = {}) {
|
||||||
if (state.busy) return;
|
if (state.busy) return;
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
@ -76,7 +78,7 @@ async function loadFile(file) {
|
|||||||
await state.show.load(file, (stage, fraction) => {
|
await state.show.load(file, (stage, fraction) => {
|
||||||
dom.thStep.textContent = stage;
|
dom.thStep.textContent = stage;
|
||||||
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
dom.thFill.style.width = `${Math.round((fraction || 0) * 100)}%`;
|
||||||
});
|
}, { seed });
|
||||||
|
|
||||||
dom.audio.src = URL.createObjectURL(file);
|
dom.audio.src = URL.createObjectURL(file);
|
||||||
dom.thLabel.textContent = 'loaded track';
|
dom.thLabel.textContent = 'loaded track';
|
||||||
@ -113,6 +115,35 @@ if (dom.changeTrack) dom.changeTrack.addEventListener('click', () => dom.fileInp
|
|||||||
dom.fileInput.addEventListener('change', (e) => {
|
dom.fileInput.addEventListener('change', (e) => {
|
||||||
if (e.target.files[0]) loadFile(e.target.files[0]);
|
if (e.target.files[0]) loadFile(e.target.files[0]);
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* `?song=centre` — open a bank song straight from a debug page.
|
||||||
|
*
|
||||||
|
* The filmstrip reduces a song to nine stills; the only way to argue with that
|
||||||
|
* reading is to watch the thing move, and until now that meant remembering
|
||||||
|
* which of seventeen near-identically-named wavs to drag in. `&seed=` carries
|
||||||
|
* the strip's seed across so the video you watch is the one it measured.
|
||||||
|
*
|
||||||
|
* Dev-only in practice: `test/songs/` is served by vite from the project root
|
||||||
|
* and is not part of a build. The name is matched against a bare word rather
|
||||||
|
* than the bank list so the app does not have to import the synth.
|
||||||
|
*/
|
||||||
|
async function loadBankSong(name, seed) {
|
||||||
|
if (!/^[a-z0-9_-]+$/i.test(name)) return;
|
||||||
|
const url = `/test/songs/${name}.wav`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
await loadFile(new File([blob], `${name}.wav`, { type: 'audio/wav' }), { seed });
|
||||||
|
} catch (err) {
|
||||||
|
dom.thLabel.textContent = 'no such song';
|
||||||
|
dom.thName.hidden = false;
|
||||||
|
dom.thName.textContent = name;
|
||||||
|
dom.thStep.textContent = `${url}: ${err.message} — run npm run build:songs`;
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('dragover', (e) => e.preventDefault());
|
document.addEventListener('dragover', (e) => e.preventDefault());
|
||||||
document.addEventListener('drop', (e) => {
|
document.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -265,7 +296,8 @@ function renderPanel() {
|
|||||||
|
|
||||||
if (state.tab === 'scene') {
|
if (state.tab === 'scene') {
|
||||||
paramPanel.container = dom.panelBody;
|
paramPanel.container = dom.panelBody;
|
||||||
paramPanel.build(section.layers[0].module, section.layers[0].params);
|
const subject = subjectOf(section.layers);
|
||||||
|
paramPanel.build(subject.module, subject.params);
|
||||||
|
|
||||||
// The section's stage visuals, with the one currently on screen marked.
|
// The section's stage visuals, with the one currently on screen marked.
|
||||||
// Params above edit the anchor (variant 0) — the image the section opens
|
// Params above edit the anchor (variant 0) — the image the section opens
|
||||||
@ -278,20 +310,29 @@ function renderPanel() {
|
|||||||
list.innerHTML = `<div class="pp-sub">stage visuals · ${shots.length} shots</div>` +
|
list.innerHTML = `<div class="pp-sub">stage visuals · ${shots.length} shots</div>` +
|
||||||
section.variants.map((stack, v) =>
|
section.variants.map((stack, v) =>
|
||||||
`<div class="pp-react-row${v === active ? ' current' : ''}">` +
|
`<div class="pp-react-row${v === active ? ' current' : ''}">` +
|
||||||
`<span>${v === 0 ? '●' : '○'} ${stack[0].module.name}</span>` +
|
`<span>${v === 0 ? '●' : '○'} ${subjectOf(stack).module.name}</span>` +
|
||||||
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}×</span>` +
|
`<span class="pp-feature">${shots.filter((s) => s.variant === v).length}×</span>` +
|
||||||
`</div>`).join('');
|
`</div>`).join('');
|
||||||
dom.panelBody.appendChild(list);
|
dom.panelBody.appendChild(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (section.layers.length > 1) {
|
// The rest of the stack, named by the job each layer is doing rather
|
||||||
|
// than by its index: what is under the shot and what is over it are
|
||||||
|
// different questions, and the panel used to call both "layered over".
|
||||||
|
const ground = groundOf(section.layers);
|
||||||
|
const overlays = overlaysOf(section.layers);
|
||||||
|
if (ground || overlays.length) {
|
||||||
|
const row = (l, label) =>
|
||||||
|
`<div class="pp-react-row"><span>${l.module.name}</span>` +
|
||||||
|
`<span class="pp-feature">${label}</span>` +
|
||||||
|
`<span class="pp-amount">${(sceneCoverage(l.module) * 100).toFixed(0)}%</span></div>`;
|
||||||
const note = document.createElement('div');
|
const note = document.createElement('div');
|
||||||
note.className = 'pp-reactive';
|
note.className = 'pp-reactive';
|
||||||
note.innerHTML = `<div class="pp-sub">layered over (${section.layers.length - 1})</div>` +
|
note.innerHTML =
|
||||||
section.layers.slice(1).map((l) =>
|
`<div class="pp-sub">stack · ${(stackCoverage(section.layers) * 100).toFixed(0)}% painted</div>` +
|
||||||
`<div class="pp-react-row"><span>${l.module.name}</span>` +
|
(ground ? row(ground, 'ground') : '') +
|
||||||
`<span class="pp-feature">${l.blend}</span>` +
|
row(subject, 'shot') +
|
||||||
`<span class="pp-amount">${l.opacity.toFixed(2)}</span></div>`).join('');
|
overlays.map((l) => row(l, l.blend)).join('');
|
||||||
dom.panelBody.appendChild(note);
|
dom.panelBody.appendChild(note);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -326,7 +367,7 @@ function renderPanel() {
|
|||||||
<div class="kv ${i === index ? 'current' : ''}">
|
<div class="kv ${i === index ? 'current' : ''}">
|
||||||
<span>${s.kind}${s.locked ? ' 🔒' : ''}
|
<span>${s.kind}${s.locked ? ' 🔒' : ''}
|
||||||
${s.shots ? `<i class="dim">${s.shots.length} shots</i>` : ''}</span>
|
${s.shots ? `<i class="dim">${s.shots.length} shots</i>` : ''}</span>
|
||||||
<b>${(s.variants || [s.layers]).map((v) => v[0].module.name).join(' / ')}</b>
|
<b>${(s.variants || [s.layers]).map((v) => subjectOf(v).module.name).join(' / ')}</b>
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
<button id="btn-metronome" class="wide">download click track</button>
|
<button id="btn-metronome" class="wide">download click track</button>
|
||||||
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
|
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
|
||||||
@ -664,3 +705,9 @@ resize();
|
|||||||
// "never going to start", and the failure it exists to catch is exactly the one
|
// "never going to start", and the failure it exists to catch is exactly the one
|
||||||
// that produces no error at all in the page.
|
// that produces no error at all in the page.
|
||||||
window.__FLOW_STATE_READY__ = true;
|
window.__FLOW_STATE_READY__ = true;
|
||||||
|
|
||||||
|
const query = new URLSearchParams(location.search);
|
||||||
|
if (query.get('song')) {
|
||||||
|
const seed = query.get('seed');
|
||||||
|
loadBankSong(query.get('song'), seed === null ? null : (Number(seed) >>> 0));
|
||||||
|
}
|
||||||
|
|||||||
@ -68,34 +68,26 @@ export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
|
|||||||
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging', 'form'];
|
export const ARTIFACT_NAMES = ['cast', 'ink', 'staging', 'form'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a scene is for, compositionally.
|
* Whether a scene's image depends on the FRAME BEFORE IT.
|
||||||
*
|
*
|
||||||
* canvas fills the frame. Belongs underneath, and two of them stacked is
|
* Read off the shader rather than declared, because the declaration would be a
|
||||||
* two pictures fighting rather than one picture with depth.
|
* second copy of something the source already says exactly: a scene reads
|
||||||
* composable mostly empty by design. Reads as elements ON something, and
|
* history if and only if it calls `prev()`.
|
||||||
* shown alone it is a few bright things on black — which scores
|
|
||||||
* well for variety and is thin to watch.
|
|
||||||
*
|
*
|
||||||
* The distinction was implicit in `role: 'accent'` and that was not enough: it
|
* What it decides is where a scene may sit in a stack. `prev()` returns the
|
||||||
* marked the depth passes and said nothing about the sixty scenes that are all
|
* whole composited previous frame — everything, including whatever was layered
|
||||||
* treated as interchangeable backgrounds despite half of them being sparse.
|
* ON TOP of this scene — so a datamosh or a time smear underneath a shot is not
|
||||||
|
* grounding it, it is recycling it. Two things follow, and both were measured
|
||||||
|
* the moment such a scene became a bed: the render stops being reproducible from
|
||||||
|
* a seek (state carries across frames that a seek has not rendered), and small
|
||||||
|
* numeric differences compound frame over frame instead of staying put — 91/255
|
||||||
|
* between two WebGL contexts, against a ceiling of 4.
|
||||||
*
|
*
|
||||||
* It also has to be DECLARED to mean anything. For a while the field existed,
|
* These scenes are exactly right as a shot or as a pass over one. They are only
|
||||||
* the generator gated layering on it, and no scene carried it — so nothing could
|
* wrong as the thing underneath. See canGround in scenes/surface.js.
|
||||||
* ever be layered and the one scene with `role: 'accent'` was the only thing
|
|
||||||
* that ever appeared on top of anything. The labels below come from the measured
|
|
||||||
* coverage the phase 12 gate prints; anything under 30% is composable.
|
|
||||||
*
|
|
||||||
* Verified rather than trusted — see the coverage gate in checks/phase12. A
|
|
||||||
* scene that claims to be composable and paints the whole frame will cover
|
|
||||||
* whatever it is layered over.
|
|
||||||
*/
|
*/
|
||||||
export const SURFACES = ['canvas', 'composable'];
|
export function readsHistory(module) {
|
||||||
|
return typeof module.shader === 'string' && /\bprev\s*\(/.test(module.shader);
|
||||||
/** What a scene is, defaulting to the behaviour it had before this existed. */
|
|
||||||
export function surfaceOf(module) {
|
|
||||||
if (module.surface) return module.surface;
|
|
||||||
return 'canvas';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -291,8 +283,11 @@ export function validateModule(module) {
|
|||||||
if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`);
|
if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (module.surface !== undefined && !SURFACES.includes(module.surface)) {
|
// `surface` was a declaration and is now measured — see scenes/surface.js.
|
||||||
errors.push(`${id}: unknown surface '${module.surface}' — one of ${SURFACES.join('/')}`);
|
// Rejected rather than ignored, so a scene file carrying a stale one is a
|
||||||
|
// loud error instead of a line that quietly means nothing.
|
||||||
|
if (module.surface !== undefined) {
|
||||||
|
errors.push(`${id}: 'surface' is derived from scenes/metadata.json — remove the declaration`);
|
||||||
}
|
}
|
||||||
if (module.consumes !== undefined) {
|
if (module.consumes !== undefined) {
|
||||||
if (!Array.isArray(module.consumes)) {
|
if (!Array.isArray(module.consumes)) {
|
||||||
|
|||||||
@ -19,7 +19,6 @@ export const particleField = {
|
|||||||
// top — every layered stack in every song was these particles — while the
|
// top — every layered stack in every song was these particles — while the
|
||||||
// general overlay path sat dead because nothing else was labelled. It is one
|
// general overlay path sat dead because nothing else was labelled. It is one
|
||||||
// composable scene among many now.
|
// composable scene among many now.
|
||||||
surface: 'composable',
|
|
||||||
// Points in empty space with no ground of their own — the one scene in the
|
// Points in empty space with no ground of their own — the one scene in the
|
||||||
// library that genuinely cannot carry a section alone. See schema.canBackground.
|
// library that genuinely cannot carry a section alone. See schema.canBackground.
|
||||||
background: false,
|
background: false,
|
||||||
|
|||||||
8190
flow-state/src/scenes/metadata.json
Normal file
8190
flow-state/src/scenes/metadata.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -20,8 +20,6 @@ export const aqueductMarch = {
|
|||||||
name: 'Aqueduct March',
|
name: 'Aqueduct March',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 27% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
texture: 0.9,
|
texture: 0.9,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
traits: ['shape', 'camera', 'space', 'style'],
|
traits: ['shape', 'camera', 'space', 'style'],
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const auroraVeil = {
|
|||||||
name: 'Aurora Veil',
|
name: 'Aurora Veil',
|
||||||
family: 'flow',
|
family: 'flow',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 13% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const cargoBelt = {
|
|||||||
name: 'Cargo Belt',
|
name: 'Cargo Belt',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 12% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const circuitBloom = {
|
|||||||
name: 'Circuit Bloom',
|
name: 'Circuit Bloom',
|
||||||
family: 'geometric',
|
family: 'geometric',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 1% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
|
|||||||
@ -8,8 +8,6 @@ export const curlFlow = {
|
|||||||
name: 'Curl Flow',
|
name: 'Curl Flow',
|
||||||
family: 'flow',
|
family: 'flow',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 21% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|||||||
@ -11,8 +11,6 @@ export const dataAisle = {
|
|||||||
name: 'Data Aisle',
|
name: 'Data Aisle',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 21% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
texture: 0.6,
|
texture: 0.6,
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|||||||
@ -21,8 +21,6 @@ export const drosteFeedback = {
|
|||||||
name: 'Droste Feedback',
|
name: 'Droste Feedback',
|
||||||
family: 'glitch',
|
family: 'glitch',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 2% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
texture: 0.5,
|
texture: 0.5,
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'style'],
|
traits: ['camera', 'style'],
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const dustChamber = {
|
|||||||
name: 'Dust Chamber',
|
name: 'Dust Chamber',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 2% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
traits: ['shape', 'camera', 'space', 'style'],
|
traits: ['shape', 'camera', 'space', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,6 @@ export const eclipseField = {
|
|||||||
name: 'Eclipse Field',
|
name: 'Eclipse Field',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 17% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Crisp line work: the track's surface grain would only fur the edges.
|
// Crisp line work: the track's surface grain would only fur the edges.
|
||||||
texture: 0,
|
texture: 0,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
|
|||||||
@ -9,8 +9,6 @@ export const fireflyDrift = {
|
|||||||
name: 'Firefly Drift',
|
name: 'Firefly Drift',
|
||||||
family: 'flow',
|
family: 'flow',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 2% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['cast', 'ink', 'staging'],
|
consumes: ['cast', 'ink', 'staging'],
|
||||||
traits: ['camera', 'style'],
|
traits: ['camera', 'style'],
|
||||||
|
|||||||
@ -11,8 +11,6 @@ export const flora = {
|
|||||||
name: 'Flora',
|
name: 'Flora',
|
||||||
family: 'organic',
|
family: 'organic',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 15% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|||||||
@ -14,8 +14,6 @@ export const gateCorridor = {
|
|||||||
name: 'Gate Corridor',
|
name: 'Gate Corridor',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 29% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
|
|||||||
@ -6,8 +6,6 @@ export const horizonLines = {
|
|||||||
name: 'Horizon Lines',
|
name: 'Horizon Lines',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 16% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
// Crisp line work: the track's surface grain would only fur the edges.
|
// Crisp line work: the track's surface grain would only fur the edges.
|
||||||
texture: 0,
|
texture: 0,
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const inkBleed = {
|
|||||||
name: 'Ink Bleed',
|
name: 'Ink Bleed',
|
||||||
family: 'organic',
|
family: 'organic',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 1% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,6 @@ export const magnetLines = {
|
|||||||
name: 'Magnet Lines',
|
name: 'Magnet Lines',
|
||||||
family: 'flow',
|
family: 'flow',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 18% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Line work. Grain furs it up, so take only a dusting.
|
// Line work. Grain furs it up, so take only a dusting.
|
||||||
texture: 0.5,
|
texture: 0.5,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
|
|||||||
@ -7,8 +7,6 @@ export const metaballs = {
|
|||||||
name: 'Metaballs',
|
name: 'Metaballs',
|
||||||
family: 'organic',
|
family: 'organic',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 26% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['cast', 'ink', 'staging'],
|
consumes: ['cast', 'ink', 'staging'],
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|||||||
@ -12,8 +12,6 @@ export const myceliumWeb = {
|
|||||||
name: 'Mycelium Web',
|
name: 'Mycelium Web',
|
||||||
family: 'organic',
|
family: 'organic',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 22% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,6 @@ export const neonCity = {
|
|||||||
name: 'Neon City',
|
name: 'Neon City',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 26% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['space', 'camera', 'style'],
|
traits: ['space', 'camera', 'style'],
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const pendulumTrace = {
|
|||||||
name: 'Pendulum Trace',
|
name: 'Pendulum Trace',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 0% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Fine line work; grain only furs it up.
|
// Fine line work; grain only furs it up.
|
||||||
texture: 0.25,
|
texture: 0.25,
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
|
|||||||
@ -7,8 +7,6 @@ export const prismBloom = {
|
|||||||
name: 'Prism Bloom',
|
name: 'Prism Bloom',
|
||||||
family: 'geometric',
|
family: 'geometric',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 1% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
// Crisp line work: the track's surface grain would only fur the edges.
|
// Crisp line work: the track's surface grain would only fur the edges.
|
||||||
texture: 0,
|
texture: 0,
|
||||||
|
|||||||
@ -9,8 +9,6 @@ export const psychedelicDrift = {
|
|||||||
name: 'Psychedelic Drift',
|
name: 'Psychedelic Drift',
|
||||||
family: 'glitch',
|
family: 'glitch',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 6% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|||||||
@ -48,8 +48,6 @@ export const pylonGrid = {
|
|||||||
name: 'Pylon Grid',
|
name: 'Pylon Grid',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 25% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
|
|||||||
@ -7,8 +7,6 @@ export const ridgeTerrain = {
|
|||||||
name: 'Ridge Terrain',
|
name: 'Ridge Terrain',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 13% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['space', 'camera', 'style'],
|
traits: ['space', 'camera', 'style'],
|
||||||
|
|||||||
@ -12,8 +12,6 @@ export const shojiGrid = {
|
|||||||
name: 'Shoji Grid',
|
name: 'Shoji Grid',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 23% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Paper has a tooth, and this is the one scene that wants it.
|
// Paper has a tooth, and this is the one scene that wants it.
|
||||||
texture: 1.0,
|
texture: 1.0,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
|
|||||||
@ -10,8 +10,6 @@ export const signalDecay = {
|
|||||||
name: 'Signal Decay',
|
name: 'Signal Decay',
|
||||||
family: 'glitch',
|
family: 'glitch',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 5% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'style'],
|
traits: ['camera', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,6 @@ export const silkRibbon = {
|
|||||||
name: 'Silk Ribbon',
|
name: 'Silk Ribbon',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 3% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
// A soft drape of light. Grain furs the one thing it is made of.
|
// A soft drape of light. Grain furs the one thing it is made of.
|
||||||
texture: 0,
|
texture: 0,
|
||||||
|
|||||||
@ -9,8 +9,6 @@ export const slowOrb = {
|
|||||||
name: 'Slow Orb',
|
name: 'Slow Orb',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 11% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
// One soft body in an empty frame — the emptiest scene in the library, and
|
// One soft body in an empty frame — the emptiest scene in the library, and
|
||||||
// the one where speckle is most visible for being least justified. It keeps
|
// the one where speckle is most visible for being least justified. It keeps
|
||||||
|
|||||||
@ -12,8 +12,6 @@ export const smokeColumn = {
|
|||||||
name: 'Smoke Column',
|
name: 'Smoke Column',
|
||||||
family: 'flow',
|
family: 'flow',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 16% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,6 @@ export const spectrumSculpture = {
|
|||||||
name: 'Spectrum Sculpture',
|
name: 'Spectrum Sculpture',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 2% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const stormRift = {
|
|||||||
name: 'Storm Rift',
|
name: 'Storm Rift',
|
||||||
family: 'glitch',
|
family: 'glitch',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 0% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['camera', 'space', 'style'],
|
traits: ['camera', 'space', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,6 @@ export const suspensionSpan = {
|
|||||||
name: 'Suspension Span',
|
name: 'Suspension Span',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 21% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
texture: 0.6,
|
texture: 0.6,
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
traits: ['shape', 'camera', 'space', 'style'],
|
traits: ['shape', 'camera', 'space', 'style'],
|
||||||
|
|||||||
@ -10,8 +10,6 @@ export const synthwaveRun = {
|
|||||||
name: 'Synthwave Run',
|
name: 'Synthwave Run',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 5% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
// Personality: see look/Personality.js.
|
// Personality: see look/Personality.js.
|
||||||
consumes: ['ink'],
|
consumes: ['ink'],
|
||||||
traits: ['space', 'camera', 'style'],
|
traits: ['space', 'camera', 'style'],
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const vortexDrift = {
|
|||||||
name: 'Vortex Drift',
|
name: 'Vortex Drift',
|
||||||
family: 'flow',
|
family: 'flow',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 21% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['cast', 'ink'],
|
consumes: ['cast', 'ink'],
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,6 @@ export const constellation = {
|
|||||||
name: 'Constellation',
|
name: 'Constellation',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 1% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['cast', 'ink', 'staging'],
|
consumes: ['cast', 'ink', 'staging'],
|
||||||
texture: 0.6,
|
texture: 0.6,
|
||||||
traits: ['shape', 'camera', 'space', 'style'],
|
traits: ['shape', 'camera', 'space', 'style'],
|
||||||
|
|||||||
@ -15,7 +15,6 @@ export const effigy = {
|
|||||||
family: 'geometric',
|
family: 'geometric',
|
||||||
// One object against a dark wash — most of the frame is legitimately empty,
|
// One object against a dark wash — most of the frame is legitimately empty,
|
||||||
// and it reads as something standing in a space rather than as the space.
|
// and it reads as something standing in a space rather than as the space.
|
||||||
surface: 'composable',
|
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
consumes: ['form', 'ink', 'staging'],
|
consumes: ['form', 'ink', 'staging'],
|
||||||
texture: 0.6,
|
texture: 0.6,
|
||||||
|
|||||||
@ -9,8 +9,6 @@ export const procession = {
|
|||||||
name: 'Procession',
|
name: 'Procession',
|
||||||
family: 'structural',
|
family: 'structural',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 18% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['cast', 'ink', 'staging'],
|
consumes: ['cast', 'ink', 'staging'],
|
||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|||||||
@ -8,8 +8,6 @@ export const soloist = {
|
|||||||
name: 'Soloist',
|
name: 'Soloist',
|
||||||
family: 'minimal',
|
family: 'minimal',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 20% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['cast', 'ink', 'staging'],
|
consumes: ['cast', 'ink', 'staging'],
|
||||||
texture: 0.5,
|
texture: 0.5,
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|||||||
@ -16,8 +16,6 @@ export const swarm = {
|
|||||||
name: 'Swarm',
|
name: 'Swarm',
|
||||||
family: 'organic',
|
family: 'organic',
|
||||||
kind: 'fragment',
|
kind: 'fragment',
|
||||||
// Paints 2% of the frame — see checks/phase12 coverage.
|
|
||||||
surface: 'composable',
|
|
||||||
consumes: ['form', 'ink', 'staging'],
|
consumes: ['form', 'ink', 'staging'],
|
||||||
texture: 0.5,
|
texture: 0.5,
|
||||||
traits: ['shape', 'camera', 'style'],
|
traits: ['shape', 'camera', 'style'],
|
||||||
|
|||||||
241
flow-state/src/scenes/surface.js
Normal file
241
flow-state/src/scenes/surface.js
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
// What the measurements MEAN — the thresholds and the rules, kept apart from
|
||||||
|
// the numbers they read.
|
||||||
|
//
|
||||||
|
// scenes/metadata.json is generated and holds no opinions: it says a scene
|
||||||
|
// paints 32% of the frame across six songs and nothing about whether that is
|
||||||
|
// enough. This file is where that becomes a decision, and it is hand-written
|
||||||
|
// precisely because a threshold is a judgement about the videos rather than a
|
||||||
|
// fact about the library.
|
||||||
|
//
|
||||||
|
// `surface` used to be declared in each scene file, and it drifted: nine scenes
|
||||||
|
// said `canvas` while painting under a third of the frame, which is how a
|
||||||
|
// section ended up standing on a few bright things and black. It is derived
|
||||||
|
// now. A scene cannot lie about what it paints, because nobody writes it down.
|
||||||
|
|
||||||
|
import metadata from './metadata.json';
|
||||||
|
import { readsHistory } from '../params/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line between a picture and elements on one.
|
||||||
|
*
|
||||||
|
* Half the frame. Under it a scene is not somewhere a shot can happen — it is
|
||||||
|
* the shot, or something in it, and what would be behind it is black.
|
||||||
|
*
|
||||||
|
* The same number does both jobs on purpose: `canvas` and "may be a ground" are
|
||||||
|
* one idea, and having a scene that is a canvas but not ground-capable would be
|
||||||
|
* a distinction with nothing behind it.
|
||||||
|
*/
|
||||||
|
export const GROUND_MIN = 0.5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bias a GROUND is sampled with, wherever it appears.
|
||||||
|
*
|
||||||
|
* A bed is not sampled like a shot. It keeps a density floor whatever the
|
||||||
|
* section wants — an intro biased sparse would otherwise draw the ground at
|
||||||
|
* density zero and be thin again for a new reason — and it sits back through
|
||||||
|
* energy and motion instead.
|
||||||
|
*
|
||||||
|
* Defined here rather than in the generator because the measurement has to use
|
||||||
|
* it too. Coverage depends enormously on parameters: Metaballs paints 69% of
|
||||||
|
* the frame sampled as a drop's shot and 27% sampled as an intro's bed, and a
|
||||||
|
* label taken at the first bias tells you nothing about the second. So the
|
||||||
|
* number that decides whether a scene may be a ground is measured AS a ground.
|
||||||
|
* One definition, both places, or the two silently disagree.
|
||||||
|
*/
|
||||||
|
export const GROUND_BIAS = { energy: 0.5, density: 0.45, motion: 0.3 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hand a GROUND is drawn with.
|
||||||
|
*
|
||||||
|
* `extremity` is how far toward the ends of its own ranges a track pushes every
|
||||||
|
* parameter, and it is the right dial for a shot: the ends are where a scene is
|
||||||
|
* most itself. It is the wrong dial for a bed. Half of what decides how much a
|
||||||
|
* scene paints is a parameter with no `bias` key at all — Metaballs' threshold,
|
||||||
|
* for one — so extremity is the only thing moving it, and one end of that range
|
||||||
|
* is an empty frame. Measured: a ground drawn at the track's own temperament
|
||||||
|
* landed on threshold 1.85 of 2.0 and painted 6% of the frame, while the same
|
||||||
|
* scene measures 58% drawn moderately.
|
||||||
|
*
|
||||||
|
* So a bed is drawn near the middle and the extremes are left to the shot,
|
||||||
|
* which is also the right instinct visually.
|
||||||
|
*/
|
||||||
|
export function groundTemperamentFrom(temperament) {
|
||||||
|
if (!temperament) return temperament;
|
||||||
|
return { ...temperament, extremity: temperament.extremity * 0.2, intensity: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The song's hand, as it applies to a GROUND.
|
||||||
|
*
|
||||||
|
* The ink treatment — hatch, stipple, halftone, hollow — is how the song draws.
|
||||||
|
* `hollow` draws outlines and no fill at all, which is a strong, legible
|
||||||
|
* identity for the thing the section is ABOUT and a disaster for the thing it
|
||||||
|
* stands on: a scene that paints 61% of the frame averaged over six identities
|
||||||
|
* paints 2% in the song that asked for outlines, and the section renders black
|
||||||
|
* with a ground under it. Measured, holding grounds to their worst identity
|
||||||
|
* instead left four castable beds in the whole library.
|
||||||
|
*
|
||||||
|
* So the world is drawn solid and the hand is kept for the subject. Everything
|
||||||
|
* else about the identity — the cast, the lattice, the line weight, the
|
||||||
|
* palette — reaches the ground untouched, so it is still unmistakably this
|
||||||
|
* song's world.
|
||||||
|
*/
|
||||||
|
export function groundPersonalityFrom(personality) {
|
||||||
|
if (!personality || !personality.identity || !personality.identity.ink) return personality;
|
||||||
|
const { identity } = personality;
|
||||||
|
if (identity.ink.fill !== 'hollow') return personality;
|
||||||
|
return {
|
||||||
|
...personality,
|
||||||
|
identity: { ...identity, ink: { ...identity.ink, fill: 'flat' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bias `ground` would be sampled with inside `section`. */
|
||||||
|
export function groundBiasFrom(bias) {
|
||||||
|
return {
|
||||||
|
...bias,
|
||||||
|
// FLOORS, not reductions, on both of the axes that fill a frame.
|
||||||
|
//
|
||||||
|
// The first version of this lowered energy to make the bed sit back,
|
||||||
|
// which turned out to be precisely how to empty it: scenes bias the
|
||||||
|
// parameters that decide how much they paint against `energy` as often
|
||||||
|
// as against `density` — Metaballs sizes its blobs on energy, so an
|
||||||
|
// intro's ground drew radius 0.14 out of a 0.1–0.6 range and painted 4%
|
||||||
|
// of the frame. A bed sits back through MOTION, which costs it nothing
|
||||||
|
// in coverage, and through being behind everything else.
|
||||||
|
density: Math.max(GROUND_BIAS.density, bias.density),
|
||||||
|
energy: Math.max(GROUND_BIAS.energy, bias.energy),
|
||||||
|
motion: Math.max(0, (bias.motion ?? 0.5) - 0.2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a scene is, compositionally.
|
||||||
|
*
|
||||||
|
* canvas fills the frame. Belongs underneath, and two of them stacked is
|
||||||
|
* two pictures fighting rather than one picture with depth.
|
||||||
|
* composable mostly empty by design. Reads as elements ON something, and
|
||||||
|
* shown alone it is a few bright things on black — which scores
|
||||||
|
* well for variety and is thin to watch.
|
||||||
|
*/
|
||||||
|
export const SURFACES = ['canvas', 'composable'];
|
||||||
|
|
||||||
|
const ROW = (module) => (metadata.scenes && metadata.scenes[module.name]) || null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for a scene nobody has measured.
|
||||||
|
*
|
||||||
|
* Two ways to land here: a `layer3d`, which the measuring pass does not render,
|
||||||
|
* and a scene added since the last refresh. Deliberately pessimistic — an
|
||||||
|
* unmeasured scene must not be able to talk its way into the ground slot, which
|
||||||
|
* `canGround` enforces by requiring a real row rather than by trusting this.
|
||||||
|
*/
|
||||||
|
const ASSUMED_COVERAGE = 0.15;
|
||||||
|
|
||||||
|
export function coverageOf(module) {
|
||||||
|
const row = ROW(module);
|
||||||
|
return row ? row.coverage : ASSUMED_COVERAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much this scene paints when it is used as a BED — measured at
|
||||||
|
* GROUND_BIAS, which is the only figure that answers the question `canGround`
|
||||||
|
* asks. See GROUND_BIAS for why the two numbers differ so much.
|
||||||
|
*/
|
||||||
|
export function groundCoverageOf(module) {
|
||||||
|
const row = ROW(module);
|
||||||
|
if (!row) return ASSUMED_COVERAGE;
|
||||||
|
if (row.bedCoverageMean !== undefined) return row.bedCoverageMean;
|
||||||
|
return row.bedCoverage === undefined ? row.coverage : row.bedCoverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this scene paints as a bed in the IDENTITY THAT SUITS IT LEAST.
|
||||||
|
*
|
||||||
|
* A song's identity can gut a scene's fill — `hollow` ink draws outlines and no
|
||||||
|
* fill, a dark palette drops it under the threshold — and the spread is wide:
|
||||||
|
* Metaballs paints 61% averaged over six identities and 14% in the worst of
|
||||||
|
* them, which is a black frame with a ground under it.
|
||||||
|
*
|
||||||
|
* Held as a SEPARATE, lower bar rather than as the eligibility number. Demanding
|
||||||
|
* the worst case clear 50% leaves four castable grounds in the whole library —
|
||||||
|
* every video in the world standing on one of four beds is a worse failure than
|
||||||
|
* an occasional dim intro. So: fills the frame on average, and never vanishes.
|
||||||
|
*/
|
||||||
|
export function groundFloorCoverageOf(module) {
|
||||||
|
const row = ROW(module);
|
||||||
|
if (!row) return ASSUMED_COVERAGE;
|
||||||
|
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A ground may dim under a hostile identity. It may not disappear. */
|
||||||
|
export const GROUND_FLOOR_MIN = 0.25;
|
||||||
|
|
||||||
|
export function isMeasured(module) {
|
||||||
|
return ROW(module) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived, never declared. See the header. */
|
||||||
|
export function surfaceOf(module) {
|
||||||
|
return coverageOf(module) >= GROUND_MIN ? 'canvas' : 'composable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scene can be the ground under a section.
|
||||||
|
*
|
||||||
|
* Measured to fill the frame, and self-contained. The second half rules out
|
||||||
|
* `prev()`: that returns the whole composited previous frame, INCLUDING the
|
||||||
|
* layers above this one, so a datamosh under a shot is not grounding it, it is
|
||||||
|
* eating it. Measured when one first became a bed — the render stopped
|
||||||
|
* reproducing from a seek, and two WebGL contexts diverged by 91/255 against a
|
||||||
|
* tolerance of 4.
|
||||||
|
*/
|
||||||
|
export function canGround(module) {
|
||||||
|
return isMeasured(module)
|
||||||
|
&& surfaceOf(module) === 'canvas'
|
||||||
|
&& groundCoverageOf(module) >= GROUND_MIN
|
||||||
|
&& groundFloorCoverageOf(module) >= GROUND_FLOOR_MIN
|
||||||
|
&& !readsHistory(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How unalike two scenes are, structurally — 0 is the same picture.
|
||||||
|
*
|
||||||
|
* Both profiles are means over the same six songs, so this is a fair comparison
|
||||||
|
* in a way that two arbitrary renders would not be. Colour is excluded: six
|
||||||
|
* palettes would otherwise make any two scenes look unalike, and the whole
|
||||||
|
* point is to find pairs that differ in STRUCTURE.
|
||||||
|
*
|
||||||
|
* This is the quantity that makes a stack interesting rather than merely full.
|
||||||
|
* Two scenes at 0.02 layered over each other are one picture at double density;
|
||||||
|
* the same two at 0.3 are a picture with something happening in it.
|
||||||
|
*/
|
||||||
|
export function structuralDistance(a, b) {
|
||||||
|
const rowA = ROW(a), rowB = ROW(b);
|
||||||
|
if (!rowA || !rowB || !rowA.profile || !rowB.profile) return null;
|
||||||
|
|
||||||
|
let total = 0, blocks = 0;
|
||||||
|
for (const block of ['scale', 'orient', 'layout', 'region', 'texture']) {
|
||||||
|
const x = rowA.profile[block], y = rowB.profile[block];
|
||||||
|
if (!x || !y || x.length !== y.length) continue;
|
||||||
|
// Chi-square, matching what variety/signature.js compares descriptors
|
||||||
|
// with. Duplicated rather than imported because this runs inside the
|
||||||
|
// generator and the checks must not be a dependency of it.
|
||||||
|
let d = 0;
|
||||||
|
for (let i = 0; i < x.length; i++) {
|
||||||
|
const sum = x[i] + y[i];
|
||||||
|
if (sum > 1e-9) d += ((x[i] - y[i]) ** 2) / sum;
|
||||||
|
}
|
||||||
|
total += Math.min(1, d / 2);
|
||||||
|
blocks++;
|
||||||
|
}
|
||||||
|
return blocks ? total / blocks : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The variety score from the gallery: how much a scene changes between songs. */
|
||||||
|
export function varietyOf(module) {
|
||||||
|
const row = ROW(module);
|
||||||
|
return row ? row.variety : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const METADATA = metadata;
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { subjectOf } from '../look/stack.js';
|
||||||
// The transport strip: sections coloured by kind, bar ticks, scene-change
|
// The transport strip: sections coloured by kind, bar ticks, scene-change
|
||||||
// markers, playhead.
|
// markers, playhead.
|
||||||
//
|
//
|
||||||
@ -144,12 +145,12 @@ export class TimelineStrip {
|
|||||||
if (sw > 34) {
|
if (sw > 34) {
|
||||||
const stack = look.variants ? look.variants[shot.variant] : look.layers;
|
const stack = look.variants ? look.variants[shot.variant] : look.layers;
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||||
ctx.fillText(stack[0].module.name, sx + 4, h - 6);
|
ctx.fillText(subjectOf(stack).module.name, sx + 4, h - 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (look) {
|
} else if (look) {
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
ctx.fillStyle = 'rgba(255,255,255,0.45)';
|
||||||
ctx.fillText(look.layers[0].module.name, x0 + 5, h - 6);
|
ctx.fillText(subjectOf(look.layers).module.name, x0 + 5, h - 6);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
|
|||||||
@ -200,3 +200,9 @@ console.log('next:');
|
|||||||
console.log(' 1. write the scene() body — everything else is done');
|
console.log(' 1. write the scene() body — everything else is done');
|
||||||
console.log(' 2. npm run lint:scenes');
|
console.log(' 2. npm run lint:scenes');
|
||||||
console.log(` 3. open http://localhost:5180/checks.html?scene=${encodeURIComponent(name.trim())}`);
|
console.log(` 3. open http://localhost:5180/checks.html?scene=${encodeURIComponent(name.trim())}`);
|
||||||
|
// A scene with no measured coverage cannot be a ground and fails the phase 12
|
||||||
|
// table check, which is the point: how much frame it paints decides where it is
|
||||||
|
// allowed to sit in a stack, and that is a render result rather than a claim.
|
||||||
|
console.log(' 4. open http://localhost:5180/checks.html?phase=12&slow=1 and paste');
|
||||||
|
console.log(' your scene\'s line from window.__COVERAGE_TABLE__ into');
|
||||||
|
console.log(' src/scenes/coverage.js — declare `surface` to match it');
|
||||||
|
|||||||
@ -1,7 +1,53 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Let the measuring pages write their results back into the source tree.
|
||||||
|
*
|
||||||
|
* The measurements that describe the scene library — how much frame each one
|
||||||
|
* paints, how much it changes between songs, what it looks like structurally —
|
||||||
|
* can only be taken by rendering, and rendering needs WebGL, which means a
|
||||||
|
* browser. So the numbers are produced in a page and have to get from there
|
||||||
|
* into a git-tracked file. This is that hole in the wall.
|
||||||
|
*
|
||||||
|
* Dev only, by construction: `apply: 'serve'` means it does not exist in a
|
||||||
|
* build, so a deployed gallery cannot write anything. The path it may write is
|
||||||
|
* a fixed allow-list rather than a parameter — a request body decides the
|
||||||
|
* CONTENTS of a known file and never which file.
|
||||||
|
*/
|
||||||
|
function measurementWriter() {
|
||||||
|
const WRITABLE = {
|
||||||
|
'/__metadata': 'src/scenes/metadata.json',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'flow-state-measurement-writer',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const target = WRITABLE[req.url];
|
||||||
|
if (!target || req.method !== 'POST') return next();
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
writeFileSync(resolve(process.cwd(), target), body);
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end(`wrote ${target} (${body.length} bytes)`);
|
||||||
|
} catch (err) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(String(err && err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [measurementWriter()],
|
||||||
server: {
|
server: {
|
||||||
port: 5180,
|
port: 5180,
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user