fablecut 1.12.1 → 1.13.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
@@ -28,6 +28,8 @@ 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");
31
33
 
32
34
  /* ROOT is where the code lives (server.js, CLAUDE.md); the user's timeline and
33
35
  media live under DATA_DIR. Identical unless FABLECUT_DATA_DIR is set. */
@@ -139,7 +141,7 @@ const TOOLS = [
139
141
  },
140
142
  {
141
143
  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.",
144
+ 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). 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
145
  inputSchema: {
144
146
  type: "object",
145
147
  properties: {
@@ -263,7 +265,7 @@ const TOOLS = [
263
265
  },
264
266
  {
265
267
  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.",
268
+ 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
269
  inputSchema: {
268
270
  type: "object",
269
271
  properties: {
@@ -274,11 +276,45 @@ const TOOLS = [
274
276
  properties: { clip: { type: "string" }, layer: { type: "integer", minimum: 0 } },
275
277
  required: ["clip", "layer"],
276
278
  },
279
+ mask: {
280
+ type: "object",
281
+ 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",
282
+ properties: { clip: { type: "string" } },
283
+ required: ["clip"],
284
+ },
277
285
  where: { type: "string", enum: ["auto", "tab", "headless"], description: "auto (default): an open editor tab, else headless · tab · headless" },
278
286
  timeout: { type: "number", description: "Seconds to wait (default 60)" },
279
287
  },
280
288
  },
281
289
  },
290
+ {
291
+ name: "fablecut_track",
292
+ 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.",
293
+ inputSchema: {
294
+ type: "object",
295
+ properties: {
296
+ clip: { type: "string", description: "The video clip to track in" },
297
+ 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"] },
298
+ 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"] },
299
+ size: { type: "number", description: "With point: the region's width, fraction of the picture's width (default 0.08)" },
300
+ at: { type: "number", description: "Timeline seconds where the region is as given (default: the clip's start)" },
301
+ direction: { type: "string", enum: ["forward", "backward", "both"], description: "Track from `at` to the clip's end (default), to its start, or both" },
302
+ to: { type: "number", description: "Timeline seconds to stop at (default: the clip's end / start)" },
303
+ scale: { type: "boolean", description: "Also follow the region's size (zooms, objects moving closer)" },
304
+ rotation: { type: "boolean", description: "Also follow the region's rotation" },
305
+ 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" },
306
+ apply: {
307
+ type: "object",
308
+ 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)",
309
+ properties: { mask: { type: ["integer", "string"] }, clip: { type: "string" }, scale: { type: "boolean" }, rotation: { type: "boolean" }, at: { type: "number" } },
310
+ },
311
+ force: { type: "boolean", description: "Allow changing locked clips — only when the user asked" },
312
+ where: { type: "string", enum: ["auto", "tab", "headless"], description: "auto (default): an open editor tab, else headless · tab · headless" },
313
+ timeout: { type: "number", description: "Seconds to wait (default 300)" },
314
+ },
315
+ required: ["clip"],
316
+ },
317
+ },
282
318
  ];
283
319
 
284
320
  /* ── Loudness normalize (fablecut_normalize_audio) ──
@@ -615,20 +651,23 @@ function describeScopes(j) {
615
651
  ` clipped: black ${st.clipped.blackPct}% · white ${st.clipped.whitePct}%`,
616
652
  ` rgb mean [${st.rgbMean.join(", ")}] · saturation ${st.saturation}`,
617
653
  ` 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"}`,
654
+ `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
655
  ];
620
656
  const m = r.matte;
621
657
  if (m) {
622
658
  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
659
  (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
660
  }
625
- return lines.join("\n") + "\n" + JSON.stringify({ time: r.time, stats: st, ...(m ? { matte: m } : {}) });
661
+ const k = r.mask;
662
+ 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`);
663
+ return lines.join("\n") + "\n" + JSON.stringify({ time: r.time, stats: st, ...(m ? { matte: m } : {}), ...(k ? { mask: k } : {}) });
626
664
  }
627
665
  async function scopesTool(args) {
628
666
  if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
629
667
  const body = { where: args.where || "auto" };
630
668
  if (args.time != null) body.time = args.time;
631
669
  if (args.matte != null) body.matte = args.matte;
670
+ if (args.mask != null) body.mask = args.mask;
632
671
  const r = await apiJSON("POST", "/api/scopes/request", body);
633
672
  if (r.status !== 200) throw new Error(r.body?.error || `scope request failed (${r.status})`);
634
673
  let job = r.body;
@@ -647,9 +686,48 @@ async function scopesTool(args) {
647
686
  return describeScopes(job);
648
687
  }
649
688
 
689
+ /* ── Tracking (fablecut_track): runs in the editor, which saves the result ── */
690
+ async function trackTool(args) {
691
+ if (typeof args.clip !== "string") throw new Error("fablecut_track needs clip");
692
+ const proj = readProject(), c = proj.clips.find((x) => x.id === args.clip);
693
+ if (!c) throw new Error("no clip " + args.clip);
694
+ if (c.kind !== "video") throw new Error(`${c.id} is a ${c.kind} clip — tracking follows a video clip's picture`);
695
+ 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)`);
696
+ 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}");
697
+ if (!(await ensureUIServer())) throw new Error(`the editor server is not running and could not be started on port ${PORT}`);
698
+ const track = {};
699
+ for (const k of ["clip", "region", "point", "size", "at", "direction", "to", "scale", "rotation", "name", "apply", "force"]) if (args[k] != null) track[k] = args[k];
700
+ const r = await apiJSON("POST", "/api/track/request", { where: args.where || "auto", track });
701
+ if (r.status !== 200) throw new Error(r.body?.error || `track request failed (${r.status})`);
702
+ let job = r.body;
703
+ const deadline = Date.now() + 1000 * (Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 300);
704
+ while (job.status === "pending" || job.status === "running") {
705
+ if (Date.now() > deadline) {
706
+ await apiJSON("POST", "/api/export/job/cancel?id=" + encodeURIComponent(job.id));
707
+ throw new Error("timed out waiting for the editor to track — try a shorter range (to), or a larger timeout");
708
+ }
709
+ await sleep(500);
710
+ const q = await apiJSON("GET", "/api/export/job?id=" + encodeURIComponent(job.id));
711
+ if (q.status !== 200) throw new Error(q.body?.error || "lost track of the tracking job");
712
+ job = q.body;
713
+ }
714
+ if (job.status !== "done") throw new Error(`tracking ${job.status}: ${job.error || "no reason given"}`);
715
+ const t = job.result || {};
716
+ return [
717
+ 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}`
718
+ : `Used the stored track "${t.track}" on ${t.clip} (${t.from}–${t.to} s, ${t.samples} samples)`,
719
+ 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)` : "",
720
+ t.cancelled ? "Cancelled — kept what was tracked so far." : "",
721
+ t.applied ? `Applied → ${t.applied}` : "Stored only (apply it with apply:{mask} or apply:{clip}).",
722
+ `Project revision ${t.revision}.`,
723
+ ].filter(Boolean).join("\n");
724
+ }
725
+
650
726
  /* ── Tool implementations ── */
651
727
  async function callTool(name, args, attempt = 0) {
652
728
  switch (name) {
729
+ case "fablecut_track":
730
+ return trackTool(args);
653
731
  case "fablecut_export":
654
732
  return exportTool(args);
655
733
  case "fablecut_scopes":
@@ -742,9 +820,11 @@ async function callTool(name, args, attempt = 0) {
742
820
  kept[k] = v;
743
821
  continue;
744
822
  }
823
+ if (k === "masks" || k === "tracks") continue; // summarized below — point lists and samples are long
745
824
  if (hex(DEFAULTS[k]) !== hex(v)) kept[k] = v;
746
825
  }
747
- return Object.keys(kept).length ? " " + JSON.stringify(kept) : "";
826
+ const masks = Mask.describe(Mask.normalizeMasks(o.masks)), tracks = Tracker.describe(Tracker.normalizeTracks(o.tracks));
827
+ return (Object.keys(kept).length ? " " + JSON.stringify(kept) : "") + (masks ? ` [${masks}]` : "") + (tracks ? ` [tracks: ${tracks}]` : "");
748
828
  };
749
829
  // Mixer: only off-default faders / pans, so a fresh project adds no line.
750
830
  const signed = (v) => (v > 0 ? "+" : "") + v;
@@ -869,6 +949,34 @@ async function callTool(name, args, attempt = 0) {
869
949
  } else target[k] = v;
870
950
  }
871
951
  };
952
+ // props.masks written wholesale (addClip / updateClip) is validated like setMask
953
+ const checkMasks = (c, label) => {
954
+ if (!c.props || c.props.masks == null) return;
955
+ if (c.kind === "audio") throw new Error(`${label}: masks go on video, image, svg, text or adjust clips`);
956
+ let n;
957
+ try { n = Mask.normalizeMasks(c.props.masks, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
958
+ if (n) c.props.masks = n; else delete c.props.masks;
959
+ };
960
+ // props.tracks written wholesale is validated too (the editor writes them; agents rarely should)
961
+ const checkTracks = (c, label) => {
962
+ if (!c.props || c.props.tracks == null) return;
963
+ if (c.kind !== "video") throw new Error(`${label}: tracks belong to video clips`);
964
+ let n;
965
+ try { n = Tracker.normalizeTracks(c.props.tracks, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
966
+ if (n) c.props.tracks = n; else delete c.props.tracks;
967
+ };
968
+ // blend / matte / matteTrack: checked when the op writes them
969
+ const videoTrackIds = () => {
970
+ const ids = (Array.isArray(proj.tracks) && proj.tracks.length ? proj.tracks : DEFAULT_TRACKS).filter((t) => t.kind === "video").map((t) => t.id);
971
+ for (const x of proj.clips) if (/^V\d+$/.test(x.track) && !ids.includes(x.track)) ids.push(x.track);
972
+ return ids;
973
+ };
974
+ const checkCompositing = (c, label, props) => {
975
+ if (!props || !["blend", "matte", "matteTrack"].some((k) => props[k] != null)) return;
976
+ if (c.kind === "audio") throw new Error(`${label}: blend and track mattes go on video, image, svg, text or adjust clips`);
977
+ try { Mask.checkCompositing(c.props, videoTrackIds(), c.track, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
978
+ if (!c.props.matte) delete c.props.matteTrack;
979
+ };
872
980
  for (const op of ops) {
873
981
  switch (op.op) {
874
982
  case "addClip": {
@@ -880,6 +988,9 @@ async function callTool(name, args, attempt = 0) {
880
988
  refuseLocked("addClip", lockedTracks().has(c.track) ? `track ${c.track} is locked` : null, op);
881
989
  if (c.kind !== "text" && c.kind !== "adjust" && !proj.media.some((m) => m.id === c.mediaId))
882
990
  throw new Error(`addClip: unknown mediaId ${c.mediaId}`);
991
+ checkMasks(c, "addClip");
992
+ checkTracks(c, "addClip");
993
+ checkCompositing(c, "addClip", c.props);
883
994
  proj.clips.push(c);
884
995
  notes.push("+" + c.id);
885
996
  break;
@@ -895,6 +1006,11 @@ async function callTool(name, args, attempt = 0) {
895
1006
  refuseLocked("updateClip", `track ${op.set.track} is locked`, op);
896
1007
  }
897
1008
  mergeInto(c, op.set);
1009
+ checkMasks(c, "updateClip");
1010
+ if (op.set && op.set.props && "tracks" in op.set.props) checkTracks(c, "updateClip");
1011
+ // a track move re-checks the matte too (a clip can't be its own matte)
1012
+ checkCompositing(c, "updateClip", op.set && op.set.track ? c.props : op.set && op.set.props);
1013
+ if (c.props && c.props.matteTrack && !c.props.matte) delete c.props.matteTrack; // matte:null clears its track too
898
1014
  notes.push("~" + op.id);
899
1015
  break;
900
1016
  }
@@ -1007,6 +1123,90 @@ async function callTool(name, args, attempt = 0) {
1007
1123
  }
1008
1124
  break;
1009
1125
  }
1126
+ case "setMask":
1127
+ case "removeMask":
1128
+ case "setMaskKeys": {
1129
+ // {op:"setMask", id | ids, mask?: index | "name", set:{…}, replace?:true, remove?:true}
1130
+ // {op:"removeMask", id | ids, mask} · {op:"setMaskKeys", id | ids, mask, keys:[…] | null}
1131
+ const name = op.op;
1132
+ const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
1133
+ if (!ids.length) throw new Error(`${name} needs id or ids`);
1134
+ const remove = name === "removeMask" || op.remove === true;
1135
+ const byName = typeof op.mask === "string" && op.mask.trim() !== "";
1136
+ 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`);
1137
+ if ((remove || name === "setMaskKeys") && op.mask == null) throw new Error(`${name} needs mask: an index or a name`);
1138
+ let set;
1139
+ if (name === "setMaskKeys") {
1140
+ if (op.keys !== null && !Array.isArray(op.keys)) throw new Error("setMaskKeys needs keys:[{t, x?, y?, …}] (or keys:null to stop animating)");
1141
+ set = { keys: op.keys && op.keys.length ? op.keys : null };
1142
+ } else if (!remove) {
1143
+ if (op.set == null || typeof op.set !== "object" || Array.isArray(op.set)) throw new Error("setMask needs set:{shape?, x?, y?, …} (or remove:true)");
1144
+ set = op.set;
1145
+ }
1146
+ for (const id of ids) {
1147
+ const c = proj.clips.find((x) => x.id === id);
1148
+ if (!c) throw new Error(`${name}: no clip ${id}`);
1149
+ if (c.kind === "audio") throw new Error(`${name}: ${id} is an audio clip — masks go on video, image, svg, text or adjust clips`);
1150
+ refuseLocked(name, lockReason(c), op);
1151
+ const list = (Mask.normalizeMasks(c.props?.masks) || []).slice();
1152
+ let i = op.mask == null ? list.length : byName ? list.findIndex((m) => m.name === op.mask.trim()) : op.mask;
1153
+ const where = `${name}: ${id}`;
1154
+ if (remove) {
1155
+ if (i < 0 || i >= list.length) throw new Error(`${where} has no mask ${JSON.stringify(op.mask)}`);
1156
+ list.splice(i, 1);
1157
+ } else {
1158
+ const adding = i < 0 || i === list.length;
1159
+ if (adding && name === "setMaskKeys") throw new Error(`${where} has no mask ${JSON.stringify(op.mask)}`);
1160
+ 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`);
1161
+ if (adding && list.length >= Mask.MAX_MASKS) throw new Error(`${where}: at most ${Mask.MAX_MASKS} masks`);
1162
+ const base = adding ? (byName ? { name: op.mask.trim() } : null) : op.replace ? { name: list[i].name } : list[i];
1163
+ if (adding && !set.shape && !(base && base.shape)) throw new Error(`${where}: a new mask needs set.shape (rect | ellipse | bezier | freehand)`);
1164
+ let next;
1165
+ try { next = Mask.mergeMask(base, set, true); }
1166
+ catch (err) { throw new Error(`${where}: ${err.message.replace(/^mask\.?/, "mask.")}`); }
1167
+ if (adding) { i = list.length; list.push(next); } else list[i] = next;
1168
+ }
1169
+ c.props = c.props || {};
1170
+ if (list.length) c.props.masks = list; else delete c.props.masks;
1171
+ notes.push(`~${id}.masks(${Mask.describe(list) || "none"})`);
1172
+ }
1173
+ break;
1174
+ }
1175
+ case "removeTrack": {
1176
+ // {op:"removeTrack", id, track: "name" | index} — what follows it keeps its keys
1177
+ const c = proj.clips.find((x) => x.id === op.id);
1178
+ if (!c) throw new Error("removeTrack: no clip " + op.id);
1179
+ refuseLocked("removeTrack", lockReason(c), op);
1180
+ const list = Tracker.normalizeTracks(c.props?.tracks) || [];
1181
+ const i = typeof op.track === "string" ? list.findIndex((t) => t.name === op.track) : op.track;
1182
+ if (!list[i]) throw new Error(`removeTrack: ${op.id} has no track ${JSON.stringify(op.track)}${list.length ? ` (it has ${Tracker.describe(list)})` : ""}`);
1183
+ list.splice(i, 1);
1184
+ if (list.length) c.props.tracks = list; else delete c.props.tracks;
1185
+ notes.push(`~${op.id}.tracks(${Tracker.describe(list) || "none"})`);
1186
+ break;
1187
+ }
1188
+ case "setMatte": {
1189
+ // {op:"setMatte", id | ids, matte:"alpha"|"alpha-inverted"|"luma"|"luma-inverted"|null, track?:"V3"|null}
1190
+ const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
1191
+ if (!ids.length) throw new Error("setMatte needs id or ids");
1192
+ if (op.matte !== null && !Mask.MATTES.includes(op.matte)) throw new Error(`setMatte needs matte: ${Mask.MATTES.join(" | ")} (or null for none)`);
1193
+ for (const id of ids) {
1194
+ const c = proj.clips.find((x) => x.id === id);
1195
+ if (!c) throw new Error("setMatte: no clip " + id);
1196
+ if (c.kind === "audio") throw new Error(`setMatte: ${id} is an audio clip — track mattes go on video, image, svg, text or adjust clips`);
1197
+ refuseLocked("setMatte", lockReason(c), op);
1198
+ c.props = c.props || {};
1199
+ if (op.matte === null) { delete c.props.matte; delete c.props.matteTrack; notes.push(`~${id}.matte(none)`); continue; }
1200
+ c.props.matte = op.matte;
1201
+ if (op.track === null) delete c.props.matteTrack;
1202
+ else if (op.track != null) c.props.matteTrack = op.track;
1203
+ try { Mask.checkCompositing(c.props, videoTrackIds(), c.track, true); } catch (err) { throw new Error(`setMatte: ${id}: ${err.message.replace(/^matteTrack/, "track")}`); }
1204
+ const from = Mask.matteTrackFor(c, videoTrackIds());
1205
+ 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)`);
1206
+ notes.push(`~${id}.matte(${op.matte} from ${from})`);
1207
+ }
1208
+ break;
1209
+ }
1010
1210
  case "setTrack": {
1011
1211
  // Mixer settings on one lane: {op:"setTrack", id:"A1", set:{gain:-6, pan:0.2}}.
1012
1212
  if (!/^A\d+$/.test(String(op.id || ""))) throw new Error("setTrack: id must be an audio track (A1, A2, …)");
@@ -1091,7 +1291,7 @@ async function callTool(name, args, attempt = 0) {
1091
1291
  notes.push(timelineEdit(proj, op, refuseLocked, lockReason));
1092
1292
  break;
1093
1293
  default:
1094
- throw new Error("Unknown op: " + op.op + " (addClip|updateClip|removeClip|addMedia|removeMedia|setProject|setTrack|setBus|removeBus|setFx|setFxKeys|setGrade|setGradeLayer|" +
1294
+ 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|" +
1095
1295
  "split|rippleDelete|closeGap|lift|extract|rippleTrim|roll|slip|slide|insert|overwrite|crossfade)");
1096
1296
  }
1097
1297
  }
@@ -1381,7 +1581,7 @@ async function handle(msg) {
1381
1581
  result: {
1382
1582
  protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
1383
1583
  capabilities: { tools: {} },
1384
- serverInfo: { name: "fablecut", version: "1.12.1" },
1584
+ serverInfo: { name: "fablecut", version: "1.13.0" },
1385
1585
  },
1386
1586
  });
1387
1587
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fablecut",
3
- "version": "1.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "Zero-dependency browser video editor that AI agents can drive — JSON timeline, MCP server, REST API, live-reloading UI.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -541,7 +541,28 @@ const server = http.createServer(async (req, res) => {
541
541
  if (m != null && (typeof m !== "object" || typeof m.clip !== "string" || !Number.isInteger(m.layer) || m.layer < 0)) {
542
542
  sendJSON(res, 400, { error: "matte must be {clip: \"<clip id>\", layer: <index ≥ 0>}" }); return;
543
543
  }
544
- sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null }));
544
+ const mk = opts.mask;
545
+ if (mk != null && (typeof mk !== "object" || typeof mk.clip !== "string")) {
546
+ sendJSON(res, 400, { error: "mask must be {clip: \"<clip id>\"}" }); return;
547
+ }
548
+ sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null, mask: mk || null }));
549
+ } catch (e) {
550
+ sendJSON(res, e.code === 409 || e.code === 400 ? e.code : 500, { error: String(e.message || e) });
551
+ }
552
+ return;
553
+ }
554
+ /* API: follow a region of a clip (fablecut_track) — a "track" job on the
555
+ same queue; the tab that runs it saves the track into the project. */
556
+ if (p === "/api/track/request" && req.method === "POST") {
557
+ try {
558
+ const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
559
+ let revision = null;
560
+ try { revision = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(/^/, "")).revision ?? null; } catch {}
561
+ const t = opts.track;
562
+ if (!t || typeof t !== "object" || Array.isArray(t) || typeof t.clip !== "string") { sendJSON(res, 400, { error: "track must be {clip: \"<clip id>\", …}" }); return; }
563
+ const body = JSON.stringify(t);
564
+ if (body.length > 8192) { sendJSON(res, 400, { error: "track spec too large" }); return; }
565
+ sendJSON(res, 200, exportJobs.request({ kind: "track", where: opts.where, revision, track: JSON.parse(body) }));
545
566
  } catch (e) {
546
567
  sendJSON(res, e.code === 409 || e.code === 400 ? e.code : 500, { error: String(e.message || e) });
547
568
  }
package/style.css CHANGED
@@ -2296,6 +2296,17 @@ input[type=range] {
2296
2296
  text-decoration: line-through;
2297
2297
  }
2298
2298
 
2299
+ /* A track matte: it cuts the clip below instead of drawing. */
2300
+ .clip.matte-src .clip-body {
2301
+ opacity: .55;
2302
+ background-image: repeating-linear-gradient(135deg, transparent 0 6px, rgba(255, 255, 255, .08) 6px 12px);
2303
+ }
2304
+
2305
+ .clip.matte-src .clip-label::before {
2306
+ content: "Matte · ";
2307
+ opacity: .8;
2308
+ }
2309
+
2299
2310
  .timeline-scroll {
2300
2311
  flex: 1;
2301
2312
  min-height: 0;