fablecut 1.13.0 → 1.15.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
+ });