fablecut 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +373 -11
- package/README.md +48 -5
- package/analyze.js +161 -9
- package/app.js +1894 -109
- package/delivery.js +779 -0
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +136 -4
- package/mcp-server.js +318 -1070
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/package.json +1 -1
- package/paths.js +9 -2
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +498 -15
- package/style.css +361 -0
package/mcp-server.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
or, from a clone:
|
|
8
8
|
claude mcp add -s user fablecut -- node "<path-to>/fablecut/mcp-server.js"
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
Tools: fablecut_status, fablecut_docs, fablecut_get_project,
|
|
11
|
+
fablecut_set_project, fablecut_patch_project, fablecut_import_media,
|
|
12
|
+
fablecut_analyze_reference, fablecut_frame, fablecut_encode_profiles,
|
|
13
|
+
fablecut_normalize_audio, fablecut_auto_duck, fablecut_export,
|
|
14
|
+
fablecut_scopes
|
|
15
15
|
═══════════════════════════════════════════════════════════════════════════ */
|
|
16
16
|
"use strict";
|
|
17
17
|
const fs = require("fs");
|
|
@@ -21,18 +21,23 @@ const { spawn, spawnSync } = require("child_process");
|
|
|
21
21
|
const { loadEncodeProfiles, listProfilesPublic, resolveProfile, profileSummary } = require("./encode-profiles");
|
|
22
22
|
|
|
23
23
|
const {
|
|
24
|
-
APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR, PROJECT_FILE, ensureDirs, readSaved, writeSaved,
|
|
24
|
+
APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR, PROJECT_FILE, PROXIES_DIR, ensureDirs, readSaved, writeSaved,
|
|
25
25
|
} = require("./paths");
|
|
26
26
|
const { downloadImportUrl, kindFromName, maybeFaststart } = require("./import-url");
|
|
27
27
|
const FX = require("./audio-fx");
|
|
28
28
|
const EditOps = require("./edit-ops");
|
|
29
|
+
const ProjectOps = require("./project-ops");
|
|
29
30
|
const Denoise = require("./denoise");
|
|
30
31
|
const Color = require("./color");
|
|
32
|
+
const FrameStats = require("./frame-stats");
|
|
31
33
|
const Mask = require("./mask");
|
|
32
34
|
const Tracker = require("./tracker");
|
|
33
35
|
const Motion = require("./motion");
|
|
34
36
|
const CompLib = require("./comp");
|
|
35
37
|
const Templates = require("./templates");
|
|
38
|
+
const Delivery = require("./delivery");
|
|
39
|
+
const { probeFile, infoForEntry } = require("./media-probe");
|
|
40
|
+
const { findProxies } = require("./proxy");
|
|
36
41
|
/** The user's saved presets / templates (library/presets, library/templates), validated; bad files skipped. */
|
|
37
42
|
const savedItems = (kind) => readSaved(kind).map((x) => (kind === "presets" ? Templates.normalizePreset(x.data) : Templates.normalizeTemplate(x.data))).filter(Boolean);
|
|
38
43
|
|
|
@@ -77,10 +82,11 @@ function httpOk(url) {
|
|
|
77
82
|
req.setTimeout(1200, () => { req.destroy(); resolve(false); });
|
|
78
83
|
});
|
|
79
84
|
}
|
|
80
|
-
/** JSON request to the editor server → {status, body}.
|
|
81
|
-
|
|
85
|
+
/** JSON request to the editor server → {status, body}. Extra headers are for
|
|
86
|
+
* endpoints that gate on them — /api/frame?path= needs X-FableCut-Client. */
|
|
87
|
+
function apiJSON(method, urlPath, payload, headers) {
|
|
82
88
|
return new Promise((resolve, reject) => {
|
|
83
|
-
const req = http.request(API + urlPath, { method, headers: { "Content-Type": "application/json" } }, (r) => {
|
|
89
|
+
const req = http.request(API + urlPath, { method, headers: { "Content-Type": "application/json", ...headers } }, (r) => {
|
|
84
90
|
let data = "";
|
|
85
91
|
r.setEncoding("utf8");
|
|
86
92
|
r.on("data", (d) => { data += d; });
|
|
@@ -97,6 +103,11 @@ function apiJSON(method, urlPath, payload) {
|
|
|
97
103
|
}
|
|
98
104
|
async function ensureUIServer() {
|
|
99
105
|
if (await httpOk(API + "/api/project")) return true;
|
|
106
|
+
/* Tests (and nothing else) may set FABLECUT_NO_AUTOSTART=1: the auto-start
|
|
107
|
+
below spawns a detached server.js the test harness cannot stop — it
|
|
108
|
+
inherits the test's port and data dir and keeps running after the test.
|
|
109
|
+
With the flag set, report the missing server instead of starting one. */
|
|
110
|
+
if (process.env.FABLECUT_NO_AUTOSTART === "1") return false;
|
|
100
111
|
spawn(process.execPath, [path.join(ROOT, "server.js")],
|
|
101
112
|
{ cwd: ROOT, detached: true, stdio: "ignore" }).unref();
|
|
102
113
|
for (let i = 0; i < 12; i++) {
|
|
@@ -146,7 +157,7 @@ const TOOLS = [
|
|
|
146
157
|
},
|
|
147
158
|
{
|
|
148
159
|
name: "fablecut_patch_project",
|
|
149
|
-
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}) · {op:'setMask', id | ids, mask?: index | 'name', set:{shape?:'rect'|'ellipse'|'bezier'|'freehand', mode?:'add'|'subtract'|'intersect'|'difference', invert?, opacity?, feather? (px), expand? (px, − shrinks), x?, y? (centre, 0…1 of the clip's picture), w?, h?, scale?, rotation?, points?:[[dx, dy, inX?, inY?, outX?, outY?], …] (bezier, around x, y), stroke?:[[u, v], …] (freehand: fitted into a bezier), keys?:[{t, ease?, x?, y?, w?, h?, scale?, rotation?, feather?, expand?, opacity?, points?}], name?, on?}, replace?:true, remove?:true} (clip masks on video / image / svg / text / adjust clips: the clip shows only where its masks combine to, bottom-up; mask left out, an unknown name or index = length adds one — a new mask needs shape; set merges key by key; masks follow the clip's transform; check with fablecut_scopes mask:{clip}) · {op:'removeMask', id, mask} · {op:'setMaskKeys', id, mask, keys:[…]|null} (animate a mask; t = seconds from the clip's start) · {op:'setMatte', id | ids, matte:'alpha'|'alpha-inverted'|'luma'|'luma-inverted'|null, track?:'V3'|null} (track matte: the picture on the video track directly above — or track — cuts the clip by its alpha or its brightness, inverted or not; that track stops drawing while it is a matte; null clears). · {op:'removeTrack', id, track: name | index} (delete a motion track stored by fablecut_track; what follows it keeps its keys). MOTION GRAPHICS: a shape layer is addClip {kind:'shape', track, start, duration, props:{shapes:[…]}} (no mediaId); a null is addClip {kind:'null', …} — invisible, a parent for other clips. {op:'setShape', id | ids, shape?: index | 'name' | '2.0' (path into a group), set:{type?:'rect'|'ellipse'|'polygon'|'star'|'line'|'path'|'group', name?, on?, x?, y? (px from the layer's origin), rotation?, scale?, opacity?, w?, h?, radius? (rect corners), sides?, inner? (star 0.05…1), points?:[[x, y, inX?, inY?, outX?, outY?], …] (line: 2, path: 2…128, px around x, y), closed? (path), fill?, stroke? ('#rrggbb' | '#rrggbbaa' | 'none'), strokeWidth?, lineCap?, lineJoin?, dash?:[on, off…], trimStart?, trimEnd? (0…100 — a draw-on), trimOffset?, items?:[…] (group), keys?:[{t, ease?, …any of x y rotation scale opacity w h radius inner strokeWidth trimStart trimEnd trimOffset fill stroke points}]}, replace?:true, remove?:true} (shape left out, an unknown name or index = length adds one — a new shape needs type; set merges key by key) · {op:'removeShape', id, shape} · {op:'setParent', id | ids, parent: clip id | null, keep?: true, at?} (the clip moves, turns and scales with its parent; keep:true — the default — keeps it where it is at time at, default its start; opacity and effects are not inherited). COMPOSITIONS (nested sequences): project.comps = [{id, name, width, height, duration, background?, tracks?, clips:[…]}]; a clip {kind:'comp', compId, start, in, duration} places one (draws like a picture of the comp's size — transform, masks, grade, blend, blur all apply — and its media plays and exports). {op:'addComp', comp:{id?, name, duration, width?, height?, background?, clips?:[…]}} · {op:'updateComp', id, set:{name?, width?, height?, duration?, background?, tracks?}} · {op:'removeComp', id} (refused while used) · {op:'precompose', ids:[…], name?, id?} (the clips move into a new comp; a comp clip takes their place) · {op:'inComp', comp, ops:[…]} (run clip and timeline ops on the comp's own clips — addClip, updateClip, setShape, split, …; nests). PRESETS AND TEMPLATES: {op:'applyPreset', id | ids, preset, length?, at?} keys a named animation onto visual clips, relative to where they sit (in: fade-in, pop-in, slide-up-in, slide-down-in, slide-left-in, slide-right-in, drop-in, zoom-in, spin-in, whip-in, rise-blur, draw-on · out: fade-out, pop-out, slide-up-out, slide-down-out, slide-left-out, slide-right-out, zoom-out, spin-out, whip-out, sink-blur, draw-off · at (an emphasis at timeline time at): pulse, shake, wobble, bounce, flash · loop (the whole clip): float, breathe, sway, spin, wiggle — or a saved one, or an inline {anchor:'in'|'out'|'at'|'whole', length, keys:{channel:[{t, v, ease?}]}} where scale / opacity multiply and the rest add). {op:'addTemplate', template, start?, track?, duration?, fields?:{key: value}, id?, compId?, name?} places a template as its own comp + a comp clip (lower-third-bar, lower-third-underline, lower-third-boxes [name, title, accent…] · logo-ring, logo-slam, logo-lines [logo = a media id, brand / tagline, accent] · social-story 1080x1920, social-quote 1080x1080, social-end-card [text fields, accent, background]); {op:'setTemplate', id: its clip or comp, fields?, duration?} changes the fields or the length (the exit keeps its timing). {op:'savePreset', id, name, anchor?} / {op:'saveTemplate', comp, name?, fields?} save to library/presets and library/templates for every project.MOTION PATHS: x and y keyed at the same time are a position key; curve handles pin / pout (px) on those x and y keys bend the path between keys ({op:'setMotionPath', id | ids, path:'smooth'|'straight', autoOrient?:bool} sets them for you; props.autoOrient turns the clip along its path). props.motionBlur = shutter angle 0…720 (180 = film look), props.motionBlurSamples 2…32. TEXT ON A PATH: {op:'setTextPath', id | ids, path:'line'|'arc'|'circle'|'wave'|{type:'path'|'line'|'ellipse'|'rect'|'polygon'|'star', …shape item geometry, keys?}|null, offset? (% along, keyable as pathOffset), align?:'start'|'center'|'end'} — the title runs along the path (more lines on parallel curves below it), every textAnim plays along it. Every visual clip also has props.anchorX / anchorY (px from its picture's centre: the point x / y place and rotation / scale turn around) and shape clips props.trimStart / trimEnd / trimOffset (trim every path in the layer) — all keyframable. props.blend: normal · multiply · screen · overlay · darken · lighten · color-dodge · color-burn · hard-light · soft-light · difference · exclusion · hue · saturation · color · luminosity · lighter. 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.",
|
|
160
|
+
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} · MEDIA BIN: {op:'updateMedia', id | ids, set:{name?, favorite?:true|false, tags?:[…], folderId?}, addTags?:[…], removeTags?:[…]} (organize media; fps, codec, audio channels and size are probed from the file and read-only) · {op:'setCollection', id?, name?, media?:[…ids] (replaces), add?:[…], remove?:[…]} (a collection groups media across folders; a new id or name creates it) · {op:'removeCollection', id} · {op:'relinkMedia', id, src:'/media/…'} (the same footage found elsewhere — an OFFLINE file in the compact view; clips keep their In points) · {op:'replaceMedia', id, src, name?} (a different file behind the media — every clip using it switches, keeping its timing, trimmed to fit a shorter file; import the new file first with fablecut_import_media) · {op:'setProject', set:{name|width|height|fps|background|markers|disabledTracks|lockedTracks|untargetedTracks|encodeProfile|delivery|outputs|master}} (markers = the full list [{t, label?, color?}], color: gold|red|orange|green|cyan|blue|purple|pink; master = {gain}, the master fader in dB; delivery = a delivery preset id or spec — see fablecut_export — that exports use by default; outputs = the batch list, [preset id | spec, …] up to 16, exported together by fablecut_export {outputs:true}) · {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}) · {op:'setMask', id | ids, mask?: index | 'name', set:{shape?:'rect'|'ellipse'|'bezier'|'freehand', mode?:'add'|'subtract'|'intersect'|'difference', invert?, opacity?, feather? (px), expand? (px, − shrinks), x?, y? (centre, 0…1 of the clip's picture), w?, h?, scale?, rotation?, points?:[[dx, dy, inX?, inY?, outX?, outY?], …] (bezier, around x, y), stroke?:[[u, v], …] (freehand: fitted into a bezier), keys?:[{t, ease?, x?, y?, w?, h?, scale?, rotation?, feather?, expand?, opacity?, points?}], name?, on?}, replace?:true, remove?:true} (clip masks on video / image / svg / text / adjust clips: the clip shows only where its masks combine to, bottom-up; mask left out, an unknown name or index = length adds one — a new mask needs shape; set merges key by key; masks follow the clip's transform; check with fablecut_scopes mask:{clip}) · {op:'removeMask', id, mask} · {op:'setMaskKeys', id, mask, keys:[…]|null} (animate a mask; t = seconds from the clip's start) · {op:'setMatte', id | ids, matte:'alpha'|'alpha-inverted'|'luma'|'luma-inverted'|null, track?:'V3'|null} (track matte: the picture on the video track directly above — or track — cuts the clip by its alpha or its brightness, inverted or not; that track stops drawing while it is a matte; null clears). · {op:'removeTrack', id, track: name | index} (delete a motion track stored by fablecut_track; what follows it keeps its keys). MOTION GRAPHICS: a shape layer is addClip {kind:'shape', track, start, duration, props:{shapes:[…]}} (no mediaId); a null is addClip {kind:'null', …} — invisible, a parent for other clips. {op:'setShape', id | ids, shape?: index | 'name' | '2.0' (path into a group), set:{type?:'rect'|'ellipse'|'polygon'|'star'|'line'|'path'|'group', name?, on?, x?, y? (px from the layer's origin), rotation?, scale?, opacity?, w?, h?, radius? (rect corners), sides?, inner? (star 0.05…1), points?:[[x, y, inX?, inY?, outX?, outY?], …] (line: 2, path: 2…128, px around x, y), closed? (path), fill?, stroke? ('#rrggbb' | '#rrggbbaa' | 'none'), strokeWidth?, lineCap?, lineJoin?, dash?:[on, off…], trimStart?, trimEnd? (0…100 — a draw-on), trimOffset?, items?:[…] (group), keys?:[{t, ease?, …any of x y rotation scale opacity w h radius inner strokeWidth trimStart trimEnd trimOffset fill stroke points}]}, replace?:true, remove?:true} (shape left out, an unknown name or index = length adds one — a new shape needs type; set merges key by key) · {op:'removeShape', id, shape} · {op:'setParent', id | ids, parent: clip id | null, keep?: true, at?} (the clip moves, turns and scales with its parent; keep:true — the default — keeps it where it is at time at, default its start; opacity and effects are not inherited). COMPOSITIONS (nested sequences): project.comps = [{id, name, width, height, duration, background?, tracks?, clips:[…]}]; a clip {kind:'comp', compId, start, in, duration} places one (draws like a picture of the comp's size — transform, masks, grade, blend, blur all apply — and its media plays and exports). {op:'addComp', comp:{id?, name, duration, width?, height?, background?, clips?:[…]}} · {op:'updateComp', id, set:{name?, width?, height?, duration?, background?, tracks?}} · {op:'removeComp', id} (refused while used) · {op:'precompose', ids:[…], name?, id?} (the clips move into a new comp; a comp clip takes their place) · {op:'inComp', comp, ops:[…]} (run clip and timeline ops on the comp's own clips — addClip, updateClip, setShape, split, …; nests). PRESETS AND TEMPLATES: {op:'applyPreset', id | ids, preset, length?, at?} keys a named animation onto visual clips, relative to where they sit (in: fade-in, pop-in, slide-up-in, slide-down-in, slide-left-in, slide-right-in, drop-in, zoom-in, spin-in, whip-in, rise-blur, draw-on · out: fade-out, pop-out, slide-up-out, slide-down-out, slide-left-out, slide-right-out, zoom-out, spin-out, whip-out, sink-blur, draw-off · at (an emphasis at timeline time at): pulse, shake, wobble, bounce, flash · loop (the whole clip): float, breathe, sway, spin, wiggle — or a saved one, or an inline {anchor:'in'|'out'|'at'|'whole', length, keys:{channel:[{t, v, ease?}]}} where scale / opacity multiply and the rest add). {op:'addTemplate', template, start?, track?, duration?, fields?:{key: value}, id?, compId?, name?} places a template as its own comp + a comp clip (lower-third-bar, lower-third-underline, lower-third-boxes [name, title, accent…] · logo-ring, logo-slam, logo-lines [logo = a media id, brand / tagline, accent] · social-story 1080x1920, social-quote 1080x1080, social-end-card [text fields, accent, background]); {op:'setTemplate', id: its clip or comp, fields?, duration?} changes the fields or the length (the exit keeps its timing). {op:'savePreset', id, name, anchor?} / {op:'saveTemplate', comp, name?, fields?} save to library/presets and library/templates for every project.MOTION PATHS: x and y keyed at the same time are a position key; curve handles pin / pout (px) on those x and y keys bend the path between keys ({op:'setMotionPath', id | ids, path:'smooth'|'straight', autoOrient?:bool} sets them for you; props.autoOrient turns the clip along its path). props.motionBlur = shutter angle 0…720 (180 = film look), props.motionBlurSamples 2…32. TEXT ON A PATH: {op:'setTextPath', id | ids, path:'line'|'arc'|'circle'|'wave'|{type:'path'|'line'|'ellipse'|'rect'|'polygon'|'star', …shape item geometry, keys?}|null, offset? (% along, keyable as pathOffset), align?:'start'|'center'|'end'} — the title runs along the path (more lines on parallel curves below it), every textAnim plays along it. Every visual clip also has props.anchorX / anchorY (px from its picture's centre: the point x / y place and rotation / scale turn around) and shape clips props.trimStart / trimEnd / trimOffset (trim every path in the layer) — all keyframable. props.blend: normal · multiply · screen · overlay · darken · lighten · color-dodge · color-burn · hard-light · soft-light · difference · exclusion · hue · saturation · color · luminosity · lighter. 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.",
|
|
150
161
|
inputSchema: {
|
|
151
162
|
type: "object",
|
|
152
163
|
properties: {
|
|
@@ -175,6 +186,26 @@ const TOOLS = [
|
|
|
175
186
|
required: ["project"],
|
|
176
187
|
},
|
|
177
188
|
},
|
|
189
|
+
{
|
|
190
|
+
name: "fablecut_frame",
|
|
191
|
+
description: "LOOK at the footage — returns an actual image, not just numbers. REQUIRES A VISION-CAPABLE MODEL: if you cannot read images, pass `text:true` instead (or skip this tool and use fablecut_analyze_reference). Three modes. (1) One frame of a source clip: pass `path` (an absolute file path, or a '/media/…' src — read where it lies, never copied) and `t` in seconds. (2) A contact sheet: pass `frames` (2–60) for one grid image with that many moments spread across the clip (`from`/`to` to survey part of it) — the cheap way to see what a whole clip holds. (3) With no `path`, `time` renders the COMPOSED TIMELINE at that moment through the editor's own compositor, graded and multi-track, exactly as export would. Use it before choosing a clip's `in` point, or to pick footage matching a blueprint's shots[] energy — seeing beats guessing. Needs ffmpeg on PATH (timeline mode runs in the open editor tab, else a headless browser).",
|
|
192
|
+
inputSchema: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {
|
|
195
|
+
path: { type: "string", description: "Source clip to look at: an absolute file path or a '/media/…' src. Read in place — nothing is copied into media/. Omit to render the timeline instead." },
|
|
196
|
+
t: { type: "number", description: "Seconds into the source clip (default 0). Ignored when `frames` is passed." },
|
|
197
|
+
time: { type: "number", description: "TIMELINE seconds to render, when `path` is omitted (default: the editor's playhead)" },
|
|
198
|
+
frames: { type: "number", description: "Contact-sheet mode: number of moments to sample across the clip, 2–60 (default 12)" },
|
|
199
|
+
cols: { type: "number", description: "Contact-sheet columns (omit for the squarest layout — 6 frames give 3×2, 12 give 4×3)" },
|
|
200
|
+
from: { type: "number", description: "Contact sheet: start of the range to survey (default 0)" },
|
|
201
|
+
to: { type: "number", description: "Contact sheet: end of the range (default: the clip's duration)" },
|
|
202
|
+
width: { type: "number", description: "Pixel width of the returned image (default 768 for one frame, 320 per cell for a sheet)" },
|
|
203
|
+
text: { type: "boolean", description: "Return TEXT measurements instead of the image, for a client or model that cannot see one: brightness and luma levels, dominant named colours with their share, texture (detail within the frame, 0–100), and for a contact sheet a per-cell line with change-since-the-previous-cell. Measurements, NOT a caption — they say what the picture is made of, never what is in it. Use when you have no vision." },
|
|
204
|
+
where: { type: "string", enum: ["auto", "tab", "headless"], description: "Timeline mode only: auto (default): an open editor tab, else headless · tab · headless" },
|
|
205
|
+
timeout: { type: "number", description: "Timeline mode: seconds to wait for the render (default 60)" },
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
178
209
|
{
|
|
179
210
|
name: "fablecut_analyze_reference",
|
|
180
211
|
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.",
|
|
@@ -199,12 +230,13 @@ const TOOLS = [
|
|
|
199
230
|
},
|
|
200
231
|
{
|
|
201
232
|
name: "fablecut_encode_profiles",
|
|
202
|
-
description: "List
|
|
233
|
+
description: "List what fablecut_export can make. DELIVERY PRESETS (structured settings, the Export dialog's Preset list): youtube, youtube-shorts, instagram-reels, tiktok, x, linkedin, master (project size), broadcast (ProRes 422 HQ), web (720p), archive (lossless FFV1), custom — plus presets the user saved — each a spec of resolution, fps, codec (h264, hevc, av1, vp9, prores, dnxhr, mpeg2 XDCAM, ffv1), rate control (crf / vbr / cbr), keyframe interval, pixel format, color, container, audio, a file-name template and metadata. {preset:'youtube'} returns one preset's full spec; the encoders this ffmpeg can use are listed (hardware ones only when they work on this machine). ENCODING PROFILES (encoding-profiles.json): raw ffmpeg argument lists plus jpegQuality, extension and color, for anything the presets do not cover; edit the file on disk (hot-reloaded); {profile:'hq'} returns one, detail:true includes every args array. Both are dry-run against ffmpeg when an export starts, so bad settings are refused before rendering.",
|
|
203
234
|
inputSchema: {
|
|
204
235
|
type: "object",
|
|
205
236
|
properties: {
|
|
206
|
-
detail: { type: "boolean", description: "Include the full ffmpeg args array per profile (default: a truncated summary)" },
|
|
207
|
-
profile: { type: "string", description: "Return one profile by id instead of the full list" },
|
|
237
|
+
detail: { type: "boolean", description: "Include the full ffmpeg args array per encoding profile (default: a truncated summary)" },
|
|
238
|
+
profile: { type: "string", description: "Return one encoding profile by id instead of the full list" },
|
|
239
|
+
preset: { type: "string", description: "Return one delivery preset's full spec (youtube, broadcast, a saved preset id …)" },
|
|
208
240
|
},
|
|
209
241
|
},
|
|
210
242
|
},
|
|
@@ -252,6 +284,21 @@ const TOOLS = [
|
|
|
252
284
|
required: ["clipIds"],
|
|
253
285
|
},
|
|
254
286
|
},
|
|
287
|
+
{
|
|
288
|
+
name: "fablecut_proxy",
|
|
289
|
+
description: "Proxies: light H.264 copies (540p by default, 720p or 1080p; never larger than the source) of heavy footage — 4K, HEVC, ProRes, long phone clips — that the editor plays while the user edits, so playback and scrubbing stay smooth. Export, fablecut_frame, fablecut_scopes and every measurement always read the original, so a proxy never changes the result. Without create or delete it reports each video's proxy (ready, being made with its progress, failed, none). create:true makes them (one at a time, in the background on the server) and waits until they are done unless wait:false; delete:true removes them. mediaIds defaults to every video in the project. The user turns proxy playback on or off with the monitor's Proxy button. Needs ffmpeg on PATH.",
|
|
290
|
+
inputSchema: {
|
|
291
|
+
type: "object",
|
|
292
|
+
properties: {
|
|
293
|
+
mediaIds: { type: "array", items: { type: "string" }, description: "Video media ids (default: all video media)" },
|
|
294
|
+
create: { type: "boolean", description: "Make proxies for these files" },
|
|
295
|
+
delete: { type: "boolean", description: "Delete their proxies" },
|
|
296
|
+
height: { type: "number", enum: [540, 720, 1080], description: "Proxy height in pixels (default 540)" },
|
|
297
|
+
wait: { type: "boolean", description: "With create: wait until every proxy is done (default true)" },
|
|
298
|
+
timeout: { type: "number", description: "Seconds to wait with create (default 900)" },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
},
|
|
255
302
|
{
|
|
256
303
|
name: "fablecut_export",
|
|
257
304
|
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.",
|
|
@@ -259,7 +306,10 @@ const TOOLS = [
|
|
|
259
306
|
type: "object",
|
|
260
307
|
properties: {
|
|
261
308
|
range: { type: "string", enum: ["entire", "in-out"], description: "Whole timeline, or the project's inPoint→outPoint (default: in-out when either is set)" },
|
|
262
|
-
|
|
309
|
+
preset: { type: "string", description: "Delivery preset: youtube, youtube-shorts, instagram-reels, tiktok, x, linkedin, master, broadcast, web, archive, custom, or a saved preset's id (fablecut_encode_profiles lists them). The output is reframed to the preset's size — cropped from the middle (or from the project's exportFrame), or padded with reframe:'fit'" },
|
|
310
|
+
delivery: { type: "object", description: "Delivery settings over the preset (or over custom): {width, height (null = the picture's size), fps (null = the project's), reframe:'fill'|'fit'|'stretch', container:'mp4'|'mov'|'mkv'|'webm'|'mxf', video:{codec:'h264'|'hevc'|'av1'|'vp9'|'prores'|'dnxhr'|'mpeg2'|'ffv1', encoder?, rate:'crf'|'vbr'|'cbr', crf, bitrate (kb/s), maxrate (kb/s), keyint (s), pixFmt, speed:'fast'|'balanced'|'best', profile (prores: proxy|lt|standard|hq|4444|4444xq · dnxhr: lb|sq|hq|hqx|444)}, audio:{codec:'aac'|'opus'|'mp3'|'pcm16'|'pcm24'|'flac'|'none', bitrate (kb/s), sampleRate, channels, loudness (LUFS target: -14 streaming/social, -16 podcast, -23 EBU R128, -24 ATSC; null = as mixed), truePeak (dBTP ceiling, default -1)}, color:'bt709'|'bt709-full'|'bt601-625'|'bt601-525', filename:'{project}_{preset}_{res}' (tokens {project} {preset} {res} {w} {h} {fps} {codec} {date} {time} {range} {n}), metadata:{title, artist, description, copyright, date, keywords}}. Anything left out comes from the preset; impossible combinations are corrected and the reply says how. Every finished file is checked against what was asked (size, fps, frames, codecs, length, loudness, true peak, file size) and the reply lists the checks" },
|
|
311
|
+
outputs: { description: "Several files, rendered once per frame rate (up to 8 outputs per render): true = the project's checked batch outputs (project.outputs, set with setProject {outputs:[…]}), or a list like [{preset:'master'}, {preset:'youtube'}, {preset:'instagram-reels'}, {preset:'custom', width:1080, height:1080, filename:'{project}_square_{res}'}, {preset:'web', filename:'{project}_preview_{res}'}]. Outputs with the same fps share one render; the reply lists every file", anyOf: [{ type: "boolean" }, { type: "array", items: { anyOf: [{ type: "string" }, { type: "object" }] } }] },
|
|
312
|
+
profile: { type: "string", description: "Encoding profile id (raw ffmpeg args, fablecut_encode_profiles) instead of a preset. With neither: project.delivery, else project.encodeProfile, else the server's default profile" },
|
|
263
313
|
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" },
|
|
264
314
|
wait: { type: "boolean", description: "Block until the file is written (default true)" },
|
|
265
315
|
timeout: { type: "number", description: "Seconds to wait before returning the job's status instead (default 900); the export keeps running" },
|
|
@@ -434,114 +484,6 @@ function normalizeGroups(proj, ids) {
|
|
|
434
484
|
return [...groups.values()];
|
|
435
485
|
}
|
|
436
486
|
|
|
437
|
-
/* ── Timeline edits (patch ops split … crossfade) ──
|
|
438
|
-
The editor's own edit code (edit-ops.js) run against the document, so an
|
|
439
|
-
agent gets the same targeting (untargetedTracks, or the op's `tracks`), the
|
|
440
|
-
same sync lock (linked partners ride along) and the same lock rules as the
|
|
441
|
-
keyboard shortcuts. Returns a short note; throws to abort the patch. */
|
|
442
|
-
const relinkedDocs = new WeakSet();
|
|
443
|
-
function timelineEdit(proj, op, refuseLocked, lockReason) {
|
|
444
|
-
const name = op.op;
|
|
445
|
-
const E = EditOps.forDoc(proj, { tracks: op.tracks, force: op.force === true, uid: () => uid() });
|
|
446
|
-
// Links are rebuilt from timing on every editor load; do the same once per
|
|
447
|
-
// patch so clips an agent added (without linkGroup) ride along with their stems.
|
|
448
|
-
if (!relinkedDocs.has(proj)) { E.relinkClips(); relinkedDocs.add(proj); }
|
|
449
|
-
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
450
|
-
const signed = (n) => (n > 0 ? "+" : "") + r3(n);
|
|
451
|
-
const num = (v, what) => {
|
|
452
|
-
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${name}: ${what} must be a number (seconds)`);
|
|
453
|
-
return v;
|
|
454
|
-
};
|
|
455
|
-
const clipOf = (id) => {
|
|
456
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
457
|
-
if (!c) throw new Error(`${name}: no clip ${id}`);
|
|
458
|
-
return c;
|
|
459
|
-
};
|
|
460
|
-
const named = () => {
|
|
461
|
-
if (!Array.isArray(op.ids) || !op.ids.length) throw new Error(`${name}: ids must list at least one clip`);
|
|
462
|
-
const clips = op.ids.map(String).map(clipOf);
|
|
463
|
-
for (const c of clips) refuseLocked(name, lockReason(c), op);
|
|
464
|
-
return clips;
|
|
465
|
-
};
|
|
466
|
-
const refused = (why) => new Error(`${name}: ${why}` +
|
|
467
|
-
(/locked/i.test(why) ? " — the user locked it; pass force:true only if they asked" : ""));
|
|
468
|
-
const trimmed = (c, r, extra = "") => {
|
|
469
|
-
if (typeof r === "string") throw refused(r);
|
|
470
|
-
const asked = num(op.delta, "delta");
|
|
471
|
-
return `${name} ${c.id}${extra} ${signed(r)}s` + (Math.abs(r - asked) > 1e-6 ? ` (asked ${signed(asked)}, clamped)` : "");
|
|
472
|
-
};
|
|
473
|
-
switch (name) {
|
|
474
|
-
case "split": {
|
|
475
|
-
const at = num(op.at, "at");
|
|
476
|
-
const r = E.splitAt(at, op.ids ? named() : null);
|
|
477
|
-
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"}`);
|
|
478
|
-
return `split@${at} +${r.rights.map((x) => x.id).join("+")}` + (r.blocked ? " (locked clips left whole)" : "");
|
|
479
|
-
}
|
|
480
|
-
case "rippleDelete": {
|
|
481
|
-
const r = E.rippleDelete(named());
|
|
482
|
-
return `-${r.removed.map((x) => x.id).join("-")} (rippled)`;
|
|
483
|
-
}
|
|
484
|
-
case "closeGap": {
|
|
485
|
-
const G = E.closeGapAt(num(op.at, "at"));
|
|
486
|
-
if (typeof G === "string") throw refused(G.replace("at playhead", `at ${op.at}s`).replace("move the playhead", "pick another time"));
|
|
487
|
-
return `closed ${r3(G)}s gap @${op.at}`;
|
|
488
|
-
}
|
|
489
|
-
case "lift": case "extract": {
|
|
490
|
-
const usePoints = op.from == null && op.to == null;
|
|
491
|
-
const t0 = op.from == null ? proj.inPoint : num(op.from, "from");
|
|
492
|
-
const t1 = op.to == null ? proj.outPoint : num(op.to, "to");
|
|
493
|
-
if (t0 == null || t1 == null) throw refused("give from and to (seconds), or set the project's inPoint and outPoint");
|
|
494
|
-
const why = E.liftRange(t0, t1, name === "extract");
|
|
495
|
-
if (why) throw refused(why.replace("Set IN and OUT first (I / O) to", "from→to must span more than a frame to"));
|
|
496
|
-
if (usePoints) { delete proj.inPoint; delete proj.outPoint; } // as in the editor
|
|
497
|
-
return `${name} ${r3(t0)}→${r3(t1)}s`;
|
|
498
|
-
}
|
|
499
|
-
case "rippleTrim": case "roll": {
|
|
500
|
-
const c = clipOf(String(op.id));
|
|
501
|
-
if (op.side !== "in" && op.side !== "out") throw new Error(`${name}: side must be "in" (head) or "out" (tail)`);
|
|
502
|
-
const fn = name === "roll" ? E.rollEdit : E.rippleTrim;
|
|
503
|
-
return trimmed(c, fn(c, op.side, num(op.delta, "delta")), "." + op.side);
|
|
504
|
-
}
|
|
505
|
-
case "slip": case "slide": {
|
|
506
|
-
const c = clipOf(String(op.id));
|
|
507
|
-
const fn = name === "slip" ? E.slipClip : E.slideClip;
|
|
508
|
-
return trimmed(c, fn(c, num(op.delta, "delta")));
|
|
509
|
-
}
|
|
510
|
-
case "insert": case "overwrite": {
|
|
511
|
-
const m = proj.media.find((x) => x.id === op.mediaId);
|
|
512
|
-
if (!m) throw new Error(`${name}: unknown mediaId ${op.mediaId}`);
|
|
513
|
-
const timed = m.kind === "video" || m.kind === "audio";
|
|
514
|
-
const inn = op.in == null ? 0 : num(op.in, "in");
|
|
515
|
-
if (inn < 0) throw new Error(`${name}: in must be ≥ 0`);
|
|
516
|
-
const dur = op.duration != null ? num(op.duration, "duration")
|
|
517
|
-
: timed && m.duration > 0 ? m.duration - inn : null;
|
|
518
|
-
if (dur == null) throw new Error(`${name}: duration is required for ${timed ? "media with no known duration yet" : m.kind}`);
|
|
519
|
-
if (!(dur >= EditOps.MIN_DUR)) throw new Error(`${name}: duration must be at least ${EditOps.MIN_DUR}s`);
|
|
520
|
-
if (timed && m.duration > 0 && inn + dur > m.duration + 1e-3)
|
|
521
|
-
throw new Error(`${name}: in + duration (${r3(inn + dur)}s) runs past the end of ${m.id} (${m.duration}s)`);
|
|
522
|
-
let stems = 2;
|
|
523
|
-
if (m.kind === "video") {
|
|
524
|
-
try { const f = mediaFile(m.src); if (f) stems = probeChannels(f) || 0; } catch { /* no ffprobe: assume stereo */ }
|
|
525
|
-
}
|
|
526
|
-
const r = (name === "insert" ? E.insertAt : E.overwriteAt)(m, timed ? inn : 0, dur, num(op.at, "at"), stems);
|
|
527
|
-
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:[…])"));
|
|
528
|
-
const placed = r.clip ? (r.clip.linkGroup ? proj.clips.filter((x) => x.linkGroup === r.clip.linkGroup) : [r.clip]) : [];
|
|
529
|
-
return `${name}@${r3(r.at)} ` + placed.map((x) => `+${x.id}(${x.track})`).join("") + (name === "insert" ? ` · later clips moved +${r3(dur)}s` : "");
|
|
530
|
-
}
|
|
531
|
-
case "crossfade": {
|
|
532
|
-
const dur = op.duration == null ? EditOps.CROSSFADE_DUR : num(op.duration, "duration");
|
|
533
|
-
if (dur < EditOps.MIN_TRANS_DUR) throw new Error(`${name}: duration must be at least ${EditOps.MIN_TRANS_DUR}s`);
|
|
534
|
-
const pairs = op.ids ? E.crossfadeCuts(E.withLinked(named())) : E.crossfadeCutsNear(num(op.at, "at"));
|
|
535
|
-
if (!pairs.length) throw refused(op.ids ? "no audio cut next to those clips — they must touch or overlap on one track"
|
|
536
|
-
: `no audio cut within 0.5s of ${op.at}s on the targeted audio tracks`);
|
|
537
|
-
const { done, why } = E.crossfadePairs(pairs, dur);
|
|
538
|
-
if (!done) throw refused(why.join(", "));
|
|
539
|
-
return `crossfaded ${done} cut${done === 1 ? "" : "s"}` + (why.length ? ` (skipped: ${why.join(", ")})` : "");
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
throw new Error("Unknown edit op " + name);
|
|
543
|
-
}
|
|
544
|
-
|
|
545
487
|
/* ── Noise reduction (fablecut_denoise) — denoise.js, as the editor's Noise control ── */
|
|
546
488
|
async function denoiseTool(args) {
|
|
547
489
|
const ids = Array.isArray(args.clipIds) ? args.clipIds.map(String) : [];
|
|
@@ -602,20 +544,71 @@ async function denoiseTool(args) {
|
|
|
602
544
|
}
|
|
603
545
|
|
|
604
546
|
/* ── Export (fablecut_export) ── */
|
|
547
|
+
/** Saved delivery presets (library/delivery/), as the server lists them. */
|
|
548
|
+
function savedDeliveryPresets() { return Delivery.savedPresets(readSaved("delivery")); }
|
|
605
549
|
function describeExportJob(j) {
|
|
606
550
|
const pct = Math.round((j.progress || 0) * 100);
|
|
607
551
|
if (j.status === "done") {
|
|
608
552
|
const file = path.join(EXPORTS_DIR, path.basename(decodeURIComponent(j.src || "")));
|
|
609
553
|
let size = "";
|
|
610
554
|
try { size = ` (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)`; } catch {}
|
|
611
|
-
|
|
555
|
+
const outs = j.result && Array.isArray(j.result.outputs) ? j.result.outputs : null;
|
|
556
|
+
if (outs && outs.length > 1) {
|
|
557
|
+
return `Export ${j.id} done — ${outs.filter((o) => o.ok).length} of ${outs.length} files:\n` + outs.map((o) => o.ok
|
|
558
|
+
? `✓ ${path.join(EXPORTS_DIR, o.file)}${o.summary ? ` — ${o.summary}` : ""}${o.report ? `\n ${o.report.ok ? "checks passed" : "CHECK"}: ${o.report.line}` : ""}`
|
|
559
|
+
: `✗ ${o.file}: ${o.error}`).join("\n");
|
|
560
|
+
}
|
|
561
|
+
const rep = outs && outs[0] && outs[0].report;
|
|
562
|
+
return `Export ${j.id} done → ${file}${size}${j.result && j.result.summary ? `\n${j.result.summary}` : ""}` +
|
|
563
|
+
`${rep ? `\n${rep.ok ? "Checks passed" : "CHECK"}: ${rep.line}` : ""}\nServed at ${BASE}${j.src}`;
|
|
612
564
|
}
|
|
613
565
|
if (j.status === "failed" || j.status === "cancelled") return `Export ${j.id} ${j.status}: ${j.error || "no reason given"}`;
|
|
614
566
|
return `Export ${j.id} ${j.status}${j.status === "running" ? ` — ${pct}%` : ""} (in ${j.via === "headless" ? "a headless browser" : "the open editor tab"}). ` +
|
|
615
567
|
`Check again with fablecut_export {job:"${j.id}"}, or stop it with {cancel:"${j.id}"}.`;
|
|
616
568
|
}
|
|
569
|
+
/* fablecut_proxy: the server makes proxies (proxy.js); this asks it, and waits. */
|
|
570
|
+
async function proxyTool(args) {
|
|
571
|
+
if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
|
|
572
|
+
const proj = readProject();
|
|
573
|
+
const ids = Array.isArray(args.mediaIds) && args.mediaIds.length ? args.mediaIds : proj.media.filter((m) => m.kind === "video").map((m) => m.id);
|
|
574
|
+
const list = ids.map((id) => {
|
|
575
|
+
const m = proj.media.find((x) => x.id === id);
|
|
576
|
+
if (!m) throw new Error(`no media ${id}`);
|
|
577
|
+
if (m.kind !== "video") throw new Error(`${id} is ${m.kind === "audio" || m.kind === "image" ? "an" : "a"} ${m.kind} — only video gets a proxy`);
|
|
578
|
+
return m;
|
|
579
|
+
});
|
|
580
|
+
if (!list.length) return "No video in the project.";
|
|
581
|
+
const srcs = list.map((m) => m.src);
|
|
582
|
+
if (args.delete) {
|
|
583
|
+
const r = await apiJSON("POST", "/api/proxy/delete", { srcs });
|
|
584
|
+
if (r.status !== 200) throw new Error(r.body?.error || "delete failed");
|
|
585
|
+
return list.map((m) => `${m.id} "${m.name}": ${r.body.deleted[m.src] ? "proxy deleted" : "had no proxy"}`).join("\n");
|
|
586
|
+
}
|
|
587
|
+
let r = await apiJSON("POST", args.create ? "/api/proxy" : "/api/proxy/status", { srcs, height: args.height });
|
|
588
|
+
if (r.status !== 200) throw new Error(r.body?.error || `proxy request failed (${r.status})`);
|
|
589
|
+
let status = r.body.status;
|
|
590
|
+
const busy = () => Object.values(status).some((s) => s && (s.state === "queued" || s.state === "running"));
|
|
591
|
+
if (args.create && args.wait !== false) {
|
|
592
|
+
const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 900);
|
|
593
|
+
while (busy() && Date.now() < deadline) {
|
|
594
|
+
await sleep(1000);
|
|
595
|
+
r = await apiJSON("POST", "/api/proxy/status", { srcs });
|
|
596
|
+
if (r.status === 200) status = r.body.status;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const line = (m) => {
|
|
600
|
+
const s = status[m.src];
|
|
601
|
+
if (!s) return `${m.id} "${m.name}": OFFLINE — the file is missing`;
|
|
602
|
+
if (s.state === "ready") return `${m.id} "${m.name}": proxy ready — ${s.width ? s.width + "×" + s.height : s.height + "p"}, ${(s.size / 1048576).toFixed(1)} MB (${s.src})`;
|
|
603
|
+
if (s.state === "running" || s.state === "queued") return `${m.id} "${m.name}": proxy ${s.state}${s.state === "running" ? ` ${Math.round(s.progress * 100)}%` : ""} (${s.height}p)`;
|
|
604
|
+
if (s.state === "failed") return `${m.id} "${m.name}": proxy FAILED — ${s.error}`;
|
|
605
|
+
return `${m.id} "${m.name}": no proxy${m.height ? ` (source ${m.width}×${m.height})` : ""}`;
|
|
606
|
+
};
|
|
607
|
+
return list.map(line).join("\n") + (busy() ? "\nStill working — call fablecut_proxy again (without create) to check." : "");
|
|
608
|
+
}
|
|
617
609
|
async function exportTool(args) {
|
|
618
610
|
if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
|
|
611
|
+
let startNote = "";
|
|
619
612
|
if (args.cancel) {
|
|
620
613
|
const r = await apiJSON("POST", "/api/export/job/cancel?id=" + encodeURIComponent(args.cancel));
|
|
621
614
|
if (r.status !== 200) throw new Error(r.body?.error || "cancel failed");
|
|
@@ -627,10 +620,11 @@ async function exportTool(args) {
|
|
|
627
620
|
if (r.status !== 200) throw new Error(r.body?.error || "no such export job");
|
|
628
621
|
job = r.body;
|
|
629
622
|
} else {
|
|
630
|
-
const r = await apiJSON("POST", "/api/export/request", { range: args.range, profile: args.profile, where: args.where || "auto" });
|
|
623
|
+
const r = await apiJSON("POST", "/api/export/request", { range: args.range, profile: args.profile, preset: args.preset, delivery: args.delivery, outputs: args.outputs, where: args.where || "auto" });
|
|
631
624
|
if (r.status !== 200) throw new Error(r.body?.error || `export request failed (${r.status})`);
|
|
632
625
|
job = r.body;
|
|
633
|
-
if (
|
|
626
|
+
if (job.notes && job.notes.length) startNote = `Settings adjusted: ${job.notes.join("; ")}\n`;
|
|
627
|
+
if (args.wait === false) return (job.delivery ? `Output: ${job.delivery}\n` : "") + startNote + describeExportJob(job);
|
|
634
628
|
}
|
|
635
629
|
const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 900);
|
|
636
630
|
while (job.status === "pending" || job.status === "running") {
|
|
@@ -641,32 +635,148 @@ async function exportTool(args) {
|
|
|
641
635
|
if (r.status !== 200) throw new Error(r.body?.error || "lost track of the export job");
|
|
642
636
|
job = r.body;
|
|
643
637
|
}
|
|
644
|
-
return describeExportJob(job);
|
|
638
|
+
return startNote + describeExportJob(job);
|
|
645
639
|
}
|
|
646
640
|
|
|
647
|
-
/* ──
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
641
|
+
/* ── Frames (fablecut_frame) ──
|
|
642
|
+
The tool is the only one that answers with an image, so it returns content
|
|
643
|
+
blocks rather than a string. Text comes FIRST: it carries the coordinates of
|
|
644
|
+
what is in the picture (src, timestamp, dimensions), which is the part an
|
|
645
|
+
agent would otherwise have to guess at, and it keeps the existing
|
|
646
|
+
content[0].text reading in the test harness valid. */
|
|
647
|
+
|
|
648
|
+
/** {mimeType, data:Buffer} → an MCP image content block. */
|
|
649
|
+
function imageBlock(mimeType, buf) {
|
|
650
|
+
return { type: "image", data: buf.toString("base64"), mimeType };
|
|
651
|
+
}
|
|
652
|
+
/** A data: URL from the compositor → an image content block, or null. */
|
|
653
|
+
function imageBlockFromDataUrl(url) {
|
|
654
|
+
const m = /^data:(image\/\w+);base64,(.+)$/.exec(String(url || ""));
|
|
655
|
+
return m ? { type: "image", data: m[2], mimeType: m[1] } : null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Raw GET to the editor server → {status, headers, buffer}.
|
|
659
|
+
* Sends the MCP client header: /api/frame?path= only answers requests that
|
|
660
|
+
* carry it, so a web page in the user's browser can neither render frames of
|
|
661
|
+
* arbitrary files nor probe which ones exist. */
|
|
662
|
+
function apiBytes(urlPath) {
|
|
663
|
+
return new Promise((resolve, reject) => {
|
|
664
|
+
const req = http.request(API + urlPath, { method: "GET", headers: { "X-FableCut-Client": "mcp" } }, (r) => {
|
|
665
|
+
const chunks = [];
|
|
666
|
+
r.on("data", (c) => chunks.push(c));
|
|
667
|
+
r.on("end", () => resolve({ status: r.statusCode, headers: r.headers, body: Buffer.concat(chunks) }));
|
|
668
|
+
});
|
|
669
|
+
req.on("error", reject);
|
|
670
|
+
req.setTimeout(60_000, () => req.destroy(new Error("the editor server did not answer")));
|
|
671
|
+
req.end();
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function frameTool(args) {
|
|
676
|
+
/* Timeline mode: no path. The compositor lives in the browser, so this rides
|
|
677
|
+
the same job queue the scopes reading uses, with image:true. Text mode
|
|
678
|
+
needs no image at all here — the scopes reading already measures the frame,
|
|
679
|
+
and those numbers describe the composed picture. */
|
|
680
|
+
if (!args.path) {
|
|
681
|
+
if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
|
|
682
|
+
const body = { where: args.where || "auto", image: true };
|
|
683
|
+
if (args.time != null) body.time = args.time;
|
|
684
|
+
const r = await apiJSON("POST", "/api/scopes/request", body);
|
|
685
|
+
if (r.status !== 200) throw new Error(r.body?.error || `frame request failed (${r.status})`);
|
|
686
|
+
let job = r.body;
|
|
687
|
+
const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 60);
|
|
688
|
+
while (job.status === "pending" || job.status === "running") {
|
|
689
|
+
if (Date.now() > deadline) {
|
|
690
|
+
await apiJSON("POST", "/api/export/job/cancel?id=" + encodeURIComponent(job.id));
|
|
691
|
+
throw new Error("timed out waiting for the editor to render the frame");
|
|
692
|
+
}
|
|
693
|
+
await sleep(300);
|
|
694
|
+
const q = await apiJSON("GET", "/api/export/job?id=" + encodeURIComponent(job.id));
|
|
695
|
+
if (q.status !== 200) throw new Error(q.body?.error || "lost track of the frame render");
|
|
696
|
+
job = q.body;
|
|
697
|
+
}
|
|
698
|
+
if (job.status !== "done") throw new Error(`frame render ${job.status}: ${job.error || "no reason given"}`);
|
|
699
|
+
const res = job.result || {};
|
|
700
|
+
const onScreen = (res.clips || []).map((c) => `${c.id} ${c.track} ${c.kind}`).join(", ") || "nothing";
|
|
701
|
+
const head = `Timeline at ${res.time}s (${res.frame?.w}x${res.frame?.h}) — on screen: ${onScreen}`;
|
|
702
|
+
|
|
703
|
+
/* Text mode: the scopes reading measured this exact frame already, and its
|
|
704
|
+
numbers are the same family as the source-frame measurements — so a
|
|
705
|
+
text-only agent gets the composed picture described, not nothing. */
|
|
706
|
+
if (args.text) {
|
|
707
|
+
const st = res.stats, st_ = st ? describeScopeLines(st) : null;
|
|
708
|
+
return [{ type: "text", text: head + "\n" + (st_ || "The editor returned no measurements for this frame.") + "\n\n" + TEXT_ONLY_NOTE }];
|
|
709
|
+
}
|
|
710
|
+
const img = imageBlockFromDataUrl(res.image);
|
|
711
|
+
if (!img) throw new Error("the editor rendered the frame but could not encode it as an image (a cross-origin source taints the canvas)");
|
|
712
|
+
return [{ type: "text", text: head }, img];
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/* Source mode: ffmpeg on the server. Looking must not have side effects, so
|
|
716
|
+
the file is read where it is — an absolute path is passed through as
|
|
717
|
+
`path`, and a /media/… or /library/… src is passed through as `src`. The
|
|
718
|
+
server never copies anything into media/ on our behalf. */
|
|
719
|
+
if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
|
|
720
|
+
const raw = String(args.path);
|
|
721
|
+
const served = /^\/(media|library)\//i.test(raw);
|
|
722
|
+
// A served src is resolved by the server against its own data dir, which may
|
|
723
|
+
// be somewhere this process cannot see (FABLECUT_DATA_DIR) — so let it decide
|
|
724
|
+
// and report the 404. A filesystem path we can check ourselves is checked.
|
|
725
|
+
if (!served && (!fs.existsSync(raw) || !fs.statSync(raw).isFile()))
|
|
726
|
+
throw new Error("File not found: " + raw);
|
|
727
|
+
|
|
728
|
+
const q = new URLSearchParams(served ? { src: raw } : { path: path.resolve(raw) });
|
|
729
|
+
if (args.frames != null) {
|
|
730
|
+
q.set("frames", String(Math.round(Number(args.frames))));
|
|
731
|
+
// No cols → the server picks the squarest layout (6 frames give 3×2).
|
|
732
|
+
if (args.cols != null) q.set("cols", String(Math.round(Number(args.cols))));
|
|
733
|
+
if (args.from != null) q.set("from", String(args.from));
|
|
734
|
+
if (args.to != null) q.set("to", String(args.to));
|
|
735
|
+
q.set("w", String(args.width || 320));
|
|
736
|
+
} else {
|
|
737
|
+
q.set("t", String(Number.isFinite(args.t) ? args.t : 0));
|
|
738
|
+
q.set("w", String(args.width || 768));
|
|
739
|
+
}
|
|
740
|
+
if (args.quality != null) q.set("q", String(args.quality));
|
|
741
|
+
|
|
742
|
+
/* text mode asks the server for measurements instead of the picture, in the
|
|
743
|
+
one request — not the picture and then a second call for the numbers. */
|
|
744
|
+
if (args.text) {
|
|
745
|
+
q.set("text", "1");
|
|
746
|
+
const j = await apiJSON("GET", "/api/frame?" + q.toString(), undefined, { "X-FableCut-Client": "mcp" });
|
|
747
|
+
if (j.status !== 200) throw new Error(j.body?.error || `frame description failed (${j.status})`);
|
|
748
|
+
const st = j.body.stats, b = j.body;
|
|
749
|
+
const where = b.frames > 1
|
|
750
|
+
? `${b.frames} moments across ${b.source || raw}, left→right then top→bottom, at ${(b.times || []).join("s, ")}s.`
|
|
751
|
+
: `Frame at ${(b.times || [])[0] ?? 0}s of ${b.source || raw}.`;
|
|
752
|
+
const body = !st
|
|
753
|
+
? "The server returned no measurements for this frame (does it have a video track?)."
|
|
754
|
+
: b.frames > 1 ? FrameStats.gridLines(st).join("\n") : FrameStats.frameLines("Frame", st).join("\n");
|
|
755
|
+
return [{ type: "text", text: where + "\n" + body + "\n\n" + TEXT_ONLY_NOTE }];
|
|
665
756
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
757
|
+
|
|
758
|
+
const res = await apiBytes("/api/frame?" + q.toString());
|
|
759
|
+
if (res.status !== 200) {
|
|
760
|
+
let msg = `frame request failed (${res.status})`;
|
|
761
|
+
try { msg = JSON.parse(res.body.toString("utf8")).error || msg; } catch { /* not JSON */ }
|
|
762
|
+
throw new Error(msg);
|
|
763
|
+
}
|
|
764
|
+
const times = (res.headers["x-fablecut-frame-times"] || "").split(",").filter(Boolean);
|
|
765
|
+
const type = res.headers["content-type"] || "image/jpeg";
|
|
766
|
+
const source = decodeURI(res.headers["x-fablecut-frame-source"] || "") || raw;
|
|
767
|
+
const head = args.frames != null
|
|
768
|
+
? `Contact sheet — ${times.length} moments across ${source}, left→right then top→bottom, at ${times.join("s, ")}s.`
|
|
769
|
+
: `Frame at ${times[0] ?? 0}s of ${source}.`;
|
|
770
|
+
|
|
771
|
+
if (res.body.length < 4 || res.body[0] !== 0xff || res.body[1] !== 0xd8)
|
|
772
|
+
throw new Error("the server did not return a JPEG");
|
|
773
|
+
return [{ type: "text", text: head }, imageBlock(type, res.body)];
|
|
669
774
|
}
|
|
775
|
+
|
|
776
|
+
/* Appended to every text: true answer, because a measurement is not a caption:
|
|
777
|
+
it says what the picture is made of, never what is in it. */
|
|
778
|
+
const { TEXT_ONLY_NOTE, describeScopeLines, describeScopes } = ProjectOps;
|
|
779
|
+
|
|
670
780
|
async function scopesTool(args) {
|
|
671
781
|
if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
|
|
672
782
|
const body = { where: args.where || "auto" };
|
|
@@ -717,15 +827,7 @@ async function trackTool(args) {
|
|
|
717
827
|
job = q.body;
|
|
718
828
|
}
|
|
719
829
|
if (job.status !== "done") throw new Error(`tracking ${job.status}: ${job.error || "no reason given"}`);
|
|
720
|
-
|
|
721
|
-
return [
|
|
722
|
-
t.tracked ? `Tracked ${t.clip} "${t.track}" (${t.kind}) ${t.from}–${t.to} s: ${t.samples} samples, match quality min ${t.quality.min} · mean ${t.quality.mean}`
|
|
723
|
-
: `Used the stored track "${t.track}" on ${t.clip} (${t.from}–${t.to} s, ${t.samples} samples)`,
|
|
724
|
-
t.lostAt && t.lostAt.length ? `Lost the region at ${t.lostAt.join(", ")} s — the track stops there (pick a spot with more detail, or a larger region, and track on from there)` : "",
|
|
725
|
-
t.cancelled ? "Cancelled — kept what was tracked so far." : "",
|
|
726
|
-
t.applied ? `Applied → ${t.applied}` : "Stored only (apply it with apply:{mask} or apply:{clip}).",
|
|
727
|
-
`Project revision ${t.revision}.`,
|
|
728
|
-
].filter(Boolean).join("\n");
|
|
830
|
+
return ProjectOps.describeTrack(job.result || {});
|
|
729
831
|
}
|
|
730
832
|
|
|
731
833
|
/* ── Tool implementations ── */
|
|
@@ -737,8 +839,12 @@ async function callTool(name, args, attempt = 0) {
|
|
|
737
839
|
return exportTool(args);
|
|
738
840
|
case "fablecut_scopes":
|
|
739
841
|
return scopesTool(args);
|
|
842
|
+
case "fablecut_frame":
|
|
843
|
+
return frameTool(args);
|
|
740
844
|
case "fablecut_denoise":
|
|
741
845
|
return denoiseTool(args);
|
|
846
|
+
case "fablecut_proxy":
|
|
847
|
+
return proxyTool(args);
|
|
742
848
|
case "fablecut_status": {
|
|
743
849
|
const up = await ensureUIServer();
|
|
744
850
|
const proj = readProject();
|
|
@@ -769,11 +875,26 @@ async function callTool(name, args, attempt = 0) {
|
|
|
769
875
|
encLine = `Export profile: ${describe(pinned || cfg.default) || `server default "${cfg.default}"`}`;
|
|
770
876
|
}
|
|
771
877
|
} catch { encLine = "Export profile: (encoding-profiles.json unavailable)"; }
|
|
878
|
+
if (proj.delivery) {
|
|
879
|
+
try {
|
|
880
|
+
const { spec } = Delivery.normalize(proj.delivery, { presets: savedDeliveryPresets() });
|
|
881
|
+
encLine = `Export: project.delivery — ${spec.preset} · ${Delivery.describe(spec)} (fablecut_export uses it unless you pass profile or preset)`;
|
|
882
|
+
} catch { }
|
|
883
|
+
}
|
|
884
|
+
if (Array.isArray(proj.outputs) && proj.outputs.length) {
|
|
885
|
+
const on = proj.outputs.filter((o) => o.on !== false);
|
|
886
|
+
encLine += `\nBatch outputs (fablecut_export {outputs:true}): ${on.length} of ${proj.outputs.length} checked — ` +
|
|
887
|
+
on.map((o) => Delivery.projectFilename(o, proj)).join(", ");
|
|
888
|
+
}
|
|
772
889
|
return [
|
|
773
890
|
`Editor server: ${up ? "RUNNING — open " + BASE + " in a browser to watch edits live" : "FAILED TO START (check node / port " + PORT + ")"}`,
|
|
774
891
|
`Project: "${proj.name}" — ${proj.width}x${proj.height} @ ${proj.fps}fps, ${proj.clips.length} clip(s), ${dur.toFixed(2)}s, revision ${proj.revision}`,
|
|
775
892
|
encLine,
|
|
776
893
|
`Registered media: ${cap(proj.media.map((m) => `${m.id} (${m.kind}, ${m.name}${m.duration ? ", " + m.duration + "s" : ""})`), 25).join("; ") || "none"}`,
|
|
894
|
+
...(() => {
|
|
895
|
+
const off = proj.media.filter((m) => /^\/(media|library)\//.test(m.src || "") && !mediaFile(m.src));
|
|
896
|
+
return off.length ? [`OFFLINE media (file missing — find it and relinkMedia): ${cap(off.map((m) => `${m.id} ${m.src}`), 25).join("; ")}`] : [];
|
|
897
|
+
})(),
|
|
777
898
|
`Files in media/: ${cap(files, 25).join(", ") || "none"}`,
|
|
778
899
|
`Library assets (./library): ${libSummary}`,
|
|
779
900
|
`Project file: ${PROJECT_FILE}`,
|
|
@@ -794,106 +915,10 @@ async function callTool(name, args, attempt = 0) {
|
|
|
794
915
|
const doc = readProject();
|
|
795
916
|
lastReadRevision = doc.revision || 0;
|
|
796
917
|
if (!args.compact) return JSON.stringify(doc);
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
gain: 0, channelMode: "stereo", duck: 0,
|
|
802
|
-
blend: "normal", fit: "contain", cropL: 0, cropR: 0, cropT: 0, cropB: 0,
|
|
803
|
-
cornerRadius: 0, flipH: false, flipV: false, filterPreset: "none",
|
|
804
|
-
brightness: 100, contrast: 100, saturation: 100, hue: 0, temperature: 0,
|
|
805
|
-
tint: 0, blur: 0, grayscale: 0, sepia: 0, invert: 0, vignette: 0,
|
|
806
|
-
shake: 0, shakeSpeed: 8, rgbSplit: 0, grain: 0,
|
|
807
|
-
chromaKey: "", chromaTolerance: 26, chromaSoftness: 12, bgRemove: false,
|
|
808
|
-
text: "Title", fontSize: 72, color: "#ffffff", color2: "", font: "Segoe UI",
|
|
809
|
-
bold: true, weight: 0, italic: false, uppercase: false, align: "center",
|
|
810
|
-
letterSpacing: 0, lineHeight: 1.2, textShadow: 12, glow: 0, glowColor: "",
|
|
811
|
-
strokeWidth: 0, strokeColor: "#000", bgColor: "#000", bgOpacity: 0,
|
|
812
|
-
textAnim: "none", wordRate: 0.15, direction: "auto", boxW: 0, boxH: 0, boxFit: false, vAlign: "middle",
|
|
813
|
-
anchorX: 0, anchorY: 0, trimStart: 0, trimEnd: 100, trimOffset: 0,
|
|
814
|
-
autoOrient: false, motionBlur: 0, motionBlurSamples: 8, pathOffset: 0, pathAlign: "start",
|
|
815
|
-
};
|
|
816
|
-
const hex = (v) => typeof v === "string" && /^#[0-9a-f]{3}$/i.test(v)
|
|
817
|
-
? "#" + [...v.slice(1)].map((c) => c + c).join("").toLowerCase()
|
|
818
|
-
: (typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : v);
|
|
819
|
-
const fmtProps = (o, kind) => {
|
|
820
|
-
if (!o) return "";
|
|
821
|
-
const kept = {};
|
|
822
|
-
for (const [k, v] of Object.entries(o)) {
|
|
823
|
-
if (kind !== "text" && k === "text" && v === "Title") continue;
|
|
824
|
-
// Always keep pan on linked stems — pan:0 (center) is meaningful and
|
|
825
|
-
// must not be stripped, or agents rebuilding from compact can lose it.
|
|
826
|
-
if (k === "pan" && Number.isInteger(o.audioChannel) && o.audioChannel >= 0) {
|
|
827
|
-
kept[k] = v;
|
|
828
|
-
continue;
|
|
829
|
-
}
|
|
830
|
-
if (k === "masks" || k === "tracks" || k === "shapes" || k === "textPath") continue; // summarized below — point lists and samples are long
|
|
831
|
-
if (hex(DEFAULTS[k]) !== hex(v)) kept[k] = v;
|
|
832
|
-
}
|
|
833
|
-
const masks = Mask.describe(Mask.normalizeMasks(o.masks)), tracks = Tracker.describe(Tracker.normalizeTracks(o.tracks));
|
|
834
|
-
const shapes = Motion.describe(Motion.normalizeShapes(o.shapes));
|
|
835
|
-
const tp = o.textPath && Motion.normalizeTextPath(o.textPath);
|
|
836
|
-
if (tp) kept.textPath = tp.type + (tp.points ? `(${tp.points.length})` : tp.w ? ` ${tp.w}×${tp.h}` : "");
|
|
837
|
-
return (Object.keys(kept).length ? " " + JSON.stringify(kept) : "") + (shapes ? ` [${shapes}]` : "") + (masks ? ` [${masks}]` : "") + (tracks ? ` [tracks: ${tracks}]` : "");
|
|
838
|
-
};
|
|
839
|
-
// Mixer: only off-default faders / pans, so a fresh project adds no line.
|
|
840
|
-
const signed = (v) => (v > 0 ? "+" : "") + v;
|
|
841
|
-
const fxTag = (fx) => Array.isArray(fx) && fx.length ? ` fx:${FX.summarizeFx(fx)}` : "";
|
|
842
|
-
const mixLine = (d) => {
|
|
843
|
-
const parts = (Array.isArray(d.tracks) ? d.tracks : [])
|
|
844
|
-
.filter((t) => t && (+t.gain || +t.pan || t.fx?.length || t.out))
|
|
845
|
-
.map((t) => t.id + (t.out ? `→${t.out}` : "") + (+t.gain ? ` ${signed(+t.gain)}dB` : "") + (+t.pan ? ` pan:${+t.pan}` : "") + fxTag(t.fx));
|
|
846
|
-
for (const b of Array.isArray(d.buses) ? d.buses : [])
|
|
847
|
-
parts.push(`bus ${b.id}${b.name ? ` "${b.name}"` : ""}` + (+b.gain ? ` ${signed(+b.gain)}dB` : "") + (+b.pan ? ` pan:${+b.pan}` : "") +
|
|
848
|
-
(b.mute ? " muted" : "") + fxTag(b.fx));
|
|
849
|
-
if (d.master && (+d.master.gain || d.master.fx?.length))
|
|
850
|
-
parts.push("master" + (+d.master.gain ? ` ${signed(+d.master.gain)}dB` : "") + fxTag(d.master.fx));
|
|
851
|
-
return parts.length ? [`MIX: ${parts.join(" · ")}`] : [];
|
|
852
|
-
};
|
|
853
|
-
const lines = [
|
|
854
|
-
`"${doc.name}" ${doc.width}x${doc.height}@${doc.fps} rev:${doc.revision}` +
|
|
855
|
-
(doc.panSchema >= 1 ? " panSchema:1" : "") +
|
|
856
|
-
(doc.background ? ` bg:${doc.background}` : "") +
|
|
857
|
-
(doc.inPoint != null || doc.outPoint != null ? ` in/out:${doc.inPoint ?? "-"}→${doc.outPoint ?? "-"}` : "") +
|
|
858
|
-
(doc.markers?.length ? ` markers:${doc.markers.length} [${doc.markers.slice(0, 12).map((m) => m.t).join(",")}${doc.markers.length > 12 ? ",…" : ""}]` : "") +
|
|
859
|
-
(doc.lockedTracks?.length ? ` lockedTracks:[${doc.lockedTracks.join(",")}]` : "") +
|
|
860
|
-
(doc.untargetedTracks?.length ? ` untargetedTracks:[${doc.untargetedTracks.join(",")}]` : "") +
|
|
861
|
-
(doc.disabledTracks?.length ? ` disabledTracks:[${doc.disabledTracks.join(",")}]` : ""),
|
|
862
|
-
...mixLine(doc),
|
|
863
|
-
`MEDIA (${doc.media.length}):`,
|
|
864
|
-
...doc.media.map((m) => ` ${m.id} ${m.kind} "${m.name}"${m.duration ? " " + m.duration + "s" : ""}` +
|
|
865
|
-
(m.derivedFrom ? ` (${m.denoise ? "denoised " + m.denoise : "derived"} from ${m.derivedFrom})` : "")),
|
|
866
|
-
`CLIPS (${doc.clips.length}), by track/time:`,
|
|
867
|
-
...doc.clips
|
|
868
|
-
.slice()
|
|
869
|
-
.sort((a, b) => (a.track === b.track ? a.start - b.start : String(a.track).localeCompare(b.track)))
|
|
870
|
-
.map((c) => {
|
|
871
|
-
const kf = c.keyframes ? " kf:" + Object.entries(c.keyframes).map(([k, v]) => `${k}(${v.length})`).join(",") +
|
|
872
|
-
(Motion.hasPathHandles(c) ? " path:curved" : "") : "";
|
|
873
|
-
const tr = (c.transitionIn ? ` in:${c.transitionIn.type}/${c.transitionIn.duration}` : "") +
|
|
874
|
-
(c.transitionOut ? ` out:${c.transitionOut.type}/${c.transitionOut.duration}` : "");
|
|
875
|
-
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
876
|
-
return ` ${c.id} ${c.track} ${r3(c.start)}s+${r3(c.duration)}s ${c.kind}` +
|
|
877
|
-
(c.mediaId ? `(${c.mediaId}${c.in ? ` in:${r3(c.in)}` : ""})` : "") +
|
|
878
|
-
(c.kind === "comp" ? `(${c.compId}${c.in ? ` in:${r3(c.in)}` : ""})` : "") +
|
|
879
|
-
(c.name ? ` "${c.name}"` : "") + (c.parent ? ` parent:${c.parent}` : "") + fmtProps(c.props, c.kind) + kf + tr +
|
|
880
|
-
(c.locked === true ? " [locked]" : "") + (c.disabled === true ? " [disabled]" : "") +
|
|
881
|
-
(c.unlinked === true ? " [unlinked]" : "") + fxTag(c.fx);
|
|
882
|
-
}),
|
|
883
|
-
...(Array.isArray(doc.comps) && doc.comps.length ? [`COMPS (${doc.comps.length}) — edit their clips with {op:"inComp", comp, ops:[…]}:`,
|
|
884
|
-
...doc.comps.flatMap((comp) => [` ${CompLib.describe(comp)}`,
|
|
885
|
-
...(comp.template ? [` template ${comp.template.id} — fields (setTemplate): ` + (comp.template.fields || []).map((f) => {
|
|
886
|
-
const v = Templates.fieldValue(comp, f);
|
|
887
|
-
return `${f.key}=${typeof v === "string" ? JSON.stringify(v.length > 40 ? v.slice(0, 40) + "…" : v) : v}`;
|
|
888
|
-
}).join(" ")] : []),
|
|
889
|
-
...(comp.clips || []).slice().sort((a, b) => a.start - b.start).map((c) => {
|
|
890
|
-
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
891
|
-
return ` ${c.id} ${c.track} ${r3(c.start)}s+${r3(c.duration)}s ${c.kind}` + (c.mediaId ? `(${c.mediaId})` : "") + (c.kind === "comp" ? `(${c.compId})` : "") +
|
|
892
|
-
(c.name ? ` "${c.name}"` : "") + (c.parent ? ` parent:${c.parent}` : "") + fmtProps(c.props, c.kind);
|
|
893
|
-
})])] : []),
|
|
894
|
-
`(compact view — full JSON: fablecut_get_project without compact; edit via fablecut_patch_project)`,
|
|
895
|
-
];
|
|
896
|
-
return lines.join("\n");
|
|
918
|
+
return ProjectOps.compactView(doc, {
|
|
919
|
+
offline: (m) => /^\/(media|library)\//.test(m.src || "") && !mediaFile(m.src),
|
|
920
|
+
proxy: (m) => { const f = mediaFile(m.src); const p = f && findProxies(PROXIES_DIR, f)[0]; return p ? p.height : null; },
|
|
921
|
+
});
|
|
897
922
|
}
|
|
898
923
|
case "fablecut_patch_project": {
|
|
899
924
|
if (!Array.isArray(args.ops) || !args.ops.length) throw new Error("`ops` must be a non-empty array");
|
|
@@ -910,788 +935,13 @@ async function callTool(name, args, attempt = 0) {
|
|
|
910
935
|
`Re-read the clips you touch (fablecut_get_project {compact:true}), then retry with baseRevision:${rev} — ` +
|
|
911
936
|
`or omit baseRevision to apply on top of the latest anyway.`);
|
|
912
937
|
if (base != null && readRev !== base) throw conflict(readRev);
|
|
913
|
-
const notes =
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
const group = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup) : [c];
|
|
921
|
-
for (const x of group) {
|
|
922
|
-
const who = x.id === c.id ? `clip ${c.id}` : `clip ${c.id} is linked to ${x.id}, which`;
|
|
923
|
-
if (x.locked === true) return `${who} is locked`;
|
|
924
|
-
if (lockedTracks().has(x.track)) return `${who} is on locked track ${x.track}`;
|
|
925
|
-
}
|
|
926
|
-
return null;
|
|
927
|
-
};
|
|
928
|
-
const refuseLocked = (opName, reason, op) => {
|
|
929
|
-
if (reason && op.force !== true)
|
|
930
|
-
throw new Error(`${opName}: ${reason} — the user locked it. Leave it alone, or pass force:true if they asked you to change it`);
|
|
931
|
-
};
|
|
932
|
-
/* The effect chain an op names: a clip (and its linked stems), an
|
|
933
|
-
A-track, a submix bus or the master → {label, read(), write(list)}. */
|
|
934
|
-
const fxTarget = (op, name) => {
|
|
935
|
-
const target = op.target || (op.id && /^A\d+$/.test(op.id) ? "track" : op.id && /^B\d+$/.test(op.id) ? "bus" : op.id ? "clip" : "master");
|
|
936
|
-
if (target === "clip") {
|
|
937
|
-
const c = proj.clips.find((x) => x.id === op.id);
|
|
938
|
-
if (!c) throw new Error(`${name}: no clip ${op.id}`);
|
|
939
|
-
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)`);
|
|
940
|
-
refuseLocked(name, lockReason(c), op);
|
|
941
|
-
const group = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup && x.kind === "audio") : [c];
|
|
942
|
-
return { label: group.map((x) => x.id).join("+"), read: () => c.fx, write(next) {
|
|
943
|
-
for (const x of group) { if (next.length) x.fx = next.map((e) => JSON.parse(JSON.stringify(e))); else delete x.fx; }
|
|
944
|
-
} };
|
|
945
|
-
}
|
|
946
|
-
if (target === "track") {
|
|
947
|
-
if (!/^A\d+$/.test(String(op.id || ""))) throw new Error(`${name}: track id must be an audio track (A1, A2, …)`);
|
|
948
|
-
if (!Array.isArray(proj.tracks) || !proj.tracks.length) proj.tracks = DEFAULT_TRACKS.map((t) => ({ ...t }));
|
|
949
|
-
let t = proj.tracks.find((x) => x.id === op.id);
|
|
950
|
-
if (!t) { t = { id: op.id, kind: "audio" }; proj.tracks.push(t); }
|
|
951
|
-
return { label: op.id, read: () => t.fx, write(next) { if (next.length) t.fx = next; else delete t.fx; } };
|
|
952
|
-
}
|
|
953
|
-
if (target === "bus") {
|
|
954
|
-
const b = (proj.buses || []).find((x) => x.id === op.id);
|
|
955
|
-
if (!b) throw new Error(`${name}: no bus ${JSON.stringify(op.id)} — create it with {op:"setBus", id:"B1"}`);
|
|
956
|
-
return { label: op.id, read: () => b.fx, write(next) { if (next.length) b.fx = next; else delete b.fx; } };
|
|
957
|
-
}
|
|
958
|
-
if (target === "master") {
|
|
959
|
-
return { label: "master", read: () => proj.master?.fx, write(next) {
|
|
960
|
-
const m = proj.master && typeof proj.master === "object" ? proj.master : {};
|
|
961
|
-
if (next.length) m.fx = next; else delete m.fx;
|
|
962
|
-
if (Object.keys(m).length) proj.master = m; else delete proj.master;
|
|
963
|
-
} };
|
|
964
|
-
}
|
|
965
|
-
throw new Error(`${name}: target must be clip, track, bus or master`);
|
|
966
|
-
};
|
|
967
|
-
const mergeInto = (target, set) => {
|
|
968
|
-
for (const [k, v] of Object.entries(set || {})) {
|
|
969
|
-
if (v === null) delete target[k];
|
|
970
|
-
else if (k === "props" && target.props && typeof v === "object" && !Array.isArray(v)) {
|
|
971
|
-
for (const [pk, pv] of Object.entries(v)) {
|
|
972
|
-
if (pv === null) delete target.props[pk]; else target.props[pk] = pv;
|
|
973
|
-
}
|
|
974
|
-
} else target[k] = v;
|
|
975
|
-
}
|
|
976
|
-
};
|
|
977
|
-
// props.masks written wholesale (addClip / updateClip) is validated like setMask
|
|
978
|
-
const checkMasks = (c, label) => {
|
|
979
|
-
if (!c.props || c.props.masks == null) return;
|
|
980
|
-
if (c.kind === "audio") throw new Error(`${label}: masks go on video, image, svg, text or adjust clips`);
|
|
981
|
-
let n;
|
|
982
|
-
try { n = Mask.normalizeMasks(c.props.masks, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
983
|
-
if (n) c.props.masks = n; else delete c.props.masks;
|
|
984
|
-
};
|
|
985
|
-
// props.tracks written wholesale is validated too (the editor writes them; agents rarely should)
|
|
986
|
-
const checkTracks = (c, label) => {
|
|
987
|
-
if (!c.props || c.props.tracks == null) return;
|
|
988
|
-
if (c.kind !== "video") throw new Error(`${label}: tracks belong to video clips`);
|
|
989
|
-
let n;
|
|
990
|
-
try { n = Tracker.normalizeTracks(c.props.tracks, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
991
|
-
if (n) c.props.tracks = n; else delete c.props.tracks;
|
|
992
|
-
};
|
|
993
|
-
// props.shapes (shape layers) and clip.parent: validated like setShape / setParent
|
|
994
|
-
const NO_MEDIA = ["text", "adjust", "shape", "null", "comp"];
|
|
995
|
-
let inCompId = null; // set while an inComp op runs its ops inside a comp
|
|
996
|
-
// a comp clip names a comp that exists, and placing it here makes no loop
|
|
997
|
-
const checkCompClip = (c, label) => {
|
|
998
|
-
if (c.kind !== "comp") { if (c.compId != null) throw new Error(`${label}: compId is for kind:"comp" clips`); return; }
|
|
999
|
-
if (typeof c.compId !== "string" || !CompLib.getComp(proj, c.compId))
|
|
1000
|
-
throw new Error(`${label}: compId must name a comp in project.comps${(proj.comps || []).length ? ` (${proj.comps.map((x) => x.id).join(", ")})` : " — add one with addComp"}`);
|
|
1001
|
-
if (CompLib.wouldCycle(proj, inCompId, c.compId)) throw new Error(`${label}: comp ${c.compId} can't go inside ${inCompId} — that would contain itself`);
|
|
1002
|
-
c.props = c.props || {};
|
|
1003
|
-
if (c.props.fit == null) c.props.fit = "none"; // a comp shows at its own size unless told otherwise
|
|
1004
|
-
if (c.in == null) c.in = 0;
|
|
1005
|
-
};
|
|
1006
|
-
const checkShapes = (c, label) => {
|
|
1007
|
-
if (!c.props || c.props.shapes == null) return;
|
|
1008
|
-
if (c.kind !== "shape") throw new Error(`${label}: shapes go on shape clips (kind:"shape")`);
|
|
1009
|
-
let n;
|
|
1010
|
-
try { n = Motion.normalizeShapes(c.props.shapes, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
1011
|
-
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
1012
|
-
};
|
|
1013
|
-
const byId = (id) => proj.clips.find((x) => x.id === id) || null;
|
|
1014
|
-
// motion blur, auto-orient, text paths and motion-path handles (pin / pout on x / y keys)
|
|
1015
|
-
const checkMotion = (c, label) => {
|
|
1016
|
-
const p = c.props || {};
|
|
1017
|
-
if (p.motionBlur != null && !(typeof p.motionBlur === "number" && p.motionBlur >= 0 && p.motionBlur <= 720)) throw new Error(`${label}: props.motionBlur is a shutter angle 0…720 (0 = off)`);
|
|
1018
|
-
if (p.motionBlurSamples != null && !(Number.isInteger(p.motionBlurSamples) && p.motionBlurSamples >= 2 && p.motionBlurSamples <= 32)) throw new Error(`${label}: props.motionBlurSamples is a whole number 2…32`);
|
|
1019
|
-
if (p.autoOrient != null && typeof p.autoOrient !== "boolean") throw new Error(`${label}: props.autoOrient is true or false`);
|
|
1020
|
-
if (p.pathAlign != null && !["start", "center", "end"].includes(p.pathAlign)) throw new Error(`${label}: props.pathAlign is start | center | end`);
|
|
1021
|
-
if (p.textPath != null) {
|
|
1022
|
-
if (c.kind !== "text") throw new Error(`${label}: textPath goes on text clips`);
|
|
1023
|
-
try { p.textPath = Motion.normalizeTextPath(p.textPath, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
1024
|
-
}
|
|
1025
|
-
for (const ch of ["x", "y"]) for (const k of (c.keyframes && Array.isArray(c.keyframes[ch]) ? c.keyframes[ch] : []))
|
|
1026
|
-
for (const h of ["pin", "pout"]) if (k && k[h] != null && !(typeof k[h] === "number" && Number.isFinite(k[h])))
|
|
1027
|
-
throw new Error(`${label}: keyframes.${ch} ${h} must be a number (px, the motion path's curve handle)`);
|
|
1028
|
-
};
|
|
1029
|
-
const checkParent = (c, label) => {
|
|
1030
|
-
if (c.parent == null) { delete c.parent; return; }
|
|
1031
|
-
const par = typeof c.parent === "string" && byId(c.parent);
|
|
1032
|
-
if (!par) throw new Error(`${label}: parent ${JSON.stringify(c.parent)} is not a clip`);
|
|
1033
|
-
if (par.id === c.id || Motion.wouldCycle(c.id, par.id, byId)) throw new Error(`${label}: parenting ${c.id} to ${par.id} makes a loop`);
|
|
1034
|
-
if (par.kind === "audio" || par.kind === "adjust" || c.kind === "audio" || c.kind === "adjust")
|
|
1035
|
-
throw new Error(`${label}: parenting is for visual clips (video, image, svg, text, shape, null)`);
|
|
1036
|
-
};
|
|
1037
|
-
// blend / matte / matteTrack: checked when the op writes them
|
|
1038
|
-
const videoTrackIds = () => {
|
|
1039
|
-
const ids = (Array.isArray(proj.tracks) && proj.tracks.length ? proj.tracks : DEFAULT_TRACKS).filter((t) => t.kind === "video").map((t) => t.id);
|
|
1040
|
-
for (const x of proj.clips) if (/^V\d+$/.test(x.track) && !ids.includes(x.track)) ids.push(x.track);
|
|
1041
|
-
return ids;
|
|
1042
|
-
};
|
|
1043
|
-
const checkCompositing = (c, label, props) => {
|
|
1044
|
-
if (!props || !["blend", "matte", "matteTrack"].some((k) => props[k] != null)) return;
|
|
1045
|
-
if (c.kind === "audio") throw new Error(`${label}: blend and track mattes go on video, image, svg, text or adjust clips`);
|
|
1046
|
-
try { Mask.checkCompositing(c.props, videoTrackIds(), c.track, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
1047
|
-
if (!c.props.matte) delete c.props.matteTrack;
|
|
1048
|
-
};
|
|
1049
|
-
const applyOps = (opList) => { for (const op of opList) {
|
|
1050
|
-
switch (op.op) {
|
|
1051
|
-
case "addClip": {
|
|
1052
|
-
const c = op.clip;
|
|
1053
|
-
if (!c || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
|
|
1054
|
-
throw new Error("addClip needs clip{track, start, duration}");
|
|
1055
|
-
c.id = c.id || "c_" + uid();
|
|
1056
|
-
if (proj.clips.some((x) => x.id === c.id)) throw new Error("addClip: duplicate clip id " + c.id);
|
|
1057
|
-
refuseLocked("addClip", lockedTracks().has(c.track) ? `track ${c.track} is locked` : null, op);
|
|
1058
|
-
if (!NO_MEDIA.includes(c.kind) && !proj.media.some((m) => m.id === c.mediaId))
|
|
1059
|
-
throw new Error(`addClip: unknown mediaId ${c.mediaId}`);
|
|
1060
|
-
if (NO_MEDIA.includes(c.kind)) c.mediaId = null;
|
|
1061
|
-
checkMasks(c, "addClip");
|
|
1062
|
-
checkShapes(c, "addClip");
|
|
1063
|
-
checkParent(c, "addClip");
|
|
1064
|
-
checkMotion(c, "addClip");
|
|
1065
|
-
checkCompClip(c, "addClip");
|
|
1066
|
-
checkTracks(c, "addClip");
|
|
1067
|
-
checkCompositing(c, "addClip", c.props);
|
|
1068
|
-
proj.clips.push(c);
|
|
1069
|
-
notes.push("+" + c.id);
|
|
1070
|
-
break;
|
|
1071
|
-
}
|
|
1072
|
-
case "updateClip": {
|
|
1073
|
-
const c = proj.clips.find((x) => x.id === op.id);
|
|
1074
|
-
if (!c) throw new Error("updateClip: no clip " + op.id);
|
|
1075
|
-
// Toggling the lock itself is always allowed — that's how you unlock.
|
|
1076
|
-
const onlyLock = Object.keys(op.set || {}).every((k) => k === "locked");
|
|
1077
|
-
if (!onlyLock) {
|
|
1078
|
-
refuseLocked("updateClip", lockReason(c), op);
|
|
1079
|
-
if (op.set && op.set.track && lockedTracks().has(op.set.track))
|
|
1080
|
-
refuseLocked("updateClip", `track ${op.set.track} is locked`, op);
|
|
1081
|
-
}
|
|
1082
|
-
mergeInto(c, op.set);
|
|
1083
|
-
checkMasks(c, "updateClip");
|
|
1084
|
-
if (op.set && op.set.props && "tracks" in op.set.props) checkTracks(c, "updateClip");
|
|
1085
|
-
if (op.set && op.set.props && "shapes" in op.set.props) checkShapes(c, "updateClip");
|
|
1086
|
-
if (op.set && "parent" in op.set) checkParent(c, "updateClip");
|
|
1087
|
-
checkMotion(c, "updateClip");
|
|
1088
|
-
if (op.set && ("compId" in op.set || "kind" in op.set)) checkCompClip(c, "updateClip");
|
|
1089
|
-
// a track move re-checks the matte too (a clip can't be its own matte)
|
|
1090
|
-
checkCompositing(c, "updateClip", op.set && op.set.track ? c.props : op.set && op.set.props);
|
|
1091
|
-
if (c.props && c.props.matteTrack && !c.props.matte) delete c.props.matteTrack; // matte:null clears its track too
|
|
1092
|
-
notes.push("~" + op.id);
|
|
1093
|
-
break;
|
|
1094
|
-
}
|
|
1095
|
-
case "removeClip": {
|
|
1096
|
-
const doomed = proj.clips.find((x) => x.id === op.id);
|
|
1097
|
-
if (doomed) refuseLocked("removeClip", lockReason(doomed), op);
|
|
1098
|
-
const n = proj.clips.length;
|
|
1099
|
-
// its children stay where they are (at their own start), under its own parent if it has one
|
|
1100
|
-
const kids = doomed ? proj.clips.filter((x) => x.parent === op.id).map((x) => x.id) : [];
|
|
1101
|
-
if (doomed) EditOps.detachChildren(proj, new Set([op.id]));
|
|
1102
|
-
for (const id of kids) notes.push(`~${id}.parent(${byId(id).parent || "none"})`);
|
|
1103
|
-
proj.clips = proj.clips.filter((x) => x.id !== op.id);
|
|
1104
|
-
if (proj.clips.length === n) throw new Error("removeClip: no clip " + op.id);
|
|
1105
|
-
notes.push("-" + op.id);
|
|
1106
|
-
break;
|
|
1107
|
-
}
|
|
1108
|
-
case "addMedia": {
|
|
1109
|
-
const m = op.media;
|
|
1110
|
-
if (!m || !m.src || !m.kind) throw new Error("addMedia needs media{src, kind}");
|
|
1111
|
-
m.id = m.id || "m_" + uid();
|
|
1112
|
-
if (proj.media.some((x) => x.id === m.id)) throw new Error("addMedia: duplicate media id " + m.id);
|
|
1113
|
-
m.name = m.name || path.basename(decodeURIComponent(m.src));
|
|
1114
|
-
proj.media.push(m);
|
|
1115
|
-
notes.push("+" + m.id);
|
|
1116
|
-
break;
|
|
1117
|
-
}
|
|
1118
|
-
case "removeMedia": {
|
|
1119
|
-
const used = proj.clips.find((c) => c.mediaId === op.id);
|
|
1120
|
-
if (used) throw new Error(`removeMedia: media ${op.id} is used by clip ${used.id}`);
|
|
1121
|
-
for (const comp of proj.comps || []) {
|
|
1122
|
-
const inner = (comp.clips || []).find((c) => c.mediaId === op.id);
|
|
1123
|
-
if (inner) throw new Error(`removeMedia: media ${op.id} is used by clip ${inner.id} in comp ${comp.id}`);
|
|
1124
|
-
}
|
|
1125
|
-
const n = proj.media.length;
|
|
1126
|
-
proj.media = proj.media.filter((x) => x.id !== op.id);
|
|
1127
|
-
if (proj.media.length === n) throw new Error("removeMedia: no media " + op.id);
|
|
1128
|
-
notes.push("-" + op.id);
|
|
1129
|
-
break;
|
|
1130
|
-
}
|
|
1131
|
-
case "setFx": {
|
|
1132
|
-
// {op:"setFx", target:"clip"|"track"|"bus"|"master", id?, fx?:[…] | preset?:"podcast", append?:true}
|
|
1133
|
-
const t = fxTarget(op, "setFx");
|
|
1134
|
-
let chain;
|
|
1135
|
-
if (op.preset != null) chain = FX.presetChain(String(op.preset));
|
|
1136
|
-
else if (op.fx === null) chain = [];
|
|
1137
|
-
else chain = FX.normalizeFx(op.fx, true);
|
|
1138
|
-
t.write(op.append ? [...FX.normalizeFx(t.read()), ...chain] : chain);
|
|
1139
|
-
notes.push("~" + t.label + ".fx");
|
|
1140
|
-
break;
|
|
1141
|
-
}
|
|
1142
|
-
case "setFxKeys": {
|
|
1143
|
-
// {op:"setFxKeys", target, id?, index?|type?, param, keys:[{t, v, ease?}] | null}
|
|
1144
|
-
const t = fxTarget(op, "setFxKeys");
|
|
1145
|
-
const list = FX.normalizeFx(t.read()).map((e) => ({ ...e }));
|
|
1146
|
-
let i = Number.isInteger(op.index) ? op.index : op.type != null ? list.findIndex((e) => e.type === op.type) : list.length === 1 ? 0 : -1;
|
|
1147
|
-
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"}`);
|
|
1148
|
-
const e = list[i];
|
|
1149
|
-
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(", ")})`);
|
|
1150
|
-
const keys = { ...(e.keys || {}) };
|
|
1151
|
-
if (op.keys == null || (Array.isArray(op.keys) && !op.keys.length)) delete keys[op.param];
|
|
1152
|
-
else Object.assign(keys, FX.normalizeKeys(e.type, { [op.param]: op.keys }, true) || {});
|
|
1153
|
-
if (Object.keys(keys).length) e.keys = keys; else delete e.keys;
|
|
1154
|
-
t.write(list);
|
|
1155
|
-
notes.push(`~${t.label}.fx[${i}].${op.param}` + (keys[op.param] ? `(${keys[op.param].length} keys)` : "(keys cleared)"));
|
|
1156
|
-
break;
|
|
1157
|
-
}
|
|
1158
|
-
case "setGrade": {
|
|
1159
|
-
// {op:"setGrade", id | ids, grade:{…} | null, replace?:true}
|
|
1160
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1161
|
-
if (!ids.length) throw new Error("setGrade needs id or ids");
|
|
1162
|
-
if (op.grade !== null && (typeof op.grade !== "object" || Array.isArray(op.grade)))
|
|
1163
|
-
throw new Error("setGrade needs grade:{…} (or grade:null to clear)");
|
|
1164
|
-
if (op.grade) Color.normalizeGrade(Object.fromEntries(Object.entries(op.grade).filter(([, v]) => v !== null)), true); // validate first
|
|
1165
|
-
for (const id of ids) {
|
|
1166
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
1167
|
-
if (!c) throw new Error("setGrade: no clip " + id);
|
|
1168
|
-
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`);
|
|
1169
|
-
refuseLocked("setGrade", lockReason(c), op);
|
|
1170
|
-
const next = op.grade === null ? null : Color.mergeGrade(op.replace ? null : c.props?.grade, op.grade, true);
|
|
1171
|
-
c.props = c.props || {};
|
|
1172
|
-
if (next) c.props.grade = next; else delete c.props.grade;
|
|
1173
|
-
notes.push(`~${id}.grade(${Color.summarizeGrade(next)})`);
|
|
1174
|
-
}
|
|
1175
|
-
break;
|
|
1176
|
-
}
|
|
1177
|
-
case "setGradeLayer": {
|
|
1178
|
-
// {op:"setGradeLayer", id | ids, layer: index | "name", set?:{…}, replace?:true, remove?:true}
|
|
1179
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1180
|
-
if (!ids.length) throw new Error("setGradeLayer needs id or ids");
|
|
1181
|
-
const byName = typeof op.layer === "string" && op.layer.trim() !== "";
|
|
1182
|
-
if (!byName && !(Number.isInteger(op.layer) && op.layer >= 0)) throw new Error("setGradeLayer needs layer: an index (0 = first) or a layer name");
|
|
1183
|
-
if (!op.remove && (op.set == null || typeof op.set !== "object" || Array.isArray(op.set))) throw new Error("setGradeLayer needs set:{…} (or remove:true)");
|
|
1184
|
-
for (const id of ids) {
|
|
1185
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
1186
|
-
if (!c) throw new Error("setGradeLayer: no clip " + id);
|
|
1187
|
-
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`);
|
|
1188
|
-
refuseLocked("setGradeLayer", lockReason(c), op);
|
|
1189
|
-
const layers = (c.props?.grade?.layers || []).map((l) => ({ ...l }));
|
|
1190
|
-
let i = byName ? layers.findIndex((l) => l.name === op.layer.trim()) : op.layer;
|
|
1191
|
-
if (op.remove) {
|
|
1192
|
-
if (i < 0 || i >= layers.length) throw new Error(`setGradeLayer: ${id} has no layer ${JSON.stringify(op.layer)}`);
|
|
1193
|
-
layers.splice(i, 1);
|
|
1194
|
-
} else {
|
|
1195
|
-
if (i < 0 || i === layers.length) { // a new layer
|
|
1196
|
-
if (layers.length >= Color.MAX_LAYERS) throw new Error(`setGradeLayer: at most ${Color.MAX_LAYERS} layers`);
|
|
1197
|
-
i = layers.length; layers.push({ name: byName ? op.layer.trim() : `Layer ${i + 1}` });
|
|
1198
|
-
} else if (i > layers.length) throw new Error(`setGradeLayer: ${id} has ${layers.length} layer(s) — use layer:${layers.length} to add one`);
|
|
1199
|
-
const base = op.replace ? { name: layers[i].name } : layers[i];
|
|
1200
|
-
let next;
|
|
1201
|
-
try { next = Color.mergeLayer(base, op.set, true); } catch (err) { throw new Error(`setGradeLayer: ${err.message.replace(/^grade\.layers\[0\]\.?/, "")}`); }
|
|
1202
|
-
if (!next.name) next.name = layers[i].name || `Layer ${i + 1}`;
|
|
1203
|
-
layers[i] = next;
|
|
1204
|
-
}
|
|
1205
|
-
const g = Color.mergeGrade(c.props?.grade, { layers: layers.length ? layers : null }, true);
|
|
1206
|
-
c.props = c.props || {};
|
|
1207
|
-
if (g) c.props.grade = g; else delete c.props.grade;
|
|
1208
|
-
notes.push(`~${id}.grade(${Color.summarizeGrade(g)})`);
|
|
1209
|
-
}
|
|
1210
|
-
break;
|
|
1211
|
-
}
|
|
1212
|
-
case "setMask":
|
|
1213
|
-
case "removeMask":
|
|
1214
|
-
case "setMaskKeys": {
|
|
1215
|
-
// {op:"setMask", id | ids, mask?: index | "name", set:{…}, replace?:true, remove?:true}
|
|
1216
|
-
// {op:"removeMask", id | ids, mask} · {op:"setMaskKeys", id | ids, mask, keys:[…] | null}
|
|
1217
|
-
const name = op.op;
|
|
1218
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1219
|
-
if (!ids.length) throw new Error(`${name} needs id or ids`);
|
|
1220
|
-
const remove = name === "removeMask" || op.remove === true;
|
|
1221
|
-
const byName = typeof op.mask === "string" && op.mask.trim() !== "";
|
|
1222
|
-
if (op.mask != null && !byName && !(Number.isInteger(op.mask) && op.mask >= 0)) throw new Error(`${name}: mask is an index (0 = first) or a mask name`);
|
|
1223
|
-
if ((remove || name === "setMaskKeys") && op.mask == null) throw new Error(`${name} needs mask: an index or a name`);
|
|
1224
|
-
let set;
|
|
1225
|
-
if (name === "setMaskKeys") {
|
|
1226
|
-
if (op.keys !== null && !Array.isArray(op.keys)) throw new Error("setMaskKeys needs keys:[{t, x?, y?, …}] (or keys:null to stop animating)");
|
|
1227
|
-
set = { keys: op.keys && op.keys.length ? op.keys : null };
|
|
1228
|
-
} else if (!remove) {
|
|
1229
|
-
if (op.set == null || typeof op.set !== "object" || Array.isArray(op.set)) throw new Error("setMask needs set:{shape?, x?, y?, …} (or remove:true)");
|
|
1230
|
-
set = op.set;
|
|
1231
|
-
}
|
|
1232
|
-
for (const id of ids) {
|
|
1233
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
1234
|
-
if (!c) throw new Error(`${name}: no clip ${id}`);
|
|
1235
|
-
if (c.kind === "audio") throw new Error(`${name}: ${id} is an audio clip — masks go on video, image, svg, text or adjust clips`);
|
|
1236
|
-
refuseLocked(name, lockReason(c), op);
|
|
1237
|
-
const list = (Mask.normalizeMasks(c.props?.masks) || []).slice();
|
|
1238
|
-
let i = op.mask == null ? list.length : byName ? list.findIndex((m) => m.name === op.mask.trim()) : op.mask;
|
|
1239
|
-
const where = `${name}: ${id}`;
|
|
1240
|
-
if (remove) {
|
|
1241
|
-
if (i < 0 || i >= list.length) throw new Error(`${where} has no mask ${JSON.stringify(op.mask)}`);
|
|
1242
|
-
list.splice(i, 1);
|
|
1243
|
-
} else {
|
|
1244
|
-
const adding = i < 0 || i === list.length;
|
|
1245
|
-
if (adding && name === "setMaskKeys") throw new Error(`${where} has no mask ${JSON.stringify(op.mask)}`);
|
|
1246
|
-
if (!adding && i > list.length) throw new Error(`${where} has ${list.length} mask(s) — use mask:${list.length} (or leave mask out) to add one`);
|
|
1247
|
-
if (adding && list.length >= Mask.MAX_MASKS) throw new Error(`${where}: at most ${Mask.MAX_MASKS} masks`);
|
|
1248
|
-
const base = adding ? (byName ? { name: op.mask.trim() } : null) : op.replace ? { name: list[i].name } : list[i];
|
|
1249
|
-
if (adding && !set.shape && !(base && base.shape)) throw new Error(`${where}: a new mask needs set.shape (rect | ellipse | bezier | freehand)`);
|
|
1250
|
-
let next;
|
|
1251
|
-
try { next = Mask.mergeMask(base, set, true); }
|
|
1252
|
-
catch (err) { throw new Error(`${where}: ${err.message.replace(/^mask\.?/, "mask.")}`); }
|
|
1253
|
-
if (adding) { i = list.length; list.push(next); } else list[i] = next;
|
|
1254
|
-
}
|
|
1255
|
-
c.props = c.props || {};
|
|
1256
|
-
if (list.length) c.props.masks = list; else delete c.props.masks;
|
|
1257
|
-
notes.push(`~${id}.masks(${Mask.describe(list) || "none"})`);
|
|
1258
|
-
}
|
|
1259
|
-
break;
|
|
1260
|
-
}
|
|
1261
|
-
case "setShape":
|
|
1262
|
-
case "removeShape": {
|
|
1263
|
-
// {op:"setShape", id | ids, shape?: index | "name" | "2.0" (group path), set:{…}, replace?:true, remove?:true}
|
|
1264
|
-
const name = op.op;
|
|
1265
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1266
|
-
if (!ids.length) throw new Error(`${name} needs id or ids`);
|
|
1267
|
-
const remove = name === "removeShape" || op.remove === true;
|
|
1268
|
-
if (remove && op.shape == null) throw new Error(`${name} needs shape: an index, a name or a path like "2.0"`);
|
|
1269
|
-
if (!remove && (op.set == null || typeof op.set !== "object" || Array.isArray(op.set))) throw new Error("setShape needs set:{type?, x?, y?, w?, h?, fill?, …} (or remove:true)");
|
|
1270
|
-
const isPath = (v) => Array.isArray(v) || (typeof v === "string" && /^\d+(\.\d+)*$/.test(v.trim()));
|
|
1271
|
-
for (const id of ids) {
|
|
1272
|
-
const c = byId(id);
|
|
1273
|
-
if (!c) throw new Error(`${name}: no clip ${id}`);
|
|
1274
|
-
if (c.kind !== "shape") throw new Error(`${name}: ${id} is a ${c.kind} clip — shapes go on shape clips (addClip kind:"shape")`);
|
|
1275
|
-
refuseLocked(name, lockReason(c), op);
|
|
1276
|
-
const where = `${name}: ${id}`;
|
|
1277
|
-
let list = (Motion.normalizeShapes(c.props?.shapes) || []).slice();
|
|
1278
|
-
// the target as a path [i, j…]; its last index may be one past the end (= add there)
|
|
1279
|
-
let path, named = null;
|
|
1280
|
-
if (op.shape == null) path = [list.length];
|
|
1281
|
-
else if (Number.isInteger(op.shape) && op.shape >= 0) path = [op.shape];
|
|
1282
|
-
else if (isPath(op.shape)) path = (Array.isArray(op.shape) ? op.shape : op.shape.trim().split(".")).map(Number);
|
|
1283
|
-
else if (typeof op.shape === "string" && op.shape.trim()) {
|
|
1284
|
-
path = Motion.itemPaths(list).find((q) => Motion.getItem(list, q).name === op.shape.trim());
|
|
1285
|
-
if (!path) { if (remove) throw new Error(`${where} has no shape named ${JSON.stringify(op.shape)}`); path = [list.length]; named = op.shape.trim(); }
|
|
1286
|
-
} else throw new Error(`${name}: shape is an index (0 = first), a name or a path like "2.0"`);
|
|
1287
|
-
if (path.some((i) => !Number.isInteger(i) || i < 0)) throw new Error(`${name}: bad shape path ${JSON.stringify(op.shape)}`);
|
|
1288
|
-
const parentPath = path.slice(0, -1), last = path[path.length - 1];
|
|
1289
|
-
const group = parentPath.length ? Motion.getItem(list, parentPath) : null;
|
|
1290
|
-
if (parentPath.length && (!group || group.type !== "group")) throw new Error(`${where}: ${parentPath.join(".")} is not a group`);
|
|
1291
|
-
const siblings = group ? group.items : list;
|
|
1292
|
-
const it = siblings[last];
|
|
1293
|
-
if (remove) {
|
|
1294
|
-
if (!it) throw new Error(`${where} has no shape ${JSON.stringify(op.shape)}${list.length ? ` (it has ${Motion.describe(list)})` : ""}`);
|
|
1295
|
-
list = Motion.updateItem(list, path, () => null);
|
|
1296
|
-
} else if (!it) {
|
|
1297
|
-
if (last !== siblings.length) throw new Error(`${where}: ${parentPath.length ? `group ${parentPath.join(".")}` : "the layer"} has ${siblings.length} item(s) — use index ${siblings.length} (or leave shape out) to add one`);
|
|
1298
|
-
if (!op.set.type) throw new Error(`${where}: a new shape needs set.type (${Motion.TYPES.join(" | ")})`);
|
|
1299
|
-
let next;
|
|
1300
|
-
try { next = Motion.normalizeItem({ ...op.set, ...(named && !op.set.name ? { name: named } : {}) }, true, "shape"); }
|
|
1301
|
-
catch (err) { throw new Error(`${where}: ${err.message}`); }
|
|
1302
|
-
list = group ? Motion.updateItem(list, parentPath, (g) => ({ ...g, items: [...g.items, next] })) : [...list, next];
|
|
1303
|
-
} else {
|
|
1304
|
-
let next;
|
|
1305
|
-
const base = op.replace ? { type: it.type, name: it.name, ...(it.type === "group" ? { items: it.items } : {}) } : it;
|
|
1306
|
-
try { next = Motion.mergeItem(base, op.set, true); }
|
|
1307
|
-
catch (err) { throw new Error(`${where}: ${err.message}`); }
|
|
1308
|
-
list = Motion.updateItem(list, path, () => next);
|
|
1309
|
-
}
|
|
1310
|
-
let n;
|
|
1311
|
-
try { n = Motion.normalizeShapes(list, true); } catch (err) { throw new Error(`${where}: ${err.message}`); }
|
|
1312
|
-
c.props = c.props || {};
|
|
1313
|
-
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
1314
|
-
notes.push(`~${id}.shapes(${Motion.describe(n) || "none"})`);
|
|
1315
|
-
}
|
|
1316
|
-
break;
|
|
1317
|
-
}
|
|
1318
|
-
case "setParent": {
|
|
1319
|
-
// {op:"setParent", id | ids, parent: "c_…" | null, keep?: true, at?: seconds}
|
|
1320
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1321
|
-
if (!ids.length) throw new Error("setParent needs id or ids");
|
|
1322
|
-
if (op.parent !== null && typeof op.parent !== "string") throw new Error("setParent needs parent: a clip id (or null to unparent)");
|
|
1323
|
-
if (op.at != null && typeof op.at !== "number") throw new Error("setParent: at is a timeline time in seconds");
|
|
1324
|
-
for (const id of ids) {
|
|
1325
|
-
const c = byId(id);
|
|
1326
|
-
if (!c) throw new Error("setParent: no clip " + id);
|
|
1327
|
-
refuseLocked("setParent", lockReason(c), op);
|
|
1328
|
-
const prev = c.parent;
|
|
1329
|
-
c.parent = op.parent;
|
|
1330
|
-
try { checkParent(c, `setParent: ${id}`); } finally { if (prev) c.parent = prev; else delete c.parent; }
|
|
1331
|
-
if (op.keep !== false) {
|
|
1332
|
-
const t = op.at != null ? Math.min(Math.max(op.at, c.start), c.start + c.duration) : c.start;
|
|
1333
|
-
const v = Motion.reparentValues(c, op.parent, byId, t);
|
|
1334
|
-
c.props = c.props || {};
|
|
1335
|
-
for (const [k, val] of Object.entries(v)) if (!c.keyframes?.[k]?.length) c.props[k] = val;
|
|
1336
|
-
}
|
|
1337
|
-
if (op.parent) c.parent = op.parent; else delete c.parent;
|
|
1338
|
-
notes.push(`~${id}.parent(${op.parent || "none"})`);
|
|
1339
|
-
}
|
|
1340
|
-
break;
|
|
1341
|
-
}
|
|
1342
|
-
case "setMotionPath": {
|
|
1343
|
-
// {op:"setMotionPath", id | ids, path?: "smooth" | "straight", autoOrient?: bool}
|
|
1344
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1345
|
-
if (!ids.length) throw new Error("setMotionPath needs id or ids");
|
|
1346
|
-
if (op.path != null && !["smooth", "straight"].includes(op.path)) throw new Error('setMotionPath: path is "smooth" (curve through the position keys) or "straight"');
|
|
1347
|
-
if (op.autoOrient != null && typeof op.autoOrient !== "boolean") throw new Error("setMotionPath: autoOrient is true or false");
|
|
1348
|
-
if (op.path == null && op.autoOrient == null) throw new Error('setMotionPath needs path:"smooth"|"straight" and / or autoOrient');
|
|
1349
|
-
for (const id of ids) {
|
|
1350
|
-
const c = byId(id);
|
|
1351
|
-
if (!c) throw new Error("setMotionPath: no clip " + id);
|
|
1352
|
-
if (c.kind === "audio" || c.kind === "adjust") throw new Error(`setMotionPath: ${id} is a ${c.kind} clip`);
|
|
1353
|
-
refuseLocked("setMotionPath", lockReason(c), op);
|
|
1354
|
-
if (op.path) {
|
|
1355
|
-
if (Motion.posKeys(c).length < 2) throw new Error(`setMotionPath: ${id} needs x and y keyed together at two or more times first (the position keys)`);
|
|
1356
|
-
c.keyframes = op.path === "smooth" ? Motion.smoothPath(c.keyframes) : Motion.straightPath(c.keyframes);
|
|
1357
|
-
}
|
|
1358
|
-
if (op.autoOrient != null) { c.props = c.props || {}; if (op.autoOrient) c.props.autoOrient = true; else delete c.props.autoOrient; }
|
|
1359
|
-
notes.push(`~${id}.motionPath(${Motion.posKeys(c).length} keys${Motion.hasPathHandles(c) ? ", curved" : ""}${c.props && c.props.autoOrient ? ", auto-orient" : ""})`);
|
|
1360
|
-
}
|
|
1361
|
-
break;
|
|
1362
|
-
}
|
|
1363
|
-
case "setTextPath": {
|
|
1364
|
-
// {op:"setTextPath", id | ids, path: "line" | "arc" | "circle" | "wave" | {type, …} | null, offset?, align?}
|
|
1365
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1366
|
-
if (!ids.length) throw new Error("setTextPath needs id or ids");
|
|
1367
|
-
if (op.path === undefined && op.offset == null && op.align == null) throw new Error("setTextPath needs path (a preset name, a shape item or null), offset and / or align");
|
|
1368
|
-
let item;
|
|
1369
|
-
if (op.path === null) item = null;
|
|
1370
|
-
else if (typeof op.path === "string") {
|
|
1371
|
-
if (!Motion.TEXT_PATH_PRESETS[op.path]) throw new Error(`setTextPath: path is ${Object.keys(Motion.TEXT_PATH_PRESETS).join(" | ")}, a shape item or null`);
|
|
1372
|
-
item = Motion.normalizeTextPath(Motion.TEXT_PATH_PRESETS[op.path], true);
|
|
1373
|
-
} else if (op.path !== undefined) {
|
|
1374
|
-
try { item = Motion.normalizeTextPath(op.path, true); } catch (err) { throw new Error(`setTextPath: ${err.message}`); }
|
|
1375
|
-
}
|
|
1376
|
-
if (op.offset != null && typeof op.offset !== "number") throw new Error("setTextPath: offset is % of the path's length");
|
|
1377
|
-
if (op.align != null && !["start", "center", "end"].includes(op.align)) throw new Error("setTextPath: align is start | center | end");
|
|
1378
|
-
for (const id of ids) {
|
|
1379
|
-
const c = byId(id);
|
|
1380
|
-
if (!c) throw new Error("setTextPath: no clip " + id);
|
|
1381
|
-
if (c.kind !== "text") throw new Error(`setTextPath: ${id} is a ${c.kind} clip — text paths go on text clips`);
|
|
1382
|
-
refuseLocked("setTextPath", lockReason(c), op);
|
|
1383
|
-
c.props = c.props || {};
|
|
1384
|
-
if (op.path !== undefined) { if (item) c.props.textPath = JSON.parse(JSON.stringify(item)); else delete c.props.textPath; }
|
|
1385
|
-
if (op.offset != null) c.props.pathOffset = op.offset;
|
|
1386
|
-
if (op.align != null) c.props.pathAlign = op.align;
|
|
1387
|
-
notes.push(`~${id}.textPath(${c.props.textPath ? c.props.textPath.type : "none"})`);
|
|
1388
|
-
}
|
|
1389
|
-
break;
|
|
1390
|
-
}
|
|
1391
|
-
case "addComp": {
|
|
1392
|
-
// {op:"addComp", comp:{id?, name, width?, height?, duration, background?, tracks?, clips?:[…]}}
|
|
1393
|
-
const src = op.comp;
|
|
1394
|
-
if (!src || typeof src !== "object") throw new Error("addComp needs comp:{name, duration, …}");
|
|
1395
|
-
const id = src.id || "comp_" + uid();
|
|
1396
|
-
if (CompLib.getComp(proj, id)) throw new Error(`addComp: comp ${id} already exists`);
|
|
1397
|
-
let comp;
|
|
1398
|
-
try { comp = CompLib.normalizeComp({ ...src, id, clips: [] }, true, proj); } catch (err) { throw new Error(`addComp: ${err.message}`); }
|
|
1399
|
-
if (!comp.tracks) comp.tracks = CompLib.DEFAULT_TRACKS.map((t) => ({ ...t }));
|
|
1400
|
-
proj.comps = [...(proj.comps || []), comp];
|
|
1401
|
-
notes.push(`+comp ${id}`);
|
|
1402
|
-
// its clips go in through the same checks as addClip
|
|
1403
|
-
if (Array.isArray(src.clips) && src.clips.length) applyOps([{ op: "inComp", comp: id, ops: src.clips.map((clip) => ({ op: "addClip", clip })) }]);
|
|
1404
|
-
break;
|
|
1405
|
-
}
|
|
1406
|
-
case "updateComp": {
|
|
1407
|
-
// {op:"updateComp", id, set:{name?, width?, height?, duration?, background?, tracks?}}
|
|
1408
|
-
const comp = CompLib.getComp(proj, op.id);
|
|
1409
|
-
if (!comp) throw new Error("updateComp: no comp " + op.id);
|
|
1410
|
-
const set = op.set || {};
|
|
1411
|
-
for (const k of Object.keys(set)) if (!["name", "width", "height", "duration", "background", "tracks", "disabledTracks"].includes(k))
|
|
1412
|
-
throw new Error(`updateComp: ${k} can't be set here (name, width, height, duration, background, tracks, disabledTracks) — edit its clips with inComp`);
|
|
1413
|
-
const next = { ...comp, ...set };
|
|
1414
|
-
for (const [k, v] of Object.entries(set)) if (v === null) delete next[k];
|
|
1415
|
-
let n;
|
|
1416
|
-
try { n = CompLib.normalizeComp(next, true, proj); } catch (err) { throw new Error(`updateComp: ${err.message}`); }
|
|
1417
|
-
proj.comps = proj.comps.map((c) => (c.id === comp.id ? n : c));
|
|
1418
|
-
notes.push(`~comp ${comp.id}`);
|
|
1419
|
-
break;
|
|
1420
|
-
}
|
|
1421
|
-
case "removeComp": {
|
|
1422
|
-
const comp = CompLib.getComp(proj, op.id);
|
|
1423
|
-
if (!comp) throw new Error("removeComp: no comp " + op.id);
|
|
1424
|
-
const used = CompLib.usages(proj, comp.id);
|
|
1425
|
-
if (used.length) throw new Error(`removeComp: ${comp.id} is used by ${used.map((u) => u.clip.id + (u.in ? ` (in ${u.in})` : "")).join(", ")} — remove those clips first`);
|
|
1426
|
-
proj.comps = proj.comps.filter((c) => c.id !== comp.id);
|
|
1427
|
-
notes.push(`-comp ${comp.id}`);
|
|
1428
|
-
break;
|
|
1429
|
-
}
|
|
1430
|
-
case "precompose": {
|
|
1431
|
-
// {op:"precompose", ids:[…], name?, id?} — the clips (here, or inside the comp an inComp op is in) move into a new comp
|
|
1432
|
-
const ids = Array.isArray(op.ids) ? op.ids : [];
|
|
1433
|
-
if (!ids.length) throw new Error("precompose needs ids:[…] (the clips to move into the new comp)");
|
|
1434
|
-
for (const id of ids) {
|
|
1435
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
1436
|
-
if (!c) throw new Error("precompose: no clip " + id + (inCompId ? ` in comp ${inCompId}` : ""));
|
|
1437
|
-
refuseLocked("precompose", lockReason(c), op);
|
|
1438
|
-
}
|
|
1439
|
-
const doc = { clips: proj.clips, comps: proj.comps || [], tracks: proj.tracks, width: proj.width, height: proj.height };
|
|
1440
|
-
let r;
|
|
1441
|
-
try { r = CompLib.precompose(doc, ids, { id: op.id, name: op.name }); } catch (err) { throw new Error(err.message); }
|
|
1442
|
-
proj.clips = doc.clips; proj.comps = doc.comps;
|
|
1443
|
-
r.clip.id = "c_" + uid();
|
|
1444
|
-
notes.push(`+comp ${r.comp.id} (${ids.length} clips) +${r.clip.id}`);
|
|
1445
|
-
break;
|
|
1446
|
-
}
|
|
1447
|
-
case "applyPreset": {
|
|
1448
|
-
// {op:"applyPreset", id | ids, preset: name | {anchor, length, keys…}, length?, at?} — at: timeline seconds, for an "at" (emphasis) preset
|
|
1449
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id ? [op.id] : [];
|
|
1450
|
-
if (!ids.length) throw new Error("applyPreset needs id or ids:[…]");
|
|
1451
|
-
let p;
|
|
1452
|
-
try { p = Templates.findPreset(op.preset, savedList("presets")); } catch (err) { throw new Error(`applyPreset: ${err.message}`); }
|
|
1453
|
-
if (!p) throw new Error(`applyPreset: no preset ${JSON.stringify(op.preset)} — built in: ${Templates.PRESETS.map((x) => x.id).join(", ")}` +
|
|
1454
|
-
(savedList("presets").length ? `; saved: ${savedList("presets").map((x) => x.id).join(", ")}` : ""));
|
|
1455
|
-
if (op.length != null && !(typeof op.length === "number" && op.length > 0)) throw new Error("applyPreset: length is seconds, more than 0");
|
|
1456
|
-
for (const id of ids) {
|
|
1457
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
1458
|
-
if (!c) throw new Error("applyPreset: no clip " + id + (inCompId ? ` in comp ${inCompId}` : ""));
|
|
1459
|
-
if (c.kind === "audio" || c.kind === "adjust") throw new Error(`applyPreset: clip ${id} is ${c.kind} — presets animate visual clips`);
|
|
1460
|
-
refuseLocked("applyPreset", lockReason(c), op);
|
|
1461
|
-
Templates.applyPreset(c, p, { length: op.length, at: typeof op.at === "number" ? op.at - c.start : undefined });
|
|
1462
|
-
}
|
|
1463
|
-
notes.push(`~${ids.join(",")}.${p.id}`);
|
|
1464
|
-
break;
|
|
1465
|
-
}
|
|
1466
|
-
case "addTemplate": {
|
|
1467
|
-
// {op:"addTemplate", template: name | {…}, start?, track?, duration?, fields?:{key: value}, id?, compId?, name?}
|
|
1468
|
-
let t;
|
|
1469
|
-
try { t = Templates.findTemplate(op.template, savedList("templates")); } catch (err) { throw new Error(`addTemplate: ${err.message}`); }
|
|
1470
|
-
if (!t) throw new Error(`addTemplate: no template ${JSON.stringify(op.template)} — built in: ${Templates.TEMPLATES.map((x) => x.id).join(", ")}` +
|
|
1471
|
-
(savedList("templates").length ? `; saved: ${savedList("templates").map((x) => x.id).join(", ")}` : ""));
|
|
1472
|
-
let r;
|
|
1473
|
-
try {
|
|
1474
|
-
r = Templates.instantiate(proj, t, { compId: op.compId || "comp_" + uid(), clipId: op.id || "c_" + uid(), name: op.name,
|
|
1475
|
-
start: typeof op.start === "number" ? op.start : 0, track: op.track || "V2", duration: op.duration, fields: op.fields });
|
|
1476
|
-
} catch (err) { throw new Error(`addTemplate: ${err.message}`); }
|
|
1477
|
-
notes.push(`+comp ${r.comp.id} (${t.id})`);
|
|
1478
|
-
applyOps([{ op: "addClip", clip: r.clip }]); // the same checks as any clip: track, locks
|
|
1479
|
-
break;
|
|
1480
|
-
}
|
|
1481
|
-
case "setTemplate": {
|
|
1482
|
-
// {op:"setTemplate", id: comp clip id | comp id, fields?:{key: value}, duration?}
|
|
1483
|
-
let comp = CompLib.getComp(proj, op.id);
|
|
1484
|
-
if (!comp) { const c = proj.clips.find((x) => x.id === op.id && x.kind === "comp"); comp = c && CompLib.getComp(proj, c.compId); }
|
|
1485
|
-
if (!comp) throw new Error("setTemplate: no template clip or comp " + op.id);
|
|
1486
|
-
if (!comp.template) throw new Error(`setTemplate: comp ${comp.id} isn't a placed template — edit its clips with inComp`);
|
|
1487
|
-
try {
|
|
1488
|
-
for (const [k, v] of Object.entries(op.fields || {})) Templates.setField(comp, Templates.slug(k), v, proj);
|
|
1489
|
-
if (op.duration != null) {
|
|
1490
|
-
const old = comp.duration;
|
|
1491
|
-
Templates.retime(comp, op.duration);
|
|
1492
|
-
// clips that showed the comp to its end keep doing so
|
|
1493
|
-
for (const u of CompLib.usages(proj, comp.id)) if (Math.abs((+u.clip.in || 0) + u.clip.duration - old) < 1e-3) u.clip.duration = +(comp.duration - (+u.clip.in || 0)).toFixed(4);
|
|
1494
|
-
}
|
|
1495
|
-
} catch (err) { throw new Error(`setTemplate: ${err.message}`); }
|
|
1496
|
-
notes.push(`~template ${comp.id}`);
|
|
1497
|
-
break;
|
|
1498
|
-
}
|
|
1499
|
-
case "savePreset": {
|
|
1500
|
-
// {op:"savePreset", id: clip id, name, anchor?:"in"|"out"|"whole", from?, to?} (clip seconds) or {op:"savePreset", preset:{…}}
|
|
1501
|
-
let p;
|
|
1502
|
-
try {
|
|
1503
|
-
if (op.preset) p = Templates.normalizePreset(op.preset, true);
|
|
1504
|
-
else {
|
|
1505
|
-
const c = proj.clips.find((x) => x.id === op.id);
|
|
1506
|
-
if (!c) throw new Error("no clip " + op.id);
|
|
1507
|
-
const times = Object.values(c.keyframes || {}).flat().map((x) => x.t);
|
|
1508
|
-
if (!times.length) throw new Error(`clip ${c.id} has no keyframes to save`);
|
|
1509
|
-
const anchor = op.anchor || "in";
|
|
1510
|
-
p = Templates.presetFromClip(c, { name: op.name, anchor,
|
|
1511
|
-
from: typeof op.from === "number" ? op.from : anchor === "out" ? Math.min(...times) : 0,
|
|
1512
|
-
to: typeof op.to === "number" ? op.to : anchor === "in" ? Math.max(...times) : c.duration });
|
|
1513
|
-
}
|
|
1514
|
-
} catch (err) { throw new Error(`savePreset: ${err.message}`); }
|
|
1515
|
-
p = Templates.asSaved(p, "presets");
|
|
1516
|
-
toSave.push(["presets", p]);
|
|
1517
|
-
notes.push(`saved preset ${p.id} → library/presets/${p.id}.json`);
|
|
1518
|
-
break;
|
|
1519
|
-
}
|
|
1520
|
-
case "saveTemplate": {
|
|
1521
|
-
// {op:"saveTemplate", comp, name?, fields?, hold?} or {op:"saveTemplate", template:{…}}
|
|
1522
|
-
let t;
|
|
1523
|
-
try {
|
|
1524
|
-
if (op.template) t = Templates.normalizeTemplate(op.template, true);
|
|
1525
|
-
else {
|
|
1526
|
-
const comp = CompLib.getComp(proj, op.comp);
|
|
1527
|
-
if (!comp) throw new Error("no comp " + op.comp);
|
|
1528
|
-
t = Templates.templateFromComp(comp, { name: op.name, fields: op.fields, hold: op.hold, media: proj.media });
|
|
1529
|
-
}
|
|
1530
|
-
} catch (err) { throw new Error(`saveTemplate: ${err.message}`); }
|
|
1531
|
-
t = Templates.asSaved(t, "templates");
|
|
1532
|
-
toSave.push(["templates", t]);
|
|
1533
|
-
notes.push(`saved template ${t.id} → library/templates/${t.id}.json`);
|
|
1534
|
-
break;
|
|
1535
|
-
}
|
|
1536
|
-
case "inComp": {
|
|
1537
|
-
// {op:"inComp", comp, ops:[…clip / timeline ops…]} — run ops on the comp's own clips and tracks
|
|
1538
|
-
const comp = CompLib.getComp(proj, op.comp);
|
|
1539
|
-
if (!comp) throw new Error("inComp: no comp " + op.comp);
|
|
1540
|
-
if (!Array.isArray(op.ops)) throw new Error("inComp needs ops:[…]");
|
|
1541
|
-
const allowed = ["addClip", "updateClip", "removeClip", "setGrade", "setGradeLayer", "setMask", "removeMask", "setMaskKeys", "setMatte",
|
|
1542
|
-
"setShape", "removeShape", "setParent", "setMotionPath", "setTextPath", "setFx", "setFxKeys", "precompose", "inComp",
|
|
1543
|
-
"applyPreset", "addTemplate", "setTemplate",
|
|
1544
|
-
"split", "rippleDelete", "closeGap", "lift", "extract", "rippleTrim", "roll", "slip", "slide", "crossfade"];
|
|
1545
|
-
for (const o of op.ops) {
|
|
1546
|
-
if (!o || !allowed.includes(o.op)) throw new Error(`inComp: ${o && o.op} can't run inside a comp (clip and timeline ops only: ${allowed.join(", ")})`);
|
|
1547
|
-
// the same target rule as fxTarget: no id means master, A… a track, B… a bus — none of them live in a comp
|
|
1548
|
-
const fxAt = o.target || (o.id && /^A\d+$/.test(o.id) ? "track" : o.id && /^B\d+$/.test(o.id) ? "bus" : o.id ? "clip" : "master");
|
|
1549
|
-
if ((o.op === "setFx" || o.op === "setFxKeys") && fxAt !== "clip")
|
|
1550
|
-
throw new Error(`inComp: ${o.op} inside a comp works on its clips only (target:"clip", id) — the ${fxAt} effects are the main timeline's`);
|
|
1551
|
-
}
|
|
1552
|
-
const keep = { clips: proj.clips, tracks: proj.tracks, width: proj.width, height: proj.height, lockedTracks: proj.lockedTracks,
|
|
1553
|
-
untargetedTracks: proj.untargetedTracks, disabledTracks: proj.disabledTracks, inPoint: proj.inPoint, outPoint: proj.outPoint };
|
|
1554
|
-
const prevIn = inCompId;
|
|
1555
|
-
Object.assign(proj, { clips: comp.clips || [], tracks: CompLib.compTracks(comp), width: comp.width, height: comp.height,
|
|
1556
|
-
lockedTracks: [], untargetedTracks: [], disabledTracks: comp.disabledTracks || [], inPoint: null, outPoint: null });
|
|
1557
|
-
inCompId = comp.id;
|
|
1558
|
-
const n0 = notes.length;
|
|
1559
|
-
try { applyOps(op.ops); }
|
|
1560
|
-
finally {
|
|
1561
|
-
const live = CompLib.getComp(proj, comp.id) || comp;
|
|
1562
|
-
live.clips = proj.clips;
|
|
1563
|
-
inCompId = prevIn;
|
|
1564
|
-
Object.assign(proj, keep);
|
|
1565
|
-
}
|
|
1566
|
-
const inner = notes.splice(n0);
|
|
1567
|
-
notes.push(`${comp.id}{${inner.join(" ")}}`);
|
|
1568
|
-
break;
|
|
1569
|
-
}
|
|
1570
|
-
case "removeTrack": {
|
|
1571
|
-
// {op:"removeTrack", id, track: "name" | index} — what follows it keeps its keys
|
|
1572
|
-
const c = proj.clips.find((x) => x.id === op.id);
|
|
1573
|
-
if (!c) throw new Error("removeTrack: no clip " + op.id);
|
|
1574
|
-
refuseLocked("removeTrack", lockReason(c), op);
|
|
1575
|
-
const list = Tracker.normalizeTracks(c.props?.tracks) || [];
|
|
1576
|
-
const i = typeof op.track === "string" ? list.findIndex((t) => t.name === op.track) : op.track;
|
|
1577
|
-
if (!list[i]) throw new Error(`removeTrack: ${op.id} has no track ${JSON.stringify(op.track)}${list.length ? ` (it has ${Tracker.describe(list)})` : ""}`);
|
|
1578
|
-
list.splice(i, 1);
|
|
1579
|
-
if (list.length) c.props.tracks = list; else delete c.props.tracks;
|
|
1580
|
-
notes.push(`~${op.id}.tracks(${Tracker.describe(list) || "none"})`);
|
|
1581
|
-
break;
|
|
1582
|
-
}
|
|
1583
|
-
case "setMatte": {
|
|
1584
|
-
// {op:"setMatte", id | ids, matte:"alpha"|"alpha-inverted"|"luma"|"luma-inverted"|null, track?:"V3"|null}
|
|
1585
|
-
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
1586
|
-
if (!ids.length) throw new Error("setMatte needs id or ids");
|
|
1587
|
-
if (op.matte !== null && !Mask.MATTES.includes(op.matte)) throw new Error(`setMatte needs matte: ${Mask.MATTES.join(" | ")} (or null for none)`);
|
|
1588
|
-
for (const id of ids) {
|
|
1589
|
-
const c = proj.clips.find((x) => x.id === id);
|
|
1590
|
-
if (!c) throw new Error("setMatte: no clip " + id);
|
|
1591
|
-
if (c.kind === "audio") throw new Error(`setMatte: ${id} is an audio clip — track mattes go on video, image, svg, text or adjust clips`);
|
|
1592
|
-
refuseLocked("setMatte", lockReason(c), op);
|
|
1593
|
-
c.props = c.props || {};
|
|
1594
|
-
if (op.matte === null) { delete c.props.matte; delete c.props.matteTrack; notes.push(`~${id}.matte(none)`); continue; }
|
|
1595
|
-
c.props.matte = op.matte;
|
|
1596
|
-
if (op.track === null) delete c.props.matteTrack;
|
|
1597
|
-
else if (op.track != null) c.props.matteTrack = op.track;
|
|
1598
|
-
try { Mask.checkCompositing(c.props, videoTrackIds(), c.track, true); } catch (err) { throw new Error(`setMatte: ${id}: ${err.message.replace(/^matteTrack/, "track")}`); }
|
|
1599
|
-
const from = Mask.matteTrackFor(c, videoTrackIds());
|
|
1600
|
-
if (!from) throw new Error(`setMatte: ${id} is on ${c.track} and there is no video track above it — pass track:"Vn" (or add a track)`);
|
|
1601
|
-
notes.push(`~${id}.matte(${op.matte} from ${from})`);
|
|
1602
|
-
}
|
|
1603
|
-
break;
|
|
1604
|
-
}
|
|
1605
|
-
case "setTrack": {
|
|
1606
|
-
// Mixer settings on one lane: {op:"setTrack", id:"A1", set:{gain:-6, pan:0.2}}.
|
|
1607
|
-
if (!/^A\d+$/.test(String(op.id || ""))) throw new Error("setTrack: id must be an audio track (A1, A2, …)");
|
|
1608
|
-
if (!Array.isArray(proj.tracks) || !proj.tracks.length) proj.tracks = DEFAULT_TRACKS.map((t) => ({ ...t }));
|
|
1609
|
-
let t = proj.tracks.find((x) => x.id === op.id);
|
|
1610
|
-
if (!t) { t = { id: op.id, kind: "audio" }; proj.tracks.push(t); }
|
|
1611
|
-
for (const [k, v] of Object.entries(op.set || {})) {
|
|
1612
|
-
if (k === "out") { // route into a submix bus, or back to the master
|
|
1613
|
-
if (v == null || v === "master") { delete t.out; continue; }
|
|
1614
|
-
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"}`);
|
|
1615
|
-
t.out = v;
|
|
1616
|
-
continue;
|
|
1617
|
-
}
|
|
1618
|
-
const range = k === "gain" ? [-60, 12] : k === "pan" ? [-1, 1] : null;
|
|
1619
|
-
if (!range) throw new Error(`setTrack: '${k}' not settable (gain, pan, out)`);
|
|
1620
|
-
if (v === null || v === 0) { delete t[k]; continue; }
|
|
1621
|
-
if (typeof v !== "number" || !Number.isFinite(v) || v < range[0] || v > range[1])
|
|
1622
|
-
throw new Error(`setTrack: ${k} must be a number ${range[0]}…${range[1]}`);
|
|
1623
|
-
t[k] = v;
|
|
1624
|
-
}
|
|
1625
|
-
notes.push("~" + op.id);
|
|
1626
|
-
break;
|
|
1627
|
-
}
|
|
1628
|
-
case "setBus": {
|
|
1629
|
-
// {op:"setBus", id:"B1", set:{name?, gain?, pan?, mute?}} — creates the bus if needed
|
|
1630
|
-
if (!/^B\d+$/.test(String(op.id || ""))) throw new Error("setBus: id must be B1, B2, …");
|
|
1631
|
-
proj.buses = Array.isArray(proj.buses) ? proj.buses : [];
|
|
1632
|
-
let b = proj.buses.find((x) => x.id === op.id);
|
|
1633
|
-
if (!b) {
|
|
1634
|
-
if (proj.buses.length >= 8) throw new Error("setBus: up to 8 buses");
|
|
1635
|
-
b = { id: op.id };
|
|
1636
|
-
proj.buses.push(b);
|
|
1637
|
-
proj.buses.sort((x, y) => parseInt(x.id.slice(1), 10) - parseInt(y.id.slice(1), 10));
|
|
1638
|
-
}
|
|
1639
|
-
for (const [k, v] of Object.entries(op.set || {})) {
|
|
1640
|
-
if (k === "name") { if (v == null || v === "") delete b.name; else b.name = String(v).slice(0, 40); continue; }
|
|
1641
|
-
if (k === "mute") { if (v === true) b.mute = true; else delete b.mute; continue; }
|
|
1642
|
-
const range = k === "gain" ? [-60, 12] : k === "pan" ? [-1, 1] : null;
|
|
1643
|
-
if (!range) throw new Error(`setBus: '${k}' not settable (name, gain, pan, mute; effects via setFx target:"bus")`);
|
|
1644
|
-
if (v === null || v === 0) { delete b[k]; continue; }
|
|
1645
|
-
if (typeof v !== "number" || !Number.isFinite(v) || v < range[0] || v > range[1])
|
|
1646
|
-
throw new Error(`setBus: ${k} must be a number ${range[0]}…${range[1]}`);
|
|
1647
|
-
b[k] = v;
|
|
1648
|
-
}
|
|
1649
|
-
notes.push("~" + op.id);
|
|
1650
|
-
break;
|
|
1651
|
-
}
|
|
1652
|
-
case "removeBus": {
|
|
1653
|
-
const n = (proj.buses || []).length;
|
|
1654
|
-
proj.buses = (proj.buses || []).filter((b) => b.id !== op.id);
|
|
1655
|
-
if (proj.buses.length === n) throw new Error("removeBus: no bus " + op.id);
|
|
1656
|
-
if (!proj.buses.length) delete proj.buses;
|
|
1657
|
-
for (const t of proj.tracks || []) if (t.out === op.id) delete t.out; // back to the master
|
|
1658
|
-
notes.push("-" + op.id);
|
|
1659
|
-
break;
|
|
1660
|
-
}
|
|
1661
|
-
case "setProject": {
|
|
1662
|
-
const allowed = ["name", "width", "height", "fps", "background", "markers", "disabledTracks", "lockedTracks", "untargetedTracks", "exportFrame", "encodeProfile", "master", "inPoint", "outPoint"];
|
|
1663
|
-
for (const [k, v] of Object.entries(op.set || {})) {
|
|
1664
|
-
if (!allowed.includes(k)) throw new Error(`setProject: '${k}' not settable (allowed: ${allowed.join(", ")})`);
|
|
1665
|
-
if ((k === "inPoint" || k === "outPoint") && v != null && (typeof v !== "number" || !Number.isFinite(v) || v < 0))
|
|
1666
|
-
throw new Error(`setProject: ${k} must be seconds ≥ 0 (null clears it)`);
|
|
1667
|
-
if (k === "encodeProfile" && v != null) {
|
|
1668
|
-
resolveProfile(String(v)); // validate id exists
|
|
1669
|
-
}
|
|
1670
|
-
if (k === "master" && v != null) {
|
|
1671
|
-
const g = v.gain;
|
|
1672
|
-
if (typeof v !== "object" || Object.keys(v).some((x) => x !== "gain") ||
|
|
1673
|
-
(g != null && (typeof g !== "number" || !Number.isFinite(g) || g < -60 || g > 12)))
|
|
1674
|
-
throw new Error("setProject: master is {gain} — master fader in dB, -60…+12");
|
|
1675
|
-
}
|
|
1676
|
-
if (v === null) delete proj[k]; else proj[k] = v;
|
|
1677
|
-
}
|
|
1678
|
-
if (proj.inPoint != null && proj.outPoint != null && !(proj.outPoint > proj.inPoint))
|
|
1679
|
-
throw new Error("setProject: outPoint must be after inPoint");
|
|
1680
|
-
notes.push("~project");
|
|
1681
|
-
break;
|
|
1682
|
-
}
|
|
1683
|
-
case "split": case "rippleDelete": case "closeGap": case "lift": case "extract":
|
|
1684
|
-
case "rippleTrim": case "roll": case "slip": case "slide":
|
|
1685
|
-
case "insert": case "overwrite": case "crossfade":
|
|
1686
|
-
notes.push(timelineEdit(proj, op, refuseLocked, lockReason));
|
|
1687
|
-
break;
|
|
1688
|
-
default:
|
|
1689
|
-
throw new Error("Unknown op: " + op.op + " (addClip|updateClip|removeClip|addMedia|removeMedia|setProject|setTrack|setBus|removeBus|setFx|setFxKeys|setGrade|setGradeLayer|setMask|removeMask|setMaskKeys|setMatte|removeTrack|setShape|removeShape|setParent|setMotionPath|setTextPath|" +
|
|
1690
|
-
"split|rippleDelete|closeGap|lift|extract|rippleTrim|roll|slip|slide|insert|overwrite|crossfade|" +
|
|
1691
|
-
"addComp|updateComp|removeComp|precompose|inComp|applyPreset|addTemplate|setTemplate|savePreset|saveTemplate)");
|
|
1692
|
-
}
|
|
1693
|
-
} };
|
|
1694
|
-
applyOps(ops);
|
|
938
|
+
const { notes, toSave } = ProjectOps.applyPatch(proj, ops, {
|
|
939
|
+
savedItems,
|
|
940
|
+
audioChannels: (m) => { const f = mediaFile(m.src); return f ? probeChannels(f) || 0 : null; },
|
|
941
|
+
checkProfile: resolveProfile,
|
|
942
|
+
deliveryPresets: savedDeliveryPresets(),
|
|
943
|
+
mediaInfo: (src) => { const f = mediaFile(src); return f ? probeFile(f) : null; },
|
|
944
|
+
});
|
|
1695
945
|
// Someone else saved while the ops ran: writing now would drop their change.
|
|
1696
946
|
// Without a baseRevision the ops just re-apply on the newer document.
|
|
1697
947
|
let diskRev = readRev;
|
|
@@ -1711,34 +961,7 @@ async function callTool(name, args, attempt = 0) {
|
|
|
1711
961
|
if (!doc || typeof doc !== "object") throw new Error("`project` must be an object");
|
|
1712
962
|
if (!Array.isArray(doc.clips) || !Array.isArray(doc.media))
|
|
1713
963
|
throw new Error("project must contain `clips` and `media` arrays");
|
|
1714
|
-
|
|
1715
|
-
if (!Array.isArray(doc.comps)) throw new Error("comps must be a list of compositions");
|
|
1716
|
-
// saved as normalized (trimmed id, default name and size, rounded duration)
|
|
1717
|
-
try { doc.comps = doc.comps.map((comp) => CompLib.normalizeComp(comp, true, doc)); } catch (err) { throw new Error(err.message); }
|
|
1718
|
-
const seen = new Set();
|
|
1719
|
-
for (const comp of doc.comps) {
|
|
1720
|
-
if (seen.has(comp.id)) throw new Error(`two comps have the id ${comp.id} — comp ids must be unique`);
|
|
1721
|
-
seen.add(comp.id);
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
// the same checks for clips on the timeline and clips inside comps
|
|
1725
|
-
const checkClip = (c, where) => {
|
|
1726
|
-
const at = where ? `comp ${where}: ` : "";
|
|
1727
|
-
if (!c.id || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
|
|
1728
|
-
throw new Error(`${at}clip ${c.id || "?"} needs id, track, numeric start and duration`);
|
|
1729
|
-
if (!["text", "adjust", "shape", "null", "comp"].includes(c.kind) && !doc.media.some((m) => m.id === c.mediaId))
|
|
1730
|
-
throw new Error(`${at}clip ${c.id} references unknown mediaId ${c.mediaId}`);
|
|
1731
|
-
if (c.kind === "comp" && !CompLib.getComp(doc, c.compId)) throw new Error(`${at}clip ${c.id} references unknown compId ${c.compId}`);
|
|
1732
|
-
if (c.kind === "comp" && where && CompLib.wouldCycle(doc, where, c.compId))
|
|
1733
|
-
throw new Error(`comp ${where}: clip ${c.id} uses comp ${c.compId}, which is missing or would contain itself`);
|
|
1734
|
-
if (c.kind === "shape" && c.props && c.props.shapes != null) {
|
|
1735
|
-
let n;
|
|
1736
|
-
try { n = Motion.normalizeShapes(c.props.shapes, true); } catch (err) { throw new Error(`${at}clip ${c.id}: props.${err.message}`); }
|
|
1737
|
-
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
1738
|
-
}
|
|
1739
|
-
};
|
|
1740
|
-
for (const c of doc.clips) checkClip(c, null);
|
|
1741
|
-
for (const comp of doc.comps || []) for (const c of comp.clips) checkClip(c, comp.id);
|
|
964
|
+
ProjectOps.checkProject(doc);
|
|
1742
965
|
let cur = { revision: 0 };
|
|
1743
966
|
try { cur = readProject(); } catch {}
|
|
1744
967
|
const curRev = cur.revision || 0;
|
|
@@ -1953,6 +1176,7 @@ async function callTool(name, args, attempt = 0) {
|
|
|
1953
1176
|
src: "/media/" + encodeURIComponent(path.basename(target)),
|
|
1954
1177
|
duration: kind === "image" || kind === "svg" ? undefined : ffprobeDuration(target),
|
|
1955
1178
|
};
|
|
1179
|
+
Object.assign(entry, infoForEntry(await probeFile(target), entry));
|
|
1956
1180
|
const proj = readProject();
|
|
1957
1181
|
// import only appends a media entry (never touches clips), so it merges
|
|
1958
1182
|
// into the live document; keep lastReadRevision in step only if it
|
|
@@ -1968,6 +1192,14 @@ async function callTool(name, args, attempt = 0) {
|
|
|
1968
1192
|
: "Ready to use in clips via mediaId.");
|
|
1969
1193
|
}
|
|
1970
1194
|
case "fablecut_encode_profiles": {
|
|
1195
|
+
if (args.preset) {
|
|
1196
|
+
const saved = savedDeliveryPresets();
|
|
1197
|
+
const id = String(args.preset);
|
|
1198
|
+
if (!Delivery.PRESETS[id] && !saved[id]) throw new Error(`no delivery preset "${id}" — built-in: ${Delivery.PRESET_IDS.join(", ")}${Object.keys(saved).length ? "; saved: " + Object.keys(saved).join(", ") : ""}`);
|
|
1199
|
+
const { spec } = Delivery.normalize(id, { presets: saved });
|
|
1200
|
+
const p = Delivery.PRESETS[id] || saved[id];
|
|
1201
|
+
return JSON.stringify({ preset: id, label: p.label, note: p.note || "", summary: Delivery.describe(spec), spec }, null, 2);
|
|
1202
|
+
}
|
|
1971
1203
|
if (args.profile) {
|
|
1972
1204
|
const p = resolveProfile(String(args.profile));
|
|
1973
1205
|
return JSON.stringify({
|
|
@@ -1981,7 +1213,20 @@ async function callTool(name, args, attempt = 0) {
|
|
|
1981
1213
|
args: p.args,
|
|
1982
1214
|
}, null, 2);
|
|
1983
1215
|
}
|
|
1984
|
-
|
|
1216
|
+
const saved = savedDeliveryPresets();
|
|
1217
|
+
let encoders = null;
|
|
1218
|
+
try { const r = await apiJSON("GET", "/api/export/presets"); if (r.status === 200) encoders = r.body.encoders; } catch {}
|
|
1219
|
+
const presets = {};
|
|
1220
|
+
for (const id of [...Delivery.PRESET_IDS, ...Object.keys(saved)]) {
|
|
1221
|
+
const p = Delivery.PRESETS[id] || saved[id];
|
|
1222
|
+
presets[id] = { label: p.label, ...(saved[id] ? { saved: true } : {}), summary: Delivery.describe(Delivery.normalize(id, { presets: saved }).spec) };
|
|
1223
|
+
}
|
|
1224
|
+
// the encoding-profile fields stay at the top level, as they always were
|
|
1225
|
+
return JSON.stringify({
|
|
1226
|
+
...listProfilesPublic(!!args.detail),
|
|
1227
|
+
deliveryPresets: presets,
|
|
1228
|
+
encoders: encoders || "(start the editor server to list them)",
|
|
1229
|
+
}, null, 2);
|
|
1985
1230
|
}
|
|
1986
1231
|
default:
|
|
1987
1232
|
throw new Error("Unknown tool: " + name);
|
|
@@ -2001,7 +1246,7 @@ async function handle(msg) {
|
|
|
2001
1246
|
result: {
|
|
2002
1247
|
protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
|
|
2003
1248
|
capabilities: { tools: {} },
|
|
2004
|
-
serverInfo: { name: "fablecut", version: "1.
|
|
1249
|
+
serverInfo: { name: "fablecut", version: "1.15.0" },
|
|
2005
1250
|
},
|
|
2006
1251
|
});
|
|
2007
1252
|
}
|
|
@@ -2009,8 +1254,11 @@ async function handle(msg) {
|
|
|
2009
1254
|
if (method === "tools/call") {
|
|
2010
1255
|
pending++;
|
|
2011
1256
|
try {
|
|
2012
|
-
const
|
|
2013
|
-
|
|
1257
|
+
const out = await callTool(params.name, params.arguments || {});
|
|
1258
|
+
/* A tool may answer with ready-made content blocks (fablecut_frame
|
|
1259
|
+
returns an image); everything else answers with a string. */
|
|
1260
|
+
const content = Array.isArray(out) ? out : [{ type: "text", text: String(out) }];
|
|
1261
|
+
send({ jsonrpc: "2.0", id, result: { content } });
|
|
2014
1262
|
} catch (e) {
|
|
2015
1263
|
send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "Error: " + e.message }], isError: true } });
|
|
2016
1264
|
} finally {
|