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.
- package/CLAUDE.md +1198 -0
- package/LICENSE +21 -0
- package/README.md +571 -2
- package/analyze.js +289 -0
- package/app.js +13339 -0
- package/audio-fx.js +241 -0
- package/cli.js +31 -0
- package/color.js +1043 -0
- package/denoise.js +120 -0
- package/ducking.js +127 -0
- package/edit-ops.js +962 -0
- package/encode-profiles.js +302 -0
- package/encoding-profiles.json +68 -0
- package/export-jobs.js +163 -0
- package/favicon.svg +14 -0
- package/fx-worklet.js +162 -0
- package/icons/favicon-32.png +0 -0
- package/import-url.js +293 -0
- package/index.html +420 -0
- package/library/README.md +15 -0
- package/library/elements/.gitkeep +0 -0
- package/library/elements/bokeh-drift.svg +19 -0
- package/library/elements/light-leak-cool.svg +16 -0
- package/library/elements/light-leak-warm.svg +22 -0
- package/library/elements/vhs-scanlines.svg +18 -0
- package/library/elements/vignette-frame.svg +14 -0
- package/library/fonts/.gitkeep +0 -0
- package/library/fonts/Abril Fatface.woff2 +0 -0
- package/library/fonts/Anton.woff2 +0 -0
- package/library/fonts/Archivo Black.woff2 +0 -0
- package/library/fonts/Bangers.woff2 +0 -0
- package/library/fonts/Barlow.woff2 +0 -0
- package/library/fonts/Bebas Neue.woff2 +0 -0
- package/library/fonts/Bungee.woff2 +0 -0
- package/library/fonts/Caveat.woff2 +0 -0
- package/library/fonts/DM Sans.woff2 +0 -0
- package/library/fonts/Inter.woff2 +0 -0
- package/library/fonts/LICENSES.md +31 -0
- package/library/fonts/Lato.woff2 +0 -0
- package/library/fonts/Montserrat.woff2 +0 -0
- package/library/fonts/Oswald.woff2 +0 -0
- package/library/fonts/Pacifico.woff2 +0 -0
- package/library/fonts/Playfair Display.woff2 +0 -0
- package/library/fonts/Poppins.woff2 +0 -0
- package/library/fonts/Righteous.woff2 +0 -0
- package/library/fonts/Roboto.woff2 +0 -0
- package/library/fonts/Rubik.woff2 +0 -0
- package/library/fonts/Teko.woff2 +0 -0
- package/library/sfx/README.md +18 -0
- package/library/svg/ai-star.svg +17 -0
- package/library/svg/arrow-bounce.svg +18 -0
- package/library/svg/arrow-scribble.svg +13 -0
- package/library/svg/burst-lines.svg +30 -0
- package/library/svg/checkmark-success.svg +19 -0
- package/library/svg/confetti-burst.svg +23 -0
- package/library/svg/countdown-ring.svg +92 -0
- package/library/svg/equalizer.svg +18 -0
- package/library/svg/glow-orbs.svg +29 -0
- package/library/svg/heart-pop.svg +28 -0
- package/library/svg/loading-dots.svg +16 -0
- package/library/svg/location-pin.svg +25 -0
- package/library/svg/lower-third.svg +17 -0
- package/library/svg/new-badge.svg +18 -0
- package/library/svg/progress-bar.svg +16 -0
- package/library/svg/radial-pulse.svg +19 -0
- package/library/svg/rating-stars.svg +25 -0
- package/library/svg/rec-indicator.svg +41 -0
- package/library/svg/sparkles.svg +17 -0
- package/library/svg/spawning-hearts.svg +39 -0
- package/library/svg/speech-bubble.svg +23 -0
- package/library/svg/subscribe-bell.svg +29 -0
- package/library/svg/subscribe-pill.svg +22 -0
- package/library/svg/swipe-up.svg +46 -0
- package/library/svg/underline-swoosh.svg +15 -0
- package/loudness.js +135 -0
- package/mcp-server.js +1421 -0
- package/meter-worklet.js +229 -0
- package/package.json +45 -4
- package/paths.js +106 -0
- package/ruler-worker.js +120 -0
- package/server.js +775 -0
- 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.
|