music-video-gen/flow-state/src/engine/flash.js
Dejvino 59180948d0 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>
2026-08-05 11:38:26 +02:00

55 lines
2.2 KiB
JavaScript

// 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;
}