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>
55 lines
2.2 KiB
JavaScript
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;
|
|
}
|