music-video-gen/flow-state/src/scenes/shader/neon-city.js
Dejvino 89e05459c0 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>
2026-08-20 09:02:01 +02:00

120 lines
5.2 KiB
JavaScript

// Structural family: a receding skyline of towers with instanced lit windows.
//
// Distinct from Ridge Terrain (rolling fbm ridgelines) and Synthwave Run (a
// ground grid): this is architecture, read as a front-on skyline. Heights and
// footprints are per-tower noise, drawn far-to-near so a nearer tower correctly
// occludes the one behind while a taller far tower still rises above a short
// one in front. Windows are lit per facade cell, so the city has interior life
// rather than reading as a lit cardboard cut-out.
export const neonCity = {
name: 'Neon City',
family: 'structural',
kind: 'fragment',
// Personality: see look/Personality.js.
consumes: ['ink'],
traits: ['space', 'camera', 'style'],
params: {
cells: { type: 'float', range: [0.5, 4], default: 2.0, uniform: 'u_cells', bias: 'density' },
layers: { type: 'int', range: [2, 8], default: 6, uniform: 'u_layers', bias: 'density' },
height: { type: 'float', range: [0.2, 2.5], default: 1.0, uniform: 'u_height', bias: 'energy' },
speed: { type: 'float', range: [0.02, 0.4], default: 0.08, uniform: 'u_speed', bias: 'motion', rate: true },
horizon: { type: 'float', range: [-0.3, 0.3], default: 0.0, uniform: 'u_horizon' },
reflect: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_reflect' },
pulse: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_pulse' },
palette: { type: 'palette', count: 4 },
},
reactive: {
height: { feature: 'bandLow', amount: 0.18, response: 'smooth' },
pulse: { feature: 'beat', amount: 0.2, response: 'smooth' },
},
shader: `
float towerEdge(vec2 grid, float mullions) {
vec2 g = fract(grid);
float steel = min(1.0 - smoothstep(0.0, 0.28 * mullions, g.x),
1.0 - smoothstep(0.0, 0.28 * mullions, g.y));
return steel;
}
vec4 scene(vec2 uv, vec2 p) {
float t = u_time * u_speed + u_seed;
float beat = u_beat;
p = sigCamera(p);
// Shares the track's ground line with every other scene that has one.
float horizon = clamp(u_horizon + sigHorizonY() * 0.3, -0.9, 0.9);
// Sky, darkening away from the horizon glow.
vec3 sky = mix(pal(3) * 0.06, pal(0) * 0.35,
smoothstep(-0.1, 0.7, p.y - horizon));
sky *= 0.4 + 0.6 * exp(-abs(p.y - horizon) * 3.0);
vec3 col = sky;
for (int i = 0; i < 8; i++) {
if (i >= u_layers) break;
float fi = float(i);
float depth = float(i) / max(float(u_layers - 1), 1.0); // 0 far .. 1 near
float parallax = mix(0.25, 1.0, depth);
// Horizontal column coordinate for this depth ring. Receding layers
// get more cells per screen and slide slower, giving parallax.
float xsc = mix(1.2, 3.4, depth) * u_cells;
float xc = p.x * xsc + t * 0.18 * parallax + fi * 31.7;
float cx = floor(xc);
float xf = fract(xc);
float h1 = hash11(cx * 13.1 + fi * 7.3 + u_seed);
float h2 = hash11(cx * 61.7 + fi * 5.9 + u_seed * 0.7);
// Footprint half width, and the tower's top above its base.
float footprint = (0.55 + h1 * 0.35) * 0.5; // in cell fracs
float inTower = smoothstep(footprint + 0.04, footprint - 0.04,
abs(xf - 0.5));
float baseY = horizon - (0.05 + depth * 0.9);
float topY = baseY + u_height * (0.35 + h1 * 1.6) * mix(0.45, 1.0, depth);
float body = step(p.y, topY) * step(baseY, p.y);
float tower = inTower * body;
if (tower < 0.01) continue;
// Facade, tinted by how far into the screen the ring sits.
float gy = clamp((p.y - baseY) / max(topY - baseY, 1e-4), 0.0, 1.0);
vec3 facade = mix(pal(1), pal(3), depth);
// Instanced window grid — each facade cell decides itself lit or dark,
// with the pulse raising the lit population and the beat underlining it.
vec2 g = vec2(xf * 5.0, gy * 9.0);
vec2 cellg = floor(g);
float lit = step(0.42, hash12(cellg + fi * 3.1));
float steel = 1.0 - min(smoothstep(0.0, 0.35, fract(g.x)),
smoothstep(0.0, 0.35, fract(g.y)));
vec3 windowCol = mix(pal(2), pal(3), 0.3);
vec3 fut = mix(col, windowCol,
lit * steel * (0.45 + 0.55 * u_pulse + beat * 0.25));
// Ground-couple: darker, breath where the tower meets the street.
fut *= 0.7 + 0.3 * smoothstep(0.0, 0.5, gy);
// Haze pushes the far rings into the sky, the way distance actually does.
col = mix(col, fut, tower * (1.0 - 0.45 * (1.0 - depth)));
}
// Street: a slate floor with a molten reflection of the nearest towers.
if (p.y < horizon) {
float street = smoothstep(0.0, -0.6, p.y - horizon);
col = mix(col, pal(3) * 0.08, street);
float refl = exp(-abs(p.y - horizon) * 4.0) * u_reflect;
col += pal(2) * refl * (0.08 + 0.2 * beat) * street;
}
// Horizon haze bloom, the city's glow pooling where towers meet the sky.
col = sigAir(col, p, smoothstep(0.0, 1.4, length(p)));
return vec4(inkValue(col), 1.0);
}
`,
};
export default neonCity;