@bycrux/montaj-skills 0.1.0 → 0.3.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/contract.md CHANGED
@@ -42,9 +42,11 @@ Never save from a stale cached body. The operator may be editing the project con
42
42
 
43
43
  These verbs are sufficient for the Phase-1 domain skills:
44
44
 
45
- - **select-takes** — run step `crop_spec`, run step `virtual_to_original`; read the project.
45
+ - **select-takes** — run step `crop_spec`, run step `virtual_to_original`; read the project; write a file (the trim spec comes back inline from `waveform_trim` and must be on disk before `crop_spec` can take it as `--input`).
46
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.
47
+ - **write-overlay** — save the project (delta); write a file (the JSX); run step `sample_overlay` (the end-of-authoring measure pass, required for any overlay that declares `googleFonts`); reference asset paths via written/read files.
48
48
  - **image-search** — run step `search_images`, run step `fetch_image`; write a file / read a file.
49
49
 
50
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.
51
+
52
+ **This list is a starting index, not the final word.** It has drifted from the skill bodies before: `select-takes`'s "write a file" and `write-overlay`'s `sample_overlay` were both required by their skills and missing here. Anyone deriving the operations a workflow needs must confirm against the skill body itself — a set derived from this list alone can silently omit something the skill genuinely requires.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/montaj-skills",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "files": [
@@ -44,6 +44,10 @@ Run step `fetch_image` with the following args. Downloads one HTTPS URL to a wor
44
44
 
45
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
46
 
47
+ > To register a fetched image into Hub Media (R2-backed, survives in the library), call
48
+ > `hub.ingest_media({ projectId, items: [{ workspacePath: "<absolute fetch_image out path>" }] })`.
49
+ > Do not hand-roll `create_media` + presigned PUT.
50
+
47
51
  ## Picking the right image
48
52
 
49
53
  Search returns more than you need. Before committing:
package/skills/overlay.md CHANGED
@@ -64,22 +64,25 @@ See skill `write-overlay` for the full authoring reference.
64
64
 
65
65
  ### 5. Save overlays to the project
66
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.
67
+ Overlays live in `tracks[1+]` — overlay tracks in the unified tracks array. Each track is an object (`{id, items, ...}`); its `items` array holds the track's clips/overlays. 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
68
 
69
69
  ```json
70
70
  {
71
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
- ]
72
+ { "id": "trk-0", "items": [] },
73
+ {
74
+ "id": "trk-1",
75
+ "items": [
76
+ {
77
+ "id": "ov-0",
78
+ "type": "overlay",
79
+ "src": "/abs/path/to/project/overlays/hook.jsx",
80
+ "props": { "text": "The source code got leaked" },
81
+ "start": 0.0,
82
+ "end": 3.0
83
+ }
84
+ ]
85
+ }
83
86
  ]
84
87
  }
85
88
  ```
@@ -88,6 +91,8 @@ For multiple non-overlapping overlays, add them to the same track. For simultane
88
91
 
89
92
  Follow save discipline: **read the project**, merge the updated `tracks` array into the fresh state, then **save the project (delta)**.
90
93
 
94
+ When this is the last editorial pass before the render, the project must be `final` before the render will run — see skill `native` → "Project lifecycle — status, and the render gate".
95
+
91
96
  ## Rules
92
97
 
93
98
  - **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.
@@ -100,6 +105,7 @@ Follow save discipline: **read the project**, merge the updated `tracks` array i
100
105
  - **Keep text short** — 2–6 words for lower-thirds, 4–8 for hooks. Short + large beats long + small
101
106
  - **Leave `offsetX`, `offsetY`, `scale` at defaults** (`0`, `0`, `1`) — the human positions overlays via the UI drag tool after preview
102
107
  - **Use assets from `project.assets`** — pass asset `src` paths as `props`, don't hardcode paths inside JSX
108
+ - **Expose text styling as props** — a text overlay you want editable in the editor's properties panel must READ its font, size, weight, style, color, alignment, transform, and background from `props` (the nine standard text props) with sensible defaults, not hardcode them in the JSX. A hardcoded style shows no control in the panel. See skill `write-overlay` → "Make text overlays editable in the properties panel"
103
109
 
104
110
  ## Render Constraints
105
111
 
@@ -6,7 +6,7 @@ step: true
6
6
 
7
7
  # Select Takes
8
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`.
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`. Nothing joins them into a file: the surviving keeps become `tracks[0]` items with their own `inPoint`/`outPoint`, and the render engine assembles them in one pass at the end.
10
10
 
11
11
  ## Core Purpose
12
12
 
@@ -82,11 +82,15 @@ For each flagged seam, fix it by trimming the crop window of whichever section i
82
82
 
83
83
  **This check is required before writing any spec files.** Seam problems cannot be caught by any automated step downstream.
84
84
 
85
- ### 9. Crop the trim specs — do NOT call `trim`
85
+ ### 9. Crop the trim specs — never encode an intermediate
86
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.
87
+ For each selected take, the trim spec JSON comes back **inline** (on stdout / in the step result) from the preceding `waveform_trim` step — it is NOT written to disk automatically. The `crop_spec` step requires an on-disk file (its `--input` argument). Before calling `crop_spec`, write the inline spec to the project scratch dir using the `write_file` tool (e.g. save it as `<clip>_spec.json`), then pass that path as `crop_spec`'s `input`.
88
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`.
89
+ Crop the written spec to the selected take's virtual-timeline window using the `crop_spec` step.
90
+
91
+ **Never encode an intermediate video file here.** There is no `trim` step to call, and reaching for `materialize_cut` at this point would break the single-encode chain for no gain. Cropping the spec keeps `tracks[0].items[*].src` pointing at the original source all the way through to the final render, which is what lets the operator re-trim any cut later without losing quality — and what keeps each item's `proxySrc` valid, since a proxy covers the original file.
92
+
93
+ **Note:** the `input` path in all `crop_spec` calls below must point to a spec file you've written to disk via `write_file` first (see above).
90
94
 
91
95
  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
96
 
@@ -116,7 +120,9 @@ Run step `virtual_to_original` with `{"input": "spec.json", "inverse": true, "ti
116
120
 
117
121
  ### 10. Output
118
122
 
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`.
123
+ An ordered list of `_selected.json` trim spec paths — one per selected section, in narrative order. These become the inputs to `rm_fillers`, and the keeps that survive it become the `tracks[0]` items the render engine assembles.
124
+
125
+ When this is the last editorial pass before the render, the project must be `final` before the render will run — see skill `native` → "Project lifecycle — status, and the render gate".
120
126
 
121
127
  ## What to Log
122
128
 
@@ -7,6 +7,8 @@ description: "Write a custom JSX overlay component and add it to the project's o
7
7
 
8
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
9
 
10
+ > **Read [MOTION.md](MOTION.md) alongside this file whenever the overlay actually moves.** This file covers getting an overlay on screen correctly; MOTION.md covers making it move like someone designed it — the easing catalog (`interpolate` is strictly linear and has no easing option, which is why untutored overlays look flat), directional motion blur, per-character stagger, the no-dead-air rule, and how to verify motion by measurement instead of by eye. Every technique in it was rendered through the real sandbox before it was written down.
11
+
10
12
  ---
11
13
 
12
14
  ## Execution context
@@ -28,6 +30,7 @@ Custom overlay JSX runs in a sandboxed evaluator. All identifiers below are inje
28
30
  | `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
31
  | `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
32
  | `useThreeFrame` | hook | Bridges r3f to Montaj's frame-stepped renderer. Mount exactly once inside any `<Canvas>`. |
33
+ | `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. |
31
34
 
32
35
  **No imports.** All `import` statements are stripped before evaluation. Do not import anything — use the globals above instead.
33
36
 
@@ -66,7 +69,7 @@ export default function List() {
66
69
 
67
70
  ## Writing the JSX
68
71
 
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.
72
+ > **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`. The "go large — for video" guidance below, and the hardcoded-style `Hook` example just under it, **do not apply** to carousel editable-text overlays.
70
73
 
71
74
  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
75
 
@@ -74,8 +77,8 @@ The default aesthetic is **plain bold text directly on video** — no card, no b
74
77
  // overlays/hook.jsx — plain text on video, no background
75
78
 
76
79
  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' })
80
+ const progress = interpolate(frame, [0, 8], [0, 1])
81
+ const slideY = interpolate(frame, [0, 10], [40, 0])
79
82
 
80
83
  return (
81
84
  <div style={{
@@ -111,6 +114,58 @@ Only add a card or background when the prompt explicitly asks, or when a specifi
111
114
 
112
115
  ---
113
116
 
117
+ ## Make text overlays editable in the properties panel
118
+
119
+ 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:
120
+
121
+ `text`, `fontSize`, `fontFamily`, `fontWeight`, `fontStyle`, `color`, `textAlign`, `textTransform`, `bgColor`
122
+
123
+ 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.
124
+
125
+ **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.** Same house style (big, bold, plain on video), just sourced from props:
126
+
127
+ ```jsx
128
+ // Editable Hook — every text property is adjustable in the panel.
129
+ export default function Hook() {
130
+ const progress = interpolate(frame, [0, 8], [0, 1])
131
+ const slideY = interpolate(frame, [0, 10], [40, 0])
132
+
133
+ return (
134
+ <div style={{ position: 'absolute', bottom: 180, left: 48, right: 48, opacity: progress, transform: `translateY(${slideY}px)` }}>
135
+ <div style={{
136
+ fontFamily: props.fontFamily ?? 'Anton, Impact, sans-serif',
137
+ fontSize: props.fontSize ?? 120,
138
+ fontWeight: props.fontWeight ?? 900,
139
+ fontStyle: props.fontStyle ?? 'normal',
140
+ color: props.color ?? '#fff',
141
+ textAlign: props.textAlign ?? 'left',
142
+ textTransform: props.textTransform ?? 'uppercase',
143
+ background: props.bgColor ?? 'transparent',
144
+ lineHeight: 1.05, letterSpacing: '-1px',
145
+ textShadow: '0 2px 24px rgba(0,0,0,0.9), 0 0 60px rgba(0,0,0,0.5)',
146
+ }}>
147
+ {props.text}
148
+ </div>
149
+ </div>
150
+ )
151
+ }
152
+ ```
153
+
154
+ Declare those same values in the item's `props` too, so the panel opens on the real values rather than blank controls:
155
+
156
+ ```json
157
+ "props": {
158
+ "text": "She built an AI employee",
159
+ "fontSize": 120, "fontFamily": "Anton, Impact, sans-serif",
160
+ "fontWeight": 900, "fontStyle": "normal", "color": "#ffffff",
161
+ "textAlign": "left", "textTransform": "uppercase", "bgColor": "transparent"
162
+ }
163
+ ```
164
+
165
+ 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.
166
+
167
+ ---
168
+
114
169
  ## Splitting background from content across tracks
115
170
 
116
171
  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.
@@ -129,25 +184,31 @@ The most reliable way to use frosted-glass / blurred card backgrounds is to **pu
129
184
  ```json
130
185
  {
131
186
  "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
- ]
187
+ { "id": "trk-0", "items": [] },
188
+ {
189
+ "id": "trk-1",
190
+ "items": [
191
+ {
192
+ "id": "ov-card-bg",
193
+ "type": "overlay",
194
+ "src": "/path/overlays/card-bg.jsx",
195
+ "start": 2.0,
196
+ "end": 6.0
197
+ }
198
+ ]
199
+ },
200
+ {
201
+ "id": "trk-2",
202
+ "items": [
203
+ {
204
+ "id": "ov-card-content",
205
+ "type": "overlay",
206
+ "src": "/path/overlays/card-content.jsx",
207
+ "start": 2.0,
208
+ "end": 6.0
209
+ }
210
+ ]
211
+ }
151
212
  ]
152
213
  }
153
214
  ```
@@ -157,7 +218,7 @@ The most reliable way to use frosted-glass / blurred card backgrounds is to **pu
157
218
  ```jsx
158
219
  // overlays/card-bg.jsx
159
220
  // Just a frosted card that fades in. No children that animate opacity.
160
- const opacity = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' })
221
+ const opacity = interpolate(frame, [0, 8], [0, 1])
161
222
 
162
223
  export default function CardBg() {
163
224
  return (
@@ -221,10 +282,12 @@ const fadeOut = interpolate(frame, [duration - 15, duration], [1, 0])
221
282
  const opacity = Math.min(fadeIn, fadeOut)
222
283
 
223
284
  // Slide in from left
224
- const x = interpolate(frame, [0, 20], [-200, 0], { extrapolateRight: 'clamp' })
285
+ const x = interpolate(frame, [0, 20], [-200, 0])
225
286
  ```
226
287
 
227
- Options: `extrapolate`, `extrapolateLeft`, `extrapolateRight` — each `'clamp'` (default) or `'extend'`.
288
+ 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'`.
289
+
290
+ **`interpolate` is strictly linear** — `interpolate(5, [0,10], [0,1])` is exactly `0.5`. There is no easing parameter. For eased motion, ease the normalised value first: see **[MOTION.md](MOTION.md)** for the easing catalog, directional motion blur, per-character stagger, and the no-dead-air rule.
228
291
 
229
292
  ### `spring({ frame, fps, mass?, stiffness?, damping?, initialVelocity? })`
230
293
 
@@ -417,6 +480,35 @@ Three.js + r3f add ~250 KB to an overlay segment's bundle after esbuild tree-sha
417
480
 
418
481
  ---
419
482
 
483
+ ## 2D Canvas
484
+
485
+ 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.
486
+
487
+ ```jsx
488
+ export default function CanvasExample() {
489
+ const draw = (ctx, { frame, fps, duration }) => {
490
+ ctx.fillStyle = '#EFE3CE'
491
+ ctx.fillRect(0, 0, 1080, 1080)
492
+
493
+ const x = interpolate(frame, [0, duration], [0, 1080 - 120])
494
+ ctx.fillStyle = '#3b82f6'
495
+ ctx.fillRect(x, 480, 120, 120)
496
+ }
497
+
498
+ const ref = useCanvas2DFrame(draw, frame, fps, duration)
499
+
500
+ return <canvas ref={ref} width={1080} height={1080} style={{ position: 'absolute', inset: 0 }} />
501
+ }
502
+ ```
503
+
504
+ **`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.
505
+
506
+ **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).
507
+
508
+ **One `<canvas>` per `useCanvas2DFrame` call.** Each call returns its own ref; don't share one ref across multiple canvases or call the hook conditionally.
509
+
510
+ ---
511
+
420
512
  ## Charts / Recharts
421
513
 
422
514
  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`.
@@ -446,29 +538,32 @@ Place overlay items in `tracks[1+]` in `project.json`. Each item must have `type
446
538
  ```json
447
539
  {
448
540
  "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"
541
+ { "id": "trk-0", "items": [] },
542
+ {
543
+ "id": "trk-1",
544
+ "items": [
545
+ {
546
+ "id": "ov-hook",
547
+ "type": "overlay",
548
+ "src": "/abs/path/to/project/overlays/hook.jsx",
549
+ "start": 0.0,
550
+ "end": 3.0,
551
+ "props": {
552
+ "text": "She built an AI employee"
553
+ }
554
+ },
555
+ {
556
+ "id": "ov-logo",
557
+ "type": "overlay",
558
+ "src": "/abs/path/to/project/overlays/logo.jsx",
559
+ "start": 0.0,
560
+ "end": 999.0,
561
+ "props": {
562
+ "logoSrc": "/abs/path/to/project/assets/logo.png"
563
+ }
459
564
  }
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
- ]
565
+ ]
566
+ }
472
567
  ]
473
568
  }
474
569
  ```
@@ -576,7 +671,12 @@ Common overlay set for a social reel:
576
671
 
577
672
  **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
673
 
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 }`:
674
+ 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 }`:
675
+
676
+ - `overlay` — absolute path to the JSX file
677
+ - `out` — absolute path where the step writes the sample PNG (required)
678
+ - `google_fonts` — the overlay's declared `googleFonts` value (snake_case step arg)
679
+ - `props` — representative props for the overlay
580
680
 
581
681
  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
682