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:
Dejvino
2026-08-20 09:02:01 +02:00
co-authored by Claude Opus 5
parent 7d151be6e1
commit 89e05459c0
68 changed files with 9896 additions and 293 deletions
+20 -6
View File
@@ -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. */
+270 -15
View File
@@ -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)} · ` +
+63 -1
View File
@@ -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];
}
+55
View File
@@ -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);
}