--- name: frame-composition description: Stage the frame on a NodeTool timeline — grids, focal placement, safe areas per aspect ratio, depth layers and parallax, camera moves, and where elements enter and leave. Use when placing titles, lower thirds, shapes or overlays, when a layout must survive 16:9 and 9:16, when adding a push or a parallax move, or when a frame reads flat or cramped. Not for what a clip says or when it cuts. featured: true --- # Frame Composition → staging on the timeline Where things sit, how deep the frame is, and how the camera moves through it. `motion-graphics` is the build loop; `timeline-edit-ops` carries the op contract. This decides the coordinates you pass either. ## Four coordinate spaces, and mixing them is the usual bug | What | Space | Read as | |---|---|---| | `shapeStyle.x/y/width/height`, `x2/y2`, `d` | 0..1 of the **frame** | 0.5 is the frame's middle | | `set_mask` `x/y/width/height`, `d` | 0..1 of the **layer** | 0.5 is the clip's own middle, not the frame's | | `positionX` / `positionY` curves | canvas px | Absolute, and folds as `replace` | | `offsetX` / `offsetY` curves | canvas px, added | Composes with everything else | | `slide` `distance`, `kenBurns` `driftX/driftY` | fraction of frame width or height | 0.3 travels 30% of the frame | | `anchorX` / `anchorY` | 0..1 | The point a scale or a rotation pivots on | | `textStyle.fontSizePx` | sequence px | Against `height` from `get_timeline`, not the preview width | | `transform.depthPx`, `camera2d.position/depthPx/focalLengthPx` | sequence px | Perspective; clip depth also orders clips within one track | | `layout.gap`, `layout.padding` | sequence px | Flex container spacing, measured against its children's rendered boxes | Read `width`, `height` and `fps` off `get_timeline` before you compute anything. A layout authored against 1920×1080 and saved onto a 1080×1920 sequence is off by more than a crop. ## Text is placed by containers Building with `@nodetool-ai/sandbox-timeline` (see `motion-graphics` for when code is the right surface)? **A container decides where its children sit — you almost never write `x`/`y` for text.** `s.stack(children, o)` and `s.row(children, o)` build a real Yoga flex container and reparent `children` onto it as its real children — nest freely, a `row` of `stack`s or a `stack` holding a nested `row` both resolve correctly. A text child reflows to whatever width Yoga offers it, not a guessed line length. The container's own placement in the frame is `o.at` (`{x, y}`, default the frame centre) plus `o.anchor` (default `"center"`): `anchor` names the point of the container's own *computed* box that lands at `at` — the name `"left"` puts that box's left edge at `at.x`, no measuring a sibling's width required. Named anchors: `"top-left"`, `"top"`, `"top-right"`, `"left"`, `"center"`, `"right"`, `"bottom-left"`, `"bottom"`, `"bottom-right"`, or pass `{x, y}` fractions directly. `o.align` (cross axis, default `"start"`) and `o.justify` (main axis, unset) are the Yoga `alignItems`/`justifyContent`. A child's own flex sizing is `o.flexItem` on that child's own call (`grow`, `width`, `alignSelf`, …); `absolute: true` on a `rect`/`ellipse` (or any clip) takes it out of flow and resizes it to fill its flex parent's box exactly — how a plate sits behind a card's text with no pixel math. Reach for explicit `x`/`y` only for decoration (a stray mark, a background shape) or a deliberate free placement that has nothing to do with reading order. Anything a viewer reads — a title, a kicker, a caption, a row of stats — goes in a container. **Title block** — kicker, title, sub, stacked with a shared rhythm: ```js // fragment const kicker = s.kicker("LAUNCHING TODAY"); const title = s.text("Round-ups", { size: 120, weight: 700 }); const sub = s.text("on every card", { size: 40 }); s.stack([kicker, title, sub], { gap: 20 }); ``` **Lockup** — a mark beside its wordmark, one row: ```js // fragment const mark = s.image(logoAssetId); const wordmark = s.text("Northwind", { size: 64, weight: 700 }); s.row([mark, wordmark], { gap: 16, align: "center" }); ``` **Card** — a padded column over an absolute plate: ```js // fragment const plate = s.rect(1, 1, "#0c2a21", { r: 24, absolute: true }); const heading = s.text("Plan Pro", { size: 48, weight: 700 }); const price = s.text("$12/mo", { size: 32 }); s.stack([plate, heading, price], { gap: 8, padding: 24 }); ``` **List / grid** — a row that wraps into a grid when it runs out of width: ```js // fragment const chips = ["Round-ups", "Instant transfers", "No fees"].map((t) => s.pill(t)); s.row(chips, { gap: 12, wrap: true }); ``` ## Grid Twelve columns for landscape, six for vertical. For a 1920-wide frame with a 100px outer margin and a 24px gutter: ``` content = 1920 − 2×100 = 1720 column = (1720 − 11×24) / 12 = 121.3px ``` Space everything in multiples of 8. `fontSizePx` for body copy sits near 2.5–4% of frame height; below 2.5% `validate_timeline` reports `text_illegible`. Choose the family, weights, and title scale from `motion-direction` § Typography before placing text on this grid. ## One focal point Place the subject on a thirds power point, not dead centre, unless the piece uses symmetry deliberately. The intersections: | Sequence | Power points (px) | |---|---| | 1920×1080 | x 640 / 1280, y 360 / 720 | | 1080×1920 | x 360 / 720, y 640 / 1280 | | 1080×1080 | x 360 / 720, y 360 / 720 | Rank the eye's path by **size, then contrast, then colour, then position**. One primary per frame; everything else supports. Leave negative space around it — a frame filled edge to edge reads as cheap and gives the motion nowhere to go. ## Depth: planes and track order Split the picture into background, midground and foreground. Track index controls order **between** tracks: lowest index draws on top. Within one track, clips sort by `transform.depthPx`: farther values draw first and nearer, positive values draw later, in front. The same depth controls perspective under the sequence's `camera2d`. Moving a clip to another track changes its overlap order but not its camera distance. | Plane | Suggested track index | Clip depth | Motion and look | |---|---|---|---| | Foreground | lowest | Nearer, positive | The move the eye follows; sharp unless focus shifts | | Midground | middle | Around zero | The subject and contrast peak | | Background | highest | Farther, negative | Small drift and lower contrast | For flat parallax, run three speeds over one window. One `kenBurns` per layer with `zoom: 0` and a different drift is a `fullClip` preset, so all three cover the same span: ```json {"role": "loop", "preset": "kenBurns", "params": {"zoom": 0, "driftX": -0.02, "driftY": 0}} ``` Background −0.02, midground −0.06, foreground −0.12. One layer at one speed is not parallax, it is the template moving. For a shared 2.5D move, give the layers distinct `transform.depthPx` values and keyframe the sequence's `camera2d`. Its absolute `timeMs` keyframes interpolate camera position and depth linearly. `focusDepthPx` and `aperturePx` control depth of field. Use one camera move to connect the planes, then inspect a middle frame for unwanted scale or blur. Set track indexes for overlap between tracks and clip depth for order within a track. For a tilted card wall, parent the cards to a group with the wall's 2D `rotation`, `rotationX`, and `perspective`. Give each card its own `depthPx` and scroll curve, then use `camera2d` for the shared push and depth blur. This is a flat card projection: preview the wall at several frames to check that spacing, overlap, and blur read as one assembly. For a move with a shape of its own, write a `custom` curve instead and mind the role: an `in` window holds its `t=0` values before it and contributes nothing after, an `out` window holds its `t=1` values after it, and a `loop` repeats its cycle — so a one-way travel that must stay where it landed is an `out`, and a `loop` curve has to return to the value it started on or it jumps every cycle. Parent the assembly to a group when the layers must hold their relationship through a move: `add_group`, then animate the group. Children keep their own tracks, so grouping never changes what covers what. For a transition on the assembled picture, set `transitionIn` on the group. The children stay parented on their own tracks while the transition acts once on their precomposed surface. Shape geometry is rasterized inside a frame-sized source surface before the clip is placed. Keep normalized `shapeStyle` geometry inside that surface, then move the clip or parent group with `transform.position` or custom `offsetX`/`offsetY` curves for scrolling walls and flying cards. Geometry authored outside the source surface clips before placement. ## Camera moves Choose between a sequence camera move and a local clip move. `camera2d` keyframes move the view across all depth planes. A layer animation moves one clip, and its `anchor` decides where scale or rotation pivots. | Move | How | Timing | |---|---|---| | Push / pull | `kenBurns` with `zoom` 0.08–0.2 and `direction` in or out | 1500–4000ms, `easeInOut` | | Drift | `kenBurns` `driftX` / `driftY`, ±0.02–0.08 | Same window as the zoom | | Pan | `custom` curve on `offsetX` across the clip | 800–2000ms, `easeInOut` | | Whip | `offsetX` over 150–250ms with a `blur` effect at 12–24px | On a cut, never mid-shot | | Follow | `offsetX` and `offsetY` tracking the subject, subject held off-centre and leading | Whatever the action takes | `camera2d` is a document field. `set_clip_params` changes a clip's `transform` but refuses `camera2d`, `layout`, `repeater`, `motionBlur`, and other new document fields. Read the full document with `get_timeline`, change the relevant fields, then write the full document with `set_timeline_document`. Keep its tracks, clips, markers, tempo, setup, media tracks, template identity, and other fields in the write. `timeline-edit-ops` carries the op contract. `kenBurns` is `fullClip`: it ignores `durationMs` and `delayMs` and runs across the whole clip. To time a push to something shorter, write it as a `custom` `scale` curve instead. One move per beat. A push plus a pan plus a rotate reads as chaos, and the three fold together into a direction nobody chose. ## Enter and exit Motion that follows reading direction feels natural; reversing it reads as going back. Match `slide`'s `direction` to intent — it names the edge the element arrives **from**: - New content: from the right, or from below. - Dismissing or undoing: reverse the entrance. - Drilling in: `pop`, or a scale curve rather than a travel. Enter and leave through the nearest edge. Keep `distance` at or below 0.35 or the travel reads as a slide of the whole template — pair a longer move with a scale or opacity change on the same window. Same vocabulary on the cut: `set_transition` `direction` on `wipe`, `push` and `slide` also names the edge the incoming clip arrives from. ## Safe areas Keep titles, logos, faces and calls to action inside the margin. The numbers are fractions of the frame, so convert against `width` and `height`. | Aspect | Top | Bottom | Sides | |---|---|---|---| | 16:9 | 5% | 5% | 5% | | 9:16 | 12–14% | 18–20% | 6% | | 1:1 | 7% | 7% | 7% | | 4:5 | 8% | 8% | 6% | On 1080×1920 that leaves the usable band roughly y 250 to y 1570. A caption clip already sits at `bottomMarginFrac` 0.12 by default, which is inside the phone UI zone on some platforms — raise it rather than assuming. ## One layout, several aspects Master in the widest target with the focal point inside the centre band, then **restack** for vertical: move the headline above the subject, pull the subject up out of the bottom UI zone, and raise `fontSizePx` by about 20%. Cropping a 16:9 layout to 9:16 clips whatever sat in the outer columns, which is usually the logo and the call to action. Building in code, restacking is usually one option change on the container above — flip `s.row` to `s.stack` (or its `align`/`justify`), or move it to a new `at`/`anchor` — not a rebuild of every child's `x`/`y`. On the ops path, `layout: {display: "flex", flexDirection: "row" | "column", gap}` on a group clip keeps its real children (their `parentId` names the group) laid out with ordinary CSS flexbox — `justifyContent`/`alignItems` position them, `padding` insets the container. For a shape behind text, give the shape `flexItem: {position: "absolute", inset: 0}` inside a padded flex column holding the text: the shape's box resizes to the text's padded box, including an animated font size, with no manual pixel math. These relations avoid redoing pixel math for each text change. A 16:9 document still needs a separate placement pass for 9:16, with safe areas checked at that size. ## Check the frame, not the document `preview_timeline_frame` composites the real picture — every track in order, transforms and opacity applied, animations mid-flight. Read the layer list it returns beside each frame: it names the stack top first with each layer's `z_index`, `opacity` and wipe progress, which answers most staging questions without looking at a pixel. Preview at the midpoint of every move, not at its ends. What frames catch and a validator cannot: a title that slid outside the frame, a scrim covering the face it was meant to sit beside, a lower third under the wrong element because two track indexes are the wrong way round, a layer that never draws because it is on an audio track. ## Before you finish - One focal point, on a power point or deliberately centred. - Every element assigned a track for overlap order and a depth plane when the camera moves. - One camera move or eased layer move, 800–4000ms. - Entrances through the nearest edge, `distance` ≤ 0.35 or paired with a second channel. - Critical content inside the safe margin for every target aspect, vertical restacked rather than cropped. - Previewed at the midpoints, and the layer stack read.