Phase 5: compositing depth ("C" complete)

Multi-layer stacks with blend modes, feedback, post chain, and a 3D
particle layer proving the compositor is genuinely hybrid. Looks now
generate accent layers from a different family, composited additively at
low opacity, weighted by section energy so intros stay sparse.

Adds flash-rate safety (engine/flash.js), which was not in the original
plan and should have been. This generates beat-reactive video for
publication, and rapid light-dark cycling is the photosensitive-epilepsy
trigger; WCAG 2.3.1 caps it at three flashes per second. Classic Wave
measured 7-8/s at every output resolution from 96x54 to 1920x1080, so it
was a real hazard rather than a sampling artefact.

Root cause was general, not one bad shader: `u_time * u_speed` where speed
is reactively modulated. Phase is elapsed*rate, so changing the rate at
time T jumps phase by T*delta — sixty seconds in, a 0.05 wobble throws the
phase three whole units between consecutive frames, and it worsens as the
track runs. Fixed by introducing rate params:

- schema flag `rate: true` documents and marks them
- Layer.resolveParams skips reactivity on them
- ArcDriver skips drift on them
- validateModule rejects a reactive entry on one
- lint-scenes greps shaders for `u_time * u_X` and fails if X is unmarked,
  so no future scene can reintroduce it

Every rate param across the six scenes is now marked. Two checks were
needed to find this: a per-look flash check, and a per-SCENE sweep at
aggressive params, since the look generator only samples part of the space
and a scene can hide an unsafe region for a long time.

Gate 10/10. Worst flash rate now 1/s. Feedback stable over 10,000 frames
(luminance 0.17-0.59, no saturation or decay). 0.17ms/frame at 1280x720.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-05 11:38:26 +02:00
co-authored by Claude Opus 5
parent 5f25437b89
commit 59180948d0
14 changed files with 594 additions and 20 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ export class ArcDriver {
plan = [];
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
if (def.noDrift) continue;
if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG
const [lo, hi] = def.range || [0, 1];
plan.push({
name,
+30 -7
View File
@@ -144,11 +144,40 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
const { post, feedback } = derivePost(summary, rng.fork('post'));
// Scenes eligible as accents: 3D layers composite over a shader background
// without fighting it, so they are preferred where available.
const accentRoster = scenes.filter((m) => m.kind === 'layer3d');
const sections = track.sections.map((section) => {
const module = sceneByKind.get(section.kind) || scenes[0];
const sectionRng = rng.fork(`section:${section.index}:${module.name}`);
const bias = biasFor(section, summary);
const layers = [{
module,
params: sampleValues(module, sectionRng, bias),
seed: sectionRng.int(0, 0x7fffffff),
blend: 'normal',
opacity: 1,
}];
// Accent layer. Composited additively over the background at low opacity,
// and drawn from a DIFFERENT family so it reads as depth rather than as a
// second competing scene. Quiet sections mostly go without — an intro is
// supposed to be sparse.
const accentChance = bias.energy * 0.8;
if (accentRoster.length && sectionRng.bool(accentChance)) {
const accent = sectionRng.pick(accentRoster.filter((m) => m.family !== module.family) || accentRoster)
|| accentRoster[0];
layers.push({
module: accent,
params: sampleValues(accent, sectionRng.fork(`accent:${section.index}`), bias),
seed: sectionRng.int(0, 0x7fffffff),
blend: sectionRng.pickWeighted(['add', 'screen'], [2, 1]),
opacity: sectionRng.range(0.18, 0.5),
});
}
return {
index: section.index,
kind: section.kind,
@@ -158,13 +187,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
end: section.end,
locked: false,
bias,
layers: [{
module,
params: sampleValues(module, sectionRng, bias),
seed: sectionRng.int(0, 0x7fffffff),
blend: 'normal',
opacity: 1,
}],
layers,
};
});