Give it a JSON layer spec: a canvas, fonts, an audio list and bottom-first layers of
fills, images, video, text and SVG paths, each with a box, CSS, entrance and exit
presets and keyframes. Get back a frame-exact H.264 MP4 at the spec's size and rate
with its audio mixed in. The same runtime plays the spec live in a browser page, so
what the person previews is what renders. This is the base leaf every
hn4a-compositor-* leaf loads: the format, the commands, the first look, the live-spec
gate and the checks. The craft of each kind of job is in its own leaf, and
guide-add-titles-and-motion-graphics picks which one (Routes).
Inputs
- Word times for anything cued to speech:
python-read-word-timingson the exact voice track the spec plays. Before there is a voice, word times estimated from the script stand in for it, for the first look only (references/first-look.md). - Footage at its real path; long-GOP sources are proxied for you. ProRes, 4444
included, fails as "video failed": convert it with
hn4a-compositor proxy, which keeps the alpha. A cutout needs a real alpha channel (birefnet-matte: VP9 WebMyuva420p). - Fonts: a Google Fonts CSS URL with
display=block, or local font files.
The spec
One file per composition, in the job folder (slides/<name>.json), asset paths
relative to it:
{
"size": [1920, 1080], "fps": 30, "duration": 6, "background": "#000",
"fonts": [{"css": "https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=block"}],
"audio": [{"src": "../audio/track.wav", "start": 0, "trim": 0, "volume": 1}],
"layers": [
{"id": "bg", "type": "fill", "fill": "#1B2E24"},
{"id": "heading", "type": "text",
"runs": ["Three signs to ", {"text": "listen for.", "style": {"color": "#B8E62E"}}],
"box": {"x": 120, "y": 170, "w": 1400, "h": 120},
"style": {"font": "700 96px/1.05 Inter", "color": "#fff"},
"start": 0.8, "end": 5.5, "in": {"fx": "fade", "dur": 0.6}, "out": {"fx": "fade", "dur": 0.4}}
]
}
- Layer types:
fill,image,video,text,path,group. Layers draw bottom first;start/endare comp seconds;boxis canvas px;styleis any CSS in camelCase. - Presets for
in/out:fade,rise,pop,zoom,wipe,draw,cut. animkeyframes per property, times relative to the layer's start:{"scale": [[0, 0.8], [0.3, 1, "out"]]}. Animatable:opacity,x,y,scale,rotate,blur,reveal,draw,radius,box,crop, a group'splane.- A
groupholds the layers whoseparentis itsid. Keyed, it is a camera over them; withplaneit lays them on a surface in the footage (hn4a-compositor-perspective-planes). - Video:
trimis the source time at the layer's start;croppicks a source region. Video layers are muted: the sound goes inaudio.
Every field, the per-type options and the eases: references/spec-format.md. Writing
the spec from a build script that cues it on a voice take, so a new take is a re-run:
references/build-script.md. How fast the camera moves for each kind of piece, and a
presenter moving between full frame, window and bubble, are layout craft:
hn4a-compositor-motion-graphics.
Run it
Run through exec with cwd the job folder:
exec hn4a-compositor page slides/slide_b.json # slide_b.html: play, scrub, frame-step
exec hn4a-compositor still slides/slide_b.json stills/b_22.9.png --at 22.9
exec hn4a-compositor render slides/slide_b.json [out.mp4] # --workers 8 (default), --format jpeg|png, --no-audio, --no-proxy
exec hn4a-compositor render slides/slide_b.json --frames 690:990 --into slides/slide_b.mp4
exec hn4a-compositor proxy mattes/presenter_cutout.webm # short-GOP copy by hand; keeps alpha
page, still and render rewrite the .html page from the JSON, so edit the JSON,
never the HTML. On the page, Space plays with audio, , . step a frame, ?t=20.5
opens at a time.
The first look
At the workflow's look step, prove the opening scene or its important graphic
relationship before building the whole composition. If the treatment is undecided,
compare two or three relevant looks with review_choose; if the person supplied or
approved one, develop that direction and show it with review_approve. Each .json
spec plays live. Combine this with the workflow's design gate rather than add another
review. Word times estimated from the script let this start before the voice exists;
existing footage or a music-led piece uses its own cues. How:
references/first-look.md.
Live spec, then render
Every compositor job keeps this gate; workflows name it "the live spec":
- Write the spec and make a still inside every scene or effect, tiled on one sheet
(
python-contact-sheet), and look at it once. Check the longest copy: text boxes don't shrink text, so a long heading wraps or overflows ("whiteSpace": "nowrap"forces one line). Fix what's broken; your look catches mistakes, it doesn't approve the design. review_approvewith the spec as thesubject's file (it plays live in the card, free, in seconds) and the stills; getreadyon the type, colour, placement and entrances, and end your turn until it comes back.- Only then
render. Notes go into the build script and back as the next live spec.
Revisions re-render only the span that changed. --frames A:B --into FULL.mp4 (A:B
the frames the edit touches, the layer's start..end × fps, each rounded once:
ffmpeg-frame-exact-trim, The frame grid) widens the span to FULL's
keyframes (at most a second each side), renders it, splices it in by stream copy keeping
FULL's audio, and checks every frame outside the span is unchanged. A 10 s span of a
163 s comp: about 15 s render plus 0.4 s splice. A change to duration, fps or
audio needs a whole render (--into refuses a frame-count mismatch).
What you get
An H.264 MP4, CRF 16, BT.709-tagged, with a keyframe on every whole second (which is
what lets --into splice by stream copy), and the spec's audio mixed by ffmpeg, never
captured, so it can't drift. Speed: about 13 fps with one browser, 35 fps with 8, when
every video layer seeks fast.
Tips and traps
- Fonts fail loudly.
renderandstillabort on any warning, such as a font family with no loaded face. The first family named in each text layer's ownfont(not its html's) must be loaded infonts: a layer with no family of its own falls back to Times and aborts. - A child's
boxdefaults to the canvas, not its group: give a fill inside a group the group's own box. - Your own
animreplaces the preset for the properties it sets: ananim.opacityskips the fade of bothinandout, so write the whole curve. - The runtime owns
transformandfilteron every layer. Put mirrors on an inner element of the html, and usetext-shadow, notfilter: drop-shadow, for glow. Perspective is a plane group (hn4a-compositor-perspective-planes). - Images inside a text layer's
htmlrender blank. They show in the preview but not inrenderorstill. Use animagelayer, or inline the file as adata:URI. - Write words as
htmlspans joined by spaces, notruns, when the gaps matter:runscollapsed the spaces between caption words. - Zooms on footage go in ffmpeg, not here. An animated
cropon a video layer left the picture short of its box. Render the move into the base video (ffmpeg-eased-punch-in) and draw graphics over it. - Sound lives in
audio, an entry withstart,trimandvolumebut no length: cut a piece of a file to its own file first (references/build-script.md), on the same frame grid as the picture when it must line up with a cut (ffmpeg-frame-exact-trim, The frame grid). - The mix's channels follow its inputs. One mono voice file made a whole render mono and lost a sound panned left to right. Write every audio input as stereo.
- Long-GOP video is proxied automatically into
.proxies/next to the spec when its keyframes are more than 12 frames apart: an H3 Max talking head went from 9 to 34 fps render speed, SSIM 0.998.--no-proxyrenders from the sources. - Comparing a render to a source by pixels: the render is BT.709; decode both with their own matrices, or greens and skin look shifted.
- The runtime seeks video half a frame in (frame k at (k + 0.5)/fps), which is why
rendered frames match their source frames exactly;
seekBiasoverrides it per layer.
Check it
- Frame count = 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> - Two audio channels:
exec ffprobe -v error -select_streams a:0 -show_entries stream=channels -of csv=p=0 <out.mp4> - A contact sheet of the render (
python-contact-sheet), looked at; the first seconds listened to, or the person asked to. - After an
--intorevision, stills either side of the span.


