Compose in-scene titles
Give it a finished cut, its word timings, its cut times and the visual events to react
to; get back the cut with titles that sit in the set and react to it, rendered frame-exact
by hn4a-compositor. The effects are layers and keyframes in the ordinary compose spec
(hn4a-compositor-motion-graphics has the format, the command line, previews and
--into revisions); a small helper module writes them from cues so a build script stays
a list of calls. For overlay layouts beside the footage (slides, lists, a presenter
bubble), use hn4a-compositor-motion-graphics alone.
Inputs
- The cut, and its speech-to-text (
elevenlabs-speech-to-text, read withpython-read-word-timings) for word cues. - Its shot boundaries from scene scores at the low threshold (
ffmpeg-scene-cutsat 0.015): soft cuts and dissolves score 0.02–0.06, which ascene > 0.25pass misses. - Visual events (a face tearing, a phone picked up, a screen glitching) from a 2 fps contact sheet of the shot, then frame-stepped in the preview.
- A cutout for every shot where type goes behind people (
birefnet-matte, snapped to that shot's cut times). - The brand's fonts and colours (here Brand Sans and Globex Lime #B8E62E).
Run it
- Write
references/titles-fx.md's module into the job astitles/titles_fx.py, as is. - Write
titles/build_titles.py: the cues as constants, then the layers bottom first (the cut, then the type, then the cutout over the same span), dumped as JSON.references/worked-example.mdis a complete one. - Build and look:
exec python titles/build_titles.py --out titles/quiz_titles.json
exec hn4a-compositor page titles/quiz_titles.json
exec hn4a-compositor still titles/quiz_titles.json titles/s_3.20.png --at 3.2
exec hn4a-compositor render titles/quiz_titles.json
(cwd the job folder.) Make a still at every effect, nudge boxes in the build script
and rebuild. Then review_open with the spec as a
{"compose": "titles/quiz_titles.json"} candidate, which plays live in its card, and the
stills beside it as the row's refs, and get a yes on the title and caption design;
only then render. The stills are your own check: they catch mistakes, they don't approve
the look. A real job's script was a list of calls
writing 81 layers; it rendered at 52 fps.
The effects
- Type behind people. The cut, then the type, then
cutout(src, start, end): the people and the furniture in front of the back wall cover the type. BiRefNet "General Use (Heavy)" keeps the podium and the chair, which is right when the type sits on the back wall. Place the type so a head or shoulder covers one letter or part of one, never a whole word: check the still and nudge the box. - One layout, many layers (
line_html(parts, show)). Every layer carries the whole line in the same box, with the other wordsvisibility:hidden, so the words line up exactly while each animates on its own. SettransformOriginper layer to that word's centre. Words land on their cue withpop_keys, or pulse on beats withpulse_keysplus aspill(light on the set, under the cutout) driven byflash_keys. - Glitch (
glitchplushidden_duringon the clean copy): three clip-path bands, each with a random x offset every frame onholdkeys, plus red and cyan copies (screen blend) during each (start, duration) burst. Time the bursts to the picture's own glitch. - Typing (
typing): one layer per prefix with uneven keystrokes, a solid caret while typing, then a blinking caret until the end. Pass the caret colour. - Ring (
ring_keyson x, or on rotate withscale=0.25), drop (drop_keys, a receiver put down), focus pull (focus_keys, sharp on the word), a screen powering on (flicker_keysplusscan_bar). - Floor reflection (
reflection(layer, floor_y)): the layer mirrored about where the wall meets a glossy floor, blurred and fading away from the wall. If the reflection lands off-frame, lower the type on the wall. - Karaoke captions (
captions(words, box)on the STT words): each phrase builds word by word, and the spoken word sits on apilland bounces. Two layers per word, so the words never shift. Pass the brand colour aspill.emphasisstyles chosen words.skipleaves out spans where a full-screen title says the same words, anddirection="rtl"builds Hebrew or Arabic phrases right to left. - A camera (
camera(id, start, moves)): agrouplayer whose children (parent: id) zoom and pan as one, such as a product screen rebuilt as layers. Each move puts a point of the screen at the frame centre at a scale.hn4a-compositor-motion-graphics(references/spec-format.md, A camera over a group) has the rules. Keep it calm: one slow drift and one push per screen.
Tips and traps
- Your own
animreplaces the preset. If a layer'sanimsetsopacityorscale, thein/outpresets skip that property, so write the whole curve yourself (hidden_during(..., end_at=)includes the fades). - The runtime owns
transformandfilteron every layer. Put perspective andscaleY(-1)mirrors on an inner div of the html, and usetext-shadow, notfilter: drop-shadow, for glow. - Name the font on the layer. The runtime checks the first family in the layer's own
font(not the html's) has a loaded face;text_layer(..., font=)sets it. A family that isn't loaded stops the render. - Cutouts are per shot. A cutout's frame k is comp frame start × fps + k, so give it the
same start as its shot and
trim0. - Pass the brand colour everywhere it shows (caret, spill, scan bar); the helpers default to white.
Check it
- Stills at every cue, effect and cut, looked at: no whole word hidden by a head, the reflection in frame, the glitch only during the picture's glitch.
- Play the preview through at full speed once; then render and check the frame count
equals round(duration × fps)
(
exec ffprobe -v error -count_frames -select_streams v:0 -show_entries stream=nb_read_frames -of csv=p=0 <out.mp4>).


