A flickering light for horror video in code: seeded, frame-exact and within the flash limit

The easiest flickering light to code is the one you shouldn't ship. Picking randomly between a low and a high level on every frame at 30 fps gives 11 flashes in the worst one-second window of my test. The WCAG accessibility limit is 3, because fast full-screen flashing can trigger seizures.

A failing fluorescent tube is one of the cheapest scares there is, and it's usually done badly in code. It's either Math.random() per frame, which changes on every render, or a hand-keyed curve, which is slow to make. This tutorial builds one in about 90 lines of JavaScript that renders the same way every time, lands on the exact frame you planned when ffmpeg applies it, and holds the light itself to a flash limit.

1. Random, but the same every render

Frame-based renderers may draw frames out of order or across several workers, so a light built on Math.random() isn't reproducible. Here are two runs of that level generator, hashed:

Math.random, two runs of the generator: 2bda3d069cf5 06be79d1481a

Your hash will differ, since that's the point of the line. The fix is a random function with no memory, where the value depends only on a seed and an index. Here LOW is 0.18 and HUM is 0.85:

const level = rnd(SEED, Math.floor(frame / 2)) < 0.55 ? LOW : HUM;

Run it forward, then backward, and the result doesn't change:

seeded, forward and backward: aeb9e94d3255 aeb9e94d3255

With the same code and seed, you should get aeb9e94d3255 on your machine too, which is an easy way to test it. Now a take you like is just a seed number you can keep.

2. Shape it: hum, bursts and silence

Pure noise reads as a broken monitor, and a tube that's about to die has a rhythm. It hums almost steadily, then stutters in short bursts. And the bursts should be where the story wants them, so they're placed by hand, while the seed decides what happens inside each one:

const BURSTS = [{ at: 1.5, len: 0.9 }, { at: 4.6, len: 1.4 }, { at: 8.0, len: 0.5 }, { at: 9.4, len: 1.1 }];

Between bursts the level wobbles by at most 0.02 either side of 0.85. Inside a burst it can only change every 2 frames, because a light that changes on every single frame reads as a video glitch more than a failing tube. Those numbers are my taste for this clip, so change them for yours.

3. Count the flashes

WCAG 2.3.1 counts a flash as a pair of opposing changes in relative luminance of 10% or more of the maximum, where the darker level is below 0.80. It allows no more than three in any one-second period, unless the flash stays under the area and red-flash thresholds.

flashes() counts those pairs, tracking the highest and lowest points so a slow slide isn't counted as several steps. Counters are easy to get wrong, so it has a self-test: 6 full swings should give 6.

flash counter self-test, 6 full swings: 6 flashes
new level every frame: worst 1-second window 11
raw flicker: 15 flashes, worst 1-second window 4
capped flicker: 14 flashes, worst 1-second window 3
capped, two walks: 1a11eebcaaf4 1a11eebcaaf4
frames changed by the cap: 22 of 360

So even the shaped version goes over, by one flash in its worst window. The cap walks the frames in order and, whenever the next change would break the limit, holds the previous level. That changes 22 of the 360 frames and removes one flash overall. A held level looks like the tube hanging for a moment, so the fix doesn't look like a fix. The walk depends only on the seed, so the capped result is also the same every time, as the two hashes show. It does give up one thing: a frame's capped level can't be computed alone, since it depends on the frames before it. So compute the whole list once and hand it to your frames or workers.

There are limits to this check, and they matter. It treats the whole picture as one level at full white, which is the cautious reading, but it can't see a small bright area flashing or test saturated red. It counts only this light, so cuts and flashes already in your footage aren't included. It uses a plain 2.2 power for luminance, not the exact sRGB formula. It isn't a certified photosensitivity test either, so if your work goes to a broadcaster, use the analysis tool they require.

4. Apply it in ffmpeg, on the exact frame

ffmpeg's sendcmd filter changes another filter's options at a given time. colorchannelmixer multiplies red, green and blue, so one line per frame sets the light level:

0.0666 [enter] colorchannelmixer rr 0.8676, [enter] colorchannelmixer gg 0.8676, [enter] colorchannelmixer bb 0.8676;

This command renders it on a generated test clip, and logs the average brightness of each frame:

ffmpeg -y -v error -f lavfi -i "gradients=s=1080x1920:c0=0x4a5a66:c1=0x10161c:x0=540:y0=200:x1=540:y1=1800:r=30:d=12" -vf "sendcmd=f=flicker.cmd,colorchannelmixer=rr=1:gg=1:bb=1,format=yuv420p,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=y.txt" -c:v libx264 -crf 18 -preset fast out.mp4

To use your own footage, swap the -f lavfi -i "..." part for -i your_clip.mp4, and set FPS, SECONDS and BURSTS in the script to match the clip. This script assumes a whole-number frame rate, so 29.97 footage needs its timing adapted.

The test that earned its place here compares the plan with the filtered frames (signalstats runs in the filter chain, before the encoder), and it needs a clip of steady brightness like this one, so run it on the test clip. On real footage the luma changes by itself, and the result would stop being flat even with perfect timing. Luma above black, divided by the planned level, should be flat if each level landed on its own frame. My first version wasn't:

toFixed timestamps:  spread of luma/plan: same frame 22.685, one frame late 22.690
floor timestamps:    spread of luma/plan: same frame 1.020, one frame late 22.714

The cause was one call. toFixed(4) rounds 0.06666... up to 0.0667, which is just after that frame's timestamp, so the command fired one frame late. That hit 120 of the 360 frames, every third one. Rounding the time down fixed it, and the spread fell to 1.020, while shifting the plan by one frame on purpose still gives 22.714, so the check can tell the two apart.

5. Why not trust the measured average for flashes?

I also ran the flash count on the measured frames, and it found 0. The average luminance, estimated by applying that same 2.2 power to the mean luma, only moved between 0.001 and 0.026, so it never swings by 10%. That doesn't make the clip safe by itself: an average across a dark frame hides a bright region swinging hard, and those are the regions the area rule cares about. That's why the cap above works from the worst case.

The whole script

Save it as flicker.mjs. I ran it on Node 26 and ffmpeg 9.0.2; there are no npm dependencies:

// flicker.mjs: a failing-tube light for horror video. Same frames on every render, and a flash limit. No libraries.
// Run: node flicker.mjs    prints the checks, writes flicker.cmd (per-frame commands for ffmpeg's sendcmd)
import { readFileSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';

const FPS = 30, SECONDS = 12, FRAMES = FPS * SECONDS, SEED = 7;

// A random number from a seed and an index, with no memory: frame 97 gets the same value in any order, on any worker.
function rnd(seed, i) {
  let h = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b) ^ Math.imul(i + 1, 0xc2b2ae35);
  h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
  h = Math.imul(h ^ (h >>> 13), 0x297a2d39);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

const HUM = 0.85, LOW = 0.18;
// Bursts sit at fixed places so the light can be timed to the story. Between them, a slow hum with a small wobble.
const BURSTS = [{ at: 1.5, len: 0.9 }, { at: 4.6, len: 1.4 }, { at: 8.0, len: 0.5 }, { at: 9.4, len: 1.1 }];
function raw(frame) {
  const t = frame / FPS, burst = BURSTS.find(b => t >= b.at && t < b.at + b.len);
  if (burst) return rnd(SEED, Math.floor(frame / 2)) < 0.55 ? LOW : HUM;   // the level can change every 2 frames
  return HUM + (rnd(SEED + 1, Math.floor(frame / 6)) - 0.5) * 0.04;
}

// WCAG 2.3.1 counts a flash as a pair of opposing luminance changes of 10% or more, where the darker level is under 0.80.
// Whole-frame levels only: this treats the entire picture as flashing, and it says nothing about small bright areas.
const luminance = v => Math.pow(v, 2.2);
function flashes(levels, toLum = luminance) {
  const out = [];
  let anchor = toLum(levels[0]), dir = 0, opposed = 0;
  levels.forEach((v, f) => {
    const l = toLum(v), d = l - anchor;
    if (Math.abs(d) < 0.1) { if (dir && Math.sign(d) === dir) anchor = l; return; }
    if (Math.sign(d) === dir) { anchor = l; return; }
    if (Math.min(l, anchor) < 0.8 && ++opposed % 2 === 0) out.push(f);
    dir = Math.sign(d); anchor = l;
  });
  return out;
}
function worstSecond(marks, fps = FPS) {
  let worst = 0;
  marks.forEach((f, i) => { worst = Math.max(worst, marks.filter(g => g >= f && g < f + fps).length); });
  return worst;
}

// Walk the frames in order, once, and hold the level whenever the next change would pass the limit in any 1-second window.
// The walk is itself a pure function of the seed, so the result is the same on every render.
function capped(limit = 3) {
  const out = [];
  for (let f = 0; f < FRAMES; f++) {
    out.push(raw(f));
    if (worstSecond(flashes(out)) > limit) out[f] = out[f - 1];
  }
  return out;
}

const levels = capped();

// Second pass, after ffmpeg has run: compare the filtered frames with the plan, frame by frame.
if (process.argv[2] === 'check') {
  const y = readFileSync(process.argv[3], 'utf8').split('lavfi.signalstats.YAVG=').slice(1).map(parseFloat);
  const lift = shift => {   // luma above black, divided by the planned level: a flat result means the two line up
    const r = y.map((v, f) => (v - 16) / levels[Math.max(0, f - shift)]);
    return Math.max(...r) / Math.min(...r);
  };
  console.log(`measured frames: ${y.length}; spread of luma/plan: same frame ${lift(0).toFixed(3)}, one frame late ${lift(1).toFixed(3)}`);
  const toLum = v => Math.pow((v - 16) / 219, 2.2);
  console.log(`measured average luminance: ${Math.min(...y.map(toLum)).toFixed(3)} to ${Math.max(...y.map(toLum)).toFixed(3)}; flashes ${flashes(y, toLum).length}`);
  process.exit(0);
}
const sha = a => createHash('sha256').update(a.join()).digest('hex').slice(0, 12);
const rawLevels = Array.from({ length: FRAMES }, (_, f) => raw(f));
const backward = Array.from({ length: FRAMES }, (_, i) => raw(FRAMES - 1 - i)).reverse();
const unseeded = () => Array.from({ length: FRAMES }, () => (Math.random() < 0.55 ? LOW : HUM));

console.log(`Math.random, two runs of the generator: ${sha(unseeded())} ${sha(unseeded())}`);
console.log(`seeded, forward and backward: ${sha(rawLevels)} ${sha(backward)}`);
const everyFrame = Array.from({ length: FRAMES }, (_, f) => (rnd(SEED, f) < 0.55 ? LOW : HUM));
console.log(`flash counter self-test, 6 full swings: ${flashes([1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1]).length} flashes`);
console.log(`new level every frame: worst 1-second window ${worstSecond(flashes(everyFrame))}`);
console.log(`raw flicker: ${flashes(rawLevels).length} flashes, worst 1-second window ${worstSecond(flashes(rawLevels))}`);
console.log(`capped flicker: ${flashes(levels).length} flashes, worst 1-second window ${worstSecond(flashes(levels))}`);
console.log(`capped, two walks: ${sha(levels)} ${sha(capped())}`);
console.log(`frames changed by the cap: ${levels.filter((v, f) => v !== rawLevels[f]).length} of ${FRAMES}`);

// One sendcmd line per frame: the filter multiplies red, green and blue by the level for this frame.
const cmd = levels.map((v, f) => {
  const k = v.toFixed(4), at = Math.floor(f / FPS * 1e4) / 1e4;   // rounded down: a command timed after its frame fires one frame late
  return `${at.toFixed(4)} [enter] colorchannelmixer rr ${k}, [enter] colorchannelmixer gg ${k}, [enter] colorchannelmixer bb ${k};`;
}).join('\n');
writeFileSync('flicker.cmd', cmd + '\n');
node flicker.mjs
# run the ffmpeg command above, then:
node flicker.mjs check y.txt

The first run writes flicker.cmd. My 12-second out.mp4 came out at about 1.18 Mb/s. The test clip is a plain gradient, so expect real footage to differ.

Where this stops being enough

This is one global light level. Real scenes need more: a lit area that falls off instead of the whole frame dimming, a shadow that jumps when the light does, sound that's locked to the bursts, and a camera doing something on the beat. A scene also needs a story that earns the flicker, because a light flickering for no reason stops being scary after a few seconds.

Disclosure: I'm the founder of Homingo; Homingo sells the product below, and this article was written with AI assistance, with the code run before publishing.

If you make animated horror for Reels and Shorts, lighting is one small part of what we package. The Animated Horror Reels Skill for Claude Code gives you a hook-first story method, a timed episode spec, a camera, light and grade system, a render-ready starter project, and story lint, storyboard stills and technical QA as one command each. You direct the story and Claude Code writes the code. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds video lessons, written guides, cheat sheets, an example project and a full demo episode project), one-time, prices in INR, international cards accepted; it needs Claude Code, Node.js and ffmpeg, Claude plans not included: Animated Horror Reels Skill.

Comments

Popular posts from this blog

Catch Overlapping Labels Before Rendering With a Sweep Over X

Emit an isometric cube as three SVG polygons and test its shared edges

Shading an isometric box in plain JavaScript: three face tones from one light direction