fablecut 1.12.1 → 1.14.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/mcp-server.js CHANGED
@@ -21,13 +21,20 @@ 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,
24
+ APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR, PROJECT_FILE, 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
29
  const Denoise = require("./denoise");
30
30
  const Color = require("./color");
31
+ const Mask = require("./mask");
32
+ const Tracker = require("./tracker");
33
+ const Motion = require("./motion");
34
+ const CompLib = require("./comp");
35
+ const Templates = require("./templates");
36
+ /** The user's saved presets / templates (library/presets, library/templates), validated; bad files skipped. */
37
+ const savedItems = (kind) => readSaved(kind).map((x) => (kind === "presets" ? Templates.normalizePreset(x.data) : Templates.normalizeTemplate(x.data))).filter(Boolean);
31
38
 
32
39
  /* ROOT is where the code lives (server.js, CLAUDE.md); the user's timeline and
33
40
  media live under DATA_DIR. Identical unless FABLECUT_DATA_DIR is set. */
@@ -139,7 +146,7 @@ const TOOLS = [
139
146
  },
140
147
  {
141
148
  name: "fablecut_patch_project",
142
- description: "Apply targeted edits to the FableCut project WITHOUT round-tripping the whole document — PREFER THIS over get+set for every edit (it is ~10-100x cheaper in tokens and merge-safe by design: it re-reads the latest document from disk, applies your ops in order, bumps revision once, saves atomically). Ops: {op:'addClip', clip:{…}} (id auto-generated if omitted) · {op:'updateClip', id, set:{…}} · {op:'removeClip', id} · {op:'addMedia', media:{…}} · {op:'removeMedia', id} · {op:'setProject', set:{name|width|height|fps|background|markers|disabledTracks|lockedTracks|untargetedTracks|encodeProfile|master}} (markers = the full list [{t, label?, color?}], color: gold|red|orange|green|cyan|blue|purple|pink; master = {gain}, the master fader in dB) · {op:'setTrack', id:'A1', set:{gain?, pan?, out?}} (audio-track fader in dB −60…+12, pan −1…1, out = a submix bus id or 'master'; null or 0 resets) · {op:'setBus', id:'B1', set:{name?, gain?, pan?, mute?}} (submix bus, created if missing; route tracks into it with setTrack out) · {op:'removeBus', id} · {op:'setFx', target:'clip'|'track'|'bus'|'master', id?, preset?:'podcast'|… OR fx:[{type,…params}], append?:true} (audio effects — validated; presets: clean-voice, podcast, radio, deep-voice, telephone, cinematic, wide, muffled; fx:null clears; on a clip it applies to its linked stems too) · {op:'setFxKeys', target, id?, index?|type?, param, keys:[{t, v, ease?}]|null} (automate one effect parameter: t is clip-local for a clip's effects, timeline seconds otherwise; see the 'Audio mix' docs section) · {op:'setGrade', id | ids:[…], grade:{exposure?, temp?, tint?, lift?, gamma?, gain?, offset?, contrast?, pivot?, blacks?, shadows?, midtones?, highlights?, whites?, lowSoft?, highSoft?, saturation?, curves?:{y?, r?, g?, b?}, hueHue?, hueSat?, hueLuma?, satLuma?, layers?:[…], on?}, replace?:true} (color grade on video / image / svg / adjust clips — wheels are [r, g, b, master]; curves are [[x, y], …] point lists on 0…1 and merge per channel; hue curves are [[hue°, value], …] — one point = a ±40° band; layers replaces the whole layer list; merges key by key, null resets a key, replace:true starts from neutral, grade:null clears; see the 'Color' docs section; measure the result with fablecut_scopes) · {op:'setGradeLayer', id | ids, layer: index | 'name', set:{name?, on?, qualifier?:{hue?:[centre°, width°, soft°], sat?:[lo, hi, soft], luma?:[lo, hi, soft], invert?}, mask?:{shape:'ellipse'|'rect'|'poly', x, y, w, h, rotation?, feather?, invert?, points?, keys?:[{t, x?, y?, …}]}, …any grade key}, replace?:true, remove?:true} (a secondary: a grade applied only where the qualifier × mask select; a new name or index = length adds a layer; qualifier and mask merge key by key; check what it selects with fablecut_scopes matte:{clip, layer}). TIMELINE EDITS — the editor's own split / ripple / trim code, so linked stems, track targeting (untargetedTracks) and locks behave exactly as in the UI; times in seconds: {op:'split', at, ids?} (no ids: every targeted track) · {op:'rippleDelete', ids} (later clips close the hole) · {op:'closeGap', at} · {op:'lift'|'extract', from?, to?} (remove a range; extract closes it; default = project inPoint/outPoint, which then clear) · {op:'insert'|'overwrite', mediaId, at, in?, duration?} (three-point edit: insert pushes later clips right, overwrite replaces what is there; a video brings one audio stem per channel) · {op:'rippleTrim'|'roll', id, side:'in'|'out', delta} · {op:'slip'|'slide', id, delta} (clamped to the media; the note says what was applied) · {op:'crossfade', ids? | at, duration?} (constant-power audio crossfade, borrowing handles from both sides). Any of these takes tracks:[…] to target lanes for that op only. setProject also takes inPoint / outPoint. updateClip merge rules: top-level keys are replaced (keyframes/transitionIn/transitionOut wholesale), `props` merges key-by-key, and setting any key to null deletes it. LOCKS: the user can lock clips (`locked:true`) and tracks (`lockedTracks`); updateClip / removeClip on a locked clip — or on a clip linked to one — and addClip onto a locked track are refused. Leave locked material alone; only if the user asked you to change it, pass force:true on that op (or unlock first: updateClip set:{locked:null}, which is always allowed). All-or-nothing: an invalid op aborts the whole patch unsaved. Optional baseRevision: the revision your ops were planned against (from fablecut_get_project, fablecut_status or the previous patch result) — if the project has moved past it (e.g. the user edited in the UI meanwhile), the patch is refused with CONFLICT and the current revision, nothing saved; re-check the clips you touch, then retry with the new revision. Omit it to apply on top of whatever is latest.",
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.",
143
150
  inputSchema: {
144
151
  type: "object",
145
152
  properties: {
@@ -263,7 +270,7 @@ const TOOLS = [
263
270
  },
264
271
  {
265
272
  name: "fablecut_scopes",
266
- description: "Measure the graded picture at one moment — what the editor's scopes show, as numbers: luma levels (min, 1st percentile, median, mean, 99th percentile, max on 0–1), the % of pixels crushed to black / clipped to white, mean R/G/B, average saturation, and the colour cast of the midtones (hue name + strength; neutral below 0.01), plus the clips on screen and their grades. Rendered by the same compositor as export (an open editor tab, else headless Chrome / Edge), over the export frame when one is set. Use it to check a grade: e.g. a cast strength above ~0.03 on footage that should be neutral, whitePct above ~1 (clipped highlights), or a median far from ~0.4 for a normally exposed shot. The user's playhead does not move.",
273
+ description: "Measure the graded picture at one moment — what the editor's scopes show, as numbers: luma levels (min, 1st percentile, median, mean, 99th percentile, max on 0–1), the % of pixels crushed to black / clipped to white, mean R/G/B, average saturation, and the colour cast of the midtones (hue name + strength; neutral below 0.01), plus the clips on screen with their grades, masks, track mattes and blend modes. Rendered by the same compositor as export (an open editor tab, else headless Chrome / Edge), over the export frame when one is set. Use it to check a grade: e.g. a cast strength above ~0.03 on footage that should be neutral, whitePct above ~1 (clipped highlights), or a median far from ~0.4 for a normally exposed shot. The user's playhead does not move.",
267
274
  inputSchema: {
268
275
  type: "object",
269
276
  properties: {
@@ -274,11 +281,45 @@ const TOOLS = [
274
281
  properties: { clip: { type: "string" }, layer: { type: "integer", minimum: 0 } },
275
282
  required: ["clip", "layer"],
276
283
  },
284
+ mask: {
285
+ type: "object",
286
+ description: "Also measure a clip's masks: {clip}. Adds how much of that clip's picture the masks keep (coverage %, the feathered matte measured alone) and how much of the frame the masked clip covers",
287
+ properties: { clip: { type: "string" } },
288
+ required: ["clip"],
289
+ },
277
290
  where: { type: "string", enum: ["auto", "tab", "headless"], description: "auto (default): an open editor tab, else headless · tab · headless" },
278
291
  timeout: { type: "number", description: "Seconds to wait (default 60)" },
279
292
  },
280
293
  },
281
294
  },
295
+ {
296
+ name: "fablecut_track",
297
+ description: "Follow something in a video clip frame by frame (motion tracking, in the editor: an open tab, else headless Chrome / Edge) and store it on the clip as a named track (props.tracks). Then, in the same call, make a mask follow it (apply:{mask}) or pin another clip to it (apply:{clip} — a title, sticker, blur box… its x / y, and optionally scale / rotation, get keyframes that ride on the track, keeping where it is at `at`). What to follow: region {x, y, w, h} or point {x, y} (+ size), as fractions of the clip's picture (0…1, y down) at time `at`; or leave both out with apply:{mask} to follow what that mask covers. To re-apply an existing track, pass its name with apply and no region. Pick something with detail (a corner, a logo, text, an eye), not a flat area; the result reports match quality (≥ 0.8 is solid) and where the region was lost, if it was. Check the outcome with fablecut_scopes or by looking at the keyed clip.",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ clip: { type: "string", description: "The video clip to track in" },
302
+ region: { type: "object", description: "The area to follow at `at`: centre x, y and size w, h, fractions of the clip's picture", properties: { x: { type: "number" }, y: { type: "number" }, w: { type: "number" }, h: { type: "number" } }, required: ["x", "y", "w", "h"] },
303
+ point: { type: "object", description: "A spot to follow at `at` (fractions of the picture); a square region of `size` around it is matched", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] },
304
+ size: { type: "number", description: "With point: the region's width, fraction of the picture's width (default 0.08)" },
305
+ at: { type: "number", description: "Timeline seconds where the region is as given (default: the clip's start)" },
306
+ direction: { type: "string", enum: ["forward", "backward", "both"], description: "Track from `at` to the clip's end (default), to its start, or both" },
307
+ to: { type: "number", description: "Timeline seconds to stop at (default: the clip's end / start)" },
308
+ scale: { type: "boolean", description: "Also follow the region's size (zooms, objects moving closer)" },
309
+ rotation: { type: "boolean", description: "Also follow the region's rotation" },
310
+ name: { type: "string", description: "The track's name (default: the mask's name, or Track n). An existing name is extended / replaced over the tracked range" },
311
+ apply: {
312
+ type: "object",
313
+ description: "Bake the track into keys: {mask: index | name} (the mask's x / y — and scale / rotation when tracked — follow) or {clip: id, scale?, rotation?, at?} (that clip rides on the track)",
314
+ properties: { mask: { type: ["integer", "string"] }, clip: { type: "string" }, scale: { type: "boolean" }, rotation: { type: "boolean" }, at: { type: "number" } },
315
+ },
316
+ force: { type: "boolean", description: "Allow changing locked clips — only when the user asked" },
317
+ where: { type: "string", enum: ["auto", "tab", "headless"], description: "auto (default): an open editor tab, else headless · tab · headless" },
318
+ timeout: { type: "number", description: "Seconds to wait (default 300)" },
319
+ },
320
+ required: ["clip"],
321
+ },
322
+ },
282
323
  ];
283
324
 
284
325
  /* ── Loudness normalize (fablecut_normalize_audio) ──
@@ -615,20 +656,23 @@ function describeScopes(j) {
615
656
  ` clipped: black ${st.clipped.blackPct}% · white ${st.clipped.whitePct}%`,
616
657
  ` rgb mean [${st.rgbMean.join(", ")}] · saturation ${st.saturation}`,
617
658
  ` cast: ${cast.tone === "neutral" ? "neutral" : `${cast.tone} (hue ${cast.hue}°)`} · strength ${cast.strength}`,
618
- `On screen (bottom → top): ${(r.clips || []).map((c) => `${c.id} ${c.track} ${c.kind}${c.grade !== "neutral" ? " grade:" + c.grade : ""}`).join(" | ") || "nothing"}`,
659
+ `On screen (bottom → top): ${(r.clips || []).map((c) => `${c.id} ${c.track} ${c.kind}${c.grade !== "neutral" ? " grade:" + c.grade : ""}${c.masks ? ` masks:[${c.masks}]` : ""}${c.matte ? ` matte:${c.matte}` : ""}${c.isMatte ? " (a matte — not drawn)" : ""}${c.blend ? " blend:" + c.blend : ""}`).join(" | ") || "nothing"}`,
619
660
  ];
620
661
  const m = r.matte;
621
662
  if (m) {
622
663
  lines.push(m.error ? `Matte ${m.clip} layer ${m.layer}: ${m.error}` : `Matte of ${m.clip} layer ${m.layer}: covers ${m.coverage}% of the clip's picture` +
623
664
  (m.selected ? ` · selected pixels: luma median ${m.selected.luma.median} (p1 ${m.selected.luma.p1}, p99 ${m.selected.luma.p99}) · rgb mean [${m.selected.rgbMean.join(", ")}] · saturation ${m.selected.saturation} · cast ${m.selected.cast.tone === "neutral" ? "neutral" : `${m.selected.cast.tone} (hue ${m.selected.cast.hue}°)`}` : " · nothing selected"));
624
665
  }
625
- return lines.join("\n") + "\n" + JSON.stringify({ time: r.time, stats: st, ...(m ? { matte: m } : {}) });
666
+ const k = r.mask;
667
+ if (k) lines.push(k.error ? `Masks of ${k.clip}: ${k.error}` : `Masks of ${k.clip} (${k.masks}): keep ${k.coverage}% of the clip's picture · the masked clip covers ${k.frameCoverage}% of the frame`);
668
+ return lines.join("\n") + "\n" + JSON.stringify({ time: r.time, stats: st, ...(m ? { matte: m } : {}), ...(k ? { mask: k } : {}) });
626
669
  }
627
670
  async function scopesTool(args) {
628
671
  if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
629
672
  const body = { where: args.where || "auto" };
630
673
  if (args.time != null) body.time = args.time;
631
674
  if (args.matte != null) body.matte = args.matte;
675
+ if (args.mask != null) body.mask = args.mask;
632
676
  const r = await apiJSON("POST", "/api/scopes/request", body);
633
677
  if (r.status !== 200) throw new Error(r.body?.error || `scope request failed (${r.status})`);
634
678
  let job = r.body;
@@ -647,9 +691,48 @@ async function scopesTool(args) {
647
691
  return describeScopes(job);
648
692
  }
649
693
 
694
+ /* ── Tracking (fablecut_track): runs in the editor, which saves the result ── */
695
+ async function trackTool(args) {
696
+ if (typeof args.clip !== "string") throw new Error("fablecut_track needs clip");
697
+ const proj = readProject(), c = proj.clips.find((x) => x.id === args.clip);
698
+ if (!c) throw new Error("no clip " + args.clip);
699
+ if (c.kind !== "video") throw new Error(`${c.id} is a ${c.kind} clip — tracking follows a video clip's picture`);
700
+ for (const k of ["region", "point"]) if (args[k] != null && (typeof args[k] !== "object" || !(k === "region" ? ["x", "y", "w", "h"] : ["x", "y"]).every((q) => Number.isFinite(args[k][q])))) throw new Error(`${k} needs numbers x, y${k === "region" ? ", w, h" : ""} (fractions of the picture)`);
701
+ if (args.apply != null && (typeof args.apply !== "object" || (args.apply.mask == null && args.apply.clip == null))) throw new Error("apply must be {mask: index | name} or {clip: id}");
702
+ if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
703
+ const track = {};
704
+ for (const k of ["clip", "region", "point", "size", "at", "direction", "to", "scale", "rotation", "name", "apply", "force"]) if (args[k] != null) track[k] = args[k];
705
+ const r = await apiJSON("POST", "/api/track/request", { where: args.where || "auto", track });
706
+ if (r.status !== 200) throw new Error(r.body?.error || `track request failed (${r.status})`);
707
+ let job = r.body;
708
+ const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 300);
709
+ while (job.status === "pending" || job.status === "running") {
710
+ if (Date.now() > deadline) {
711
+ await apiJSON("POST", "/api/export/job/cancel?id=" + encodeURIComponent(job.id));
712
+ throw new Error("timed out waiting for the editor to track — try a shorter range (to), or a larger timeout");
713
+ }
714
+ await sleep(500);
715
+ const q = await apiJSON("GET", "/api/export/job?id=" + encodeURIComponent(job.id));
716
+ if (q.status !== 200) throw new Error(q.body?.error || "lost track of the tracking job");
717
+ job = q.body;
718
+ }
719
+ if (job.status !== "done") throw new Error(`tracking ${job.status}: ${job.error || "no reason given"}`);
720
+ const t = job.result || {};
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");
729
+ }
730
+
650
731
  /* ── Tool implementations ── */
651
732
  async function callTool(name, args, attempt = 0) {
652
733
  switch (name) {
734
+ case "fablecut_track":
735
+ return trackTool(args);
653
736
  case "fablecut_export":
654
737
  return exportTool(args);
655
738
  case "fablecut_scopes":
@@ -727,6 +810,8 @@ async function callTool(name, args, attempt = 0) {
727
810
  letterSpacing: 0, lineHeight: 1.2, textShadow: 12, glow: 0, glowColor: "",
728
811
  strokeWidth: 0, strokeColor: "#000", bgColor: "#000", bgOpacity: 0,
729
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",
730
815
  };
731
816
  const hex = (v) => typeof v === "string" && /^#[0-9a-f]{3}$/i.test(v)
732
817
  ? "#" + [...v.slice(1)].map((c) => c + c).join("").toLowerCase()
@@ -742,9 +827,14 @@ async function callTool(name, args, attempt = 0) {
742
827
  kept[k] = v;
743
828
  continue;
744
829
  }
830
+ if (k === "masks" || k === "tracks" || k === "shapes" || k === "textPath") continue; // summarized below — point lists and samples are long
745
831
  if (hex(DEFAULTS[k]) !== hex(v)) kept[k] = v;
746
832
  }
747
- return Object.keys(kept).length ? " " + JSON.stringify(kept) : "";
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}]` : "");
748
838
  };
749
839
  // Mixer: only off-default faders / pans, so a fresh project adds no line.
750
840
  const signed = (v) => (v > 0 ? "+" : "") + v;
@@ -778,16 +868,29 @@ async function callTool(name, args, attempt = 0) {
778
868
  .slice()
779
869
  .sort((a, b) => (a.track === b.track ? a.start - b.start : String(a.track).localeCompare(b.track)))
780
870
  .map((c) => {
781
- const kf = c.keyframes ? " kf:" + Object.entries(c.keyframes).map(([k, v]) => `${k}(${v.length})`).join(",") : "";
871
+ const kf = c.keyframes ? " kf:" + Object.entries(c.keyframes).map(([k, v]) => `${k}(${v.length})`).join(",") +
872
+ (Motion.hasPathHandles(c) ? " path:curved" : "") : "";
782
873
  const tr = (c.transitionIn ? ` in:${c.transitionIn.type}/${c.transitionIn.duration}` : "") +
783
874
  (c.transitionOut ? ` out:${c.transitionOut.type}/${c.transitionOut.duration}` : "");
784
875
  const r3 = (n) => Math.round(n * 1000) / 1000;
785
876
  return ` ${c.id} ${c.track} ${r3(c.start)}s+${r3(c.duration)}s ${c.kind}` +
786
877
  (c.mediaId ? `(${c.mediaId}${c.in ? ` in:${r3(c.in)}` : ""})` : "") +
787
- (c.name ? ` "${c.name}"` : "") + fmtProps(c.props, c.kind) + kf + tr +
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 +
788
880
  (c.locked === true ? " [locked]" : "") + (c.disabled === true ? " [disabled]" : "") +
789
881
  (c.unlinked === true ? " [unlinked]" : "") + fxTag(c.fx);
790
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
+ })])] : []),
791
894
  `(compact view — full JSON: fablecut_get_project without compact; edit via fablecut_patch_project)`,
792
895
  ];
793
896
  return lines.join("\n");
@@ -808,6 +911,8 @@ async function callTool(name, args, attempt = 0) {
808
911
  `or omit baseRevision to apply on top of the latest anyway.`);
809
912
  if (base != null && readRev !== base) throw conflict(readRev);
810
913
  const notes = [];
914
+ const toSave = []; // [kind, item] — presets / templates written to the library once the project is saved
915
+ const savedList = (kind) => [...savedItems(kind), ...toSave.filter((x) => x[0] === kind).map((x) => x[1])];
811
916
  // Locks mirror the editor: a clip is locked by its own flag or its track,
812
917
  // and a linked A/V group with any locked member is locked as a whole.
813
918
  const lockedTracks = () => new Set(Array.isArray(proj.lockedTracks) ? proj.lockedTracks : []);
@@ -869,7 +974,79 @@ async function callTool(name, args, attempt = 0) {
869
974
  } else target[k] = v;
870
975
  }
871
976
  };
872
- for (const op of ops) {
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) {
873
1050
  switch (op.op) {
874
1051
  case "addClip": {
875
1052
  const c = op.clip;
@@ -878,8 +1055,16 @@ async function callTool(name, args, attempt = 0) {
878
1055
  c.id = c.id || "c_" + uid();
879
1056
  if (proj.clips.some((x) => x.id === c.id)) throw new Error("addClip: duplicate clip id " + c.id);
880
1057
  refuseLocked("addClip", lockedTracks().has(c.track) ? `track ${c.track} is locked` : null, op);
881
- if (c.kind !== "text" && c.kind !== "adjust" && !proj.media.some((m) => m.id === c.mediaId))
1058
+ if (!NO_MEDIA.includes(c.kind) && !proj.media.some((m) => m.id === c.mediaId))
882
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);
883
1068
  proj.clips.push(c);
884
1069
  notes.push("+" + c.id);
885
1070
  break;
@@ -895,6 +1080,15 @@ async function callTool(name, args, attempt = 0) {
895
1080
  refuseLocked("updateClip", `track ${op.set.track} is locked`, op);
896
1081
  }
897
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
898
1092
  notes.push("~" + op.id);
899
1093
  break;
900
1094
  }
@@ -902,6 +1096,10 @@ async function callTool(name, args, attempt = 0) {
902
1096
  const doomed = proj.clips.find((x) => x.id === op.id);
903
1097
  if (doomed) refuseLocked("removeClip", lockReason(doomed), op);
904
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"})`);
905
1103
  proj.clips = proj.clips.filter((x) => x.id !== op.id);
906
1104
  if (proj.clips.length === n) throw new Error("removeClip: no clip " + op.id);
907
1105
  notes.push("-" + op.id);
@@ -920,6 +1118,10 @@ async function callTool(name, args, attempt = 0) {
920
1118
  case "removeMedia": {
921
1119
  const used = proj.clips.find((c) => c.mediaId === op.id);
922
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
+ }
923
1125
  const n = proj.media.length;
924
1126
  proj.media = proj.media.filter((x) => x.id !== op.id);
925
1127
  if (proj.media.length === n) throw new Error("removeMedia: no media " + op.id);
@@ -1007,6 +1209,399 @@ async function callTool(name, args, attempt = 0) {
1007
1209
  }
1008
1210
  break;
1009
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
+ }
1010
1605
  case "setTrack": {
1011
1606
  // Mixer settings on one lane: {op:"setTrack", id:"A1", set:{gain:-6, pan:0.2}}.
1012
1607
  if (!/^A\d+$/.test(String(op.id || ""))) throw new Error("setTrack: id must be an audio track (A1, A2, …)");
@@ -1091,10 +1686,12 @@ async function callTool(name, args, attempt = 0) {
1091
1686
  notes.push(timelineEdit(proj, op, refuseLocked, lockReason));
1092
1687
  break;
1093
1688
  default:
1094
- throw new Error("Unknown op: " + op.op + " (addClip|updateClip|removeClip|addMedia|removeMedia|setProject|setTrack|setBus|removeBus|setFx|setFxKeys|setGrade|setGradeLayer|" +
1095
- "split|rippleDelete|closeGap|lift|extract|rippleTrim|roll|slip|slide|insert|overwrite|crossfade)");
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)");
1096
1692
  }
1097
- }
1693
+ } };
1694
+ applyOps(ops);
1098
1695
  // Someone else saved while the ops ran: writing now would drop their change.
1099
1696
  // Without a baseRevision the ops just re-apply on the newer document.
1100
1697
  let diskRev = readRev;
@@ -1106,6 +1703,7 @@ async function callTool(name, args, attempt = 0) {
1106
1703
  proj.revision = readRev + 1;
1107
1704
  writeProject(proj);
1108
1705
  lastReadRevision = proj.revision;
1706
+ for (const [kind, item] of toSave) writeSaved(kind, item.id, item);
1109
1707
  return `Patched (revision ${proj.revision}): ${notes.join(" ")}. Now ${proj.clips.length} clip(s), ${proj.media.length} media. UI hot-reloaded.`;
1110
1708
  }
1111
1709
  case "fablecut_set_project": {
@@ -1113,12 +1711,34 @@ async function callTool(name, args, attempt = 0) {
1113
1711
  if (!doc || typeof doc !== "object") throw new Error("`project` must be an object");
1114
1712
  if (!Array.isArray(doc.clips) || !Array.isArray(doc.media))
1115
1713
  throw new Error("project must contain `clips` and `media` arrays");
1116
- for (const c of doc.clips) {
1117
- if (!c.id || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
1118
- throw new Error(`clip ${c.id || "?"} needs id, track, numeric start and duration`);
1119
- if (c.kind !== "text" && c.kind !== "adjust" && !doc.media.some((m) => m.id === c.mediaId))
1120
- throw new Error(`clip ${c.id} references unknown mediaId ${c.mediaId}`);
1714
+ if (doc.comps != null) {
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
+ }
1121
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);
1122
1742
  let cur = { revision: 0 };
1123
1743
  try { cur = readProject(); } catch {}
1124
1744
  const curRev = cur.revision || 0;
@@ -1381,7 +2001,7 @@ async function handle(msg) {
1381
2001
  result: {
1382
2002
  protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
1383
2003
  capabilities: { tools: {} },
1384
- serverInfo: { name: "fablecut", version: "1.12.1" },
2004
+ serverInfo: { name: "fablecut", version: "1.14.0" },
1385
2005
  },
1386
2006
  });
1387
2007
  }