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>
93 lines
4.2 KiB
JavaScript
93 lines
4.2 KiB
JavaScript
// Minimal family: a large occluder covering a bright source, with the light
|
|
// escaping past its edge.
|
|
//
|
|
// Distinct from Slow Orb (a small soft body drifting through empty space): the
|
|
// object here nearly fills the frame, its edge is hard, and it is not the
|
|
// subject — the subject is the corona around it. That inversion is the whole
|
|
// idea, and it gives the library its one genuinely high-contrast minimal scene:
|
|
// almost black, with a thin ring of the brightest thing in the video.
|
|
//
|
|
// The occluder is the track's signature form, so this is where a hexagonal
|
|
// track's hexagon is most legible.
|
|
//
|
|
// Scaffolded by tools/new-scene.js. See HOWTO-visualizers.md.
|
|
|
|
export const eclipseField = {
|
|
name: 'Eclipse Field',
|
|
family: 'minimal',
|
|
kind: 'fragment',
|
|
// Crisp line work: the track's surface grain would only fur the edges.
|
|
texture: 0,
|
|
consumes: ['cast', 'ink'],
|
|
// No `style`: this scene opts out of surface grain (texture: 0) and its only
|
|
// other style evidence was sigEdge, which the migration replaced with the
|
|
// ink. Traits and artifacts are different layers, so taking the ink does not
|
|
// earn the trait back. See MIGRATION.md.
|
|
traits: ['shape', 'camera', 'space', 'style'],
|
|
|
|
params: {
|
|
size: { type: 'float', range: [0.2, 0.85], default: 0.45, uniform: 'u_size' },
|
|
corona: { type: 'float', range: [0.05, 1.2], default: 0.45, uniform: 'u_corona', bias: 'energy' },
|
|
rays: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_rays' },
|
|
rayCount: { type: 'float', range: [3, 24], default: 9, uniform: 'u_rayCount', bias: 'density' },
|
|
offset: { type: 'float', range: [0, 0.5], default: 0.12, uniform: 'u_offset' },
|
|
drift: { type: 'float', range: [0.01, 0.3], default: 0.05, uniform: 'u_drift', bias: 'motion', rate: true },
|
|
sky: { type: 'float', range: [0, 0.6], default: 0.15, uniform: 'u_sky' },
|
|
palette: { type: 'palette', count: 5 },
|
|
},
|
|
|
|
reactive: {
|
|
corona: { feature: 'loudness', amount: 0.3, response: 'smooth' },
|
|
rays: { feature: 'bandAir', amount: 0.25, response: 'smooth' },
|
|
},
|
|
|
|
shader: `
|
|
vec4 scene(vec2 uv, vec2 p) {
|
|
float t = u_time * u_drift + u_seed;
|
|
p = sigCamera(p);
|
|
|
|
// The light sits a little off centre from the occluder, so the corona is
|
|
// brighter on one side — a perfectly concentric eclipse looks like a decal.
|
|
vec2 lightAt = vec2(sin(t * 0.8), cos(t * 0.6)) * u_offset;
|
|
vec2 discAt = vec2(0.0, sigHorizonY() * 0.25);
|
|
|
|
// Distance to the occluder's edge, in the track's form.
|
|
float d = castMain((p - discAt) / max(u_size, 1e-3)) * u_size;
|
|
|
|
// The source behind it.
|
|
float toLight = length(p - lightAt);
|
|
float source = exp(-toLight * toLight * 2.2);
|
|
|
|
vec3 col = pal(0) * u_sky * (1.0 - smoothstep(0.0, 1.6, length(p)));
|
|
col += pal(1) * source * 0.5;
|
|
|
|
// Corona: brightest exactly at the edge, falling off outward only. Inside
|
|
// the disc it must be zero, or the occluder stops occluding.
|
|
float outside = smoothstep(0.0, 0.02, d);
|
|
float ring = exp(-max(d, 0.0) / max(u_corona * 0.25, 0.01));
|
|
// The corona breathes on a fixed clock rather than on u_drift. At a low
|
|
// drift — which is most of this scene's range, it is a minimal scene —
|
|
// nothing else in the frame moved enough to register as animation at all.
|
|
float breath = 0.82 + 0.18 * sin(u_time * 0.5 + u_seed * 1.7);
|
|
col += palRamp(0.35 + d * 0.5) * ring * outside * (0.8 + u_corona) * breath;
|
|
|
|
// Rays: angular streaks in the corona, rotating slowly. Confined to the
|
|
// corona by the same falloff, so they never light the whole frame.
|
|
if (u_rays > 0.01) {
|
|
float ang = atan(p.y - discAt.y, p.x - discAt.x);
|
|
float spokes = 0.5 + 0.5 * sin(ang * u_rayCount + t * 2.0 + u_time * 0.3);
|
|
col += pal(4) * pow(spokes, 3.0) * ring * outside * u_rays;
|
|
}
|
|
|
|
// The occluder: not pure black, so it reads as an object rather than a hole.
|
|
col = mix(col, pal(0) * 0.12, smoothstep(0.004, -0.004, d));
|
|
col += pal(3) * inkStroke(d) * (0.4 + u_corona * 0.5);
|
|
|
|
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
|
|
return vec4(inkValue(col), 1.0);
|
|
}
|
|
`,
|
|
};
|
|
|
|
export default eclipseField;
|