Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.
Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.
The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.
The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.
Chasing the numbers turned up four real faults:
* A scene that reads prev() cannot be a ground. It returns the whole
composited frame including the layers above it, so a datamosh under a shot
is eating it: the render stopped reproducing from a seek and two WebGL
contexts diverged by 91/255 against a tolerance of 4.
* Screen was the wrong operator for a shot over a bed. It lightens, so a
median quarter of every frame clipped to paper and whole sections rendered
100% white. Replaced by a lumakey — the shot's brightness is its alpha.
* Feedback was an accumulator: a still image settled at 2.3x its own
brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
plus a highlight shoulder so the top rolls off instead of clipping.
* useTrack never prewarmed, so a fresh Show's first frame differed from every
later render of it — the export-breaking hazard Compositor.prime documents.
Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.
Rendered across twelve videos, middle of every section:
painted 51% mean, darkest 0.3% -> 87% mean, darkest 43%
clipped white 24% median, worst 100% -> 1% mean, worst 30%
separation 0.10 -> 0.44
Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import { shiftPalette } from './palette.js';
|
||||
import { frameShot, neutralFraming } from './framing.js';
|
||||
import { planGaze, gazeAt } from './Camera.js';
|
||||
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
|
||||
import { subjectIndexOf, isGround } from './stack.js';
|
||||
import { groundPersonalityFrom } from '../scenes/surface.js';
|
||||
|
||||
/**
|
||||
* Drives the look across the song.
|
||||
@@ -597,7 +599,7 @@ export class ArcDriver {
|
||||
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
layer.setPalette(palette);
|
||||
layer.setPersonality(personality);
|
||||
layer.setPersonality(isGround(spec) ? groundPersonalityFrom(personality) : personality);
|
||||
layer.setFraming(outgoingFraming);
|
||||
layers.push(layer);
|
||||
}
|
||||
@@ -610,7 +612,7 @@ export class ArcDriver {
|
||||
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
layer.setPalette(palette);
|
||||
layer.setPersonality(personality);
|
||||
layer.setPersonality(isGround(spec) ? groundPersonalityFrom(personality) : personality);
|
||||
layer.setFraming(framing);
|
||||
layers.push(layer);
|
||||
}
|
||||
@@ -622,7 +624,11 @@ export class ArcDriver {
|
||||
variant: cue.variant,
|
||||
kind: section.kind,
|
||||
crossfade: fading ? eased : 0,
|
||||
sceneName: this._specFor(cue.sectionIndex, cue.variant, 0).module.name,
|
||||
// The SHOT's name, not the ground's. Every stack starts with a bed
|
||||
// now, and the HUD naming it would report the same handful of
|
||||
// canvases for every section of every video.
|
||||
sceneName: this._specFor(
|
||||
cue.sectionIndex, cue.variant, this._subjectSlot(cue)).module.name,
|
||||
buildSlope: features ? features.buildSlope || 0 : 0,
|
||||
// Where the story is, for the HUD and the checks. A video that is
|
||||
// supposed to be going somewhere should be able to say where.
|
||||
@@ -636,10 +642,18 @@ export class ArcDriver {
|
||||
return layers;
|
||||
}
|
||||
|
||||
_stackSize(cue) {
|
||||
/** Which slot of a cue's stack is the shot. See look/stack.js. */
|
||||
_subjectSlot(cue) {
|
||||
return subjectIndexOf(this._stackFor(cue));
|
||||
}
|
||||
|
||||
_stackFor(cue) {
|
||||
const section = this.look.sections[cue.sectionIndex];
|
||||
const stack = (section.variants && section.variants[cue.variant]) || section.layers;
|
||||
return stack.length;
|
||||
return (section.variants && section.variants[cue.variant]) || section.layers;
|
||||
}
|
||||
|
||||
_stackSize(cue) {
|
||||
return this._stackFor(cue).length;
|
||||
}
|
||||
|
||||
/** Layers changed identity — the compositor needs the new list. */
|
||||
|
||||
@@ -7,13 +7,20 @@
|
||||
import { Rng, hashSamples } from '../engine/rng.js';
|
||||
import { AudioPalette, generateUsablePalette } from './palette.js';
|
||||
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
|
||||
import { sampleValues, defaultValues, surfaceOf, canBackground } from '../params/schema.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 } from './directors.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';
|
||||
@@ -208,6 +215,120 @@ 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.
|
||||
@@ -341,10 +462,49 @@ function derivePost(summary, rng, grain) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One layer stack: a background scene, and sometimes one or two composable
|
||||
* scenes composited over it.
|
||||
* How much a candidate would add to a stack, structurally.
|
||||
*
|
||||
* background — the shot. Always present, always opaque.
|
||||
* 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.
|
||||
@@ -360,14 +520,80 @@ function derivePost(summary, rng, grain) {
|
||||
*
|
||||
* Quiet material mostly goes without any — an intro is supposed to be sparse.
|
||||
*/
|
||||
function buildStack(module, overlayRoster, bias, rng, temperament, story = null) {
|
||||
const layers = [{
|
||||
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),
|
||||
blend: 'normal',
|
||||
// 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.
|
||||
blend: 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
|
||||
@@ -410,10 +636,26 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null)
|
||||
Math.min(0.25, overlayChance * bias.energy * 0.5),
|
||||
];
|
||||
for (const [pass, chance] of chances.entries()) {
|
||||
if (!available.length || !rng.bool(chance)) break;
|
||||
// Prefer a different family so the images argue instead of blurring.
|
||||
// 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 overlay = rng.pick(offFamily.length ? offFamily : available);
|
||||
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
|
||||
@@ -486,6 +728,10 @@ export function generateLook(track, {
|
||||
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'));
|
||||
@@ -523,9 +769,12 @@ export function generateLook(track, {
|
||||
// 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(
|
||||
@@ -579,7 +828,8 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
|
||||
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
|
||||
const signature = (look.personality && look.personality.signature) || [];
|
||||
const families = directorByName(look.director).families[section.kind] || Object.keys(FAMILIES);
|
||||
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))
|
||||
@@ -602,9 +852,14 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
// 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,
|
||||
@@ -633,7 +888,7 @@ function applyOverrides(look, overrides) {
|
||||
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(look.sections[i].layers[0].params, o.params);
|
||||
if (o.params) Object.assign(subjectOf(look.sections[i].layers).params, o.params);
|
||||
});
|
||||
}
|
||||
return look;
|
||||
@@ -641,7 +896,7 @@ function applyOverrides(look, overrides) {
|
||||
|
||||
/** Compact description, used by the HUD and by check output. */
|
||||
export function describeLook(look) {
|
||||
const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`);
|
||||
const kinds = look.sections.map((s) => `${s.kind}:${subjectOf(s.layers).module.name}`);
|
||||
return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
|
||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
||||
|
||||
@@ -35,7 +35,34 @@
|
||||
* when it is being quiet.
|
||||
*/
|
||||
export const RESTFUL_FAMILIES = ['minimal', 'flow', 'organic'];
|
||||
const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
||||
export const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
||||
|
||||
/**
|
||||
* How full a director lets the frame get.
|
||||
*
|
||||
* Scales the coverage budget in LookGenerator — the painted-area ceiling a
|
||||
* section's stack is built against, where 1.0 is one filled frame and 2.0 is
|
||||
* the hard cap. A point of view about what a song looks like includes how much
|
||||
* is allowed to be happening at once, and it is the difference between a
|
||||
* brutalist drop (one big thing) and a corrupt one (everything, over
|
||||
* everything). Absent, a director is read as 1.2.
|
||||
*/
|
||||
const DEFAULT_CROWD = 1.2;
|
||||
|
||||
/**
|
||||
* How willing a director is to let the frame BLAZE.
|
||||
*
|
||||
* A blaze is the shot composited additively over its ground instead of keyed
|
||||
* onto it, so the two brightnesses sum and the highlights bloom out. It is a
|
||||
* real effect and worth having — a drop that goes white for eight bars reads as
|
||||
* the song peaking — and it was, until it was made a decision, simply what
|
||||
* every section did: screen over a filled ground blew a median quarter of every
|
||||
* frame to paper, all the time, in every video.
|
||||
*
|
||||
* So it is rationed. This is the appetite; the section still has to be loud and
|
||||
* late in the story to earn one. Absent, a director is read as 0.15.
|
||||
*/
|
||||
const DEFAULT_BLAZE = 0.15;
|
||||
|
||||
/**
|
||||
* Each director maps every section kind to three families, most-preferred
|
||||
@@ -45,6 +72,11 @@ const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
||||
export const DIRECTORS = [
|
||||
{
|
||||
name: 'ambient',
|
||||
// Space, not glare. A blaze here is the exception that proves it.
|
||||
blaze: 0.1,
|
||||
// Space is the subject, so the frame stays owed to it: one
|
||||
// picture, and a pass over it only when the song is at its loudest.
|
||||
crowd: 1.1,
|
||||
// Patient camera to match: long moves, mostly along one line. See
|
||||
// look/Camera.js — a director's point of view now includes how it
|
||||
// shoots, not only what it points at.
|
||||
@@ -63,6 +95,11 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'brutalist',
|
||||
// Mass does not glow. Almost never.
|
||||
blaze: 0.05,
|
||||
// One large thing, seen whole. Layering hides mass, which is the
|
||||
// only thing this director is interested in.
|
||||
crowd: 1.05,
|
||||
// Holds, then commits to one large move. Architecture is looked AT.
|
||||
camera: 'deliberate',
|
||||
// Everything is architecture. Quiet means empty rather than soft, so it
|
||||
@@ -79,6 +116,11 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'organicist',
|
||||
// Light through leaves — bloom belongs to this world.
|
||||
blaze: 0.2,
|
||||
// Growth accumulates. Things overlap here because that is what
|
||||
// living material does — nothing in this world is a single clean plate.
|
||||
crowd: 1.45,
|
||||
// Never settles, because nothing here is ever finished settling.
|
||||
camera: 'roaming',
|
||||
// Nothing is ever built; things grow and dissolve. Deliberately never
|
||||
@@ -95,6 +137,11 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'corrupt',
|
||||
// Overload is the subject. Half its drops go white.
|
||||
blaze: 0.5,
|
||||
// Everything over everything. The damage is the subject and it is
|
||||
// never confined to one plate.
|
||||
crowd: 1.7,
|
||||
// Cuts with the camera already moving.
|
||||
camera: 'kinetic',
|
||||
// The signal is damaged and the damage is the subject — everywhere the
|
||||
@@ -112,6 +159,11 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'geometer',
|
||||
// Exact, and occasionally exact and incandescent.
|
||||
blaze: 0.15,
|
||||
// Pattern on pattern is a moiré, which is a pattern. Layers are
|
||||
// welcome as long as they are exact.
|
||||
crowd: 1.3,
|
||||
// Small, exact, always arrives — the pattern is the subject and the
|
||||
// camera does not editorialise about it.
|
||||
camera: 'precise',
|
||||
@@ -156,6 +208,16 @@ export function pickDirector(summary, rng) {
|
||||
return rng.pickWeighted(DIRECTORS, weights);
|
||||
}
|
||||
|
||||
/** How full this director lets the frame get. See DEFAULT_CROWD. */
|
||||
export function crowdOf(director) {
|
||||
return (director && director.crowd) || DEFAULT_CROWD;
|
||||
}
|
||||
|
||||
/** How willing this director is to let a section blaze. See DEFAULT_BLAZE. */
|
||||
export function blazeOf(director) {
|
||||
return (director && director.blaze !== undefined) ? director.blaze : DEFAULT_BLAZE;
|
||||
}
|
||||
|
||||
export function directorByName(name) {
|
||||
return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// What a layer stack is made of, and how to ask it questions.
|
||||
//
|
||||
// A stack used to be "the scene, then whatever was composited over it", so
|
||||
// `layers[0]` meant the section's scene everywhere in the program. It no longer
|
||||
// does: every stack now starts with a GROUND — a filled canvas the shot happens
|
||||
// on — and the scene the section is ABOUT sits above it.
|
||||
//
|
||||
// Everything that used to reach for `layers[0]` wants the subject, not the
|
||||
// ground: the param panel edits it, the HUD names it, and the variety report
|
||||
// counts it. Those all go through `subjectOf` now. Reading the ground as the
|
||||
// section's scene would be actively wrong for the measurements — grounds come
|
||||
// from a twenty-scene pool, so a report that counted them would show a library
|
||||
// three times smaller than the one actually on screen.
|
||||
|
||||
import { coverageOf } from '../scenes/surface.js';
|
||||
|
||||
/** Layers carrying this role are the bed, not the shot. */
|
||||
export const GROUND = 'ground';
|
||||
|
||||
export function isGround(layer) {
|
||||
return !!layer && layer.role === GROUND;
|
||||
}
|
||||
|
||||
/** Index of the layer the section is about. */
|
||||
export function subjectIndexOf(stack) {
|
||||
const at = stack.findIndex((l) => !isGround(l));
|
||||
return at < 0 ? 0 : at;
|
||||
}
|
||||
|
||||
/** The layer the section is about — the shot, as opposed to what it stands on. */
|
||||
export function subjectOf(stack) {
|
||||
return stack[subjectIndexOf(stack)];
|
||||
}
|
||||
|
||||
/** The ground under a stack, or null if the subject is its own ground. */
|
||||
export function groundOf(stack) {
|
||||
return stack.find(isGround) || null;
|
||||
}
|
||||
|
||||
/** Everything above the subject: the passes composited over the shot. */
|
||||
export function overlaysOf(stack) {
|
||||
return stack.slice(subjectIndexOf(stack) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much painted frame a stack adds up to, in units of one filled frame.
|
||||
*
|
||||
* Deliberately a SUM and not a union: two layers each painting 60% do not add
|
||||
* up to 120% of a screen, but they do add up to two things happening at once,
|
||||
* and that is the quantity the budget is about. 2.0 is the ceiling — see
|
||||
* MAX_COVERAGE in LookGenerator.
|
||||
*/
|
||||
export function stackCoverage(stack) {
|
||||
return stack.reduce((sum, l) => sum + coverageOf(l.module), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user