Rebuild a product screen as compositor layers
Give it one screenshot of a product screen (or two, when a part comes from another
image), the brand's colours, the cue keys the cut needs and the data to show. You get a
builder, ui/<screen>.py, that returns compose layers for that screen inside a camera
group, a ui/<screen>_test.json spec with one camera push, and stills proving it matches
the source and stays crisp at 2×. The spec format and the command line are
hn4a-compositor-motion-graphics; the camera and the type helpers (camera, typing,
pop_keys, pulse_keys, line_html, text_layer) are
hn4a-compositor-in-scene-titles (references/titles-fx.md). For a whole ad around the
screens, follow recipe-product-ui-video. Never animate a screenshot with a video model:
it warps the text.
Inputs
Read the source before writing a line of the builder.
- Crop and zoom to read small type and edges, then look at the result:
exec ffmpeg -i src.png -vf "crop=400:240:340:300,scale=iw*4:ih*4:flags=neighbor" crop.png. - Measure the layout in the source's own pixels: window and panel edges, column x's and widths, row top and pitch (a table from a 1377×969 screenshot: first row at y 358, a 63 px pitch, 10 columns), nav item centres, list spacing (36.2 px). Write them as constants at the top of the builder.
- Sample colours per pixel, never by eye:
exec ffmpeg -v error -i src.png -vf "crop=1:1:X:Y" -frames:v 1 -f rawvideo -pix_fmt rgb24 px.rgband read the three bytes in Python (or Pillow'sgetpixel). Get pill backgrounds and foregrounds, hairline borders (near#EFF1F3), muted text, the page tint. - The app's font is not the site's. The marketing site's computed styles said Brand Sans; the app in its screenshots was App Sans, judged from the glyphs and confirmed by a still side by side. Read sizes and weights off the zoomed crop (the table: body 12 px on 16, headers weight 500, muted dates 11.5 px).
- Radii and shadows: card, pill and button radii; shadows are usually faint
(
0 2px 10px rgba(20,40,40,.03)). - Leave out: marketing overlays (review-site badges, a floating duplicate card over the screenshot); the customer's logo in the app's logo slot (a neutral rounded square with a line icon instead); real people's avatars (initials or a grey silhouette).
- The data the voice mentions (
references/subagent-brief.md, the data paragraph) and the cue times, read from the voice track (python-read-word-timings).
Run it
The contract. One file per screen in the job's ui/, no shared library code beyond
titles/titles_fx.py:
POI = {"status_col": (x, y), "new_row": (x, y), "won_row": (x, y)} # group px, for the camera
def build(group_id: str, cues: dict) -> list[dict]: ... # every layer "parent": group_id
def main(): ... # writes ui/<screen>_test.json
- Boxes in the group's 1920×1080 space, every layer
"parent": group_id. The cut wraps them incamera(...). - Cues in comp seconds, all but
buildoptional, each documented in the module docstring with what it does. Say whether a missing cue means the piece is shown finished or not shown at all (a dashboard: finished; a table's new row: absent). animkeys are relative to the layer'sstart. Write them in comp seconds and convert in one helper (rel(keys, start)), or every key shifts by the start.- POIs that move: a point on something that shifts mid-screen needs both positions
(
won_rowafter an inserted row pushes it down,won_row_before_insertbefore). - The test spec: 12 s, the app font in
fonts, acamerawith one push to 1.8–2× and back.
Static chrome in a few big html layers (frame, filters, left panel, table card; or
window, panel, nav, card shells), each an html string of absolutely placed elements from a
small at(x, y, inner, style, w, h) helper. Everything that moves is its own layer.
Two ways to place things, both crisp:
- Source pixels inside a CSS
zoomwrapper.K = 1824 / 1335 ≈ 1.366mapped the source window's width onto the group's 48..1872;gbox(x, y, w, h)andgpt(x, y)convert source px to group px for layer boxes and POIs.zoomis layout, not a transform, so text is laid out and rasterized at its real size, and every number reads straight off the source. Best when the source is large and kept whole. - Directly in group px (
WIN,PANEL,KPIboxes), scaling the source's sizes up by hand. Simpler when the source is small (1199 px wide) or recomposed from two images.
Icons: inline SVG line icons (24×24 viewBox, stroke="currentColor", round caps,
stroke 1.6–1.8) in a dict, drawn at the source's icon size. Plain boxes (tints,
outlines) are fill layers with a style (boxShadow: inset 0 0 0 2px <brand>,
borderRadius).
Animation: each effect and the layers it takes (panels building, a row cascade, a row
inserting, a cell typing, a pill flipping, a highlight, a count-up, a donut, bars, a value
highlight, a voice chip): references/animation-patterns.md.
Iterate:
exec python ui/activities.py # writes ui/activities_test.json
exec hn4a-compositor page ui/activities_test.json # scrub it
exec hn4a-compositor still ui/activities_test.json ui/stills/new_row_mid.png --at 6.4
(cwd the job folder.)
Many screens: one subagent per screen, in parallel, each with the brief in
references/subagent-brief.md, filled in completely so it has nothing to discover.
What you get
ui/<screen>.py (the builder: POI, build, main), ui/<screen>_test.json, and
stills: the side by side, one per cue and mid-animation, and the 2× push. From the
builder's report: its cue keys with each animation's duration after its cue, its POI keys
and what each frames, and known gaps.
Tips and traps
- Believable data in the client's market. Replace the screenshot's placeholder rows (the same sample job and city on every row) with real-looking names, places and amounts in the client's market and region, ids counting down, and due dates a few days after the comp's "today". Keep the status vocabulary of the source (Active / Pending / Cancel). Share one "today" and date range across every screen: a dashboard whose filters read one year beside a table dated another was a nit on the worked job.
- Name the font on every text layer (
style.font): the runtime checks the layer's own font, not its html's, and stops on a family with no loaded face. - A
stilloccasionally times out fetching Google Fonts: retry it. - A path at
draw≤ 0.005 is hidden; start a donut's draw at 0.006, never 0 (0 left a hairline at the far end on older runtimes). - Children of a group skip
will-change, so Chrome re-rasterizes text at the zoomed scale; keep it that way (don't setwillChangeon them). - A layer keeps its own
display, andhtml: ""works; older builders worked around both.
Check it
- Side by side with the source: a still of the test spec at 1.0× beside the
screenshot at the same height:
exec ffmpeg -i src.png -i still.png -filter_complex "[0]scale=-2:1080[a];[1]scale=-2:1080[b];[a][b]hstack" side.png. Layout, colours, type weight and size should match; the data and the dropped overlays are the intended differences. - A still at every cue and mid-animation (the insert half-way, the pill mid-pop, the donut half-drawn).
- Crisp at 2×: a still with the test camera at 1.8–2× on a dense region, looked at 1:1.
- Fonts loaded:
stillandrenderabort on a missing face. - The builder compiles:
exec python -m py_compile ui/<screen>.py.


