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/CLAUDE.md +657 -11
- package/README.md +45 -10
- package/app.js +3352 -212
- package/color.js +47 -14
- package/comp.js +195 -0
- package/edit-ops.js +70 -17
- package/export-jobs.js +8 -5
- package/index.html +148 -62
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mask.js +467 -0
- package/mcp-server.js +639 -19
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +25 -2
- package/server.js +42 -2
- package/style.css +188 -24
- package/templates.js +576 -0
- package/tracker.js +253 -0
package/mask.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/* FableCut masks — a clip's mask stack: the shapes, their validation, their
|
|
2
|
+
keyframes and the matte they cut. Zero dependencies. Loaded by the editor
|
|
3
|
+
as a plain script (global `FableCutMask`) and required by the MCP server and
|
|
4
|
+
the tests, so the editor, an agent's `setMask` op and the tests agree on
|
|
5
|
+
what a mask is.
|
|
6
|
+
|
|
7
|
+
Masks live on a clip as `props.masks`, a list (first = bottom), each:
|
|
8
|
+
{ name?, on?, shape: rect | ellipse | bezier,
|
|
9
|
+
mode?: add | subtract | intersect | difference (default add),
|
|
10
|
+
invert?, opacity? 0…1, feather? px, expand? px (− shrinks),
|
|
11
|
+
x, y centre, 0…1 of the clip's picture (y down),
|
|
12
|
+
w, h size, 0…1 of the picture (rect / ellipse),
|
|
13
|
+
scale?, rotation?°,
|
|
14
|
+
points? bezier: [[dx, dy, inX?, inY?, outX?, outY?], …] — anchors
|
|
15
|
+
around x, y (fractions of the picture), each with optional
|
|
16
|
+
handles relative to the anchor; [dx, dy] alone is a corner,
|
|
17
|
+
keys?: [{ t, ease?, x?, y?, w?, h?, scale?, rotation?, feather?,
|
|
18
|
+
expand?, opacity?, points? }] } t = seconds from the clip's start
|
|
19
|
+
A free-hand stroke comes in as { shape: "freehand", stroke: [[u, v], …] }
|
|
20
|
+
(picture fractions) and is stored as the bezier fitted through it.
|
|
21
|
+
|
|
22
|
+
The picture is the clip's drawn rectangle for video / image / svg, the text
|
|
23
|
+
block for text and the whole frame for an adjustment layer, so a mask moves,
|
|
24
|
+
scales and turns with its clip. feather and expand are in project pixels.
|
|
25
|
+
Masks combine bottom-up into one matte; the first mask starts from empty when
|
|
26
|
+
it adds and from full otherwise. invert flips a mask before it combines.
|
|
27
|
+
|
|
28
|
+
Compositing (also here, so the editor and the agent agree):
|
|
29
|
+
props.blend one of BLENDS — how the clip lays over what is under it
|
|
30
|
+
props.matte alpha | alpha-inverted | luma | luma-inverted — a track
|
|
31
|
+
matte: the picture on the matte track cuts the clip
|
|
32
|
+
props.matteTrack? the video track that is the matte (default: the one
|
|
33
|
+
directly above). The matte track is hidden from the
|
|
34
|
+
frame while it is somebody's matte. */
|
|
35
|
+
(function (root, factory) {
|
|
36
|
+
const api = factory();
|
|
37
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
38
|
+
else root.FableCutMask = api;
|
|
39
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
40
|
+
"use strict";
|
|
41
|
+
|
|
42
|
+
const MAX_MASKS = 8;
|
|
43
|
+
const MAX_POINTS = 64;
|
|
44
|
+
const SHAPES = ["rect", "ellipse", "bezier"];
|
|
45
|
+
const MODES = ["add", "subtract", "intersect", "difference"];
|
|
46
|
+
const KEYED = ["x", "y", "w", "h", "scale", "rotation", "feather", "expand", "opacity", "points"];
|
|
47
|
+
const NUM_KEYED = KEYED.filter((k) => k !== "points");
|
|
48
|
+
const RANGE = {
|
|
49
|
+
x: [-2, 3], y: [-2, 3], w: [0.001, 8], h: [0.001, 8], scale: [0.01, 20],
|
|
50
|
+
rotation: [-3600, 3600], feather: [0, 1000], expand: [-1000, 1000], opacity: [0, 1],
|
|
51
|
+
};
|
|
52
|
+
const DEFAULTS = { mode: "add", opacity: 1, feather: 0, expand: 0, x: 0.5, y: 0.5, w: 0.5, h: 0.5, scale: 1, rotation: 0 };
|
|
53
|
+
const BLENDS = ["normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn",
|
|
54
|
+
"hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity", "lighter"];
|
|
55
|
+
const MATTES = ["alpha", "alpha-inverted", "luma", "luma-inverted"];
|
|
56
|
+
const KAPPA = 0.5522847498; // cubic bezier quarter circle
|
|
57
|
+
const EASES = {
|
|
58
|
+
linear: (u) => u,
|
|
59
|
+
"ease-in": (u) => u * u,
|
|
60
|
+
"ease-out": (u) => 1 - (1 - u) * (1 - u),
|
|
61
|
+
"ease-in-out": (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
|
65
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
66
|
+
const r4 = (v) => +v.toFixed(4);
|
|
67
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
68
|
+
|
|
69
|
+
/* ── Validation ── */
|
|
70
|
+
/** A clip's mask list, validated. null when empty. strict: throw on bad input (agent). */
|
|
71
|
+
function normalizeMasks(list, strict = false) {
|
|
72
|
+
if (list == null) return null;
|
|
73
|
+
if (!Array.isArray(list)) { if (strict) throw new Error("masks must be a list of masks"); return null; }
|
|
74
|
+
if (list.length > MAX_MASKS) {
|
|
75
|
+
if (strict) throw new Error(`masks has ${list.length} masks (at most ${MAX_MASKS})`);
|
|
76
|
+
list = list.slice(0, MAX_MASKS);
|
|
77
|
+
}
|
|
78
|
+
const out = list.map((m, i) => normalizeMask(m, strict, `masks[${i}]`)).filter(Boolean);
|
|
79
|
+
return out.length ? out : null;
|
|
80
|
+
}
|
|
81
|
+
function normalizePoints(pts, bad, label) {
|
|
82
|
+
if (!Array.isArray(pts) || pts.length < 3 || pts.length > MAX_POINTS ||
|
|
83
|
+
pts.some((p) => !Array.isArray(p) || (p.length !== 2 && p.length !== 6) || !p.every(isNum))) {
|
|
84
|
+
bad(`${label} must be 3…${MAX_POINTS} points [dx, dy] or [dx, dy, inX, inY, outX, outY] (fractions of the picture)`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return pts.map((p) => {
|
|
88
|
+
const q = p.map((v) => r4(clamp(v, -4, 4)));
|
|
89
|
+
return q.length === 6 && q.slice(2).every((v) => v === 0) ? q.slice(0, 2) : q;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** One mask, validated, with every default filled in (so the editor never guesses). */
|
|
93
|
+
function normalizeMask(m, strict = false, where = "mask") {
|
|
94
|
+
if (m == null) return null;
|
|
95
|
+
const bad = (msg) => { if (strict) throw new Error(`${where}${msg}`); return null; };
|
|
96
|
+
if (!isObj(m)) return bad(" must be an object like {shape:'ellipse', x:0.5, y:0.5, w:0.4, h:0.6}");
|
|
97
|
+
let src = m;
|
|
98
|
+
if (m.shape === "freehand") {
|
|
99
|
+
if (!Array.isArray(m.stroke) || m.stroke.length < 3 || m.stroke.some((p) => !Array.isArray(p) || p.length < 2 || !isNum(p[0]) || !isNum(p[1])))
|
|
100
|
+
return bad(".stroke must be at least 3 [u, v] points (fractions of the picture)");
|
|
101
|
+
src = { ...m, ...fitStroke(m.stroke, m.aspect), shape: "bezier" };
|
|
102
|
+
delete src.stroke; delete src.aspect;
|
|
103
|
+
}
|
|
104
|
+
const shape = src.shape == null ? "ellipse" : src.shape;
|
|
105
|
+
if (!SHAPES.includes(shape)) return bad(`.shape must be ${SHAPES.join(" | ")} (or freehand with a stroke)`);
|
|
106
|
+
const known = [...KEYED, "shape", "name", "on", "mode", "invert", "keys", "stroke", "aspect"];
|
|
107
|
+
for (const k of Object.keys(src)) if (!known.includes(k)) bad(`.${k}: unknown key (${known.slice(0, -2).join(", ")})`);
|
|
108
|
+
const out = { shape };
|
|
109
|
+
if (typeof src.name === "string" && src.name.trim()) out.name = src.name.trim().slice(0, 40);
|
|
110
|
+
else if (src.name != null && strict) bad(".name must be a string");
|
|
111
|
+
if (src.on === false) out.on = false;
|
|
112
|
+
const mode = src.mode == null ? "add" : src.mode;
|
|
113
|
+
if (!MODES.includes(mode)) bad(`.mode must be ${MODES.join(" | ")}`);
|
|
114
|
+
out.mode = MODES.includes(mode) ? mode : "add";
|
|
115
|
+
if (src.invert === true) out.invert = true;
|
|
116
|
+
for (const k of NUM_KEYED) {
|
|
117
|
+
let v = src[k];
|
|
118
|
+
if (v == null) v = DEFAULTS[k];
|
|
119
|
+
else if (!isNum(v)) { bad(`.${k} must be a number`); v = DEFAULTS[k]; }
|
|
120
|
+
out[k] = k === "rotation" ? r4(clamp(v, ...RANGE[k])) : r4(clamp(v, ...RANGE[k]));
|
|
121
|
+
}
|
|
122
|
+
if (shape === "bezier") {
|
|
123
|
+
const pts = normalizePoints(src.points, bad, ".points");
|
|
124
|
+
if (!pts) return null;
|
|
125
|
+
out.points = pts;
|
|
126
|
+
} else if (src.points != null && strict) bad(".points is for bezier masks");
|
|
127
|
+
if (src.keys != null) {
|
|
128
|
+
const keys = normalizeKeys(src.keys, out, bad);
|
|
129
|
+
if (keys) out.keys = keys;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function normalizeKeys(list, m, bad) {
|
|
134
|
+
if (!Array.isArray(list)) { bad(".keys must be a list of {t, x?, y?, …}"); return null; }
|
|
135
|
+
const keys = [];
|
|
136
|
+
for (const kf of list) {
|
|
137
|
+
if (!isObj(kf) || !isNum(kf.t)) { bad(".keys entries need a time t (seconds from the clip's start)"); continue; }
|
|
138
|
+
const k = { t: r4(Math.max(0, kf.t)) };
|
|
139
|
+
for (const p of NUM_KEYED) if (isNum(kf[p])) k[p] = r4(clamp(kf[p], ...RANGE[p]));
|
|
140
|
+
if (kf.points != null) {
|
|
141
|
+
if (m.shape !== "bezier") bad(".keys points are for bezier masks");
|
|
142
|
+
else {
|
|
143
|
+
const pts = normalizePoints(kf.points, bad, ".keys points");
|
|
144
|
+
if (pts && pts.length !== m.points.length) bad(`.keys points need the shape's ${m.points.length} points (got ${pts.length})`);
|
|
145
|
+
else if (pts) k.points = pts;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (kf.ease && kf.ease !== "ease-in-out") {
|
|
149
|
+
if (!EASES[kf.ease]) { bad(`.keys ease must be ${Object.keys(EASES).join(" | ")}`); continue; }
|
|
150
|
+
k.ease = kf.ease;
|
|
151
|
+
}
|
|
152
|
+
if (Object.keys(k).some((p) => KEYED.includes(p))) keys.push(k);
|
|
153
|
+
}
|
|
154
|
+
keys.sort((a, b) => a.t - b.t);
|
|
155
|
+
const dedup = keys.filter((k, i) => i === keys.length - 1 || keys[i + 1].t !== k.t);
|
|
156
|
+
return dedup.length ? dedup : null;
|
|
157
|
+
}
|
|
158
|
+
/** Merge `set` into a mask key by key (null resets a key); a new shape starts fresh. */
|
|
159
|
+
function mergeMask(base, set, strict = false) {
|
|
160
|
+
if (set == null) return null;
|
|
161
|
+
if (!isObj(set)) { if (strict) throw new Error("mask must be an object"); return base || null; }
|
|
162
|
+
const next = { ...(base || {}) };
|
|
163
|
+
if (set.shape === "freehand" || (set.shape && base && set.shape !== base.shape)) {
|
|
164
|
+
for (const k of ["points", "keys", "w", "h"]) delete next[k];
|
|
165
|
+
}
|
|
166
|
+
for (const [k, v] of Object.entries(set)) {
|
|
167
|
+
if (v === null) delete next[k];
|
|
168
|
+
else next[k] = v;
|
|
169
|
+
}
|
|
170
|
+
if (next.shape === "freehand") delete next.points;
|
|
171
|
+
return normalizeMask(next, strict);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── Time ── */
|
|
175
|
+
function lerpPoints(a, b, u) {
|
|
176
|
+
return a.map((p, i) => {
|
|
177
|
+
const q = b[i], n = Math.max(p.length, q.length), out = [];
|
|
178
|
+
for (let j = 0; j < n; j++) out.push((p[j] || 0) + ((q[j] || 0) - (p[j] || 0)) * u);
|
|
179
|
+
return out;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** A mask as it is at clip-local time t (keys resolved and removed). */
|
|
183
|
+
function maskAt(m, t) {
|
|
184
|
+
if (!m || !m.keys || !m.keys.length) return m;
|
|
185
|
+
const out = { ...m };
|
|
186
|
+
delete out.keys;
|
|
187
|
+
for (const p of KEYED) {
|
|
188
|
+
const ks = m.keys.filter((k) => k[p] != null);
|
|
189
|
+
if (!ks.length) continue;
|
|
190
|
+
if (t <= ks[0].t) { out[p] = ks[0][p]; continue; }
|
|
191
|
+
if (t >= ks[ks.length - 1].t) { out[p] = ks[ks.length - 1][p]; continue; }
|
|
192
|
+
let i = 0;
|
|
193
|
+
while (t > ks[i + 1].t) i++;
|
|
194
|
+
const a = ks[i], b = ks[i + 1];
|
|
195
|
+
const u = (EASES[b.ease || "ease-in-out"] || EASES.linear)((t - a.t) / Math.max(1e-9, b.t - a.t));
|
|
196
|
+
out[p] = p === "points" ? lerpPoints(a.points, b.points, u) : a[p] + (b[p] - a[p]) * u;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
/** Re-base mask keys when a clip's head moves by `offset` seconds (a split's
|
|
201
|
+
* right half, a head trim): keys before the new start fold into one key at 0
|
|
202
|
+
* holding the mask as it was there, so the animation plays on unchanged. */
|
|
203
|
+
function shiftKeys(list, offset) {
|
|
204
|
+
if (!Array.isArray(list) || !offset || !list.some((m) => m && m.keys && m.keys.length)) return list;
|
|
205
|
+
return list.map((m) => {
|
|
206
|
+
if (!m || !m.keys || !m.keys.length) return m;
|
|
207
|
+
const keys = m.keys.map((k) => ({ ...k, t: r4(k.t - offset) }));
|
|
208
|
+
const after = keys.filter((k) => k.t > 1e-4);
|
|
209
|
+
if (after.length === keys.length) return { ...m, keys };
|
|
210
|
+
const now = maskAt(m, offset), k0 = { t: 0 };
|
|
211
|
+
for (const p of KEYED) {
|
|
212
|
+
if (!m.keys.some((k) => k[p] != null)) continue;
|
|
213
|
+
k0[p] = p === "points" ? now.points.map((q) => q.map(r4)) : r4(now[p]);
|
|
214
|
+
}
|
|
215
|
+
return { ...m, keys: [k0, ...after] };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** The masks that cut the clip at clip-local time t, or null when none do. */
|
|
219
|
+
function masksAt(list, t) {
|
|
220
|
+
if (!Array.isArray(list) || !list.length) return null;
|
|
221
|
+
const on = list.filter((m) => m && m.on !== false);
|
|
222
|
+
return on.length ? on.map((m) => maskAt(m, t)) : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ── Geometry ── */
|
|
226
|
+
/** A mask's outline as closed cubic segments in picture pixels (bw × bh box,
|
|
227
|
+
* origin top-left): [{x, y, ix, iy, ox, oy}, …] — anchors with absolute handles. */
|
|
228
|
+
function anchors(m, bw, bh) {
|
|
229
|
+
let local; // in picture px around the mask centre, before scale / rotation
|
|
230
|
+
if (m.shape === "rect") {
|
|
231
|
+
const hx = m.w * bw / 2, hy = m.h * bh / 2;
|
|
232
|
+
local = [[-hx, -hy], [hx, -hy], [hx, hy], [-hx, hy]].map(([x, y]) => [x, y, 0, 0, 0, 0]);
|
|
233
|
+
} else if (m.shape === "ellipse") {
|
|
234
|
+
const rx = m.w * bw / 2, ry = m.h * bh / 2, kx = rx * KAPPA, ky = ry * KAPPA;
|
|
235
|
+
local = [[0, -ry, -kx, 0, kx, 0], [rx, 0, 0, -ky, 0, ky], [0, ry, kx, 0, -kx, 0], [-rx, 0, 0, ky, 0, -ky]];
|
|
236
|
+
} else {
|
|
237
|
+
local = (m.points || []).map((p) => [p[0] * bw, p[1] * bh, (p[2] || 0) * bw, (p[3] || 0) * bh, (p[4] || 0) * bw, (p[5] || 0) * bh]);
|
|
238
|
+
}
|
|
239
|
+
const th = (m.rotation || 0) * Math.PI / 180, s = m.scale == null ? 1 : m.scale;
|
|
240
|
+
const cs = Math.cos(th) * s, sn = Math.sin(th) * s, cx = m.x * bw, cy = m.y * bh;
|
|
241
|
+
const vec = (x, y) => [x * cs - y * sn, x * sn + y * cs];
|
|
242
|
+
return local.map(([x, y, ix, iy, ox, oy]) => {
|
|
243
|
+
const [ax, ay] = vec(x, y), [hix, hiy] = vec(ix, iy), [hox, hoy] = vec(ox, oy);
|
|
244
|
+
return { x: cx + ax, y: cy + ay, ix: cx + ax + hix, iy: cy + ay + hiy, ox: cx + ax + hox, oy: cy + ay + hoy };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** Trace the outline into a canvas-like path (moveTo / bezierCurveTo / closePath). */
|
|
248
|
+
function tracePath(ctx, A) {
|
|
249
|
+
if (!A.length) return;
|
|
250
|
+
ctx.moveTo(A[0].x, A[0].y);
|
|
251
|
+
for (let i = 0; i < A.length; i++) {
|
|
252
|
+
const a = A[i], b = A[(i + 1) % A.length];
|
|
253
|
+
ctx.bezierCurveTo(a.ox, a.oy, b.ix, b.iy, b.x, b.y);
|
|
254
|
+
}
|
|
255
|
+
ctx.closePath();
|
|
256
|
+
}
|
|
257
|
+
/** The outline as a polygon (each cubic cut into `n` pieces). */
|
|
258
|
+
function flatten(A, n = 16) {
|
|
259
|
+
const out = [];
|
|
260
|
+
for (let i = 0; i < A.length; i++) {
|
|
261
|
+
const a = A[i], b = A[(i + 1) % A.length];
|
|
262
|
+
const straight = a.ox === a.x && a.oy === a.y && b.ix === b.x && b.iy === b.y;
|
|
263
|
+
const steps = straight ? 1 : n;
|
|
264
|
+
for (let j = 0; j < steps; j++) {
|
|
265
|
+
const t = j / steps, u = 1 - t;
|
|
266
|
+
out.push([
|
|
267
|
+
u * u * u * a.x + 3 * u * u * t * a.ox + 3 * u * t * t * b.ix + t * t * t * b.x,
|
|
268
|
+
u * u * u * a.y + 3 * u * u * t * a.oy + 3 * u * t * t * b.iy + t * t * t * b.y,
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/** Signed distance from (px, py) to a polygon — negative inside (even-odd). */
|
|
275
|
+
function polyDist(P, px, py) {
|
|
276
|
+
let d = Infinity, inside = false;
|
|
277
|
+
for (let i = 0, j = P.length - 1; i < P.length; j = i, i++) {
|
|
278
|
+
const [x1, y1] = P[j], [x2, y2] = P[i], ex = x2 - x1, ey = y2 - y1;
|
|
279
|
+
const k = clamp(((px - x1) * ex + (py - y1) * ey) / Math.max(1e-12, ex * ex + ey * ey), 0, 1);
|
|
280
|
+
d = Math.min(d, Math.hypot(px - x1 - ex * k, py - y1 - ey * k));
|
|
281
|
+
if ((y2 > py) !== (y1 > py) && px < x1 + (py - y1) * ex / (ey || 1e-12)) inside = !inside;
|
|
282
|
+
}
|
|
283
|
+
return inside ? -d : d;
|
|
284
|
+
}
|
|
285
|
+
const smooth = (a, b, x) => { const u = clamp((x - a) / (b - a), 0, 1); return u * u * (3 - 2 * u); };
|
|
286
|
+
/** One mask's coverage (0…1) at picture pixel (px, py) — the CPU reference
|
|
287
|
+
* for the canvas raster; feather is a smooth ramp the width of `feather`. */
|
|
288
|
+
function maskCoverage(m, px, py, bw, bh, pxScale = 1) {
|
|
289
|
+
const d = polyDist(flatten(anchors(m, bw, bh)), px, py) - (m.expand || 0) * pxScale;
|
|
290
|
+
const f = (m.feather || 0) * pxScale;
|
|
291
|
+
let a = f > 0.5 ? 1 - smooth(-f / 2, f / 2, d) : (d <= 0 ? 1 : 0);
|
|
292
|
+
if (m.invert) a = 1 - a;
|
|
293
|
+
return a * (m.opacity == null ? 1 : m.opacity);
|
|
294
|
+
}
|
|
295
|
+
/** Combine one more mask's coverage into the matte so far. */
|
|
296
|
+
function combine(acc, a, mode) {
|
|
297
|
+
if (mode === "subtract") return acc * (1 - a);
|
|
298
|
+
if (mode === "intersect") return acc * a;
|
|
299
|
+
if (mode === "difference") return acc + a - 2 * acc * a;
|
|
300
|
+
return acc + a - acc * a;
|
|
301
|
+
}
|
|
302
|
+
/** The matte value (0…1) at picture pixel (px, py) for a resolved mask list. */
|
|
303
|
+
function matteAt(masks, px, py, bw, bh, pxScale = 1) {
|
|
304
|
+
if (!masks || !masks.length) return 1;
|
|
305
|
+
let acc = masks[0].mode === "add" ? 0 : 1;
|
|
306
|
+
for (const m of masks) acc = combine(acc, maskCoverage(m, px, py, bw, bh, pxScale), m.mode);
|
|
307
|
+
return acc;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ── Free-hand strokes → editable bezier ── */
|
|
311
|
+
function rdp(P, eps) {
|
|
312
|
+
if (P.length < 3) return P.slice();
|
|
313
|
+
const keep = new Uint8Array(P.length);
|
|
314
|
+
keep[0] = keep[P.length - 1] = 1;
|
|
315
|
+
const stack = [[0, P.length - 1]];
|
|
316
|
+
while (stack.length) {
|
|
317
|
+
const [a, b] = stack.pop();
|
|
318
|
+
const [x1, y1] = P[a], [x2, y2] = P[b], ex = x2 - x1, ey = y2 - y1, L = Math.hypot(ex, ey) || 1e-12;
|
|
319
|
+
let best = -1, bd = eps;
|
|
320
|
+
for (let i = a + 1; i < b; i++) {
|
|
321
|
+
const d = Math.abs((P[i][0] - x1) * ey - (P[i][1] - y1) * ex) / L;
|
|
322
|
+
if (d > bd) { bd = d; best = i; }
|
|
323
|
+
}
|
|
324
|
+
if (best > 0) { keep[best] = 1; stack.push([a, best], [best, b]); }
|
|
325
|
+
}
|
|
326
|
+
return P.filter((_, i) => keep[i]);
|
|
327
|
+
}
|
|
328
|
+
/** Fit a closed bezier through a stroke of [u, v] picture fractions. aspect =
|
|
329
|
+
* picture width / height (so the simplification is even in both directions);
|
|
330
|
+
* max = the most anchors to keep. Returns { x, y, points } ready for a bezier mask. */
|
|
331
|
+
function fitStroke(stroke, aspect = 16 / 9, max = 40) {
|
|
332
|
+
const a = isNum(aspect) && aspect > 0 ? aspect : 16 / 9;
|
|
333
|
+
let P = stroke.map((p) => [p[0] * a, p[1]]);
|
|
334
|
+
// drop the closing point when the stroke ends where it began
|
|
335
|
+
while (P.length > 3 && Math.hypot(P[0][0] - P[P.length - 1][0], P[0][1] - P[P.length - 1][1]) < 1e-4) P.pop();
|
|
336
|
+
let eps = 0.004, S = rdp(P, eps);
|
|
337
|
+
while (S.length > Math.min(MAX_POINTS, max) && eps < 1) { eps *= 1.5; S = rdp(P, eps); }
|
|
338
|
+
if (S.length < 3) S = [P[0], P[Math.floor(P.length / 3)], P[Math.floor(2 * P.length / 3)]];
|
|
339
|
+
// centre on the bounding box, Catmull-Rom tangents → handles (1/6 of the chord)
|
|
340
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
|
|
341
|
+
for (const [x, y] of S) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
|
|
342
|
+
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2, n = S.length;
|
|
343
|
+
const points = S.map((p, i) => {
|
|
344
|
+
const prev = S[(i - 1 + n) % n], next = S[(i + 1) % n];
|
|
345
|
+
const tx = (next[0] - prev[0]) / 6, ty = (next[1] - prev[1]) / 6;
|
|
346
|
+
return [(p[0] - cx) / a, p[1] - cy, -tx / a, -ty, tx / a, ty].map(r4);
|
|
347
|
+
});
|
|
348
|
+
return { x: r4(cx / a), y: r4(cy), points };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ── Raster (browser): the matte as the alpha of a canvas ── */
|
|
352
|
+
/** Paint the matte for `masks` into `out` (a 2D context, W × H, cleared here).
|
|
353
|
+
* `m` = the DOMMatrix from the picture's top-left-origin box (bw × bh px) to
|
|
354
|
+
* the canvas; `pxScale` = canvas px per project px (feather / expand units);
|
|
355
|
+
* `tmp`, `tmp2` = two scratch 2D contexts of the same size. */
|
|
356
|
+
function rasterize(out, tmp, tmp2, masks, m, bw, bh, pxScale = 1) {
|
|
357
|
+
const W = out.canvas.width, H = out.canvas.height;
|
|
358
|
+
const id = () => { out.setTransform(1, 0, 0, 1, 0, 0); tmp.setTransform(1, 0, 0, 1, 0, 0); tmp2.setTransform(1, 0, 0, 1, 0, 0); };
|
|
359
|
+
id();
|
|
360
|
+
out.globalAlpha = 1; out.globalCompositeOperation = "source-over"; out.filter = "none";
|
|
361
|
+
out.clearRect(0, 0, W, H);
|
|
362
|
+
if (masks[0].mode !== "add") { out.fillStyle = "#fff"; out.fillRect(0, 0, W, H); }
|
|
363
|
+
const s = Math.hypot(m.a, m.b) || 1; // the picture's scale on the canvas
|
|
364
|
+
for (const mk of masks) {
|
|
365
|
+
const A = anchors(mk, bw, bh);
|
|
366
|
+
tmp.globalCompositeOperation = "source-over"; tmp.filter = "none"; tmp.globalAlpha = 1;
|
|
367
|
+
tmp.clearRect(0, 0, W, H);
|
|
368
|
+
tmp.setTransform(m);
|
|
369
|
+
tmp.fillStyle = "#fff"; tmp.strokeStyle = "#fff"; tmp.lineJoin = "round";
|
|
370
|
+
tmp.beginPath(); tracePath(tmp, A); tmp.fill();
|
|
371
|
+
const ex = (mk.expand || 0) * pxScale / s;
|
|
372
|
+
if (ex) {
|
|
373
|
+
tmp.lineWidth = Math.abs(ex) * 2;
|
|
374
|
+
if (ex < 0) tmp.globalCompositeOperation = "destination-out";
|
|
375
|
+
tmp.stroke();
|
|
376
|
+
tmp.globalCompositeOperation = "source-over";
|
|
377
|
+
}
|
|
378
|
+
tmp.setTransform(1, 0, 0, 1, 0, 0);
|
|
379
|
+
let src = tmp;
|
|
380
|
+
const f = (mk.feather || 0) * pxScale;
|
|
381
|
+
if (f > 0.25) {
|
|
382
|
+
tmp2.globalCompositeOperation = "source-over"; tmp2.globalAlpha = 1;
|
|
383
|
+
tmp2.clearRect(0, 0, W, H);
|
|
384
|
+
tmp2.filter = `blur(${(f / 2).toFixed(2)}px)`;
|
|
385
|
+
tmp2.drawImage(tmp.canvas, 0, 0);
|
|
386
|
+
tmp2.filter = "none";
|
|
387
|
+
src = tmp2;
|
|
388
|
+
}
|
|
389
|
+
if (mk.invert) {
|
|
390
|
+
src.globalCompositeOperation = "xor";
|
|
391
|
+
src.fillStyle = "#fff"; src.fillRect(0, 0, W, H);
|
|
392
|
+
src.globalCompositeOperation = "source-over";
|
|
393
|
+
}
|
|
394
|
+
out.globalAlpha = mk.opacity == null ? 1 : mk.opacity;
|
|
395
|
+
out.globalCompositeOperation = mk.mode === "subtract" ? "destination-out" : mk.mode === "intersect" ? "destination-in"
|
|
396
|
+
: mk.mode === "difference" ? "xor" : "source-over";
|
|
397
|
+
out.drawImage(src.canvas, 0, 0);
|
|
398
|
+
}
|
|
399
|
+
out.globalAlpha = 1; out.globalCompositeOperation = "source-over";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** One line per clip for status readouts: "2 masks: ellipse, bezier(12) −". */
|
|
403
|
+
function describe(list) {
|
|
404
|
+
if (!Array.isArray(list) || !list.length) return "";
|
|
405
|
+
return `${list.length} mask${list.length > 1 ? "s" : ""}: ` + list.map((m) => {
|
|
406
|
+
const bits = [m.name ? `"${m.name}" ` : "", m.shape, m.shape === "bezier" && m.points ? `(${m.points.length})` : "",
|
|
407
|
+
m.mode && m.mode !== "add" ? ` ${m.mode}` : "", m.invert ? " inverted" : "", m.on === false ? " off" : "",
|
|
408
|
+
m.feather ? ` feather ${m.feather}` : "", m.expand ? ` expand ${m.expand}` : "", m.keys ? ` ${m.keys.length} keys` : ""];
|
|
409
|
+
return bits.join("");
|
|
410
|
+
}).join(", ");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Split segment i (anchor i → i+1) of a bezier outline at t ∈ (0,1): a new anchor at i+1, the curve
|
|
414
|
+
unchanged. Works on the mask's local points, so it applies the same way to every keyed outline. */
|
|
415
|
+
function splitPoints(points, i, t) {
|
|
416
|
+
const pts = points.map((p) => [p[0], p[1], p[2] || 0, p[3] || 0, p[4] || 0, p[5] || 0]);
|
|
417
|
+
const n = pts.length, j = (i + 1) % n, a = pts[i], b = pts[j];
|
|
418
|
+
const L = (p, q) => [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
|
|
419
|
+
const p0 = [a[0], a[1]], p1 = [a[0] + a[4], a[1] + a[5]], p2 = [b[0] + b[2], b[1] + b[3]], p3 = [b[0], b[1]];
|
|
420
|
+
const q0 = L(p0, p1), q1 = L(p1, p2), q2 = L(p2, p3), r0 = L(q0, q1), r1 = L(q1, q2), s = L(r0, r1);
|
|
421
|
+
a[4] = q0[0] - a[0]; a[5] = q0[1] - a[1]; b[2] = q2[0] - b[0]; b[3] = q2[1] - b[1];
|
|
422
|
+
pts.splice(i + 1, 0, [s[0], s[1], r0[0] - s[0], r0[1] - s[1], r1[0] - s[0], r1[1] - s[1]]);
|
|
423
|
+
return pts.map((p) => p.map(r4));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A bezier mask with its outline changed by `edit(points)` (split, remove) on the base points and every
|
|
427
|
+
keyed outline alike: the point count is the shape's topology, not a keyable value. */
|
|
428
|
+
function editTopology(m, edit) {
|
|
429
|
+
const out = { ...m, points: edit(m.points) };
|
|
430
|
+
if (m.keys) out.keys = m.keys.map((k) => (k.points ? { ...k, points: edit(k.points) } : k));
|
|
431
|
+
return out;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ── Track mattes ── */
|
|
435
|
+
const trackNum = (id) => parseInt(String(id).slice(1), 10) || 0;
|
|
436
|
+
/** The video track a clip's matte comes from: matteTrack when set (and another video track),
|
|
437
|
+
else the video track directly above (Vn+1, or the next one up). null = none. */
|
|
438
|
+
function matteTrackFor(clip, videoTrackIds) {
|
|
439
|
+
const p = clip && clip.props;
|
|
440
|
+
if (!p || !MATTES.includes(p.matte)) return null;
|
|
441
|
+
const ids = (videoTrackIds || []).filter((id) => id !== clip.track);
|
|
442
|
+
if (p.matteTrack) return ids.includes(p.matteTrack) ? p.matteTrack : null;
|
|
443
|
+
const own = trackNum(clip.track);
|
|
444
|
+
let best = null;
|
|
445
|
+
for (const id of ids) if (trackNum(id) > own && (!best || trackNum(id) < trackNum(best))) best = id;
|
|
446
|
+
return best;
|
|
447
|
+
}
|
|
448
|
+
/** Check props.blend / matte / matteTrack. strict: throw (agent); else drop what is invalid. */
|
|
449
|
+
function checkCompositing(props, videoTrackIds, ownTrack, strict = false) {
|
|
450
|
+
if (!isObj(props)) return props;
|
|
451
|
+
const bad = (msg) => { if (strict) throw new Error(msg); };
|
|
452
|
+
if (props.blend != null && !BLENDS.includes(props.blend)) { bad(`blend must be ${BLENDS.join(" | ")}`); delete props.blend; }
|
|
453
|
+
if (props.matte != null && !MATTES.includes(props.matte)) { bad(`matte must be ${MATTES.join(" | ")} (or null for none)`); delete props.matte; }
|
|
454
|
+
if (props.matteTrack != null) {
|
|
455
|
+
const ids = videoTrackIds || [];
|
|
456
|
+
if (typeof props.matteTrack !== "string" || !ids.includes(props.matteTrack)) { bad(`matteTrack must be a video track (${ids.join(", ")})`); delete props.matteTrack; }
|
|
457
|
+
else if (props.matteTrack === ownTrack) { bad("matteTrack must be another track than the clip's own"); delete props.matteTrack; }
|
|
458
|
+
}
|
|
459
|
+
return props;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
MAX_MASKS, MAX_POINTS, SHAPES, MODES, KEYED, BLENDS, MATTES, matteTrackFor, checkCompositing, NUM_KEYED, RANGE, DEFAULTS, EASES,
|
|
464
|
+
normalizeMasks, normalizeMask, mergeMask, maskAt, masksAt, shiftKeys,
|
|
465
|
+
anchors, tracePath, flatten, polyDist, maskCoverage, combine, matteAt, fitStroke, rasterize, describe, splitPoints, editTopology,
|
|
466
|
+
};
|
|
467
|
+
});
|