git:20261009.5fe24e5 to git:20261009.4d87e9e

11 added, 7 removed. Audit A to A.

---
name: write-overlay
description: "Write a custom JSX overlay component and add it to the project's overlay track."
---
# Write Overlay
An overlay is a React component rendered frame-by-frame by Puppeteer, composited over the footage at a specific timestamp. All overlays are custom JSX — there are no built-in templates.
> See [MOTION.md](MOTION.md) for sandbox facts that matter once an overlay moves (SVG filters, the `duration` guard, beat math) and for how to verify motion by sampling.
---
## Execution context
Custom overlay JSX runs in a sandboxed evaluator. All identifiers below are injected as globals:
| Identifier | Type | Description |
|------------|------|-------------|
| `frame` | number | Current frame number (0 → duration-1). Drives all animation. |
| `fps` | number | Output frame rate |
| `duration` | number | Total frames this overlay is visible for |
| `props` | object | The `props` object from the project.json item |
| `interpolate` | function | Map a frame number to any output value |
| `spring` | function | Physics-based easing (0 → 1) |
| `springStep` | function | Closed-form spring from `from` to `to` starting at frame `at`. A pure function of `frame` (exact at fractional frames, no cache), so it stays correct under sub-frame motion blur. |
| `springSum` | function | A value that changes target several times: one closed-form spring per entry in `changes`, summed. Retargets mid-flight without restarting, and any frame renders without simulating the ones before it. |
| `Ph` | object | All [Phosphor Icons](https://phosphoricons.com) — e.g. `Ph.House`, `Ph.ArrowRight` |
| `FaIcon` | component | `FontAwesomeIcon` renderer — use with `FaSolid` / `FaBrands` icon objects |
| `FaSolid` | object | All [FA Free Solid](https://fontawesome.com/icons?s=solid) icon objects — e.g. `FaSolid.faHouse` |
| `FaBrands` | object | All [FA Free Brands](https://fontawesome.com/icons?s=brands) icons — e.g. `FaBrands.faGithub` |
| `THREE` | namespace | All [Three.js](https://threejs.org) primitives — `THREE.Vector3`, `THREE.MathUtils`, etc. Only reach for it when you genuinely need 3D — see "3D / Three.js" section. |
| `Canvas` | component | [@react-three/fiber](https://r3f.docs.pmnd.rs) Canvas. **Always pass `frameloop="never"`** and mount a `useThreeFrame()` child — see "3D / Three.js" section. |
| `useThreeFrame` | hook | Bridges r3f to Montaj's frame-stepped renderer. Mount exactly once inside any `<Canvas>`. |
| `useCanvas2DFrame` | hook | Drives a plain `CanvasRenderingContext2D` from `frame` — for pixel-level 2D drawing HTML/CSS can't do (per-pixel effects, arbitrary paths, `drawImage` compositing). See "2D Canvas" section. |
**Imports.** An overlay can import your own files: relative paths (`./lib/palette.js`, `../_shared/ease.ts`, `.json` data) and `montaj/render` resolve in render everywhere, and in the editor preview in the Montaj app, where `react` resolves to the editor's React. montaj's own browser UI still strips imports, so its preview shows nothing for an overlay that relies on one. The app's preview only follows files under the workspace, the global overlay library (`~/.montaj/overlays`) and profile folders; an import from anywhere else shows an error badge. An import that doesn't resolve (e.g. `from 'remotion'`) shows an error in the preview and fails at render. Editing an imported file refreshes the video editor's preview.
The globals above stay the way to get `frame`, `fps`, `duration`, `props`, `interpolate`, `spring`, `THREE`, `Canvas`, `useThreeFrame`, `Ph`, `FaIcon`, `FaSolid`, `FaBrands` and the chart components. Do not `import` `three`, `@react-three/fiber`, `recharts`, Phosphor or FontAwesome: render cannot resolve those packages from overlay code, and the preview fails the same way. Use the globals.
**Never hardcode frame counts; projects can be 24, 30 or 60fps.** A literal like `[0, 10]` means a different real-world duration on every project — 10 frames is 0.33s at 30fps but 0.17s at 60fps, so an overlay copied between projects (or a 30fps example copied into a 60fps project, which is the current default) plays at the wrong speed. Always derive the frame count from `fps`: `Math.round(fps * 0.33)` for "about a third of a second," not `10`.
**A project made without footage is 60fps until footage arrives.** The first video clip added to it sets the project's frame rate (and canvas) from that clip, once: a 60fps animation project that adds 30fps b-roll becomes 30fps. To keep 60, save `settings.fps: 60` once (a `PUT` of the project's settings). An fps set that way is never changed.
### Top-level vs component-body
**All calls to `interpolate`, `spring`, and any read of `frame`, `fps`, `duration`, or `props` must be inside the component function body.** The module's top-level code runs before the render shim sets up these globals — calling them outside a function will throw `interpolate is not defined` and crash the entire render. The same goes for a shared module you import: it must export functions and read `frame`, `fps` and the other globals only inside them, because its top level runs before the globals are set.
**With `settings.motionBlur` above 1, the renderer passes fractional `frame` values (sub-frames). Never index arrays by `frame` or branch on `frame % n`; derive everything as a continuous function of `frame`.**
```jsx
// WRONG — crashes at render time
const opacity = interpolate(frame, [0, Math.round(fps * 0.33)], [0, 1])
export default function Hook() { ... }
// CORRECT — inside the component, runs each frame
export default function Hook() {
const opacity = interpolate(frame, [0, Math.round(fps * 0.33)], [0, 1])
return <div style={{ opacity }}>...</div>
}
```
Pure helper functions that receive their values as arguments are fine at the top level, as long as they don't call globals at definition time:
```jsx
// Fine — interpolate is only called when the function is invoked (inside the component)
const itemStyle = (show) => ({
opacity: show,
transform: `translateY(${interpolate(show, [0, 1], [20, 0])}px)`,
})
export default function List() {
const show = spring({ frame, fps, stiffness: 300, damping: 24 })
return <div style={itemStyle(show)}>...</div>
}
```
---
## Writing the JSX
> **Expose text styling as props to make an overlay editable.** A text overlay is only restyleable in the editor's properties panel for the props it declares — see "Make text overlays editable in the properties panel" below, which applies to video overlays too. **Carousel text overlays follow a stricter, required version of that contract:** every text-bearing overlay must accept its font size, family, weight, style, color, alignment, transform, and background as props with string defaults — see skill `editable-text`.
When an overlay needs a background, prefer a solid semi-transparent color. Frosted glass over the footage is a different thing: see "Frosted glass over footage" below, never `backdropFilter`.
### Rules
- **Default export only** — the evaluator imports the default export
- **No hooks** — `useState`, `useEffect`, etc. are not supported in the overlay component itself. The render shim drives re-renders by calling `flushSync` externally each frame; the component must be a pure function of its props/globals.
- **Frame-driven** — all animation must derive from `frame`. No `setTimeout`, `setInterval`, CSS `animation`, or `transition`.
- **Transparent background (default)** — overlays render with a transparent background by default. Do not set `background` on the root element; it will obscure whatever is beneath it.
- **Opaque overlays** — when `"opaque": true` is set on the item in project.json, the root element's CSS controls the entire frame. You may freely set `background`, gradients, images, or any CSS on the root. Use this for full-frame covers, title cards, and animation sections.
- **Absolute positioning** — the component fills the full video frame (1080 on the short edge, aspect of `project.settings.resolution`). The Puppeteer viewport is always 1080-short-edge regardless of output resolution; the renderer captures it at the output's pixel density (see "Sharp at 4K" below). Place elements with `position: absolute`. Author all `fontSize`, padding, and `width` values at 1080-design coordinates — they have one consistent meaning across every resolution the project might render at.
- **No side effects** — no API calls, no filesystem access, no global state mutations.
- **`backdropFilter` never sees the footage** — each overlay item is captured in its own transparent page and composited over the footage afterwards, so `backdrop-filter` cannot blur the video, nor another overlay item. It blurs only what is behind it inside the same component. Frosted glass over footage needs the `glass_plate` step (below). Where `backdrop-filter` is fine: blurring the component's own lower layers. Keep it off any element whose children animate: Chrome caches its GPU layer and replays a stale frame.
### Sharp at 4K
The renderer captures overlays at `deviceScaleFactor = output short edge / 1080`, between 1 and 2 (2 at 4K). Text, CSS and SVG come out sharp at every resolution with no work from you. Three things don't:
- **Raster images:** supply at least 2× their displayed size (a logo shown 400 px wide needs an 800 px file), or use SVG. A 1× PNG is upscaled and soft at 4K.
- **Three.js / WebGL:** render at `window.devicePixelRatio`. With `<Canvas>`, keep r3f's default `dpr` (it follows `devicePixelRatio`) and never pass `dpr={1}`. A raw `THREE.WebGLRenderer` needs `renderer.setPixelRatio(window.devicePixelRatio)`.
- **2D `<canvas>`:** size the backing store in device pixels (`width={1080 * devicePixelRatio}`, CSS size unchanged), start `draw` with `ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)`, then draw in 1080 coordinates.
**Never hard-code a 3840 or 2160 layout.** Author at the 1080 design canvas; the capture scale does the rest.
---
## Make text overlays editable in the properties panel
The editor's right-hand properties panel (and the floating text toolbar) can restyle a text overlay **only for the props it actually declares**. It reads the nine standard text props off the item's `props` object:
`text`, `fontSize`, `fontFamily`, `fontWeight`, `fontStyle`, `color`, `textAlign`, `textTransform`, `bgColor`
A control appears for each of those that is present (non-null) on `props`; anything the overlay **hardcodes** in its JSX style instead of reading from `props` shows **no control at all**, and the operator can't change it. This is exactly why an overlay that writes `textTransform: 'uppercase'` straight into its style has no text-transform control in the editor.
**So a text overlay you want a human to be able to restyle must READ its text styling from `props`, with sensible defaults — not hardcode it.** For example:
```jsx
// Editable Hook — every text property is adjustable in the panel.
export default function Hook() {
const progress = interpolate(frame, [0, Math.round(fps * 0.27)], [0, 1])
const slideY = interpolate(frame, [0, Math.round(fps * 0.33)], [40, 0])
return (
<div style={{ position: 'absolute', bottom: 180, left: 48, right: 48, opacity: progress, transform: `translateY(${slideY}px)` }}>
<div style={{
fontFamily: props.fontFamily ?? 'Anton, Impact, sans-serif',
fontSize: props.fontSize ?? 120,
fontWeight: props.fontWeight ?? 900,
fontStyle: props.fontStyle ?? 'normal',
color: props.color ?? '#fff',
textAlign: props.textAlign ?? 'left',
textTransform: props.textTransform ?? 'uppercase',
background: props.bgColor ?? 'transparent',
lineHeight: 1.05, letterSpacing: '-1px',
textShadow: '0 2px 24px rgba(0,0,0,0.9), 0 0 60px rgba(0,0,0,0.5)',
}}>
{props.text}
</div>
</div>
)
}
```
Declare those same values in the item's `props` too, so the panel opens on the real values rather than blank controls:
```json
"props": {
"text": "She built an AI employee",
"fontSize": 120, "fontFamily": "Anton, Impact, sans-serif",
"fontWeight": 900, "fontStyle": "normal", "color": "#ffffff",
"textAlign": "left", "textTransform": "uppercase", "bgColor": "transparent"
}
```
This now applies to **video** text overlays, not just carousel slides — the video editor has the same properties panel. For the full contract (types and validation) see skill `editable-text`. A non-text overlay (logo, image card, chart) has no text props to expose; the same principle still holds for whatever a human would want to tweak — pass it through `props`, don't bury it in the JSX.
---
## Frosted glass over footage
A lower overlay track does not help: every item is its own page, so its `backdrop-filter` blurs nothing of the footage. Instead, run `glass_plate` and let the component draw the blurred footage itself.
- 1. `glass_plate --project <p> --item <overlay item id>` returns `{frames, fps, size}`: one blurred JPEG per frame of the item's range (video and image tracks only, short edge 270, `--sigma` 2.2, `--short-edge`, `--out`). Plate frame `n` is the screen frame under the overlay's frame `n`, so the plate must be made for the same item (same range) that draws it. Pass `frames` to the component as the prop `plate`.
+ 1. `glass_plate --project <p> --item <overlay item id>` returns `{frames, fps, size, canvas}`: one blurred JPEG per frame of the item's range (video and image tracks only, short edge 270, `--sigma` 2.2, `--short-edge`, `--out`). Plate frame `n` is the screen frame under the overlay's frame `n`, so the plate must be made for the same item (same range) that draws it. Pass `frames` to the component as the prop `plate` and `canvas` as `canvas`.
2. To pin a shape to moving footage, run `track_points --project <p> --item <id> --anchors '[{"id":"a","x":600,"y":430}]'`, one anchor per element, placed on visible detail in the item's first frame (design pixels, 1080 short edge). It returns `{tracks: {a: [[x,y] per frame]}, minScore, ...}`; pass `tracks.a` as `track`. A `minScore` that is low means the anchor lost its patch; -9 means it left the frame and coasted.
```jsx
- // Glass card: the plate frame fills the project, shifted so it lines up with the screen.
+ // Glass card: the plate frame fills the design canvas, shifted so it lines up with the screen.
export default function Glass() {
- const { plate, track, canvas = [1080, 1920], w = 520, h = 220, x = 280, y = 700 } = props
- const [cx, cy] = track ? track[Math.min(frame, track.length - 1)] : [x + w / 2, y + h / 2]
+ const { plate, canvas, track, w = 520, h = 220, x = 280, y = 700 } = props
+ // One entry per whole frame; `frame` is fractional under motionBlur, so floor and clamp it.
+ const at = (list) => list[Math.max(0, Math.min(Math.floor(frame), list.length - 1))]
+ const [cx, cy] = track ? at(track) : [x + w / 2, y + h / 2]
const left = cx - w / 2, top = cy - h / 2
return (
<div style={{ position: 'absolute', left, top, width: w, height: h, borderRadius: 36, overflow: 'hidden',
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.28)' }}>
- <img src={plate[Math.min(frame, plate.length - 1)]}
- style={{ position: 'absolute', left: -left, top: -top, width: canvas[0], height: canvas[1] }} />
+ <img src={at(plate)}
+ style={{ position: 'absolute', left: -left, top: -top, width: canvas[0], height: canvas[1], maxWidth: 'none' }} />
<div style={{ position: 'absolute', inset: 0, background: 'rgba(255,255,255,0.14)' }} />
</div>
)
}
```
- The plate image is drawn at the design canvas size (`canvas`, `[w, h]`, the `canvas` field of `track_points`) at `left: -x, top: -y`, so the part inside the shape is exactly the footage behind it. Then a tint, then a rim. Animated children go in sibling elements above the glass, not in the plate layer.
+ The plate image is drawn at the design canvas size (`canvas`, `[w, h]`, the `canvas` field of `glass_plate` and of `track_points`) at `left: -x, top: -y`, so the part inside the shape is exactly the footage behind it. `maxWidth: 'none'` keeps the preview's base CSS from shrinking it. Then a tint, then a rim. Animated children go in sibling elements above the glass, not in the plate layer.
+
+ It lines up only when the overlay item drawing the plate is full-canvas, with no offset, scale, rotation or keyframes on the item. The plate holds the video and image tracks only: an opaque overlay underneath is not in it.
---
## Utilities
### `interpolate(frame, inputRange, outputRange, options?)`
Maps a frame number to any output value. Clamps at both ends by default.
```jsx
// Fade in over the first ~0.5s
const holdIn = Math.round(fps * 0.5)
const opacity = interpolate(frame, [0, holdIn], [0, 1])
// Fade in then out, each over ~0.5s
const holdOut = Math.round(fps * 0.5)
const fadeIn = interpolate(frame, [0, holdIn], [0, 1])
const fadeOut = interpolate(frame, [duration - holdOut, duration], [1, 0])
const opacity = Math.min(fadeIn, fadeOut)
// Slide in from left over ~0.67s
const x = interpolate(frame, [0, Math.round(fps * 0.67)], [-200, 0])
```
One option: `extrapolate` — `'clamp'` (default) or `'extend'`. That is the whole options object; the runtime destructures `{ extrapolate = 'clamp' }` and ignores everything else. Earlier versions of this file used `extrapolateRight`, which does not exist and was silently dropped — harmless only because the default was already `'clamp'`.
**`interpolate` is strictly linear** — `interpolate(5, [0,10], [0,1])` is exactly `0.5`. There is no easing parameter; to shape a move, transform the normalised value before you map it to an output.
### `spring({ frame, fps, mass?, stiffness?, damping?, initialVelocity? })`
Returns a 0 → 1 value following spring physics. Overshoots and settles naturally.
```jsx
const scale = spring({ frame, fps, stiffness: 120, damping: 14 })
// transform: `scale(${scale})`
```
Defaults: `mass: 1`, `stiffness: 100`, `damping: 10`.
### `springStep(...)` / `springSum(...)`
Closed-form damped springs, as pure functions of `frame` — no memo, no simulation, exact at fractional frames.
```jsx
springStep(frame, fps, { from, to, at, mass, stiffness, damping, initialVelocity })
springSum({ frame, fps, from, changes: [{ at, to, mass?, stiffness?, damping? }], mass, stiffness, damping })
```
`springStep` releases one spring from `from` to `to` at frame `at` (holds `from` before it). `springSum` retargets several times: each entry in `changes` is a closed-form spring summed on top of the others, so the value can jump targets mid-flight without restarting the animation.
```jsx
export default function Indicator() {
const x = springSum({
frame, fps, from: 0,
changes: [
{ at: Math.round(fps * 0.5), to: 240 },
{ at: Math.round(fps * 1.2), to: 520 },
{ at: Math.round(fps * 2.0), to: 120 },
],
stiffness: 170, damping: 26,
})
return <div style={{ position: 'absolute', left: 100 + x, top: 500, width: 160, height: 8, background: '#fff' }} />
}
```
`spring()` interpolates linearly between whole frames at fractional frames; `springStep`/`springSum` are exact at any frame.
---
## Icons
Two icon libraries are available as globals, with no imports.
### Phosphor Icons — `Ph`
Browse at [phosphoricons.com](https://phosphoricons.com). Over 9000 icons, six weights: `regular` (default), `bold`, `fill`, `duotone`, `light`, `thin`.
```jsx
// Basic usage
<Ph.House size={48} color="white" />
// With weight
<Ph.ArrowRight size={32} color="#a78bfa" weight="bold" />
<Ph.Star size={40} color="#fbbf24" weight="fill" />
// In a card row
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Ph.CheckCircle size={36} color="#34d399" weight="fill" />
<span style={{ fontFamily: 'Inter, sans-serif', fontSize: 24, color: 'white' }}>Feature unlocked</span>
</div>
```
### Font Awesome — `FaIcon` + `FaSolid` / `FaBrands`
`FaIcon` is the renderer. `FaSolid` has general-purpose icons; `FaBrands` has logos (GitHub, YouTube, X, etc.).
```jsx
// Solid icon
<FaIcon icon={FaSolid.faCode} style={{ fontSize: 48, color: 'white' }} />
// Brand logo
<FaIcon icon={FaBrands.faGithub} style={{ fontSize: 48, color: 'white' }} />
// Sized via style
<FaIcon icon={FaSolid.faBolt} style={{ fontSize: 36, color: '#fbbf24' }} />
```
### Which library to use
- **Phosphor** — preferred for most overlays. Cleaner API, consistent stroke weight, large set.
- **Font Awesome Brands** — when you need a specific brand logo (GitHub, YouTube, X/Twitter, TikTok, etc.)
- **Font Awesome Solid** — for any icon Phosphor doesn't cover.
---
## Custom fonts (Google Fonts)
The render host's system fonts are always available and preferred for performance — they avoid the network fetch entirely. On macOS render hosts (today's default) you can rely on `Helvetica`, `Arial`, `Georgia`, `Times`, `Courier`, `Impact`, and the `system-ui` / `-apple-system` generic stacks. `Inter` is **not** a macOS system font — use a Google Font declaration for it.
To use a Google Font, declare it on the overlay item in `project.json` with a `googleFonts` array:
```json
{
"id": "ov-hook",
"type": "overlay",
"src": "/path/to/overlays/hook.jsx",
"start": 0.0,
"end": 5.0,
"googleFonts": ["Anton", "Playfair+Display:ital@1"]
}
```
The render engine injects the font stylesheet into the page `<head>` before any component code runs, so the font is fully loaded at frame 0.
**Do not use `@import url(...)` inside the JSX.** A dynamically-injected `@import` fires after the page loads — the font fetch is still in flight when the next overlay's page initialises, breaking its `window.__setFrame` setup. Always declare fonts in `googleFonts` instead, and reference the family name directly in styles:
```jsx
// In your JSX — just use the family name, no @import
fontFamily: '"Anton", Impact, sans-serif'
fontFamily: '"Playfair Display", Georgia, serif'
```
**Format:** `FamilyName` for regular, `FamilyName:ital@1` for italic, `FamilyName:wght@700` for a specific weight. Each family is a separate array entry.
**System font fallbacks for common Google Fonts:**
| Google Font | System fallback |
|-------------|----------------|
| Anton | Impact |
| Playfair Display | Georgia |
| Oswald | Arial Narrow |
| Roboto / Inter | system-ui, sans-serif |
If visual fidelity isn't critical, the system fallback avoids the network fetch entirely.
---
## 3D / Three.js (react-three-fiber)
Overlay JSX can use Three.js for real 3D content via [@react-three/fiber](https://r3f.docs.pmnd.rs). Reach for it when you need depth, particles, shader effects, 3D text, or geometry that can't be faked in 2D. **Do not use it for things 2D HTML can do** — rotations, fades, slides, gradients are all cheaper and simpler in CSS.
**Preview behavior — RAF-driven, not frame-stepped.** Three.js overlays render in both the live UI preview and the final rendered MP4. The two paths share globals and library versions via the `montaj-overlay-runtime` package, so what you see in the editor matches the render output visually. One difference to be aware of: in preview the 3D content animates via r3f's own `requestAnimationFrame` loop (the preview-context `Canvas` wrapper overrides `frameloop="never"` → `"always"` automatically), so motion is smooth but not perfectly frame-accurate to the scrubbed video position. In render, `frameloop="never"` is honored and the shim drives `gl.render()` synchronously each frame.
### Two non-negotiable rules
1. **`<Canvas frameloop="never">`.** The default r3f Canvas runs its own `requestAnimationFrame` loop, which is incompatible with Montaj's frame-stepped renderer — Puppeteer would screenshot arbitrary moments. `frameloop="never"` disables r3f's loop and lets the render shim drive each frame synchronously.
2. **Mount `useThreeFrame()` exactly once inside the Canvas.** This hook registers the synchronous render trigger the shim calls every frame. Without it, Three never draws.
Convention: put a tiny `<FrameBridge />` child component at the top of the Canvas that calls `useThreeFrame()` and returns `null`.
### Drive everything from `frame`, never `useFrame`
r3f's `useFrame` hook is tied to the internal animation loop we've disabled. **It does not work.** Compute transforms inline from the `frame` global, the same way 2D overlays do:
```jsx
export default function ThreeCube() {
const t = frame / fps
const rotX = t * Math.PI // half-turn per second
const rotY = t * Math.PI * 0.7
const pulse = 1 + 0.08 * Math.sin(t * 4)
return (
<Canvas
frameloop="never"
style={{ position: 'absolute', inset: 0 }}
camera={{ position: [0, 0, 5], fov: 50 }}
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: true }}
>
<FrameBridge />
<ambientLight intensity={0.6} />
<directionalLight position={[5, 5, 5]} intensity={1.2} />
<mesh rotation={[rotX, rotY, 0]} scale={[pulse, pulse, pulse]}>
<boxGeometry args={[1.6, 1.6, 1.6]} />
<meshStandardMaterial color="#3b82f6" metalness={0.3} roughness={0.35} />
</mesh>
</Canvas>
)
}
function FrameBridge() {
useThreeFrame()
return null
}
```
### No async assets
Textures, GLTFs, and any other resource that r3f loads via Suspense will **not** load in time for frame 0 — the renderer doesn't wait for Suspense the way it waits for `document.fonts.ready`. Stick to:
- Primitive geometries: `<boxGeometry>`, `<sphereGeometry>`, `<planeGeometry>`, `<cylinderGeometry>`, `<torusGeometry>`, `<icosahedronGeometry>`, etc.
- `<meshStandardMaterial>` / `<meshBasicMaterial>` with `color` (no `map`, `normalMap`, etc.)
- Lights: `<ambientLight>`, `<directionalLight>`, `<pointLight>`, `<spotLight>`
- Math helpers from the `THREE` global (`THREE.MathUtils.lerp`, `THREE.Vector3`, etc.)
If you need a texture, render it on a 2D HTML overlay layered above the Canvas track instead.
### Canvas placement and compositing
`<Canvas>` fills its parent. Two patterns work:
**Full-frame Canvas** — apply `style={{ position: 'absolute', inset: 0 }}` directly to `<Canvas>` so it covers the whole 1080×1920 design canvas. Use when 3D content should occupy the entire frame or be anchored relative to the camera (e.g. a particle field).
**Positioned Canvas** — wrap `<Canvas>` in a `position: absolute` div with explicit `top`/`left`/`right`/`height` (or `bottom`), and set `style={{ width: '100%', height: '100%' }}` on the Canvas itself. Use when 3D content should sit in a specific region — e.g. a 3D logo lockup in the lower-third while a font overlay sits up top.
```jsx
<div style={{ position: 'absolute', top: 1100, left: 0, right: 0, height: 500 }}>
<Canvas frameloop="never" style={{ width: '100%', height: '100%' }} ...>
<FrameBridge />
...
</Canvas>
</div>
```
Make sure `gl={{ alpha: true }}` is set — the Canvas DOM element defaults to opaque, which would paint a black or white box over the underlying footage. With `alpha: true` + the renderer's default transparent page background, only the drawn 3D geometry shows up; the rest passes through to whatever is beneath.
For text labels alongside 3D content, render them as a 2D HTML overlay on a separate track rather than as 3D text inside the Canvas. HTML text is sharper, cheaper, and supports the existing Google Fonts pipeline.
### Worked reference
A known-good minimal overlay lives at `tests/fixtures/overlays/three-cube.jsx` — it's the same file the render smoke test (`tests/test_render_three.py`) uses. Copy from it when starting a new 3D overlay; everything in it is checked end-to-end by CI.
### Bundle weight
Three.js + r3f add ~250 KB to an overlay segment's bundle after esbuild tree-shakes. Overlays that don't use `<Canvas>` pay zero cost. Don't reach for Three "just in case" — use it only when the result genuinely needs 3D.
---
## 2D Canvas
For plain 2D drawing HTML/CSS can't do — per-pixel effects, arbitrary paths, `drawImage` compositing — overlay JSX can drive a `CanvasRenderingContext2D` via `useCanvas2DFrame`. Unlike Three.js, this needs no library and behaves identically in preview and render — there's no internal render loop to disable.
```jsx
export default function CanvasExample() {
const draw = (ctx, { frame, fps, duration }) => {
ctx.fillStyle = '#EFE3CE'
ctx.fillRect(0, 0, 1080, 1080)
const x = interpolate(frame, [0, duration], [0, 1080 - 120])
ctx.fillStyle = '#3b82f6'
ctx.fillRect(x, 480, 120, 120)
}
const ref = useCanvas2DFrame(draw, frame, fps, duration)
return <canvas ref={ref} width={1080} height={1080} style={{ position: 'absolute', inset: 0 }} />
}
```
**`useCanvas2DFrame(draw, frame, fps, duration)` — always pass `frame`/`fps`/`duration` explicitly**, the same as `interpolate`/`spring`. It returns a ref to attach to a `<canvas>` element; `draw(ctx, { frame, fps, duration })` runs synchronously every time that ref is attached, which happens on every frame.
**Draw the complete frame from scratch every call** — same rule as everywhere else in overlay JSX: same `frame` in, same pixels out. Don't rely on anything painted by a previous call still being there (start with a fill/clear, as in the example above).
**One `<canvas>` per `useCanvas2DFrame` call.** Each call returns its own ref; don't share one ref across multiple canvases or call the hook conditionally.
---
## Charts / Recharts
Overlay JSX can use SVG-based charts via [Recharts](https://recharts.org). Available globals: `BarChart`, `Bar`, `LineChart`, `Line`, `PieChart`, `Pie`, `Cell`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `Legend`, `ResponsiveContainer`.
### Carousel-only (for now)
Charts work in carousel slides today. Video overlays don't have a chart story yet — see follow-up plans. The carousel renderer screenshots SVG directly, so no special contract is required.
### Non-negotiable rule: disable animations
Set `isAnimationActive={false}` on every chart primitive (`<Bar>`, `<Line>`, `<Pie>`, etc.). Recharts animates by default; without this, the carousel renderer captures a mid-animation frame and the chart looks half-drawn. If you see fuzzy ticks or a half-faded legend in the rendered PNG, also pass `isAnimationActive={false}` to `<XAxis>` / `<YAxis>` / `<Legend>` defensively.
### Size charts explicitly — don't use `<ResponsiveContainer>`
`<ResponsiveContainer>` measures via `ResizeObserver`, which is asynchronous; the Puppeteer screenshot can fire before the first observer callback, producing a blank chart. Instead, take `boxWidth` / `boxHeight` as props (the slide wrapper passes them in automatically) and use them as explicit `width` / `height` on the chart root: `<BarChart width={innerW} height={innerH} ...>`.
### Stick to bar / line / pie for v1
Three system overlay templates ship today: `bar-chart`, `line-chart`, `pie-chart`. For one-off data viz, write a custom overlay using the same globals. For variants likely to be reused (area, scatter, donut-vs-pie variants beyond `innerRadius`), promote them to system overlays so they show up in the property panel for everyone.
---
## project.json item shape
Place overlay items in `tracks[1+]` in `project.json`. Each item must have `type: "overlay"` and a `src` path pointing to the JSX file. All custom data goes inside `props`.
```json
{
"tracks": [
{ "id": "trk-0", "items": [] },
{
"id": "trk-1",
"items": [
{
"id": "ov-hook",
"type": "overlay",
"src": "/abs/path/to/project/overlays/hook.jsx",
"start": 0.0,
"end": 3.0,
"props": {
"text": "She built an AI employee"
}
},
{
"id": "ov-logo",
"type": "overlay",
"src": "/abs/path/to/project/overlays/logo.jsx",
"start": 0.0,
"end": 999.0,
"props": {
"logoSrc": "/abs/path/to/project/assets/logo.png"
}
}
]
}
]
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `id` | yes | Unique identifier within the track |
| `type` | yes | Always `"overlay"` for JSX overlays |
| `src` | yes | Absolute path to the JSX file |
| `start` | yes | Start time in output video (seconds) |
| `end` | yes | End time in output video (seconds) |
| `props` | no | Arbitrary data passed through to the component as the `props` global |
| `googleFonts` | no | Google Font families to load before render (e.g. `["Anton", "Playfair+Display:ital@1"]`). See Custom fonts section. |
**Use absolute paths for `src`.** Relative paths are resolved from `project.json` location, but absolute paths are unambiguous.
---
## Using assets
Assets (logos, images) are declared in `project.assets`. Reference them by passing their `src` path in `props`, then use it in the component. A file path in `props` may be anywhere on disk (a folder outside the workspace opens nothing in it). A `file://` path hardcoded in the JSX must be inside the workspace, or render refuses it (logged as `blocked a read outside the allowed folders`). A remote URL in `props` is fetched before render only when it ends in an image, video, audio, font or data extension. One with no extension at all (cover art such as `https://i.scdn.co/image/…`) is fetched only when the overlay loads it, and drawn only if it is an image. One the overlay loads that cannot be fetched fails the render:
```json
{
"id": "ov-logo",
"type": "overlay",
"src": "/path/to/overlays/logo.jsx",
"start": 0.0,
"end": 30.0,
"props": { "src": "/path/to/assets/logo.png" }
}
```
```jsx
// overlays/logo.jsx
const opacity = interpolate(frame, [0, Math.round(fps * 0.2)], [0, 1])
export default function Logo() {
return (
<img
src={props.src}
style={{
position: 'absolute', top: 40, right: 40,
width: 80, opacity,
}}
/>
)
}
```
Reference assets by their workspace path (e.g. `/abs/path/to/project/assets/logo.png`). How that path is resolved for preview or render is the interface's concern — the component always receives a usable URL for the path it was given.
---
## Live preview in the Overlays tab
The Montaj UI has an **Overlays** tab that gives a real-time animated preview of every overlay in the project — no render needed.
- Open the **Overlays** tab and select a project from the left panel to see its overlay list
- Select any overlay item to see it playing over the preview image at full animation fidelity
- **Live reload** — the preview automatically recompiles and restarts whenever you save a `.jsx` file; latency is typically under a second
- Use the **⏸ / ▶** button in the bottom-right of the preview to pause on a specific frame
- Asset paths passed via `props` (e.g. `logoSrc`, `src`) are proxied automatically — images and logos resolve correctly in the preview even though they are absolute local paths
Use this tab to validate motion, timing, and asset rendering before committing to a full render.
---
## Writing multiple overlays in parallel
When a workflow calls for several overlays, write them concurrently — each JSX file is independent.
1. Identify all overlays needed from the editing prompt and transcript
2. Write **every** JSX file first — author the whole set before sampling or persisting anything. Do **not** sample after each file; interleaving a Puppeteer sample between every write is slow and breaks your authoring flow.
3. **Sample them all in one batch pass at the end** (see "Verify your overlays fit the canvas")
4. Fix any overflow, then save the project (delta) adding all items to the overlay track in one update
---
## Authoring guidelines
- **Text size for static formats.** **Do NOT apply video text sizing to carousels, story panels, or other static formats** where the text is being *read* rather than *glanced at*, and where headlines run longer than a punchy hook. For carousels see skill `carousel` §6 (Typography). For other static formats, default to ~32–48px body, ~52–80px headline, and size down further as line length grows.
- **Avoid the bottom ~350px** — captions render here, and platform UI (TikTok progress bar, Instagram controls) sits in this zone. Use `bottom: 350` or higher, or anchor from the top instead.
- **Avoid the right ~200px** — TikTok/Instagram action buttons (like, comment, share) occupy the right edge. Keep text and icons within `right: 200` or use `left`-anchored layout.
- **Don't overlap** — avoid two overlays occupying the same screen region at the same time
- **Persist after writing** — save the project (delta) with the new overlay items added to the track
---
## Verify your overlays fit the canvas — one sample pass at the end
**Write all of your overlay JSX first. Then sample them in a single batch pass — do not sample after each file.** Per-file sampling stalls authoring and spins up a fresh Puppeteer process each time; one pass at the end over the finished set is faster and just as safe, since nothing downstream consumes an overlay until you save the project with the whole batch.
Once every JSX file is written, loop over them in one pass — for each JSX file, **run step `sample_overlay`** with args `{ overlay, out, measure: true, google_fonts, props }`:
- `overlay` — absolute path to the JSX file
- `out` — absolute path where the step writes the sample PNG (required)
- `google_fonts` — the overlay's declared `googleFonts` value (snake_case step arg)
- `props` — representative props for the overlay
Pass each overlay's declared `googleFonts` (and representative `props`) so the step measures with the real render-time font — see the Syne case study below.
The step renders the overlay through the same Puppeteer path the production renderer uses and returns:
```json
{
"pngPath": "/tmp/sample-check.png",
"measurements": {
"anyOverflow": false,
"texts": [...],
"viewport": { "w": 1080, "h": 1920 }
}
}
```
**`measurements.anyOverflow` is the go/no-go signal.** If it is `true`, at least one text element extends past the 1080×1920 design canvas and will be visually clipped in the final render. Inspect `measurements.texts[]` for per-element detail: `bbox` gives the element's position and dimensions, and `overflow.{left,right,top,bottom}` gives the pixel overshoot on each edge.
**Two categories of false positives to rule out before fixing:**
- **`transform: rotate()` or `transform: scale()`** — `getBoundingClientRect()` returns the axis-aligned bounding box of the transformed element, which is wider and/or taller than the un-rotated element. A 1000×100 banner rotated 45° reports a ~777×777 bbox and will look like it overflows even when it doesn't. Check the `transform` field on the flagged element. If it's not `matrix(1, 0, 0, 1, 0, 0)` (the identity), the overflow signal is unreliable for that element.
- **`clippingAncestor`** — an element flagged for overflow may be the child of a container with `overflow: hidden` (animation sections that clip entering/exiting elements use exactly this). If the `clippingAncestor` field is non-null, the element is intentionally clipped. Compute `intersect(elementBbox, clippingAncestor.bbox)` to check effective overflow if you want to be precise.
**Do not ship an overlay JSX until `anyOverflow` is false** — or until you have verified each overflowing element is covered by one of the two false-positive cases above.
### The Syne overflow case study — why the editor preview cannot tell you
When the Montaj editor preview shows a layout that fits, but the rendered video shows clipped text, the cause is almost always a **font-width mismatch**. The editor preview falls back to `sans-serif` when a Google Font is not installed locally. The renderer loads the overlay's declared `googleFonts` faithfully via the Google Fonts CDN before rendering frame 0.
Display fonts like **Syne 800** are 60–70% wider than typical `sans-serif` fallbacks at the same `px` size. Concrete example: `"RECURSIVE"` at `fontSize: 160` measures ~933 px wide in fallback `sans-serif`, but ~1594 px wide in Syne 800 — 514 px of right-edge overflow on a 1080-wide canvas. The editor looked fine; the render was completely clipped.
**Any overlay that declares a `googleFonts` entry must go through the end-of-authoring sample pass with `measure: true` and that same `googleFonts` spec, before you save the project with the batch.** The preview cannot tell you whether the text fits in the render. The sample can.