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>
1016 lines
48 KiB
JavaScript
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 };
|