fablecut 1.13.0 → 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/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
@@ -20,10 +20,11 @@
20
20
  lockedTracks and untargetedTracks) for headless callers. */
21
21
  (function (root, factory) {
22
22
  const node = typeof module === "object" && module.exports;
23
- const api = factory(node ? require("./mask") : root.FableCutMask, node ? require("./tracker") : root.FableCutTracker);
23
+ const api = factory(node ? require("./mask") : root.FableCutMask, node ? require("./tracker") : root.FableCutTracker,
24
+ node ? require("./motion") : root.FableCutMotion);
24
25
  if (node) module.exports = api;
25
26
  else root.FableCutEdit = api;
26
- })(typeof self !== "undefined" ? self : this, function (MaskLib, TrackLib) {
27
+ })(typeof self !== "undefined" ? self : this, function (MaskLib, TrackLib, MotionLib) {
27
28
  "use strict";
28
29
 
29
30
  const MIN_DUR = 0.05;
@@ -41,8 +42,10 @@
41
42
  /* ── Pure clip helpers ── */
42
43
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
43
44
  const clipEnd = (c) => c.start + c.duration;
44
- 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));
45
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";
46
49
  const EASE = {
47
50
  linear: (u) => u,
48
51
  "ease-in": (u) => u * u,
@@ -65,7 +68,7 @@
65
68
  return fallback;
66
69
  }
67
70
  function hasSpeedRamp(c) {
68
- 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×
69
72
  }
70
73
  /** Source time at timeline time t: in + ∫speed (trapezoid, 1/120 s steps). */
71
74
  function mediaTimeAt(c, t) {
@@ -93,10 +96,11 @@
93
96
  }
94
97
  return Object.keys(out).length ? out : undefined;
95
98
  }
96
- /** A clip's head moved by `offset` s: its mask keys and track samples follow, like shiftKF does for keyframes. */
99
+ /** A clip's head moved by `offset` s: its mask keys, shape keys and track samples follow, like shiftKF does for keyframes. */
97
100
  function shiftMasks(c, offset) {
98
101
  if (!offset || !c.props) return;
99
- const ms = c.props.masks, ts = c.props.tracks;
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) };
100
104
  if (MaskLib && Array.isArray(ms)) {
101
105
  const next = MaskLib.shiftKeys(ms, offset);
102
106
  if (next !== ms) c.props = { ...c.props, masks: next };
@@ -107,6 +111,27 @@
107
111
  if (next.length) c.props.tracks = next; else delete c.props.tracks;
108
112
  }
109
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
+ }
110
135
  /** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
111
136
  function defaultPanForChannel(ch) {
112
137
  if (ch === 0) return -1;
@@ -120,9 +145,16 @@
120
145
  const timeAt = env.mediaTimeAt || mediaTimeAt;
121
146
  const removed = (c) => { if (env.onRemove) env.onRemove(c); };
122
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
+ };
123
154
  const getClip = (id) => P().clips.find((c) => c.id === id) || null;
124
155
  const dropClips = (pred) => {
125
156
  const project = P();
157
+ detachChildren(project, new Set(project.clips.filter(pred).map((x) => x.id)));
126
158
  const keep = [];
127
159
  for (const x of project.clips) { if (pred(x)) removed(x); else keep.push(x); }
128
160
  project.clips = keep;
@@ -286,7 +318,7 @@
286
318
  const dIn = t0 - start;
287
319
  if (dIn > 1e-6) {
288
320
  c.start = t0;
289
- if (isMediaClip(c)) c.in += dIn * clipSpeed(c);
321
+ if (hasSourceTime(c)) c.in += dIn * clipSpeed(c);
290
322
  else c.in = 0;
291
323
  c.duration -= dIn;
292
324
  c.keyframes = shiftKF(c.keyframes, dIn, c.duration);
@@ -676,10 +708,10 @@
676
708
 
677
709
  /** Longest this clip may run before it runs out of source media. */
678
710
  function maxClipDur(c) {
679
- if (!isMediaClip(c)) return Infinity;
680
- const m = getMedia(c.mediaId);
681
- if (!m || !(m.duration > 0)) return Infinity;
682
- 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));
683
715
  }
684
716
  /* A linked group shares its timing, but a partner may run at a different
685
717
  speed (links are inferred from timing alone), so every source limit is the
@@ -690,7 +722,7 @@
690
722
  }
691
723
  /** How far c's group can pull its head back before any member reaches source 0. */
692
724
  function groupHeadRoom(c) {
693
- 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)));
694
726
  }
695
727
  /** The clip butting against c's head ("in") or tail ("out") on its track. */
696
728
  function adjacentClip(c, side) {
@@ -726,7 +758,7 @@
726
758
  function trimGroupHead(c, d, moveStart) {
727
759
  for (const x of withLinked([c])) {
728
760
  if (moveStart) x.start = +(x.start + d).toFixed(4);
729
- 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);
730
762
  x.duration = +(x.duration - d).toFixed(4);
731
763
  x.keyframes = shiftKF(x.keyframes, d, x.duration);
732
764
  shiftMasks(x, d);
@@ -796,7 +828,7 @@
796
828
  * delta > 0 drags the picture right, i.e. earlier source. Position and
797
829
  * length stay; clip-local keyframes stay put on the timeline. */
798
830
  function slipClip(c, delta) {
799
- 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";
800
832
  if (isGroupLocked(c)) return TRIM_LOCKED;
801
833
  const sp = clipSpeed(c);
802
834
  const group = withLinked([c]);
@@ -804,9 +836,9 @@
804
836
  // consumes its own span of source (speed can differ across the group).
805
837
  let maxIn = Infinity;
806
838
  for (const x of group) {
807
- if (!isMediaClip(x)) continue;
808
- const m = getMedia(x.mediaId);
809
- 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)));
810
842
  }
811
843
  const newIn = clampD(c.in - delta * sp, 0, maxIn);
812
844
  const d = (c.in - newIn) / sp;
@@ -977,7 +1009,7 @@
977
1009
 
978
1010
  return {
979
1011
  MIN_DUR, MIN_TRANS_DUR, CROSSFADE_DUR, GAP_EPS, TRIM_LOCKED, DEFAULT_TRACKS,
980
- clipEnd, clipSpeed, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, shiftMasks, defaultPanForChannel,
1012
+ clipEnd, clipSpeed, isMediaClip, hasSourceTime, kfChannel, hasSpeedRamp, mediaTimeAt, shiftKF, shiftMasks, detachChildren, defaultPanForChannel,
981
1013
  create, forDoc,
982
1014
  };
983
1015
  });