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:
@@ -53,6 +53,9 @@ export class Layer {
|
||||
for (const [name, r] of Object.entries(reactive)) {
|
||||
const def = defs[name];
|
||||
if (!def || def.type === 'palette') continue;
|
||||
// Rate params multiply absolute time; modulating them jumps the phase
|
||||
// by elapsed * delta. See params/schema.js RATE_FLAG.
|
||||
if (def.rate) continue;
|
||||
const raw = features[r.feature];
|
||||
if (raw === undefined) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Flash-rate safety.
|
||||
//
|
||||
// This project generates beat-reactive video that gets published. Rapid
|
||||
// light-dark cycling between roughly 3 and 50 Hz is the photosensitive-epilepsy
|
||||
// trigger, and a generator that flashes a bright scene on every kick of a 128 BPM
|
||||
// track sits right in that band. WCAG 2.3.1 and the Harding test both use a
|
||||
// three-flashes-per-second ceiling, which is what this measures.
|
||||
//
|
||||
// It is deliberately part of the gate rather than an afterthought: an
|
||||
// unsupervised generator will find these states on its own, and nobody watches
|
||||
// every frame of every export.
|
||||
|
||||
/** One flash = a min→max→min luminance cycle with amplitude at or above `threshold`. */
|
||||
export function countFlashes(luminance, { threshold = 0.1 } = {}) {
|
||||
if (luminance.length < 3) return 0;
|
||||
|
||||
const extrema = [];
|
||||
for (let i = 1; i < luminance.length - 1; i++) {
|
||||
const a = luminance[i - 1], b = luminance[i], c = luminance[i + 1];
|
||||
if ((b > a && b >= c) || (b < a && b <= c)) {
|
||||
extrema.push({ index: i, value: b, isMax: b > a });
|
||||
}
|
||||
}
|
||||
|
||||
let flashes = 0;
|
||||
for (let i = 1; i < extrema.length - 1; i++) {
|
||||
const prev = extrema[i - 1], here = extrema[i], next = extrema[i + 1];
|
||||
if (!here.isMax) continue;
|
||||
const rise = here.value - prev.value;
|
||||
const fall = here.value - next.value;
|
||||
if (rise >= threshold && fall >= threshold) flashes++;
|
||||
}
|
||||
return flashes;
|
||||
}
|
||||
|
||||
/** Flashes per second over a luminance series sampled at `fps`. */
|
||||
export function flashRate(luminance, fps) {
|
||||
const seconds = luminance.length / fps;
|
||||
return seconds > 0 ? countFlashes(luminance) / seconds : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worst flash rate in any one-second window. A track that averages 2/s but has a
|
||||
* drop running at 8/s is not safe, and the average would hide it.
|
||||
*/
|
||||
export function peakFlashRate(luminance, fps) {
|
||||
const window = Math.round(fps);
|
||||
if (luminance.length <= window) return flashRate(luminance, fps);
|
||||
let worst = 0;
|
||||
for (let i = 0; i + window < luminance.length; i += Math.max(1, Math.round(fps / 4))) {
|
||||
worst = Math.max(worst, countFlashes(luminance.slice(i, i + window)));
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
Reference in New Issue
Block a user