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/color.js CHANGED
@@ -42,16 +42,20 @@
42
42
  layers: [{ name?, on?, qualifier?, mask?, …grade keys }]
43
43
  qualifier: { hue?: [centre°, width°, soft°], sat?: [lo, hi, soft],
44
44
  luma?: [lo, hi, soft], invert? } — measured on the layer's input
45
- mask: { shape: ellipse | rect | poly, x, y (centre, 0…1 of the picture),
45
+ mask: { shape: ellipse | rect | poly | bezier, x, y (centre, 0…1 of the picture),
46
46
  w, h (size, 0…1), rotation°, feather (0…0.5 of the height),
47
47
  invert?, points?: [[dx, dy], …] (poly, relative to x, y),
48
+ bezier: [[dx, dy, inX?, inY?, outX?, outY?], …] — anchors with
49
+ handles, the outline mask.js draws (a free-hand stroke,
50
+ { shape: "freehand", stroke: [[u, v], …] }, is fitted into one),
48
51
  keys?: [{ t, ease?, x?, y?, w?, h?, rotation?, feather? }] }
49
52
  matte = qualifier × mask (each 1 when absent); out = mix(in, graded, matte). */
50
53
  (function (root, factory) {
51
- const api = factory();
52
- if (typeof module === "object" && module.exports) module.exports = api;
54
+ const node = typeof module === "object" && module.exports;
55
+ const api = factory(node ? require("./mask") : root.FableCutMask);
56
+ if (node) module.exports = api;
53
57
  else root.FableCutColor = api;
54
- })(typeof self !== "undefined" ? self : this, function () {
58
+ })(typeof self !== "undefined" ? self : this, function (MaskLib) {
55
59
  "use strict";
56
60
 
57
61
  const WHEELS = ["lift", "gamma", "gain", "offset"];
@@ -91,7 +95,9 @@
91
95
  const HUE_N = 360; // hue-curve lookup-table size (also sat 0…1 for satLuma)
92
96
  const MAX_LAYERS = 8;
93
97
  const MAX_POLY = 16;
94
- const MASK_SHAPES = ["ellipse", "rect", "poly"];
98
+ const MAX_BEZIER = 24; // bezier anchors on a grade mask
99
+ const MAX_OUTLINE = 96; // the flattened outline the shader walks
100
+ const MASK_SHAPES = ["ellipse", "rect", "poly", "bezier"];
95
101
  const MASK_KEYED = ["x", "y", "w", "h", "rotation", "feather"];
96
102
  const KEY_RANGE = { x: [-1, 2], y: [-1, 2], w: [0.001, 4], h: [0.001, 4], rotation: [-720, 720], feather: [0, 0.5] };
97
103
  const WB_STOPS = 1 / 100; // temp / tint ±100 → ±1 stop per channel
@@ -487,6 +493,12 @@
487
493
  if (m == null) return null;
488
494
  const bad = (msg) => { if (strict) throw new Error(`${where}.mask${msg}`); return null; };
489
495
  if (typeof m !== "object" || Array.isArray(m)) return bad(" must be {shape, x, y, w, h, rotation?, feather?, invert?}");
496
+ if (m.shape === "freehand") {
497
+ if (!Array.isArray(m.stroke) || m.stroke.length < 3 || m.stroke.some((p) => !Array.isArray(p) || !isNum(p[0]) || !isNum(p[1])))
498
+ return bad(".stroke must be at least 3 [u, v] points (fractions of the picture)");
499
+ m = { ...m, ...MaskLib.fitStroke(m.stroke, m.aspect, MAX_BEZIER), shape: "bezier" };
500
+ delete m.stroke; delete m.aspect;
501
+ }
490
502
  const shape = m.shape == null ? "ellipse" : m.shape;
491
503
  if (!MASK_SHAPES.includes(shape)) return bad(`.shape must be ${MASK_SHAPES.join(" | ")}`);
492
504
  for (const k of Object.keys(m)) if (![...MASK_KEYED, "shape", "invert", "points", "keys"].includes(k)) bad(`.${k}: unknown key`);
@@ -509,6 +521,11 @@
509
521
  if (!Array.isArray(pts) || pts.length < 3 || pts.length > MAX_POLY || pts.some((p) => !Array.isArray(p) || p.length !== 2 || !p.every(isNum)))
510
522
  return bad(`.points must be 3…${MAX_POLY} [dx, dy] pairs around x, y (fractions of the picture)`);
511
523
  out.points = pts.map(([a, b]) => [+clamp(a, -2, 2).toFixed(4), +clamp(b, -2, 2).toFixed(4)]);
524
+ } else if (shape === "bezier") {
525
+ const pts = m.points;
526
+ if (!Array.isArray(pts) || pts.length < 3 || pts.length > MAX_BEZIER || pts.some((p) => !Array.isArray(p) || (p.length !== 2 && p.length !== 6) || !p.every(isNum)))
527
+ return bad(`.points must be 3…${MAX_BEZIER} anchors [dx, dy] or [dx, dy, inX, inY, outX, outY] around x, y (fractions of the picture)`);
528
+ out.points = pts.map((p) => p.map((v) => +clamp(v, -2, 2).toFixed(4)));
512
529
  }
513
530
  if (m.keys != null) {
514
531
  if (!Array.isArray(m.keys)) return bad(".keys must be a list of {t, x?, y?, w?, h?, rotation?, feather?}");
@@ -596,10 +613,26 @@
596
613
  const qx = Math.abs(px) - rx, qy = Math.abs(py) - ry;
597
614
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0);
598
615
  }
599
- if (m.shape === "poly") return polyDist(m.points.map(([a, b]) => [a * aspect, b]), px, py);
616
+ if (m.shape === "poly" || m.shape === "bezier") return polyDist(outlineAt(m, aspect), px, py);
600
617
  const k0 = Math.hypot(px / rx, py / ry), k1 = Math.hypot(px / (rx * rx), py / (ry * ry));
601
618
  return k0 < 1e-6 ? -Math.min(rx, ry) : k0 * (k0 - 1) / k1;
602
619
  }
620
+ /** A bezier mask's outline as a polygon around its centre (fractions of the
621
+ * picture, unrotated) — at most MAX_OUTLINE corners, the shader's budget. */
622
+ /** The poly / bezier outline in aspect-scaled units, cached per points array — the CPU path asks per pixel. */
623
+ const outlines = new WeakMap();
624
+ function outlineAt(m, aspect) {
625
+ let o = outlines.get(m.points);
626
+ if (!o || o.aspect !== aspect) {
627
+ o = { aspect, pts: (m.shape === "bezier" ? bezierOutline(m) : m.points).map(([a, b]) => [a * aspect, b]) };
628
+ outlines.set(m.points, o);
629
+ }
630
+ return o.pts;
631
+ }
632
+ function bezierOutline(m) {
633
+ const A = MaskLib.anchors({ shape: "bezier", x: 0, y: 0, scale: 1, rotation: 0, points: m.points }, 1, 1);
634
+ return MaskLib.flatten(A, Math.max(1, Math.min(16, Math.floor(MAX_OUTLINE / A.length))));
635
+ }
603
636
  function polyDist(pts, px, py) {
604
637
  let d = (px - pts[0][0]) ** 2 + (py - pts[0][1]) ** 2, s = 1;
605
638
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i++) {
@@ -757,10 +790,10 @@
757
790
  u.uMRot = m ? m.rotation * Math.PI / 180 : 0;
758
791
  u.uMFeather = m ? Math.max(0.002, m.feather) : 0.002;
759
792
  u.uMInv = m && m.invert ? 1 : 0;
760
- const pts = new Float32Array(MAX_POLY * 2);
761
- if (m && m.points) m.points.forEach(([a, b], i) => { pts[i * 2] = a; pts[i * 2 + 1] = b; });
793
+ const pts = new Float32Array(MAX_OUTLINE * 2), P = m && m.points ? (m.shape === "bezier" ? bezierOutline(m) : m.points) : [];
794
+ P.forEach(([a, b], i) => { pts[i * 2] = a; pts[i * 2 + 1] = b; });
762
795
  u.uMPts = pts;
763
- u.uMN = m && m.points ? m.points.length : 0;
796
+ u.uMN = P.length;
764
797
  passes.push(u);
765
798
  }
766
799
  return passes;
@@ -800,10 +833,10 @@ uniform int uLayer, uMatte, uPremul; // layer pass: mix by its matte · show t
800
833
  uniform int uQOn, uQInv;
801
834
  uniform ivec3 uQUse; // hue, sat, luma ranges in use
802
835
  uniform vec3 uQHue, uQSat, uQLuma; // hue: centre, half width, soft (turns) · sat / luma: low, high, soft
803
- uniform int uMShape, uMInv, uMN; // 0 none · 1 ellipse · 2 rect · 3 poly
836
+ uniform int uMShape, uMInv, uMN; // 0 none · 1 ellipse · 2 rect · 3 poly · 4 bezier (flattened)
804
837
  uniform vec2 uMC, uMW;
805
838
  uniform float uMRot, uMFeather, uAspect;
806
- uniform vec2 uMPts[${MAX_POLY}];
839
+ uniform vec2 uMPts[${MAX_OUTLINE}];
807
840
  in vec2 vPos;
808
841
  out vec4 outColor;
809
842
  vec4 lutAt(sampler2D t, int n, float x) {
@@ -862,10 +895,10 @@ float maskDist(vec2 uv) {
862
895
  vec2 q = abs(p) - r;
863
896
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
864
897
  }
865
- if (uMShape == 3) {
898
+ if (uMShape >= 3) {
866
899
  vec2 v0 = uMPts[0] * vec2(uAspect, 1.0);
867
900
  float dd = dot(p - v0, p - v0), s = 1.0;
868
- for (int i = 0; i < ${MAX_POLY}; i++) {
901
+ for (int i = 0; i < ${MAX_OUTLINE}; i++) {
869
902
  if (i >= uMN) break;
870
903
  int j = i == 0 ? uMN - 1 : i - 1;
871
904
  vec2 vi = uMPts[i] * vec2(uAspect, 1.0), vj = uMPts[j] * vec2(uAspect, 1.0);
@@ -1034,7 +1067,7 @@ void main() {
1034
1067
  GRADE_PARAMS, GRADE_KEYS, WHEELS, GRADE_VERT, GRADE_FRAG, SKIN_ANGLE,
1035
1068
  CURVE_CHANNELS, HUE_CURVES, HUE_KEYS, CURVE_N, HUE_N, BAND,
1036
1069
  normalizeGrade, fullGrade, isNeutral, mergeGrade, summarizeGrade, withoutCurves,
1037
- MAX_LAYERS, MAX_POLY, MASK_SHAPES, MASK_KEYED, mergeLayer, layerGrade, maskAt, gradeAt, qualAlpha, maskAlpha, maskDist, gradePasses,
1070
+ MAX_LAYERS, MAX_POLY, MAX_BEZIER, MASK_SHAPES, MASK_KEYED, mergeLayer, layerGrade, maskAt, gradeAt, qualAlpha, maskAlpha, maskDist, gradePasses,
1038
1071
  monotone, curveFn, rgbToHsv, hsvToRgb,
1039
1072
  wbGains, solveWhiteBalance, srgbToLinear, linearToSrgb, wheelToRgb, rgbToWheel, ycbcr,
1040
1073
  prepareGrade, gradePixel, gradeImageData, gradeUniforms,
package/comp.js ADDED
@@ -0,0 +1,195 @@
1
+ /* FableCut compositions — nested sequences. Zero dependencies beyond edit-ops
2
+ (for re-basing keyframes). Loaded by the editor as a plain script (global
3
+ `FableCutComp`) and required by the MCP server and the tests.
4
+
5
+ A composition lives in `project.comps`:
6
+ { id, name, width, height, duration, background?, tracks?: [{id, kind}], clips: [ …clips… ] }
7
+ Its clips are ordinary clips (any kind, comp clips too) on the comp's own
8
+ tracks, with times inside the comp. A clip of kind "comp" places one on a
9
+ timeline: { kind: "comp", compId, start, in, duration, props… } — `in` is
10
+ where in the comp it starts. It draws like a picture of the comp's size (so
11
+ it moves, scales, masks, grades and blends like an image), and the media in
12
+ it — video frames, audio — plays and exports as if it were on the timeline.
13
+
14
+ flatten() turns every comp clip's contents into "virtual" clips on the main
15
+ timeline: ids "<compClip>><clip>", start shifted to timeline time, trimmed
16
+ to the comp clip's window, keyframes / masks / shape keys re-based, parents
17
+ and links renamed into the same instance. Playback, export seeking, the
18
+ audio mix and the comp renderer all work from that list. */
19
+ (function (root, factory) {
20
+ const node = typeof module === "object" && module.exports;
21
+ const api = factory(node ? require("./edit-ops") : root.FableCutEdit);
22
+ if (node) module.exports = api;
23
+ else root.FableCutComp = api;
24
+ })(typeof self !== "undefined" ? self : this, function (EditLib) {
25
+ "use strict";
26
+
27
+ const MAX_DEPTH = 4;
28
+ const SEP = ">";
29
+ const DEFAULT_TRACKS = [
30
+ { id: "V3", kind: "video" }, { id: "V2", kind: "video" }, { id: "V1", kind: "video" },
31
+ { id: "A1", kind: "audio" }, { id: "A2", kind: "audio" },
32
+ ];
33
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
34
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
35
+ const r4 = (v) => +(+v).toFixed(4);
36
+ const compTracks = (comp) => (Array.isArray(comp.tracks) && comp.tracks.length ? comp.tracks : DEFAULT_TRACKS);
37
+
38
+ /** A comp, validated. strict: throw on bad input (agent). doc: for width / height defaults. */
39
+ function normalizeComp(c, strict = false, doc = {}) {
40
+ const bad = (m) => { if (strict) throw new Error(m); return null; };
41
+ if (!isObj(c)) return bad("a comp must be an object like {id, name, duration, clips:[…]}");
42
+ if (typeof c.id !== "string" || !c.id.trim()) return bad("a comp needs an id");
43
+ const out = { ...c, id: c.id.trim() };
44
+ out.name = typeof c.name === "string" && c.name.trim() ? c.name.trim().slice(0, 80) : out.id;
45
+ for (const [k, def] of [["width", doc.width || 1280], ["height", doc.height || 720]]) {
46
+ const v = c[k] == null ? def : c[k];
47
+ if (!Number.isInteger(v) || v < 16 || v > 8192) { bad(`comp ${out.id}: ${k} must be a whole number of px (16…8192)`); out[k] = def; }
48
+ else out[k] = v;
49
+ }
50
+ if (!isNum(c.duration) || c.duration <= 0) return bad(`comp ${out.id}: duration must be a number of seconds > 0`);
51
+ out.duration = r4(c.duration);
52
+ if (c.background != null && typeof c.background !== "string") bad(`comp ${out.id}: background is a colour string (or leave it out for transparent)`);
53
+ if (c.tracks != null) {
54
+ if (!Array.isArray(c.tracks) || c.tracks.some((t) => !isObj(t) || typeof t.id !== "string" || !["video", "audio"].includes(t.kind)))
55
+ bad(`comp ${out.id}: tracks is a list of {id, kind:"video"|"audio"}`);
56
+ }
57
+ if (c.clips != null && !Array.isArray(c.clips)) return bad(`comp ${out.id}: clips must be a list`);
58
+ out.clips = Array.isArray(c.clips) ? c.clips : [];
59
+ for (const x of out.clips) {
60
+ if (!isObj(x) || typeof x.id !== "string" || !isNum(x.start) || !isNum(x.duration))
61
+ return bad(`comp ${out.id}: every clip needs id, numeric start and duration`);
62
+ }
63
+ return out;
64
+ }
65
+ function getComp(doc, id) { return (doc && Array.isArray(doc.comps) ? doc.comps : []).find((c) => c.id === id) || null; }
66
+ /** Comp ids that comp `id` uses, directly or through nested comps. */
67
+ function usedComps(doc, id, out = new Set(), depth = 0) {
68
+ const comp = getComp(doc, id);
69
+ if (!comp || depth > 32) return out;
70
+ for (const c of comp.clips || []) if (c.kind === "comp" && c.compId && !out.has(c.compId)) { out.add(c.compId); usedComps(doc, c.compId, out, depth + 1); }
71
+ return out;
72
+ }
73
+ /** Would placing comp `childId` inside comp `parentId` (null = the main timeline) make a loop? */
74
+ function wouldCycle(doc, parentId, childId) {
75
+ if (!parentId) return false;
76
+ if (parentId === childId) return true;
77
+ return usedComps(doc, childId).has(parentId);
78
+ }
79
+ /** Where comp `id` is used: [{in: compId | null (main), clip}] */
80
+ function usages(doc, id) {
81
+ const out = [];
82
+ for (const c of doc.clips || []) if (c.kind === "comp" && c.compId === id) out.push({ in: null, clip: c });
83
+ for (const comp of doc.comps || []) for (const c of comp.clips || []) if (c.kind === "comp" && c.compId === id) out.push({ in: comp.id, clip: c });
84
+ return out;
85
+ }
86
+
87
+ /* ── Flattening ── */
88
+ const clipEnd = (c) => c.start + c.duration;
89
+ const speedOf = (c) => (c.props && +c.props.speed) || 1;
90
+ /** Keyframe times re-based by -offset, every key kept (keys before 0 still shape the curve). */
91
+ function shiftAll(kfs, offset) {
92
+ if (!kfs) return kfs;
93
+ const out = {};
94
+ for (const [k, arr] of Object.entries(kfs)) out[k] = Array.isArray(arr) ? arr.map((kf) => ({ ...kf, t: r4(kf.t - offset) })) : arr;
95
+ return out;
96
+ }
97
+ /** A comp's clip as a clip on the timeline: shifted by `offset`, trimmed to `win`, renamed under `prefix`. */
98
+ function virtualize(inner, prefix, offset, win, inst, on) {
99
+ const start = inner.start + offset, end = start + inner.duration;
100
+ const a = Math.max(start, win[0]), b = Math.min(end, win[1]);
101
+ if (b - a <= 1e-6) return null;
102
+ const head = a - start;
103
+ const v = { ...inner, id: prefix + inner.id, start: r4(a), duration: r4(b - a), _comp: inst, _src: inner.id, _on: on && inner.disabled !== true };
104
+ if (inner.props) v.props = { ...inner.props };
105
+ if (head > 1e-6) {
106
+ const shifted = { ...inner, start };
107
+ if (inner.kind === "video" || inner.kind === "audio") v.in = r4(EditLib.mediaTimeAt(shifted, a));
108
+ else if (inner.kind === "comp") v.in = r4((inner.in || 0) + head);
109
+ v.keyframes = shiftAll(inner.keyframes, head);
110
+ v.transitionIn = undefined;
111
+ if (v.props) EditLib.shiftMasks(v, head);
112
+ }
113
+ if (end - b > 1e-6) v.transitionOut = undefined;
114
+ if (inner.parent) v.parent = prefix + inner.parent;
115
+ if (inner.linkGroup) v.linkGroup = prefix + inner.linkGroup;
116
+ if (inner.linkedId) v.linkedId = prefix + inner.linkedId;
117
+ return v;
118
+ }
119
+ /** Every clip the comp clips in `clips` bring onto the timeline, depth first:
120
+ * [{…virtual clip, _comp: the comp clip's id (its instance), _on: renders}]. */
121
+ function flatten(doc, clips = doc.clips, opts = {}) {
122
+ const out = [];
123
+ const walk = (list, depth, seen, renders) => {
124
+ for (const c of list || []) {
125
+ if (c.kind !== "comp") continue;
126
+ const comp = getComp(doc, c.compId);
127
+ if (!comp || seen.has(comp.id) || depth >= MAX_DEPTH) continue;
128
+ const on = renders(c);
129
+ const offset = c.start - (c.in || 0), win = [c.start, Math.min(clipEnd(c), c.start - (c.in || 0) + comp.duration)];
130
+ const disabled = new Set(comp.disabledTracks || []);
131
+ const kids = [];
132
+ for (const inner of comp.clips || []) {
133
+ const v = virtualize(inner, c.id + SEP, offset, win, c.id, on && !disabled.has(inner.track));
134
+ if (v) { kids.push(v); out.push(v); }
135
+ }
136
+ const next = new Set(seen); next.add(comp.id);
137
+ walk(kids, depth + 1, next, (x) => x._on);
138
+ }
139
+ };
140
+ walk(clips, 0, new Set(opts.inComp ? [opts.inComp] : []), opts.renders || ((c) => c.disabled !== true));
141
+ return out;
142
+ }
143
+ /** The tracks a comp draws in, bottom first (V1 under V2 …), and its audio tracks. */
144
+ function drawOrder(comp) {
145
+ const tr = compTracks(comp);
146
+ return { video: tr.filter((t) => t.kind === "video").map((t) => t.id).reverse(), audio: tr.filter((t) => t.kind === "audio").map((t) => t.id) };
147
+ }
148
+
149
+ /* ── Precompose ── */
150
+ /** Move `ids` (clips on the main timeline or in comp `from`) into a new comp; put a comp clip
151
+ * in their place. Returns { comp, clip }. Times in the comp start at the earliest clip. */
152
+ function precompose(doc, ids, { id, name, from = null, track } = {}) {
153
+ const host = from ? getComp(doc, from) : doc;
154
+ if (!host) throw new Error(`precompose: no comp ${from}`);
155
+ const set = new Set(ids);
156
+ const moving = (host.clips || []).filter((c) => set.has(c.id));
157
+ if (!moving.length) throw new Error("precompose: none of those clips are there");
158
+ const t0 = Math.min(...moving.map((c) => c.start)), t1 = Math.max(...moving.map(clipEnd));
159
+ const hostTracks = from ? compTracks(host) : (Array.isArray(doc.tracks) && doc.tracks.length ? doc.tracks : DEFAULT_TRACKS);
160
+ const used = [...new Set(moving.map((c) => c.track))];
161
+ const tracks = hostTracks.filter((t) => used.includes(t.id)).map((t) => ({ id: t.id, kind: t.kind }));
162
+ const compId = id || "comp_" + Math.random().toString(36).slice(2, 8);
163
+ if (getComp(doc, compId)) throw new Error(`precompose: comp ${compId} already exists`);
164
+ const comp = {
165
+ id: compId, name: name || "Comp " + ((doc.comps || []).length + 1),
166
+ width: from ? host.width : (doc.width || 1280), height: from ? host.height : (doc.height || 720),
167
+ duration: r4(t1 - t0), tracks,
168
+ clips: moving.map((c) => ({ ...c, start: r4(c.start - t0) })),
169
+ };
170
+ // a hidden track stays hidden inside the comp
171
+ const hidden = (host.disabledTracks || []).filter((t) => used.includes(t));
172
+ if (hidden.length) comp.disabledTracks = hidden;
173
+ // parents outside the moved set can't follow into the comp
174
+ for (const c of comp.clips) if (c.parent && !set.has(c.parent)) delete c.parent;
175
+ const videoTracks = moving.filter((c) => c.kind !== "audio").map((c) => c.track);
176
+ const lane = track || videoTracks.sort((a, b) => (parseInt(a.slice(1), 10) || 0) - (parseInt(b.slice(1), 10) || 0))[0] || "V1";
177
+ const clip = { id: "c_" + Math.random().toString(36).slice(2, 9), kind: "comp", compId, mediaId: null, track: lane, start: r4(t0), in: 0, duration: comp.duration, name: comp.name, props: { fit: "none" } };
178
+ host.clips = host.clips.filter((c) => !set.has(c.id));
179
+ host.clips.push(clip);
180
+ // clips that were parented to a moved clip now follow nothing
181
+ for (const c of host.clips) if (c.parent && set.has(c.parent)) delete c.parent;
182
+ doc.comps = [...(doc.comps || []), comp];
183
+ return { comp, clip };
184
+ }
185
+
186
+ /** One line per comp for status readouts. */
187
+ function describe(comp) {
188
+ const kinds = {};
189
+ for (const c of comp.clips || []) kinds[c.kind] = (kinds[c.kind] || 0) + 1;
190
+ return `${comp.id} "${comp.name}" ${comp.width}x${comp.height} ${comp.duration}s, ${(comp.clips || []).length} clips` +
191
+ (Object.keys(kinds).length ? ` (${Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(", ")})` : "");
192
+ }
193
+
194
+ return { MAX_DEPTH, SEP, DEFAULT_TRACKS, normalizeComp, getComp, usedComps, wouldCycle, usages, compTracks, virtualize, flatten, drawOrder, precompose, describe };
195
+ });
package/edit-ops.js CHANGED
@@ -19,10 +19,12 @@
19
19
  `forDoc(doc, opts)` builds the env from the document itself (its tracks,
20
20
  lockedTracks and untargetedTracks) for headless callers. */
21
21
  (function (root, factory) {
22
- const api = factory();
23
- if (typeof module === "object" && module.exports) module.exports = api;
22
+ const node = typeof module === "object" && module.exports;
23
+ const api = factory(node ? require("./mask") : root.FableCutMask, node ? require("./tracker") : root.FableCutTracker,
24
+ node ? require("./motion") : root.FableCutMotion);
25
+ if (node) module.exports = api;
24
26
  else root.FableCutEdit = api;
25
- })(typeof self !== "undefined" ? self : this, function () {
27
+ })(typeof self !== "undefined" ? self : this, function (MaskLib, TrackLib, MotionLib) {
26
28
  "use strict";
27
29
 
28
30
  const MIN_DUR = 0.05;
@@ -40,8 +42,10 @@
40
42
  /* ── Pure clip helpers ── */
41
43
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
42
44
  const clipEnd = (c) => c.start + c.duration;
43
- const clipSpeed = (c) => clamp(+(c.props?.speed) || 1, 0.1, 8);
45
+ const clipSpeed = (c) => (c.kind === "comp" ? 1 : clamp(+(c.props?.speed) || 1, 0.1, 8));
44
46
  const isMediaClip = (c) => c.kind === "video" || c.kind === "audio";
47
+ /** Clips with a source window (`in` into something with a length): media, and comp clips (into their comp). */
48
+ const hasSourceTime = (c) => isMediaClip(c) || c.kind === "comp";
45
49
  const EASE = {
46
50
  linear: (u) => u,
47
51
  "ease-in": (u) => u * u,
@@ -64,7 +68,7 @@
64
68
  return fallback;
65
69
  }
66
70
  function hasSpeedRamp(c) {
67
- return Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0;
71
+ return c.kind !== "comp" && Array.isArray(c.keyframes?.speed) && c.keyframes.speed.length > 0; // comps run at 1×
68
72
  }
69
73
  /** Source time at timeline time t: in + ∫speed (trapezoid, 1/120 s steps). */
70
74
  function mediaTimeAt(c, t) {
@@ -92,6 +96,42 @@
92
96
  }
93
97
  return Object.keys(out).length ? out : undefined;
94
98
  }
99
+ /** A clip's head moved by `offset` s: its mask keys, shape keys and track samples follow, like shiftKF does for keyframes. */
100
+ function shiftMasks(c, offset) {
101
+ if (!offset || !c.props) return;
102
+ const ms = c.props.masks, ts = c.props.tracks, ss = c.props.shapes;
103
+ if (MotionLib && Array.isArray(ss)) c.props = { ...c.props, shapes: MotionLib.shiftKeys(ss, offset) };
104
+ if (MaskLib && Array.isArray(ms)) {
105
+ const next = MaskLib.shiftKeys(ms, offset);
106
+ if (next !== ms) c.props = { ...c.props, masks: next };
107
+ }
108
+ if (TrackLib && Array.isArray(ts)) {
109
+ const next = TrackLib.shiftTracks(ts, offset);
110
+ c.props = { ...c.props };
111
+ if (next.length) c.props.tracks = next; else delete c.props.tracks;
112
+ }
113
+ }
114
+ /** Clips are leaving the document: their children stay where they are. Each child is
115
+ * re-parented to the nearest ancestor that stays (or to nothing), with its static
116
+ * position / rotation / scale rewritten to hold its place at its own start. Keyed
117
+ * channels keep their keys. Call it before the clips are removed. */
118
+ function detachChildren(project, goneIds) {
119
+ if (!MotionLib || !goneIds || !goneIds.size) return 0;
120
+ const byId = (id) => project.clips.find((x) => x.id === id) || null;
121
+ let n = 0;
122
+ for (const k of project.clips) {
123
+ if (!k.parent || goneIds.has(k.id) || !goneIds.has(k.parent)) continue;
124
+ let up = byId(k.parent), guard = 0;
125
+ while (up && goneIds.has(up.id) && guard++ < 64) up = up.parent ? byId(up.parent) : null;
126
+ const next = up && !goneIds.has(up.id) ? up.id : null;
127
+ const v = MotionLib.reparentValues(k, next, byId, k.start || 0);
128
+ k.props = { ...(k.props || {}) };
129
+ for (const [p, val] of Object.entries(v)) if (!(k.keyframes && Array.isArray(k.keyframes[p]) && k.keyframes[p].length)) k.props[p] = val;
130
+ if (next) k.parent = next; else delete k.parent;
131
+ n++;
132
+ }
133
+ return n;
134
+ }
95
135
  /** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
96
136
  function defaultPanForChannel(ch) {
97
137
  if (ch === 0) return -1;
@@ -105,9 +145,16 @@
105
145
  const timeAt = env.mediaTimeAt || mediaTimeAt;
106
146
  const removed = (c) => { if (env.onRemove) env.onRemove(c); };
107
147
  const getMedia = (id) => P().media.find((m) => m.id === id);
148
+ /** Length of what c's `in` points into (media or comp), 0 when unknown. */
149
+ const sourceLen = (c) => {
150
+ if (c.kind === "comp") { const k = (P().comps || []).find((x) => x.id === c.compId); return k ? +k.duration || 0 : 0; }
151
+ const m = getMedia(c.mediaId);
152
+ return m ? +m.duration || 0 : 0;
153
+ };
108
154
  const getClip = (id) => P().clips.find((c) => c.id === id) || null;
109
155
  const dropClips = (pred) => {
110
156
  const project = P();
157
+ detachChildren(project, new Set(project.clips.filter(pred).map((x) => x.id)));
111
158
  const keep = [];
112
159
  for (const x of project.clips) { if (pred(x)) removed(x); else keep.push(x); }
113
160
  project.clips = keep;
@@ -190,6 +237,7 @@
190
237
  linkedId: undefined,
191
238
  linkGroup: undefined,
192
239
  };
240
+ shiftMasks(right, cut);
193
241
  c.duration = cut;
194
242
  c.keyframes = shiftKF(c.keyframes, 0, cut);
195
243
  c.transitionOut = undefined;
@@ -270,10 +318,11 @@
270
318
  const dIn = t0 - start;
271
319
  if (dIn > 1e-6) {
272
320
  c.start = t0;
273
- if (isMediaClip(c)) c.in += dIn * clipSpeed(c);
321
+ if (hasSourceTime(c)) c.in += dIn * clipSpeed(c);
274
322
  else c.in = 0;
275
323
  c.duration -= dIn;
276
324
  c.keyframes = shiftKF(c.keyframes, dIn, c.duration);
325
+ shiftMasks(c, dIn);
277
326
  c.transitionIn = undefined;
278
327
  changed++;
279
328
  }
@@ -559,6 +608,7 @@
559
608
  c.start = +t1.toFixed(4);
560
609
  c.transitionIn = undefined;
561
610
  c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
611
+ shiftMasks(c, t1 - oldStart);
562
612
  } else {
563
613
  // Stubs on both sides → effectively inside the window.
564
614
  drop(c);
@@ -595,6 +645,7 @@
595
645
  c.start = +t1.toFixed(4);
596
646
  c.transitionIn = undefined;
597
647
  c.keyframes = shiftKF(c.keyframes, t1 - oldStart, c.duration);
648
+ shiftMasks(c, t1 - oldStart);
598
649
  }
599
650
  }
600
651
  }
@@ -657,10 +708,10 @@
657
708
 
658
709
  /** Longest this clip may run before it runs out of source media. */
659
710
  function maxClipDur(c) {
660
- if (!isMediaClip(c)) return Infinity;
661
- const m = getMedia(c.mediaId);
662
- if (!m || !(m.duration > 0)) return Infinity;
663
- return Math.max(MIN_DUR, (m.duration - c.in) / clipSpeed(c));
711
+ if (!hasSourceTime(c)) return Infinity;
712
+ const len = sourceLen(c);
713
+ if (!(len > 0)) return Infinity;
714
+ return Math.max(MIN_DUR, (len - c.in) / clipSpeed(c));
664
715
  }
665
716
  /* A linked group shares its timing, but a partner may run at a different
666
717
  speed (links are inferred from timing alone), so every source limit is the
@@ -671,7 +722,7 @@
671
722
  }
672
723
  /** How far c's group can pull its head back before any member reaches source 0. */
673
724
  function groupHeadRoom(c) {
674
- return Math.min(...withLinked([c]).map((x) => (isMediaClip(x) ? x.in / clipSpeed(x) : Infinity)));
725
+ return Math.min(...withLinked([c]).map((x) => (hasSourceTime(x) ? x.in / clipSpeed(x) : Infinity)));
675
726
  }
676
727
  /** The clip butting against c's head ("in") or tail ("out") on its track. */
677
728
  function adjacentClip(c, side) {
@@ -707,9 +758,10 @@
707
758
  function trimGroupHead(c, d, moveStart) {
708
759
  for (const x of withLinked([c])) {
709
760
  if (moveStart) x.start = +(x.start + d).toFixed(4);
710
- if (isMediaClip(x)) x.in = +(x.in + d * clipSpeed(x)).toFixed(4);
761
+ if (hasSourceTime(x)) x.in = +(x.in + d * clipSpeed(x)).toFixed(4);
711
762
  x.duration = +(x.duration - d).toFixed(4);
712
763
  x.keyframes = shiftKF(x.keyframes, d, x.duration);
764
+ shiftMasks(x, d);
713
765
  clampTransitions(x);
714
766
  }
715
767
  }
@@ -776,7 +828,7 @@
776
828
  * delta > 0 drags the picture right, i.e. earlier source. Position and
777
829
  * length stay; clip-local keyframes stay put on the timeline. */
778
830
  function slipClip(c, delta) {
779
- if (!isMediaClip(c)) return "Slip needs a video or audio clip — stills and titles have no source time";
831
+ if (!hasSourceTime(c)) return "Slip needs a video or audio clip, or a comp clip — stills and titles have no source time";
780
832
  if (isGroupLocked(c)) return TRIM_LOCKED;
781
833
  const sp = clipSpeed(c);
782
834
  const group = withLinked([c]);
@@ -784,9 +836,9 @@
784
836
  // consumes its own span of source (speed can differ across the group).
785
837
  let maxIn = Infinity;
786
838
  for (const x of group) {
787
- if (!isMediaClip(x)) continue;
788
- const m = getMedia(x.mediaId);
789
- if (m && m.duration > 0) maxIn = Math.min(maxIn, Math.max(0, m.duration - (timeAt(x, clipEnd(x)) - x.in)));
839
+ if (!hasSourceTime(x)) continue;
840
+ const len = sourceLen(x);
841
+ if (len > 0) maxIn = Math.min(maxIn, Math.max(0, len - (timeAt(x, clipEnd(x)) - x.in)));
790
842
  }
791
843
  const newIn = clampD(c.in - delta * sp, 0, maxIn);
792
844
  const d = (c.in - newIn) / sp;
@@ -885,6 +937,7 @@
885
937
  x.in = +Math.max(0, x.in - e2 * sp).toFixed(4);
886
938
  x.duration = +(x.duration + e2).toFixed(4);
887
939
  x.keyframes = shiftKF(x.keyframes, -e2, x.duration);
940
+ shiftMasks(x, -e2);
888
941
  }
889
942
  }
890
943
  d = +Math.min(d, a.duration, b.duration).toFixed(3);
@@ -956,7 +1009,7 @@
956
1009
 
957
1010
  return {
958
1011
  MIN_DUR, MIN_TRANS_DUR, CROSSFADE_DUR, GAP_EPS, TRIM_LOCKED, DEFAULT_TRACKS,
959
- clipEnd, clipSpeed, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, defaultPanForChannel,
1012
+ clipEnd, clipSpeed, isMediaClip, hasSourceTime, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, shiftMasks, detachChildren, defaultPanForChannel,
960
1013
  create, forDoc,
961
1014
  };
962
1015
  });
package/export-jobs.js CHANGED
@@ -103,14 +103,15 @@ function createExportJobs(opts) {
103
103
 
104
104
  return {
105
105
  /** Queue an export. where: auto (open tab, else headless) · tab · headless.
106
- * kind "scopes" measures one still at `time` instead (fablecut_scopes). */
107
- request({ kind = "export", range, profile, time, revision, where = "auto", matte = null }) {
106
+ * kind "scopes" measures one still at `time` instead (fablecut_scopes);
107
+ * kind "track" follows a region of a clip and saves it (fablecut_track). */
108
+ request({ kind = "export", range, profile, time, revision, where = "auto", matte = null, mask = null, track = null }) {
108
109
  if (!["auto", "tab", "headless"].includes(where)) throw Object.assign(new Error("where must be auto, tab or headless"), { code: 400 });
109
- if (kind !== "export" && kind !== "scopes") throw Object.assign(new Error("kind must be export or scopes"), { code: 400 });
110
+ if (!["export", "scopes", "track"].includes(kind)) throw Object.assign(new Error("kind must be export, scopes or track"), { code: 400 });
110
111
  if (time != null && !(Number.isFinite(time) && time >= 0)) throw Object.assign(new Error("time must be seconds ≥ 0"), { code: 400 });
111
112
  if (range != null && range !== "entire" && range !== "in-out") throw Object.assign(new Error("range must be \"entire\" or \"in-out\""), { code: 400 });
112
113
  const busy = [...jobs.values()].find((j) => !isEnded(j));
113
- if (busy) throw Object.assign(new Error(`${busy.kind === "scopes" ? "scope reading" : "export"} ${busy.id} is still ${busy.status} — wait for it, or cancel it`), { code: 409 });
114
+ if (busy) throw Object.assign(new Error(`${busy.kind === "scopes" ? "scope reading" : busy.kind === "track" ? "tracking job" : "export"} ${busy.id} is still ${busy.status} — wait for it, or cancel it`), { code: 409 });
114
115
  const tabs = opts.editors();
115
116
  if (where === "tab" && !tabs) throw Object.assign(new Error("no editor tab is open — open the editor, or use where:\"headless\""), { code: 409 });
116
117
  const now = Date.now();
@@ -118,6 +119,8 @@ function createExportJobs(opts) {
118
119
  id: "x_" + Math.random().toString(36).slice(2, 10), kind, status: "pending", progress: 0,
119
120
  range: range || null, profile: profile || null, time: time ?? null, revision: Number.isFinite(revision) ? revision : null,
120
121
  matte: kind === "scopes" && matte ? { clip: String(matte.clip), layer: matte.layer } : null,
122
+ mask: kind === "scopes" && mask ? { clip: String(mask.clip) } : null,
123
+ track: kind === "track" && track ? track : null,
121
124
  via: where === "headless" || (where === "auto" && !tabs) ? "headless" : "tab",
122
125
  created: now, updated: now,
123
126
  };
@@ -129,7 +132,7 @@ function createExportJobs(opts) {
129
132
  },
130
133
  get(id) { const j = jobs.get(id); return j ? view(j) : null; },
131
134
  /** What a tab needs to run the job (headless pages fetch it by id). */
132
- ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, time: j.time, revision: j.revision, matte: j.matte || null, status: j.status } : null; },
135
+ ticket(id) { const j = jobs.get(id); return j ? { id: j.id, kind: j.kind, range: j.range, profile: j.profile, time: j.time, revision: j.revision, matte: j.matte || null, mask: j.mask || null, track: j.track || null, status: j.status } : null; },
133
136
  /** First tab to claim a pending job runs it. */
134
137
  claim(id) {
135
138
  const j = jobs.get(id);