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/CLAUDE.md +228 -6
- package/README.md +22 -8
- package/app.js +1303 -25
- package/color.js +47 -14
- package/edit-ops.js +25 -4
- package/export-jobs.js +8 -5
- package/index.html +10 -2
- package/mask.js +467 -0
- package/mcp-server.js +207 -7
- package/package.json +1 -1
- package/server.js +22 -1
- package/style.css +11 -0
- package/tracker.js +253 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1584
|
+
serverInfo: { name: "fablecut", version: "1.13.0" },
|
|
1385
1585
|
},
|
|
1386
1586
|
});
|
|
1387
1587
|
}
|
package/package.json
CHANGED
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
|
-
|
|
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;
|