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/tracker.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/* FableCut tracker — follows a region of a video from frame to frame by
|
|
2
|
+
template matching (normalized cross-correlation), optionally with its scale
|
|
3
|
+
and rotation. Zero dependencies. Loaded by the editor as a plain script
|
|
4
|
+
(global `FableCutTracker`) and required by the MCP server and the tests.
|
|
5
|
+
|
|
6
|
+
The editor feeds it greyscale frames; it returns where the region went.
|
|
7
|
+
A finished track is stored on the clip it was made on, in `props.tracks`:
|
|
8
|
+
{ name, kind: "point" | "box", w, h, region size at the first sample,
|
|
9
|
+
fractions of the clip's picture
|
|
10
|
+
samples: [[t, x, y, s, r, q], …] } t = seconds from the clip's start,
|
|
11
|
+
x, y = centre (fractions of the picture),
|
|
12
|
+
s = scale against the first sample,
|
|
13
|
+
r = rotation° against the first sample,
|
|
14
|
+
q = match quality 0…1
|
|
15
|
+
Masks and other clips follow a track by having it baked into their keys. */
|
|
16
|
+
(function (root, factory) {
|
|
17
|
+
const api = factory();
|
|
18
|
+
if (typeof module === "object" && module.exports) module.exports = api;
|
|
19
|
+
else root.FableCutTracker = api;
|
|
20
|
+
})(typeof self !== "undefined" ? self : this, function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
const MAX_TRACKS = 16;
|
|
24
|
+
const MAX_SAMPLES = 20000;
|
|
25
|
+
const KINDS = ["point", "box"];
|
|
26
|
+
const GRID = 40; // template samples per side (at most)
|
|
27
|
+
const MIN_SCORE = 0.45; // below this the region is lost
|
|
28
|
+
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
|
29
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
30
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
31
|
+
const r4 = (v) => +v.toFixed(4);
|
|
32
|
+
|
|
33
|
+
/** RGBA bytes → luma (0…255) as a Float32Array. */
|
|
34
|
+
function toGray(rgba, w, h) {
|
|
35
|
+
const g = new Float32Array(w * h);
|
|
36
|
+
for (let i = 0, j = 0; j < g.length; i += 4, j++) g[j] = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
|
37
|
+
return g;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Bilinear sample, clamped to the frame. */
|
|
41
|
+
function sample(img, w, h, x, y) {
|
|
42
|
+
x = clamp(x, 0, w - 1.001); y = clamp(y, 0, h - 1.001);
|
|
43
|
+
const x0 = x | 0, y0 = y | 0, fx = x - x0, fy = y - y0, i = y0 * w + x0;
|
|
44
|
+
const a = img[i], b = img[i + 1], c = img[i + w], d = img[i + w + 1];
|
|
45
|
+
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Follow a region. `box` = {cx, cy, w, h} in frame pixels on `first`.
|
|
50
|
+
* opts: scale / rotation (search them too), search (px radius, default from the box size).
|
|
51
|
+
* Returns { step(frame) → {cx, cy, s, r, q, lost} }.
|
|
52
|
+
*/
|
|
53
|
+
function createTracker(first, w, h, box, opts = {}) {
|
|
54
|
+
const gw = Math.max(6, Math.min(GRID, Math.round(box.w))), gh = Math.max(6, Math.min(GRID, Math.round(box.h)));
|
|
55
|
+
const sx = box.w / gw, sy = box.h / gh, n = gw * gh;
|
|
56
|
+
// template offsets around the centre, before scale / rotation
|
|
57
|
+
const ox = new Float32Array(n), oy = new Float32Array(n);
|
|
58
|
+
for (let j = 0, k = 0; j < gh; j++) for (let i = 0; i < gw; i++, k++) { ox[k] = (i - (gw - 1) / 2) * sx; oy[k] = (j - (gh - 1) / 2) * sy; }
|
|
59
|
+
const coarse = []; // every other sample, for the wide first pass
|
|
60
|
+
for (let j = 0; j < gh; j += 2) for (let i = 0; i < gw; i += 2) coarse.push(j * gw + i);
|
|
61
|
+
const idxAll = Array.from({ length: n }, (_, k) => k);
|
|
62
|
+
const buf = new Float32Array(n);
|
|
63
|
+
|
|
64
|
+
function grab(img, cx, cy, s, r, idx, out) {
|
|
65
|
+
const cs = Math.cos(r) * s, sn = Math.sin(r) * s;
|
|
66
|
+
for (let m = 0; m < idx.length; m++) {
|
|
67
|
+
const k = idx[m];
|
|
68
|
+
out[m] = sample(img, w, h, cx + ox[k] * cs - oy[k] * sn, cy + ox[k] * sn + oy[k] * cs);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** Zero-mean, unit-norm copy (null when flat). */
|
|
73
|
+
function normed(v, len) {
|
|
74
|
+
let mean = 0;
|
|
75
|
+
for (let i = 0; i < len; i++) mean += v[i];
|
|
76
|
+
mean /= len;
|
|
77
|
+
let ss = 0;
|
|
78
|
+
const out = new Float32Array(len);
|
|
79
|
+
for (let i = 0; i < len; i++) { out[i] = v[i] - mean; ss += out[i] * out[i]; }
|
|
80
|
+
if (ss < 1e-3 * len) return null;
|
|
81
|
+
const k = 1 / Math.sqrt(ss);
|
|
82
|
+
for (let i = 0; i < len; i++) out[i] *= k;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function ncc(tn, v, len) {
|
|
86
|
+
let sv = 0, sv2 = 0, st = 0;
|
|
87
|
+
for (let i = 0; i < len; i++) { const x = v[i]; sv += x; sv2 += x * x; st += tn[i] * x; }
|
|
88
|
+
const varv = sv2 - (sv * sv) / len;
|
|
89
|
+
return varv > 1e-6 ? st / Math.sqrt(varv) : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const raw0 = Float32Array.from(grab(first, box.cx, box.cy, 1, 0, idxAll, buf));
|
|
93
|
+
let raw = Float32Array.from(raw0);
|
|
94
|
+
const pick = (src, idx) => Float32Array.from(idx, (k) => src[k]);
|
|
95
|
+
let tAll = normed(raw, n), tCoarse = normed(pick(raw, coarse), coarse.length);
|
|
96
|
+
const flat = !tAll || !tCoarse;
|
|
97
|
+
let cx = box.cx, cy = box.cy, s = 1, r = 0, vx = 0, vy = 0;
|
|
98
|
+
const R = opts.search || Math.max(12, Math.round(0.8 * Math.max(box.w, box.h)));
|
|
99
|
+
const cbuf = new Float32Array(coarse.length);
|
|
100
|
+
|
|
101
|
+
function score(px, py, ps, pr, fine) {
|
|
102
|
+
return fine ? ncc(tAll, grab(cur, px, py, ps, pr, idxAll, buf), n) : ncc(tCoarse, grab(cur, px, py, ps, pr, coarse, cbuf), coarse.length);
|
|
103
|
+
}
|
|
104
|
+
let cur = first;
|
|
105
|
+
|
|
106
|
+
function step(frame) {
|
|
107
|
+
if (flat) return { cx, cy, s, r, q: 0, lost: true };
|
|
108
|
+
cur = frame;
|
|
109
|
+
const px = cx + vx * 0.7, py = cy + vy * 0.7;
|
|
110
|
+
// 1. wide pass on the coarse grid
|
|
111
|
+
let best = { x: px, y: py, q: -2 };
|
|
112
|
+
const st = Math.max(1, Math.round(Math.min(sx, sy) * 1.5));
|
|
113
|
+
for (let dy = -R; dy <= R; dy += st) for (let dx = -R; dx <= R; dx += st) {
|
|
114
|
+
const q = score(px + dx, py + dy, s, r, false);
|
|
115
|
+
if (q > best.q) best = { x: px + dx, y: py + dy, q };
|
|
116
|
+
}
|
|
117
|
+
// 2. full grid, one-pixel steps around it
|
|
118
|
+
let fb = { x: best.x, y: best.y, s, r, q: -2 };
|
|
119
|
+
const look = (x, y, ss, rr) => { const q = score(x, y, ss, rr, true); if (q > fb.q) fb = { x, y, s: ss, r: rr, q }; return q; };
|
|
120
|
+
for (let dy = -st; dy <= st; dy++) for (let dx = -st; dx <= st; dx++) look(best.x + dx, best.y + dy, s, r);
|
|
121
|
+
// 3. scale / rotation, then the position again
|
|
122
|
+
if (opts.scale || opts.rotation) {
|
|
123
|
+
for (const [fs, fr] of [[1.03, 0.035], [1.01, 0.012], [1.004, 0.005]]) { // coarse to fine
|
|
124
|
+
const c0 = fb;
|
|
125
|
+
for (const ds of opts.scale ? [1 / fs, 1, fs] : [1]) for (const dr of opts.rotation ? [-fr, 0, fr] : [0])
|
|
126
|
+
if (ds !== 1 || dr !== 0) look(c0.x, c0.y, c0.s * ds, c0.r + dr);
|
|
127
|
+
const c1 = fb;
|
|
128
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx || dy) look(c1.x + dx, c1.y + dy, c1.s, c1.r);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// 4. sub-pixel: a parabola through the neighbours on each axis
|
|
132
|
+
const q0 = fb.q;
|
|
133
|
+
const qx1 = score(fb.x - 1, fb.y, fb.s, fb.r, true), qx2 = score(fb.x + 1, fb.y, fb.s, fb.r, true);
|
|
134
|
+
const qy1 = score(fb.x, fb.y - 1, fb.s, fb.r, true), qy2 = score(fb.x, fb.y + 1, fb.s, fb.r, true);
|
|
135
|
+
const sub = (a, b) => { const d = a - 2 * q0 + b; return d < -1e-6 ? clamp(0.5 * (a - b) / d, -0.5, 0.5) : 0; };
|
|
136
|
+
const nx = fb.x + sub(qx1, qx2), ny = fb.y + sub(qy1, qy2);
|
|
137
|
+
const lost = fb.q < MIN_SCORE || nx < 0 || ny < 0 || nx >= w || ny >= h;
|
|
138
|
+
if (lost) return { cx, cy, s, r, q: Math.max(0, fb.q), lost: true };
|
|
139
|
+
vx = nx - cx; vy = ny - cy; cx = nx; cy = ny; s = fb.s; r = fb.r;
|
|
140
|
+
// keep the template fresh on good matches, anchored to the first frame so it does not drift
|
|
141
|
+
if (fb.q > 0.8) {
|
|
142
|
+
const now = grab(cur, cx, cy, s, r, idxAll, new Float32Array(n));
|
|
143
|
+
for (let i = 0; i < n; i++) raw[i] = 0.5 * raw0[i] + 0.35 * raw[i] + 0.15 * now[i];
|
|
144
|
+
const a = normed(raw, n), b = normed(pick(raw, coarse), coarse.length);
|
|
145
|
+
if (a && b) { tAll = a; tCoarse = b; }
|
|
146
|
+
}
|
|
147
|
+
return { cx, cy, s, r: r * 180 / Math.PI, q: fb.q, lost: false };
|
|
148
|
+
}
|
|
149
|
+
return { step, flat };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ── Stored tracks ── */
|
|
153
|
+
function normalizeTracks(list, strict = false) {
|
|
154
|
+
if (list == null) return null;
|
|
155
|
+
const bad = (msg) => { if (strict) throw new Error(msg); };
|
|
156
|
+
if (!Array.isArray(list)) { bad("tracks must be a list"); return null; }
|
|
157
|
+
if (list.length > MAX_TRACKS) bad(`tracks: at most ${MAX_TRACKS}`);
|
|
158
|
+
const out = [], names = new Set();
|
|
159
|
+
list.slice(0, MAX_TRACKS).forEach((tr, i) => {
|
|
160
|
+
const where = `tracks[${i}]`;
|
|
161
|
+
if (!isObj(tr)) { bad(`${where} must be an object`); return; }
|
|
162
|
+
const name = typeof tr.name === "string" && tr.name.trim() ? tr.name.trim().slice(0, 60) : `Track ${i + 1}`;
|
|
163
|
+
if (names.has(name)) { bad(`${where}: the name "${name}" is used twice`); return; }
|
|
164
|
+
if (tr.kind != null && !KINDS.includes(tr.kind)) bad(`${where}.kind must be ${KINDS.join(" | ")}`);
|
|
165
|
+
const samples = [];
|
|
166
|
+
for (const sm of Array.isArray(tr.samples) ? tr.samples.slice(0, MAX_SAMPLES) : []) {
|
|
167
|
+
if (!Array.isArray(sm) || sm.length < 3 || !sm.slice(0, 3).every(isNum)) { bad(`${where}.samples: each is [t, x, y, s?, r?, q?]`); continue; }
|
|
168
|
+
const [t, x, y, s = 1, r = 0, q = 1] = sm;
|
|
169
|
+
samples.push([r4(Math.max(0, t)), r4(x), r4(y), r4(isNum(s) && s > 0 ? s : 1), r4(isNum(r) ? r : 0), r4(isNum(q) ? clamp(q, 0, 1) : 1)]);
|
|
170
|
+
}
|
|
171
|
+
samples.sort((a, b) => a[0] - b[0]);
|
|
172
|
+
const dedup = samples.filter((sm, j) => j === samples.length - 1 || samples[j + 1][0] !== sm[0]);
|
|
173
|
+
if (!dedup.length) { bad(`${where} has no samples`); return; }
|
|
174
|
+
names.add(name);
|
|
175
|
+
out.push({
|
|
176
|
+
name, kind: KINDS.includes(tr.kind) ? tr.kind : "point",
|
|
177
|
+
w: r4(clamp(isNum(tr.w) ? tr.w : 0.05, 0.001, 4)), h: r4(clamp(isNum(tr.h) ? tr.h : 0.05, 0.001, 4)),
|
|
178
|
+
samples: dedup,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
return out.length ? out : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The track's position at clip-local t: { x, y, s, r, q } (linear between samples; held past the ends). */
|
|
185
|
+
function trackAt(tr, t) {
|
|
186
|
+
const S = tr.samples, last = S.length - 1;
|
|
187
|
+
const at = (sm) => ({ x: sm[1], y: sm[2], s: sm[3], r: sm[4], q: sm[5], inside: false });
|
|
188
|
+
if (t <= S[0][0]) return { ...at(S[0]), inside: t >= S[0][0] - 1e-6 };
|
|
189
|
+
if (t >= S[last][0]) return { ...at(S[last]), inside: t <= S[last][0] + 1e-6 };
|
|
190
|
+
let lo = 0, hi = last;
|
|
191
|
+
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (S[mid][0] <= t) lo = mid; else hi = mid; }
|
|
192
|
+
const a = S[lo], b = S[hi], u = (t - a[0]) / Math.max(1e-9, b[0] - a[0]);
|
|
193
|
+
const L = (i) => a[i] + (b[i] - a[i]) * u;
|
|
194
|
+
return { x: L(1), y: L(2), s: L(3), r: L(4), q: Math.min(a[5], b[5]), inside: true };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A clip's head moved by `offset` s: sample times follow; samples now before 0 or after dur go. */
|
|
198
|
+
function shiftTracks(list, offset, dur = Infinity) {
|
|
199
|
+
if (!Array.isArray(list) || !offset) return list;
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const tr of list) {
|
|
202
|
+
const samples = (tr.samples || []).map((sm) => [r4(sm[0] - offset), ...sm.slice(1)]).filter((sm) => sm[0] >= -1e-3 && sm[0] <= dur + 1e-3);
|
|
203
|
+
if (samples.length) out.push({ ...tr, samples });
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Centred moving averages: scale and rotation over ±k frames, position over ±kp (0 = as tracked). */
|
|
209
|
+
function smooth(samples, k = 3, kp = 0) {
|
|
210
|
+
if (samples.length < 3) return samples.slice();
|
|
211
|
+
const avg = (i, col, w) => {
|
|
212
|
+
w = Math.min(w, i, samples.length - 1 - i); // symmetric: no lean at the ends, which stay as tracked
|
|
213
|
+
let v = 0;
|
|
214
|
+
for (let j = i - w; j <= i + w; j++) v += samples[j][col];
|
|
215
|
+
return v / (2 * w + 1);
|
|
216
|
+
};
|
|
217
|
+
return samples.map((sm, i) => [sm[0], kp ? avg(i, 1, kp) : sm[1], kp ? avg(i, 2, kp) : sm[2], avg(i, 3, k), avg(i, 4, k), sm[5]]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The samples worth keying: drops samples a straight line between their
|
|
222
|
+
* neighbours already explains within `tol` (in the units of x / y), 0.4 % of
|
|
223
|
+
* scale or 0.3° of rotation. Ramer–Douglas–Peucker over time.
|
|
224
|
+
*/
|
|
225
|
+
function simplify(samples, tol = 0.001, sTol = 0.004, rTol = 0.3) {
|
|
226
|
+
if (samples.length <= 2) return samples.slice();
|
|
227
|
+
const keep = new Uint8Array(samples.length);
|
|
228
|
+
keep[0] = keep[samples.length - 1] = 1;
|
|
229
|
+
const err = (a, b, m) => {
|
|
230
|
+
const u = (m[0] - a[0]) / Math.max(1e-9, b[0] - a[0]);
|
|
231
|
+
const L = (i) => a[i] + (b[i] - a[i]) * u;
|
|
232
|
+
return Math.max(Math.hypot(m[1] - L(1), m[2] - L(2)) / tol, Math.abs(m[3] - L(3)) / sTol, Math.abs(m[4] - L(4)) / rTol);
|
|
233
|
+
};
|
|
234
|
+
const stack = [[0, samples.length - 1]];
|
|
235
|
+
while (stack.length) {
|
|
236
|
+
const [i, j] = stack.pop();
|
|
237
|
+
let worst = 0, at = -1;
|
|
238
|
+
for (let k = i + 1; k < j; k++) { const e = err(samples[i], samples[j], samples[k]); if (e > worst) { worst = e; at = k; } }
|
|
239
|
+
if (worst > 1 && at > 0) { keep[at] = 1; stack.push([i, at], [at, j]); }
|
|
240
|
+
}
|
|
241
|
+
return samples.filter((_, i) => keep[i]);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function describe(list) {
|
|
245
|
+
if (!list || !list.length) return "";
|
|
246
|
+
return list.map((tr) => {
|
|
247
|
+
const S = tr.samples, a = S[0][0], b = S[S.length - 1][0];
|
|
248
|
+
return `"${tr.name}" ${tr.kind} ${a.toFixed(2)}–${b.toFixed(2)} s (${S.length} samples)`;
|
|
249
|
+
}).join(", ");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { MAX_TRACKS, MAX_SAMPLES, KINDS, MIN_SCORE, toGray, sample, createTracker, normalizeTracks, trackAt, shiftTracks, smooth, simplify, describe };
|
|
253
|
+
});
|