// 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 };