music-video-gen/flow-state/src/look/LookGenerator.js
Dejvino b2c9497bae Epic 5 Phase 2.5 — model blend via alpha, not lumakey
Model stages render with alpha:0 where no mesh covers (transparent
clear) and carry dark material colours. Lumakey keys on luma
(dot(src,0.212…)), so a dim mesh keyed to ~0 and vanished — Pylon
Field 3D / Synthwave Corridor looked black. Use 'normal' for
kind:'model' so the alpha is the mask; fragment over dark ground
stays covered. Per-stage fog/light tuning deferred to a director
param (LookGenerator) per review — not hand-tuned per stage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 22:08:32 +02:00

1016 lines
48 KiB
JavaScript

// Turns a FeatureTrack into a complete LookSpec: palette, per-section scene
// assignments, parameter sets, and the post/feedback settings.
//
// Runs once per track. Deterministic in the seed, and the seed is derived from
// the decoded audio, so a given file always renders the same video.
import { Rng, hashSamples } from '../engine/rng.js';
import { AudioPalette, generateUsablePalette, rgbToOklch } from './palette.js';
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.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 {
generatePersonality, sceneHonours, signatureWeight, describePersonality,
} from './Personality.js';
import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
import {
pickDirector, directorByName, crowdOf, blazeOf, RESTFUL_FAMILIES, QUIET_KINDS,
} from './directors.js';
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
import { deriveFramingStyle, describeFraming } from './framing.js';
import { deriveCamera, describeCamera } from './Camera.js';
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js';
// Which families suit which section kind now comes from the track's DIRECTOR
// (look/directors.js) rather than from a constant here. The coupling it
// provides is the same — it is what stops a breakdown landing on a strobing
// glitch scene and an intro opening at full density — but which coupling a
// given track gets is a decision, not a fact about the program.
const KIND_ENERGY = {
intro: 0.25, build: 0.55, drop: 0.95, sustain: 0.6, breakdown: 0.25, outro: 0.2,
};
/**
* Parameter bias per section: the values scenes declare a `bias` key against.
*
* This is how a track's measured character reaches a scene's parameters without
* the scene knowing anything about audio. A dense, loud drop pushes `density`
* and `energy` up; a breakdown pulls them down. Seed variation still dominates,
* so two tracks with the same structure do not converge on the same look.
*/
function biasFor(section, summary, motion = null, story = null) {
const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5;
const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
// Where this section sits in the story moves the bias, and is deliberately
// the smallest term in it. The kind decides what a section IS — the spread
// between an intro and a drop is 0.7 of the range — and the story decides
// which drop this is, worth a tenth of that. Bounded rather than trusted:
// a breakdown at maximum tension is still, unambiguously, a breakdown, and
// the quiet-kind coupling in directors.js depends on it staying that way.
const tension = story ? story.tension : 0.5;
const population = story ? story.population : 0.5;
const energy = clamp01(kindEnergy * 0.6 + measured * 0.4 + (tension - 0.5) * 0.16);
// 60bpm → 0, 180bpm → 1. Tempo, not energy, is what a viewer reads as
// "this is moving too fast for the song": a slow track can have a huge drop
// and still want scenes that drift. Motion used to be mostly energy with
// tempo as a small correction, which is why a 70bpm ballad got a drop
// biased to 0.9 motion and scenes that skittered over it.
const tempo = clamp01((summary.bpm - 60) / 120);
// The track's motion CHARACTER, on top of its tempo. Tempo alone compresses
// — 124 and 138bpm are the same number to a viewer — and motion was the
// weakest axis in every measurement because it was the only lever. Stillness
// is allowed to halve the animation rate or half again raise it, which is a
// difference anyone can see, and it is a property of the track rather than
// of the section. See look/Personality.js.
const still = motion ? motion.stillness : 0.5;
const churn = motion ? motion.churn : 0.25;
return {
energy,
density: clamp01(energy * 0.7 + section.flux * 1.2 + (population - 0.5) * 0.2),
motion: clamp01(0.12 + tempo * 0.4 + energy * 0.2 + (1 - still) * 0.35),
// Applied on top of every `rate: true` param, so absolute animation
// speed scales with the song rather than only its sampled position in
// a range. Bounded well short of a stop or a blur. See params/schema.js.
rateScale: (0.45 + tempo * 0.95) * (1.35 - still * 0.75) * (1 + churn * 0.25),
};
}
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Derive the set of palettes for this track. The director's palette.count
* names how many distinct schemes the video will cut between; the actual
* colours stay audio-tilted (same warmth/energy) so they read as one track's
* world rather than as unrelated palettes shuffled together.
*/
function derivePaletteSet(summary, rng, director) {
const cfg = (director && director.palette) || {};
const spec = cfg.count;
let count = 1;
if (Array.isArray(spec)) {
const lo = Math.max(1, spec[0] | 0);
const hi = Math.max(lo, spec[1] | 0);
count = lo === hi ? lo : rng.int(lo, hi);
} else if (typeof spec === 'number') {
count = Math.max(1, spec | 0);
} else {
// No palette config — single palette, like before
count = 1;
}
count = Math.min(4, Math.max(1, count));
const palettes = [];
const paletteSchemes = [];
const hueRefs = [];
for (let k = 0; k < count; k++) {
let palette = null;
let scheme = null;
let hue = 0;
// Try a few forks so the set is diverse in scheme and hue, not just
// repeated draws that happen to land on the same scheme.
for (let attempt = 0; attempt < 12; attempt++) {
const fork = rng.fork(`palette:${k}:${attempt}`);
const src = new AudioPalette(summary, fork);
const cand = generateUsablePalette(src, 6);
const candScheme = src.lastScheme || 'unknown';
let candHue = 0;
try { candHue = rgbToOklch(cand[0])[2]; } catch { candHue = fork.range(0, Math.PI * 2); }
const schemeDup = paletteSchemes.includes(candScheme);
let tooClose = false;
for (const h2 of hueRefs) {
let dh = Math.abs(candHue - h2) % (2 * Math.PI);
if (dh > Math.PI) dh = 2 * Math.PI - dh;
if (dh < 0.5) { tooClose = true; break; }
}
// Keep trying while we have attempts left and the candidate would
// make the set harder to tell apart. Relax after a few tries.
if (attempt < 8 && schemeDup) continue;
if (attempt < 6 && tooClose) continue;
palette = cand;
scheme = candScheme;
hue = candHue;
break;
}
if (!palette) {
const src = new AudioPalette(summary, rng.fork(`palette:${k}:final`));
palette = generateUsablePalette(src, 6);
scheme = src.lastScheme || 'unknown';
try { hue = rgbToOklch(palette[0])[2]; } catch { hue = 0; }
}
palettes.push(palette);
paletteSchemes.push(scheme);
hueRefs.push(hue);
}
return { palettes, paletteSchemes };
}
/**
* The track's temperament, moved to where this section sits in the story.
*
* Temperament is the hand on every parameter dial and it was constant for the
* whole video, which is why two occurrences of a kind sampled around the same
* point however far apart they were. Scaling `extremity` by tension is the
* ratchet: the same scene, sampled nearer the ends of its own ranges the later
* it appears. Bounded by the range extremity is drawn from — this moves where a
* track sits inside its own character, it does not give it a different one.
*/
function temperamentFor(temperament, state) {
if (!temperament || !state) return temperament;
const tension = state.tension;
return {
...temperament,
intensity: Math.max(-1, Math.min(1, temperament.intensity + (tension - 0.5) * 0.5)),
extremity: clamp01(temperament.extremity * (0.82 + tension * 0.4)),
};
}
/**
* Scenes eligible for a section kind, weighted by how well the family fits.
*
* `signature` is the track's personality signature, and it is a hard filter
* rather than a weight: a scene with no way to express what the track is built
* on is not a worse choice, it is the shot that was clearly filmed somewhere
* else. See look/Personality.js.
*/
/**
* The scenes THIS TRACK is allowed to cast from — a seeded subset of the
* library, not the whole thing.
*
* There is a real tension here, and the first attempt at fixing the signature
* gate walked straight into it. The hard trait filter was doing two jobs at
* once: it was collapsing the library onto eleven over-declared scenes, which
* was the bug, and it was also giving each track a DIFFERENT pool to cast from,
* which was load-bearing. Replacing it with a soft weight fixed the collapse and
* removed the differentiation — every track then drew from the same weighted
* library, and measured song separation went from 0.03 to -0.15. Two songs came
* out more alike than before.
*
* So the differentiation is kept and its bias removed. Every track gets its own
* pool of about a third of the library, sampled without replacement, weighted by
* the signature so the track still has a point of view. What changes is that a
* scene declaring two traits is now merely less likely to be drawn than one
* declaring four, instead of being ineligible for six tracks in seven.
*/
/**
* How many of the library's scenes one track is allowed to draw on.
*
* Swept directly — checks.html?sweep=1 — across 4, 8, 16 and 32, over twelve
* songs with three pool draws each. The answer is that it does not matter:
*
* pool 4 spread +0.0061 ±0.0035
* pool 8 spread +0.0090 ±0.0040
* pool 16 spread +0.0102 ±0.0046
* pool 32 spread +0.0035 ±0.0032
*
* The differences are the same size as the run-to-run noise. This corrects a
* claim made when the Epic 3 arms first came in: those arms appeared to show
* that a small roster was the largest available win, but they varied two things
* at once — the pool was smaller AND it was the same pool for every song — and
* the sweep isolating size finds nothing.
*
* So 8 is chosen on grounds the metric cannot see. It puts about nine distinct
* scenes in a video rather than seventeen, and a video a viewer can hold in
* their head is worth having even when the instrument is indifferent.
*/
export const POOL_SIZE = 8;
function castingPool(rng, signature, size = POOL_SIZE) {
const pool = scenes.filter(canBackground);
const remaining = pool.slice();
const weights = remaining.map((m) => signatureWeight(m, signature));
const picked = [];
const target = Math.min(size, remaining.length);
while (picked.length < target && remaining.length) {
const chosen = rng.pickWeighted(remaining, weights);
const at = remaining.indexOf(chosen);
remaining.splice(at, 1);
weights.splice(at, 1);
picked.push(chosen);
}
return picked;
}
function candidatesForKind(kind, used, signature = [], director, pool = null) {
const families = director.families[kind] || Object.keys(FAMILIES);
const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
const candidates = [];
for (const family of families) {
const inFamily = scenesInFamily(family)
.filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)));
// Weight by family preference order, and push down anything already
// used so a five-section track doesn't show one scene five times.
const weight = families.length - families.indexOf(family);
for (const scene of inFamily) {
candidates.push({
scene,
// The signature is a lean now, not a wall. See
// Personality.signatureWeight for why it had to stop being one.
weight: weight * signatureWeight(scene, signature)
* (used.has(scene.name) ? 0.15 : 1),
});
}
}
if (!candidates.length) {
// This track's pool holds nothing in the families the director wants for
// this kind. Widen to the pool, then to the library — the track keeps
// its scenes either way.
const fallback = (pool && pool.length) ? pool : scenes.filter(canBackground);
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) }));
}
return candidates;
}
/**
* How many stage visuals a kind rotates between. Busy material takes more.
*
* Sized against the library rather than picked out of the air: a kind draws
* from three families, which is seven to nine castable scenes, so a roster of
* four still leaves the weighting room to avoid what other kinds already took.
* Variants a section never reaches cost nothing — layers are built per cue, so
* only the ones its shots actually show are ever compiled.
*/
function rosterSizeFor(kind) {
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
* ROSTER of two or three, not one.
*
* All of a track's drops therefore cut between the same small set of visuals,
* all its breakdowns between another, and the video acquires an identity
* instead of reading as a shuffle. The first entry is the anchor: it opens
* every section of that kind and comes back most often, so the rotation reads
* as one idea with variations rather than as three unrelated scenes.
*
* Variation between two sections of the same kind comes from their parameter
* sets, from where their shots fall, and from the arc driver's drift.
*/
function assignRostersByKind(sections, rng, signature = [], director, pool = null) {
const byKind = new Map();
const used = new Set();
const kinds = [...new Set(sections.map((s) => s.kind))];
// Order matters for variety: assign the high-impact kinds first so they get
// first pick of the library rather than whatever is left.
const priority = ['drop', 'sustain', 'build', 'breakdown', 'intro', 'outro'];
kinds.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));
for (const kind of kinds) {
const roster = [];
const size = rosterSizeFor(kind);
for (let slot = 0; slot < size; slot++) {
const options = candidatesForKind(kind, used, signature, director, pool)
.filter((c) => !roster.includes(c.scene))
.map((c) => ({
scene: c.scene,
// Companions stay in the anchor's family where possible: a
// cut inside a section should change the image, not the
// whole visual language.
weight: c.weight * (roster.length && c.scene.family === roster[0].family ? 3 : 1),
}));
if (!options.length) break;
const chosen = rng.pickWeighted(
options.map((c) => c.scene), options.map((c) => c.weight));
roster.push(chosen);
used.add(chosen.name);
}
byKind.set(kind, roster.length ? roster : [scenes[0]]);
}
return byKind;
}
/**
* The RECAPITULATION: the outro re-casts what the intro opened on.
*
* The oldest device in the form and the cheapest one available here — the scene
* is already in the roster and already compiled, and all that changes is which
* member of it anchors. What makes it read as a return rather than as a repeat
* is that the outro plays it with the parameters the story has arrived at:
* the same scene, four minutes further along its slow axis, at the story's
* closing tension. See look/Story.js.
*/
function applyRecap(rosterByKind) {
const intro = rosterByKind.get('intro');
const outro = rosterByKind.get('outro');
if (!intro || !outro || !intro.length || !outro.length) return;
const opener = intro[0];
// Keep the outro's own roster behind the recapped anchor, minus a duplicate:
// the section still cuts away from it, it just opens and closes there.
const rest = outro.filter((m) => m.name !== opener.name);
rosterByKind.set('outro', [opener, ...rest]);
}
/**
* Which member of the kind's roster anchors THIS section.
*
* `roster[0]` opened every section of its kind, so a track's biggest visual was
* spent in the first fifteen seconds of the first drop and then spent again,
* identically, at every drop after it. Reserving it makes the anchor something
* the video arrives at: earlier occurrences open on a companion, and the
* anchor's own section is the one the story calls the climax.
*
* The roster itself does not change — the section still cuts between all of it,
* which is what keeps the kind's identity — only which member it opens on.
*/
function anchorOrder(roster, state) {
if (roster.length < 2 || !state) return roster;
// The climax, the resolution and any single occurrence get the real anchor.
const earned = state.act === 'climax' || state.act === 'resolution'
|| state.ordinalOf < 2 || state.reveal > 0.66;
if (earned) return roster;
const companion = 1 + (state.ordinal % (roster.length - 1));
return [roster[companion], ...roster.filter((_, i) => i !== companion)];
}
/**
* Post-processing and feedback derived from track character.
* Ambient material gets more feedback and bloom; dense club material gets
* tighter, punchier settings.
*
* Grain is NOT decided here — see look/grain.js. `post.grain` carries only the
* amount for the current frame, which the Show multiplies by the grain
* envelope, so a track can be clean, permanently dirty, or anything between.
*/
function derivePost(summary, rng, grain) {
const bright = summary.meanCentroid;
const noisy = Math.min(1, summary.meanFlatness * 3);
const dynamic = Math.min(1, summary.dynamicRange);
return {
post: {
bloom: 0.25 + (1 - noisy) * 0.35 + rng.range(-0.05, 0.05),
bloomThreshold: 0.45 + bright * 0.25,
bloomKnee: 0.25,
chroma: 0.05 + noisy * 0.25 + rng.range(0, 0.08),
...applyGrainToPost(grain, {}),
vignette: 0.25 + (1 - bright) * 0.25,
contrast: 1.0 + dynamic * 0.15,
saturation: 1.0 + (1 - noisy) * 0.25,
lift: 0.0,
exposure: 1.0,
},
feedback: {
// Dynamic, spacious material tolerates long trails; dense material
// turns to smear, so it gets much less.
amount: Math.min(0.75, 0.15 + dynamic * 0.5),
decay: 0.86 + dynamic * 0.08,
zoom: 1.0 + rng.range(-0.006, 0.006),
rotate: rng.range(-0.004, 0.004),
},
};
}
/**
* How much a candidate would add to a stack, structurally.
*
* 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
* variation valve, and a stack that always doubled up would read
* as permanently cluttered rather than as occasionally layered.
* Drawn from a different family so the two images argue instead
* of blurring, and kept off scenes that are already busy.
*
* There used to be a third slot, `accent`, reserved for scenes declaring
* `role: 'accent'`. Exactly one scene ever declared it, and the overlay path
* above required a `composable` label no scene carried — so the reserved slot
* was the only layering that ever happened, and every layered stack in every
* song was the same particle field. One path, one roster: what goes on top is
* whatever is composable, which is now a third of the library.
*
* Quiet material mostly goes without any — an intro is supposed to be sparse.
*/
function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
{ 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,
params: sampleValues(module, rng, bias, temperament),
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.
// Model stages render with correct alpha (transparent clear) and carry
// dark material colours — lumakey would key them out. Use normal for
// kind:'model' so the alpha is the mask (Assembly, Pylon Field 3D,
// Synthwave Corridor).
blend: module.kind === 'model'
? 'normal'
: 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',
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 --------------------------------------------------------
// Roughly a third of stacks on busy material, rarely on quiet material, and
// never on a background that is itself a full-frame glitch — two competing
// corruption passes is noise, not depth.
// Layering is much more likely now that what goes on top is guaranteed to
// leave the shot underneath visible.
// `population` is how crowded the story wants this point in the video to
// be, and layering is the only lever on that which does not need the scene's
// cooperation: a lone form in an empty frame and the same form under two
// more passes are the sparse and crowded ends of one video.
const crowd = story ? (story.population - 0.5) * 0.5 : 0;
// The base rate was tuned when this branch was dead and layering only ever
// came from the reserved accent slot. With a third of the library eligible
// it lands on half of all stacks, which is the "permanently cluttered" the
// comment above warns about — and it costs seed separation, because a video
// where everything is doubled up looks like every other video where
// everything is doubled up.
const overlayChance = module.family === 'glitch'
? 0.1
: 0.2 + bias.energy * 0.35 + crowd
+ (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);
// Only COMPOSABLE scenes go on top. A second canvas over the first is two
// pictures fighting rather than one picture with depth, and it is what the
// library did for as long as every scene was treated as interchangeable.
//
// The other half of the trade: a composable scene alone is a few bright
// things on black, which scores well for variety and is thin to watch.
// Layering is what turns both halves into one image.
let available = overlayRoster.filter((m) => m.name !== module.name
&& surfaceOf(m) === 'composable');
// Two passes at the same slot rather than two differently-named slots. The
// second is rarer and only on loud, crowded material — that is where the
// old accent pass used to land, and it is the difference between a shot with
// something over it and a shot with a texture and a shimmer over it.
const chances = [
Math.min(0.65, overlayChance),
Math.min(0.25, overlayChance * bias.energy * 0.5),
];
for (const [pass, chance] of chances.entries()) {
// The budget is a wall, and it is also a lean: as the frame fills up
// 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 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
&& m.family !== overlay.family);
// Screen and add keep the background readable underneath; softlight and
// overlay tint it instead. All four preserve the shot; 'normal' would
// simply replace it, which is what the shot cut is for.
const blend = rng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]);
// A second pass sits lighter than the first, so what accumulates is
// depth rather than a third opaque picture.
const fade = pass === 0 ? 1 : 0.6;
layers.push({
module: overlay,
params: sampleValues(overlay, rng.fork(`overlay:${pass}:${overlay.name}`), {
// An overlay reads as texture over the shot, so it is sampled
// sparser and calmer than it would be as a background.
...bias,
density: Math.max(0, bias.density - 0.25),
energy: Math.max(0, bias.energy - 0.2),
}, temperament),
seed: rng.int(0, 0x7fffffff),
blend,
opacity: (blend === 'add' ? rng.range(0.18, 0.42) : rng.range(0.25, 0.55)) * fade,
});
}
return layers;
}
/**
* @param {FeatureTrack} track
* @param {object} options
* @returns {object} LookSpec
*/
export function generateLook(track, {
seed = null, samples = null, overrides = null,
pool: poolOverride = null, poolSize = POOL_SIZE,
} = {}) {
const resolvedSeed = seed !== null
? seed >>> 0
: samples ? hashSamples(samples) : 0x9e3779b9;
const rng = new Rng(resolvedSeed);
const summary = track.summary;
// The production design, decided before a single scene is cast — casting
// depends on it. See look/Personality.js.
const personality = generatePersonality(summary, rng.fork('personality'), (signature) =>
scenes.filter((m) => canBackground(m) && sceneHonours(m, signature)).length,
track.sections.length);
// The track's point of view about what a song looks like. Cast before any
// scene is, because it decides which scenes are even candidates.
const director = pickDirector(summary, rng.fork('director'));
const { palettes, paletteSchemes } = derivePaletteSet(
summary, rng.fork('palettes'), director);
// Back-compat alias: the first palette is still the track's palette.
const palette = palettes[0];
const paletteSource = { lastScheme: paletteSchemes[0] };
// This track's cast, drawn before any section is assigned. See castingPool.
//
// `poolOverride` exists for the variety harness, which needs reference
// videos that share no scenes with each other but are otherwise built by
// exactly this code — a reference assembled by any other path stops being
// comparable to the thing it is bounding.
const pool = poolOverride && poolOverride.length
? poolOverride
: castingPool(rng.fork('pool'), personality.signature, poolSize);
// What HAPPENS over the track, as opposed to what it is made of. Derived
// before casting because it decides which member of a roster anchors which
// section, and whether the outro answers the intro. See look/Story.js.
const story = deriveStory(track, summary, rng.fork('story'));
const rosterByKind = assignRostersByKind(
track.sections, rng.fork('scenes'), personality.signature, director, pool);
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
// dialled. See look/grain.js.
const grain = deriveGrain(summary, rng.fork('grain'));
// How the palette MOVES over the track. See look/paletteArc.js.
const paletteArc = derivePaletteArc(summary, rng.fork('paletteArc'));
// Whether shots change SIZE at the cut, and how boldly. See look/framing.js.
const framing = deriveFramingStyle(summary, rng.fork('framing'));
// WHERE the camera looks, and how it travels there. The director leans
// toward one camera the way it leans toward one family per kind, and the
// seed decides — see look/Camera.js.
const camera = deriveCamera(director, summary, rng.fork('camera'));
// The cast with bodies — one ActorSpec per archetype, seeded so a later
// library of actors (Stage C) grows without changing the infra. See
// src/actors/ActorGenerator.js — audio tilts the centre, seed picks within.
const actors = generateActorSet(summary, rng.fork('actors'), personality, personality.identity);
const { post, feedback } = derivePost(summary, rng.fork('post'), grain);
// Scenes eligible to be composited OVER a background. Same casting rule as
// everything else — an overlay is on screen as much as the shot under it,
// so an off-design one would be just as visible.
//
// Widened past the casting pool with the scenes that exist only to sit on
// top: those are never drawn as a section's primary scene, so the pool —
// which is built out of background candidates — would never contain them.
const overlayOnly = scenes.filter((m) => !canBackground(m));
const overlayRoster = (pool.length >= 4 ? pool : scenes.filter(canBackground))
.concat(overlayOnly);
const sections = track.sections.map((section) => {
const state = storyForSection(story, section.index);
const kindRoster = rosterByKind.get(section.kind) || [scenes[0]];
// The kind's roster, opened on the member this point in the story has
// earned. Same set, different anchor. See anchorOrder.
const roster = anchorOrder(kindRoster, state);
const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
const bias = biasFor(section, summary, personality.motion, state);
// The RATCHET: how hard the track pushes its dials is a property of the
// track (Personality.temperament) scaled by where in the story it is,
// so the last occurrence of a kind samples further out than the first.
const temperament = temperamentFor(personality.temperament, state);
const ground = groundByKind.get(section.kind) || null;
const variants = roster.map((module, v) => buildStack(
module, overlayRoster, bias,
sectionRng.fork(`variant:${section.index}:${v}`), temperament, state,
{ ground, director, kind: section.kind },
));
const shots = planShots(
section, track, bias, variants.length,
sectionRng.fork(`shots:${section.index}`), state,
);
return {
index: section.index,
kind: section.kind,
story: state,
startFrame: section.startFrame,
endFrame: section.endFrame,
start: section.start,
end: section.end,
locked: false,
bias,
variants,
shots,
// The anchor stack, aliased. Everything that predates shots — the
// param panel, presets, the checks — edits a section through this,
// and it is the same object the first variant holds.
layers: variants[0],
};
});
const look = {
seed: resolvedSeed,
palette,
// The full set the director cuts between — ArcDriver reads this per cue.
palettes,
paletteSchemes,
personality,
paletteScheme: paletteSource.lastScheme,
director: director.name,
story,
paletteArc,
framing,
camera,
actors,
grain,
post,
feedback,
sections,
summary,
};
return overrides ? applyOverrides(look, overrides) : look;
}
/** Re-roll one section, leaving everything else — and locked sections — alone. */
export function rerollSection(look, track, sectionIndex, salt = 0) {
const section = look.sections[sectionIndex];
if (!section || section.locked) return look;
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
const signature = (look.personality && look.personality.signature) || [];
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
// was built with, weighted by the signature rather than filtered by it.
let candidates = families.flatMap((f) => scenesInFamily(f))
.filter(canBackground);
if (!candidates.length) candidates = scenes.filter(canBackground);
// Re-roll the whole roster, not just the anchor: the section's shots cut
// between all of them, so replacing one would leave the section half old.
const size = Math.min(rosterSizeFor(section.kind), Math.max(1, candidates.length));
const roster = [];
while (roster.length < size) {
const options = candidates.filter((m) => !roster.includes(m));
if (!options.length) break;
roster.push(rng.pickWeighted(options, options.map((m) => signatureWeight(m, signature))));
}
if (!roster.length) roster.push(scenes[0]);
const overlayRoster = castingPool(rng.fork('pool'), signature)
.concat(scenes.filter((m) => !canBackground(m)));
// 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.
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(
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
temperamentFor(look.personality && look.personality.temperament, state), state,
{ ground, director, kind: section.kind },
));
section.shots = planShots(
section, track, section.bias, section.variants.length, rng.fork('shots'), state,
);
section.layers = section.variants[0];
return look;
}
/** Reroll the whole track with a new seed, preserving locked sections. */
export function rerollLook(look, track, newSeed) {
const locked = new Map();
look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });
const next = generateLook(track, { seed: newSeed >>> 0 });
next.sections.forEach((s, i) => {
if (locked.has(i)) next.sections[i] = locked.get(i);
});
return next;
}
function applyOverrides(look, overrides) {
if (overrides.palette) {
look.palette = overrides.palette;
// Keep the full set in sync if the caller replaced the single alias.
if (look.palettes && look.palettes.length) look.palettes[0] = overrides.palette;
if (overrides.palettes) {
look.palettes = overrides.palettes;
look.palette = look.palettes[0] || look.palette;
}
}
if (overrides.palettes) {
look.palettes = overrides.palettes;
look.palette = look.palettes[0] || look.palette;
}
if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes;
if (overrides.post) look.post = { ...look.post, ...overrides.post };
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
if (overrides.sections) {
overrides.sections.forEach((o, i) => {
if (!look.sections[i]) return;
if (o.locked !== undefined) look.sections[i].locked = o.locked;
if (o.params) Object.assign(subjectOf(look.sections[i].layers).params, o.params);
});
}
return look;
}
/** Compact description, used by the HUD and by check output. */
export function describeLook(look) {
const kinds = look.sections.map((s) => `${s.kind}:${subjectOf(s.layers).module.name}`);
const palTag = look.paletteSchemes && look.paletteSchemes.length > 1
? look.paletteSchemes.join('→')
: look.paletteScheme;
const planTag = look.palettePlan
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
: '';
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
`${describeCamera(look.camera)}${planTag}${actorTag} · ` +
`${[...new Set(kinds)].join(', ')}`;
}
export { defaultValues };