What does motion-reels do?
Code-drawn motion graphics for vertical Reels. Each reel is one or more blocks. A block is a small JavaScript file that places headlines, drawings, photos and swatches in a world and says when each one appears. Every time comes from a timeline of words: either a beat sheet (no voice) or the word timings of a voice take. The engine renders every frame on its own fresh browser page, checks the layout, and assembles the result with voice and/or music.
The person who owns the page decides the look, the words and what gets posted. Claude never posts anything.
Setup (once)
- Python 3.10+ and ffmpeg/ffprobe on PATH. Then the repo's
setup.ps1(Windows) orsetup.sh(macOS/Linux), orpip install -r requirements.txt+python -m playwright install chromium(or envMG_CHROME= an existing Chrome). - Voice only:
pip install -r requirements-voice.txt(faster-whisper; first run downloads the speech model). - Test:
python engine/selftest.pymust printSELFTEST OK(checks pass on the examples, negative tests fail as they should, Arabic captions split right, and a short render + fault scan + assemble reaches -14 LUFS).--quickskips the render.
Engine files (all in engine/): render.py (check / storyboard / stills / video), beats.py, words.py,
frames.py, faultscan.py, assemble.py, storyboard_sheet.py, make_grain.py, templates/ (shell, reel.js, reel.css, fonts).
One folder per reel
<reel name>/
brief.md what the reel is for, the one idea, the call to action, photos available
beats.txt the words on screen, one beat per line (or script.txt + take.wav when there is a voice)
words.json timeline (from beats.py or words.py)
photos/ the owner's photos (only ones they have the rights to)
A.js, B.js ... blocks
build/ check json, storyboard stills, block videos, render.json files, contact sheets
<Reel>_REV01_<date>.mp4 the deliverable; a later fix ships as _REV02, never overwrites
Gates (never skip or reorder; stop at each ⛔ for the owner's OK)
- Brief. One idea per reel (a room reveal, a material story, a plan change, a tip). Ask for the photos and the handle. List what each photo shows. Hook in the first 2 seconds.
- Beat sheet (
beats.txt): the exact on-screen words, in order, one beat per line. Keep a beat short (2–7 words). Put each item of a list on its own line, so each has time to land. ⛔ Owner signs the words. After sign-off a word change means re-timing the block (and re-voicing it, if voiced): say so before changing anything. - Timeline.
- No voice:
python engine/beats.py beats.txt words.json(default pace 2.6 words/s + 0.7 s after each beat;[+1.5]at the end of a line holds longer;[pause 2]holds with nothing new). - Voice (any TTS or a recording, one take per block, never edited):
python engine/words.py take.wav words.json, then compare the transcript with the script word for word. The picture is fitted to the take, never the reverse.
- No voice:
- ⛔ Storyboard stills. Build the block layouts (positions only are enough), run
check, thenrender.py A.js words.json storyboard build/sbandstoryboard_sheet.py build/storyboard.png build/sb. Self-review the sheet (list below), then send it. Nothing is animated before the owner OKs the layout. - Build the motion. Cue every element on the word that names it (rules below). Run
checkafter every change. - Render.
render.py A.js words.json video build/A.mp4 --workers 4per block (never--draftfor anything sent). Every block'sbuild/A.mp4.render.jsonmust say"capture": "fresh"and the same browser as the other blocks; if the browser changed, re-render all blocks. - Fault scan.
faultscan.py build/A.mp4 --sheet build/A_contact.pngon every block, then on the final reel. Look at every flagged time. A black band or a one-frame flash is a fault: stop and fix it. Dark photo content is fine. - Assemble.
assemble.py blocks.json <Reel>_REV01_<date>.mp4 [--music bed.mp3]. It prints the measured loudness; it must be at the target (-14 LUFS default) and the true peak at or below -1.5 dBTP. - ⛔ Review. Send the reel and its contact sheet. Picture-only fixes re-render only the changed blocks and ship as the next _REV. The owner posts it.
Motion rules (why: a calm, premium feel; nothing jitters, nothing is hard to read)
- Words never move. A word fades in (0.28 s) and then stays still. No slide, scale, bounce or drift on text.
- Headlines:
hl(el, text, cue(...))fades each word in on its own word in the timeline. When a headline paraphrases a voice, use{stream:true}: words appear left to right, 0.1 s apart, from the cue. - Camera: dead still while a shot holds; it only slides (0.8 s, ease-in-out) between shots. In 9:16, stack shots
vertically in the world (shot 2 at y 1920, shot 3 at y 3840) and slide down:
SHOTS=[[t,540,960,1],[t2,540,2880,1],...]. - Photos may push in slowly:
push:[1,1.04]= constant speed, never more than +6 % (MOTION.PUSH_MAX; the engine refuses more). No push on a photo that carries text. - Drawings draw on as they are named, reference first: walls before the window and door in them, the floor line before
heights measured from it.
at('#walls', cue('Walls'), null, 'draw', 0.9); 0.6–1.0 s per element. - Dimensions to one scale. If a plan shows two dimensions, both use the same scale.
- Label what the words name; every number names its unit ("3.2 m", never a bare "3.2").
- No bounce, overshoot, elastic or spring easing anywhere. Every timing comes from
MOTIONinreel.js; change it there, never as a literal in a block. - A bare dashed SVG element given to
at()shows before its cue: wrap it in a<g>.
Timing tokens (MOTION in reel.js)
| Token | Value | Use |
|---|---|---|
| WORD_FADE / WORD_LEAD | 0.28 s / 0.08 s | word fade-in; starts just before the word |
| STREAM | 0.10 s | stream-mode headline word spacing |
| REVEAL / EXIT | 0.5 s / 0.4 s | blocks, chips, swatches, photos in / out |
draw (at(...,'draw',dur)) | 0.6–1.0 s | per drawn element |
| CAM_SLIDE / CAM_LEAD | 0.8 s / 0.35 s | camera slide; starts before the shot's cue |
| PUSH_MAX | 0.06 | photo push limit |
| WIPE | 1.2 s | before/after wipe |
| Easing: ease-out cubic for reveals, ease-in-out cubic for slides, draws and wipes. |
Layout and the check
- Safe zone (
SAFEin reel.js): text, labels and captions stay inside x 65–1015, y 269–1248 of the 1080x1920 frame (top 14 %, bottom 35 %, sides 6 %). These figures are widely used third-party guidance for Reels, not a Meta-published rule for organic posts: re-measure from a phone screenshot of a posted reel and setsafeZone({...})if the app changes. Photos and drawings may extend past it; text may not. render.py <block> words.json check build/<block>.check.jsonis mandatory before every render. It samples every 0.25 s and fails on any overlap held 0.5 s or more between separate elements (text, drawings, chips, swatches, photos, captions) and on any text outside the safe zone while the camera holds. A fail means fix the layout, never render. Prove a new rule with a negative test (re-introduce a known fault; the check must fail).- Text that belongs on a photo goes inside the photo's element (
parent: photoDiv) with ascrim()behind it, or the photo is a background (bg:true). Otherwise the check reports text over photo, which is what you want. - The check does not see things cut off at the frame edge: the self-review does.
Building blocks (reel.js)
boot(()=>{ // every block starts with boot(...)
handle('@yourstudio'); // small handle at the top of the safe zone
$('world').insertAdjacentHTML('beforeend', `<div id="h1" class="abs serif" style="left:90px;top:360px;width:900px;font-size:104px"></div>`);
hl('h1', 'A small room, *re-planned.*', cue('A small')); // *...* = accent italic; ~word = not in the timeline
at('#walls', cue('Walls'), null, 'draw', 0.9); // fx: 'fade' | 'pop' | 'wipe' | 'draw'
photo({src:rel('photos/living.jpg'), x:0, y:1920, w:1080, h:1350, a:cue('Light'), push:[1,1.04]});
beforeAfter({before:rel('photos/before.jpg'), after:rel('photos/after.jpg'), x:90, y:2440, w:900, h:900, a:cue('Before'), t:cue('after')});
swatch({name:'Oak', note:'floor', color:'#B08A62', x:90, y:4480, size:270, a:cue('Oak')}); // or src: rel('photos/oak.jpg')
clip({dir:rel('clips/walk'), n:240, x:0, y:5760, w:1080, h:1920, a:cue('Walk'), t0:cue('Walk')}); // frames from frames.py
captions(1120, 3); // burned-in captions from the timeline (voiced reels)
SHOTS=[[0,540,960,1],[cue('Light'),540,2880,1]];
});
rel(path) resolves a file next to the block. cue('words', after) throws if the words are not in the timeline, so a
typo fails loudly. Full example: examples/demo/demo.js.
Arabic and other right-to-left reels
- Call
rtl()first inboot(). Headlines use Noto Naskh Arabic, text IBM Plex Sans Arabic (OFL); Latin falls back to the default fonts. No italic, letter-spacing or upper case in Arabic (letters must join); accent words change colour only. - Text boxes keep
left+width; text aligns to the right edge. Lay out lists right to left (first named = rightmost). - Cues ignore diacritics, tatweel, alef/yaa/taa-marbuta variants and convert Arabic-Indic digits; a cue with no letters
throws. ة and ه count as one letter, so cue a longer phrase where two words differ only there. Number words match in
English only: in Arabic, cue digits or the word as transcribed (
words.py --lang arpicks the multilingual model). - Captions break on ، ؛ ؟ and Latin punctuation, and do not display it.
- Sample:
examples/arabic/.
Look
The theme lives only in engine/templates/reel.css (colours as CSS variables, fonts) and the font files. Default "warm
minimal": paper #F4EFE8, ink #2B2622, accent #9C6B4E, Cormorant Garamond headlines + Jost text (both SIL OFL 1.1).
To change the look: edit the variables, or add OFL fonts to engine/fonts/ with their licence file and list them in
fonts.css and window.FONTS (reel.js). Ask the owner for 3–5 screenshots of their grid first and match it.
Self-review (Claude, before sending any sheet or render)
- Each element starts on its word; reference lines draw first.
- Words still once landed; camera still while holding; no jump at the end of a slide.
- Nothing cut off at the frame edge; all text inside the safe zone; nothing important under the bottom 35 %.
- Dimensions to one scale; every number has its unit; spelling matches the signed beat sheet.
- Check PASS on every block;
render.jsonsayscapture: fresh, same browser for all blocks. - Fault scan clean, or every flagged time inspected and explained.
- Loudness and true peak printed by
assemble.pyat target.
Rights and privacy
- Only the owner's photos, or ones they have the rights to. Client homes: no faces, addresses or documents without consent.
- Music: only tracks the owner has a licence for, or the platform's own library added in the app at posting.
- No claims the owner has not made (prices, timelines, awards).
Troubleshooting
cue not found: "...": the words are not inwords.jsonin that order; fix the beat sheet or the cue.ERR reqfail:file:///...jpg: a photo or clip path is wrong; userel()and check the file name.- Ghost copies of earlier drawings or a black band: you rendered with
--draft. Re-render without it. - Slow renders:
--workers 4(up to 8) on a machine with that many cores; check and storyboard are fast, video is not.