film-engine: films drawn from code, one frame at a time
Where the kit lives: run every command in this skill from the
reels-makerfolder (the clone of github.com/thativanng/reels-maker that holdstemplate/andtools/). If the project has none, set it up first by following that repo's SETUP.md.
The idea in one paragraph
A film here is one HTML page with one rule: window.seek(t) must draw exactly the frame for time t, every time, from nothing but t. Nothing in the picture may read a real clock. A renderer then steps t at 30 fps, screenshots each frame at 2x and pipes it to ffmpeg. Because the page is deterministic, a re-render matches (on average to within a fraction of a level; GPU noise can move single pixels by up to about 60 levels), a still at 17.3 s is the frame the video will show, and every check (film-audit) can look at any moment without playing the film.
The route (do not skip a station)
- Brief and look first. reel-intake ends in a signed brief and storyboard (format, length, sound, what never appears); reel-style ends in a picked look. Don't open this skill's build steps without both.
- Storyboard. reel-intake writes it (time · on-screen words · sound and caption · why; references/brief.md), and the person signs it before you build. A style with a banner adds a banner column.
- Test strip. Build about the first 10 s in the chosen look (a walk-style film may need 15–30 s to show one chapter), take stills, get a yes. A full build on an unsigned look is wasted work.
- Build the full film (the style pack says how to build scenes).
- Gates (film-audit): the collision check at 40 fps, the pixel probes, then an independent auditor. Nothing goes to the person before this.
- Render:
node tools/render.mjs --page reels/<name>/page.html --out reels/<name>/out/film-silent.mp4(2x frames, scaled down with lanczos, no colour tags). - Audio:
python3 template/audio.py out/film.wav(a cue list on the same clock as the film). - Finish:
zsh tools/mux.sh out/film-silent.mp4 out/film.wav out/film.mp4. This gives −14 LUFS, a limiter, an untagged H.264 file and a phone preview the same shape as the film (short side 720 px). - Cover:
node tools/cover.mjs --page reels/<name>/page.html --out reels/<name>/out/cover.jpg. A page with a cover mode (the starter's?cover, from configcover) draws the designed still; any other page gives the frame atT=. It is saved in the film's own shape and kept under 2 MB, plus the centre 3:4 crop for a 9:16 film (the Instagram grid, reported). LinkedIn takes a cover only at posting time: afterwards only a post's text can be edited (LinkedIn Help, "Edit a shared post", https://www.linkedin.com/help/linkedin/answer/a522811). Its thumbnail spec, JPG or PNG in the video's shape and at most 2 MB, comes from LinkedIn's help as quoted by a search summary; the page itself would not load, so treat it as unverified. - Send the phone preview first, then the full file and the cover.
The page contract
window.seek(T): draws frame T. It is the only place that animates anything.window.ready: a promise that resolves after fonts, textures and the first frame.window.DUR: the film's length in seconds.window.FILM = { w, h, format, safe }: the frame size and the keep-clear zone in pixels. Every tool sizes its window from this, so the page owns its format.- Optional:
window.cover()draws the cover instead of a frame, andFILM.coverAtnames the moment it sits on (tools/cover.mjs uses both; without them it takes the frame atT=). window.collide(T)andwindow._dbg: hooks for film-audit (see that skill).- The tools serve the kit folder over http on their own (ES modules don't load from
file://). To watch a page live in a browser,npm run serveand open http://127.0.0.1:8765/template/starter.html.
Formats, the starter, and other languages
template/formats.jsonholds the four sizes (9:16 1080×1920 · 4:5 1080×1350 · 1:1 1080×1080 · 16:9 1920×1080), the platform → format map, and each platform's keep-clear zone with its source. The brief's format line picks from it.- Every reel lives in its own folder,
reels/<name>/(gitignored): its brief, storyboard,page.html(a copy of the starter or of film.html), itsreel.jsonorcareer.json, and itsout/. Pages load fonts and formats.json from the kit's root, so a copy works from there. Point the tools at it:PAGE=reels/<name>/page.html. template/starter.html+template/reel.jsonis the skeleton for every look except the voxel walk. Its words and timings live in the config, it sizes itself from?fmt=or the config, and it keeps every word inside the platform's keep-clear core.?guidesshows the covered zones in red, for checking only; never render with it on. It is a skeleton, not a look: reel-style rewrites its tokens and layout.template/film.html(the voxel template) is 4:5 only, and it opts out of keep-clear zones (safeis 0): its banner and subtitles sit near the edges by design, so thesafeprobe always passes there and the auditor's eye is the check. Its cameras are keyed for 4:5; another ratio means re-keying them and re-running every gate.- Chinese, Japanese, Korean: the starter loads Noto Sans TC from Google Fonts at render time (only the characters the config uses), and runs CJK type at about 0.72× the Latin size (their characters are a full em wide). Write
\nwhere a line should break: Chinese breaks between any two characters, so a long line needs a break you chose.template/reel.zh.jsonis a worked example. Any other script needs its own font in the stack.
Clocks and timing
- One film clock drives the scenes, the camera, the banner, the subtitles and the sound. When you insert a beat later without re-timing everything, use a remap: the scene clock pauses at the insert point while the overlays keep the real clock:
The audio script needs the same table: write anconst INS = [[24.0, 5.0, 'detail']]; // at scene time 24.0, play a 5 s inserted beat function remap(T) { let sh = 0; for (const [a, len, id] of INS) { if (T < a + sh) return { t: T - sh, ins: null }; if (T < a + sh + len) return { t: a, ins: id, u: T - sh - a, len }; sh += len; } return { t: T - sh, ins: null }; }RT(t)that adds the inserted lengths to a scene time, and place every cue atRT(t), or every cue after the insert drifts. (The template has no inserts, so its audio.py doesn't need one.) - Easing:
eiofor camera moves,eofor things arriving,trap(trapezoid speed) for walking and driving, so a mover doesn't "snap" at the ends. - Camera keys are absolute
A(pos, target)or followF(offset, targetOffset)from the hero's ground point. Blend between keys witheio. Hold on a beat that matters; a shot that moves all the time reads as nervous.
The overlays (in DOM, above the canvas)
- Banner: year, then company, then the role pill, plus a timeline whose dot breathes on the current year. The banner changes when the new scene is ON SCREEN (its set has risen, the outfit has changed, the hero has arrived), never when the code starts building it. Check this on a frame sheet at ±1 s around every change.
- Subtitles: one line of story per beat, with the key phrase in bold accent. Use
text-wrap: balance. Give a reader about 3 words a second, and fade a subtitle out before a fast camera move uncovers the subject behind it. - A speech bubble that follows a character: project the head point and clamp the bubble ≥24 px inside the frame.
- The end card: name, one tagline, what they're good at, one quote. The scene blurs behind it.
Audio
- Cues are written on the film clock, next to the moment they belong to (steps per walk leg, a chime when a thing appears, a thud when it lands).
- No whoosh sounds on transitions; they read as cheap. Use chimes, ticks and soft impacts.
- The default bed in
template/audio.pyis generated from sine waves, so it's free to use anywhere. If someone brings an AI-generated track, check its licence: some models' weights only allow non-commercial use. - Loudness: two-pass loudnorm to −14 LUFS (measure, then apply linearly), then a limiter. One pass overshoots.
Render facts that cost us days (more in references/gotchas.md)
- Render at 2x and scale down. EffectComposer drops the canvas anti-aliasing, so a 1x render has jagged edges everywhere.
- No colour tags on the MP4. A JPEG pipe tags it as full-range, and some players then shift the colours.
setparams=...=unknownin the filter. - Two faces in one plane flicker (z-fighting). Give the front one a depth offset.
- Fonts must be loaded (
document.fonts.load) before any canvas text is drawn, or it silently falls back. - GPU on in headless Chromium (
--use-angle=metalon macOS). Without it a render is about 10x slower.
Working without a prompt at every step
The person said yes to the kit's allow rules once (SETUP.md step 4); keep inside them, or they get asked again and again:
- Write and change files with the Write and Edit tools: pages, reel.json, brief, storyboard, AUDIT.md. Don't generate them with a Python or shell script, a heredoc or
sed -i; each of those is an approval prompt. - Measure with the kit's tools (stills, probe, sheet, collide, render, cover), not with one-off
python3 -csnippets. - No
mkdir, loops or chains needed: every tool creates its own output folder, and takes a list of times in one call (stills.mjs … 1.5,6,12). - A reel's own scripts (its sound script, a helper) live in
reels/<name>/and run aspython3 reels/<name>/audio.pyornode reels/<name>/x.mjs, which the rules cover.
Commands
Run each tool as one plain command (flags, no leading VAR=…, no loops, no && chains). Then the person's allow rules (node tools/*) cover it and they aren't asked to approve every step. The environment variables still work for scripts.
npm run doctor # checks this machine first
npm run demo # the 12-second proof: out/demo/
node tools/stills.mjs "2,8.5,15" # 2x stills → out/stills (the voxel template)
node tools/stills.mjs --page template/starter.html --format 16:9 1.6 # the starter at another ratio
node tools/stills.mjs --page template/starter.html --cfg reel.zh.json 1.6 # another config (here: Chinese)
node tools/stills.mjs --dsf 1 --out out/chk 2,8.5 # quick 1x frames for a sheet
python3 tools/sheet.py out/chk/sheet.jpg 6 300 out/chk/t*.jpg
node tools/render.mjs --page reels/<name>/page.html --out reels/<name>/out/film-silent.mp4
python3 template/audio.py out/film.wav
zsh tools/mux.sh out/film-silent.mp4 out/film.wav out/film.mp4