fablecut 0.0.0-stage → 1.12.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.
Files changed (82) hide show
  1. package/CLAUDE.md +1198 -0
  2. package/LICENSE +21 -0
  3. package/README.md +571 -2
  4. package/analyze.js +289 -0
  5. package/app.js +13339 -0
  6. package/audio-fx.js +241 -0
  7. package/cli.js +31 -0
  8. package/color.js +1043 -0
  9. package/denoise.js +120 -0
  10. package/ducking.js +127 -0
  11. package/edit-ops.js +962 -0
  12. package/encode-profiles.js +302 -0
  13. package/encoding-profiles.json +68 -0
  14. package/export-jobs.js +163 -0
  15. package/favicon.svg +14 -0
  16. package/fx-worklet.js +162 -0
  17. package/icons/favicon-32.png +0 -0
  18. package/import-url.js +293 -0
  19. package/index.html +420 -0
  20. package/library/README.md +15 -0
  21. package/library/elements/.gitkeep +0 -0
  22. package/library/elements/bokeh-drift.svg +19 -0
  23. package/library/elements/light-leak-cool.svg +16 -0
  24. package/library/elements/light-leak-warm.svg +22 -0
  25. package/library/elements/vhs-scanlines.svg +18 -0
  26. package/library/elements/vignette-frame.svg +14 -0
  27. package/library/fonts/.gitkeep +0 -0
  28. package/library/fonts/Abril Fatface.woff2 +0 -0
  29. package/library/fonts/Anton.woff2 +0 -0
  30. package/library/fonts/Archivo Black.woff2 +0 -0
  31. package/library/fonts/Bangers.woff2 +0 -0
  32. package/library/fonts/Barlow.woff2 +0 -0
  33. package/library/fonts/Bebas Neue.woff2 +0 -0
  34. package/library/fonts/Bungee.woff2 +0 -0
  35. package/library/fonts/Caveat.woff2 +0 -0
  36. package/library/fonts/DM Sans.woff2 +0 -0
  37. package/library/fonts/Inter.woff2 +0 -0
  38. package/library/fonts/LICENSES.md +31 -0
  39. package/library/fonts/Lato.woff2 +0 -0
  40. package/library/fonts/Montserrat.woff2 +0 -0
  41. package/library/fonts/Oswald.woff2 +0 -0
  42. package/library/fonts/Pacifico.woff2 +0 -0
  43. package/library/fonts/Playfair Display.woff2 +0 -0
  44. package/library/fonts/Poppins.woff2 +0 -0
  45. package/library/fonts/Righteous.woff2 +0 -0
  46. package/library/fonts/Roboto.woff2 +0 -0
  47. package/library/fonts/Rubik.woff2 +0 -0
  48. package/library/fonts/Teko.woff2 +0 -0
  49. package/library/sfx/README.md +18 -0
  50. package/library/svg/ai-star.svg +17 -0
  51. package/library/svg/arrow-bounce.svg +18 -0
  52. package/library/svg/arrow-scribble.svg +13 -0
  53. package/library/svg/burst-lines.svg +30 -0
  54. package/library/svg/checkmark-success.svg +19 -0
  55. package/library/svg/confetti-burst.svg +23 -0
  56. package/library/svg/countdown-ring.svg +92 -0
  57. package/library/svg/equalizer.svg +18 -0
  58. package/library/svg/glow-orbs.svg +29 -0
  59. package/library/svg/heart-pop.svg +28 -0
  60. package/library/svg/loading-dots.svg +16 -0
  61. package/library/svg/location-pin.svg +25 -0
  62. package/library/svg/lower-third.svg +17 -0
  63. package/library/svg/new-badge.svg +18 -0
  64. package/library/svg/progress-bar.svg +16 -0
  65. package/library/svg/radial-pulse.svg +19 -0
  66. package/library/svg/rating-stars.svg +25 -0
  67. package/library/svg/rec-indicator.svg +41 -0
  68. package/library/svg/sparkles.svg +17 -0
  69. package/library/svg/spawning-hearts.svg +39 -0
  70. package/library/svg/speech-bubble.svg +23 -0
  71. package/library/svg/subscribe-bell.svg +29 -0
  72. package/library/svg/subscribe-pill.svg +22 -0
  73. package/library/svg/swipe-up.svg +46 -0
  74. package/library/svg/underline-swoosh.svg +15 -0
  75. package/loudness.js +135 -0
  76. package/mcp-server.js +1421 -0
  77. package/meter-worklet.js +229 -0
  78. package/package.json +45 -4
  79. package/paths.js +106 -0
  80. package/ruler-worker.js +120 -0
  81. package/server.js +775 -0
  82. package/style.css +3563 -0
package/CLAUDE.md ADDED
@@ -0,0 +1,1198 @@
1
+ # FableCut — browser video editor, drivable by Claude Code
2
+
3
+ A production-style non-linear video editor (Premiere-style) that runs in the
4
+ browser. An AI agent edits videos by **editing `project.json`** (or calling the
5
+ REST API / MCP tools) — the open browser UI live-reloads within ~150 ms via SSE.
6
+ No build step, no npm dependencies.
7
+
8
+ **This file is the master manual.** Any model pointed at this document (or at
9
+ the `fablecut_docs` MCP tool, which returns it) has everything needed to fully
10
+ drive the editor.
11
+
12
+ ## MCP connection (preferred — works from any session, any directory)
13
+
14
+ Register the MCP server once at user scope as `fablecut`:
15
+ `claude mcp add -s user fablecut -- npx -y fablecut mcp` (from npm, no path needed),
16
+ or from a clone `claude mcp add -s user fablecut -- node "<path-to>/fablecut/mcp-server.js"`.
17
+ Every Claude Code session then has these tools:
18
+
19
+ - `fablecut_status` — auto-starts the editor server, returns URL + project summary. Call first.
20
+ - `fablecut_docs` — returns this document (`section: "…"` returns only matching `## ` sections).
21
+ - `fablecut_get_project` / `fablecut_set_project` — read / replace the timeline JSON.
22
+ `fablecut_get_project {compact:true}` returns a one-line-per-clip summary instead.
23
+ - `fablecut_patch_project` — apply targeted ops (add/update/remove clip/media,
24
+ set project fields) without round-tripping the document. **Prefer this for edits.**
25
+ - `fablecut_import_media` — copy a local file (or download an `https://` URL)
26
+ into `./media/` and register it. The stored `src` is always `/media/…`.
27
+ - `fablecut_analyze_reference` — turn a reference video into an edit blueprint
28
+ (shots, beats, BPM, energy, drop) + extract its music. See "Remake a reference video".
29
+ - `fablecut_encode_profiles` — list export presets from `encoding-profiles.json` (each is a
30
+ raw ffmpeg args list). Set `project.encodeProfile` via patch to pin a project default.
31
+ - `fablecut_normalize_audio` — measure clips (BS.1770 LUFS or sample peak, via ffmpeg) and
32
+ set their clip gain to a loudness target. See "Audio mix" below.
33
+ - `fablecut_auto_duck` — find where the voice tracks speak and write `duck` keyframes that
34
+ dip the music under it. See "Audio mix" below.
35
+ - `fablecut_denoise` — reduce background noise on audio clips (renders a cleaned copy of the
36
+ file and switches the clip's stems to it). See "Audio mix" below.
37
+ - `fablecut_export` — render the timeline to a file in `exports/` with the editor's own
38
+ Fast export (open tab, or headless Chrome / Edge). See "Export" below.
39
+ - `fablecut_scopes` — measure the graded picture at a moment (levels, clipping, colour
40
+ cast), as the editor's scopes see it. See "Color" below.
41
+
42
+ ### Token-efficient editing (important for agents)
43
+
44
+ Editing via full get→modify→set costs thousands of tokens per change. Cheaper:
45
+
46
+ 1. **Plan** from `fablecut_get_project {compact:true}` (≈10× smaller than the JSON)
47
+ and `fablecut_status` — fetch the full JSON only to inspect exact keyframes.
48
+ 2. **Edit** with `fablecut_patch_project` ops — send only what changes, e.g.
49
+ `{ops:[{op:"updateClip", id:"c_v2", set:{props:{filterPreset:"noir"}}}]}`.
50
+ It re-reads the latest document internally, so it is merge-safe by design
51
+ (no CONFLICT dance) and never destroys concurrent UI tweaks. To also be sure
52
+ the clips you touch haven't changed since you looked, pass `baseRevision`
53
+ (the `revision` you planned from — `fablecut_get_project`, `fablecut_status`
54
+ or the last "Patched (revision N)" result): if the project has moved past it,
55
+ the patch is refused with "CONFLICT — not saved" and the current revision.
56
+ Re-check the clips, then retry with that revision.
57
+ 3. **Docs**: request `fablecut_docs {section:"schema"}` (or "Recipes", "Remake", …)
58
+ instead of the whole manual; skip it entirely if the schema is already in context.
59
+ 4. **Media questions** (duration, fps, size): read them from the registered media
60
+ entries — don't shell out to ffprobe; the browser probes and writes them back.
61
+ 5. Batch related changes into ONE patch call (ops apply in order, one revision bump).
62
+ 6. **Cut and trim with the edit ops** (next section) rather than recomputing
63
+ `start` / `in` / `duration` by hand — they keep linked stems in sync.
64
+
65
+ ### Timeline edit ops (`fablecut_patch_project`)
66
+
67
+ The editor's own split / ripple / trim code (`edit-ops.js`), run on the
68
+ document — so linked audio stems ride along, edits land on the **targeted**
69
+ tracks (`untargetedTracks`, see Semantics), and locked clips stay put, exactly
70
+ as with the keyboard shortcuts. Times are seconds.
71
+
72
+ | op | does |
73
+ |---|---|
74
+ | `{op:"split", at, ids?}` | cut at `at` — the named clips (and their stems), or every targeted track |
75
+ | `{op:"rippleDelete", ids}` | remove clips; later clips on those tracks close the hole |
76
+ | `{op:"closeGap", at}` | close the gap under `at` on every targeted track |
77
+ | `{op:"lift" \| "extract", from?, to?}` | remove a range (default: `inPoint`→`outPoint`, which then clear); extract also closes it |
78
+ | `{op:"insert" \| "overwrite", mediaId, at, in?, duration?}` | three-point edit: place `in`→`in+duration` of the media at `at` (default: the rest of the media). Insert pushes later clips right; overwrite replaces what is under it. A video brings one linked audio stem per channel. |
79
+ | `{op:"rippleTrim", id, side:"in"\|"out", delta}` | move a head / tail; everything after follows |
80
+ | `{op:"roll", id, side, delta}` | move the cut between two clips; nothing else moves |
81
+ | `{op:"slip", id, delta}` · `{op:"slide", id, delta}` | new source window in place · move a clip between its neighbors |
82
+ | `{op:"crossfade", ids? \| at, duration?}` | constant-power audio crossfade at the cuts touching `ids`, or the cut nearest `at` (handles borrowed from both sides) |
83
+
84
+ Trims are clamped to the source media, the free room and `MIN_DUR`; the result
85
+ note shows the applied delta (`… (asked +5, clamped)`). Any edit op takes
86
+ `tracks:[…]` to target lanes for that op only, and `force:true` to edit locked
87
+ material (only when the user asked). A refused op (nothing under `at`, a
88
+ locked clip, a media window past the file's end) aborts the whole patch unsaved.
89
+ `setProject` also sets `inPoint` / `outPoint` (`null` clears).
90
+
91
+ Example — cut the 2 s from 10 s to 12 s out of the edit, then crossfade the new cut:
92
+ `{ops:[{op:"extract", from:10, to:12}, {op:"crossfade", at:10, duration:0.5}]}`
93
+
94
+ **`fablecut_set_project` is conflict-checked.** The MCP server remembers the
95
+ `revision` from the most recent `fablecut_get_project` call. If `project.json`
96
+ has been written by anyone else since that read (e.g. the user dragged a clip in
97
+ the UI), `fablecut_set_project` refuses with a "CONFLICT — not saved" error
98
+ instead of overwriting. Protocol:
99
+
100
+ 1. `fablecut_get_project` → read the document and note its `revision`.
101
+ 2. Apply your edits in memory, bump `revision`.
102
+ 3. `fablecut_set_project` → if it succeeds you're done.
103
+ 4. **On conflict**: call `fablecut_get_project` again to get the latest document,
104
+ re-apply your intended changes on top of it, bump `revision`, and call
105
+ `fablecut_set_project` again.
106
+
107
+ Pass `force: true` to `fablecut_set_project` only when the user explicitly
108
+ asks to overwrite conflicting changes. `fablecut_import_media` only appends a
109
+ new media entry and always merges safely — no conflict check needed.
110
+
111
+ For Claude Desktop, add to its MCP config:
112
+ `{"mcpServers":{"fablecut":{"command":"npx","args":["-y","fablecut","mcp"]}}}`
113
+ (or `"command":"node","args":["<path-to>/fablecut/mcp-server.js"]` from a clone)
114
+ Direct file editing of `project.json` (below) works too and is equivalent.
115
+
116
+ Installing as a Claude Code plugin (`/plugin marketplace add ronak-create/FableCut`,
117
+ then `/plugin install fablecut@fablecut`) does the registration for you.
118
+
119
+ ### Where the files are
120
+
121
+ `project.json`, `media/`, `exports/`, `analysis/` and `library/` normally sit in
122
+ the repo next to `server.js`; installed from npm (`npx fablecut`) they sit in
123
+ `~/FableCut`. Set **`FABLECUT_DATA_DIR`** to move all five
124
+ somewhere else; the code and the static app files stay in the install directory
125
+ either way. The plugin sets this so a plugin update can replace the install
126
+ directory without touching anyone's timeline or footage. **Don't assume
127
+ `project.json` is beside `mcp-server.js`** — call `fablecut_status`, which
128
+ reports the real paths.
129
+
130
+ Tests (and nothing else) may set **`FABLECUT_NO_FS_WATCH=1`** to skip `fs.watch`.
131
+ On Windows, libuv can abort the process when a file is created under a temp
132
+ data dir. Production leaves watching on so the UI live-reloads.
133
+
134
+ ## Run
135
+
136
+ ```
137
+ node server.js # → http://localhost:7777 (from npm: npx -y fablecut)
138
+ ```
139
+
140
+ Files: `index.html` + `style.css` + `app.js` (editor UI), `server.js` (API + hosting),
141
+ `project.json` (the timeline — THE file to edit), `media/` (project footage),
142
+ `library/` (default asset library, see below).
143
+
144
+ ## How Claude Code edits a video
145
+
146
+ 1. Ensure the server is running (background: `node server.js`, or `fablecut_status`).
147
+ 2. Put source files in `./media/` (copy them in, import via the UI / **+ URL**,
148
+ or `fablecut_import_media` with a local path or HTTPS URL).
149
+ 3. Read `project.json`, modify `media` / `clips`, **increment `revision`**, write it back.
150
+ 4. The browser UI (if open) reloads instantly. The user previews/exports from the UI.
151
+
152
+ Rules:
153
+ - **Prefer `fablecut_set_project`** over direct file writes — it detects conflicts
154
+ automatically (see the MCP section above). If you do write `project.json`
155
+ directly, read it **immediately** before writing (never write from a stale read:
156
+ if the user tweaked something in the UI between your read and write, that write
157
+ destroys their changes). The UI detects external changes by revision comparison,
158
+ so a write that does not bump `revision` is invisible to it.
159
+ - Make each edit a single atomic write (read → modify → write once), and bump
160
+ `revision` (integer). Partial multi-step edits can be picked up half-finished.
161
+ - New media entries may omit `duration` — the browser probes it and writes it back.
162
+ If you need the duration yourself, re-read `project.json` after a second or two,
163
+ or probe with ffprobe.
164
+ - Don't edit `project.json` while the UI may be mid-drag — the UI defers external
165
+ reloads during gestures, then picks up the next change.
166
+
167
+ ## The asset library (`./library/`) — default media
168
+
169
+ Reusable assets, visible in the editor's left-panel tabs and never copied:
170
+
171
+ | Folder | Editor tab | Purpose |
172
+ | ----------- | ------------ | ------- |
173
+ | `library/sfx/` | **Sound FX** | whooshes, impacts, risers, UI clicks |
174
+ | `library/elements/` | **Elements** | overlay art: alpha PNGs, light leaks, textures, stickers |
175
+ | `library/svg/` | **SVG** | animated vector graphics **you author** (convention below) |
176
+ | `library/fonts/` | font editor | `.ttf/.otf/.woff/.woff2`, auto-registered, family name = file name; a variable font draws every `weight` |
177
+
178
+ - List via `GET /api/library?dir=sfx|elements|svg|fonts` (recursive; subfolders OK).
179
+ - To use one in the timeline, add a media entry whose `src` is its library path,
180
+ e.g. `{ "id":"m_x", "name":"whoosh.mp3", "kind":"audio", "src":"/library/sfx/whoosh.mp3" }`
181
+ — then reference it from clips like any other media.
182
+ - Dropping files into these folders live-refreshes the open UI.
183
+
184
+ ## Authoring animated SVGs (the `svg` clip kind)
185
+
186
+ You can create your own vector animations/overlays: write an `.svg` file into
187
+ `library/svg/` (or `media/`), register it as media with `"kind": "svg"`, and
188
+ place it on a video track. The compositor renders it frame-accurately, driven
189
+ by the clip's local time (preview and export).
190
+
191
+ **Conventions (required for time-driven animation):**
192
+ 1. Root `<svg>` must carry `width` and `height` attributes (or a `viewBox`).
193
+ 2. Animate with **CSS `@keyframes` inside a `<style>` block** — SMIL
194
+ (`<animate>`) is NOT time-controlled.
195
+ 3. Never write a literal `animation-delay`. For staggered starts set the custom
196
+ property `--d` on the element instead: `style="--d:0.4s"`. (The engine drives
197
+ time by overriding `animation-delay` to `calc(var(--d,0s) - t)` with
198
+ animations paused.)
199
+ 4. `animation-fill-mode: both` (or the `both` keyword in the shorthand) for
200
+ one-shot intros; `infinite` for loops — both work.
201
+ 5. For rotations/scales around an element's own center add
202
+ `transform-box: fill-box; transform-origin: center;`.
203
+ 6. Keep files self-contained (no external hrefs).
204
+
205
+ Skeleton:
206
+
207
+ ```svg
208
+ <svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">
209
+ <style>
210
+ .a { transform-box: fill-box; transform-origin: center;
211
+ animation: pop 0.5s cubic-bezier(.34,1.56,.64,1) both; }
212
+ @keyframes pop { from { transform: scale(0); opacity: 0; } to { transform: scale(1); opacity: 1; } }
213
+ </style>
214
+ <circle class="a" style="--d:0s" cx="200" cy="225" r="60" fill="#7b6cff"/>
215
+ <circle class="a" style="--d:0.2s" cx="400" cy="225" r="60" fill="#ffd166"/>
216
+ </svg>
217
+ ```
218
+
219
+ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
220
+ `confetti-burst.svg`, `underline-swoosh.svg` (draw-on via stroke-dashoffset).
221
+
222
+ ## project.json schema
223
+
224
+ ```jsonc
225
+ {
226
+ "name": "My Edit",
227
+ "width": 1280, "height": 720, "fps": 30, // composition canvas + timeline/export rate — sole FPS source (UI: FPS select)
228
+ "exportFrame": { "x": 438, "y": 0, "w": 404, "h": 720 }, // optional delivery crop (even w/h for H.264)
229
+ // ^ omit = export the full canvas. Preview dims outside this rect; Fast export crops
230
+ // JPEGs to w×h. Clip x/y/scale stay relative to the composition center, not the frame.
231
+ // Frame size is always even (yuv420p / libx264); 9:16 on 1280×720 → 404×720, not 405.
232
+ "background": "#000000", // canvas color behind all clips (optional)
233
+ "revision": 7, // bump on every write!
234
+ "panSchema": 1, // 1 = pan-aware; omit only on pre-pan projects (UI migrates once)
235
+ "markers": [ { "t": 2.5 }, { "t": 5.0, "label": "drop", "color": "red" } ],
236
+ // ^ beat/cue markers: diamonds on the ruler (label shown beside them), snap
237
+ // targets for clip edges. color: gold (default) · red · orange · green ·
238
+ // cyan · blue · purple · pink. Kept sorted by t; the UI rounds t to 1 ms.
239
+ "inPoint": 10.023, // IN work-area marker (Limit playback + optional export range)
240
+ "outPoint": 21.500, // OUT work-area marker; omit = timeline end
241
+ "folders": [
242
+ // Project-bin virtual folders (tree). Media reference them via folderId.
243
+ { "id": "f_broll", "name": "B-roll", "parentId": null, "open": true }
244
+ ],
245
+ "disabledTracks": [ "A2" ],
246
+ // ^ optional — track ids (e.g. V4 V3 V2 V1 A1 A2 A3) omitted from preview/export when listed
247
+ "lockedTracks": [ "A3" ],
248
+ // ^ optional — locked lanes: nothing on them may be edited or moved (see Semantics)
249
+ "untargetedTracks": [ "V2" ],
250
+ // ^ optional — lanes edits skip (split, insert, ripple, close gap…); default: all targeted
251
+ "encodeProfile": "hq", // optional — fast-export profile id (see encoding-profiles.json)
252
+ "master": { "gain": -1.5, "fx": [ { "type": "limiter", "ceiling": -1 } ] },
253
+ // ^ optional — master fader, dB (−60 = silence … +12; omit = 0 dB) and master effects (see "Audio mix")
254
+ "tracks": [ // optional — timeline lanes (default V3…V1 + A1…A4)
255
+ { "id": "V3", "kind": "video" }, // video: higher number drawn on top (V1 under V2 under V3…)
256
+ { "id": "V2", "kind": "video" },
257
+ { "id": "V1", "kind": "video" },
258
+ { "id": "A1", "kind": "audio", "gain": -6, "pan": 0, "out": "B1" }, // audio: A1…An top→bottom; +V/+A appends Vn+1 / An+1
259
+ // ^ gain = track fader in dB (−60 = silence … +12), pan = −1…1, fx = track effects,
260
+ // out = the submix bus it feeds (default: the master); all optional
261
+ { "id": "A2", "kind": "audio" },
262
+ { "id": "A3", "kind": "audio" },
263
+ { "id": "A4", "kind": "audio" }
264
+ ],
265
+ "buses": [ { "id": "B1", "name": "Dialogue", "gain": -2, "fx": [ { "type": "compressor" } ] } ],
266
+ // ^ optional — submix buses B1…B8: name, gain (dB), pan, mute, fx; tracks route in with `out`
267
+ "media": [
268
+ { "id": "m_abc", "name": "intro.mp4", "kind": "video", // video|audio|image|svg
269
+ "src": "/media/intro.mp4", // path under ./media or ./library (never a raw https:// URL)
270
+ "duration": 12.4, "width": 1920, "height": 1080,
271
+ "folderId": null }, // optional: id of a folders[] entry
272
+ { "id": "m_abd", "name": "intro.denoise-medium.flac", "kind": "audio", "src": "/media/intro.denoise-medium.flac",
273
+ "derivedFrom": "m_abc", "denoise": "medium" } // a noise-reduced copy of m_abc (see "Audio mix")
274
+ ],
275
+ "clips": [
276
+ {
277
+ "id": "c_xyz", // unique string
278
+ "mediaId": "m_abc", // null for kind:"text" and kind:"adjust"
279
+ "kind": "video", // video | audio | image | svg | text | adjust
280
+ "track": "V1", // Vn…V1 (video) | A1…An (audio); extras via UI +V/+A or tracks[]
281
+ "start": 0, // timeline position, seconds
282
+ "in": 2.5, // offset into source media, seconds (0 for image/svg/text)
283
+ "duration": 5, // clip length on timeline, seconds
284
+ "name": "intro",
285
+ "linkGroup": "lg_abc", // OPTIONAL — AV link: video + per-channel audio stems share one id
286
+ "locked": true, // OPTIONAL — locked by the user: leave it alone (MCP patch refuses edits)
287
+ "disabled": true, // OPTIONAL — stays on the timeline, hidden from preview / audio / export
288
+ "unlinked": true, // OPTIONAL — the user unlinked this clip; never auto-relinked on load
289
+ "props": { /* all optional — see the props reference below */ },
290
+ // OPTIONAL — keyframe animation. Times are seconds RELATIVE TO CLIP START.
291
+ // "ease" sits on the DESTINATION keyframe of each segment:
292
+ // linear | ease-in | ease-out | ease-in-out (default ease-in-out).
293
+ "keyframes": {
294
+ "scale": [ { "t": 0, "v": 1 }, { "t": 5, "v": 1.25, "ease": "linear" } ],
295
+ "opacity": [ { "t": 0, "v": 0 }, { "t": 0.8, "v": 1 } ]
296
+ },
297
+ // OPTIONAL — audio effects (audio clips), processed in order. See "Audio mix".
298
+ "fx": [ { "type": "highpass", "freq": 80 }, { "type": "compressor", "threshold": -20, "ratio": 4 } ],
299
+ // OPTIONAL — transitions at the clip's head/tail.
300
+ "transitionIn": { "type": "fade", "duration": 0.8 },
301
+ "transitionOut": { "type": "slide-left", "duration": 0.6 }
302
+ }
303
+ ]
304
+ }
305
+ ```
306
+
307
+ ### props reference
308
+
309
+ **Transform & compositing** (all visual kinds):
310
+ | prop | default | notes |
311
+ |---|---|---|
312
+ | `x`, `y` | 0 | px offset from canvas center |
313
+ | `scale` | 1 | |
314
+ | `rotation` | 0 | degrees |
315
+ | `opacity` | 1 | 0–1 |
316
+ | `blend` | "normal" | multiply · screen · overlay · lighter · soft-light · hard-light · color-dodge · darken · lighten · difference |
317
+
318
+ **Layout** (video/image/svg):
319
+ | prop | default | notes |
320
+ |---|---|---|
321
+ | `fit` | "contain" | contain · cover (fill canvas, crop overflow) · stretch · none (native px) |
322
+ | `cropL` `cropR` `cropT` `cropB` | 0 | % of the source trimmed off each edge |
323
+ | `cornerRadius` | 0 | px, rounded corners — the PiP look |
324
+ | `flipH`, `flipV` | false | mirror |
325
+
326
+ **Filter / color** (video/image/svg):
327
+ | prop | default | notes |
328
+ |---|---|---|
329
+ | `filterPreset` | "none" | one of: cinematic · teal-orange · noir · vintage · faded · warm · cold · pop · dreamy · retro · bw-soft · cyberpunk · sunset · midnight. Combines non-destructively with the sliders below. |
330
+ | `brightness` `contrast` `saturation` | 100 | % (100 = neutral) |
331
+ | `hue` | 0 | degrees |
332
+ | `temperature` | 0 | −100 (cool blue) … +100 (warm orange) |
333
+ | `tint` | 0 | −100 (magenta) … +100 (green) |
334
+ | `blur` | 0 | px |
335
+ | `grayscale` `sepia` `invert` `vignette` | 0 | % |
336
+
337
+ These are quick looks. For real grading use `props.grade` (see "Color" below).
338
+
339
+ **Motion FX** (video/image/svg/adjust — all animatable):
340
+ | prop | default | notes |
341
+ |---|---|---|
342
+ | `shake` | 0 | camera-shake amplitude in px (deterministic — identical in export) |
343
+ | `shakeSpeed` | 8 | shake frequency |
344
+ | `rgbSplit` | 0 | chromatic aberration / RGB channel split, px |
345
+ | `grain` | 0 | animated film grain, % |
346
+
347
+ **Keying / cut-out** (video/image):
348
+ | prop | default | notes |
349
+ |---|---|---|
350
+ | `chromaKey` | "" | hex key color, e.g. `"#00ff00"` for green screen ("" = off) |
351
+ | `chromaTolerance` | 26 | 0–100, how much color distance is keyed out |
352
+ | `chromaSoftness` | 12 | 0–100, edge feather + spill suppression |
353
+ | `bgRemove` | false | AI person cut-out (MediaPipe, loads from CDN on first use; needs internet once) |
354
+
355
+ **Audio / time** (video/audio):
356
+ | prop | default | notes |
357
+ |---|---|---|
358
+ | `volume` | 1 | 0–2 (linear; animatable — fades, ducking) |
359
+ | `gain` | 0 | clip gain in dB (−60…+24), applied **before** `volume`, so keyframes and fades ride on top. Set by Normalize. |
360
+ | `channelMode` | "stereo" | stereo · mono (L+R folded to one channel) · left · right (use one side) · swap (exchange L/R). Ignored on linked stems — `audioChannel` already picks their channel. |
361
+ | `duck` | 0 | ducking in dB (≤ 0), multiplied into `volume`. Normally keyframed by Auto-duck (`keyframes.duck`), so the clip's own volume, keyframes and fades are untouched. |
362
+ | `pan` | 0 | −1 (full left) … 0 (center) … +1 (full right). Linked stems default L `−1` / R `+1` / other `0`. Projects saved before pan migrate once on load (`panSchema`); keep `panSchema: 1` (and explicit `pan` on stems) when rewriting the document so a centered stem is not re-hard-panned. |
363
+ | `speed` | 1 | 0.25–4× playback rate. **Keyframable → speed ramps**: with `keyframes.speed` the engine time-remaps (media time = `in` + ∫speed dt), in preview and in the export audio mix. Static case: source window consumed = `duration × speed`, so `in + duration×speed ≤ media.duration`. |
364
+
365
+ **Text clips only:**
366
+ | prop | default | notes |
367
+ |---|---|---|
368
+ | `text` | "Title" | `\n` for multi-line |
369
+ | `fontSize` | 72 | px |
370
+ | `color` | "#ffffff" | fill |
371
+ | `color2` | "" | if set: vertical gradient fill color→color2 |
372
+ | `font` | "Segoe UI" | system font, a `library/fonts` family, or ANY Google Font name — unknown names are fetched from Google Fonts automatically |
373
+ | `bold` / `weight` | true / 0 | `weight` (300–900) overrides `bold` when non-zero |
374
+ | `italic` `uppercase` | false | |
375
+ | `align` | "center" | left · center · right · **justify** (inserts spaces between words to fill the text box width, or ~85% of the canvas when no box) |
376
+ | `boxW` `boxH` | 0 | px text box; **0 = hug content**. When both > 0, corner handles resize the box (not `scale`). Default: fixed `fontSize`, word-wrap inside the box. |
377
+ | `boxFit` | false | when a box is set: `false` = wrap at fixed font size; `true` = scale font down (up to `fontSize`) so the whole text fits |
378
+ | `vAlign` | "middle" | top · middle · bottom — vertical alignment of the text block inside the box |
379
+ | `direction` | "auto" | auto · ltr · rtl — `auto` detects per line (Hebrew/Arabic → RTL); all `textAnim` modes honor reading direction (wipe, typewriter reveal, word/letter layout) |
380
+ | `letterSpacing` | 0 | px |
381
+ | `lineHeight` | 1.2 | multiplier |
382
+ | `textShadow` | 12 | soft drop shadow amount, 0 = off |
383
+ | `glow` | 0 | neon glow strength (0–100); replaces the drop shadow |
384
+ | `glowColor` | "" | glow color ("" = use the text color) |
385
+ | `strokeWidth` `strokeColor` | 0, "#000" | text outline |
386
+ | `bgColor` `bgOpacity` | "#000", 0 | rounded pill behind each line |
387
+ | `textAnim` | "none" | typewriter · word-pop · word-slide · karaoke · **letter-pop** (per-character entrance; Arabic/Indic auto-fallback to per-word clusters so joined letters shape correctly) · **wave** (looping per-character ride; same shaping fallback) · **bounce** (looping per-word hop) · **shake** (looping jitter) · **clip-reveal** (wipe-mask sweep, per line) · **zoom-in** (scale + opacity settle) · **font-cut** (rhythmically swaps typeface, then settles — see `fontCutSet`) · **rise-mask** (line rises from behind its baseline, lower-third reveal) |
388
+ | `wordRate` | 0.15 | seconds per word (typewriter: /4, letter-pop: /3 per character); also staggers `clip-reveal`/`zoom-in`/`rise-mask` per line |
389
+ | `fontCutSet` | (curated) | array of font family names cycled by `font-cut`, e.g. `["Anton","Bebas Neue","Archivo Black","Oswald"]`; each is auto-loaded |
390
+
391
+ **Title styles (one-tap cohesive looks).** Text clips created in the UI now
392
+ rotate through curated styles so titles vary instead of all looking basic (the
393
+ old flat `Segoe UI` / no-animation default). Each style bundles a **different
394
+ font**, placement and animation. Apply one in the inspector (Title style
395
+ dropdown + Shuffle), or reproduce it from an agent by writing the same props:
396
+
397
+ | Style | Font | Look |
398
+ | --- | --- | --- |
399
+ | `impact` | Anton | uppercase, lower third, `word-pop`, big shadow |
400
+ | `elegant` | Playfair Display | white→gold gradient, centered, `clip-reveal` |
401
+ | `kinetic` | Bebas Neue | gold, `font-cut` cycling Anton/Bebas/Archivo/Oswald |
402
+ | `neon` | Bebas Neue | cyan `glow`, `wave` |
403
+ | `handwritten` | Caveat | rotated −4°, lower-left, `word-slide` |
404
+ | `serifDrop` | Abril Fatface | centered, `zoom-in` |
405
+ | `subtitle` | Roboto | small, bottom, bg pill, `karaoke` |
406
+ | `boldRise` | Archivo Black | uppercase, lower third, `rise-mask` |
407
+ | `luxury` | Cinzel | uppercase, cream→gold gradient, wide `letterSpacing`, `clip-reveal` |
408
+
409
+ **Rule for agents: vary the font per title — never reuse one font across a whole
410
+ edit.** Any Google Font name auto-loads; the display faces above ship in
411
+ `library/fonts/`. Placement props (`x`/`y`) are canvas-aware in the styles
412
+ (lower third ≈ `y: height*0.30`).
413
+
414
+ Switching the style on an **existing** title (inspector dropdown/Shuffle) only
415
+ restyles the look (font, size, colors, shadow/glow, animation) — the clip's
416
+ `x`/`y`/`scale`/`rotation`/`align` and its `text` are preserved. The style's
417
+ canvas-aware placement is applied only when a title is first created.
418
+
419
+ **Adjustment layers** (`kind:"adjust"`, `mediaId:null`): a clip that re-renders
420
+ everything drawn *below* it (lower tracks + earlier clips) through its own
421
+ filter stack — Premiere-style. Supports all Filter/Color props, vignette,
422
+ grain, rgbSplit, temperature/tint, whole-frame `shake`, and `opacity` as the
423
+ intensity. Keyframe/transition it like any clip. Put it on V2/V3 above the
424
+ footage. Example: 0.3 s impact shake over everything =
425
+ `{kind:"adjust", track:"V3", duration:0.3, props:{shake:18}}`.
426
+
427
+ **Animatable props** (usable in `keyframes`): x, y, scale, rotation, opacity,
428
+ volume, pan, duck, speed, brightness, contrast, saturation, hue, blur, grayscale, sepia,
429
+ invert, temperature, tint, vignette, cornerRadius, shake, rgbSplit, grain,
430
+ fontSize, letterSpacing, glow.
431
+
432
+ **Transition types**: fade · slide-left/right/up/down · zoom · wipe (=wipe-left)
433
+ · wipe-right/up/down · iris (circular) · spin · blur · whip (whip-pan) ·
434
+ glitch (RGB split + jitter) · pop (overshoot scale — stickers/captions).
435
+
436
+ ### Audio mix
437
+
438
+ Every clip runs through the same chain in preview **and** export:
439
+ `source → channels (audioChannel / channelMode) → gain (dB) → clip fx → volume (keyframes, fades, duck) → pan → its A-track`,
440
+ then `track fx → track fader → track pan → (submix bus fx → fader → pan) → master sum → master fx → master fader`.
441
+ A track has a fader (`tracks[].gain`, dB), a pan (`tracks[].pan`) and effects
442
+ (`tracks[].fx`); the master has a fader (`master.gain`) and effects (`master.fx`).
443
+ **Submix buses** (`buses[]`, B1…B8) group tracks: a track with `out:"B1"` feeds
444
+ that bus instead of the master, and the bus has its own effects, fader, pan and
445
+ mute — e.g. every dialogue lane into one bus with one compressor and one fader.
446
+ Audio on a video lane (a clip with `volume > 0` on V1…) goes straight to the
447
+ master. In the UI these are the strips in the **Mixer** tab beside the
448
+ Inspector (its **FX** button edits that track's or the master's effects);
449
+ mute / solo are the existing track switches (`disabledTracks`).
450
+
451
+ **Effects** (`fx` — an array processed in order; every parameter optional, out-of-range
452
+ values are clamped; `on:false` bypasses one without losing its settings):
453
+
454
+ | type | parameters (default) |
455
+ |---|---|
456
+ | `eq` | `lowFreq` 120 Hz, `lowGain` 0 dB (low shelf) · `midFreq` 1000, `midGain` 0, `midQ` 1 (peak) · `highFreq` 8000, `highGain` 0 (high shelf); gains −18…+18 |
457
+ | `highpass` / `lowpass` | `freq` (80 / 8000 Hz), `q` 0.71 |
458
+ | `compressor` | `threshold` −20 dB, `ratio` 4, `attack` 10 ms, `release` 200 ms, `knee` 6 dB, `makeup` 0 dB |
459
+ | `limiter` | `ceiling` −1 dB, `release` 80 ms — brickwall, 5 ms lookahead; nothing passes the ceiling |
460
+ | `gate` | `threshold` −50 dB, `range` −40 dB (how far it closes), `attack` 2, `hold` 80, `release` 150 ms |
461
+ | `delay` | `time` 0.3 s, `feedback` 0.35, `mix` 0.25 |
462
+ | `reverb` | `decay` 2 s, `predelay` 20 ms, `mix` 0.25 |
463
+ | `distortion` | `drive` 0.3, `mix` 1 |
464
+ | `widener` | `width` 1.5 (0 = mono, 1 = as is, 2 = extra wide) |
465
+ | `pitch` | `semitones` 0 (−12…+12; changes pitch, not speed — about 30 ms of delay), `mix` 1 |
466
+
467
+ **Presets** fill the chain with tweakable effects: Voice — `clean-voice`, `podcast`,
468
+ `radio`, `deep-voice` (pitch down 4 semitones + a warmer EQ), `telephone`;
469
+ Music — `cinematic`, `wide`, `muffled` (next room). Agents apply them (or any chain)
470
+ with the patch op `setFx`:
471
+ `{op:"setFx", target:"clip", id:"c_vo", preset:"podcast"}` ·
472
+ `{op:"setFx", target:"track", id:"A3", fx:[{type:"eq", highGain:-3}]}` ·
473
+ `{op:"setFx", target:"master", fx:[{type:"limiter", ceiling:-1}], append:true}` ·
474
+ `{op:"setFx", target:"bus", id:"B1", preset:"podcast"}` ·
475
+ `fx:null` clears. On a clip it applies to its linked stems too; chains are
476
+ validated (unknown effect or preset → the whole patch is refused). The compact
477
+ project view shows chains as `fx:highpass·eq·…` (`lowpass~freq` = automated).
478
+
479
+ **Effect automation** — any effect parameter except reverb `decay` can follow
480
+ keyframes: `keys` on the effect, `{param: [{t, v, ease?}]}`. `t` is seconds from
481
+ the clip's start for a clip's effects, and timeline seconds for a track's, a
482
+ bus's or the master's; `ease` (linear · ease-in · ease-out · ease-in-out, default
483
+ ease-in-out) shapes the segment arriving at that key, as with clip keyframes.
484
+ While a parameter has keys they set it. Preview and export follow them the same
485
+ way (export steps every 20 ms with a short glide). In the effects panel each
486
+ parameter has a ◆: set or remove a key at the playhead; with keys, the slider
487
+ edits the key under the playhead. Agents:
488
+ `{op:"setFxKeys", target:"bus", id:"B1", type:"lowpass", param:"freq", keys:[{t:0, v:18000}, {t:8, v:500, ease:"ease-in"}]}`
489
+ (`index` picks the effect when the type repeats; `keys:null` clears) — or write
490
+ `keys` inside a `setFx` chain. Example — a filter sweep into the drop: a
491
+ `lowpass` on the music clip keyed from 400 Hz up to 20000 Hz over the build.
492
+
493
+ - Set a track fader / pan: `{op:"setTrack", id:"A2", set:{gain:-8, pan:0}}`
494
+ (`null` or `0` resets). Master: `{op:"setProject", set:{master:{gain:-1}}}`.
495
+ - Buses: `{op:"setBus", id:"B1", set:{name:"Dialogue", gain:-2, pan?, mute?}}`
496
+ creates or updates one; route a track in with `{op:"setTrack", id:"A1", set:{out:"B1"}}`
497
+ (`out:"master"` or `null` routes it back); `{op:"removeBus", id:"B1"}` sends
498
+ its tracks back to the master. In the Mixer: **+ Bus**, the **→** menu under a
499
+ track's name, double-click a bus name to rename it.
500
+ - **Noise reduction** — `fablecut_denoise {clipIds, amount?:"light"|"medium"|"strong"|"off"}`
501
+ (default medium; the inspector's **Noise** control does the same). The file's
502
+ noise floor is measured (its quietest 50 ms windows), then ffmpeg's FFT
503
+ denoiser pulls everything near it down (10 / 18 / 30 dB) into a FLAC copy of
504
+ the **whole** source file in `media/`, registered with `derivedFrom` (the
505
+ original) and `denoise` (the amount). The clip and its linked audio stems
506
+ switch to that copy; the picture keeps its own file, so links, `in` points
507
+ and timing stay as they were. `off` switches back; a level already rendered
508
+ is reused. Works on audio clips (a video's sound is on its linked stems).
509
+ Needs ffmpeg. Use it before normalizing — the measured loudness changes.
510
+ - **Normalize** — `fablecut_normalize_audio {clipIds, mode?:"lufs"|"peak", target?}`
511
+ measures each clip's source window after its channel routing (ITU-R BS.1770
512
+ integrated loudness with EBU R128 gating, or sample peak) and sets
513
+ `props.gain` to hit the target. Linked stems are measured together and share
514
+ one gain; passing a video clip normalizes its stems. Targets: **−14 LUFS**
515
+ streaming / social, **−16 LUFS** podcast / dialogue, **−23 LUFS** broadcast,
516
+ or `mode:"peak", target:-1`. Locked clips are skipped unless `force:true`.
517
+ The inspector's **Normalize** in the editor does the same measurement.
518
+ - **Fade curves** — a `fade` transition on an audio clip may carry `curve`:
519
+ `"power"` (constant power — the crossfade default, no dip in the middle),
520
+ `"linear"` (constant gain — for crossfading the *same* material, e.g. a split
521
+ of one take) or `"exp"` (slow start, natural fade to silence). No `curve` keeps
522
+ the original eased shape. Only the volume follows the curve; picture fades
523
+ are unchanged. `{transitionOut:{type:"fade", duration:3, curve:"exp"}}`.
524
+ - **Crossfades** — overlap A and B on one track and give A
525
+ `transitionOut:{type:"fade", duration:d, curve:"power"}` and B
526
+ `transitionIn` the same, with `d` = the overlap. **Shift+D** in the editor does
527
+ it for you: it borrows the overlap from spare media on both sides of the cut
528
+ (half each, or more from the side that has it), so nothing after the cut
529
+ moves; for a linked shot the stems crossfade and B's picture dissolves in.
530
+ - **Auto-duck** — `fablecut_auto_duck {clipIds:[music…], under?:["A1"], amount?:-12,
531
+ threshold?:-40, attack?:0.3, release?:0.6}` finds where the voice tracks
532
+ (default: every other audio track) are above `threshold` dBFS — short gaps are
533
+ bridged so the music doesn't pump between words — and writes `keyframes.duck`
534
+ on the music: ramp down `attack` s before speech, hold, back up over
535
+ `release` s. It replaces earlier ducking; `amount:0` clears it. Same as the
536
+ inspector's **Auto-duck** section or the clip menu's *Auto-duck under other tracks*.
537
+ - In the editor, each audio clip shows its **volume line** (yellow; dashed =
538
+ after ducking): drag it to change the level, Ctrl/Cmd-click to add a keyframe,
539
+ drag a point to move it, Alt-click or double-click to remove one. The corner
540
+ **fade grips** drag a constant-power fade in or out. Both edit linked stems together.
541
+ - Use **gain** for "this clip is too quiet / loud", **volume keyframes** for
542
+ shaping, **duck** for dipping under dialogue, the **track fader** to
543
+ balance whole lanes (dialogue against the music bed), and a **bus** when
544
+ several lanes need the same processing or one fader.
545
+
546
+ ### Color
547
+
548
+ A clip's color grade lives in `props.grade` on any video, image, svg or
549
+ adjustment clip (an adjustment layer grades everything below it). It runs on
550
+ the GPU (WebGL2; CPU fallback) **before** the clip is composited, in preview and
551
+ export alike, so it comes before the Filter / color sliders and presets, which
552
+ still apply on top. In the editor: the **Color** workspace (top bar) shows
553
+ scopes beside the program monitor and the **Color** tab beside the Inspector
554
+ has the wheels and sliders for the selected clip.
555
+
556
+ The grade is sparse: only keys that differ from neutral are stored.
557
+
558
+ | key | neutral | range | does |
559
+ |---|---|---|---|
560
+ | `exposure` | 0 | −5…5 | stops, in linear light |
561
+ | `temp` · `tint` | 0 | −100…100 | white balance in linear light: temp > 0 warmer, tint > 0 magenta (±100 ≈ ±1 stop per channel) |
562
+ | `lift` · `gamma` · `gain` · `offset` | [0,0,0,0] | each −1…1 | colour wheels, `[r, g, b, master]`; a channel's amount is its own value + master. offset adds, lift raises blacks keeping white (`x + l·(1−x)`), gain multiplies (`x·(1+g)`), gamma bends mids (`x^(1/2^γ)`, > 0 brighter) |
563
+ | `contrast` · `pivot` | 1 · 0.435 | 0…3 · 0.05…0.95 | log-style contrast `pivot·(x/pivot)^c` — blacks never crush |
564
+ | `blacks` `shadows` `midtones` `highlights` `whites` | 0 | −100…100 | tone bands on luma, darkest → brightest (smooth, overlapping) |
565
+ | `lowSoft` · `highSoft` | 0 | 0…100 | soft rolloff into black / white instead of a hard clip |
566
+ | `saturation` | 100 | 0…200 | around Rec.709 luma |
567
+ | `curves` | — | `{y?, r?, g?, b?}` | tone curves, each `[[x, y], …]` on 0…1 (see Curves below) |
568
+ | `hueHue` · `hueSat` · `hueLuma` · `satLuma` | — | `[[x, value], …]` | hue / saturation curves (see Curves below) |
569
+ | `layers` | — | up to 8 | secondaries: grades limited by a qualifier and / or a mask (see Layers below) |
570
+ | `on` | true | — | `false` bypasses the grade without losing it |
571
+
572
+ Order: exposure + white balance → offset → lift → gain → gamma → contrast →
573
+ tone bands → rolloff → saturation → curves (luma, then R G B) → hue curves →
574
+ layers, in order.
575
+
576
+ **Curves.** Point lists drawn through a smooth curve that never overshoots
577
+ (monotone cubic) — at most 24 points each.
578
+
579
+ | key | x | value | neutral | does |
580
+ |---|---|---|---|---|
581
+ | `curves.y` | level 0…1 | level 0…1 | the diagonal | luma curve: brightness only, the colour is kept |
582
+ | `curves.r` / `.g` / `.b` | level 0…1 | level 0…1 | the diagonal | one channel |
583
+ | `hueHue` | hue 0…360° | shift −180…180° | 0 | rotate one hue toward another |
584
+ | `hueSat` | hue 0…360° | factor 0…2 | 1 | saturate / desaturate one hue (0 = grey) |
585
+ | `hueLuma` | hue 0…360° | −0.5…0.5 | 0 | brighten / darken one hue (scaled by its saturation, so greys never move) |
586
+ | `satLuma` | saturation 0…1 | −0.5…0.5 | 0 | brighten / darken by how saturated a pixel is |
587
+
588
+ Tone curves get `[0, 0]` and `[1, 1]` added when missing, so
589
+ `curves:{y:[[0.25, 0.2], [0.75, 0.8]]}` is a gentle S. Hue curves wrap around
590
+ 360°, and **a hue curve with one point is a band**: that value at the hue,
591
+ easing back to neutral 40° either side — `hueSat:[[220, 0.6]]` mutes blues
592
+ only. Hues: red 0, yellow 60, green 120, cyan 180, blue 240, magenta 300;
593
+ skin sits near 20–30. A curve equal to neutral is dropped.
594
+
595
+ In the editor the **Curves** section of the Color tab edits them: tabs for
596
+ Curves (Y / R / G / B) and the four hue curves; click to add a point on the
597
+ curve, drag to move it, double-click to remove it, **Pick** then click the
598
+ monitor to mark that colour's level or hue on the curve (new points snap to
599
+ the mark), **Reset** clears the curve shown.
600
+
601
+ **Layers (secondaries).** Each layer is a grade of its own — any keys above
602
+ except `layers` — mixed into the picture where its **matte** says:
603
+ matte = qualifier × mask (either may be left out; with neither, the layer
604
+ grades the whole frame). Layers run after the primary grade, in order, and
605
+ each one's qualifier looks at the picture as it reaches that layer.
606
+
607
+ ```jsonc
608
+ "layers": [
609
+ { "name": "Skin", "qualifier": { "hue": [25, 40, 20], "sat": [0.15, 0.7, 0.1] },
610
+ "saturation": 90, "hueHue": [[25, -4]] },
611
+ { "name": "Vignette", "mask": { "shape": "ellipse", "x": 0.5, "y": 0.5, "w": 0.8, "h": 0.9, "feather": 0.25, "invert": true },
612
+ "exposure": -0.6 }
613
+ ]
614
+ ```
615
+
616
+ | key | does |
617
+ |---|---|
618
+ | `name` · `on` | a label; `on:false` turns the layer off |
619
+ | `qualifier.hue` | `[centre°, width°, soft°]` — hues within ±width/2 of the centre, easing out over `soft`; wraps through red. Greys carry no hue, so a hue range leaves them out |
620
+ | `qualifier.sat` · `qualifier.luma` | `[low, high, soft]` on 0…1 — saturation (HSV) / Rec.709 luma between low and high |
621
+ | `qualifier.invert` | select everything else |
622
+ | `mask.shape` | `ellipse` · `rect` · `poly` |
623
+ | `mask.x` · `mask.y` | centre, as fractions of the clip's picture (0…1, y down) |
624
+ | `mask.w` · `mask.h` | size, fractions of the picture's width / height (ellipse, rect) |
625
+ | `mask.rotation` | degrees, clockwise |
626
+ | `mask.feather` | soft edge, a fraction of the picture's height (0…0.5), centred on the outline |
627
+ | `mask.points` | poly: 3–16 `[dx, dy]` corners around `x, y` (fractions of width / height) |
628
+ | `mask.invert` | grade outside the shape |
629
+ | `mask.keys` | animate it: `[{t, x?, y?, w?, h?, rotation?, feather?, ease?}]`, `t` in seconds from the clip's start; each param eases between the keys that set it (`ease` on the arriving key, as for clip keyframes) |
630
+
631
+ Masks live in the clip's own picture, so they follow the clip when it is
632
+ moved, scaled, rotated, cropped or flipped. On an adjustment layer they are
633
+ fractions of the canvas.
634
+
635
+ In the editor the strip under **Copy / Paste** lists **Primary** and the
636
+ layers; **+ Layer** adds one. With a layer picked, the wheels, sliders and
637
+ curves edit that layer, and three more sections appear: the layer's name,
638
+ **On**, **Matte** (the monitor shows what it selects, white = graded), ↑ ↓ to
639
+ reorder, **Delete**; **Qualifier** — **Pick** then click a colour in the
640
+ monitor to select its hue, saturation and luma, then tune the ranges; and
641
+ **Mask** — pick a shape and drag it on the monitor (inside moves it, the
642
+ handles resize it, the knob rotates it, Shift snaps to 15°; on a poly,
643
+ Ctrl-click adds a point and double-click removes one). **◆ Key** keys the
644
+ shape at the playhead; once it has keys, every change to the shape keys.
645
+
646
+ **Wheel colours.** A wheel's `r, g, b` normally carry no brightness: they are a
647
+ point on the vectorscope's Cb / Cr plane, so pushing a wheel toward a colour
648
+ moves the picture's trace toward that colour's target. To tint shadows teal by
649
+ about 0.03: `lift: [-0.034, 0.008, 0.012, 0]` (r = 1.5748·Cr, g = −0.1873·Cb −
650
+ 0.4681·Cr, b = 1.8556·Cb, with Cb = 0.0065, Cr = −0.0216 here). Small values go a
651
+ long way — ±0.02…0.05 on lift and offset, ±0.05…0.15 on gamma and gain.
652
+
653
+ **Agents** set a grade with the patch op `setGrade` and check it with
654
+ `fablecut_scopes`:
655
+
656
+ - `{op:"setGrade", id:"c_a", grade:{exposure:0.3, temp:-15, contrast:1.15}}` — merges
657
+ into the clip's grade key by key; `null` on a key resets it; `replace:true`
658
+ starts from neutral; `grade:null` clears it; `ids:[…]` grades several clips
659
+ at once. `curves` merges per channel: `grade:{curves:{r:[[0.5, 0.55]]}}`
660
+ keeps the other channels, `curves:{y:null}` removes the luma curve. `layers`
661
+ replaces the whole layer list. Unknown keys or bad values refuse the whole patch. Locks apply
662
+ (`force:true` as elsewhere).
663
+ - `{op:"setGradeLayer", id, layer, set:{…}}` edits one layer: `layer` is an
664
+ index (0 = first) or a name. A name that isn't there yet — or the index one
665
+ past the end — adds a layer. `set` takes any layer key and merges like
666
+ `setGrade`; `qualifier` and `mask` also merge key by key (`mask:{x:0.6}`
667
+ moves it, `qualifier:{luma:null}` drops the luma range). `replace:true`
668
+ starts the layer from scratch (keeping its name), `remove:true` deletes it.
669
+ `ids:[…]` works too.
670
+ - `fablecut_scopes {time}` renders the graded frame at `time` (default: the
671
+ playhead) exactly as export would — an open editor tab, else headless — and
672
+ returns numbers: luma min / 1st percentile / median / mean / 99th percentile /
673
+ max (0–1), the % of pixels at pure black and pure white, mean R/G/B, average
674
+ saturation, and the midtones' colour cast (hue name, hue angle, strength;
675
+ "neutral" below 0.01), plus the clips on screen and their grades. With an
676
+ `exportFrame` it measures the delivered crop.
677
+ - `fablecut_scopes {time, matte:{clip, layer}}` also measures what one layer
678
+ selects: the matte's coverage (% of that clip's own picture, measured
679
+ alone — other clips and adjustment layers don't count) and the levels, mean
680
+ RGB, saturation and cast of the selected pixels after grading. A coverage
681
+ near 0 means the qualifier or mask misses; check it before trusting a
682
+ secondary.
683
+
684
+ Reading the numbers: a normally exposed shot has its median near 0.35–0.5;
685
+ `whitePct` above ~1 means clipped highlights (lower `highlights` / `whites`, or
686
+ add `highSoft`); `blackPct` above ~2 means crushed shadows. A cast strength
687
+ above ~0.03 on a scene that should be neutral wants white balance: move `temp`
688
+ against the cast (orange / yellow → lower temp; blue / cyan → raise it) and
689
+ `tint` against green / magenta, then measure again.
690
+
691
+ Secondary workflow: grade the whole shot first (primary), then add a layer
692
+ for the one thing that needs its own correction — e.g. skin:
693
+ `{op:"setGradeLayer", id, layer:"Skin", set:{qualifier:{hue:[25, 40, 20], sat:[0.12, 0.7, 0.1]}, saturation:92}}`,
694
+ then `fablecut_scopes {matte:{clip:id, layer:0}}` — expect a coverage that
695
+ matches how much of the frame is skin and a cast near orange (hue ~25–35°).
696
+ A sky: `qualifier:{hue:[210, 50, 25], luma:[0.45, 1, 0.1]}` with `hueSat:[[210, 1.25]]`.
697
+ A vignette or a relight: a feathered ellipse `mask` with `invert:true` and
698
+ `exposure:-0.5`, or without `invert` and `exposure:+0.3` on a face; give it
699
+ `keys` to follow a move.
700
+
701
+ Workflow for matching shots: grade the hero shot, measure it, then grade every
702
+ other shot of the scene until its median and cast land near the hero's. In the
703
+ editor, **Copy** / **Paste** on the Color tab pastes one clip's grade onto every
704
+ selected clip.
705
+
706
+ ### Semantics
707
+
708
+ - Rendering order: V1 is drawn first, then V2, V3, … on top. SVG/image/text clips
709
+ go on any V track (higher V lanes are handy overlay lanes). Add lanes with the
710
+ timeline **+V** / **+A** buttons (or by listing them in `tracks`); up to 16 per kind.
711
+ Right-click an empty track header → **Remove track** (disabled if the lane has
712
+ clips or is the last video/audio track).
713
+ - **Audio tracks A1–An** hold both standalone audio files *and* linked companions
714
+ for imported video. Dropping a video creates the picture on a V track
715
+ (`props.volume: 0` so it isn't doubled) plus one `kind:"audio"` clip per source
716
+ channel on consecutive A-tracks that share a `linkGroup` (and the same
717
+ `mediaId` / timing). Each stem is mono-isolated then placed with `pan` (defaults
718
+ preserve stereo: L `−1`, R `+1`). Standalone music/SFX also live on A-tracks. Linked partners
719
+ move/trim/split together — edit timing on any member of the group; do not treat
720
+ A-tracks as music-only.
721
+ - **Three track switches** (Premiere-style), all in the track header and all
722
+ persisted on `project.json`:
723
+ - **Enable (eye / speaker)** → `disabledTracks`. Output only: a disabled lane
724
+ is left out of preview, audio and export. It does *not* affect editing.
725
+ - **Target (click the track name — lit = targeted)** → `untargetedTracks`.
726
+ Decides which lanes an edit touches when nothing is selected: split at
727
+ playhead (S), insert ripple (`,`), replace punches (`.`), ripple-delete
728
+ shifting (⇧Del), close gap (⇧G), next gap (G), split / trim at IN/OUT
729
+ (T / ⇧T) and jump to cut (↑ / ↓). Source placement follows targeting too:
730
+ the picture lands on V1 if it is targeted, else the lowest targeted video
731
+ lane (none → the edit lands audio-only); stems land on the first two
732
+ targeted audio lanes. A **selection** is always edited wherever it sits.
733
+ - **Lock (padlock)** → `lockedTracks`. Nothing on a locked lane can be
734
+ moved, trimmed, split, deleted or edited in the inspector, and ripples leave
735
+ it where it is while the other lanes shift. A locked lane is never an edit
736
+ target. Typical use: lock the music bed, then ripple the picture freely.
737
+ - **Clip lock / enable** — right-click a clip, or use the toggles at the top of
738
+ the inspector. `locked: true` pins one clip the same way a locked lane does.
739
+ `disabled: true` (⇧E) keeps the clip on the timeline but drops it from
740
+ preview, audio and export — like a disabled lane, for a single clip. Both
741
+ apply to the clip's whole linked group.
742
+ - **Locks and linked groups** — a linked A/V group with *any* locked member
743
+ (its own flag or its lane) counts as locked as a whole, so sync is never
744
+ broken by a partial move.
745
+ - **Sync lock** — edits that shift clips move the targeted lanes; linked AV
746
+ partners always ride along, even on an untargeted lane, and locked groups
747
+ stay put. On reload `relinkClips` rebuilds `linkGroup` from matching
748
+ mediaId / start / in / duration.
749
+ - **Unlink / link (Ctrl/Cmd+L)** — unlinking a group removes its `linkGroup`
750
+ and stamps `unlinked: true` on each member, so the reload-time relink leaves
751
+ them apart; picture and audio then move, trim and delete separately. Link
752
+ (select one video clip plus its audio, Ctrl/Cmd+L) only joins clips of the
753
+ same file that line up exactly — same start, in and duration — because a
754
+ link here means identical timing.
755
+ - **Agents and locks** — `fablecut_patch_project` refuses `updateClip` /
756
+ `removeClip` on locked material (own flag, locked lane, or a linked partner
757
+ of one) and `addClip` onto a locked lane. Treat locked clips as the user's
758
+ finished work: leave them alone. Only when the user asks you to change one,
759
+ pass `force: true` on that op, or unlock first with
760
+ `updateClip set:{locked:null}` (always allowed). `fablecut_set_project`,
761
+ REST `PUT` and direct file edits are not guarded — respect locks there too.
762
+ - Media is `fit`-ted to the canvas (default "contain"), then crop → scale/x/y/
763
+ rotation → flips apply.
764
+ - `props` keys are all optional — missing keys get the defaults above.
765
+ - Video/audio clips must satisfy `in + duration×speed ≤ media.duration`.
766
+ - Keyframes fully override the static prop value while present; they are
767
+ clip-local and are re-based automatically when clips are split or trimmed.
768
+ - Transitions modulate the evaluated props (fade also fades audio); they render
769
+ on top of keyframes, so both can coexist.
770
+ - Same-track overlap IS the crossfade idiom: overlap A and B by ~1 s and give B
771
+ `transitionIn: {type:"fade"}` (for audio also A `transitionOut`, both with
772
+ `curve:"power"` — see "Audio mix").
773
+ - A cut/split is just two clips: first with `duration: t`, second with
774
+ `start: +t, in: +t×speed, duration: rest`.
775
+ - **Edit tools** (timeline toolbar picker, or V / B / R / Y / U) change what a
776
+ clip drag does. Agents run the same edits with the patch ops `rippleTrim`,
777
+ `roll`, `slip` and `slide` (see "Timeline edit ops").
778
+ - **Selection (V)** — move and trim, as always.
779
+ - **Ripple edit (B)** — drag a clip edge; everything after it on the targeted
780
+ tracks (and the clip's own lanes) moves with it, so no gap opens or closes.
781
+ Dragging a head keeps the clip's start and trims its source In.
782
+ - **Rolling edit (R)** — drag a cut; the clip on one side lengthens by what
783
+ the other loses, nothing else moves.
784
+ - **Slip (Y)** — drag a clip to change its source In only (dragging left shows
785
+ later source); position and length stay. Video / audio clips only.
786
+ - **Slide (U)** — drag a clip along its track; the clip before it trims its
787
+ tail and the clip after it its head, so the sequence length stays.
788
+ All four are clamped to the available source media, MIN_DUR and the free
789
+ room on the track, take linked partners with them, and refuse on locked
790
+ clips; a readout by the pointer shows the offset while dragging.
791
+ - **Lift (;) / Extract (')** — remove IN→OUT on the targeted tracks. Lift
792
+ leaves the gap; Extract closes it. Linked partners are cut too, locked clips
793
+ stay whole, and IN / OUT clear afterwards.
794
+ - **Jump to cut** — `↑` / `↓` move the playhead to the previous / next clip
795
+ In or Out. Selection first (that clip’s start then end); no selection walks
796
+ targeted-track cuts. Source monitor: 0 / In / Out / duration. Does not change
797
+ `project.json`.
798
+ - `bgRemove` and `chromaKey` can combine with all filters; heavy pixel work is
799
+ automatic (only runs when those props are set).
800
+
801
+ ## Remake a reference video (analyze → blueprint → rebuild)
802
+
803
+ Given a reference edit (a reel/montage the user likes), FableCut can analyze it
804
+ and hand back an **edit blueprint** so the same idea can be rebuilt with
805
+ different footage over the same music.
806
+
807
+ **Run the analysis** (any of):
808
+ - MCP: `fablecut_analyze_reference {path:"C:\\…\\ref.mp4"}` (absolute path or an
809
+ existing `/media/...` src; copies the file into `media/` if needed)
810
+ - REST: `POST /api/analyze` body `{"src":"/media/ref.mp4", "threshold":0.3, "music":true}`
811
+ (GET `/api/analyze?src=/media/ref.mp4` returns the cached result)
812
+ - CLI: `node analyze.js media/ref.mp4` (results also cached in `./analysis/<name>.json`)
813
+
814
+ **The blueprint** (needs ffmpeg on PATH):
815
+ ```jsonc
816
+ {
817
+ "duration": 21.4, "fps": 30, "width": 1080, "height": 1920,
818
+ "cuts": [1.8, 3.1, ...], // detected shot boundaries, seconds
819
+ "shots": [ // one entry per shot between cuts
820
+ { "index": 0, "start": 0, "end": 1.8, "duration": 1.8, "energy": 42 } ],
821
+ "avgShotLen": 1.4, "cutsPerSecond": 0.7,
822
+ "beats": [0.51, 1.02, ...], // music onsets — snap cut points to these
823
+ "bpm": 118, // detected tempo
824
+ "energy": { "step": 0.5, "values": [12, 30, ...] }, // loudness curve 0–100
825
+ "drop": 8.5, // biggest musical rise — the money moment
826
+ "music": { "name": "ref-music.m4a", "src": "/media/…", "mediaId": "m_x" }
827
+ } // ^ extracted + registered by the MCP tool
828
+ ```
829
+ `threshold` tunes cut sensitivity (default adapts 0.30→0.20→0.12): lower it if
830
+ obvious cuts were missed, raise it if motion is being misread as cuts.
831
+
832
+ **Rebuild recipe** — the analysis is deterministic; the creative mapping is yours:
833
+ 1. `setProject`: copy the reference's `width/height/fps`; write `beats`
834
+ (or the `cuts`) into `markers` so the user sees the grid.
835
+ 2. Music: the extracted track on A1, `in:0, duration:<ref duration>`.
836
+ 3. Structure: one clip per `shots[]` entry on V1 at the same `start`/`duration`
837
+ (hard cuts by default — that's what shot detection saw). Pick source footage
838
+ whose motion matches each shot's `energy` (calm ≤40, action ≥70), and choose
839
+ each clip's `in` so something interesting happens inside the window.
840
+ 4. The `drop`: put the hero shot there; classic garnish = a speed ramp landing
841
+ on it, an impact `adjust` layer (shake+rgbSplit), or a whip transition.
842
+ 5. Pacing garnish to taste: shots shorter than ~0.6 s read as beat-flashes;
843
+ `avgShotLen` tells you the reference's overall pace. Captions/grade/SFX per
844
+ the Recipes section — the blueprint gives structure, not style.
845
+
846
+ ## REST API (alternative to file editing)
847
+
848
+ - `GET /api/project` — current project JSON
849
+ - `PUT /api/project` — replace project JSON (body = full document).
850
+ **Conflict-safe**: if the body's `revision` ≤ the revision currently on disk,
851
+ the server rejects with **409** and returns `{"error":"…","revision":<current>}`.
852
+ Append `?force=1` to overwrite unconditionally. Writes are atomic (tmp file +
853
+ rename), so a crashed write never corrupts the file.
854
+ - `GET /api/media` — list files in ./media (name, src, size)
855
+ - `GET /api/library?dir=sfx|elements|svg|fonts` — list library assets
856
+ - `POST /api/upload?name=foo.mp4` — raw body saved into ./media, returns `{src}`.
857
+ MP4/MOV/M4V uploads are auto-remuxed with `+faststart` (needs ffmpeg on PATH).
858
+ Files copied straight into ./media by external tools skip this — remux big ones
859
+ yourself (`ffmpeg -i in.mp4 -c copy -movflags +faststart out.mp4`) or playback stalls.
860
+ - `POST /api/import-url` — body `{url:"https://…"}` downloads the file into
861
+ `./media/` and returns `{src,name}` (same-origin `/media/…`, like upload).
862
+ HTTPS only; rejects localhost, private/link-local/CGNAT addresses, and
863
+ redirects to those. Remote SVG is refused (`/media/*.svg` is served
864
+ same-origin as `image/svg+xml` — a scripted SVG opened as a document would
865
+ run on the editor origin). Does not write `project.json` — register the media
866
+ afterwards (UI and `fablecut_import_media` do this). Do **not** put a
867
+ raw `https://` URL (or another origin, including `localhost` on a different
868
+ port) in `media.src`: canvas CORS would taint the compositor and Fast /
869
+ WebCodecs export cannot JPEG-encode. Import into `./media` so `src` is
870
+ `/media/…`, or serve the remote with `Access-Control-Allow-Origin`.
871
+ - `POST /api/denoise` — body `{src:"/media/…", amount:"light"|"medium"|"strong"}`: writes a
872
+ noise-reduced FLAC of the whole file into `./media/` (reused if it exists) and returns
873
+ `{src, name, duration, channels}`. Does not touch `project.json` (see "Audio mix").
874
+ - `POST /api/analyze` — body `{src:"/media/ref.mp4", threshold?, music?}`: analyze a
875
+ reference video into an edit blueprint (see "Remake a reference video"); extracts
876
+ its music into ./media. `GET /api/analyze?src=…` returns the cached blueprint.
877
+ - `GET /api/events` — SSE: named event `change` when project.json, ./media or
878
+ ./library changes; named event `profiles` when `encoding-profiles.json` changes
879
+ (UI refreshes the export-profile list only — no project reload)
880
+ - Fast / WebCodecs export (browser compositor → server ffmpeg):
881
+ `GET /api/export/ffmpeg` → `{available}` · `GET /api/export/profiles[?detail=1]` →
882
+ `{default, profiles, issues}` · `POST /api/export/begin`
883
+ `{fps,name,mode?,profile?,hasAudio?,pixelFormat?,width?,height?}` → `{id,mode,profile?,label,summary}`
884
+ (`fps` is required — pass `project.fps`, no server-side default;
885
+ `mode` is `"jpeg"` (default, Fast) or `"annexb"` (WebCodecs H.264 elementary stream);
886
+ jpeg **400** if `profile` is not a defined id, or if ffmpeg rejects its args in the dry run;
887
+ optional `pixelFormat:"rgba"` plus `width`/`height` pipes raw canvas frames instead of JPEG)
888
+ · `POST /api/export/frame?id=` (JPEG body for jpeg mode; raw RGBA if `begin`
889
+ used `pixelFormat:"rgba"`; Annex-B for annexb — one POST may concatenate frames
890
+ or AUs. Must be after audio; ffmpeg is spawned on the first frame in both modes)
891
+ · `POST /api/export/audio?id=` (WAV body — must be sent before the first frame)
892
+ · `POST /api/export/end?id=[&discard=1]` → `{src}` under `/exports/`
893
+ - Export jobs (what `fablecut_export` drives): `POST /api/export/request`
894
+ `{range?, profile?, where?:"auto"|"tab"|"headless"}` → `{id, status, via, …}`
895
+ (409 if one is already running, or nothing can render it) · `GET /api/export/job?id=`
896
+ → `{status: pending|running|done|failed|cancelled, progress 0–1, src, error}` ·
897
+ `POST /api/export/job/cancel?id=`. The editor tab side: SSE event `export`
898
+ (a ticket), `GET /api/export/job/ticket?id=`, `POST /api/export/job/claim?id=`
899
+ (first tab wins, else 409), `POST /api/export/job/report?id=`
900
+ `{progress} | {status:"done", src} | {status:"failed", error}` → `{cancel}`.
901
+
902
+ ## Recipes
903
+
904
+ **Assemble a rough cut**: clips back-to-back on V1; each `start` = running sum
905
+ of previous durations.
906
+
907
+ **Insert / replace at playhead (3-point editing)**: load media into the Source
908
+ monitor (double-click a bin item or timeline clip), mark the window with I/O,
909
+ then `,` (insert icon) splits straddling clips on targeted tracks at the
910
+ playhead, ripples everything later to the right by the window length, and drops
911
+ the Source window in — video brings its linked audio stems. `.` (replace icon)
912
+ overwrites instead: punches the placement tracks (V1 + linked stems) over
913
+ [playhead, +window) with no ripple. Placement lanes are resolved against the
914
+ live track list and follow targeting — if V1 / A1 / A2 were removed or
915
+ untargeted, the lowest remaining targeted lanes of the right kind are used
916
+ instead. Locked clips are never punched or split. A locked clip loaded into
917
+ Source can't be retargeted with `.` (toast) until it is unlocked. When Source was loaded *from* a timeline
918
+ clip, `.` instead retargets that clip's In/Out (and its linked stems) and
919
+ ripples later clips on its tracks if the duration changed.
920
+
921
+ **Ripple delete**: select clip(s), then ⇧Del or the timeline-toolbar **Ripple
922
+ delete** — removes the selection (linked partners included) and pulls later
923
+ clips left on each targeted track to close the gap. Plain Del lifts (leaves a
924
+ gap). Sync lock applies: linked partners on untargeted tracks move with the
925
+ ripple, and locked clips are neither deleted nor moved. An unselected clip that merely *overlaps* the deleted range stays put —
926
+ its overlap ends up bridging the shifted-in clip, so a crossfade across the cut
927
+ survives.
928
+
929
+ **Tighten a cut without breaking the rest**: Ripple edit (B) on the tail of the
930
+ shot that runs long — the rest of the edit follows. To change where a cut
931
+ falls between two shots without shifting anything else, Rolling edit (R) the
932
+ cut. To keep a shot's timing but use a better take of the same moment, Slip
933
+ (Y) it. To pull a whole segment out of the middle: set IN / OUT around it and
934
+ Extract (').
935
+
936
+ **Jump to cut**: select a clip, then ↑ / ↓ — playhead snaps to its In, then
937
+ Out (further taps walk neighboring cuts). No selection → previous / next cut
938
+ on targeted tracks. Source monitor: same keys jump among 0 / In / Out / duration.
939
+
940
+ **Protect finished work**: lock the lanes you are done with (`lockedTracks`,
941
+ or the padlock), e.g. `setProject {lockedTracks:["A3"]}` for a music bed. Now
942
+ insert, ripple delete and close gap re-time the picture without touching it.
943
+ To try an alternative without deleting a clip, disable it (⇧E / `disabled:true`).
944
+
945
+ **Title card**: `{kind:"text", mediaId:null, track:"V2", props:{text,fontSize,color}}`.
946
+
947
+ **Music bed**: audio file on A1, `props.volume: 0.3`, trim with `in`/`duration`.
948
+
949
+ **Level a voice-over edit**: `fablecut_normalize_audio {clipIds:[…every VO clip…], target:-16}`
950
+ so all takes sit at one loudness, then keep the music on its own lane and pull
951
+ that lane down: `{op:"setTrack", id:"A3", set:{gain:-14}}`. Then dip it under the
952
+ voice: `fablecut_auto_duck {clipIds:[<music clip>], under:["A1"], amount:-10}`.
953
+
954
+ **Center a mono stem**: linked or standalone audio clip with `props.pan: 0` (inspector **Pan** slider, −1…+1). Stereo video stems default to L `−1` / R `+1`; set A1 to `0` to center that channel in the mix.
955
+
956
+ **Cinematic grade**: `props.filterPreset: "teal-orange"` (tweak with
957
+ `temperature`/`vignette` on top).
958
+
959
+ **Balanced, contrasty base grade** (then measure with `fablecut_scopes`):
960
+ `{op:"setGrade", id, grade:{contrast:1.15, pivot:0.42, shadows:-10, highlights:-20, highSoft:30, saturation:108}}`.
961
+ Teal shadows / warm highlights: add `lift:[-0.034, 0.008, 0.012, 0]` and `gain:[0.04, 0, -0.06, 0]`.
962
+ Film-style S-curve, muted greens, yellows pushed toward orange:
963
+ `grade:{curves:{y:[[0.25, 0.2], [0.75, 0.82]]}, hueSat:[[115, 0.7]], hueHue:[[60, -12]]}` —
964
+ then `fablecut_scopes` to check the blacks didn't crush.
965
+ A grade on an adjustment layer over the whole edit = one look for every shot.
966
+
967
+ **Green-screen composite**: subject clip on V2 with
968
+ `props: {chromaKey:"#00ff00", chromaTolerance:30, chromaSoftness:15}`,
969
+ background footage on V1.
970
+
971
+ **Remove background without a green screen** (people):
972
+ `props: {bgRemove:true}` — then put anything behind it on V1.
973
+
974
+ **Picture-in-picture**: clip on V3 with
975
+ `props: {scale:0.35, x:380, y:-200, cornerRadius:24}` — add
976
+ `transitionIn: {type:"slide-right", duration:0.5}` to fly it in.
977
+
978
+ **Slow motion / timelapse**: `props.speed: 0.5` (half speed) or `4` (4×).
979
+ Remember the source window is `duration × speed`.
980
+
981
+ **Speed ramp** (the reel move — fast into slow on the beat):
982
+ `keyframes: { speed: [ {t:0, v:3}, {t:1.1, v:3}, {t:1.3, v:0.4, "ease":"ease-out"} ] }`
983
+ — sync the drop (t:1.3) to a marker; add `transitionIn:{type:"whip",duration:0.2}` before it.
984
+
985
+ **Impact hit**: adjustment layer, 0.25–0.4 s at the hit:
986
+ `{kind:"adjust", track:"V3", props:{shake:20, rgbSplit:8}}` + `impact-hit.mp3`
987
+ from `library/sfx` on A2.
988
+
989
+ **VHS / glitch look**: `props: {rgbSplit:4, grain:35, saturation:120}` +
990
+ `library/elements/vhs-scanlines.svg` on V3 with `blend:"soft-light", fit:"stretch"`.
991
+ Cut between shots with `transitionIn:{type:"glitch",duration:0.3}`.
992
+
993
+ **Film look**: adjustment layer across the whole edit:
994
+ `props: {filterPreset:"cinematic", grain:18}` — one clip grades every shot.
995
+
996
+ **Neon caption**: `props: {glow:60, glowColor:"#22d3ee", color:"#ffffff",
997
+ font:"Bebas Neue", textAnim:"wave"}` on a dark shot.
998
+
999
+ **Light leak accent**: `library/elements/light-leak-warm.svg` on V3,
1000
+ `props:{blend:"screen", fit:"cover", opacity:0.7}`, 1–2 s at scene changes,
1001
+ `transitionIn/Out: fade`.
1002
+
1003
+ **Light-leak / dust overlay**: element from `library/elements` on V3 with
1004
+ `props: {blend:"screen", opacity:0.6, fit:"cover"}`.
1005
+
1006
+ **Reel caption**: text clip with `props: {textAnim:"word-pop", wordRate:0.15,
1007
+ strokeWidth:6, bgColor:"#000000", bgOpacity:0.45, fontSize:88}`. `karaoke`
1008
+ dims words until "spoken"; `typewriter` for terminal vibes.
1009
+
1010
+ **RTL / Hebrew / Arabic caption**: omit `direction` (defaults to `"auto"`) or set
1011
+ `direction:"rtl"` explicitly; pick a font with the script's glyphs (Google Fonts
1012
+ work). Animations wipe and stagger in reading order automatically. `letter-pop` /
1013
+ `wave` animate whole words on Arabic/Indic lines (not isolated letters) so
1014
+ cursive joining stays correct.
1015
+
1016
+ **Branded title**: `props: {font:"Bebas Neue", weight:700, letterSpacing:6,
1017
+ uppercase:true, color:"#ffffff", color2:"#7b6cff", textShadow:20}` — any Google
1018
+ Font name just works.
1019
+
1020
+ **Kinetic font-cut title** (rhythmic typeface cuts on the beat, then settle):
1021
+ `props: {font:"Bebas Neue", fontSize:120, uppercase:true, color:"#ffd166",
1022
+ textAnim:"font-cut", fontCutSet:["Anton","Bebas Neue","Archivo Black","Oswald"]}`.
1023
+
1024
+ **Elegant clip-on title** (letters wipe in): `props: {font:"Playfair Display",
1025
+ fontSize:88, color:"#ffffff", color2:"#ffd166", letterSpacing:2,
1026
+ textAnim:"clip-reveal"}` — centered, one clean sweep.
1027
+
1028
+ **Lower-third reveal**: `props: {font:"Archivo Black", fontSize:92,
1029
+ uppercase:true, textAnim:"rise-mask", y:<height*0.30>}` — the line rises from
1030
+ behind its baseline. Pair with a `serifDrop`/`zoom-in` kicker above it.
1031
+
1032
+ **Custom font**: drop `MyBrand.ttf` into `library/fonts/`, then
1033
+ `props.font: "MyBrand"`.
1034
+
1035
+ **Animated sticker**: author an SVG (convention above) into `library/svg/`,
1036
+ register `{kind:"svg", src:"/library/svg/foo.svg"}`, clip on V2/V3. Scale/move/
1037
+ rotate with normal props & keyframes; the SVG's own CSS animation plays on top,
1038
+ frame-accurately.
1039
+
1040
+ **Whoosh on a cut**: 0.4 s sfx clip from `library/sfx` on A2 aligned to the cut.
1041
+
1042
+ **Ken Burns** (slow push on a photo):
1043
+ `keyframes: { scale:[{t:0,v:1},{t:D,v:1.2,ease:"linear"}], x:[{t:0,v:0},{t:D,v:-40,ease:"linear"}] }`.
1044
+
1045
+ **Crossfade**: overlap two clips on the same track by ~1 s; later clip gets
1046
+ `transitionIn: {type:"fade", duration:1}`. For audio give the earlier clip the
1047
+ matching `transitionOut` and both `curve:"power"` (or select a clip and press
1048
+ Shift+D in the editor).
1049
+
1050
+ **Whip-pan cut**: A gets `transitionOut:{type:"whip",duration:0.25}`, B gets
1051
+ `transitionIn:{type:"whip",duration:0.25}`.
1052
+
1053
+ **Beat-synced cut**: write beat times into `markers`, then align clip `start`s
1054
+ to them (the UI also snaps drags to markers).
1055
+
1056
+ **Label the structure**: write named, coloured markers for sections the user
1057
+ can jump between (⇧M / Alt+⇧M, or the Markers list):
1058
+ `setProject {markers:[{t:0,label:"intro",color:"blue"},{t:8.5,label:"drop",color:"red"},{t:24,label:"outro",color:"purple"}]}`.
1059
+ `setProject` replaces the whole list — include the existing markers you want to keep.
1060
+
1061
+ **Music fade-out**: audio clip `keyframes: { volume:[{t:D-3,v:0.8},{t:D,v:0}] }`
1062
+ or simply `transitionOut: {type:"fade", duration:3, curve:"exp"}`.
1063
+
1064
+ **Pulse / emphasis**: `keyframes: { scale:[{t:0,v:1},{t:0.3,v:1.12},{t:0.6,v:1}] }`.
1065
+
1066
+ **Reframe horizontal → vertical**: keep a wide composition canvas and crop for
1067
+ delivery — e.g. `width:1920, height:1080` with
1068
+ `exportFrame:{x:656,y:0,w:608,h:1080}` (9:16 fitted to canvas height). Position
1069
+ footage with clip `x`/`y`/`scale`; the export frame can be dragged in the UI.
1070
+ Omit `exportFrame` to export the full canvas.
1071
+
1072
+ **Vertical reel**: set project `width:1080, height:1920`; use the UI's safe-area
1073
+ guides (▦) to keep captions out of platform UI zones.
1074
+
1075
+ **Project frame rate**: set `fps` in `project.json` (or the Program Monitor FPS
1076
+ dropdown — 24 / 25 / 30 / 50 / 60). Preview stepping, timecode frames, Fast /
1077
+ WebCodecs / Realtime export, and `/api/export/begin` all use this value; pass the same
1078
+ `fps` when starting an export via the API.
1079
+
1080
+ ## Export
1081
+
1082
+ Export is the Export button → dialog, or `fablecut_export` from an agent (below). Three engines:
1083
+
1084
+ 1. **Fast** — browser renders each frame with the normal compositor (SVG, keys,
1085
+ AI masks), streams JPEGs + an offline WAV mix to the server; a single ffmpeg
1086
+ pass encodes them via an **encoding profile** into `./exports/`. Quality /
1087
+ software path; keeps rendering if you switch tabs.
1088
+ 2. **WebCodecs** — same frame-accurate compositor loop, but the browser’s
1089
+ `VideoEncoder` produces Annex-B H.264 (Main 4:2:0) and the server stream-copies
1090
+ (`-c:v copy`) while muxing the WAV. Faster uploads, less server CPU. Requires
1091
+ Chromium-class `VideoEncoder` with `avc: { format: "annexb" }` plus ffmpeg.
1092
+ Encoding profiles do not apply (the bitstream is already encoded). No ffmpeg-style
1093
+ CRF — quality is bitrate + VBR/CBR (export dialog; remembered in localStorage).
1094
+ Optional `bitrateMode: "quantizer"` (fixed QP) exists in the spec but is rarely
1095
+ supported by hardware encoders with Annex-B. Unavailable while an `exportFrame`
1096
+ crop is set — use Fast for cropped delivery.
1097
+ 3. **Realtime (MediaRecorder)** — automatic offline fallback when the server,
1098
+ ffmpeg, or WebCodecs is unavailable. Plays the timeline once and records it;
1099
+ keep the tab focused.
1100
+
1101
+ The exported span is chosen in the Export dialog (**Range**: Entire
1102
+ timeline / IN–OUT). IN–OUT is the default when `inPoint` / `outPoint` are
1103
+ set; pick Entire timeline to keep those markers for split/trim. Effective
1104
+ IN/OUT export bounds are clamped to `projDur` so an IN past the last clip
1105
+ cannot produce a one-frame black file.
1106
+
1107
+ 1. Entire timeline (or no markers) → the whole timeline
1108
+ 2. IN–OUT, IN only → from `inPoint` to the end
1109
+ 3. IN–OUT, OUT only → from the start to `outPoint`
1110
+ 4. IN–OUT, both → the range between them
1111
+
1112
+ **Agents export with `fablecut_export`** (or `POST /api/export/request`). It is
1113
+ the same Fast export as the button — same compositor, audio mix and encoding
1114
+ profile — so it needs ffmpeg on PATH. The job goes to an open editor tab (the
1115
+ user sees the progress bar), or, with none open, the server starts a headless
1116
+ Chrome / Edge on the editor and closes it when the file is written
1117
+ (`FABLECUT_CHROME` picks the browser). The tab first loads the revision the
1118
+ agent last wrote, so an export right after a patch includes that patch.
1119
+
1120
+ - `fablecut_export {}` — waits, then returns the file path under `exports/`.
1121
+ Range: `inPoint`→`outPoint` when either is set, else the whole timeline;
1122
+ `range:"entire"|"in-out"` overrides. `profile:"hq"` picks an encoding profile.
1123
+ - `where:"headless"` renders in the background and leaves the user's tab alone;
1124
+ `where:"tab"` refuses if no tab is open.
1125
+ - Long edits: `wait:false` returns a job id at once; poll with `{job:"x_…"}`
1126
+ and stop one with `{cancel:"x_…"}`. One export runs at a time.
1127
+
1128
+ ### Encoding profiles (`encoding-profiles.json`)
1129
+
1130
+ User-editable at the repo root. A profile is a **raw ffmpeg argument list** plus the
1131
+ things that are not ffmpeg arguments: `jpegQuality` (browser frame quality),
1132
+ `extension` (output container), and optional `color` (output matrix / range tags).
1133
+ Edit the file while the server runs — the UI hot-reloads the profile list via an SSE
1134
+ `profiles` event (no full project reload). Fast export only; WebCodecs ignores them.
1135
+
1136
+ ```jsonc
1137
+ {
1138
+ "default": "delivery", // profile id used when nothing else is set
1139
+ "profiles": {
1140
+ "draft": {
1141
+ "label": "Draft · H.264 fast",
1142
+ "description": "Quick preview — smaller file, faster encode.",
1143
+ "jpegQuality": 0.85, // browser JPEG frame quality (0.1–1)
1144
+ "extension": ".mp4",
1145
+ // Output color (optional — defaults to BT.709 limited/tv). Independent of
1146
+ // -pix_fmt: yuv420p and yuv422p10le both typically use bt709 for HD SDR.
1147
+ "color": { "matrix": "bt709", "primaries": "bt709", "trc": "bt709", "range": "tv" },
1148
+ "args": ["-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
1149
+ "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k",
1150
+ "-movflags", "+faststart", "-shortest"]
1151
+ }
1152
+ }
1153
+ }
1154
+ ```
1155
+
1156
+ Export is **one ffmpeg pass**. The server owns the input side and the output path;
1157
+ `args` is everything in between (plus JPEG color conversion derived from `color`):
1158
+
1159
+ ```
1160
+ ffmpeg -y -f image2pipe -framerate <fps> -i - [-i audio.wav] <jpeg-color> <args…> exports/<name><extension>
1161
+ ```
1162
+
1163
+ - **There is no allow-list.** Any codec, filter, container or flag your local ffmpeg
1164
+ supports works — ProRes, DNxHD, NVENC/QSV/VideoToolbox, VP9/AV1, 10-bit, HDR tags.
1165
+ See the shipped `prores422` and `broadcast1080i50` profiles.
1166
+ - **Args are validated by ffmpeg itself**, not by a schema: when an export starts the
1167
+ server dry-runs the profile against a 0.1 s synthetic input (`lavfi`). A typo or an
1168
+ encoder your build lacks is rejected up front with ffmpeg's own message, instead of
1169
+ failing after every frame has been rendered.
1170
+ - Use the **array form** — each element is passed to `spawn` untouched, so no quoting
1171
+ is needed (`["-vf", "drawtext=text='hi there'"]` just works). A plain string is
1172
+ accepted and split on whitespace.
1173
+ - **`color`** controls JPEG→YUV conversion and stream tags (`-colorspace` /
1174
+ `-color_primaries` / `-color_trc` / `-color_range`). Default is BT.709 limited
1175
+ (`range: "tv"`). Use `"range": "pc"` for full-range masters. Do **not** put those
1176
+ flags in `args` — the engine strips them and applies `color` so vf and tags stay
1177
+ aligned. `-pix_fmt` stays in `args` (sampling / bit depth only).
1178
+ - Nothing else is injected for you: `+faststart`, `-shortest`, `-strict -2` for Opus
1179
+ in MP4 and pixel-format choices are all yours to write.
1180
+ - Frames arrive as **JPEG (4:2:0)**, so `yuv422p`/`yuv444p` cannot recover chroma the
1181
+ source never had; raise `jpegQuality` before reaching for a wider pixel format.
1182
+ Optional `pixelFormat:"rgba"` on `/api/export/begin` pipes uncompressed canvas
1183
+ frames instead (`-f rawvideo`) when a caller wants full chroma.
1184
+ - The audio mix is only present when the timeline has audio; with no audio there is a
1185
+ single input, so avoid hardcoded `-map 1:a`.
1186
+
1187
+ **Note:** Fast export pipes **composited JPEG frames** from the browser (`-f image2pipe`),
1188
+ not `-i source.mp4`. Filters and codec settings apply to that frame stream. To transcode
1189
+ an existing file verbatim, run ffmpeg directly — that is outside the compositor path.
1190
+
1191
+ **Which profile is used (priority):**
1192
+ 1. Profile picked in the Export dialog (one-off; saved to browser settings unless overridden)
1193
+ 2. `project.encodeProfile` — set via UI reload or `{op:"setProject", set:{encodeProfile:"hq"}}`
1194
+ 3. Browser setting `encodeProfile` in localStorage (set when you change the Export dropdown)
1195
+ 4. `default` in `encoding-profiles.json`
1196
+
1197
+ **MCP:** `fablecut_encode_profiles` lists profiles; `{detail:true}` includes each `args`
1198
+ array; `{profile:"hq"}` returns one profile. `fablecut_status` shows the effective profile.