@bycrux/montaj-skills 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @bycrux/montaj-skills
2
+
3
+ Transport-agnostic **Montaj domain skills** — the prose that teaches an agent
4
+ *what* Montaj can do and *how to reason* about it, with zero assumptions about
5
+ *how* the agent talks to a Montaj server.
6
+
7
+ This bundle is published from the Montaj monorepo. The canonical source lives at
8
+ `/skills/<name>/SKILL.md`; the files shipped here are derived copies, staged at
9
+ pack time by `scripts/stage.mjs`.
10
+
11
+ ## What's in the bundle
12
+
13
+ Flat Markdown files:
14
+
15
+ | File | Source skill | What it covers |
16
+ | --- | --- | --- |
17
+ | `skills/select-takes.md` | `select-takes` | Choosing the best takes from raw footage |
18
+ | `skills/overlay.md` | `overlay` | Placing graphic/text overlays on a render |
19
+ | `skills/write-overlay.md` | `write-overlay` | Authoring the copy that goes into overlays |
20
+ | `skills/image-search.md` | `image-search` | Sourcing imagery for a project |
21
+ | `contract.md` | `_contract` | The **vocabulary contract** (see below) |
22
+
23
+ Deliberately **excluded**: the `native` and `mcp` skills and the root
24
+ `SKILL.md`. Those describe a specific transport and host wiring — they are not
25
+ part of the portable domain bundle.
26
+
27
+ ## The vocabulary contract
28
+
29
+ `contract.md` defines the shared vocabulary the domain skills speak: the nouns
30
+ (projects, takes, overlays, media…) and the verbs/operations the agent invokes.
31
+ The domain skills are written *against* this contract — they say "do operation
32
+ X" without saying which HTTP route, MCP tool, or CLI call performs it.
33
+
34
+ ## Consumers MUST supply their own interface skill
35
+
36
+ **This package ships domain knowledge and the contract only — no transport.**
37
+ Montaj intentionally does not bundle an interface skill here.
38
+
39
+ To use these skills, a consumer must provide its **own interface skill** that
40
+ *implements* the vocabulary contract — mapping each operation in `contract.md`
41
+ to a concrete call (an HTTP request to a Montaj server, an MCP tool invocation,
42
+ a CLI command, etc.). The domain skills + contract + your interface skill
43
+ together form a working agent. Without an interface skill, the domain skills
44
+ have no way to actually reach a Montaj instance.
45
+
46
+ ## Versioning / publishing
47
+
48
+ Published as `@bycrux/montaj-skills` to the public npm registry, tag-gated on
49
+ `montaj-skills-v*`. The staged files are regenerated on every pack via the
50
+ `prepack` script, so the published tarball always reflects `/skills` at the
51
+ tagged commit.
package/contract.md ADDED
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: _contract
3
+ description: "The shared vocabulary that domain skills use to name Montaj operations. Domain skills phrase every Montaj interaction as one of these verbs; an interface skill defines how each verb is actually performed. Load this to learn the canonical verbs before authoring or reading a domain skill."
4
+ ---
5
+
6
+ # The Vocabulary Contract
7
+
8
+ Montaj's skills are split into **domain skills** (what to do — transport-agnostic) and **interface skills** (how Montaj is reached — native CLI/HTTP here, or a consumer's remote interface in another repo). This document is the small, stable vocabulary the two sides share.
9
+
10
+ A **domain skill** describes creative/editing work in terms of the canonical verbs below — and nothing else. An **interface skill** defines HOW each verb is performed on a particular transport. The domain skills never need to know which interface is in play.
11
+
12
+ This is a contract written for an LLM reader: it is prose conventions, not a strict schema. Phrase things naturally, but stay inside these verbs.
13
+
14
+ ## The canonical verbs
15
+
16
+ Each Montaj interaction is exactly one of these:
17
+
18
+ - **run step `<name>` with `<args>`** — invoke a Montaj step and use its output. Steps are the core compute units: e.g. `waveform_trim`, `rm_nonspeech`, `transcribe`, `crop_spec`, `virtual_to_original`, `search_images`, `fetch_image`. (Authoring an overlay is NOT a step — overlays are written as files and saved into the project.)
19
+ - **read the project** — get the current project state: clips, tracks, settings.
20
+ - **save the project (delta)** — persist only the fields you changed. See the discipline below; this verb is never "save the whole thing I had cached."
21
+ - **write a file `<path>` `<contents>`** — create or overwrite a workspace file (overlay JSX, fetched images, assets).
22
+ - **read a file `<path>`** — read a workspace file back.
23
+ - **log `<message>`** — emit operator-visible progress.
24
+
25
+ ## Save discipline (domain rule, transport-neutral)
26
+
27
+ Saving the project is always **GET-fresh → merge → save**:
28
+
29
+ 1. **read the project** immediately before saving — get the current state right now.
30
+ 2. **merge** only your changed fields into that fresh state.
31
+ 3. **save the project (delta)** with those fields.
32
+
33
+ Never save from a stale cached body. The operator may be editing the project concurrently, so a body you read minutes ago can clobber their work. Re-read every time, even if you "just" read it.
34
+
35
+ ## Two hard rules
36
+
37
+ **No transport language.** Domain skills MUST phrase every Montaj interaction as one of the verbs above and MUST NOT name a transport. No `localhost`, no port numbers, no `montaj run`, no `PUT /api/...`, no `curl`, no reading or writing `project.json` directly. If a domain skill mentions a URL, a port, an HTTP method, or a literal on-disk project file, it has leaked the transport and is wrong. The interface skill owns all of that.
38
+
39
+ **Name-based sub-skill references.** Refer to other skills by name, not by file path — e.g. "load skill `write-overlay`", not `skills/write-overlay/SKILL.md`. The reader resolves the name; the path may differ per interface.
40
+
41
+ ## Coverage note
42
+
43
+ These verbs are sufficient for the Phase-1 domain skills:
44
+
45
+ - **select-takes** — run step `crop_spec`, run step `virtual_to_original`; read the project.
46
+ - **overlay** — read the project; save the project (delta); load skill `write-overlay`.
47
+ - **write-overlay** — save the project (delta); write a file (the JSX); reference asset paths via written/read files.
48
+ - **image-search** — run step `search_images`, run step `fetch_image`; write a file / read a file.
49
+
50
+ If a future domain skill needs an operation not on this list, extend this contract first — do not let a domain skill invent its own transport-specific phrasing.
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@bycrux/montaj-skills",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "files": [
7
+ "skills",
8
+ "contract.md",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "prepack": "node scripts/stage.mjs"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ }
17
+ }
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: image-search
3
+ description: "Find and download real images from the web — people, logos, brand/event stills, B-roll — to add as overlay image cards or project assets. Load when the prompt asks to source or insert images (e.g. 'add a photo of X', 'find images of the IPO', 'pull a shot of the factory')."
4
+ ---
5
+
6
+ # Image Search
7
+
8
+ Sourcing real imagery is a two-step pipeline: **`search_images`** finds candidate URLs, **`fetch_image`** downloads the one you pick to a workspace path. From there the image becomes an overlay image card (load skill `overlay`) or a project asset.
9
+
10
+ Use this when the editing prompt asks for outside imagery — a person ("a photo of Elon Musk"), an event ("the IPO"), a logo, a brand still, or B-roll the footage doesn't contain. Never fabricate an image path or hotlink a remote URL into the project — always run step `fetch_image` to a real local file first (the preview player and render engine read local files).
11
+
12
+ ## Step 1 — `search_images`
13
+
14
+ Run step `search_images` with the following args. Returns candidate results; no download is performed.
15
+
16
+ | Arg | Default | Notes |
17
+ |-----|---------|-------|
18
+ | `query` | — | Search string. Be specific: `"Elon Musk portrait 2025"` beats `"elon"`. |
19
+ | `provider` | `commons` | `commons` · `sportsdb` · `web` (see below) |
20
+ | `limit` | `10` | Max results, capped at 30 |
21
+
22
+ Output: `{ "results": [ { title, url, width, height, mime, license, artist, source, thumbnail? } ] }`
23
+ `url` is always an HTTPS original (so the downstream `fetch_image` step accepts it).
24
+
25
+ ### Providers
26
+
27
+ - **`commons`** (Wikimedia Commons, **keyless**) — license-clean, well-labeled. First choice for historical figures, landmarks, public-domain/CC imagery, and anything not time-sensitive.
28
+ - **`sportsdb`** (TheSportsDB, **keyless**) — team badges / escudos. Use for sports crests.
29
+ - **`web`** (open-web Google Images via SerpApi, **needs a key**) — broadest coverage. Use for current events, specific living people, brand/product shots, and anything Commons won't have. License is reported `"unknown"` — web results are **not** license-filtered; editorial use is the caller's judgment.
30
+
31
+ Pick the **narrowest provider that will have the subject.** Reach for `web` only when `commons` won't cover it (recent news, a specific private individual, a current product).
32
+
33
+ If the key is missing the step fails — tell the human to set up their SerpApi key via the native interface.
34
+
35
+ ## Step 2 — `fetch_image`
36
+
37
+ Run step `fetch_image` with the following args. Downloads one HTTPS URL to a workspace path. Private/internal IPs are blocked; non-image and oversized responses are rejected.
38
+
39
+ | Arg | Default | Notes |
40
+ |-----|---------|-------|
41
+ | `url` | — | HTTPS URL from a search result's `url` field |
42
+ | `out` | — | Destination path. Use `<project>/assets/<name>.jpg` |
43
+ | `max-bytes` | 25 MiB | Size cap |
44
+
45
+ Output: `{ "path": "/abs/path/to/file.jpg" }`. Some hosts return `403` to non-browser fetches — if one fails, run step `fetch_image` with the next candidate instead of fighting it.
46
+
47
+ ## Picking the right image
48
+
49
+ Search returns more than you need. Before committing:
50
+
51
+ 1. **Right subject** — read the `title` and dimensions. For a named person or specific event, confirm it's actually them/it, not a lookalike or a generic stock shot.
52
+ 2. **High enough resolution** — prefer ≥1000px on the long edge for full-frame cards. Tiny thumbnails upscale badly on 4K output.
53
+ 3. **Clean frame — verify visually.** After fetching, **look at the file** (read it as an image, or run step `analyze_media` with a "is there burned-in text or a watermark?" prompt). **Reject** images with burned-in captions, news-chyron bars, watermarks, logos stamped across them, or collages. Plain, uncluttered shots composite far better behind text overlays. This matters most for `web` results, which are unfiltered.
54
+ 4. **Fetch 1–2 backups** per subject when the first pick is uncertain — hosts 403, or the image turns out cluttered.
55
+
56
+ ## Where the image goes
57
+
58
+ - **As an overlay image card** — the usual choice for a B-roll insert synced to a beat. Author/point a `photo_card`-style JSX overlay at it and pass the local path via `props`. Load skill `overlay` and load skill `write-overlay`.
59
+ - **As a project asset** — add to `project.assets[]` (`{ id, type: "image", src, name }`) so it's tracked and reusable.
60
+
61
+ Always use the **local fetched path** (absolute), never the remote URL.
62
+
63
+ ## Worked example
64
+
65
+ ```
66
+ 1. Run step `search_images` with {query:"Elon Musk portrait 2025", provider:"web", limit:6}
67
+ 2. Eyeball results → pick a clean ≥1500px original
68
+ 3. Run step `fetch_image` with {url:"https://…/elon.jpg", out:"<project>/assets/elon_musk.jpg"}
69
+ 4. Read the downloaded file → confirm subject + no watermark/chyron
70
+ 5. Add an image-card overlay (props.src = the fetched path) → load skill `overlay`
71
+ ```
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: overlay
3
+ description: "Agent-authored workflow task: decide what overlays to write, author the JSX, and add them to the project's overlay track. Load this when you hit montaj/overlay in a workflow."
4
+ step: true
5
+ subskills: "write-overlay"
6
+ ---
7
+
8
+ # Overlay
9
+
10
+ `montaj/overlay` is an agent-authored task — no CLI step, no API call. You decide what overlays the video needs, write the JSX files, and add them to the project's visual tracks.
11
+
12
+ **Before writing any JSX, load skill `write-overlay`** — it contains the full JSX authoring reference (globals, `interpolate`/`spring` utilities, canvas rules, examples).
13
+
14
+ ## Sub-skills
15
+
16
+ | Name | When to load |
17
+ |------|--------------|
18
+ | `write-overlay` | Before writing any JSX overlay — globals, `interpolate`/`spring` utilities, canvas rules, examples. |
19
+ | `image-search` | When the prompt asks to source outside imagery (a photo of a person, a logo, an event/B-roll still) — find via `search_images` + download via `fetch_image`, then place as an image-card overlay. |
20
+
21
+ ---
22
+
23
+ ## Process
24
+
25
+ ### 1. Read the editing prompt and transcripts
26
+
27
+ The prompt tells you the tone and intent. The transcript tells you the moments worth annotating. Read both before deciding what to write.
28
+
29
+ ### 2. Decide what overlays to write
30
+
31
+ Ask: what does this video need that isn't already in the footage? Common answers:
32
+
33
+ - **Opening hook** (0–3s) — almost always right for social content. A punchy text statement that sells the video before the viewer decides to scroll.
34
+ - **Lower-thirds** — speaker name, context, stat callouts. Tied to specific transcript moments.
35
+ - **Logo/watermark** — if assets include a logo, add it as a persistent or bookend overlay.
36
+ - **Stat cards** — when the speaker cites a number ("33 million views"), a card reinforces it visually.
37
+ - **Image cards / B-roll stills** — when the speaker names a person, company, event, or thing the footage doesn't show ("Elon Musk", "the IPO", a specific welder), a real photo synced to that beat lands hard. **If the prompt asks you to source images** ("add a photo of X", "find images of the IPO"), load skill `image-search` to find them via `search_images` and download via `fetch_image`, then add each as an image-card overlay (pass the local fetched path via `props`).
38
+
39
+ If the prompt says "no overlays" — write nothing. Don't add an opening hook anyway.
40
+
41
+ ### Visual style defaults
42
+
43
+ **Plain text directly on video is almost always the right call.** Skip the card. Skip the frosted glass. Big, bold text sitting right on the footage is more dynamic and feels native — not slapped on top.
44
+
45
+ - **Go large** — 96–160px is a starting point, not a ceiling. If it looks a little too big, it's probably right. Small text gets scrolled past.
46
+ - **No backgrounds** — avoid dark cards, frosted panels, and semi-transparent boxes unless the prompt asks for them. A text shadow (`textShadow: '0 2px 16px rgba(0,0,0,0.9)'`) is enough to ensure legibility on any footage without boxing the text in.
47
+ - **Covering the face is fine** — text is more important than an unobstructed view of the speaker. Don't shrink or reposition text just to avoid the face.
48
+ - **Match the energy of the speech** — fast, punchy delivery gets tight entrance animations (4–6 frames). Slower, deliberate speech gets a smoother slide or fade (10–15 frames).
49
+ - **Use color sparingly** — one accent color maximum. White text with a colored word or icon reads better than multi-color text.
50
+ - **Avoid the bottom ~350px** — that's where captions render and where platform UI lives (TikTok progress bar, Instagram controls). Keep `bottom` values above 350px, or use `top`-anchored placement instead.
51
+ - **Avoid the right ~200px** — TikTok and Instagram stack action buttons (like, comment, share, follow) down the right edge. Don't push text or icons into that zone.
52
+
53
+ ### 3. Tie overlays to the transcript
54
+
55
+ Use word-level timings from the transcript JSON to sync overlays to speech. An overlay that appears when the speaker says the word it displays lands harder than one that floats at an arbitrary time.
56
+
57
+ ### 4. Write the JSX files
58
+
59
+ One JSX file per overlay component. Save to `overlays/<name>.jsx` in the project directory.
60
+
61
+ **There are no built-in templates.** Every overlay is custom JSX. Style it to match the editing prompt — a "dark, cinematic" prompt gets different typography than "energetic TikTok vibes."
62
+
63
+ See skill `write-overlay` for the full authoring reference.
64
+
65
+ ### 5. Save overlays to the project
66
+
67
+ Overlays live in `tracks[1+]` — overlay tracks in the unified tracks array. Each inner array is one track. Items in the same track cannot overlap in time; items in different tracks are z-ordered (higher indexes render on top). `tracks[0]` is always the primary footage track.
68
+
69
+ ```json
70
+ {
71
+ "tracks": [
72
+ [],
73
+ [
74
+ {
75
+ "id": "ov-0",
76
+ "type": "overlay",
77
+ "src": "/abs/path/to/project/overlays/hook.jsx",
78
+ "props": { "text": "The source code got leaked" },
79
+ "start": 0.0,
80
+ "end": 3.0
81
+ }
82
+ ]
83
+ ]
84
+ }
85
+ ```
86
+
87
+ For multiple non-overlapping overlays, add them to the same track. For simultaneous overlays at different z-levels, add them to separate tracks.
88
+
89
+ Follow save discipline: **read the project**, merge the updated `tracks` array into the fresh state, then **save the project (delta)**.
90
+
91
+ ## Rules
92
+
93
+ - **Use icons, not emojis** — `Ph.*` (Phosphor) or `FaIcon` with `FaSolid`/`FaBrands` (Font Awesome). Both are available as globals — no imports needed. Only use emojis if the prompt asks.
94
+ - **Always use absolute paths** for `src` — the render engine won't resolve relative paths
95
+ - **Don't overlap items at the same position** at the same time
96
+ - **To cover footage fully**, set `"opaque": true` on the item — the render engine removes transparency and lets the JSX root's CSS define the background. The audio track is unaffected.
97
+ - **Go large** — 96px+ for most text, 120–160px for hooks. Big text beats small text every time
98
+ - **No backgrounds by default** — plain text on video with a text shadow is the preferred style. Only use cards or panels when the prompt explicitly asks, or when legibility genuinely requires it
99
+ - **Covering the face is acceptable** — don't compromise text size or position to avoid the speaker
100
+ - **Keep text short** — 2–6 words for lower-thirds, 4–8 for hooks. Short + large beats long + small
101
+ - **Leave `offsetX`, `offsetY`, `scale` at defaults** (`0`, `0`, `1`) — the human positions overlays via the UI drag tool after preview
102
+ - **Use assets from `project.assets`** — pass asset `src` paths as `props`, don't hardcode paths inside JSX
103
+
104
+ ## Render Constraints
105
+
106
+ - Canvas is **1080 on the short edge** with the aspect ratio of `project.settings.resolution` (default `[1080, 1920]` portrait) — always, regardless of output resolution. The render pipeline captures overlay segments at design resolution (Puppeteer viewport = 1080-short-edge) and upscales to the final output resolution (e.g. 2× for 4K) at compose time. All sizing in JSX is authored for 1080-design coordinates.
107
+ - **Never apply `transform: translate` or `scale` to the root element** — these are applied by the pipeline at compose time. Applying them in JSX pushes content off-canvas.
108
+ - **Animations must complete before the overlay ends** — the last frame is held. If you fade out, opacity must reach 0 before the final frame. No mid-fade endings.
109
+ - **HDR output** — when the project's `settings.colorSpace` is `hdr_hlg` or `hdr_pq`, the pipeline encodes the final output as HEVC 10-bit `yuv420p10le` with bt2020 color metadata (transfer `arib-std-b67` for HLG or `smpte2084` for PQ). Overlay segments are composited into the project's working color space at compose time; no action required in JSX.
110
+ - **Split background from animated content** — never put `backdrop-filter: blur()` on a container whose children animate. It creates a GPU compositor layer that Chrome caches, producing stale/flashing frames in the rendered output. Put the frosted-glass card on its own lower track (where it can safely be cached — it's static), and put animated content on a higher track with no `backdrop-filter`. See skill `write-overlay` for the full split pattern and when to skip backdrop-filter entirely.
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: select-takes
3
+ description: "Agent-authored workflow task: analyze transcripts across all clips, pick ONE best take per script section, discard all others. Load this when you hit montaj/select_takes in a workflow."
4
+ step: true
5
+ ---
6
+
7
+ # Select Takes
8
+
9
+ `montaj/select_takes` is an agent-authored task — no CLI step, no API call. You reason across all clip transcripts and make editorial decisions. The output is a set of cropped trim specs ready for `rm_fillers` and `concat`.
10
+
11
+ ## Core Purpose
12
+
13
+ **Pick one. Kill the rest.**
14
+
15
+ Every repeated take of the same line is wasted runtime in the final video. Your job is to identify every section of the script, find all takes of that section across all clips, select the single best delivery, and discard everything else. If a section has three takes, two get cut entirely. If a clip is entirely a worse take of content covered better in another clip, that clip is dropped.
16
+
17
+ This is the only step in the pipeline with full cross-file awareness. Use it.
18
+
19
+ ## Process
20
+
21
+ ### 1. Read all transcripts
22
+
23
+ Read the SRT file for every clip from the preceding `transcribe` step. Read them all before making any decisions — the best take of a section may be in a different clip than you expect.
24
+
25
+ ### 2. Map the script
26
+
27
+ Lay out every distinct section of the intended script in narrative order. A "section" is a unit of content — a sentence, a thought, a beat. Name each one.
28
+
29
+ Example for a 5-clip set:
30
+ ```
31
+ A. Hook — "this is insane, the source code got leaked"
32
+ B. What happened — "at 3am someone posted, 33M views"
33
+ C. What was found — "tamagotchi, Kyros mode, dreaming"
34
+ D. Fallout — "copyright claims, repos taken down"
35
+ E. Resolution — "people rewrote in different languages, Boris said human error"
36
+ F. CTA — "go check it out, follow me"
37
+ ```
38
+
39
+ ### 3. Find all takes of each section
40
+
41
+ For each section, find every occurrence across all clips. A take is any transcript segment that covers that section's content — same words, same idea, same intent. Include:
42
+ - Complete takes
43
+ - False starts (partial delivery that stops mid-sentence)
44
+ - Repeated attempts (full delivery but not the best one)
45
+
46
+ ### 4. Pick the best take
47
+
48
+ For each section, select **one** take. Apply these criteria in order:
49
+
50
+ 1. **Complete over truncated** — a take that finishes the thought beats one that trails off
51
+ 2. **No mid-sentence restarts** — a take with no repeated phrases within it beats one that corrects itself
52
+ 3. **Clean delivery** — fewer filler words, less dead air within the take
53
+ 4. **Last attempt wins ties** — speakers improve with repetition; when two takes are equally clean, prefer the later one
54
+
55
+ **Do not hedge.** Pick one. If two takes are genuinely indistinguishable, pick the last one and move on.
56
+
57
+ ### 5. Scan each selected take for within-take repetition
58
+
59
+ After picking a take, re-read its SRT segments carefully. Look for the **same phrase (3+ words) appearing more than once** within the selected window — this is a mid-take stutter where the speaker restarted a clause without a long enough pause to be split into a separate take by `waveform_trim`.
60
+
61
+ For each repetition found:
62
+ 1. **Identify the repeated phrase** and all its occurrences in the SRT
63
+ 2. **Keep only the final occurrence** — the speaker lands the phrase correctly on the last attempt
64
+ 3. **Tighten the crop window** to start just before the final occurrence, discarding the earlier stumbles
65
+
66
+ Example: SRT shows `"and always on mode, and always on mode called Kyros that basically lets and always on mode called Kyros..."` — the speaker repeated "always on mode" three times. Crop the section start to just before the last clean attempt.
67
+
68
+ **This is a required check, not optional.** `rm_fillers` only removes um/uh/hmm; it will not catch repeated phrases. If you don't catch it here, it encodes into the final video.
69
+
70
+ ### 6. Determine the output order
71
+
72
+ Arrange the selected takes in narrative order. This may differ from the original clip order. A clip that contains section C might come before a clip that contains section B if that serves the story.
73
+
74
+ ### 7. Check every seam for narrative overlap
75
+
76
+ After ordering, read the **last sentence of section N** and the **first sentence of section N+1** for every adjacent pair. Flag any pair where:
77
+ - The same fact, event, or phrase is stated in both (e.g. hook ends "source code got leaked" → next section opens "they had leaked the entire source code")
78
+ - The same emotional beat lands twice in a row
79
+ - A setup at the end of N is answered by N itself, making the opening of N+1 redundant
80
+
81
+ For each flagged seam, fix it by trimming the crop window of whichever section is redundant — usually cutting the opening of N+1 forward to where it adds new information, or cutting the close of N back to where it hands off cleanly. Do not simply accept the overlap because both sections were independently "the best take."
82
+
83
+ **This check is required before writing any spec files.** Seam problems cannot be caught by any automated step downstream.
84
+
85
+ ### 9. Crop the trim specs — do NOT call `trim`
86
+
87
+ For each selected take, load the trim spec JSON produced by the preceding `waveform_trim` step for that clip. Crop it to the selected take's virtual-timeline window using the `crop_spec` step.
88
+
89
+ **Never call the `trim` step.** That encodes an intermediate video file and breaks the single-encode chain. Cropping the spec keeps the original source file all the way through to `concat`.
90
+
91
+ Run step `crop_spec` with `{"input": "/path/IMG_4893_spec.json", "keeps": [[8.5, 34.1]]}` → returns `{"path": "/path/IMG_4893_spec_cropped.json"}` (single window).
92
+
93
+ Run step `crop_spec` with `{"input": "/path/IMG_4893_spec.json", "keeps": [[0, 2.4], [13.84, 18.33]]}` → returns `{"path": "/path/IMG_4893_spec_cropped.json"}` (multiple windows — skip rejected content in between).
94
+
95
+ Run step `crop_spec` with `{"input": "/path/IMG_4893_spec.json", "keeps": [[40.28, null]]}` (open-ended: keep from virtual 40.28s to end of clip).
96
+
97
+ The `keeps` field is a **native JSON array** of `[start, end]` pairs — not a string. Use `null` for an open-ended window.
98
+
99
+ **Important:** the timestamps you pass to `crop_spec` are **virtual-timeline timestamps** — time within the waveform_trim spec's kept audio, not original-file timestamps. If your reference points come from an SRT transcript (which uses original-file timestamps), use `virtual_to_original --inverse` to convert them first (see below).
100
+
101
+ Write each cropped spec to `<original>_selected.json` by saving the path returned by the step.
102
+
103
+ ### 9a. Timestamps — SRT is already virtual; use virtual_to_original for seam debugging only
104
+
105
+ **SRT timestamps are virtual-timeline timestamps.** The `transcribe` step runs on the extracted audio (the waveform_trim keeps played back-to-back), so its timestamps are relative to that extracted audio — i.e., the virtual timeline. Pass them directly to `crop_spec` without any conversion.
106
+
107
+ SRT shows the best take at 8.5s–34.1s → run step `crop_spec` with `{"input": "/path/IMG_4893_spec.json", "keeps": [[8.5, 34.1]]}` and pass the result directly.
108
+
109
+ `virtual_to_original` is a **debugging tool**, not a conversion step in the normal workflow. Use it when you need to verify that a virtual timestamp maps to the right spot in the original file — for example, to check why a cut looks off:
110
+
111
+ Run step `virtual_to_original` with `{"input": "spec.json", "verbose": true, "timestamp": 47.32}` → returns something like `47.32 → 95.483 (keep 10: [93.295, 96.166])`.
112
+
113
+ The `inverse` option goes the other direction (original-file → virtual). Use it when you have an original-file timestamp from somewhere else (e.g., ffprobe output, manual note) and need to know where it falls in the virtual timeline:
114
+
115
+ Run step `virtual_to_original` with `{"input": "spec.json", "inverse": true, "timestamp": 95.483}` → returns `47.320`.
116
+
117
+ ### 10. Output
118
+
119
+ An ordered list of `_selected.json` trim spec paths — one per selected section, in narrative order. These become the inputs to `rm_fillers` and ultimately `concat`.
120
+
121
+ ## What to Log
122
+
123
+ Before writing specs, log your decisions clearly:
124
+
125
+ ```
126
+ select_takes decisions:
127
+ A. Hook → IMG_4891 0–18s (only take, clean delivery)
128
+ B. What happened → IMG_4893 0–7.5s (first clean take; second at 22s is identical but trails off)
129
+ C. What was found → IMG_4893 42.5–66.8s (THIRD take — first two at 26s and 34s cut off before "Kyros")
130
+ D. Fallout → IMG_4894 0–18.4s (only take)
131
+ E. Resolution → IMG_4895 8.5–34.1s (cleaner pivot take + Boris statement; dropping filler at 34–44s)
132
+ F. CTA → IMG_4896 12.9–18.9s (FOURTH take — first three are false starts)
133
+
134
+ Dropped entirely: IMG_4893 0–42s (repeated takes of B and C), IMG_4895 44–60s (trailing filler), IMG_4896 0–12.9s (false starts)
135
+ ```
136
+
137
+ ## Common Mistakes
138
+
139
+ **Too conservative — the most common failure.** Keeping a wide window like `0–66s` because it "contains the best take" is wrong. It also contains two rejected takes. Crop to the specific take only.
140
+
141
+ **Not reading all clips before deciding.** The best take of section C might be in clip 4, not clip 2. Read everything first.
142
+
143
+ **Keeping false starts.** A false start is not content. If a speaker says "so the — actually let me start over — the tweet got..." cut before the restart.
144
+
145
+ **Keeping the outro filler.** Clips often end with trailing "so yeah", "anyway", "alright" after the real content. Cut at the end of the last meaningful sentence.
146
+
147
+ **Missing within-take phrase repetition.** Even after picking the best take, the speaker may have stumbled and repeated a clause mid-sentence — no automated step catches this. You must read the SRT for every selected take and crop out earlier occurrences of any repeated phrase. Choosing the "best" take is not enough if that take still contains an internal stutter.
148
+
149
+ **Skipping the seam check.** Each section is picked independently, but the seams are where edits fall apart. A hook that ends "the source code got leaked" followed by an opener that says "they had leaked the entire source code" is the same beat twice — no automated step catches cross-section redundancy. Always read adjacent section boundaries as a pair before writing specs.
@@ -0,0 +1,611 @@
1
+ ---
2
+ name: write-overlay
3
+ description: "Write a custom JSX overlay component and add it to the project's overlay track."
4
+ ---
5
+
6
+ # Write Overlay
7
+
8
+ 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.
9
+
10
+ ---
11
+
12
+ ## Execution context
13
+
14
+ Custom overlay JSX runs in a sandboxed evaluator. All identifiers below are injected as globals:
15
+
16
+ | Identifier | Type | Description |
17
+ |------------|------|-------------|
18
+ | `frame` | number | Current frame number (0 → duration-1). Drives all animation. |
19
+ | `fps` | number | Output frame rate |
20
+ | `duration` | number | Total frames this overlay is visible for |
21
+ | `props` | object | The `props` object from the project.json item |
22
+ | `interpolate` | function | Map a frame number to any output value |
23
+ | `spring` | function | Physics-based easing (0 → 1) |
24
+ | `Ph` | object | All [Phosphor Icons](https://phosphoricons.com) — e.g. `Ph.House`, `Ph.ArrowRight` |
25
+ | `FaIcon` | component | `FontAwesomeIcon` renderer — use with `FaSolid` / `FaBrands` icon objects |
26
+ | `FaSolid` | object | All [FA Free Solid](https://fontawesome.com/icons?s=solid) icon objects — e.g. `FaSolid.faHouse` |
27
+ | `FaBrands` | object | All [FA Free Brands](https://fontawesome.com/icons?s=brands) icons — e.g. `FaBrands.faGithub` |
28
+ | `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. |
29
+ | `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. |
30
+ | `useThreeFrame` | hook | Bridges r3f to Montaj's frame-stepped renderer. Mount exactly once inside any `<Canvas>`. |
31
+
32
+ **No imports.** All `import` statements are stripped before evaluation. Do not import anything — use the globals above instead.
33
+
34
+ ### Top-level vs component-body
35
+
36
+ **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.
37
+
38
+ ```jsx
39
+ // WRONG — crashes at render time
40
+ const opacity = interpolate(frame, [0, 10], [0, 1])
41
+ export default function Hook() { ... }
42
+
43
+ // CORRECT — inside the component, runs each frame
44
+ export default function Hook() {
45
+ const opacity = interpolate(frame, [0, 10], [0, 1])
46
+ return <div style={{ opacity }}>...</div>
47
+ }
48
+ ```
49
+
50
+ 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:
51
+
52
+ ```jsx
53
+ // Fine — interpolate is only called when the function is invoked (inside the component)
54
+ const itemStyle = (show) => ({
55
+ opacity: show,
56
+ transform: `translateY(${interpolate(show, [0, 1], [20, 0])}px)`,
57
+ })
58
+
59
+ export default function List() {
60
+ const show = spring({ frame, fps, stiffness: 300, damping: 24 })
61
+ return <div style={itemStyle(show)}>...</div>
62
+ }
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Writing the JSX
68
+
69
+ > **Carousel text overlays follow a stricter contract.** For carousel projects, 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`. The "go large — for video" guidance below, and the hardcoded-style style of the `Hook` example, **do not apply** to carousel editable-text overlays.
70
+
71
+ The default aesthetic is **plain bold text directly on video** — no card, no background, just a text shadow for legibility. Big text (96–160px) that covers the footage, including the speaker's face if needed.
72
+
73
+ ```jsx
74
+ // overlays/hook.jsx — plain text on video, no background
75
+
76
+ export default function Hook() {
77
+ const progress = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' })
78
+ const slideY = interpolate(frame, [0, 10], [40, 0], { extrapolateRight: 'clamp' })
79
+
80
+ return (
81
+ <div style={{
82
+ position: 'absolute', bottom: 180, left: 48, right: 48,
83
+ opacity: progress,
84
+ transform: `translateY(${slideY}px)`,
85
+ }}>
86
+ <div style={{
87
+ fontFamily: 'Anton, Impact, sans-serif', fontSize: 120, fontWeight: 900,
88
+ color: '#fff', lineHeight: 1.05, letterSpacing: '-1px',
89
+ textShadow: '0 2px 24px rgba(0,0,0,0.9), 0 0 60px rgba(0,0,0,0.5)',
90
+ textTransform: 'uppercase',
91
+ }}>
92
+ {props.text}
93
+ </div>
94
+ </div>
95
+ )
96
+ }
97
+ ```
98
+
99
+ Only add a card or background when the prompt explicitly asks, or when a specific overlay type genuinely requires it (e.g. a logo lockup, an opaque title card). When you do need a background, prefer a solid semi-transparent color over `backdropFilter: blur()` — see the track-splitting section below.
100
+
101
+ ### Rules
102
+
103
+ - **Default export only** — the evaluator imports the default export
104
+ - **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.
105
+ - **Frame-driven** — all animation must derive from `frame`. No `setTimeout`, `setInterval`, CSS `animation`, or `transition`.
106
+ - **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.
107
+ - **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.
108
+ - **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 upscales to the final video dimensions at compose time. 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.
109
+ - **No side effects** — no API calls, no filesystem access, no global state mutations.
110
+ - **`backdropFilter` caution** — `backdrop-filter: blur(...)` causes Chrome to create a separate GPU compositor layer that can be cached and replayed as a stale frame during rendering. Avoid putting `backdrop-filter` on any element whose children animate — the blur container will flash or freeze. See the track-splitting guidance below.
111
+
112
+ ---
113
+
114
+ ## Splitting background from content across tracks
115
+
116
+ The most reliable way to use frosted-glass / blurred card backgrounds is to **put the background on a separate, lower track** and the animated content on a higher track. The render pipeline composites tracks in order, so the content renders on top.
117
+
118
+ **Why this works:** A background card with `backdrop-filter` is essentially static — it fades in, then stays put. When Chrome's headless compositor caches the GPU layer for it, the cache is *correct* (the layer genuinely hasn't changed). The content overlay on the higher track has no `backdrop-filter`, so there's no caching issue and animations render cleanly every frame.
119
+
120
+ **When to split:**
121
+
122
+ | Background behavior | Animated content | Verdict |
123
+ |---------------------|-----------------|---------|
124
+ | Static or simple fade only | Any — text, icons, logos staggering in | **Split** |
125
+ | Shakes, bounces, or translates together with content | Content must move with the background | **Keep together** (no backdrop-filter, use solid `background` instead) |
126
+
127
+ **How to split in project.json:**
128
+
129
+ ```json
130
+ {
131
+ "tracks": [
132
+ [],
133
+ [
134
+ {
135
+ "id": "ov-card-bg",
136
+ "type": "overlay",
137
+ "src": "/path/overlays/card-bg.jsx",
138
+ "start": 2.0,
139
+ "end": 6.0
140
+ }
141
+ ],
142
+ [
143
+ {
144
+ "id": "ov-card-content",
145
+ "type": "overlay",
146
+ "src": "/path/overlays/card-content.jsx",
147
+ "start": 2.0,
148
+ "end": 6.0
149
+ }
150
+ ]
151
+ ]
152
+ }
153
+ ```
154
+
155
+ **Background component — no animated children:**
156
+
157
+ ```jsx
158
+ // overlays/card-bg.jsx
159
+ // Just a frosted card that fades in. No children that animate opacity.
160
+ const opacity = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' })
161
+
162
+ export default function CardBg() {
163
+ return (
164
+ <div style={{ position: 'absolute', bottom: 340, left: 0, right: 0, display: 'flex', justifyContent: 'center', opacity }}>
165
+ <div style={{
166
+ background: 'rgba(0,0,0,0.84)',
167
+ backdropFilter: 'blur(24px)',
168
+ borderRadius: 36,
169
+ padding: '44px 72px',
170
+ border: '1px solid rgba(255,255,255,0.10)',
171
+ minWidth: 560,
172
+ minHeight: 200,
173
+ }} />
174
+ </div>
175
+ )
176
+ }
177
+ ```
178
+
179
+ **Content component — no backdrop-filter:**
180
+
181
+ ```jsx
182
+ // overlays/card-content.jsx
183
+ // Animated items rendered on top of the background card.
184
+ const s1 = spring({ frame: Math.max(0, frame - 4), fps, stiffness: 300, damping: 24 })
185
+
186
+ export default function CardContent() {
187
+ return (
188
+ <div style={{ position: 'absolute', bottom: 340, left: 0, right: 0, display: 'flex', justifyContent: 'center' }}>
189
+ <div style={{ padding: '44px 72px', minWidth: 560 }}>
190
+ <div style={{ opacity: Math.min(1, s1 * 2.5), transform: `translateX(${interpolate(s1, [0, 1], [-24, 0])}px)` }}>
191
+ <Ph.CheckCircle size={52} weight="fill" color="#34d399" />
192
+ </div>
193
+ </div>
194
+ </div>
195
+ )
196
+ }
197
+ ```
198
+
199
+ **When you can't split** (background and content animate together as one unit — e.g., a card that shakes on impact), skip `backdrop-filter` entirely and use a solid or semi-transparent `background` instead:
200
+
201
+ ```jsx
202
+ // Instead of backdropFilter: 'blur(24px)'
203
+ background: 'rgba(10,10,10,0.88)' // solid dark — visually similar, no GPU layer caching
204
+ ```
205
+
206
+ ---
207
+
208
+ ## Utilities
209
+
210
+ ### `interpolate(frame, inputRange, outputRange, options?)`
211
+
212
+ Maps a frame number to any output value. Clamps at both ends by default.
213
+
214
+ ```jsx
215
+ // Fade in over frames 0–15
216
+ const opacity = interpolate(frame, [0, 15], [0, 1])
217
+
218
+ // Fade in then out
219
+ const fadeIn = interpolate(frame, [0, 15], [0, 1])
220
+ const fadeOut = interpolate(frame, [duration - 15, duration], [1, 0])
221
+ const opacity = Math.min(fadeIn, fadeOut)
222
+
223
+ // Slide in from left
224
+ const x = interpolate(frame, [0, 20], [-200, 0], { extrapolateRight: 'clamp' })
225
+ ```
226
+
227
+ Options: `extrapolate`, `extrapolateLeft`, `extrapolateRight` — each `'clamp'` (default) or `'extend'`.
228
+
229
+ ### `spring({ frame, fps, mass?, stiffness?, damping?, initialVelocity? })`
230
+
231
+ Returns a 0 → 1 value following spring physics. Overshoots and settles naturally.
232
+
233
+ ```jsx
234
+ const scale = spring({ frame, fps, stiffness: 120, damping: 14 })
235
+ // transform: `scale(${scale})`
236
+ ```
237
+
238
+ Defaults: `mass: 1`, `stiffness: 100`, `damping: 10`.
239
+
240
+ ---
241
+
242
+ ## Icons
243
+
244
+ Use icons instead of emojis unless the prompt explicitly asks for emojis. Icons scale cleanly, render crisply at any resolution, and look intentional.
245
+
246
+ ### Phosphor Icons — `Ph`
247
+
248
+ Browse at [phosphoricons.com](https://phosphoricons.com). Over 9000 icons, six weights: `regular` (default), `bold`, `fill`, `duotone`, `light`, `thin`.
249
+
250
+ ```jsx
251
+ // Basic usage
252
+ <Ph.House size={48} color="white" />
253
+
254
+ // With weight
255
+ <Ph.ArrowRight size={32} color="#a78bfa" weight="bold" />
256
+ <Ph.Star size={40} color="#fbbf24" weight="fill" />
257
+
258
+ // In a card row
259
+ <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
260
+ <Ph.CheckCircle size={36} color="#34d399" weight="fill" />
261
+ <span style={{ fontFamily: 'Inter, sans-serif', fontSize: 24, color: 'white' }}>Feature unlocked</span>
262
+ </div>
263
+ ```
264
+
265
+ ### Font Awesome — `FaIcon` + `FaSolid` / `FaBrands`
266
+
267
+ `FaIcon` is the renderer. `FaSolid` has general-purpose icons; `FaBrands` has logos (GitHub, YouTube, X, etc.).
268
+
269
+ ```jsx
270
+ // Solid icon
271
+ <FaIcon icon={FaSolid.faCode} style={{ fontSize: 48, color: 'white' }} />
272
+
273
+ // Brand logo
274
+ <FaIcon icon={FaBrands.faGithub} style={{ fontSize: 48, color: 'white' }} />
275
+
276
+ // Sized via style
277
+ <FaIcon icon={FaSolid.faBolt} style={{ fontSize: 36, color: '#fbbf24' }} />
278
+ ```
279
+
280
+ ### Which library to use
281
+
282
+ - **Phosphor** — preferred for most overlays. Cleaner API, consistent stroke weight, large set.
283
+ - **Font Awesome Brands** — when you need a specific brand logo (GitHub, YouTube, X/Twitter, TikTok, etc.)
284
+ - **Font Awesome Solid** — for any icon Phosphor doesn't cover.
285
+
286
+ ---
287
+
288
+ ## Custom fonts (Google Fonts)
289
+
290
+ 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.
291
+
292
+ To use a Google Font, declare it on the overlay item in `project.json` with a `googleFonts` array:
293
+
294
+ ```json
295
+ {
296
+ "id": "ov-hook",
297
+ "type": "overlay",
298
+ "src": "/path/to/overlays/hook.jsx",
299
+ "start": 0.0,
300
+ "end": 5.0,
301
+ "googleFonts": ["Anton", "Playfair+Display:ital@1"]
302
+ }
303
+ ```
304
+
305
+ 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.
306
+
307
+ **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:
308
+
309
+ ```jsx
310
+ // In your JSX — just use the family name, no @import
311
+ fontFamily: '"Anton", Impact, sans-serif'
312
+ fontFamily: '"Playfair Display", Georgia, serif'
313
+ ```
314
+
315
+ **Format:** `FamilyName` for regular, `FamilyName:ital@1` for italic, `FamilyName:wght@700` for a specific weight. Each family is a separate array entry.
316
+
317
+ **System font fallbacks for common Google Fonts:**
318
+
319
+ | Google Font | System fallback |
320
+ |-------------|----------------|
321
+ | Anton | Impact |
322
+ | Playfair Display | Georgia |
323
+ | Oswald | Arial Narrow |
324
+ | Roboto / Inter | system-ui, sans-serif |
325
+
326
+ If visual fidelity isn't critical, the system fallback avoids the network fetch entirely.
327
+
328
+ ---
329
+
330
+ ## 3D / Three.js (react-three-fiber)
331
+
332
+ 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.
333
+
334
+ **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.
335
+
336
+ ### Two non-negotiable rules
337
+
338
+ 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.
339
+ 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.
340
+
341
+ Convention: put a tiny `<FrameBridge />` child component at the top of the Canvas that calls `useThreeFrame()` and returns `null`.
342
+
343
+ ### Drive everything from `frame`, never `useFrame`
344
+
345
+ 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:
346
+
347
+ ```jsx
348
+ export default function ThreeCube() {
349
+ const t = frame / fps
350
+ const rotX = t * Math.PI // half-turn per second
351
+ const rotY = t * Math.PI * 0.7
352
+ const pulse = 1 + 0.08 * Math.sin(t * 4)
353
+
354
+ return (
355
+ <Canvas
356
+ frameloop="never"
357
+ style={{ position: 'absolute', inset: 0 }}
358
+ camera={{ position: [0, 0, 5], fov: 50 }}
359
+ gl={{ preserveDrawingBuffer: true, antialias: true, alpha: true }}
360
+ >
361
+ <FrameBridge />
362
+ <ambientLight intensity={0.6} />
363
+ <directionalLight position={[5, 5, 5]} intensity={1.2} />
364
+ <mesh rotation={[rotX, rotY, 0]} scale={[pulse, pulse, pulse]}>
365
+ <boxGeometry args={[1.6, 1.6, 1.6]} />
366
+ <meshStandardMaterial color="#3b82f6" metalness={0.3} roughness={0.35} />
367
+ </mesh>
368
+ </Canvas>
369
+ )
370
+ }
371
+
372
+ function FrameBridge() {
373
+ useThreeFrame()
374
+ return null
375
+ }
376
+ ```
377
+
378
+ ### No async assets
379
+
380
+ 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:
381
+
382
+ - Primitive geometries: `<boxGeometry>`, `<sphereGeometry>`, `<planeGeometry>`, `<cylinderGeometry>`, `<torusGeometry>`, `<icosahedronGeometry>`, etc.
383
+ - `<meshStandardMaterial>` / `<meshBasicMaterial>` with `color` (no `map`, `normalMap`, etc.)
384
+ - Lights: `<ambientLight>`, `<directionalLight>`, `<pointLight>`, `<spotLight>`
385
+ - Math helpers from the `THREE` global (`THREE.MathUtils.lerp`, `THREE.Vector3`, etc.)
386
+
387
+ If you need a texture, render it on a 2D HTML overlay layered above the Canvas track instead.
388
+
389
+ ### Canvas placement and compositing
390
+
391
+ `<Canvas>` fills its parent. Two patterns work:
392
+
393
+ **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).
394
+
395
+ **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.
396
+
397
+ ```jsx
398
+ <div style={{ position: 'absolute', top: 1100, left: 0, right: 0, height: 500 }}>
399
+ <Canvas frameloop="never" style={{ width: '100%', height: '100%' }} ...>
400
+ <FrameBridge />
401
+ ...
402
+ </Canvas>
403
+ </div>
404
+ ```
405
+
406
+ 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.
407
+
408
+ 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.
409
+
410
+ ### Worked reference
411
+
412
+ 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.
413
+
414
+ ### Bundle weight
415
+
416
+ 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.
417
+
418
+ ---
419
+
420
+ ## Charts / Recharts
421
+
422
+ 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`.
423
+
424
+ ### Carousel-only (for now)
425
+
426
+ 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.
427
+
428
+ ### Non-negotiable rule: disable animations
429
+
430
+ 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.
431
+
432
+ ### Size charts explicitly — don't use `<ResponsiveContainer>`
433
+
434
+ `<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} ...>`.
435
+
436
+ ### Stick to bar / line / pie for v1
437
+
438
+ 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.
439
+
440
+ ---
441
+
442
+ ## project.json item shape
443
+
444
+ 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`.
445
+
446
+ ```json
447
+ {
448
+ "tracks": [
449
+ [],
450
+ [
451
+ {
452
+ "id": "ov-hook",
453
+ "type": "overlay",
454
+ "src": "/abs/path/to/project/overlays/hook.jsx",
455
+ "start": 0.0,
456
+ "end": 3.0,
457
+ "props": {
458
+ "text": "She built an AI employee"
459
+ }
460
+ },
461
+ {
462
+ "id": "ov-logo",
463
+ "type": "overlay",
464
+ "src": "/abs/path/to/project/overlays/logo.jsx",
465
+ "start": 0.0,
466
+ "end": 999.0,
467
+ "props": {
468
+ "logoSrc": "/abs/path/to/project/assets/logo.png"
469
+ }
470
+ }
471
+ ]
472
+ ]
473
+ }
474
+ ```
475
+
476
+ | Field | Required | Description |
477
+ |-------|----------|-------------|
478
+ | `id` | yes | Unique identifier within the track |
479
+ | `type` | yes | Always `"overlay"` for JSX overlays |
480
+ | `src` | yes | Absolute path to the JSX file |
481
+ | `start` | yes | Start time in output video (seconds) |
482
+ | `end` | yes | End time in output video (seconds) |
483
+ | `props` | no | Arbitrary data passed through to the component as the `props` global |
484
+ | `googleFonts` | no | Google Font families to load before render (e.g. `["Anton", "Playfair+Display:ital@1"]`). See Custom fonts section. |
485
+
486
+ **Use absolute paths for `src`.** Relative paths are resolved from `project.json` location, but absolute paths are unambiguous.
487
+
488
+ ---
489
+
490
+ ## Using assets
491
+
492
+ Assets (logos, images) are declared in `project.assets`. Reference them by passing their `src` path in `props`, then use it in the component:
493
+
494
+ ```json
495
+ {
496
+ "id": "ov-logo",
497
+ "type": "overlay",
498
+ "src": "/path/to/overlays/logo.jsx",
499
+ "start": 0.0,
500
+ "end": 30.0,
501
+ "props": { "src": "/path/to/assets/logo.png" }
502
+ }
503
+ ```
504
+
505
+ ```jsx
506
+ // overlays/logo.jsx
507
+ const opacity = interpolate(frame, [0, 6], [0, 1])
508
+
509
+ export default function Logo() {
510
+ return (
511
+ <img
512
+ src={props.src}
513
+ style={{
514
+ position: 'absolute', top: 40, right: 40,
515
+ width: 80, opacity,
516
+ }}
517
+ />
518
+ )
519
+ }
520
+ ```
521
+
522
+ 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.
523
+
524
+ ---
525
+
526
+ ## Live preview in the Overlays tab
527
+
528
+ The Montaj UI has an **Overlays** tab that gives a real-time animated preview of every overlay in the project — no render needed.
529
+
530
+ - Open the **Overlays** tab and select a project from the left panel to see its overlay list
531
+ - Select any overlay item to see it playing over the preview image at full animation fidelity
532
+ - **Live reload** — the preview automatically recompiles and restarts whenever you save a `.jsx` file; latency is typically under a second
533
+ - Use the **⏸ / ▶** button in the bottom-right of the preview to pause on a specific frame
534
+ - 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
535
+
536
+ Use this tab to validate motion, timing, and asset rendering before committing to a full render.
537
+
538
+ ---
539
+
540
+ ## Writing multiple overlays in parallel
541
+
542
+ When a workflow calls for several overlays, write them concurrently — each JSX file is independent.
543
+
544
+ 1. Identify all overlays needed from the editing prompt and transcript
545
+ 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.
546
+ 3. **Sample them all in one batch pass at the end** (see "Verify your overlays fit the canvas")
547
+ 4. Fix any overflow, then save the project (delta) adding all items to the overlay track in one update
548
+
549
+ Common overlay set for a social reel:
550
+ - Opening hook (0–3s) — text statement that earns the watch
551
+ - Lower third (first speech moment) — speaker handle or title
552
+ - CTA (final 3s) — follow / subscribe / link
553
+
554
+ ---
555
+
556
+ ## Authoring guidelines
557
+
558
+ - **Use icons, not emojis** — use `Ph.*` or `FaIcon` for visual symbols. Emojis render inconsistently across platforms and look low-effort. Only use emojis if the prompt explicitly asks for them.
559
+ - **Go large — for video.** On 1080×1920 video overlays carrying a short, glanceable hook (3–6 words) over moving footage, 96px is the floor, not the ceiling. 120–160px for hooks. Text should feel oversized; if it looks a little too big, it's probably right. This rule is calibrated to video viewing — a thumb-stop on TikTok/Reels. **Do NOT apply this rule 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.
560
+ - **No backgrounds by default** — plain text on video with `textShadow` for legibility is the house style. No dark cards, no frosted glass, no semi-transparent boxes unless the prompt asks. A well-placed `textShadow` works on any footage.
561
+ - **Cover the face if needed** — text position and size take priority. Don't shrink or reposition to avoid the speaker.
562
+ - **Tie to transcript** — use word timings from the transcript to sync text appearance with speech. An overlay that appears exactly when the speaker says the word it displays lands much harder.
563
+ - **Match the energy of the speech** — fast, punchy delivery: 4–6 frame entrances. Slower delivery: 10–15 frame fades or slides.
564
+ - **Short text** — 2–6 words for lower-thirds, 4–8 for hooks. Short + large beats long + small.
565
+ - **One accent color max** — white text with one colored word or icon. Multi-color text reads as noise.
566
+ - **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.
567
+ - **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.
568
+ - **Don't overlap** — avoid two overlays occupying the same screen region at the same time
569
+ - **Style to the prompt** — match font weight, color, and motion to the tone of the edit
570
+ - **Opening hook** — almost always appropriate for social content; fires in the first 0–3s
571
+ - **Persist after writing** — save the project (delta) with the new overlay items added to the track
572
+
573
+ ---
574
+
575
+ ## Verify your overlays fit the canvas — one sample pass at the end
576
+
577
+ **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.
578
+
579
+ Once every JSX file is written, loop over them in one pass — for each JSX file, **run step `sample_overlay`** with args `{ path, measure: true, googleFonts, props }`:
580
+
581
+ 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.
582
+
583
+ The step renders the overlay through the same Puppeteer path the production renderer uses and returns:
584
+
585
+ ```json
586
+ {
587
+ "pngPath": "/tmp/sample-check.png",
588
+ "measurements": {
589
+ "anyOverflow": false,
590
+ "texts": [...],
591
+ "viewport": { "w": 1080, "h": 1920 }
592
+ }
593
+ }
594
+ ```
595
+
596
+ **`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.
597
+
598
+ **Two categories of false positives to rule out before fixing:**
599
+
600
+ - **`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.
601
+ - **`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.
602
+
603
+ **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.
604
+
605
+ ### The Syne overflow case study — why the editor preview cannot tell you
606
+
607
+ 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.
608
+
609
+ 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.
610
+
611
+ **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.