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/mcp-server.js ADDED
@@ -0,0 +1,1421 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════════
2
+ FableCut MCP server — connects Claude (Code / Desktop) to the video editor.
3
+ Zero-dependency stdio JSON-RPC (Model Context Protocol).
4
+
5
+ Register once for all Claude Code sessions:
6
+ claude mcp add -s user fablecut -- npx -y fablecut mcp
7
+ or, from a clone:
8
+ claude mcp add -s user fablecut -- node "<path-to>/fablecut/mcp-server.js"
9
+
10
+ Tools: fablecut_status, fablecut_docs, fablecut_get_project,
11
+ fablecut_set_project, fablecut_patch_project, fablecut_import_media,
12
+ fablecut_analyze_reference, fablecut_encode_profiles,
13
+ fablecut_normalize_audio, fablecut_auto_duck, fablecut_export,
14
+ fablecut_scopes
15
+ ═══════════════════════════════════════════════════════════════════════════ */
16
+ "use strict";
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const http = require("http");
20
+ const { spawn, spawnSync } = require("child_process");
21
+ const { loadEncodeProfiles, listProfilesPublic, resolveProfile, profileSummary } = require("./encode-profiles");
22
+
23
+ const {
24
+ APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR, PROJECT_FILE, ensureDirs,
25
+ } = require("./paths");
26
+ const { downloadImportUrl, kindFromName, maybeFaststart } = require("./import-url");
27
+ const FX = require("./audio-fx");
28
+ const EditOps = require("./edit-ops");
29
+ const Denoise = require("./denoise");
30
+ const Color = require("./color");
31
+
32
+ /* ROOT is where the code lives (server.js, CLAUDE.md); the user's timeline and
33
+ media live under DATA_DIR. Identical unless FABLECUT_DATA_DIR is set. */
34
+ const ROOT = APP_DIR;
35
+ ensureDirs();
36
+ const PORT = process.env.FABLECUT_PORT || 7777;
37
+ const BASE = `http://localhost:${PORT}`;
38
+ // Requests go to the address the server binds: on Node 18, "localhost" can
39
+ // resolve to ::1 first and is not retried on 127.0.0.1.
40
+ const API = `http://${process.env.HOST === "::1" ? "[::1]" : "127.0.0.1"}:${PORT}`;
41
+
42
+ /* MCP initialize must echo a version we actually speak. Echoing an unknown
43
+ client version (or crashing) fails handshake with stock SDK clients. */
44
+ const MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"];
45
+ const MCP_DEFAULT_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0];
46
+ function negotiateProtocolVersion(requested) {
47
+ return MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_DEFAULT_PROTOCOL_VERSION;
48
+ }
49
+
50
+ /* ── Helpers ── */
51
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
52
+ function readProject() {
53
+ const raw = fs.readFileSync(PROJECT_FILE, "utf8").replace(new RegExp("^\\uFEFF"), "");
54
+ return JSON.parse(raw);
55
+ }
56
+ function writeProject(doc) {
57
+ // atomic tmp+rename so the UI's file watcher never sees a half-written doc
58
+ const tmp = PROJECT_FILE + ".mcp.tmp";
59
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 2));
60
+ fs.renameSync(tmp, PROJECT_FILE);
61
+ }
62
+ /* Optimistic concurrency: revision of project.json when this session last read
63
+ the full document. If the file has moved past it by write time, someone else
64
+ (usually the user, in the editor UI) edited in between — refuse to clobber. */
65
+ let lastReadRevision = null;
66
+ function httpOk(url) {
67
+ return new Promise((resolve) => {
68
+ const req = http.get(url, (r) => { r.resume(); resolve(r.statusCode < 500); });
69
+ req.on("error", () => resolve(false));
70
+ req.setTimeout(1200, () => { req.destroy(); resolve(false); });
71
+ });
72
+ }
73
+ /** JSON request to the editor server → {status, body}. */
74
+ function apiJSON(method, urlPath, payload) {
75
+ return new Promise((resolve, reject) => {
76
+ const req = http.request(API + urlPath, { method, headers: { "Content-Type": "application/json" } }, (r) => {
77
+ let data = "";
78
+ r.setEncoding("utf8");
79
+ r.on("data", (d) => { data += d; });
80
+ r.on("end", () => {
81
+ let body = null;
82
+ try { body = JSON.parse(data); } catch { body = { error: data }; }
83
+ resolve({ status: r.statusCode, body });
84
+ });
85
+ });
86
+ req.on("error", reject);
87
+ req.setTimeout(15_000, () => req.destroy(new Error("editor server did not answer")));
88
+ req.end(payload === undefined ? undefined : JSON.stringify(payload));
89
+ });
90
+ }
91
+ async function ensureUIServer() {
92
+ if (await httpOk(API + "/api/project")) return true;
93
+ spawn(process.execPath, [path.join(ROOT, "server.js")],
94
+ { cwd: ROOT, detached: true, stdio: "ignore" }).unref();
95
+ for (let i = 0; i < 12; i++) {
96
+ await sleep(300);
97
+ if (await httpOk(API + "/api/project")) return true;
98
+ }
99
+ return false;
100
+ }
101
+ const KIND_BY_EXT = {
102
+ ".mp4": "video", ".webm": "video", ".mov": "video", ".mkv": "video", ".m4v": "video", ".avi": "video",
103
+ ".mp3": "audio", ".wav": "audio", ".ogg": "audio", ".m4a": "audio", ".aac": "audio", ".flac": "audio",
104
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "svg",
105
+ };
106
+ function ffprobeDuration(file) {
107
+ try {
108
+ const r = spawnSync("ffprobe",
109
+ ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file],
110
+ { encoding: "utf8", timeout: 10_000 });
111
+ const d = parseFloat((r.stdout || "").trim());
112
+ return isNaN(d) ? undefined : Math.round(d * 1000) / 1000;
113
+ } catch { return undefined; }
114
+ }
115
+ const uid = () => Math.random().toString(36).slice(2, 9);
116
+
117
+ /* ── Tool definitions ── */
118
+ const TOOLS = [
119
+ {
120
+ name: "fablecut_status",
121
+ description: "FableCut video editor: ensure the editor web server is running (auto-starts it), and get the editor URL, project summary and media library. Call this first in a session.",
122
+ inputSchema: { type: "object", properties: {} },
123
+ },
124
+ {
125
+ name: "fablecut_docs",
126
+ description: "Return the FableCut project schema documentation: clips, tracks, props, keyframe animation, transitions, and editing recipes. Read this before editing. TOKEN TIP: pass `section` to fetch only the '## …' section(s) you need (substring match, e.g. \"props\", \"Recipes\", \"Remake\") instead of the whole manual.",
127
+ inputSchema: {
128
+ type: "object",
129
+ properties: { section: { type: "string", description: "Return only '## ' sections whose heading contains this text (case-insensitive). Omit for the full document." } },
130
+ },
131
+ },
132
+ {
133
+ name: "fablecut_get_project",
134
+ description: "Get the FableCut project (the timeline document). TOKEN TIP: pass compact:true for a one-line-per-clip summary (ids, tracks, timings, non-default props) — usually all you need to plan an edit; fetch the full JSON only when you must inspect exact keyframes.",
135
+ inputSchema: {
136
+ type: "object",
137
+ properties: { compact: { type: "boolean", description: "Return a compact human-readable summary instead of the full JSON" } },
138
+ },
139
+ },
140
+ {
141
+ name: "fablecut_patch_project",
142
+ description: "Apply targeted edits to the FableCut project WITHOUT round-tripping the whole document — PREFER THIS over get+set for every edit (it is ~10-100x cheaper in tokens and merge-safe by design: it re-reads the latest document from disk, applies your ops in order, bumps revision once, saves atomically). Ops: {op:'addClip', clip:{…}} (id auto-generated if omitted) · {op:'updateClip', id, set:{…}} · {op:'removeClip', id} · {op:'addMedia', media:{…}} · {op:'removeMedia', id} · {op:'setProject', set:{name|width|height|fps|background|markers|disabledTracks|lockedTracks|untargetedTracks|encodeProfile|master}} (markers = the full list [{t, label?, color?}], color: gold|red|orange|green|cyan|blue|purple|pink; master = {gain}, the master fader in dB) · {op:'setTrack', id:'A1', set:{gain?, pan?, out?}} (audio-track fader in dB −60…+12, pan −1…1, out = a submix bus id or 'master'; null or 0 resets) · {op:'setBus', id:'B1', set:{name?, gain?, pan?, mute?}} (submix bus, created if missing; route tracks into it with setTrack out) · {op:'removeBus', id} · {op:'setFx', target:'clip'|'track'|'bus'|'master', id?, preset?:'podcast'|… OR fx:[{type,…params}], append?:true} (audio effects — validated; presets: clean-voice, podcast, radio, deep-voice, telephone, cinematic, wide, muffled; fx:null clears; on a clip it applies to its linked stems too) · {op:'setFxKeys', target, id?, index?|type?, param, keys:[{t, v, ease?}]|null} (automate one effect parameter: t is clip-local for a clip's effects, timeline seconds otherwise; see the 'Audio mix' docs section) · {op:'setGrade', id | ids:[…], grade:{exposure?, temp?, tint?, lift?, gamma?, gain?, offset?, contrast?, pivot?, blacks?, shadows?, midtones?, highlights?, whites?, lowSoft?, highSoft?, saturation?, curves?:{y?, r?, g?, b?}, hueHue?, hueSat?, hueLuma?, satLuma?, layers?:[…], on?}, replace?:true} (color grade on video / image / svg / adjust clips — wheels are [r, g, b, master]; curves are [[x, y], …] point lists on 0…1 and merge per channel; hue curves are [[hue°, value], …] — one point = a ±40° band; layers replaces the whole layer list; merges key by key, null resets a key, replace:true starts from neutral, grade:null clears; see the 'Color' docs section; measure the result with fablecut_scopes) · {op:'setGradeLayer', id | ids, layer: index | 'name', set:{name?, on?, qualifier?:{hue?:[centre°, width°, soft°], sat?:[lo, hi, soft], luma?:[lo, hi, soft], invert?}, mask?:{shape:'ellipse'|'rect'|'poly', x, y, w, h, rotation?, feather?, invert?, points?, keys?:[{t, x?, y?, …}]}, …any grade key}, replace?:true, remove?:true} (a secondary: a grade applied only where the qualifier × mask select; a new name or index = length adds a layer; qualifier and mask merge key by key; check what it selects with fablecut_scopes matte:{clip, layer}). TIMELINE EDITS — the editor's own split / ripple / trim code, so linked stems, track targeting (untargetedTracks) and locks behave exactly as in the UI; times in seconds: {op:'split', at, ids?} (no ids: every targeted track) · {op:'rippleDelete', ids} (later clips close the hole) · {op:'closeGap', at} · {op:'lift'|'extract', from?, to?} (remove a range; extract closes it; default = project inPoint/outPoint, which then clear) · {op:'insert'|'overwrite', mediaId, at, in?, duration?} (three-point edit: insert pushes later clips right, overwrite replaces what is there; a video brings one audio stem per channel) · {op:'rippleTrim'|'roll', id, side:'in'|'out', delta} · {op:'slip'|'slide', id, delta} (clamped to the media; the note says what was applied) · {op:'crossfade', ids? | at, duration?} (constant-power audio crossfade, borrowing handles from both sides). Any of these takes tracks:[…] to target lanes for that op only. setProject also takes inPoint / outPoint. updateClip merge rules: top-level keys are replaced (keyframes/transitionIn/transitionOut wholesale), `props` merges key-by-key, and setting any key to null deletes it. LOCKS: the user can lock clips (`locked:true`) and tracks (`lockedTracks`); updateClip / removeClip on a locked clip — or on a clip linked to one — and addClip onto a locked track are refused. Leave locked material alone; only if the user asked you to change it, pass force:true on that op (or unlock first: updateClip set:{locked:null}, which is always allowed). All-or-nothing: an invalid op aborts the whole patch unsaved. Optional baseRevision: the revision your ops were planned against (from fablecut_get_project, fablecut_status or the previous patch result) — if the project has moved past it (e.g. the user edited in the UI meanwhile), the patch is refused with CONFLICT and the current revision, nothing saved; re-check the clips you touch, then retry with the new revision. Omit it to apply on top of whatever is latest.",
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: {
146
+ ops: {
147
+ type: "array",
148
+ items: { type: "object" },
149
+ description: "Edit operations, applied in order (see tool description for shapes). Any op may carry force:true to override a lock the user set.",
150
+ },
151
+ baseRevision: {
152
+ type: "integer",
153
+ description: "Revision these ops were planned against. If project.json has moved past it, the patch is refused with CONFLICT (nothing saved). Omit to apply on top of the latest revision.",
154
+ },
155
+ },
156
+ required: ["ops"],
157
+ },
158
+ },
159
+ {
160
+ name: "fablecut_set_project",
161
+ description: "Replace the FableCut project JSON. Pass the COMPLETE document (read with fablecut_get_project, modify, send back whole). Revision is auto-bumped; the open editor UI hot-reloads instantly so the user sees the edit live. CONFLICT-SAFE: if the project changed on disk since your last fablecut_get_project (e.g. the user tweaked something in the UI), the call errors instead of overwriting — re-read, re-apply your edit on top of the latest document, and retry.",
162
+ inputSchema: {
163
+ type: "object",
164
+ properties: {
165
+ project: { type: "object", description: "The complete project document (see fablecut_docs for schema)" },
166
+ force: { type: "boolean", description: "Overwrite even if the project changed since it was last read (discards those external/user changes). Only when the user explicitly asks." },
167
+ },
168
+ required: ["project"],
169
+ },
170
+ },
171
+ {
172
+ name: "fablecut_analyze_reference",
173
+ description: "Analyze a reference video into an EDIT BLUEPRINT so a similar edit can be rebuilt with different footage over the same music. Returns: shot boundaries (cuts) with per-shot audio energy, music beats + BPM, a loudness curve, the detected drop, and extracts the reference's music track into media/ (registered in the project, ready to place on A1). Remake recipe: copy the reference's width/height/fps to the project, write `beats` into project `markers`, lay the extracted music on A1, then place one clip per blueprint `shot` at the same start/duration — pick calm footage for low-energy shots and action for high-energy ones, and make the biggest moment land on `drop`. See the 'Remake a reference video' section of fablecut_docs.",
174
+ inputSchema: {
175
+ type: "object",
176
+ properties: {
177
+ path: { type: "string", description: "The reference video: an absolute file path (copied into media/ automatically) or an existing '/media/…' src" },
178
+ threshold: { type: "number", description: "Scene-cut sensitivity 0–1 (default: adaptive 0.30→0.20→0.12). Lower it if obvious cuts are missed, raise it if too many false cuts." },
179
+ registerMusic: { type: "boolean", description: "Extract the reference's music and register it as project media (default true)" },
180
+ },
181
+ required: ["path"],
182
+ },
183
+ },
184
+ {
185
+ name: "fablecut_import_media",
186
+ description: "Import a media file into FableCut's media library and register it in the project. Pass a local absolute path (copied, including .svg) or an https:// URL (downloaded into ./media/; video/audio/image only — remote SVG is refused). The URL is never kept as the playback src — CORS would break export. Returns the created media entry (use its id in clips).",
187
+ inputSchema: {
188
+ type: "object",
189
+ properties: { path: { type: "string", description: "Absolute path to a local file, or an https:// URL to download" } },
190
+ required: ["path"],
191
+ },
192
+ },
193
+ {
194
+ name: "fablecut_encode_profiles",
195
+ description: "List ffmpeg encoding profiles for Fast export (from encoding-profiles.json). Each profile is a raw ffmpeg argument list plus jpegQuality, extension, and optional color (output matrix/range — default BT.709 tv). Use to pick a profile id for project.encodeProfile. Edit encoding-profiles.json on disk to add custom profiles — anything the local ffmpeg supports works, and the server hot-reloads the file. Profiles are dry-run against ffmpeg when an export starts, so a bad argument is rejected before rendering.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ detail: { type: "boolean", description: "Include the full ffmpeg args array per profile (default: a truncated summary)" },
200
+ profile: { type: "string", description: "Return one profile by id instead of the full list" },
201
+ },
202
+ },
203
+ },
204
+ {
205
+ name: "fablecut_normalize_audio",
206
+ description: "Loudness-normalize clips: measures each clip's source audio (ITU-R BS.1770 integrated LUFS, or sample peak) after its channel routing and sets `props.gain` (clip gain, dB) so it lands on the target — the same measurement as the editor's Normalize button. Linked stems (a video's per-channel audio clips) are measured together and get one shared gain, so a stereo pair stays balanced; passing a video clip normalizes its stems. Volume keyframes / fades are untouched (they ride on top). Typical targets: -14 LUFS streaming/social, -16 LUFS podcast/dialogue, -23 LUFS broadcast, or mode 'peak' at -1 dBFS. Needs ffmpeg + ffprobe on PATH. Refuses locked clips unless force:true.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ clipIds: { type: "array", items: { type: "string" }, description: "Clips to normalize (audio, or video with audio)" },
211
+ mode: { type: "string", enum: ["lufs", "peak"], description: "Measure integrated loudness (default) or sample peak" },
212
+ target: { type: "number", description: "Target level: LUFS for mode lufs (default -14), dBFS for mode peak (default -1)" },
213
+ force: { type: "boolean", description: "Also change clips the user locked (only when they asked)" },
214
+ },
215
+ required: ["clipIds"],
216
+ },
217
+ },
218
+ {
219
+ name: "fablecut_auto_duck",
220
+ description: "Duck music under dialogue: finds where the voice tracks have sound (RMS above `threshold`, short gaps bridged so the music doesn't pump between words) and writes `duck` keyframes (dB) on the given music clips — a dip of `amount` dB ramping down `attack` s before speech and back up `release` s after. The duck multiplies the clip's volume, so its own level, volume keyframes and fades are untouched, and re-running replaces the previous dips (amount 0 clears them). Voice = every audio clip on `under` tracks (default: all audio tracks the music isn't on). Linked stems of a music clip get the same keys. The same pipeline as the editor's Auto-duck. Needs ffmpeg + ffprobe on PATH. Refuses locked clips unless force:true.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ clipIds: { type: "array", items: { type: "string" }, description: "The music / bed clips to duck" },
225
+ under: { type: "array", items: { type: "string" }, description: "Audio tracks holding the voice, e.g. [\"A1\"] (default: every other audio track)" },
226
+ amount: { type: "number", description: "Dip depth in dB, -40…0 (default -12; 0 removes the ducking)" },
227
+ threshold: { type: "number", description: "Voice detection level in dBFS (default -40; raise it if room noise triggers ducks)" },
228
+ attack: { type: "number", description: "Seconds to ramp down before speech (default 0.3)" },
229
+ release: { type: "number", description: "Seconds to ramp back up after speech (default 0.6)" },
230
+ force:{ type: "boolean", description: "Also change clips the user locked (only when they asked)" },
231
+ },
232
+ required: ["clipIds"],
233
+ },
234
+ },
235
+ {
236
+ name: "fablecut_denoise",
237
+ description: "Reduce background noise (hiss, hum, room tone) on audio clips: ffmpeg's FFT denoiser renders a cleaned FLAC of each clip's whole source file into media/, and the clip — with its linked stems — switches to it (the picture keeps its own file, so links and timing stay intact). The same as the inspector's Noise control. amount 'off' switches back to the original. Re-running reuses a file already rendered. Needs ffmpeg + ffprobe on PATH. Refuses locked clips unless force:true.",
238
+ inputSchema: {
239
+ type: "object",
240
+ properties: {
241
+ clipIds: { type: "array", items: { type: "string" }, description: "Audio clips, or video clips with linked audio stems" },
242
+ amount: { type: "string", enum: ["light", "medium", "strong", "off"], description: "How hard to pull the noise down (default medium); off restores the original audio" },
243
+ force: { type: "boolean", description: "Also change clips the user locked (only when they asked)" },
244
+ },
245
+ required: ["clipIds"],
246
+ },
247
+ },
248
+ {
249
+ name: "fablecut_export",
250
+ description: "Render the timeline to a video file in exports/ — the editor's own Fast export (same compositor, audio mix and encoding profile as the Export button), so the file matches what the user sees. It runs in a browser: an open editor tab takes the job (the user sees the progress bar), or with no tab open the server starts headless Chrome / Edge (set FABLECUT_CHROME to pick one) and closes it afterwards. Waits for the file by default and returns its path. Range: the project's inPoint→outPoint when set, else the whole timeline (or pass range). Needs ffmpeg on PATH. One export at a time. Long edits: pass wait:false, then poll with {job}; {cancel:job} stops one.",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {
254
+ range: { type: "string", enum: ["entire", "in-out"], description: "Whole timeline, or the project's inPoint→outPoint (default: in-out when either is set)" },
255
+ profile: { type: "string", description: "Encoding profile id (fablecut_encode_profiles); default: project.encodeProfile, else the server default" },
256
+ where: { type: "string", enum: ["auto", "tab", "headless"], description: "auto (default): an open editor tab, else headless · tab: only an open tab · headless: always a background browser, leaving the user's tab alone" },
257
+ wait: { type: "boolean", description: "Block until the file is written (default true)" },
258
+ timeout: { type: "number", description: "Seconds to wait before returning the job's status instead (default 900); the export keeps running" },
259
+ job: { type: "string", description: "Report on an export started earlier instead of starting one" },
260
+ cancel: { type: "string", description: "Cancel this export job" },
261
+ },
262
+ },
263
+ },
264
+ {
265
+ name: "fablecut_scopes",
266
+ description: "Measure the graded picture at one moment — what the editor's scopes show, as numbers: luma levels (min, 1st percentile, median, mean, 99th percentile, max on 0–1), the % of pixels crushed to black / clipped to white, mean R/G/B, average saturation, and the colour cast of the midtones (hue name + strength; neutral below 0.01), plus the clips on screen and their grades. Rendered by the same compositor as export (an open editor tab, else headless Chrome / Edge), over the export frame when one is set. Use it to check a grade: e.g. a cast strength above ~0.03 on footage that should be neutral, whitePct above ~1 (clipped highlights), or a median far from ~0.4 for a normally exposed shot. The user's playhead does not move.",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ time: { type: "number", description: "Timeline seconds to measure (default: the editor's playhead)" },
271
+ matte: {
272
+ type: "object",
273
+ description: "Also measure what one grade layer selects: {clip, layer} (layer = index into the clip's grade.layers). Adds the matte's coverage (% of that clip's picture) and the levels / colour of the selected pixels after grading",
274
+ properties: { clip: { type: "string" }, layer: { type: "integer", minimum: 0 } },
275
+ required: ["clip", "layer"],
276
+ },
277
+ where: { type: "string", enum: ["auto", "tab", "headless"], description: "auto (default): an open editor tab, else headless · tab · headless" },
278
+ timeout: { type: "number", description: "Seconds to wait (default 60)" },
279
+ },
280
+ },
281
+ },
282
+ ];
283
+
284
+ /* ── Loudness normalize (fablecut_normalize_audio) ──
285
+ Decodes each clip's source window with ffmpeg and measures it with the same
286
+ loudness.js the editor uses, so agent and UI land on identical gains. */
287
+ const DEFAULT_TRACKS = [
288
+ { id: "V3", kind: "video" }, { id: "V2", kind: "video" }, { id: "V1", kind: "video" },
289
+ { id: "A1", kind: "audio" }, { id: "A2", kind: "audio" }, { id: "A3", kind: "audio" }, { id: "A4", kind: "audio" },
290
+ ];
291
+ const NORM_SR = 48000;
292
+ function mediaFile(src) {
293
+ const p = decodeURIComponent(String(src || "").split("?")[0]);
294
+ const [root, rel] = p.startsWith("/media/") ? [MEDIA_DIR, p.slice(7)]
295
+ : p.startsWith("/library/") ? [LIBRARY_DIR, p.slice(9)] : [null, null];
296
+ if (!root) return null;
297
+ const file = path.normalize(path.join(root, rel));
298
+ return file.startsWith(path.normalize(root + path.sep)) && fs.existsSync(file) ? file : null;
299
+ }
300
+ /** Source seconds a clip consumes: duration × speed, integrating speed keyframes. */
301
+ function clipSourceLen(c) {
302
+ const base = Math.min(8, Math.max(0.1, +c.props?.speed || 1));
303
+ const kf = c.keyframes?.speed;
304
+ if (!Array.isArray(kf) || !kf.length) return c.duration * base;
305
+ const at = (t) => {
306
+ if (t <= kf[0].t) return kf[0].v;
307
+ for (let i = 1; i < kf.length; i++)
308
+ if (t <= kf[i].t) return kf[i - 1].v + (kf[i].v - kf[i - 1].v) * (t - kf[i - 1].t) / ((kf[i].t - kf[i - 1].t) || 1);
309
+ return kf[kf.length - 1].v;
310
+ };
311
+ let sum = 0;
312
+ const n = Math.max(2, Math.ceil(c.duration * 60));
313
+ for (let i = 0; i < n; i++) sum += Math.min(8, Math.max(0.1, at((i + 0.5) * c.duration / n))) * c.duration / n;
314
+ return sum;
315
+ }
316
+ function probeChannels(file) {
317
+ const r = spawnSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=channels",
318
+ "-of", "csv=p=0", file], { encoding: "utf8" });
319
+ if (r.error) throw new Error("ffprobe not found on PATH — needed to measure audio");
320
+ const n = parseInt(String(r.stdout).trim(), 10);
321
+ return n > 0 ? n : 0;
322
+ }
323
+ /** Decode a clip's source window with ffmpeg and hand each chunk to
324
+ * `onChunk(channels)`. Resolves to the channel count (0 = no audio stream). */
325
+ function streamClipAudio(proj, c0, onStart, onChunk) {
326
+ const media = proj.media.find((m) => m.id === c0.mediaId);
327
+ const file = media && mediaFile(media.src);
328
+ if (!file) return Promise.reject(new Error(`clip ${c0.id}: media file not found`));
329
+ const nCh = probeChannels(file);
330
+ if (!nCh || onStart(nCh) === false) return Promise.resolve(0);
331
+ return new Promise((resolve, reject) => {
332
+ const proc = spawn("ffmpeg", ["-v", "error", "-ss", String(Math.max(0, +c0.in || 0)),
333
+ "-t", String(Math.max(0.01, clipSourceLen(c0))), "-i", file, "-vn", "-map", "0:a:0",
334
+ "-ar", String(NORM_SR), "-f", "f32le", "-acodec", "pcm_f32le", "-"]);
335
+ let rest = Buffer.alloc(0), err = "";
336
+ const frameBytes = 4 * nCh;
337
+ proc.stdout.on("data", (chunk) => {
338
+ const buf = rest.length ? Buffer.concat([rest, chunk]) : chunk;
339
+ const frames = Math.floor(buf.length / frameBytes);
340
+ rest = buf.subarray(frames * frameBytes);
341
+ if (!frames) return;
342
+ const chs = Array.from({ length: nCh }, () => new Float32Array(frames));
343
+ for (let i = 0; i < frames; i++)
344
+ for (let c = 0; c < nCh; c++) chs[c][i] = buf.readFloatLE((i * nCh + c) * 4);
345
+ onChunk(chs);
346
+ });
347
+ proc.stderr.on("data", (d) => { err += d; });
348
+ proc.on("error", () => reject(new Error("ffmpeg not found on PATH — needed to read audio")));
349
+ proc.on("close", (code) => code === 0 ? resolve(nCh)
350
+ : reject(new Error(`ffmpeg failed reading ${c0.id}: ${err.trim().split("\n").pop() || code}`)));
351
+ });
352
+ }
353
+ /** Measure clips that play together (one linked group: same file + window). */
354
+ async function measureClipGroup(proj, clips) {
355
+ const L = require("./loudness.js");
356
+ let meter = null;
357
+ await streamClipAudio(proj, clips[0], (nCh) => {
358
+ const routedCount = clips.reduce((n, c) =>
359
+ n + L.routeChannels(Array.from({ length: nCh }, () => new Float32Array(0)), c.props || {}).length, 0);
360
+ if (!routedCount) return false;
361
+ meter = new L.LoudnessMeter(routedCount, NORM_SR);
362
+ }, (chs) => meter.push(clips.flatMap((c) => L.routeChannels(chs, c.props || {}))));
363
+ return meter ? meter.result() : null;
364
+ }
365
+ /** Timeline spans with sound on these voice clips (ducking.js pipeline). */
366
+ async function voiceRegions(proj, voices, threshold) {
367
+ const L = require("./loudness.js"), D = require("./ducking.js");
368
+ const lists = [];
369
+ for (const v of voices) {
370
+ let env = null;
371
+ await streamClipAudio(proj, v, (nCh) => { env = new D.EnvelopeMeter(nCh, NORM_SR); },
372
+ (chs) => env.push(L.routeChannels(chs, v.props || {})));
373
+ if (!env) continue;
374
+ const sp = Math.min(8, Math.max(0.1, +v.props?.speed || 1));
375
+ const gain = Math.pow(10, (+v.props?.gain || 0) / 20) * Math.min(2, Math.max(0, +(v.props?.volume ?? 1)));
376
+ lists.push(D.activeRegions(env.result(), { t0: v.start, speed: sp, gain, threshold }));
377
+ }
378
+ return D.mergeRegions(lists);
379
+ }
380
+ /** The clips that sound for a selection: a linked group's audio stems, else the clip. */
381
+ function normalizeGroups(proj, ids) {
382
+ const groups = new Map();
383
+ for (const id of ids) {
384
+ const c = proj.clips.find((x) => x.id === id);
385
+ if (!c) throw new Error("no clip " + id);
386
+ if (c.kind !== "audio" && c.kind !== "video") throw new Error(`clip ${id} is ${c.kind} — no audio to normalize`);
387
+ const key = c.linkGroup || c.id;
388
+ if (groups.has(key)) continue;
389
+ const members = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup) : [c];
390
+ const stems = members.filter((x) => x.kind === "audio");
391
+ groups.set(key, stems.length ? stems : members);
392
+ }
393
+ return [...groups.values()];
394
+ }
395
+
396
+ /* ── Timeline edits (patch ops split … crossfade) ──
397
+ The editor's own edit code (edit-ops.js) run against the document, so an
398
+ agent gets the same targeting (untargetedTracks, or the op's `tracks`), the
399
+ same sync lock (linked partners ride along) and the same lock rules as the
400
+ keyboard shortcuts. Returns a short note; throws to abort the patch. */
401
+ const relinkedDocs = new WeakSet();
402
+ function timelineEdit(proj, op, refuseLocked, lockReason) {
403
+ const name = op.op;
404
+ const E = EditOps.forDoc(proj, { tracks: op.tracks, force: op.force === true, uid: () => uid() });
405
+ // Links are rebuilt from timing on every editor load; do the same once per
406
+ // patch so clips an agent added (without linkGroup) ride along with their stems.
407
+ if (!relinkedDocs.has(proj)) { E.relinkClips(); relinkedDocs.add(proj); }
408
+ const r3 = (n) => Math.round(n * 1000) / 1000;
409
+ const signed = (n) => (n > 0 ? "+" : "") + r3(n);
410
+ const num = (v, what) => {
411
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${name}: ${what} must be a number (seconds)`);
412
+ return v;
413
+ };
414
+ const clipOf = (id) => {
415
+ const c = proj.clips.find((x) => x.id === id);
416
+ if (!c) throw new Error(`${name}: no clip ${id}`);
417
+ return c;
418
+ };
419
+ const named = () => {
420
+ if (!Array.isArray(op.ids) || !op.ids.length) throw new Error(`${name}: ids must list at least one clip`);
421
+ const clips = op.ids.map(String).map(clipOf);
422
+ for (const c of clips) refuseLocked(name, lockReason(c), op);
423
+ return clips;
424
+ };
425
+ const refused = (why) => new Error(`${name}: ${why}` +
426
+ (/locked/i.test(why) ? " — the user locked it; pass force:true only if they asked" : ""));
427
+ const trimmed = (c, r, extra = "") => {
428
+ if (typeof r === "string") throw refused(r);
429
+ const asked = num(op.delta, "delta");
430
+ return `${name} ${c.id}${extra} ${signed(r)}s` + (Math.abs(r - asked) > 1e-6 ? ` (asked ${signed(asked)}, clamped)` : "");
431
+ };
432
+ switch (name) {
433
+ case "split": {
434
+ const at = num(op.at, "at");
435
+ const r = E.splitAt(at, op.ids ? named() : null);
436
+ if (!r.split) throw refused(r.blocked ? "only locked clips under that time — left whole" : `no clip runs across ${at}s on the ${op.ids ? "given clips" : "targeted tracks"}`);
437
+ return `split@${at} +${r.rights.map((x) => x.id).join("+")}` + (r.blocked ? " (locked clips left whole)" : "");
438
+ }
439
+ case "rippleDelete": {
440
+ const r = E.rippleDelete(named());
441
+ return `-${r.removed.map((x) => x.id).join("-")} (rippled)`;
442
+ }
443
+ case "closeGap": {
444
+ const G = E.closeGapAt(num(op.at, "at"));
445
+ if (typeof G === "string") throw refused(G.replace("at playhead", `at ${op.at}s`).replace("move the playhead", "pick another time"));
446
+ return `closed ${r3(G)}s gap @${op.at}`;
447
+ }
448
+ case "lift": case "extract": {
449
+ const usePoints = op.from == null && op.to == null;
450
+ const t0 = op.from == null ? proj.inPoint : num(op.from, "from");
451
+ const t1 = op.to == null ? proj.outPoint : num(op.to, "to");
452
+ if (t0 == null || t1 == null) throw refused("give from and to (seconds), or set the project's inPoint and outPoint");
453
+ const why = E.liftRange(t0, t1, name === "extract");
454
+ if (why) throw refused(why.replace("Set IN and OUT first (I / O) to", "from→to must span more than a frame to"));
455
+ if (usePoints) { delete proj.inPoint; delete proj.outPoint; } // as in the editor
456
+ return `${name} ${r3(t0)}→${r3(t1)}s`;
457
+ }
458
+ case "rippleTrim": case "roll": {
459
+ const c = clipOf(String(op.id));
460
+ if (op.side !== "in" && op.side !== "out") throw new Error(`${name}: side must be "in" (head) or "out" (tail)`);
461
+ const fn = name === "roll" ? E.rollEdit : E.rippleTrim;
462
+ return trimmed(c, fn(c, op.side, num(op.delta, "delta")), "." + op.side);
463
+ }
464
+ case "slip": case "slide": {
465
+ const c = clipOf(String(op.id));
466
+ const fn = name === "slip" ? E.slipClip : E.slideClip;
467
+ return trimmed(c, fn(c, num(op.delta, "delta")));
468
+ }
469
+ case "insert": case "overwrite": {
470
+ const m = proj.media.find((x) => x.id === op.mediaId);
471
+ if (!m) throw new Error(`${name}: unknown mediaId ${op.mediaId}`);
472
+ const timed = m.kind === "video" || m.kind === "audio";
473
+ const inn = op.in == null ? 0 : num(op.in, "in");
474
+ if (inn < 0) throw new Error(`${name}: in must be ≥ 0`);
475
+ const dur = op.duration != null ? num(op.duration, "duration")
476
+ : timed && m.duration > 0 ? m.duration - inn : null;
477
+ if (dur == null) throw new Error(`${name}: duration is required for ${timed ? "media with no known duration yet" : m.kind}`);
478
+ if (!(dur >= EditOps.MIN_DUR)) throw new Error(`${name}: duration must be at least ${EditOps.MIN_DUR}s`);
479
+ if (timed && m.duration > 0 && inn + dur > m.duration + 1e-3)
480
+ throw new Error(`${name}: in + duration (${r3(inn + dur)}s) runs past the end of ${m.id} (${m.duration}s)`);
481
+ let stems = 2;
482
+ if (m.kind === "video") {
483
+ try { const f = mediaFile(m.src); if (f) stems = probeChannels(f) || 0; } catch { /* no ffprobe: assume stereo */ }
484
+ }
485
+ const r = (name === "insert" ? E.insertAt : E.overwriteAt)(m, timed ? inn : 0, dur, num(op.at, "at"), stems);
486
+ if (typeof r === "string") throw refused(r.replace(" — click a track name to target it (and unlock it)", " — every lane for it is untargeted or locked (pass tracks:[…])"));
487
+ const placed = r.clip ? (r.clip.linkGroup ? proj.clips.filter((x) => x.linkGroup === r.clip.linkGroup) : [r.clip]) : [];
488
+ return `${name}@${r3(r.at)} ` + placed.map((x) => `+${x.id}(${x.track})`).join("") + (name === "insert" ? ` · later clips moved +${r3(dur)}s` : "");
489
+ }
490
+ case "crossfade": {
491
+ const dur = op.duration == null ? EditOps.CROSSFADE_DUR : num(op.duration, "duration");
492
+ if (dur < EditOps.MIN_TRANS_DUR) throw new Error(`${name}: duration must be at least ${EditOps.MIN_TRANS_DUR}s`);
493
+ const pairs = op.ids ? E.crossfadeCuts(E.withLinked(named())) : E.crossfadeCutsNear(num(op.at, "at"));
494
+ if (!pairs.length) throw refused(op.ids ? "no audio cut next to those clips — they must touch or overlap on one track"
495
+ : `no audio cut within 0.5s of ${op.at}s on the targeted audio tracks`);
496
+ const { done, why } = E.crossfadePairs(pairs, dur);
497
+ if (!done) throw refused(why.join(", "));
498
+ return `crossfaded ${done} cut${done === 1 ? "" : "s"}` + (why.length ? ` (skipped: ${why.join(", ")})` : "");
499
+ }
500
+ }
501
+ throw new Error("Unknown edit op " + name);
502
+ }
503
+
504
+ /* ── Noise reduction (fablecut_denoise) — denoise.js, as the editor's Noise control ── */
505
+ async function denoiseTool(args) {
506
+ const ids = Array.isArray(args.clipIds) ? args.clipIds.map(String) : [];
507
+ if (!ids.length) throw new Error("`clipIds` must list at least one clip");
508
+ const amount = args.amount == null ? "medium" : String(args.amount);
509
+ if (amount !== "off" && !Denoise.AMOUNT_IDS.includes(amount)) throw new Error(`amount must be one of ${Denoise.AMOUNT_IDS.join(", ")}, off`);
510
+ let doc = readProject();
511
+ const picked = ids.map((id) => {
512
+ const c = doc.clips.find((x) => x.id === id);
513
+ if (!c) throw new Error("no clip " + id);
514
+ return c;
515
+ });
516
+ const targets = Denoise.denoiseTargets(doc, picked);
517
+ const lockedTracks = new Set(Array.isArray(doc.lockedTracks) ? doc.lockedTracks : []);
518
+ const isLocked = (c) => (c.linkGroup ? doc.clips.filter((x) => x.linkGroup === c.linkGroup) : [c])
519
+ .some((x) => x.locked === true || lockedTracks.has(x.track));
520
+ if (args.force !== true && targets.some(isLocked))
521
+ throw new Error(`${targets.filter(isLocked).map((c) => c.id).join(", ")} locked — the user locked it. Leave it alone, or pass force:true if they asked you to change it`);
522
+ const bases = [...new Set(targets.map((c) => Denoise.baseMediaId(doc, c.mediaId)))];
523
+ const rendered = new Map(); // base id → {r, base}
524
+ if (amount !== "off") {
525
+ for (const id of bases) {
526
+ const base = doc.media.find((m) => m.id === id);
527
+ const file = base && mediaFile(base.src);
528
+ if (!file) throw new Error(`media ${id}: file not found`);
529
+ rendered.set(id, { base, r: await Denoise.denoiseFile(file, MEDIA_DIR, amount) });
530
+ }
531
+ }
532
+ // Re-read after the (slow) render so concurrent UI edits are kept.
533
+ const proj = readProject();
534
+ const report = [];
535
+ const swap = new Map();
536
+ for (const id of bases) {
537
+ if (amount === "off") { swap.set(id, id); continue; }
538
+ const { base, r } = rendered.get(id);
539
+ const src = "/media/" + encodeURIComponent(r.name);
540
+ let m = proj.media.find((x) => x.src === src);
541
+ if (!m) {
542
+ m = { id: "m_" + uid(), name: r.name, kind: "audio", src, duration: r.duration, folderId: base.folderId || null,
543
+ derivedFrom: base.id, denoise: amount };
544
+ proj.media.push(m);
545
+ }
546
+ swap.set(id, m.id);
547
+ report.push(`${id} → ${m.id} "${r.name}"${r.cached ? " (already rendered)" : ""}`);
548
+ }
549
+ let changed = 0;
550
+ for (const t of targets) {
551
+ const c = proj.clips.find((x) => x.id === t.id);
552
+ if (!c) continue;
553
+ const next = swap.get(Denoise.baseMediaId(proj, c.mediaId));
554
+ if (next && next !== c.mediaId) { c.mediaId = next; changed++; }
555
+ }
556
+ proj.revision = (proj.revision || 0) + 1;
557
+ writeProject(proj);
558
+ lastReadRevision = proj.revision;
559
+ return `Noise reduction ${amount} on ${targets.map((c) => c.id).join(", ")} (revision ${proj.revision}, ${changed} clip${changed === 1 ? "" : "s"} switched)` +
560
+ (report.length ? ":\n" + report.join("\n") : "");
561
+ }
562
+
563
+ /* ── Export (fablecut_export) ── */
564
+ function describeExportJob(j) {
565
+ const pct = Math.round((j.progress || 0) * 100);
566
+ if (j.status === "done") {
567
+ const file = path.join(EXPORTS_DIR, path.basename(decodeURIComponent(j.src || "")));
568
+ let size = "";
569
+ try { size = ` (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)`; } catch {}
570
+ return `Export ${j.id} done → ${file}${size}\nServed at ${BASE}${j.src}`;
571
+ }
572
+ if (j.status === "failed" || j.status === "cancelled") return `Export ${j.id} ${j.status}: ${j.error || "no reason given"}`;
573
+ return `Export ${j.id} ${j.status}${j.status === "running" ? ` — ${pct}%` : ""} (in ${j.via === "headless" ? "a headless browser" : "the open editor tab"}). ` +
574
+ `Check again with fablecut_export {job:"${j.id}"}, or stop it with {cancel:"${j.id}"}.`;
575
+ }
576
+ async function exportTool(args) {
577
+ if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
578
+ if (args.cancel) {
579
+ const r = await apiJSON("POST", "/api/export/job/cancel?id=" + encodeURIComponent(args.cancel));
580
+ if (r.status !== 200) throw new Error(r.body?.error || "cancel failed");
581
+ return describeExportJob(r.body);
582
+ }
583
+ let job;
584
+ if (args.job) {
585
+ const r = await apiJSON("GET", "/api/export/job?id=" + encodeURIComponent(args.job));
586
+ if (r.status !== 200) throw new Error(r.body?.error || "no such export job");
587
+ job = r.body;
588
+ } else {
589
+ const r = await apiJSON("POST", "/api/export/request", { range: args.range, profile: args.profile, where: args.where || "auto" });
590
+ if (r.status !== 200) throw new Error(r.body?.error || `export request failed (${r.status})`);
591
+ job = r.body;
592
+ if (args.wait === false) return describeExportJob(job);
593
+ }
594
+ const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 900);
595
+ while (job.status === "pending" || job.status === "running") {
596
+ if (args.job && args.wait !== true) break; // a status check answers right away
597
+ if (Date.now() > deadline) break;
598
+ await sleep(1000);
599
+ const r = await apiJSON("GET", "/api/export/job?id=" + encodeURIComponent(job.id));
600
+ if (r.status !== 200) throw new Error(r.body?.error || "lost track of the export job");
601
+ job = r.body;
602
+ }
603
+ return describeExportJob(job);
604
+ }
605
+
606
+ /* ── Scopes (fablecut_scopes): one graded still, measured in the editor ── */
607
+ function describeScopes(j) {
608
+ if (j.status !== "done") return `Scope reading ${j.id} ${j.status}${j.error ? ": " + j.error : ""}`;
609
+ const r = j.result || {}, st = r.stats;
610
+ if (!st) return `Frame at ${r.time}s is empty (nothing drawn).`;
611
+ const L = st.luma, cast = st.cast;
612
+ const lines = [
613
+ `Frame at ${r.time}s (${r.frame?.w}x${r.frame?.h}):`,
614
+ ` luma min ${L.min} · p1 ${L.p1} · median ${L.median} · mean ${L.mean} · p99 ${L.p99} · max ${L.max}`,
615
+ ` clipped: black ${st.clipped.blackPct}% · white ${st.clipped.whitePct}%`,
616
+ ` rgb mean [${st.rgbMean.join(", ")}] · saturation ${st.saturation}`,
617
+ ` cast: ${cast.tone === "neutral" ? "neutral" : `${cast.tone} (hue ${cast.hue}°)`} · strength ${cast.strength}`,
618
+ `On screen (bottom → top): ${(r.clips || []).map((c) => `${c.id} ${c.track} ${c.kind}${c.grade !== "neutral" ? " grade:" + c.grade : ""}`).join(" | ") || "nothing"}`,
619
+ ];
620
+ const m = r.matte;
621
+ if (m) {
622
+ lines.push(m.error ? `Matte ${m.clip} layer ${m.layer}: ${m.error}` : `Matte of ${m.clip} layer ${m.layer}: covers ${m.coverage}% of the clip's picture` +
623
+ (m.selected ? ` · selected pixels: luma median ${m.selected.luma.median} (p1 ${m.selected.luma.p1}, p99 ${m.selected.luma.p99}) · rgb mean [${m.selected.rgbMean.join(", ")}] · saturation ${m.selected.saturation} · cast ${m.selected.cast.tone === "neutral" ? "neutral" : `${m.selected.cast.tone} (hue ${m.selected.cast.hue}°)`}` : " · nothing selected"));
624
+ }
625
+ return lines.join("\n") + "\n" + JSON.stringify({ time: r.time, stats: st, ...(m ? { matte: m } : {}) });
626
+ }
627
+ async function scopesTool(args) {
628
+ if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
629
+ const body = { where: args.where || "auto" };
630
+ if (args.time != null) body.time = args.time;
631
+ if (args.matte != null) body.matte = args.matte;
632
+ const r = await apiJSON("POST", "/api/scopes/request", body);
633
+ if (r.status !== 200) throw new Error(r.body?.error || `scope request failed (${r.status})`);
634
+ let job = r.body;
635
+ const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 60);
636
+ while (job.status === "pending" || job.status === "running") {
637
+ if (Date.now() > deadline) {
638
+ await apiJSON("POST", "/api/export/job/cancel?id=" + encodeURIComponent(job.id));
639
+ throw new Error("timed out waiting for the editor to measure the frame");
640
+ }
641
+ await sleep(300);
642
+ const q = await apiJSON("GET", "/api/export/job?id=" + encodeURIComponent(job.id));
643
+ if (q.status !== 200) throw new Error(q.body?.error || "lost track of the scope reading");
644
+ job = q.body;
645
+ }
646
+ if (job.status !== "done") throw new Error(`scope reading ${job.status}: ${job.error || "no reason given"}`);
647
+ return describeScopes(job);
648
+ }
649
+
650
+ /* ── Tool implementations ── */
651
+ async function callTool(name, args, attempt = 0) {
652
+ switch (name) {
653
+ case "fablecut_export":
654
+ return exportTool(args);
655
+ case "fablecut_scopes":
656
+ return scopesTool(args);
657
+ case "fablecut_denoise":
658
+ return denoiseTool(args);
659
+ case "fablecut_status": {
660
+ const up = await ensureUIServer();
661
+ const proj = readProject();
662
+ const dur = proj.clips.reduce((m, c) => Math.max(m, c.start + c.duration), 0);
663
+ const files = fs.existsSync(MEDIA_DIR)
664
+ ? fs.readdirSync(MEDIA_DIR).filter((f) => fs.statSync(path.join(MEDIA_DIR, f)).isFile())
665
+ : [];
666
+ const libSummary = ["sfx", "elements", "svg", "fonts"].map((d) => {
667
+ const dir = path.join(LIBRARY_DIR, d);
668
+ const n = fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
669
+ return `${d}: ${n}`;
670
+ }).join(", ");
671
+ const cap = (arr, n) => arr.length > n ? arr.slice(0, n).concat(`… +${arr.length - n} more`) : arr;
672
+ let encLine = "";
673
+ try {
674
+ const cfg = loadEncodeProfiles();
675
+ const describe = (id) => {
676
+ const p = cfg.profiles[id];
677
+ return p ? `${id} (${p.label}) — ${profileSummary(p)}` : null;
678
+ };
679
+ const pinned = proj.encodeProfile;
680
+ if (pinned && !cfg.profiles[pinned]) {
681
+ /* a project pinning a since-deleted profile must not read as "nothing
682
+ configured" — the bad id and the fallback are two separate facts */
683
+ encLine = `Export profile: project encodeProfile "${pinned}" is NOT DEFINED in encoding-profiles.json` +
684
+ ` — falling back to default ${describe(cfg.default) || `"${cfg.default}"`}`;
685
+ } else {
686
+ encLine = `Export profile: ${describe(pinned || cfg.default) || `server default "${cfg.default}"`}`;
687
+ }
688
+ } catch { encLine = "Export profile: (encoding-profiles.json unavailable)"; }
689
+ return [
690
+ `Editor server: ${up ? "RUNNING — open " + BASE + " in a browser to watch edits live" : "FAILED TO START (check node / port " + PORT + ")"}`,
691
+ `Project: "${proj.name}" — ${proj.width}x${proj.height} @ ${proj.fps}fps, ${proj.clips.length} clip(s), ${dur.toFixed(2)}s, revision ${proj.revision}`,
692
+ encLine,
693
+ `Registered media: ${cap(proj.media.map((m) => `${m.id} (${m.kind}, ${m.name}${m.duration ? ", " + m.duration + "s" : ""})`), 25).join("; ") || "none"}`,
694
+ `Files in media/: ${cap(files, 25).join(", ") || "none"}`,
695
+ `Library assets (./library): ${libSummary}`,
696
+ `Project file: ${PROJECT_FILE}`,
697
+ `Tips: fablecut_docs (use \`section\`) for the schema · fablecut_get_project {compact:true} to see the timeline · fablecut_patch_project for edits (cheapest) · fablecut_encode_profiles for export presets.`,
698
+ ].join("\n");
699
+ }
700
+ case "fablecut_docs": {
701
+ const md = fs.readFileSync(path.join(ROOT, "CLAUDE.md"), "utf8");
702
+ if (!args.section) return md;
703
+ const q = args.section.toLowerCase();
704
+ const parts = md.split(/^(?=## )/m);
705
+ const hits = parts.filter((s) => s.startsWith("## ") && s.slice(0, s.indexOf("\n")).toLowerCase().includes(q));
706
+ return hits.length ? hits.join("\n") :
707
+ `No '## ' section matches "${args.section}". Headings: ` +
708
+ parts.filter((s) => s.startsWith("## ")).map((s) => s.slice(3, s.indexOf("\n"))).join(" · ");
709
+ }
710
+ case "fablecut_get_project": {
711
+ const doc = readProject();
712
+ lastReadRevision = doc.revision || 0;
713
+ if (!args.compact) return JSON.stringify(doc);
714
+ // the UI persists default-valued props on every clip; hide them so the
715
+ // compact view only shows what actually deviates
716
+ const DEFAULTS = {
717
+ x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0, speed: 1,
718
+ gain: 0, channelMode: "stereo", duck: 0,
719
+ blend: "normal", fit: "contain", cropL: 0, cropR: 0, cropT: 0, cropB: 0,
720
+ cornerRadius: 0, flipH: false, flipV: false, filterPreset: "none",
721
+ brightness: 100, contrast: 100, saturation: 100, hue: 0, temperature: 0,
722
+ tint: 0, blur: 0, grayscale: 0, sepia: 0, invert: 0, vignette: 0,
723
+ shake: 0, shakeSpeed: 8, rgbSplit: 0, grain: 0,
724
+ chromaKey: "", chromaTolerance: 26, chromaSoftness: 12, bgRemove: false,
725
+ text: "Title", fontSize: 72, color: "#ffffff", color2: "", font: "Segoe UI",
726
+ bold: true, weight: 0, italic: false, uppercase: false, align: "center",
727
+ letterSpacing: 0, lineHeight: 1.2, textShadow: 12, glow: 0, glowColor: "",
728
+ strokeWidth: 0, strokeColor: "#000", bgColor: "#000", bgOpacity: 0,
729
+ textAnim: "none", wordRate: 0.15, direction: "auto", boxW: 0, boxH: 0, boxFit: false, vAlign: "middle",
730
+ };
731
+ const hex = (v) => typeof v === "string" && /^#[0-9a-f]{3}$/i.test(v)
732
+ ? "#" + [...v.slice(1)].map((c) => c + c).join("").toLowerCase()
733
+ : (typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : v);
734
+ const fmtProps = (o, kind) => {
735
+ if (!o) return "";
736
+ const kept = {};
737
+ for (const [k, v] of Object.entries(o)) {
738
+ if (kind !== "text" && k === "text" && v === "Title") continue;
739
+ // Always keep pan on linked stems — pan:0 (center) is meaningful and
740
+ // must not be stripped, or agents rebuilding from compact can lose it.
741
+ if (k === "pan" && Number.isInteger(o.audioChannel) && o.audioChannel >= 0) {
742
+ kept[k] = v;
743
+ continue;
744
+ }
745
+ if (hex(DEFAULTS[k]) !== hex(v)) kept[k] = v;
746
+ }
747
+ return Object.keys(kept).length ? " " + JSON.stringify(kept) : "";
748
+ };
749
+ // Mixer: only off-default faders / pans, so a fresh project adds no line.
750
+ const signed = (v) => (v > 0 ? "+" : "") + v;
751
+ const fxTag = (fx) => Array.isArray(fx) && fx.length ? ` fx:${FX.summarizeFx(fx)}` : "";
752
+ const mixLine = (d) => {
753
+ const parts = (Array.isArray(d.tracks) ? d.tracks : [])
754
+ .filter((t) => t && (+t.gain || +t.pan || t.fx?.length || t.out))
755
+ .map((t) => t.id + (t.out ? `→${t.out}` : "") + (+t.gain ? ` ${signed(+t.gain)}dB` : "") + (+t.pan ? ` pan:${+t.pan}` : "") + fxTag(t.fx));
756
+ for (const b of Array.isArray(d.buses) ? d.buses : [])
757
+ parts.push(`bus ${b.id}${b.name ? ` "${b.name}"` : ""}` + (+b.gain ? ` ${signed(+b.gain)}dB` : "") + (+b.pan ? ` pan:${+b.pan}` : "") +
758
+ (b.mute ? " muted" : "") + fxTag(b.fx));
759
+ if (d.master && (+d.master.gain || d.master.fx?.length))
760
+ parts.push("master" + (+d.master.gain ? ` ${signed(+d.master.gain)}dB` : "") + fxTag(d.master.fx));
761
+ return parts.length ? [`MIX: ${parts.join(" · ")}`] : [];
762
+ };
763
+ const lines = [
764
+ `"${doc.name}" ${doc.width}x${doc.height}@${doc.fps} rev:${doc.revision}` +
765
+ (doc.panSchema >= 1 ? " panSchema:1" : "") +
766
+ (doc.background ? ` bg:${doc.background}` : "") +
767
+ (doc.inPoint != null || doc.outPoint != null ? ` in/out:${doc.inPoint ?? "-"}→${doc.outPoint ?? "-"}` : "") +
768
+ (doc.markers?.length ? ` markers:${doc.markers.length} [${doc.markers.slice(0, 12).map((m) => m.t).join(",")}${doc.markers.length > 12 ? ",…" : ""}]` : "") +
769
+ (doc.lockedTracks?.length ? ` lockedTracks:[${doc.lockedTracks.join(",")}]` : "") +
770
+ (doc.untargetedTracks?.length ? ` untargetedTracks:[${doc.untargetedTracks.join(",")}]` : "") +
771
+ (doc.disabledTracks?.length ? ` disabledTracks:[${doc.disabledTracks.join(",")}]` : ""),
772
+ ...mixLine(doc),
773
+ `MEDIA (${doc.media.length}):`,
774
+ ...doc.media.map((m) => ` ${m.id} ${m.kind} "${m.name}"${m.duration ? " " + m.duration + "s" : ""}` +
775
+ (m.derivedFrom ? ` (${m.denoise ? "denoised " + m.denoise : "derived"} from ${m.derivedFrom})` : "")),
776
+ `CLIPS (${doc.clips.length}), by track/time:`,
777
+ ...doc.clips
778
+ .slice()
779
+ .sort((a, b) => (a.track === b.track ? a.start - b.start : String(a.track).localeCompare(b.track)))
780
+ .map((c) => {
781
+ const kf = c.keyframes ? " kf:" + Object.entries(c.keyframes).map(([k, v]) => `${k}(${v.length})`).join(",") : "";
782
+ const tr = (c.transitionIn ? ` in:${c.transitionIn.type}/${c.transitionIn.duration}` : "") +
783
+ (c.transitionOut ? ` out:${c.transitionOut.type}/${c.transitionOut.duration}` : "");
784
+ const r3 = (n) => Math.round(n * 1000) / 1000;
785
+ return ` ${c.id} ${c.track} ${r3(c.start)}s+${r3(c.duration)}s ${c.kind}` +
786
+ (c.mediaId ? `(${c.mediaId}${c.in ? ` in:${r3(c.in)}` : ""})` : "") +
787
+ (c.name ? ` "${c.name}"` : "") + fmtProps(c.props, c.kind) + kf + tr +
788
+ (c.locked === true ? " [locked]" : "") + (c.disabled === true ? " [disabled]" : "") +
789
+ (c.unlinked === true ? " [unlinked]" : "") + fxTag(c.fx);
790
+ }),
791
+ `(compact view — full JSON: fablecut_get_project without compact; edit via fablecut_patch_project)`,
792
+ ];
793
+ return lines.join("\n");
794
+ }
795
+ case "fablecut_patch_project": {
796
+ if (!Array.isArray(args.ops) || !args.ops.length) throw new Error("`ops` must be a non-empty array");
797
+ const base = args.baseRevision;
798
+ if (base != null && !Number.isInteger(base))
799
+ throw new Error("`baseRevision` must be an integer revision (from fablecut_get_project, fablecut_status or the last patch result)");
800
+ // ops get filled in as they apply (auto ids …); a retry must start from the originals
801
+ const ops = structuredClone(args.ops);
802
+ const proj = readProject();
803
+ const readRev = proj.revision || 0;
804
+ const conflict = (rev) => new Error(
805
+ `CONFLICT — not saved. project.json is at revision ${rev}, but this patch was based on ` +
806
+ `revision ${base ?? readRev}: the project changed in between (the user probably edited in the UI). ` +
807
+ `Re-read the clips you touch (fablecut_get_project {compact:true}), then retry with baseRevision:${rev} — ` +
808
+ `or omit baseRevision to apply on top of the latest anyway.`);
809
+ if (base != null && readRev !== base) throw conflict(readRev);
810
+ const notes = [];
811
+ // Locks mirror the editor: a clip is locked by its own flag or its track,
812
+ // and a linked A/V group with any locked member is locked as a whole.
813
+ const lockedTracks = () => new Set(Array.isArray(proj.lockedTracks) ? proj.lockedTracks : []);
814
+ const lockReason = (c) => {
815
+ const group = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup) : [c];
816
+ for (const x of group) {
817
+ const who = x.id === c.id ? `clip ${c.id}` : `clip ${c.id} is linked to ${x.id}, which`;
818
+ if (x.locked === true) return `${who} is locked`;
819
+ if (lockedTracks().has(x.track)) return `${who} is on locked track ${x.track}`;
820
+ }
821
+ return null;
822
+ };
823
+ const refuseLocked = (opName, reason, op) => {
824
+ if (reason && op.force !== true)
825
+ throw new Error(`${opName}: ${reason} — the user locked it. Leave it alone, or pass force:true if they asked you to change it`);
826
+ };
827
+ /* The effect chain an op names: a clip (and its linked stems), an
828
+ A-track, a submix bus or the master → {label, read(), write(list)}. */
829
+ const fxTarget = (op, name) => {
830
+ const target = op.target || (op.id && /^A\d+$/.test(op.id) ? "track" : op.id && /^B\d+$/.test(op.id) ? "bus" : op.id ? "clip" : "master");
831
+ if (target === "clip") {
832
+ const c = proj.clips.find((x) => x.id === op.id);
833
+ if (!c) throw new Error(`${name}: no clip ${op.id}`);
834
+ if (c.kind !== "audio") throw new Error(`${name}: clip ${op.id} is ${c.kind} — effects go on audio clips (a video's sound is on its linked A-track clips)`);
835
+ refuseLocked(name, lockReason(c), op);
836
+ const group = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup && x.kind === "audio") : [c];
837
+ return { label: group.map((x) => x.id).join("+"), read: () => c.fx, write(next) {
838
+ for (const x of group) { if (next.length) x.fx = next.map((e) => JSON.parse(JSON.stringify(e))); else delete x.fx; }
839
+ } };
840
+ }
841
+ if (target === "track") {
842
+ if (!/^A\d+$/.test(String(op.id || ""))) throw new Error(`${name}: track id must be an audio track (A1, A2, …)`);
843
+ if (!Array.isArray(proj.tracks) || !proj.tracks.length) proj.tracks = DEFAULT_TRACKS.map((t) => ({ ...t }));
844
+ let t = proj.tracks.find((x) => x.id === op.id);
845
+ if (!t) { t = { id: op.id, kind: "audio" }; proj.tracks.push(t); }
846
+ return { label: op.id, read: () => t.fx, write(next) { if (next.length) t.fx = next; else delete t.fx; } };
847
+ }
848
+ if (target === "bus") {
849
+ const b = (proj.buses || []).find((x) => x.id === op.id);
850
+ if (!b) throw new Error(`${name}: no bus ${JSON.stringify(op.id)} — create it with {op:"setBus", id:"B1"}`);
851
+ return { label: op.id, read: () => b.fx, write(next) { if (next.length) b.fx = next; else delete b.fx; } };
852
+ }
853
+ if (target === "master") {
854
+ return { label: "master", read: () => proj.master?.fx, write(next) {
855
+ const m = proj.master && typeof proj.master === "object" ? proj.master : {};
856
+ if (next.length) m.fx = next; else delete m.fx;
857
+ if (Object.keys(m).length) proj.master = m; else delete proj.master;
858
+ } };
859
+ }
860
+ throw new Error(`${name}: target must be clip, track, bus or master`);
861
+ };
862
+ const mergeInto = (target, set) => {
863
+ for (const [k, v] of Object.entries(set || {})) {
864
+ if (v === null) delete target[k];
865
+ else if (k === "props" && target.props && typeof v === "object" && !Array.isArray(v)) {
866
+ for (const [pk, pv] of Object.entries(v)) {
867
+ if (pv === null) delete target.props[pk]; else target.props[pk] = pv;
868
+ }
869
+ } else target[k] = v;
870
+ }
871
+ };
872
+ for (const op of ops) {
873
+ switch (op.op) {
874
+ case "addClip": {
875
+ const c = op.clip;
876
+ if (!c || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
877
+ throw new Error("addClip needs clip{track, start, duration}");
878
+ c.id = c.id || "c_" + uid();
879
+ if (proj.clips.some((x) => x.id === c.id)) throw new Error("addClip: duplicate clip id " + c.id);
880
+ refuseLocked("addClip", lockedTracks().has(c.track) ? `track ${c.track} is locked` : null, op);
881
+ if (c.kind !== "text" && c.kind !== "adjust" && !proj.media.some((m) => m.id === c.mediaId))
882
+ throw new Error(`addClip: unknown mediaId ${c.mediaId}`);
883
+ proj.clips.push(c);
884
+ notes.push("+" + c.id);
885
+ break;
886
+ }
887
+ case "updateClip": {
888
+ const c = proj.clips.find((x) => x.id === op.id);
889
+ if (!c) throw new Error("updateClip: no clip " + op.id);
890
+ // Toggling the lock itself is always allowed — that's how you unlock.
891
+ const onlyLock = Object.keys(op.set || {}).every((k) => k === "locked");
892
+ if (!onlyLock) {
893
+ refuseLocked("updateClip", lockReason(c), op);
894
+ if (op.set && op.set.track && lockedTracks().has(op.set.track))
895
+ refuseLocked("updateClip", `track ${op.set.track} is locked`, op);
896
+ }
897
+ mergeInto(c, op.set);
898
+ notes.push("~" + op.id);
899
+ break;
900
+ }
901
+ case "removeClip": {
902
+ const doomed = proj.clips.find((x) => x.id === op.id);
903
+ if (doomed) refuseLocked("removeClip", lockReason(doomed), op);
904
+ const n = proj.clips.length;
905
+ proj.clips = proj.clips.filter((x) => x.id !== op.id);
906
+ if (proj.clips.length === n) throw new Error("removeClip: no clip " + op.id);
907
+ notes.push("-" + op.id);
908
+ break;
909
+ }
910
+ case "addMedia": {
911
+ const m = op.media;
912
+ if (!m || !m.src || !m.kind) throw new Error("addMedia needs media{src, kind}");
913
+ m.id = m.id || "m_" + uid();
914
+ if (proj.media.some((x) => x.id === m.id)) throw new Error("addMedia: duplicate media id " + m.id);
915
+ m.name = m.name || path.basename(decodeURIComponent(m.src));
916
+ proj.media.push(m);
917
+ notes.push("+" + m.id);
918
+ break;
919
+ }
920
+ case "removeMedia": {
921
+ const used = proj.clips.find((c) => c.mediaId === op.id);
922
+ if (used) throw new Error(`removeMedia: media ${op.id} is used by clip ${used.id}`);
923
+ const n = proj.media.length;
924
+ proj.media = proj.media.filter((x) => x.id !== op.id);
925
+ if (proj.media.length === n) throw new Error("removeMedia: no media " + op.id);
926
+ notes.push("-" + op.id);
927
+ break;
928
+ }
929
+ case "setFx": {
930
+ // {op:"setFx", target:"clip"|"track"|"bus"|"master", id?, fx?:[…] | preset?:"podcast", append?:true}
931
+ const t = fxTarget(op, "setFx");
932
+ let chain;
933
+ if (op.preset != null) chain = FX.presetChain(String(op.preset));
934
+ else if (op.fx === null) chain = [];
935
+ else chain = FX.normalizeFx(op.fx, true);
936
+ t.write(op.append ? [...FX.normalizeFx(t.read()), ...chain] : chain);
937
+ notes.push("~" + t.label + ".fx");
938
+ break;
939
+ }
940
+ case "setFxKeys": {
941
+ // {op:"setFxKeys", target, id?, index?|type?, param, keys:[{t, v, ease?}] | null}
942
+ const t = fxTarget(op, "setFxKeys");
943
+ const list = FX.normalizeFx(t.read()).map((e) => ({ ...e }));
944
+ let i = Number.isInteger(op.index) ? op.index : op.type != null ? list.findIndex((e) => e.type === op.type) : list.length === 1 ? 0 : -1;
945
+ if (!list[i]) throw new Error(`setFxKeys: pick the effect with index (0…${list.length - 1}) or type — ${t.label} has ${FX.summarizeFx(list) || "no effects"}`);
946
+ const e = list[i];
947
+ if (!FX.FX_DEFS[e.type].params[op.param]) throw new Error(`setFxKeys: ${e.type} has no parameter ${JSON.stringify(op.param)} (has: ${Object.keys(FX.FX_DEFS[e.type].params).join(", ")})`);
948
+ const keys = { ...(e.keys || {}) };
949
+ if (op.keys == null || (Array.isArray(op.keys) && !op.keys.length)) delete keys[op.param];
950
+ else Object.assign(keys, FX.normalizeKeys(e.type, { [op.param]: op.keys }, true) || {});
951
+ if (Object.keys(keys).length) e.keys = keys; else delete e.keys;
952
+ t.write(list);
953
+ notes.push(`~${t.label}.fx[${i}].${op.param}` + (keys[op.param] ? `(${keys[op.param].length} keys)` : "(keys cleared)"));
954
+ break;
955
+ }
956
+ case "setGrade": {
957
+ // {op:"setGrade", id | ids, grade:{…} | null, replace?:true}
958
+ const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
959
+ if (!ids.length) throw new Error("setGrade needs id or ids");
960
+ if (op.grade !== null && (typeof op.grade !== "object" || Array.isArray(op.grade)))
961
+ throw new Error("setGrade needs grade:{…} (or grade:null to clear)");
962
+ if (op.grade) Color.normalizeGrade(Object.fromEntries(Object.entries(op.grade).filter(([, v]) => v !== null)), true); // validate first
963
+ for (const id of ids) {
964
+ const c = proj.clips.find((x) => x.id === id);
965
+ if (!c) throw new Error("setGrade: no clip " + id);
966
+ if (!["video", "image", "svg", "adjust"].includes(c.kind)) throw new Error(`setGrade: ${id} is a ${c.kind} clip — grades go on video, image, svg or adjust clips`);
967
+ refuseLocked("setGrade", lockReason(c), op);
968
+ const next = op.grade === null ? null : Color.mergeGrade(op.replace ? null : c.props?.grade, op.grade, true);
969
+ c.props = c.props || {};
970
+ if (next) c.props.grade = next; else delete c.props.grade;
971
+ notes.push(`~${id}.grade(${Color.summarizeGrade(next)})`);
972
+ }
973
+ break;
974
+ }
975
+ case "setGradeLayer": {
976
+ // {op:"setGradeLayer", id | ids, layer: index | "name", set?:{…}, replace?:true, remove?:true}
977
+ const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
978
+ if (!ids.length) throw new Error("setGradeLayer needs id or ids");
979
+ const byName = typeof op.layer === "string" && op.layer.trim() !== "";
980
+ if (!byName && !(Number.isInteger(op.layer) && op.layer >= 0)) throw new Error("setGradeLayer needs layer: an index (0 = first) or a layer name");
981
+ if (!op.remove && (op.set == null || typeof op.set !== "object" || Array.isArray(op.set))) throw new Error("setGradeLayer needs set:{…} (or remove:true)");
982
+ for (const id of ids) {
983
+ const c = proj.clips.find((x) => x.id === id);
984
+ if (!c) throw new Error("setGradeLayer: no clip " + id);
985
+ if (!["video", "image", "svg", "adjust"].includes(c.kind)) throw new Error(`setGradeLayer: ${id} is a ${c.kind} clip — grades go on video, image, svg or adjust clips`);
986
+ refuseLocked("setGradeLayer", lockReason(c), op);
987
+ const layers = (c.props?.grade?.layers || []).map((l) => ({ ...l }));
988
+ let i = byName ? layers.findIndex((l) => l.name === op.layer.trim()) : op.layer;
989
+ if (op.remove) {
990
+ if (i < 0 || i >= layers.length) throw new Error(`setGradeLayer: ${id} has no layer ${JSON.stringify(op.layer)}`);
991
+ layers.splice(i, 1);
992
+ } else {
993
+ if (i < 0 || i === layers.length) { // a new layer
994
+ if (layers.length >= Color.MAX_LAYERS) throw new Error(`setGradeLayer: at most ${Color.MAX_LAYERS} layers`);
995
+ i = layers.length; layers.push({ name: byName ? op.layer.trim() : `Layer ${i + 1}` });
996
+ } else if (i > layers.length) throw new Error(`setGradeLayer: ${id} has ${layers.length} layer(s) — use layer:${layers.length} to add one`);
997
+ const base = op.replace ? { name: layers[i].name } : layers[i];
998
+ let next;
999
+ try { next = Color.mergeLayer(base, op.set, true); } catch (err) { throw new Error(`setGradeLayer: ${err.message.replace(/^grade\.layers\[0\]\.?/, "")}`); }
1000
+ if (!next.name) next.name = layers[i].name || `Layer ${i + 1}`;
1001
+ layers[i] = next;
1002
+ }
1003
+ const g = Color.mergeGrade(c.props?.grade, { layers: layers.length ? layers : null }, true);
1004
+ c.props = c.props || {};
1005
+ if (g) c.props.grade = g; else delete c.props.grade;
1006
+ notes.push(`~${id}.grade(${Color.summarizeGrade(g)})`);
1007
+ }
1008
+ break;
1009
+ }
1010
+ case "setTrack": {
1011
+ // Mixer settings on one lane: {op:"setTrack", id:"A1", set:{gain:-6, pan:0.2}}.
1012
+ if (!/^A\d+$/.test(String(op.id || ""))) throw new Error("setTrack: id must be an audio track (A1, A2, …)");
1013
+ if (!Array.isArray(proj.tracks) || !proj.tracks.length) proj.tracks = DEFAULT_TRACKS.map((t) => ({ ...t }));
1014
+ let t = proj.tracks.find((x) => x.id === op.id);
1015
+ if (!t) { t = { id: op.id, kind: "audio" }; proj.tracks.push(t); }
1016
+ for (const [k, v] of Object.entries(op.set || {})) {
1017
+ if (k === "out") { // route into a submix bus, or back to the master
1018
+ if (v == null || v === "master") { delete t.out; continue; }
1019
+ if (!(proj.buses || []).some((b) => b.id === v)) throw new Error(`setTrack: no bus ${JSON.stringify(v)} — create it first with {op:"setBus", id:"B1"}`);
1020
+ t.out = v;
1021
+ continue;
1022
+ }
1023
+ const range = k === "gain" ? [-60, 12] : k === "pan" ? [-1, 1] : null;
1024
+ if (!range) throw new Error(`setTrack: '${k}' not settable (gain, pan, out)`);
1025
+ if (v === null || v === 0) { delete t[k]; continue; }
1026
+ if (typeof v !== "number" || !Number.isFinite(v) || v < range[0] || v > range[1])
1027
+ throw new Error(`setTrack: ${k} must be a number ${range[0]}…${range[1]}`);
1028
+ t[k] = v;
1029
+ }
1030
+ notes.push("~" + op.id);
1031
+ break;
1032
+ }
1033
+ case "setBus": {
1034
+ // {op:"setBus", id:"B1", set:{name?, gain?, pan?, mute?}} — creates the bus if needed
1035
+ if (!/^B\d+$/.test(String(op.id || ""))) throw new Error("setBus: id must be B1, B2, …");
1036
+ proj.buses = Array.isArray(proj.buses) ? proj.buses : [];
1037
+ let b = proj.buses.find((x) => x.id === op.id);
1038
+ if (!b) {
1039
+ if (proj.buses.length >= 8) throw new Error("setBus: up to 8 buses");
1040
+ b = { id: op.id };
1041
+ proj.buses.push(b);
1042
+ proj.buses.sort((x, y) => parseInt(x.id.slice(1), 10) - parseInt(y.id.slice(1), 10));
1043
+ }
1044
+ for (const [k, v] of Object.entries(op.set || {})) {
1045
+ if (k === "name") { if (v == null || v === "") delete b.name; else b.name = String(v).slice(0, 40); continue; }
1046
+ if (k === "mute") { if (v === true) b.mute = true; else delete b.mute; continue; }
1047
+ const range = k === "gain" ? [-60, 12] : k === "pan" ? [-1, 1] : null;
1048
+ if (!range) throw new Error(`setBus: '${k}' not settable (name, gain, pan, mute; effects via setFx target:"bus")`);
1049
+ if (v === null || v === 0) { delete b[k]; continue; }
1050
+ if (typeof v !== "number" || !Number.isFinite(v) || v < range[0] || v > range[1])
1051
+ throw new Error(`setBus: ${k} must be a number ${range[0]}…${range[1]}`);
1052
+ b[k] = v;
1053
+ }
1054
+ notes.push("~" + op.id);
1055
+ break;
1056
+ }
1057
+ case "removeBus": {
1058
+ const n = (proj.buses || []).length;
1059
+ proj.buses = (proj.buses || []).filter((b) => b.id !== op.id);
1060
+ if (proj.buses.length === n) throw new Error("removeBus: no bus " + op.id);
1061
+ if (!proj.buses.length) delete proj.buses;
1062
+ for (const t of proj.tracks || []) if (t.out === op.id) delete t.out; // back to the master
1063
+ notes.push("-" + op.id);
1064
+ break;
1065
+ }
1066
+ case "setProject": {
1067
+ const allowed = ["name", "width", "height", "fps", "background", "markers", "disabledTracks", "lockedTracks", "untargetedTracks", "exportFrame", "encodeProfile", "master", "inPoint", "outPoint"];
1068
+ for (const [k, v] of Object.entries(op.set || {})) {
1069
+ if (!allowed.includes(k)) throw new Error(`setProject: '${k}' not settable (allowed: ${allowed.join(", ")})`);
1070
+ if ((k === "inPoint" || k === "outPoint") && v != null && (typeof v !== "number" || !Number.isFinite(v) || v < 0))
1071
+ throw new Error(`setProject: ${k} must be seconds ≥ 0 (null clears it)`);
1072
+ if (k === "encodeProfile" && v != null) {
1073
+ resolveProfile(String(v)); // validate id exists
1074
+ }
1075
+ if (k === "master" && v != null) {
1076
+ const g = v.gain;
1077
+ if (typeof v !== "object" || Object.keys(v).some((x) => x !== "gain") ||
1078
+ (g != null && (typeof g !== "number" || !Number.isFinite(g) || g < -60 || g > 12)))
1079
+ throw new Error("setProject: master is {gain} — master fader in dB, -60…+12");
1080
+ }
1081
+ if (v === null) delete proj[k]; else proj[k] = v;
1082
+ }
1083
+ if (proj.inPoint != null && proj.outPoint != null && !(proj.outPoint > proj.inPoint))
1084
+ throw new Error("setProject: outPoint must be after inPoint");
1085
+ notes.push("~project");
1086
+ break;
1087
+ }
1088
+ case "split": case "rippleDelete": case "closeGap": case "lift": case "extract":
1089
+ case "rippleTrim": case "roll": case "slip": case "slide":
1090
+ case "insert": case "overwrite": case "crossfade":
1091
+ notes.push(timelineEdit(proj, op, refuseLocked, lockReason));
1092
+ break;
1093
+ default:
1094
+ throw new Error("Unknown op: " + op.op + " (addClip|updateClip|removeClip|addMedia|removeMedia|setProject|setTrack|setBus|removeBus|setFx|setFxKeys|setGrade|setGradeLayer|" +
1095
+ "split|rippleDelete|closeGap|lift|extract|rippleTrim|roll|slip|slide|insert|overwrite|crossfade)");
1096
+ }
1097
+ }
1098
+ // Someone else saved while the ops ran: writing now would drop their change.
1099
+ // Without a baseRevision the ops just re-apply on the newer document.
1100
+ let diskRev = readRev;
1101
+ try { diskRev = readProject().revision || 0; } catch {}
1102
+ if (diskRev !== readRev) {
1103
+ if (base == null && attempt < 3) return callTool(name, args, attempt + 1);
1104
+ throw conflict(diskRev);
1105
+ }
1106
+ proj.revision = readRev + 1;
1107
+ writeProject(proj);
1108
+ lastReadRevision = proj.revision;
1109
+ return `Patched (revision ${proj.revision}): ${notes.join(" ")}. Now ${proj.clips.length} clip(s), ${proj.media.length} media. UI hot-reloaded.`;
1110
+ }
1111
+ case "fablecut_set_project": {
1112
+ const doc = args.project;
1113
+ if (!doc || typeof doc !== "object") throw new Error("`project` must be an object");
1114
+ if (!Array.isArray(doc.clips) || !Array.isArray(doc.media))
1115
+ throw new Error("project must contain `clips` and `media` arrays");
1116
+ for (const c of doc.clips) {
1117
+ if (!c.id || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
1118
+ throw new Error(`clip ${c.id || "?"} needs id, track, numeric start and duration`);
1119
+ if (c.kind !== "text" && c.kind !== "adjust" && !doc.media.some((m) => m.id === c.mediaId))
1120
+ throw new Error(`clip ${c.id} references unknown mediaId ${c.mediaId}`);
1121
+ }
1122
+ let cur = { revision: 0 };
1123
+ try { cur = readProject(); } catch {}
1124
+ const curRev = cur.revision || 0;
1125
+ // strict check when this session read via the tool; otherwise fall back to
1126
+ // the revision baked into the submitted doc (e.g. it was read as a file)
1127
+ const stale = lastReadRevision !== null
1128
+ ? curRev !== lastReadRevision
1129
+ : (doc.revision || 0) < curRev;
1130
+ if (stale && !args.force) {
1131
+ throw new Error(
1132
+ `CONFLICT — not saved. project.json is at revision ${curRev}, but this edit was based on ` +
1133
+ `revision ${lastReadRevision ?? (doc.revision || 0)}: the project changed in between ` +
1134
+ `(the user probably tweaked something in the editor UI). ` +
1135
+ `Call fablecut_get_project, re-apply your edit on top of the latest document, then save again. ` +
1136
+ `Pass force:true only if the user explicitly wants those changes discarded.`);
1137
+ }
1138
+ doc.revision = Math.max(curRev + 1, (doc.revision || 0));
1139
+ // Agents often omit top-level flags they don't know about. Keep panSchema
1140
+ // once established so a rewrite that drops pan:0 cannot re-trigger L/R migration.
1141
+ if (!(doc.panSchema >= 1) && (cur.panSchema >= 1)) doc.panSchema = 1;
1142
+ writeProject(doc);
1143
+ lastReadRevision = doc.revision;
1144
+ return `Saved (revision ${doc.revision}). ${doc.clips.length} clip(s). The editor UI (if open at ${BASE}) has hot-reloaded.`;
1145
+ }
1146
+ case "fablecut_normalize_audio": {
1147
+ const ids = Array.isArray(args.clipIds) ? args.clipIds.map(String) : [];
1148
+ if (!ids.length) throw new Error("`clipIds` must list at least one clip");
1149
+ const mode = args.mode === "peak" ? "peak" : "lufs";
1150
+ const target = Number.isFinite(args.target) ? args.target : mode === "peak" ? -1 : -14;
1151
+ if (mode === "lufs" && (target < -60 || target > 0)) throw new Error("target LUFS must be -60…0");
1152
+ if (mode === "peak" && (target < -60 || target > 0)) throw new Error("target peak must be -60…0 dBFS");
1153
+ const L = require("./loudness.js");
1154
+ const groups = normalizeGroups(readProject(), ids);
1155
+ const measured = [];
1156
+ for (const g of groups) measured.push({ ids: g.map((c) => c.id), m: await measureClipGroup(readProject(), g) });
1157
+ // Re-read after the (slow) measuring so concurrent UI edits are kept.
1158
+ const proj = readProject();
1159
+ const locked = new Set(Array.isArray(proj.lockedTracks) ? proj.lockedTracks : []);
1160
+ const report = [];
1161
+ let changed = 0;
1162
+ for (const { ids: gIds, m } of measured) {
1163
+ const clips = gIds.map((id) => proj.clips.find((x) => x.id === id)).filter(Boolean);
1164
+ const lockedBy = clips.find((c) => c.locked === true || locked.has(c.track));
1165
+ if (lockedBy && args.force !== true) {
1166
+ report.push(`${gIds.join("+")}: skipped — ${lockedBy.id} is locked (pass force:true if the user asked)`);
1167
+ continue;
1168
+ }
1169
+ const db = m && L.normalizeGainDb(m, { mode, value: target });
1170
+ if (db == null) { report.push(`${gIds.join("+")}: silent — left alone`); continue; }
1171
+ const gain = Math.round(Math.min(24, Math.max(-60, db)) * 10) / 10;
1172
+ for (const c of clips) { c.props = c.props || {}; c.props.gain = gain; }
1173
+ changed++;
1174
+ const level = mode === "peak" ? `${m.peakDb.toFixed(1)} dBFS peak` : `${m.lufs.toFixed(1)} LUFS`;
1175
+ report.push(`${gIds.join("+")}: ${level} → gain ${gain > 0 ? "+" : ""}${gain} dB`);
1176
+ }
1177
+ if (changed) {
1178
+ proj.revision = (proj.revision || 0) + 1;
1179
+ writeProject(proj);
1180
+ lastReadRevision = proj.revision;
1181
+ }
1182
+ return `Normalize to ${target} ${mode === "peak" ? "dBFS peak" : "LUFS"}${changed ? ` (revision ${proj.revision})` : " — nothing changed"}:\n` + report.join("\n");
1183
+ }
1184
+ case "fablecut_auto_duck": {
1185
+ const ids = Array.isArray(args.clipIds) ? args.clipIds.map(String) : [];
1186
+ if (!ids.length) throw new Error("`clipIds` must list the music clip(s) to duck");
1187
+ const amount = args.amount == null ? -12 : +args.amount;
1188
+ if (!Number.isFinite(amount) || amount < -40 || amount > 0) throw new Error("amount must be -40…0 dB");
1189
+ const threshold = args.threshold == null ? -40 : +args.threshold;
1190
+ const attack = args.attack == null ? 0.3 : Math.max(0, +args.attack || 0);
1191
+ const release = args.release == null ? 0.6 : Math.max(0, +args.release || 0);
1192
+ const D = require("./ducking.js");
1193
+ const doc = readProject();
1194
+ const musicGroups = [];
1195
+ const seen = new Set();
1196
+ for (const id of ids) {
1197
+ const c = doc.clips.find((x) => x.id === id);
1198
+ if (!c) throw new Error("no clip " + id);
1199
+ if (c.kind !== "audio" && c.kind !== "video") throw new Error(`clip ${id} is ${c.kind} — no audio to duck`);
1200
+ const key = c.linkGroup || c.id;
1201
+ if (seen.has(key)) continue;
1202
+ seen.add(key);
1203
+ const members = c.linkGroup ? doc.clips.filter((x) => x.linkGroup === c.linkGroup) : [c];
1204
+ const stems = members.filter((x) => x.kind === "audio");
1205
+ if (stems.length) musicGroups.push(stems.map((x) => x.id));
1206
+ }
1207
+ if (!musicGroups.length) throw new Error("none of those clips carry audio on an A-track");
1208
+ const musicIds = new Set(musicGroups.flat());
1209
+ const musicClips = doc.clips.filter((x) => musicIds.has(x.id));
1210
+ const own = new Set(musicClips.map((x) => x.track));
1211
+ const lanes = Array.isArray(args.under) && args.under.length ? args.under.map(String)
1212
+ : [...new Set(doc.clips.filter((x) => x.kind === "audio").map((x) => x.track))].filter((t) => !own.has(t));
1213
+ const t0 = Math.min(...musicClips.map((x) => x.start));
1214
+ const t1 = Math.max(...musicClips.map((x) => x.start + x.duration));
1215
+ const disabled = new Set(Array.isArray(doc.disabledTracks) ? doc.disabledTracks : []);
1216
+ const voices = doc.clips.filter((x) => x.kind === "audio" && lanes.includes(x.track) && !musicIds.has(x.id) &&
1217
+ !disabled.has(x.track) && x.disabled !== true && x.start + x.duration > t0 && x.start < t1);
1218
+ const regions = amount && voices.length ? await voiceRegions(doc, voices, threshold) : [];
1219
+ if (amount && !voices.length) throw new Error(`no audio clips on ${lanes.join(", ") || "other tracks"} under the music — nothing to duck for`);
1220
+ // Re-read after the (slow) analysis so concurrent UI edits are kept.
1221
+ const proj = readProject();
1222
+ const locked = new Set(Array.isArray(proj.lockedTracks) ? proj.lockedTracks : []);
1223
+ const report = [];
1224
+ let changed = 0;
1225
+ for (const g of musicGroups) {
1226
+ const clips = g.map((id) => proj.clips.find((x) => x.id === id)).filter(Boolean);
1227
+ if (!clips.length) continue;
1228
+ const lockedBy = clips.find((c) => c.locked === true || locked.has(c.track));
1229
+ if (lockedBy && args.force !== true) {
1230
+ report.push(`${g.join("+")}: skipped — ${lockedBy.id} is locked (pass force:true if the user asked)`);
1231
+ continue;
1232
+ }
1233
+ const keys = amount ? D.duckKeyframes(regions, clips[0], { amount, attack, release }) : [];
1234
+ for (const c of clips) {
1235
+ if (keys.length) { c.keyframes = c.keyframes || {}; c.keyframes.duck = keys.map((k) => ({ ...k })); }
1236
+ else if (c.keyframes && c.keyframes.duck) {
1237
+ delete c.keyframes.duck;
1238
+ if (!Object.keys(c.keyframes).length) delete c.keyframes;
1239
+ }
1240
+ }
1241
+ changed++;
1242
+ const dips = keys.filter((k, i) => k.v === amount && (i === 0 || keys[i - 1].v !== amount)).length;
1243
+ report.push(`${g.join("+")}: ${amount ? `${dips} dip${dips === 1 ? "" : "s"} of ${amount} dB` : "ducking removed"}`);
1244
+ }
1245
+ if (changed) {
1246
+ proj.revision = (proj.revision || 0) + 1;
1247
+ writeProject(proj);
1248
+ lastReadRevision = proj.revision;
1249
+ }
1250
+ const head = amount ? `Auto-duck under ${lanes.join(", ")} — ${regions.length} voice span${regions.length === 1 ? "" : "s"}` : "Ducking cleared";
1251
+ return `${head}${changed ? ` (revision ${proj.revision})` : " — nothing changed"}:\n` + report.join("\n");
1252
+ }
1253
+ case "fablecut_analyze_reference": {
1254
+ let src = args.path || "";
1255
+ let file = /^\/media\//i.test(src)
1256
+ ? path.join(MEDIA_DIR, decodeURIComponent(src.replace(/^\/media\//i, "")))
1257
+ : src;
1258
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile())
1259
+ throw new Error("File not found: " + src);
1260
+ // keep the reference inside media/ so the user can preview it in the UI
1261
+ if (path.dirname(path.resolve(file)).toLowerCase() !== MEDIA_DIR.toLowerCase()) {
1262
+ if (!fs.existsSync(MEDIA_DIR)) fs.mkdirSync(MEDIA_DIR);
1263
+ const ext = path.extname(file);
1264
+ const stem = path.basename(file, ext).replace(/[^\w.\- ()\[\]]+/g, "_");
1265
+ let target = path.join(MEDIA_DIR, stem + ext);
1266
+ let i = 1;
1267
+ while (fs.existsSync(target)) target = path.join(MEDIA_DIR, `${stem}_${i++}${ext}`);
1268
+ fs.copyFileSync(file, target);
1269
+ file = target;
1270
+ }
1271
+ const { analyze } = require("./analyze.js");
1272
+ const bp = await analyze(file, {
1273
+ threshold: args.threshold,
1274
+ music: args.registerMusic !== false,
1275
+ musicDir: MEDIA_DIR,
1276
+ srcUrl: "/media/" + encodeURIComponent(path.basename(file)),
1277
+ });
1278
+ let musicNote = "Reference has no audio track — no music extracted.";
1279
+ if (bp.music) {
1280
+ bp.music.src = "/media/" + encodeURIComponent(bp.music.name);
1281
+ // merge-safe append, same protocol as fablecut_import_media
1282
+ const entry = {
1283
+ id: "m_" + uid(), name: bp.music.name, kind: "audio",
1284
+ src: bp.music.src, duration: bp.duration,
1285
+ };
1286
+ const proj = readProject();
1287
+ const wasCurrent = lastReadRevision === (proj.revision || 0);
1288
+ proj.media.push(entry);
1289
+ proj.revision = (proj.revision || 0) + 1;
1290
+ writeProject(proj);
1291
+ if (wasCurrent) lastReadRevision = proj.revision;
1292
+ bp.music.mediaId = entry.id;
1293
+ musicNote = `Music extracted and registered as media "${entry.id}" — place it on A1 (in:0, duration:${bp.duration}).`;
1294
+ }
1295
+ const dir = ANALYSIS_DIR;
1296
+ fs.mkdirSync(dir, { recursive: true });
1297
+ fs.writeFileSync(path.join(dir, path.basename(file, path.extname(file)) + ".json"),
1298
+ JSON.stringify(bp, null, 2));
1299
+ return JSON.stringify(bp, null, 2) + "\n\n" + [
1300
+ musicNote,
1301
+ `REMAKE RECIPE: 1) set project width/height/fps to ${bp.width}x${bp.height} @ ${bp.fps} — 2) write beats[] into project markers — 3) extracted music on A1 — 4) one clip per shots[] entry at the same start/duration on V1 (hard cuts; footage energy should track each shot's energy value) — 5) biggest moment on drop (${bp.drop}s)${bp.bpm ? ` — tempo ${bp.bpm} BPM` : ""}. Full recipe: fablecut_docs → "Remake a reference video".`,
1302
+ ].join("\n");
1303
+ }
1304
+ case "fablecut_import_media": {
1305
+ const src = args.path;
1306
+ if (!src) throw new Error("path is required");
1307
+ let target, kind;
1308
+ if (/^https?:\/\//i.test(src)) {
1309
+ if (!/^https:\/\//i.test(src)) throw new Error("URL must be https");
1310
+ const got = await downloadImportUrl(src, MEDIA_DIR);
1311
+ target = got.target;
1312
+ kind = kindFromName(got.name);
1313
+ await maybeFaststart(target);
1314
+ } else {
1315
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile())
1316
+ throw new Error("File not found: " + src);
1317
+ const ext = path.extname(src).toLowerCase();
1318
+ kind = KIND_BY_EXT[ext];
1319
+ if (!kind) throw new Error(`Unsupported extension ${ext}`);
1320
+ if (!fs.existsSync(MEDIA_DIR)) fs.mkdirSync(MEDIA_DIR);
1321
+ let base = path.basename(src).replace(/[^\w.\- ()\[\]]+/g, "_");
1322
+ target = path.join(MEDIA_DIR, base);
1323
+ let i = 1;
1324
+ const stem = path.basename(base, ext);
1325
+ while (fs.existsSync(target)) target = path.join(MEDIA_DIR, `${stem}_${i++}${ext}`);
1326
+ fs.copyFileSync(src, target);
1327
+ }
1328
+ if (!kind) throw new Error("Unsupported media type");
1329
+ const entry = {
1330
+ id: "m_" + uid(),
1331
+ name: path.basename(target),
1332
+ kind,
1333
+ src: "/media/" + encodeURIComponent(path.basename(target)),
1334
+ duration: kind === "image" || kind === "svg" ? undefined : ffprobeDuration(target),
1335
+ };
1336
+ const proj = readProject();
1337
+ // import only appends a media entry (never touches clips), so it merges
1338
+ // into the live document; keep lastReadRevision in step only if it
1339
+ // already was — otherwise a later set_project must still re-read
1340
+ const wasCurrent = lastReadRevision === (proj.revision || 0);
1341
+ proj.media.push(entry);
1342
+ proj.revision = (proj.revision || 0) + 1;
1343
+ writeProject(proj);
1344
+ if (wasCurrent) lastReadRevision = proj.revision;
1345
+ return `Imported → ${JSON.stringify(entry)}\n` +
1346
+ (entry.duration == null && kind !== "image"
1347
+ ? "Note: duration unknown (no ffprobe). The browser UI will probe and fill it in; re-read the project before trimming this media."
1348
+ : "Ready to use in clips via mediaId.");
1349
+ }
1350
+ case "fablecut_encode_profiles": {
1351
+ if (args.profile) {
1352
+ const p = resolveProfile(String(args.profile));
1353
+ return JSON.stringify({
1354
+ default: loadEncodeProfiles().default,
1355
+ profile: args.profile,
1356
+ label: p.label,
1357
+ description: p.description,
1358
+ jpegQuality: p.jpegQuality,
1359
+ extension: p.extension,
1360
+ color: p.color,
1361
+ args: p.args,
1362
+ }, null, 2);
1363
+ }
1364
+ return JSON.stringify(listProfilesPublic(!!args.detail), null, 2);
1365
+ }
1366
+ default:
1367
+ throw new Error("Unknown tool: " + name);
1368
+ }
1369
+ }
1370
+
1371
+ /* ── MCP stdio plumbing (newline-delimited JSON-RPC 2.0) ── */
1372
+ function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
1373
+
1374
+ async function handle(msg) {
1375
+ const { id, method, params } = msg;
1376
+ const isRequest = id !== undefined && id !== null;
1377
+ try {
1378
+ if (method === "initialize") {
1379
+ return send({
1380
+ jsonrpc: "2.0", id,
1381
+ result: {
1382
+ protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
1383
+ capabilities: { tools: {} },
1384
+ serverInfo: { name: "fablecut", version: "1.12.0" },
1385
+ },
1386
+ });
1387
+ }
1388
+ if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
1389
+ if (method === "tools/call") {
1390
+ pending++;
1391
+ try {
1392
+ const text = await callTool(params.name, params.arguments || {});
1393
+ send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text }] } });
1394
+ } catch (e) {
1395
+ send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "Error: " + e.message }], isError: true } });
1396
+ } finally {
1397
+ if (--pending === 0 && stdinClosed) process.exit(0);
1398
+ }
1399
+ return;
1400
+ }
1401
+ if (method === "ping") return send({ jsonrpc: "2.0", id, result: {} });
1402
+ if (!isRequest) return; // ignore other notifications (e.g. notifications/initialized)
1403
+ send({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found: " + method } });
1404
+ } catch (e) {
1405
+ if (isRequest) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e) } });
1406
+ }
1407
+ }
1408
+
1409
+ let buf = "", pending = 0, stdinClosed = false;
1410
+ process.stdin.setEncoding("utf8");
1411
+ process.stdin.on("data", (chunk) => {
1412
+ buf += chunk;
1413
+ let nl;
1414
+ while ((nl = buf.indexOf("\n")) >= 0) {
1415
+ const line = buf.slice(0, nl).trim();
1416
+ buf = buf.slice(nl + 1);
1417
+ if (!line) continue;
1418
+ try { handle(JSON.parse(line)); } catch { /* skip malformed line */ }
1419
+ }
1420
+ });
1421
+ process.stdin.on("end", () => { stdinClosed = true; if (pending === 0) process.exit(0); });