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/CLAUDE.md +802 -16
- package/README.md +71 -7
- package/analyze.js +161 -9
- package/app.js +3905 -258
- package/comp.js +195 -0
- package/delivery.js +779 -0
- package/edit-ops.js +50 -18
- package/export-jobs.js +9 -5
- package/frame-cache.js +49 -0
- package/frame-stats.js +267 -0
- package/index.html +282 -72
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mcp-server.js +324 -656
- package/media-lib.js +287 -0
- package/media-probe.js +88 -0
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +33 -3
- package/project-ops.js +1290 -0
- package/proxy.js +180 -0
- package/server.js +517 -15
- package/style.css +534 -20
- package/templates.js +576 -0
package/project-ops.js
ADDED
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
/* FableCut project operations: what `fablecut_patch_project` does to a
|
|
2
|
+
project document, the compact project view, and `fablecut_set_project`'s
|
|
3
|
+
checks. No I/O: the MCP server reads and writes project.json around them,
|
|
4
|
+
and the module runs unchanged anywhere else a document needs the same ops,
|
|
5
|
+
validation and messages (it loads in a browser too).
|
|
6
|
+
|
|
7
|
+
applyPatch(proj, ops, ctx) → {notes, toSave}
|
|
8
|
+
Applies the ops to proj in place, in order; throws on the first bad op
|
|
9
|
+
(the caller then discards proj). notes are the per-op summaries, toSave
|
|
10
|
+
the [kind, item] presets / templates to write once the project is saved.
|
|
11
|
+
ctx.savedItems(kind) → the saved presets / templates (default none)
|
|
12
|
+
ctx.audioChannels(media) → audio channels of a video file, 0 = none,
|
|
13
|
+
null = unknown (insert / overwrite assume 2)
|
|
14
|
+
ctx.checkProfile(id) → throws if id is not an encoding profile
|
|
15
|
+
ctx.mediaInfo(src) → the file's probed details (media-probe.js), null = no
|
|
16
|
+
such file, undefined = can't tell (relinkMedia / replaceMedia)
|
|
17
|
+
compactView(doc) → the one-line-per-clip summary of fablecut_get_project
|
|
18
|
+
checkProject(doc) → validates and normalizes a whole document in place
|
|
19
|
+
(fablecut_set_project); throws on the first problem
|
|
20
|
+
timelineEdit(proj, op, refuseLocked, lockReason, ctx) → note (split … crossfade)
|
|
21
|
+
describeScopes(job) · describeScopeLines(stats) · TEXT_ONLY_NOTE · describeTrack(result)
|
|
22
|
+
→ what fablecut_scopes, fablecut_frame and fablecut_track say
|
|
23
|
+
|
|
24
|
+
In a browser it loads as a plain script (global `FableCutProjectOps`)
|
|
25
|
+
after the modules it uses, the same way edit-ops.js does. */
|
|
26
|
+
(function (root, factory) {
|
|
27
|
+
const node = typeof module === "object" && module.exports;
|
|
28
|
+
const dep = (file, name) => (node ? require(file) : root[name]);
|
|
29
|
+
const api = factory(dep("./audio-fx", "FableCutFx"), dep("./edit-ops", "FableCutEdit"), dep("./color", "FableCutColor"),
|
|
30
|
+
dep("./mask", "FableCutMask"), dep("./tracker", "FableCutTracker"), dep("./motion", "FableCutMotion"),
|
|
31
|
+
dep("./comp", "FableCutComp"), dep("./templates", "FableCutTemplates"), dep("./media-lib", "FableCutMedia"),
|
|
32
|
+
dep("./delivery", "FableCutDelivery"));
|
|
33
|
+
if (node) module.exports = api;
|
|
34
|
+
else root.FableCutProjectOps = api;
|
|
35
|
+
})(typeof self !== "undefined" ? self : this, function (FX, EditOps, Color, Mask, Tracker, Motion, CompLib, Templates, MediaLib, Delivery) {
|
|
36
|
+
"use strict";
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TRACKS = EditOps.DEFAULT_TRACKS;
|
|
39
|
+
const uid = () => Math.random().toString(36).slice(2, 9);
|
|
40
|
+
const basename = (p) => String(p).split(/[\\/]/).pop();
|
|
41
|
+
|
|
42
|
+
// the UI persists default-valued props on every clip; the compact view hides
|
|
43
|
+
// them so it only shows what actually deviates
|
|
44
|
+
const PROP_DEFAULTS = {
|
|
45
|
+
x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0, speed: 1,
|
|
46
|
+
gain: 0, channelMode: "stereo", duck: 0,
|
|
47
|
+
blend: "normal", fit: "contain", cropL: 0, cropR: 0, cropT: 0, cropB: 0,
|
|
48
|
+
cornerRadius: 0, flipH: false, flipV: false, filterPreset: "none",
|
|
49
|
+
brightness: 100, contrast: 100, saturation: 100, hue: 0, temperature: 0,
|
|
50
|
+
tint: 0, blur: 0, grayscale: 0, sepia: 0, invert: 0, vignette: 0,
|
|
51
|
+
shake: 0, shakeSpeed: 8, rgbSplit: 0, grain: 0,
|
|
52
|
+
chromaKey: "", chromaTolerance: 26, chromaSoftness: 12, bgRemove: false,
|
|
53
|
+
text: "Title", fontSize: 72, color: "#ffffff", color2: "", font: "Segoe UI",
|
|
54
|
+
bold: true, weight: 0, italic: false, uppercase: false, align: "center",
|
|
55
|
+
letterSpacing: 0, lineHeight: 1.2, textShadow: 12, glow: 0, glowColor: "",
|
|
56
|
+
strokeWidth: 0, strokeColor: "#000", bgColor: "#000", bgOpacity: 0,
|
|
57
|
+
textAnim: "none", wordRate: 0.15, direction: "auto", boxW: 0, boxH: 0, boxFit: false, vAlign: "middle",
|
|
58
|
+
anchorX: 0, anchorY: 0, trimStart: 0, trimEnd: 100, trimOffset: 0,
|
|
59
|
+
autoOrient: false, motionBlur: 0, motionBlurSamples: 8, pathOffset: 0, pathAlign: "start",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/* ── Timeline edits (patch ops split … crossfade) ──
|
|
63
|
+
The editor's own edit code (edit-ops.js) run against the document, so an
|
|
64
|
+
agent gets the same targeting (untargetedTracks, or the op's `tracks`), the
|
|
65
|
+
same sync lock (linked partners ride along) and the same lock rules as the
|
|
66
|
+
keyboard shortcuts. Returns a short note; throws to abort the patch. */
|
|
67
|
+
const relinkedDocs = new WeakSet();
|
|
68
|
+
function timelineEdit(proj, op, refuseLocked, lockReason, ctx) {
|
|
69
|
+
const name = op.op;
|
|
70
|
+
const E = EditOps.forDoc(proj, { tracks: op.tracks, force: op.force === true, uid: () => uid() });
|
|
71
|
+
// Links are rebuilt from timing on every editor load; do the same once per
|
|
72
|
+
// patch so clips an agent added (without linkGroup) ride along with their stems.
|
|
73
|
+
if (!relinkedDocs.has(proj)) { E.relinkClips(); relinkedDocs.add(proj); }
|
|
74
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
75
|
+
const signed = (n) => (n > 0 ? "+" : "") + r3(n);
|
|
76
|
+
const num = (v, what) => {
|
|
77
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${name}: ${what} must be a number (seconds)`);
|
|
78
|
+
return v;
|
|
79
|
+
};
|
|
80
|
+
const clipOf = (id) => {
|
|
81
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
82
|
+
if (!c) throw new Error(`${name}: no clip ${id}`);
|
|
83
|
+
return c;
|
|
84
|
+
};
|
|
85
|
+
const named = () => {
|
|
86
|
+
if (!Array.isArray(op.ids) || !op.ids.length) throw new Error(`${name}: ids must list at least one clip`);
|
|
87
|
+
const clips = op.ids.map(String).map(clipOf);
|
|
88
|
+
for (const c of clips) refuseLocked(name, lockReason(c), op);
|
|
89
|
+
return clips;
|
|
90
|
+
};
|
|
91
|
+
const refused = (why) => new Error(`${name}: ${why}` +
|
|
92
|
+
(/locked/i.test(why) ? " — the user locked it; pass force:true only if they asked" : ""));
|
|
93
|
+
const trimmed = (c, r, extra = "") => {
|
|
94
|
+
if (typeof r === "string") throw refused(r);
|
|
95
|
+
const asked = num(op.delta, "delta");
|
|
96
|
+
return `${name} ${c.id}${extra} ${signed(r)}s` + (Math.abs(r - asked) > 1e-6 ? ` (asked ${signed(asked)}, clamped)` : "");
|
|
97
|
+
};
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "split": {
|
|
100
|
+
const at = num(op.at, "at");
|
|
101
|
+
const r = E.splitAt(at, op.ids ? named() : null);
|
|
102
|
+
if (!r.split) throw refused(r.blocked ? "only locked clips under that time — left whole" : `no clip runs across ${at}s on the ${op.ids ? "given clips" : "targeted tracks"}`);
|
|
103
|
+
return `split@${at} +${r.rights.map((x) => x.id).join("+")}` + (r.blocked ? " (locked clips left whole)" : "");
|
|
104
|
+
}
|
|
105
|
+
case "rippleDelete": {
|
|
106
|
+
const r = E.rippleDelete(named());
|
|
107
|
+
return `-${r.removed.map((x) => x.id).join("-")} (rippled)`;
|
|
108
|
+
}
|
|
109
|
+
case "closeGap": {
|
|
110
|
+
const G = E.closeGapAt(num(op.at, "at"));
|
|
111
|
+
if (typeof G === "string") throw refused(G.replace("at playhead", `at ${op.at}s`).replace("move the playhead", "pick another time"));
|
|
112
|
+
return `closed ${r3(G)}s gap @${op.at}`;
|
|
113
|
+
}
|
|
114
|
+
case "lift": case "extract": {
|
|
115
|
+
const usePoints = op.from == null && op.to == null;
|
|
116
|
+
const t0 = op.from == null ? proj.inPoint : num(op.from, "from");
|
|
117
|
+
const t1 = op.to == null ? proj.outPoint : num(op.to, "to");
|
|
118
|
+
if (t0 == null || t1 == null) throw refused("give from and to (seconds), or set the project's inPoint and outPoint");
|
|
119
|
+
const why = E.liftRange(t0, t1, name === "extract");
|
|
120
|
+
if (why) throw refused(why.replace("Set IN and OUT first (I / O) to", "from→to must span more than a frame to"));
|
|
121
|
+
if (usePoints) { delete proj.inPoint; delete proj.outPoint; } // as in the editor
|
|
122
|
+
return `${name} ${r3(t0)}→${r3(t1)}s`;
|
|
123
|
+
}
|
|
124
|
+
case "rippleTrim": case "roll": {
|
|
125
|
+
const c = clipOf(String(op.id));
|
|
126
|
+
if (op.side !== "in" && op.side !== "out") throw new Error(`${name}: side must be "in" (head) or "out" (tail)`);
|
|
127
|
+
const fn = name === "roll" ? E.rollEdit : E.rippleTrim;
|
|
128
|
+
return trimmed(c, fn(c, op.side, num(op.delta, "delta")), "." + op.side);
|
|
129
|
+
}
|
|
130
|
+
case "slip": case "slide": {
|
|
131
|
+
const c = clipOf(String(op.id));
|
|
132
|
+
const fn = name === "slip" ? E.slipClip : E.slideClip;
|
|
133
|
+
return trimmed(c, fn(c, num(op.delta, "delta")));
|
|
134
|
+
}
|
|
135
|
+
case "insert": case "overwrite": {
|
|
136
|
+
const m = proj.media.find((x) => x.id === op.mediaId);
|
|
137
|
+
if (!m) throw new Error(`${name}: unknown mediaId ${op.mediaId}`);
|
|
138
|
+
const timed = m.kind === "video" || m.kind === "audio";
|
|
139
|
+
const inn = op.in == null ? 0 : num(op.in, "in");
|
|
140
|
+
if (inn < 0) throw new Error(`${name}: in must be ≥ 0`);
|
|
141
|
+
const dur = op.duration != null ? num(op.duration, "duration")
|
|
142
|
+
: timed && m.duration > 0 ? m.duration - inn : null;
|
|
143
|
+
if (dur == null) throw new Error(`${name}: duration is required for ${timed ? "media with no known duration yet" : m.kind}`);
|
|
144
|
+
if (!(dur >= EditOps.MIN_DUR)) throw new Error(`${name}: duration must be at least ${EditOps.MIN_DUR}s`);
|
|
145
|
+
if (timed && m.duration > 0 && inn + dur > m.duration + 1e-3)
|
|
146
|
+
throw new Error(`${name}: in + duration (${r3(inn + dur)}s) runs past the end of ${m.id} (${m.duration}s)`);
|
|
147
|
+
let stems = 2;
|
|
148
|
+
if (m.kind === "video") {
|
|
149
|
+
try { const n = ctx && ctx.audioChannels ? ctx.audioChannels(m) : null; if (n != null) stems = n; } catch { /* no ffprobe: assume stereo */ }
|
|
150
|
+
}
|
|
151
|
+
const r = (name === "insert" ? E.insertAt : E.overwriteAt)(m, timed ? inn : 0, dur, num(op.at, "at"), stems);
|
|
152
|
+
if (typeof r === "string") throw refused(r.replace(" — click a track name to target it (and unlock it)", " — every lane for it is untargeted or locked (pass tracks:[…])"));
|
|
153
|
+
const placed = r.clip ? (r.clip.linkGroup ? proj.clips.filter((x) => x.linkGroup === r.clip.linkGroup) : [r.clip]) : [];
|
|
154
|
+
return `${name}@${r3(r.at)} ` + placed.map((x) => `+${x.id}(${x.track})`).join("") + (name === "insert" ? ` · later clips moved +${r3(dur)}s` : "");
|
|
155
|
+
}
|
|
156
|
+
case "crossfade": {
|
|
157
|
+
const dur = op.duration == null ? EditOps.CROSSFADE_DUR : num(op.duration, "duration");
|
|
158
|
+
if (dur < EditOps.MIN_TRANS_DUR) throw new Error(`${name}: duration must be at least ${EditOps.MIN_TRANS_DUR}s`);
|
|
159
|
+
const pairs = op.ids ? E.crossfadeCuts(E.withLinked(named())) : E.crossfadeCutsNear(num(op.at, "at"));
|
|
160
|
+
if (!pairs.length) throw refused(op.ids ? "no audio cut next to those clips — they must touch or overlap on one track"
|
|
161
|
+
: `no audio cut within 0.5s of ${op.at}s on the targeted audio tracks`);
|
|
162
|
+
const { done, why } = E.crossfadePairs(pairs, dur);
|
|
163
|
+
if (!done) throw refused(why.join(", "));
|
|
164
|
+
return `crossfaded ${done} cut${done === 1 ? "" : "s"}` + (why.length ? ` (skipped: ${why.join(", ")})` : "");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw new Error("Unknown edit op " + name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
function applyPatch(proj, ops, ctx) {
|
|
172
|
+
const savedItems = (ctx && ctx.savedItems) || (() => []);
|
|
173
|
+
const notes = [];
|
|
174
|
+
const toSave = []; // [kind, item] — presets / templates written to the library once the project is saved
|
|
175
|
+
const savedList = (kind) => [...savedItems(kind), ...toSave.filter((x) => x[0] === kind).map((x) => x[1])];
|
|
176
|
+
// Locks mirror the editor: a clip is locked by its own flag or its track,
|
|
177
|
+
// and a linked A/V group with any locked member is locked as a whole.
|
|
178
|
+
const lockedTracks = () => new Set(Array.isArray(proj.lockedTracks) ? proj.lockedTracks : []);
|
|
179
|
+
const lockReason = (c) => {
|
|
180
|
+
const group = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup) : [c];
|
|
181
|
+
for (const x of group) {
|
|
182
|
+
const who = x.id === c.id ? `clip ${c.id}` : `clip ${c.id} is linked to ${x.id}, which`;
|
|
183
|
+
if (x.locked === true) return `${who} is locked`;
|
|
184
|
+
if (lockedTracks().has(x.track)) return `${who} is on locked track ${x.track}`;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
};
|
|
188
|
+
const refuseLocked = (opName, reason, op) => {
|
|
189
|
+
if (reason && op.force !== true)
|
|
190
|
+
throw new Error(`${opName}: ${reason} — the user locked it. Leave it alone, or pass force:true if they asked you to change it`);
|
|
191
|
+
};
|
|
192
|
+
/* The effect chain an op names: a clip (and its linked stems), an
|
|
193
|
+
A-track, a submix bus or the master → {label, read(), write(list)}. */
|
|
194
|
+
const fxTarget = (op, name) => {
|
|
195
|
+
const target = op.target || (op.id && /^A\d+$/.test(op.id) ? "track" : op.id && /^B\d+$/.test(op.id) ? "bus" : op.id ? "clip" : "master");
|
|
196
|
+
if (target === "clip") {
|
|
197
|
+
const c = proj.clips.find((x) => x.id === op.id);
|
|
198
|
+
if (!c) throw new Error(`${name}: no clip ${op.id}`);
|
|
199
|
+
if (c.kind !== "audio") throw new Error(`${name}: clip ${op.id} is ${c.kind} — effects go on audio clips (a video's sound is on its linked A-track clips)`);
|
|
200
|
+
refuseLocked(name, lockReason(c), op);
|
|
201
|
+
const group = c.linkGroup ? proj.clips.filter((x) => x.linkGroup === c.linkGroup && x.kind === "audio") : [c];
|
|
202
|
+
return { label: group.map((x) => x.id).join("+"), read: () => c.fx, write(next) {
|
|
203
|
+
for (const x of group) { if (next.length) x.fx = next.map((e) => JSON.parse(JSON.stringify(e))); else delete x.fx; }
|
|
204
|
+
} };
|
|
205
|
+
}
|
|
206
|
+
if (target === "track") {
|
|
207
|
+
if (!/^A\d+$/.test(String(op.id || ""))) throw new Error(`${name}: track id must be an audio track (A1, A2, …)`);
|
|
208
|
+
if (!Array.isArray(proj.tracks) || !proj.tracks.length) proj.tracks = DEFAULT_TRACKS.map((t) => ({ ...t }));
|
|
209
|
+
let t = proj.tracks.find((x) => x.id === op.id);
|
|
210
|
+
if (!t) { t = { id: op.id, kind: "audio" }; proj.tracks.push(t); }
|
|
211
|
+
return { label: op.id, read: () => t.fx, write(next) { if (next.length) t.fx = next; else delete t.fx; } };
|
|
212
|
+
}
|
|
213
|
+
if (target === "bus") {
|
|
214
|
+
const b = (proj.buses || []).find((x) => x.id === op.id);
|
|
215
|
+
if (!b) throw new Error(`${name}: no bus ${JSON.stringify(op.id)} — create it with {op:"setBus", id:"B1"}`);
|
|
216
|
+
return { label: op.id, read: () => b.fx, write(next) { if (next.length) b.fx = next; else delete b.fx; } };
|
|
217
|
+
}
|
|
218
|
+
if (target === "master") {
|
|
219
|
+
return { label: "master", read: () => proj.master?.fx, write(next) {
|
|
220
|
+
const m = proj.master && typeof proj.master === "object" ? proj.master : {};
|
|
221
|
+
if (next.length) m.fx = next; else delete m.fx;
|
|
222
|
+
if (Object.keys(m).length) proj.master = m; else delete proj.master;
|
|
223
|
+
} };
|
|
224
|
+
}
|
|
225
|
+
throw new Error(`${name}: target must be clip, track, bus or master`);
|
|
226
|
+
};
|
|
227
|
+
const mergeInto = (target, set) => {
|
|
228
|
+
for (const [k, v] of Object.entries(set || {})) {
|
|
229
|
+
if (v === null) delete target[k];
|
|
230
|
+
else if (k === "props" && target.props && typeof v === "object" && !Array.isArray(v)) {
|
|
231
|
+
for (const [pk, pv] of Object.entries(v)) {
|
|
232
|
+
if (pv === null) delete target.props[pk]; else target.props[pk] = pv;
|
|
233
|
+
}
|
|
234
|
+
} else target[k] = v;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
// props.masks written wholesale (addClip / updateClip) is validated like setMask
|
|
238
|
+
const checkMasks = (c, label) => {
|
|
239
|
+
if (!c.props || c.props.masks == null) return;
|
|
240
|
+
if (c.kind === "audio") throw new Error(`${label}: masks go on video, image, svg, text or adjust clips`);
|
|
241
|
+
let n;
|
|
242
|
+
try { n = Mask.normalizeMasks(c.props.masks, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
243
|
+
if (n) c.props.masks = n; else delete c.props.masks;
|
|
244
|
+
};
|
|
245
|
+
// props.tracks written wholesale is validated too (the editor writes them; agents rarely should)
|
|
246
|
+
const checkTracks = (c, label) => {
|
|
247
|
+
if (!c.props || c.props.tracks == null) return;
|
|
248
|
+
if (c.kind !== "video") throw new Error(`${label}: tracks belong to video clips`);
|
|
249
|
+
let n;
|
|
250
|
+
try { n = Tracker.normalizeTracks(c.props.tracks, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
251
|
+
if (n) c.props.tracks = n; else delete c.props.tracks;
|
|
252
|
+
};
|
|
253
|
+
// props.shapes (shape layers) and clip.parent: validated like setShape / setParent
|
|
254
|
+
const NO_MEDIA = ["text", "adjust", "shape", "null", "comp"];
|
|
255
|
+
let inCompId = null; // set while an inComp op runs its ops inside a comp
|
|
256
|
+
// a comp clip names a comp that exists, and placing it here makes no loop
|
|
257
|
+
const checkCompClip = (c, label) => {
|
|
258
|
+
if (c.kind !== "comp") { if (c.compId != null) throw new Error(`${label}: compId is for kind:"comp" clips`); return; }
|
|
259
|
+
if (typeof c.compId !== "string" || !CompLib.getComp(proj, c.compId))
|
|
260
|
+
throw new Error(`${label}: compId must name a comp in project.comps${(proj.comps || []).length ? ` (${proj.comps.map((x) => x.id).join(", ")})` : " — add one with addComp"}`);
|
|
261
|
+
if (CompLib.wouldCycle(proj, inCompId, c.compId)) throw new Error(`${label}: comp ${c.compId} can't go inside ${inCompId} — that would contain itself`);
|
|
262
|
+
c.props = c.props || {};
|
|
263
|
+
if (c.props.fit == null) c.props.fit = "none"; // a comp shows at its own size unless told otherwise
|
|
264
|
+
if (c.in == null) c.in = 0;
|
|
265
|
+
};
|
|
266
|
+
const checkShapes = (c, label) => {
|
|
267
|
+
if (!c.props || c.props.shapes == null) return;
|
|
268
|
+
if (c.kind !== "shape") throw new Error(`${label}: shapes go on shape clips (kind:"shape")`);
|
|
269
|
+
let n;
|
|
270
|
+
try { n = Motion.normalizeShapes(c.props.shapes, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
271
|
+
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
272
|
+
};
|
|
273
|
+
const byId = (id) => proj.clips.find((x) => x.id === id) || null;
|
|
274
|
+
// motion blur, auto-orient, text paths and motion-path handles (pin / pout on x / y keys)
|
|
275
|
+
const checkMotion = (c, label) => {
|
|
276
|
+
const p = c.props || {};
|
|
277
|
+
if (p.motionBlur != null && !(typeof p.motionBlur === "number" && p.motionBlur >= 0 && p.motionBlur <= 720)) throw new Error(`${label}: props.motionBlur is a shutter angle 0…720 (0 = off)`);
|
|
278
|
+
if (p.motionBlurSamples != null && !(Number.isInteger(p.motionBlurSamples) && p.motionBlurSamples >= 2 && p.motionBlurSamples <= 32)) throw new Error(`${label}: props.motionBlurSamples is a whole number 2…32`);
|
|
279
|
+
if (p.autoOrient != null && typeof p.autoOrient !== "boolean") throw new Error(`${label}: props.autoOrient is true or false`);
|
|
280
|
+
if (p.pathAlign != null && !["start", "center", "end"].includes(p.pathAlign)) throw new Error(`${label}: props.pathAlign is start | center | end`);
|
|
281
|
+
if (p.textPath != null) {
|
|
282
|
+
if (c.kind !== "text") throw new Error(`${label}: textPath goes on text clips`);
|
|
283
|
+
try { p.textPath = Motion.normalizeTextPath(p.textPath, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
284
|
+
}
|
|
285
|
+
for (const ch of ["x", "y"]) for (const k of (c.keyframes && Array.isArray(c.keyframes[ch]) ? c.keyframes[ch] : []))
|
|
286
|
+
for (const h of ["pin", "pout"]) if (k && k[h] != null && !(typeof k[h] === "number" && Number.isFinite(k[h])))
|
|
287
|
+
throw new Error(`${label}: keyframes.${ch} ${h} must be a number (px, the motion path's curve handle)`);
|
|
288
|
+
};
|
|
289
|
+
const checkParent = (c, label) => {
|
|
290
|
+
if (c.parent == null) { delete c.parent; return; }
|
|
291
|
+
const par = typeof c.parent === "string" && byId(c.parent);
|
|
292
|
+
if (!par) throw new Error(`${label}: parent ${JSON.stringify(c.parent)} is not a clip`);
|
|
293
|
+
if (par.id === c.id || Motion.wouldCycle(c.id, par.id, byId)) throw new Error(`${label}: parenting ${c.id} to ${par.id} makes a loop`);
|
|
294
|
+
if (par.kind === "audio" || par.kind === "adjust" || c.kind === "audio" || c.kind === "adjust")
|
|
295
|
+
throw new Error(`${label}: parenting is for visual clips (video, image, svg, text, shape, null)`);
|
|
296
|
+
};
|
|
297
|
+
// blend / matte / matteTrack: checked when the op writes them
|
|
298
|
+
const videoTrackIds = () => {
|
|
299
|
+
const ids = (Array.isArray(proj.tracks) && proj.tracks.length ? proj.tracks : DEFAULT_TRACKS).filter((t) => t.kind === "video").map((t) => t.id);
|
|
300
|
+
for (const x of proj.clips) if (/^V\d+$/.test(x.track) && !ids.includes(x.track)) ids.push(x.track);
|
|
301
|
+
return ids;
|
|
302
|
+
};
|
|
303
|
+
const checkCompositing = (c, label, props) => {
|
|
304
|
+
if (!props || !["blend", "matte", "matteTrack"].some((k) => props[k] != null)) return;
|
|
305
|
+
if (c.kind === "audio") throw new Error(`${label}: blend and track mattes go on video, image, svg, text or adjust clips`);
|
|
306
|
+
try { Mask.checkCompositing(c.props, videoTrackIds(), c.track, true); } catch (err) { throw new Error(`${label}: props.${err.message}`); }
|
|
307
|
+
if (!c.props.matte) delete c.props.matteTrack;
|
|
308
|
+
};
|
|
309
|
+
const applyOps = (opList) => { for (const op of opList) {
|
|
310
|
+
switch (op.op) {
|
|
311
|
+
case "addClip": {
|
|
312
|
+
const c = op.clip;
|
|
313
|
+
if (!c || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
|
|
314
|
+
throw new Error("addClip needs clip{track, start, duration}");
|
|
315
|
+
c.id = c.id || "c_" + uid();
|
|
316
|
+
if (proj.clips.some((x) => x.id === c.id)) throw new Error("addClip: duplicate clip id " + c.id);
|
|
317
|
+
refuseLocked("addClip", lockedTracks().has(c.track) ? `track ${c.track} is locked` : null, op);
|
|
318
|
+
if (!NO_MEDIA.includes(c.kind) && !proj.media.some((m) => m.id === c.mediaId))
|
|
319
|
+
throw new Error(`addClip: unknown mediaId ${c.mediaId}`);
|
|
320
|
+
if (NO_MEDIA.includes(c.kind)) c.mediaId = null;
|
|
321
|
+
checkMasks(c, "addClip");
|
|
322
|
+
checkShapes(c, "addClip");
|
|
323
|
+
checkParent(c, "addClip");
|
|
324
|
+
checkMotion(c, "addClip");
|
|
325
|
+
checkCompClip(c, "addClip");
|
|
326
|
+
checkTracks(c, "addClip");
|
|
327
|
+
checkCompositing(c, "addClip", c.props);
|
|
328
|
+
proj.clips.push(c);
|
|
329
|
+
notes.push("+" + c.id);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "updateClip": {
|
|
333
|
+
const c = proj.clips.find((x) => x.id === op.id);
|
|
334
|
+
if (!c) throw new Error("updateClip: no clip " + op.id);
|
|
335
|
+
// Toggling the lock itself is always allowed — that's how you unlock.
|
|
336
|
+
const onlyLock = Object.keys(op.set || {}).every((k) => k === "locked");
|
|
337
|
+
if (!onlyLock) {
|
|
338
|
+
refuseLocked("updateClip", lockReason(c), op);
|
|
339
|
+
if (op.set && op.set.track && lockedTracks().has(op.set.track))
|
|
340
|
+
refuseLocked("updateClip", `track ${op.set.track} is locked`, op);
|
|
341
|
+
}
|
|
342
|
+
mergeInto(c, op.set);
|
|
343
|
+
checkMasks(c, "updateClip");
|
|
344
|
+
if (op.set && op.set.props && "tracks" in op.set.props) checkTracks(c, "updateClip");
|
|
345
|
+
if (op.set && op.set.props && "shapes" in op.set.props) checkShapes(c, "updateClip");
|
|
346
|
+
if (op.set && "parent" in op.set) checkParent(c, "updateClip");
|
|
347
|
+
checkMotion(c, "updateClip");
|
|
348
|
+
if (op.set && ("compId" in op.set || "kind" in op.set)) checkCompClip(c, "updateClip");
|
|
349
|
+
// a track move re-checks the matte too (a clip can't be its own matte)
|
|
350
|
+
checkCompositing(c, "updateClip", op.set && op.set.track ? c.props : op.set && op.set.props);
|
|
351
|
+
if (c.props && c.props.matteTrack && !c.props.matte) delete c.props.matteTrack; // matte:null clears its track too
|
|
352
|
+
notes.push("~" + op.id);
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
case "removeClip": {
|
|
356
|
+
const doomed = proj.clips.find((x) => x.id === op.id);
|
|
357
|
+
if (doomed) refuseLocked("removeClip", lockReason(doomed), op);
|
|
358
|
+
const n = proj.clips.length;
|
|
359
|
+
// its children stay where they are (at their own start), under its own parent if it has one
|
|
360
|
+
const kids = doomed ? proj.clips.filter((x) => x.parent === op.id).map((x) => x.id) : [];
|
|
361
|
+
if (doomed) EditOps.detachChildren(proj, new Set([op.id]));
|
|
362
|
+
for (const id of kids) notes.push(`~${id}.parent(${byId(id).parent || "none"})`);
|
|
363
|
+
proj.clips = proj.clips.filter((x) => x.id !== op.id);
|
|
364
|
+
if (proj.clips.length === n) throw new Error("removeClip: no clip " + op.id);
|
|
365
|
+
notes.push("-" + op.id);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "addMedia": {
|
|
369
|
+
const m = op.media;
|
|
370
|
+
if (!m || !m.src || !m.kind) throw new Error("addMedia needs media{src, kind}");
|
|
371
|
+
m.id = m.id || "m_" + uid();
|
|
372
|
+
if (proj.media.some((x) => x.id === m.id)) throw new Error("addMedia: duplicate media id " + m.id);
|
|
373
|
+
m.name = m.name || basename(decodeURIComponent(m.src));
|
|
374
|
+
proj.media.push(m);
|
|
375
|
+
notes.push("+" + m.id);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case "updateMedia": {
|
|
379
|
+
// {op:"updateMedia", id | ids, set:{name?, favorite?, tags?, folderId?}, addTags?, removeTags?}
|
|
380
|
+
const ids = op.ids || [op.id];
|
|
381
|
+
if (!ids.length || ids.some((id) => !id)) throw new Error("updateMedia needs id or ids");
|
|
382
|
+
const set = op.set || {};
|
|
383
|
+
const SETTABLE = ["name", "favorite", "tags", "folderId"];
|
|
384
|
+
for (const k of Object.keys(set))
|
|
385
|
+
if (!SETTABLE.includes(k)) throw new Error(`updateMedia: '${k}' not settable (allowed: ${SETTABLE.join(", ")}; metadata such as fps or codec is probed from the file)`);
|
|
386
|
+
if ("folderId" in set && set.folderId != null && !(proj.folders || []).some((f) => f.id === set.folderId))
|
|
387
|
+
throw new Error(`updateMedia: no folder ${set.folderId}`);
|
|
388
|
+
if ("name" in set && !String(set.name || "").trim()) throw new Error("updateMedia: name can't be empty");
|
|
389
|
+
for (const id of ids) {
|
|
390
|
+
const m = proj.media.find((x) => x.id === id);
|
|
391
|
+
if (!m) throw new Error("updateMedia: no media " + id);
|
|
392
|
+
if ("name" in set) m.name = String(set.name).trim().slice(0, 200);
|
|
393
|
+
if ("favorite" in set) { if (set.favorite) m.favorite = true; else delete m.favorite; }
|
|
394
|
+
if ("folderId" in set) m.folderId = set.folderId || null;
|
|
395
|
+
let tags = "tags" in set ? MediaLib.normalizeTags(set.tags) : MediaLib.normalizeTags(m.tags);
|
|
396
|
+
if (op.addTags) tags = MediaLib.normalizeTags([...tags, ...MediaLib.normalizeTags(op.addTags)]);
|
|
397
|
+
if (op.removeTags) { const rm = new Set(MediaLib.normalizeTags(op.removeTags).map((t) => t.toLowerCase())); tags = tags.filter((t) => !rm.has(t.toLowerCase())); }
|
|
398
|
+
if (tags.length) m.tags = tags; else delete m.tags;
|
|
399
|
+
notes.push("~" + id);
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case "setCollection": {
|
|
404
|
+
// {op:"setCollection", id?, name?, media?:[…], add?:[…], remove?:[…]} — creates it when the id is new
|
|
405
|
+
const list = proj.collections = MediaLib.normalizeCollections(proj.collections, proj.media);
|
|
406
|
+
const known = (ids, what) => {
|
|
407
|
+
if (ids == null) return [];
|
|
408
|
+
if (!Array.isArray(ids)) throw new Error(`setCollection: ${what} must be a list of media ids`);
|
|
409
|
+
for (const id of ids) if (!proj.media.some((m) => m.id === id)) throw new Error(`setCollection: no media ${id}`);
|
|
410
|
+
return ids.map(String);
|
|
411
|
+
};
|
|
412
|
+
let col = op.id ? list.find((c) => c.id === op.id) : null;
|
|
413
|
+
if (!col && !op.id && op.name) col = list.find((c) => c.name.toLowerCase() === String(op.name).trim().toLowerCase()) || null;
|
|
414
|
+
const fresh = !col;
|
|
415
|
+
if (fresh) {
|
|
416
|
+
if (!op.name) throw new Error("setCollection: a new collection needs a name");
|
|
417
|
+
col = { id: op.id ? String(op.id) : "col_" + uid(), name: "", media: [] };
|
|
418
|
+
list.push(col);
|
|
419
|
+
}
|
|
420
|
+
if (op.name != null) col.name = String(op.name).trim().slice(0, 80) || col.name || "Collection";
|
|
421
|
+
if (op.media != null) col.media = [...new Set(known(op.media, "media"))];
|
|
422
|
+
for (const id of known(op.add, "add")) if (!col.media.includes(id)) col.media.push(id);
|
|
423
|
+
const rm = new Set(known(op.remove, "remove"));
|
|
424
|
+
col.media = col.media.filter((id) => !rm.has(id));
|
|
425
|
+
notes.push((fresh ? "+" : "~") + col.id + `(${col.media.length})`);
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
case "removeCollection": {
|
|
429
|
+
const list = MediaLib.normalizeCollections(proj.collections, proj.media);
|
|
430
|
+
const n = list.length;
|
|
431
|
+
proj.collections = list.filter((c) => c.id !== op.id);
|
|
432
|
+
if (proj.collections.length === n) throw new Error("removeCollection: no collection " + op.id);
|
|
433
|
+
if (!proj.collections.length) delete proj.collections;
|
|
434
|
+
notes.push("-" + op.id);
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case "relinkMedia": case "replaceMedia": {
|
|
438
|
+
// relink: the same footage found somewhere else — clips keep their In points.
|
|
439
|
+
// replace: a different file behind the same media entry (a new render, a
|
|
440
|
+
// better take) — clips keep their timing, trimmed to fit a shorter file.
|
|
441
|
+
const name = op.op;
|
|
442
|
+
const m = proj.media.find((x) => x.id === op.id);
|
|
443
|
+
if (!m) throw new Error(`${name}: no media ${op.id}`);
|
|
444
|
+
const src = String(op.src || "");
|
|
445
|
+
if (!/^\/(media|library)\//.test(src)) throw new Error(`${name}: src must be a /media/… or /library/… path (import the file first with fablecut_import_media)`);
|
|
446
|
+
const info = ctx.mediaInfo ? ctx.mediaInfo(src) : undefined;
|
|
447
|
+
if (info === null) throw new Error(`${name}: no file at ${src}`);
|
|
448
|
+
const kindOf = MediaLib.kindFromName(src);
|
|
449
|
+
if (kindOf && kindOf !== m.kind && !(m.kind === "audio" && kindOf === "video")) // an audio entry may sit in a video container
|
|
450
|
+
throw new Error(`${name}: ${src} is ${kindOf === "audio" || kindOf === "image" ? "an" : "a"} ${kindOf} file, but ${m.id} is ${m.kind}`);
|
|
451
|
+
const was = { duration: m.duration };
|
|
452
|
+
m.src = src;
|
|
453
|
+
for (const k of MediaLib.INFO_KEYS) delete m[k];
|
|
454
|
+
if (name === "replaceMedia") {
|
|
455
|
+
m.name = op.name ? String(op.name).trim().slice(0, 200) : MediaLib.srcName(src);
|
|
456
|
+
delete m.duration; delete m.width; delete m.height;
|
|
457
|
+
} else if (op.name) m.name = String(op.name).trim().slice(0, 200);
|
|
458
|
+
if (info) {
|
|
459
|
+
for (const k of MediaLib.INFO_KEYS) if (info[k] != null) m[k] = info[k];
|
|
460
|
+
if (info.duration > 0 && m.kind !== "image" && m.kind !== "svg") m.duration = info.duration;
|
|
461
|
+
if (info.width > 0) { m.width = info.width; m.height = info.height; }
|
|
462
|
+
}
|
|
463
|
+
let note = `~${m.id}→${MediaLib.srcName(src)}`;
|
|
464
|
+
if (name === "relinkMedia" && was.duration > 0 && m.duration > 0 && Math.abs(was.duration - m.duration) > 0.1)
|
|
465
|
+
note += ` (warning: ${m.duration}s, the media was ${was.duration}s — is it the same footage?)`;
|
|
466
|
+
if (name === "replaceMedia" && m.duration > 0) {
|
|
467
|
+
// keep every clip's In and timing, trimmed to fit the new file
|
|
468
|
+
let trimmed = 0;
|
|
469
|
+
const fit = (clips) => {
|
|
470
|
+
for (const c of clips || []) {
|
|
471
|
+
if (c.mediaId !== m.id || c.kind === "image" || c.kind === "svg") continue;
|
|
472
|
+
const speed = (c.props && +c.props.speed) || 1;
|
|
473
|
+
const maxIn = Math.max(0, m.duration - 0.001);
|
|
474
|
+
if ((+c.in || 0) > maxIn) { c.in = +maxIn.toFixed(3); trimmed++; }
|
|
475
|
+
const maxDur = Math.max(0.05, (m.duration - (+c.in || 0)) / speed);
|
|
476
|
+
if (c.duration > maxDur + 1e-6) { c.duration = +maxDur.toFixed(3); trimmed++; }
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
fit(proj.clips);
|
|
480
|
+
for (const comp of proj.comps || []) fit(comp.clips);
|
|
481
|
+
if (trimmed) note += ` (${trimmed} clip edge${trimmed === 1 ? "" : "s"} trimmed to the new file's ${m.duration}s)`;
|
|
482
|
+
}
|
|
483
|
+
notes.push(note);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
case "removeMedia": {
|
|
487
|
+
const used = proj.clips.find((c) => c.mediaId === op.id);
|
|
488
|
+
if (used) throw new Error(`removeMedia: media ${op.id} is used by clip ${used.id}`);
|
|
489
|
+
for (const comp of proj.comps || []) {
|
|
490
|
+
const inner = (comp.clips || []).find((c) => c.mediaId === op.id);
|
|
491
|
+
if (inner) throw new Error(`removeMedia: media ${op.id} is used by clip ${inner.id} in comp ${comp.id}`);
|
|
492
|
+
}
|
|
493
|
+
const n = proj.media.length;
|
|
494
|
+
proj.media = proj.media.filter((x) => x.id !== op.id);
|
|
495
|
+
if (proj.media.length === n) throw new Error("removeMedia: no media " + op.id);
|
|
496
|
+
if (proj.collections) proj.collections = MediaLib.normalizeCollections(proj.collections, proj.media);
|
|
497
|
+
notes.push("-" + op.id);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case "setFx": {
|
|
501
|
+
// {op:"setFx", target:"clip"|"track"|"bus"|"master", id?, fx?:[…] | preset?:"podcast", append?:true}
|
|
502
|
+
const t = fxTarget(op, "setFx");
|
|
503
|
+
let chain;
|
|
504
|
+
if (op.preset != null) chain = FX.presetChain(String(op.preset));
|
|
505
|
+
else if (op.fx === null) chain = [];
|
|
506
|
+
else chain = FX.normalizeFx(op.fx, true);
|
|
507
|
+
t.write(op.append ? [...FX.normalizeFx(t.read()), ...chain] : chain);
|
|
508
|
+
notes.push("~" + t.label + ".fx");
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case "setFxKeys": {
|
|
512
|
+
// {op:"setFxKeys", target, id?, index?|type?, param, keys:[{t, v, ease?}] | null}
|
|
513
|
+
const t = fxTarget(op, "setFxKeys");
|
|
514
|
+
const list = FX.normalizeFx(t.read()).map((e) => ({ ...e }));
|
|
515
|
+
let i = Number.isInteger(op.index) ? op.index : op.type != null ? list.findIndex((e) => e.type === op.type) : list.length === 1 ? 0 : -1;
|
|
516
|
+
if (!list[i]) throw new Error(`setFxKeys: pick the effect with index (0…${list.length - 1}) or type — ${t.label} has ${FX.summarizeFx(list) || "no effects"}`);
|
|
517
|
+
const e = list[i];
|
|
518
|
+
if (!FX.FX_DEFS[e.type].params[op.param]) throw new Error(`setFxKeys: ${e.type} has no parameter ${JSON.stringify(op.param)} (has: ${Object.keys(FX.FX_DEFS[e.type].params).join(", ")})`);
|
|
519
|
+
const keys = { ...(e.keys || {}) };
|
|
520
|
+
if (op.keys == null || (Array.isArray(op.keys) && !op.keys.length)) delete keys[op.param];
|
|
521
|
+
else Object.assign(keys, FX.normalizeKeys(e.type, { [op.param]: op.keys }, true) || {});
|
|
522
|
+
if (Object.keys(keys).length) e.keys = keys; else delete e.keys;
|
|
523
|
+
t.write(list);
|
|
524
|
+
notes.push(`~${t.label}.fx[${i}].${op.param}` + (keys[op.param] ? `(${keys[op.param].length} keys)` : "(keys cleared)"));
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
case "setGrade": {
|
|
528
|
+
// {op:"setGrade", id | ids, grade:{…} | null, replace?:true}
|
|
529
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
530
|
+
if (!ids.length) throw new Error("setGrade needs id or ids");
|
|
531
|
+
if (op.grade !== null && (typeof op.grade !== "object" || Array.isArray(op.grade)))
|
|
532
|
+
throw new Error("setGrade needs grade:{…} (or grade:null to clear)");
|
|
533
|
+
if (op.grade) Color.normalizeGrade(Object.fromEntries(Object.entries(op.grade).filter(([, v]) => v !== null)), true); // validate first
|
|
534
|
+
for (const id of ids) {
|
|
535
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
536
|
+
if (!c) throw new Error("setGrade: no clip " + id);
|
|
537
|
+
if (!["video", "image", "svg", "adjust"].includes(c.kind)) throw new Error(`setGrade: ${id} is a ${c.kind} clip — grades go on video, image, svg or adjust clips`);
|
|
538
|
+
refuseLocked("setGrade", lockReason(c), op);
|
|
539
|
+
const next = op.grade === null ? null : Color.mergeGrade(op.replace ? null : c.props?.grade, op.grade, true);
|
|
540
|
+
c.props = c.props || {};
|
|
541
|
+
if (next) c.props.grade = next; else delete c.props.grade;
|
|
542
|
+
notes.push(`~${id}.grade(${Color.summarizeGrade(next)})`);
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
case "setGradeLayer": {
|
|
547
|
+
// {op:"setGradeLayer", id | ids, layer: index | "name", set?:{…}, replace?:true, remove?:true}
|
|
548
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
549
|
+
if (!ids.length) throw new Error("setGradeLayer needs id or ids");
|
|
550
|
+
const byName = typeof op.layer === "string" && op.layer.trim() !== "";
|
|
551
|
+
if (!byName && !(Number.isInteger(op.layer) && op.layer >= 0)) throw new Error("setGradeLayer needs layer: an index (0 = first) or a layer name");
|
|
552
|
+
if (!op.remove && (op.set == null || typeof op.set !== "object" || Array.isArray(op.set))) throw new Error("setGradeLayer needs set:{…} (or remove:true)");
|
|
553
|
+
for (const id of ids) {
|
|
554
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
555
|
+
if (!c) throw new Error("setGradeLayer: no clip " + id);
|
|
556
|
+
if (!["video", "image", "svg", "adjust"].includes(c.kind)) throw new Error(`setGradeLayer: ${id} is a ${c.kind} clip — grades go on video, image, svg or adjust clips`);
|
|
557
|
+
refuseLocked("setGradeLayer", lockReason(c), op);
|
|
558
|
+
const layers = (c.props?.grade?.layers || []).map((l) => ({ ...l }));
|
|
559
|
+
let i = byName ? layers.findIndex((l) => l.name === op.layer.trim()) : op.layer;
|
|
560
|
+
if (op.remove) {
|
|
561
|
+
if (i < 0 || i >= layers.length) throw new Error(`setGradeLayer: ${id} has no layer ${JSON.stringify(op.layer)}`);
|
|
562
|
+
layers.splice(i, 1);
|
|
563
|
+
} else {
|
|
564
|
+
if (i < 0 || i === layers.length) { // a new layer
|
|
565
|
+
if (layers.length >= Color.MAX_LAYERS) throw new Error(`setGradeLayer: at most ${Color.MAX_LAYERS} layers`);
|
|
566
|
+
i = layers.length; layers.push({ name: byName ? op.layer.trim() : `Layer ${i + 1}` });
|
|
567
|
+
} else if (i > layers.length) throw new Error(`setGradeLayer: ${id} has ${layers.length} layer(s) — use layer:${layers.length} to add one`);
|
|
568
|
+
const base = op.replace ? { name: layers[i].name } : layers[i];
|
|
569
|
+
let next;
|
|
570
|
+
try { next = Color.mergeLayer(base, op.set, true); } catch (err) { throw new Error(`setGradeLayer: ${err.message.replace(/^grade\.layers\[0\]\.?/, "")}`); }
|
|
571
|
+
if (!next.name) next.name = layers[i].name || `Layer ${i + 1}`;
|
|
572
|
+
layers[i] = next;
|
|
573
|
+
}
|
|
574
|
+
const g = Color.mergeGrade(c.props?.grade, { layers: layers.length ? layers : null }, true);
|
|
575
|
+
c.props = c.props || {};
|
|
576
|
+
if (g) c.props.grade = g; else delete c.props.grade;
|
|
577
|
+
notes.push(`~${id}.grade(${Color.summarizeGrade(g)})`);
|
|
578
|
+
}
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
case "setMask":
|
|
582
|
+
case "removeMask":
|
|
583
|
+
case "setMaskKeys": {
|
|
584
|
+
// {op:"setMask", id | ids, mask?: index | "name", set:{…}, replace?:true, remove?:true}
|
|
585
|
+
// {op:"removeMask", id | ids, mask} · {op:"setMaskKeys", id | ids, mask, keys:[…] | null}
|
|
586
|
+
const name = op.op;
|
|
587
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
588
|
+
if (!ids.length) throw new Error(`${name} needs id or ids`);
|
|
589
|
+
const remove = name === "removeMask" || op.remove === true;
|
|
590
|
+
const byName = typeof op.mask === "string" && op.mask.trim() !== "";
|
|
591
|
+
if (op.mask != null && !byName && !(Number.isInteger(op.mask) && op.mask >= 0)) throw new Error(`${name}: mask is an index (0 = first) or a mask name`);
|
|
592
|
+
if ((remove || name === "setMaskKeys") && op.mask == null) throw new Error(`${name} needs mask: an index or a name`);
|
|
593
|
+
let set;
|
|
594
|
+
if (name === "setMaskKeys") {
|
|
595
|
+
if (op.keys !== null && !Array.isArray(op.keys)) throw new Error("setMaskKeys needs keys:[{t, x?, y?, …}] (or keys:null to stop animating)");
|
|
596
|
+
set = { keys: op.keys && op.keys.length ? op.keys : null };
|
|
597
|
+
} else if (!remove) {
|
|
598
|
+
if (op.set == null || typeof op.set !== "object" || Array.isArray(op.set)) throw new Error("setMask needs set:{shape?, x?, y?, …} (or remove:true)");
|
|
599
|
+
set = op.set;
|
|
600
|
+
}
|
|
601
|
+
for (const id of ids) {
|
|
602
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
603
|
+
if (!c) throw new Error(`${name}: no clip ${id}`);
|
|
604
|
+
if (c.kind === "audio") throw new Error(`${name}: ${id} is an audio clip — masks go on video, image, svg, text or adjust clips`);
|
|
605
|
+
refuseLocked(name, lockReason(c), op);
|
|
606
|
+
const list = (Mask.normalizeMasks(c.props?.masks) || []).slice();
|
|
607
|
+
let i = op.mask == null ? list.length : byName ? list.findIndex((m) => m.name === op.mask.trim()) : op.mask;
|
|
608
|
+
const where = `${name}: ${id}`;
|
|
609
|
+
if (remove) {
|
|
610
|
+
if (i < 0 || i >= list.length) throw new Error(`${where} has no mask ${JSON.stringify(op.mask)}`);
|
|
611
|
+
list.splice(i, 1);
|
|
612
|
+
} else {
|
|
613
|
+
const adding = i < 0 || i === list.length;
|
|
614
|
+
if (adding && name === "setMaskKeys") throw new Error(`${where} has no mask ${JSON.stringify(op.mask)}`);
|
|
615
|
+
if (!adding && i > list.length) throw new Error(`${where} has ${list.length} mask(s) — use mask:${list.length} (or leave mask out) to add one`);
|
|
616
|
+
if (adding && list.length >= Mask.MAX_MASKS) throw new Error(`${where}: at most ${Mask.MAX_MASKS} masks`);
|
|
617
|
+
const base = adding ? (byName ? { name: op.mask.trim() } : null) : op.replace ? { name: list[i].name } : list[i];
|
|
618
|
+
if (adding && !set.shape && !(base && base.shape)) throw new Error(`${where}: a new mask needs set.shape (rect | ellipse | bezier | freehand)`);
|
|
619
|
+
let next;
|
|
620
|
+
try { next = Mask.mergeMask(base, set, true); }
|
|
621
|
+
catch (err) { throw new Error(`${where}: ${err.message.replace(/^mask\.?/, "mask.")}`); }
|
|
622
|
+
if (adding) { i = list.length; list.push(next); } else list[i] = next;
|
|
623
|
+
}
|
|
624
|
+
c.props = c.props || {};
|
|
625
|
+
if (list.length) c.props.masks = list; else delete c.props.masks;
|
|
626
|
+
notes.push(`~${id}.masks(${Mask.describe(list) || "none"})`);
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
case "setShape":
|
|
631
|
+
case "removeShape": {
|
|
632
|
+
// {op:"setShape", id | ids, shape?: index | "name" | "2.0" (group path), set:{…}, replace?:true, remove?:true}
|
|
633
|
+
const name = op.op;
|
|
634
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
635
|
+
if (!ids.length) throw new Error(`${name} needs id or ids`);
|
|
636
|
+
const remove = name === "removeShape" || op.remove === true;
|
|
637
|
+
if (remove && op.shape == null) throw new Error(`${name} needs shape: an index, a name or a path like "2.0"`);
|
|
638
|
+
if (!remove && (op.set == null || typeof op.set !== "object" || Array.isArray(op.set))) throw new Error("setShape needs set:{type?, x?, y?, w?, h?, fill?, …} (or remove:true)");
|
|
639
|
+
const isPath = (v) => Array.isArray(v) || (typeof v === "string" && /^\d+(\.\d+)*$/.test(v.trim()));
|
|
640
|
+
for (const id of ids) {
|
|
641
|
+
const c = byId(id);
|
|
642
|
+
if (!c) throw new Error(`${name}: no clip ${id}`);
|
|
643
|
+
if (c.kind !== "shape") throw new Error(`${name}: ${id} is a ${c.kind} clip — shapes go on shape clips (addClip kind:"shape")`);
|
|
644
|
+
refuseLocked(name, lockReason(c), op);
|
|
645
|
+
const where = `${name}: ${id}`;
|
|
646
|
+
let list = (Motion.normalizeShapes(c.props?.shapes) || []).slice();
|
|
647
|
+
// the target as a path [i, j…]; its last index may be one past the end (= add there)
|
|
648
|
+
let path, named = null;
|
|
649
|
+
if (op.shape == null) path = [list.length];
|
|
650
|
+
else if (Number.isInteger(op.shape) && op.shape >= 0) path = [op.shape];
|
|
651
|
+
else if (isPath(op.shape)) path = (Array.isArray(op.shape) ? op.shape : op.shape.trim().split(".")).map(Number);
|
|
652
|
+
else if (typeof op.shape === "string" && op.shape.trim()) {
|
|
653
|
+
path = Motion.itemPaths(list).find((q) => Motion.getItem(list, q).name === op.shape.trim());
|
|
654
|
+
if (!path) { if (remove) throw new Error(`${where} has no shape named ${JSON.stringify(op.shape)}`); path = [list.length]; named = op.shape.trim(); }
|
|
655
|
+
} else throw new Error(`${name}: shape is an index (0 = first), a name or a path like "2.0"`);
|
|
656
|
+
if (path.some((i) => !Number.isInteger(i) || i < 0)) throw new Error(`${name}: bad shape path ${JSON.stringify(op.shape)}`);
|
|
657
|
+
const parentPath = path.slice(0, -1), last = path[path.length - 1];
|
|
658
|
+
const group = parentPath.length ? Motion.getItem(list, parentPath) : null;
|
|
659
|
+
if (parentPath.length && (!group || group.type !== "group")) throw new Error(`${where}: ${parentPath.join(".")} is not a group`);
|
|
660
|
+
const siblings = group ? group.items : list;
|
|
661
|
+
const it = siblings[last];
|
|
662
|
+
if (remove) {
|
|
663
|
+
if (!it) throw new Error(`${where} has no shape ${JSON.stringify(op.shape)}${list.length ? ` (it has ${Motion.describe(list)})` : ""}`);
|
|
664
|
+
list = Motion.updateItem(list, path, () => null);
|
|
665
|
+
} else if (!it) {
|
|
666
|
+
if (last !== siblings.length) throw new Error(`${where}: ${parentPath.length ? `group ${parentPath.join(".")}` : "the layer"} has ${siblings.length} item(s) — use index ${siblings.length} (or leave shape out) to add one`);
|
|
667
|
+
if (!op.set.type) throw new Error(`${where}: a new shape needs set.type (${Motion.TYPES.join(" | ")})`);
|
|
668
|
+
let next;
|
|
669
|
+
try { next = Motion.normalizeItem({ ...op.set, ...(named && !op.set.name ? { name: named } : {}) }, true, "shape"); }
|
|
670
|
+
catch (err) { throw new Error(`${where}: ${err.message}`); }
|
|
671
|
+
list = group ? Motion.updateItem(list, parentPath, (g) => ({ ...g, items: [...g.items, next] })) : [...list, next];
|
|
672
|
+
} else {
|
|
673
|
+
let next;
|
|
674
|
+
const base = op.replace ? { type: it.type, name: it.name, ...(it.type === "group" ? { items: it.items } : {}) } : it;
|
|
675
|
+
try { next = Motion.mergeItem(base, op.set, true); }
|
|
676
|
+
catch (err) { throw new Error(`${where}: ${err.message}`); }
|
|
677
|
+
list = Motion.updateItem(list, path, () => next);
|
|
678
|
+
}
|
|
679
|
+
let n;
|
|
680
|
+
try { n = Motion.normalizeShapes(list, true); } catch (err) { throw new Error(`${where}: ${err.message}`); }
|
|
681
|
+
c.props = c.props || {};
|
|
682
|
+
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
683
|
+
notes.push(`~${id}.shapes(${Motion.describe(n) || "none"})`);
|
|
684
|
+
}
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
case "setParent": {
|
|
688
|
+
// {op:"setParent", id | ids, parent: "c_…" | null, keep?: true, at?: seconds}
|
|
689
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
690
|
+
if (!ids.length) throw new Error("setParent needs id or ids");
|
|
691
|
+
if (op.parent !== null && typeof op.parent !== "string") throw new Error("setParent needs parent: a clip id (or null to unparent)");
|
|
692
|
+
if (op.at != null && typeof op.at !== "number") throw new Error("setParent: at is a timeline time in seconds");
|
|
693
|
+
for (const id of ids) {
|
|
694
|
+
const c = byId(id);
|
|
695
|
+
if (!c) throw new Error("setParent: no clip " + id);
|
|
696
|
+
refuseLocked("setParent", lockReason(c), op);
|
|
697
|
+
const prev = c.parent;
|
|
698
|
+
c.parent = op.parent;
|
|
699
|
+
try { checkParent(c, `setParent: ${id}`); } finally { if (prev) c.parent = prev; else delete c.parent; }
|
|
700
|
+
if (op.keep !== false) {
|
|
701
|
+
const t = op.at != null ? Math.min(Math.max(op.at, c.start), c.start + c.duration) : c.start;
|
|
702
|
+
const v = Motion.reparentValues(c, op.parent, byId, t);
|
|
703
|
+
c.props = c.props || {};
|
|
704
|
+
for (const [k, val] of Object.entries(v)) if (!c.keyframes?.[k]?.length) c.props[k] = val;
|
|
705
|
+
}
|
|
706
|
+
if (op.parent) c.parent = op.parent; else delete c.parent;
|
|
707
|
+
notes.push(`~${id}.parent(${op.parent || "none"})`);
|
|
708
|
+
}
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
case "setMotionPath": {
|
|
712
|
+
// {op:"setMotionPath", id | ids, path?: "smooth" | "straight", autoOrient?: bool}
|
|
713
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
714
|
+
if (!ids.length) throw new Error("setMotionPath needs id or ids");
|
|
715
|
+
if (op.path != null && !["smooth", "straight"].includes(op.path)) throw new Error('setMotionPath: path is "smooth" (curve through the position keys) or "straight"');
|
|
716
|
+
if (op.autoOrient != null && typeof op.autoOrient !== "boolean") throw new Error("setMotionPath: autoOrient is true or false");
|
|
717
|
+
if (op.path == null && op.autoOrient == null) throw new Error('setMotionPath needs path:"smooth"|"straight" and / or autoOrient');
|
|
718
|
+
for (const id of ids) {
|
|
719
|
+
const c = byId(id);
|
|
720
|
+
if (!c) throw new Error("setMotionPath: no clip " + id);
|
|
721
|
+
if (c.kind === "audio" || c.kind === "adjust") throw new Error(`setMotionPath: ${id} is a ${c.kind} clip`);
|
|
722
|
+
refuseLocked("setMotionPath", lockReason(c), op);
|
|
723
|
+
if (op.path) {
|
|
724
|
+
if (Motion.posKeys(c).length < 2) throw new Error(`setMotionPath: ${id} needs x and y keyed together at two or more times first (the position keys)`);
|
|
725
|
+
c.keyframes = op.path === "smooth" ? Motion.smoothPath(c.keyframes) : Motion.straightPath(c.keyframes);
|
|
726
|
+
}
|
|
727
|
+
if (op.autoOrient != null) { c.props = c.props || {}; if (op.autoOrient) c.props.autoOrient = true; else delete c.props.autoOrient; }
|
|
728
|
+
notes.push(`~${id}.motionPath(${Motion.posKeys(c).length} keys${Motion.hasPathHandles(c) ? ", curved" : ""}${c.props && c.props.autoOrient ? ", auto-orient" : ""})`);
|
|
729
|
+
}
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case "setTextPath": {
|
|
733
|
+
// {op:"setTextPath", id | ids, path: "line" | "arc" | "circle" | "wave" | {type, …} | null, offset?, align?}
|
|
734
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
735
|
+
if (!ids.length) throw new Error("setTextPath needs id or ids");
|
|
736
|
+
if (op.path === undefined && op.offset == null && op.align == null) throw new Error("setTextPath needs path (a preset name, a shape item or null), offset and / or align");
|
|
737
|
+
let item;
|
|
738
|
+
if (op.path === null) item = null;
|
|
739
|
+
else if (typeof op.path === "string") {
|
|
740
|
+
if (!Motion.TEXT_PATH_PRESETS[op.path]) throw new Error(`setTextPath: path is ${Object.keys(Motion.TEXT_PATH_PRESETS).join(" | ")}, a shape item or null`);
|
|
741
|
+
item = Motion.normalizeTextPath(Motion.TEXT_PATH_PRESETS[op.path], true);
|
|
742
|
+
} else if (op.path !== undefined) {
|
|
743
|
+
try { item = Motion.normalizeTextPath(op.path, true); } catch (err) { throw new Error(`setTextPath: ${err.message}`); }
|
|
744
|
+
}
|
|
745
|
+
if (op.offset != null && typeof op.offset !== "number") throw new Error("setTextPath: offset is % of the path's length");
|
|
746
|
+
if (op.align != null && !["start", "center", "end"].includes(op.align)) throw new Error("setTextPath: align is start | center | end");
|
|
747
|
+
for (const id of ids) {
|
|
748
|
+
const c = byId(id);
|
|
749
|
+
if (!c) throw new Error("setTextPath: no clip " + id);
|
|
750
|
+
if (c.kind !== "text") throw new Error(`setTextPath: ${id} is a ${c.kind} clip — text paths go on text clips`);
|
|
751
|
+
refuseLocked("setTextPath", lockReason(c), op);
|
|
752
|
+
c.props = c.props || {};
|
|
753
|
+
if (op.path !== undefined) { if (item) c.props.textPath = JSON.parse(JSON.stringify(item)); else delete c.props.textPath; }
|
|
754
|
+
if (op.offset != null) c.props.pathOffset = op.offset;
|
|
755
|
+
if (op.align != null) c.props.pathAlign = op.align;
|
|
756
|
+
notes.push(`~${id}.textPath(${c.props.textPath ? c.props.textPath.type : "none"})`);
|
|
757
|
+
}
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
case "addComp": {
|
|
761
|
+
// {op:"addComp", comp:{id?, name, width?, height?, duration, background?, tracks?, clips?:[…]}}
|
|
762
|
+
const src = op.comp;
|
|
763
|
+
if (!src || typeof src !== "object") throw new Error("addComp needs comp:{name, duration, …}");
|
|
764
|
+
const id = src.id || "comp_" + uid();
|
|
765
|
+
if (CompLib.getComp(proj, id)) throw new Error(`addComp: comp ${id} already exists`);
|
|
766
|
+
let comp;
|
|
767
|
+
try { comp = CompLib.normalizeComp({ ...src, id, clips: [] }, true, proj); } catch (err) { throw new Error(`addComp: ${err.message}`); }
|
|
768
|
+
if (!comp.tracks) comp.tracks = CompLib.DEFAULT_TRACKS.map((t) => ({ ...t }));
|
|
769
|
+
proj.comps = [...(proj.comps || []), comp];
|
|
770
|
+
notes.push(`+comp ${id}`);
|
|
771
|
+
// its clips go in through the same checks as addClip
|
|
772
|
+
if (Array.isArray(src.clips) && src.clips.length) applyOps([{ op: "inComp", comp: id, ops: src.clips.map((clip) => ({ op: "addClip", clip })) }]);
|
|
773
|
+
break;
|
|
774
|
+
}
|
|
775
|
+
case "updateComp": {
|
|
776
|
+
// {op:"updateComp", id, set:{name?, width?, height?, duration?, background?, tracks?}}
|
|
777
|
+
const comp = CompLib.getComp(proj, op.id);
|
|
778
|
+
if (!comp) throw new Error("updateComp: no comp " + op.id);
|
|
779
|
+
const set = op.set || {};
|
|
780
|
+
for (const k of Object.keys(set)) if (!["name", "width", "height", "duration", "background", "tracks", "disabledTracks"].includes(k))
|
|
781
|
+
throw new Error(`updateComp: ${k} can't be set here (name, width, height, duration, background, tracks, disabledTracks) — edit its clips with inComp`);
|
|
782
|
+
const next = { ...comp, ...set };
|
|
783
|
+
for (const [k, v] of Object.entries(set)) if (v === null) delete next[k];
|
|
784
|
+
let n;
|
|
785
|
+
try { n = CompLib.normalizeComp(next, true, proj); } catch (err) { throw new Error(`updateComp: ${err.message}`); }
|
|
786
|
+
proj.comps = proj.comps.map((c) => (c.id === comp.id ? n : c));
|
|
787
|
+
notes.push(`~comp ${comp.id}`);
|
|
788
|
+
break;
|
|
789
|
+
}
|
|
790
|
+
case "removeComp": {
|
|
791
|
+
const comp = CompLib.getComp(proj, op.id);
|
|
792
|
+
if (!comp) throw new Error("removeComp: no comp " + op.id);
|
|
793
|
+
const used = CompLib.usages(proj, comp.id);
|
|
794
|
+
if (used.length) throw new Error(`removeComp: ${comp.id} is used by ${used.map((u) => u.clip.id + (u.in ? ` (in ${u.in})` : "")).join(", ")} — remove those clips first`);
|
|
795
|
+
proj.comps = proj.comps.filter((c) => c.id !== comp.id);
|
|
796
|
+
notes.push(`-comp ${comp.id}`);
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
case "precompose": {
|
|
800
|
+
// {op:"precompose", ids:[…], name?, id?} — the clips (here, or inside the comp an inComp op is in) move into a new comp
|
|
801
|
+
const ids = Array.isArray(op.ids) ? op.ids : [];
|
|
802
|
+
if (!ids.length) throw new Error("precompose needs ids:[…] (the clips to move into the new comp)");
|
|
803
|
+
for (const id of ids) {
|
|
804
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
805
|
+
if (!c) throw new Error("precompose: no clip " + id + (inCompId ? ` in comp ${inCompId}` : ""));
|
|
806
|
+
refuseLocked("precompose", lockReason(c), op);
|
|
807
|
+
}
|
|
808
|
+
const doc = { clips: proj.clips, comps: proj.comps || [], tracks: proj.tracks, width: proj.width, height: proj.height };
|
|
809
|
+
let r;
|
|
810
|
+
try { r = CompLib.precompose(doc, ids, { id: op.id, name: op.name }); } catch (err) { throw new Error(err.message); }
|
|
811
|
+
proj.clips = doc.clips; proj.comps = doc.comps;
|
|
812
|
+
r.clip.id = "c_" + uid();
|
|
813
|
+
notes.push(`+comp ${r.comp.id} (${ids.length} clips) +${r.clip.id}`);
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
case "applyPreset": {
|
|
817
|
+
// {op:"applyPreset", id | ids, preset: name | {anchor, length, keys…}, length?, at?} — at: timeline seconds, for an "at" (emphasis) preset
|
|
818
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id ? [op.id] : [];
|
|
819
|
+
if (!ids.length) throw new Error("applyPreset needs id or ids:[…]");
|
|
820
|
+
let p;
|
|
821
|
+
try { p = Templates.findPreset(op.preset, savedList("presets")); } catch (err) { throw new Error(`applyPreset: ${err.message}`); }
|
|
822
|
+
if (!p) throw new Error(`applyPreset: no preset ${JSON.stringify(op.preset)} — built in: ${Templates.PRESETS.map((x) => x.id).join(", ")}` +
|
|
823
|
+
(savedList("presets").length ? `; saved: ${savedList("presets").map((x) => x.id).join(", ")}` : ""));
|
|
824
|
+
if (op.length != null && !(typeof op.length === "number" && op.length > 0)) throw new Error("applyPreset: length is seconds, more than 0");
|
|
825
|
+
for (const id of ids) {
|
|
826
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
827
|
+
if (!c) throw new Error("applyPreset: no clip " + id + (inCompId ? ` in comp ${inCompId}` : ""));
|
|
828
|
+
if (c.kind === "audio" || c.kind === "adjust") throw new Error(`applyPreset: clip ${id} is ${c.kind} — presets animate visual clips`);
|
|
829
|
+
refuseLocked("applyPreset", lockReason(c), op);
|
|
830
|
+
Templates.applyPreset(c, p, { length: op.length, at: typeof op.at === "number" ? op.at - c.start : undefined });
|
|
831
|
+
}
|
|
832
|
+
notes.push(`~${ids.join(",")}.${p.id}`);
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
case "addTemplate": {
|
|
836
|
+
// {op:"addTemplate", template: name | {…}, start?, track?, duration?, fields?:{key: value}, id?, compId?, name?}
|
|
837
|
+
let t;
|
|
838
|
+
try { t = Templates.findTemplate(op.template, savedList("templates")); } catch (err) { throw new Error(`addTemplate: ${err.message}`); }
|
|
839
|
+
if (!t) throw new Error(`addTemplate: no template ${JSON.stringify(op.template)} — built in: ${Templates.TEMPLATES.map((x) => x.id).join(", ")}` +
|
|
840
|
+
(savedList("templates").length ? `; saved: ${savedList("templates").map((x) => x.id).join(", ")}` : ""));
|
|
841
|
+
let r;
|
|
842
|
+
try {
|
|
843
|
+
r = Templates.instantiate(proj, t, { compId: op.compId || "comp_" + uid(), clipId: op.id || "c_" + uid(), name: op.name,
|
|
844
|
+
start: typeof op.start === "number" ? op.start : 0, track: op.track || "V2", duration: op.duration, fields: op.fields });
|
|
845
|
+
} catch (err) { throw new Error(`addTemplate: ${err.message}`); }
|
|
846
|
+
notes.push(`+comp ${r.comp.id} (${t.id})`);
|
|
847
|
+
applyOps([{ op: "addClip", clip: r.clip }]); // the same checks as any clip: track, locks
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
case "setTemplate": {
|
|
851
|
+
// {op:"setTemplate", id: comp clip id | comp id, fields?:{key: value}, duration?}
|
|
852
|
+
let comp = CompLib.getComp(proj, op.id);
|
|
853
|
+
if (!comp) { const c = proj.clips.find((x) => x.id === op.id && x.kind === "comp"); comp = c && CompLib.getComp(proj, c.compId); }
|
|
854
|
+
if (!comp) throw new Error("setTemplate: no template clip or comp " + op.id);
|
|
855
|
+
if (!comp.template) throw new Error(`setTemplate: comp ${comp.id} isn't a placed template — edit its clips with inComp`);
|
|
856
|
+
try {
|
|
857
|
+
for (const [k, v] of Object.entries(op.fields || {})) Templates.setField(comp, Templates.slug(k), v, proj);
|
|
858
|
+
if (op.duration != null) {
|
|
859
|
+
const old = comp.duration;
|
|
860
|
+
Templates.retime(comp, op.duration);
|
|
861
|
+
// clips that showed the comp to its end keep doing so
|
|
862
|
+
for (const u of CompLib.usages(proj, comp.id)) if (Math.abs((+u.clip.in || 0) + u.clip.duration - old) < 1e-3) u.clip.duration = +(comp.duration - (+u.clip.in || 0)).toFixed(4);
|
|
863
|
+
}
|
|
864
|
+
} catch (err) { throw new Error(`setTemplate: ${err.message}`); }
|
|
865
|
+
notes.push(`~template ${comp.id}`);
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
case "savePreset": {
|
|
869
|
+
// {op:"savePreset", id: clip id, name, anchor?:"in"|"out"|"whole", from?, to?} (clip seconds) or {op:"savePreset", preset:{…}}
|
|
870
|
+
let p;
|
|
871
|
+
try {
|
|
872
|
+
if (op.preset) p = Templates.normalizePreset(op.preset, true);
|
|
873
|
+
else {
|
|
874
|
+
const c = proj.clips.find((x) => x.id === op.id);
|
|
875
|
+
if (!c) throw new Error("no clip " + op.id);
|
|
876
|
+
const times = Object.values(c.keyframes || {}).flat().map((x) => x.t);
|
|
877
|
+
if (!times.length) throw new Error(`clip ${c.id} has no keyframes to save`);
|
|
878
|
+
const anchor = op.anchor || "in";
|
|
879
|
+
p = Templates.presetFromClip(c, { name: op.name, anchor,
|
|
880
|
+
from: typeof op.from === "number" ? op.from : anchor === "out" ? Math.min(...times) : 0,
|
|
881
|
+
to: typeof op.to === "number" ? op.to : anchor === "in" ? Math.max(...times) : c.duration });
|
|
882
|
+
}
|
|
883
|
+
} catch (err) { throw new Error(`savePreset: ${err.message}`); }
|
|
884
|
+
p = Templates.asSaved(p, "presets");
|
|
885
|
+
toSave.push(["presets", p]);
|
|
886
|
+
notes.push(`saved preset ${p.id} → library/presets/${p.id}.json`);
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
case "saveTemplate": {
|
|
890
|
+
// {op:"saveTemplate", comp, name?, fields?, hold?} or {op:"saveTemplate", template:{…}}
|
|
891
|
+
let t;
|
|
892
|
+
try {
|
|
893
|
+
if (op.template) t = Templates.normalizeTemplate(op.template, true);
|
|
894
|
+
else {
|
|
895
|
+
const comp = CompLib.getComp(proj, op.comp);
|
|
896
|
+
if (!comp) throw new Error("no comp " + op.comp);
|
|
897
|
+
t = Templates.templateFromComp(comp, { name: op.name, fields: op.fields, hold: op.hold, media: proj.media });
|
|
898
|
+
}
|
|
899
|
+
} catch (err) { throw new Error(`saveTemplate: ${err.message}`); }
|
|
900
|
+
t = Templates.asSaved(t, "templates");
|
|
901
|
+
toSave.push(["templates", t]);
|
|
902
|
+
notes.push(`saved template ${t.id} → library/templates/${t.id}.json`);
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
case "inComp": {
|
|
906
|
+
// {op:"inComp", comp, ops:[…clip / timeline ops…]} — run ops on the comp's own clips and tracks
|
|
907
|
+
const comp = CompLib.getComp(proj, op.comp);
|
|
908
|
+
if (!comp) throw new Error("inComp: no comp " + op.comp);
|
|
909
|
+
if (!Array.isArray(op.ops)) throw new Error("inComp needs ops:[…]");
|
|
910
|
+
const allowed = ["addClip", "updateClip", "removeClip", "setGrade", "setGradeLayer", "setMask", "removeMask", "setMaskKeys", "setMatte",
|
|
911
|
+
"setShape", "removeShape", "setParent", "setMotionPath", "setTextPath", "setFx", "setFxKeys", "precompose", "inComp",
|
|
912
|
+
"applyPreset", "addTemplate", "setTemplate",
|
|
913
|
+
"split", "rippleDelete", "closeGap", "lift", "extract", "rippleTrim", "roll", "slip", "slide", "crossfade"];
|
|
914
|
+
for (const o of op.ops) {
|
|
915
|
+
if (!o || !allowed.includes(o.op)) throw new Error(`inComp: ${o && o.op} can't run inside a comp (clip and timeline ops only: ${allowed.join(", ")})`);
|
|
916
|
+
// the same target rule as fxTarget: no id means master, A… a track, B… a bus — none of them live in a comp
|
|
917
|
+
const fxAt = o.target || (o.id && /^A\d+$/.test(o.id) ? "track" : o.id && /^B\d+$/.test(o.id) ? "bus" : o.id ? "clip" : "master");
|
|
918
|
+
if ((o.op === "setFx" || o.op === "setFxKeys") && fxAt !== "clip")
|
|
919
|
+
throw new Error(`inComp: ${o.op} inside a comp works on its clips only (target:"clip", id) — the ${fxAt} effects are the main timeline's`);
|
|
920
|
+
}
|
|
921
|
+
const keep = { clips: proj.clips, tracks: proj.tracks, width: proj.width, height: proj.height, lockedTracks: proj.lockedTracks,
|
|
922
|
+
untargetedTracks: proj.untargetedTracks, disabledTracks: proj.disabledTracks, inPoint: proj.inPoint, outPoint: proj.outPoint };
|
|
923
|
+
const prevIn = inCompId;
|
|
924
|
+
Object.assign(proj, { clips: comp.clips || [], tracks: CompLib.compTracks(comp), width: comp.width, height: comp.height,
|
|
925
|
+
lockedTracks: [], untargetedTracks: [], disabledTracks: comp.disabledTracks || [], inPoint: null, outPoint: null });
|
|
926
|
+
inCompId = comp.id;
|
|
927
|
+
const n0 = notes.length;
|
|
928
|
+
try { applyOps(op.ops); }
|
|
929
|
+
finally {
|
|
930
|
+
const live = CompLib.getComp(proj, comp.id) || comp;
|
|
931
|
+
live.clips = proj.clips;
|
|
932
|
+
inCompId = prevIn;
|
|
933
|
+
Object.assign(proj, keep);
|
|
934
|
+
}
|
|
935
|
+
const inner = notes.splice(n0);
|
|
936
|
+
notes.push(`${comp.id}{${inner.join(" ")}}`);
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
case "removeTrack": {
|
|
940
|
+
// {op:"removeTrack", id, track: "name" | index} — what follows it keeps its keys
|
|
941
|
+
const c = proj.clips.find((x) => x.id === op.id);
|
|
942
|
+
if (!c) throw new Error("removeTrack: no clip " + op.id);
|
|
943
|
+
refuseLocked("removeTrack", lockReason(c), op);
|
|
944
|
+
const list = Tracker.normalizeTracks(c.props?.tracks) || [];
|
|
945
|
+
const i = typeof op.track === "string" ? list.findIndex((t) => t.name === op.track) : op.track;
|
|
946
|
+
if (!list[i]) throw new Error(`removeTrack: ${op.id} has no track ${JSON.stringify(op.track)}${list.length ? ` (it has ${Tracker.describe(list)})` : ""}`);
|
|
947
|
+
list.splice(i, 1);
|
|
948
|
+
if (list.length) c.props.tracks = list; else delete c.props.tracks;
|
|
949
|
+
notes.push(`~${op.id}.tracks(${Tracker.describe(list) || "none"})`);
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
case "setMatte": {
|
|
953
|
+
// {op:"setMatte", id | ids, matte:"alpha"|"alpha-inverted"|"luma"|"luma-inverted"|null, track?:"V3"|null}
|
|
954
|
+
const ids = Array.isArray(op.ids) ? op.ids : op.id != null ? [op.id] : [];
|
|
955
|
+
if (!ids.length) throw new Error("setMatte needs id or ids");
|
|
956
|
+
if (op.matte !== null && !Mask.MATTES.includes(op.matte)) throw new Error(`setMatte needs matte: ${Mask.MATTES.join(" | ")} (or null for none)`);
|
|
957
|
+
for (const id of ids) {
|
|
958
|
+
const c = proj.clips.find((x) => x.id === id);
|
|
959
|
+
if (!c) throw new Error("setMatte: no clip " + id);
|
|
960
|
+
if (c.kind === "audio") throw new Error(`setMatte: ${id} is an audio clip — track mattes go on video, image, svg, text or adjust clips`);
|
|
961
|
+
refuseLocked("setMatte", lockReason(c), op);
|
|
962
|
+
c.props = c.props || {};
|
|
963
|
+
if (op.matte === null) { delete c.props.matte; delete c.props.matteTrack; notes.push(`~${id}.matte(none)`); continue; }
|
|
964
|
+
c.props.matte = op.matte;
|
|
965
|
+
if (op.track === null) delete c.props.matteTrack;
|
|
966
|
+
else if (op.track != null) c.props.matteTrack = op.track;
|
|
967
|
+
try { Mask.checkCompositing(c.props, videoTrackIds(), c.track, true); } catch (err) { throw new Error(`setMatte: ${id}: ${err.message.replace(/^matteTrack/, "track")}`); }
|
|
968
|
+
const from = Mask.matteTrackFor(c, videoTrackIds());
|
|
969
|
+
if (!from) throw new Error(`setMatte: ${id} is on ${c.track} and there is no video track above it — pass track:"Vn" (or add a track)`);
|
|
970
|
+
notes.push(`~${id}.matte(${op.matte} from ${from})`);
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
case "setTrack": {
|
|
975
|
+
// Mixer settings on one lane: {op:"setTrack", id:"A1", set:{gain:-6, pan:0.2}}.
|
|
976
|
+
if (!/^A\d+$/.test(String(op.id || ""))) throw new Error("setTrack: id must be an audio track (A1, A2, …)");
|
|
977
|
+
if (!Array.isArray(proj.tracks) || !proj.tracks.length) proj.tracks = DEFAULT_TRACKS.map((t) => ({ ...t }));
|
|
978
|
+
let t = proj.tracks.find((x) => x.id === op.id);
|
|
979
|
+
if (!t) { t = { id: op.id, kind: "audio" }; proj.tracks.push(t); }
|
|
980
|
+
for (const [k, v] of Object.entries(op.set || {})) {
|
|
981
|
+
if (k === "out") { // route into a submix bus, or back to the master
|
|
982
|
+
if (v == null || v === "master") { delete t.out; continue; }
|
|
983
|
+
if (!(proj.buses || []).some((b) => b.id === v)) throw new Error(`setTrack: no bus ${JSON.stringify(v)} — create it first with {op:"setBus", id:"B1"}`);
|
|
984
|
+
t.out = v;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const range = k === "gain" ? [-60, 12] : k === "pan" ? [-1, 1] : null;
|
|
988
|
+
if (!range) throw new Error(`setTrack: '${k}' not settable (gain, pan, out)`);
|
|
989
|
+
if (v === null || v === 0) { delete t[k]; continue; }
|
|
990
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < range[0] || v > range[1])
|
|
991
|
+
throw new Error(`setTrack: ${k} must be a number ${range[0]}…${range[1]}`);
|
|
992
|
+
t[k] = v;
|
|
993
|
+
}
|
|
994
|
+
notes.push("~" + op.id);
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
case "setBus": {
|
|
998
|
+
// {op:"setBus", id:"B1", set:{name?, gain?, pan?, mute?}} — creates the bus if needed
|
|
999
|
+
if (!/^B\d+$/.test(String(op.id || ""))) throw new Error("setBus: id must be B1, B2, …");
|
|
1000
|
+
proj.buses = Array.isArray(proj.buses) ? proj.buses : [];
|
|
1001
|
+
let b = proj.buses.find((x) => x.id === op.id);
|
|
1002
|
+
if (!b) {
|
|
1003
|
+
if (proj.buses.length >= 8) throw new Error("setBus: up to 8 buses");
|
|
1004
|
+
b = { id: op.id };
|
|
1005
|
+
proj.buses.push(b);
|
|
1006
|
+
proj.buses.sort((x, y) => parseInt(x.id.slice(1), 10) - parseInt(y.id.slice(1), 10));
|
|
1007
|
+
}
|
|
1008
|
+
for (const [k, v] of Object.entries(op.set || {})) {
|
|
1009
|
+
if (k === "name") { if (v == null || v === "") delete b.name; else b.name = String(v).slice(0, 40); continue; }
|
|
1010
|
+
if (k === "mute") { if (v === true) b.mute = true; else delete b.mute; continue; }
|
|
1011
|
+
const range = k === "gain" ? [-60, 12] : k === "pan" ? [-1, 1] : null;
|
|
1012
|
+
if (!range) throw new Error(`setBus: '${k}' not settable (name, gain, pan, mute; effects via setFx target:"bus")`);
|
|
1013
|
+
if (v === null || v === 0) { delete b[k]; continue; }
|
|
1014
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < range[0] || v > range[1])
|
|
1015
|
+
throw new Error(`setBus: ${k} must be a number ${range[0]}…${range[1]}`);
|
|
1016
|
+
b[k] = v;
|
|
1017
|
+
}
|
|
1018
|
+
notes.push("~" + op.id);
|
|
1019
|
+
break;
|
|
1020
|
+
}
|
|
1021
|
+
case "removeBus": {
|
|
1022
|
+
const n = (proj.buses || []).length;
|
|
1023
|
+
proj.buses = (proj.buses || []).filter((b) => b.id !== op.id);
|
|
1024
|
+
if (proj.buses.length === n) throw new Error("removeBus: no bus " + op.id);
|
|
1025
|
+
if (!proj.buses.length) delete proj.buses;
|
|
1026
|
+
for (const t of proj.tracks || []) if (t.out === op.id) delete t.out; // back to the master
|
|
1027
|
+
notes.push("-" + op.id);
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
case "setProject": {
|
|
1031
|
+
const allowed = ["name", "width", "height", "fps", "background", "markers", "disabledTracks", "lockedTracks", "untargetedTracks", "exportFrame", "encodeProfile", "delivery", "outputs", "master", "inPoint", "outPoint"];
|
|
1032
|
+
for (const [k, v] of Object.entries(op.set || {})) {
|
|
1033
|
+
if (!allowed.includes(k)) throw new Error(`setProject: '${k}' not settable (allowed: ${allowed.join(", ")})`);
|
|
1034
|
+
if ((k === "inPoint" || k === "outPoint") && v != null && (typeof v !== "number" || !Number.isFinite(v) || v < 0))
|
|
1035
|
+
throw new Error(`setProject: ${k} must be seconds ≥ 0 (null clears it)`);
|
|
1036
|
+
if (k === "encodeProfile" && v != null) {
|
|
1037
|
+
if (ctx && ctx.checkProfile) ctx.checkProfile(String(v)); // validate id exists
|
|
1038
|
+
}
|
|
1039
|
+
if (k === "delivery" && v != null) {
|
|
1040
|
+
// a preset id or a spec → the complete spec, so the editor and agents read the same thing
|
|
1041
|
+
if (typeof v !== "string" && (typeof v !== "object" || Array.isArray(v)))
|
|
1042
|
+
throw new Error("setProject: delivery is a preset id ('youtube') or a spec {preset?, width?, height?, fps?, video?, audio?, …}");
|
|
1043
|
+
const n = Delivery.normalize(v, { presets: (ctx && ctx.deliveryPresets) || {} });
|
|
1044
|
+
if (typeof v === "string" && !Delivery.PRESETS[v] && !(ctx && ctx.deliveryPresets && ctx.deliveryPresets[v]))
|
|
1045
|
+
throw new Error(`setProject: no delivery preset "${v}" (built-in: ${Delivery.PRESET_IDS.join(", ")})`);
|
|
1046
|
+
proj.delivery = n.spec;
|
|
1047
|
+
notes.push(`delivery ${n.spec.preset}: ${Delivery.describe(n.spec)}${n.notes.length ? ` (adjusted: ${n.notes.join("; ")})` : ""}`);
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (k === "outputs" && v != null) {
|
|
1051
|
+
// the batch list: each entry a preset id or a spec, stored complete with {id, on}
|
|
1052
|
+
if (!Array.isArray(v) || v.length > 16) throw new Error("setProject: outputs is a list of up to 16 delivery specs (or preset ids)");
|
|
1053
|
+
const presets = (ctx && ctx.deliveryPresets) || {};
|
|
1054
|
+
proj.outputs = v.map((o, i) => {
|
|
1055
|
+
if (typeof o !== "string" && (!o || typeof o !== "object" || Array.isArray(o))) throw new Error(`setProject: outputs[${i}] is a preset id or a spec`);
|
|
1056
|
+
if (typeof o === "string" && !Delivery.PRESETS[o] && !presets[o]) throw new Error(`setProject: outputs[${i}]: no delivery preset "${o}"`);
|
|
1057
|
+
const n = Delivery.normalize(o, { presets });
|
|
1058
|
+
const id = typeof o === "object" && typeof o.id === "string" && o.id ? o.id.slice(0, 40) : "o_" + uid();
|
|
1059
|
+
return { id, on: !(typeof o === "object" && o.on === false), ...n.spec };
|
|
1060
|
+
});
|
|
1061
|
+
if (!proj.outputs.length) delete proj.outputs;
|
|
1062
|
+
notes.push(`outputs: ${(proj.outputs || []).map((o) => Delivery.projectFilename(o, proj)).join(", ") || "none"}`);
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (k === "master" && v != null) {
|
|
1066
|
+
const g = v.gain;
|
|
1067
|
+
if (typeof v !== "object" || Object.keys(v).some((x) => x !== "gain") ||
|
|
1068
|
+
(g != null && (typeof g !== "number" || !Number.isFinite(g) || g < -60 || g > 12)))
|
|
1069
|
+
throw new Error("setProject: master is {gain} — master fader in dB, -60…+12");
|
|
1070
|
+
}
|
|
1071
|
+
if (v === null) delete proj[k]; else proj[k] = v;
|
|
1072
|
+
}
|
|
1073
|
+
if (proj.inPoint != null && proj.outPoint != null && !(proj.outPoint > proj.inPoint))
|
|
1074
|
+
throw new Error("setProject: outPoint must be after inPoint");
|
|
1075
|
+
notes.push("~project");
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
case "split": case "rippleDelete": case "closeGap": case "lift": case "extract":
|
|
1079
|
+
case "rippleTrim": case "roll": case "slip": case "slide":
|
|
1080
|
+
case "insert": case "overwrite": case "crossfade":
|
|
1081
|
+
notes.push(timelineEdit(proj, op, refuseLocked, lockReason, ctx));
|
|
1082
|
+
break;
|
|
1083
|
+
default:
|
|
1084
|
+
throw new Error("Unknown op: " + op.op + " (addClip|updateClip|removeClip|addMedia|updateMedia|removeMedia|relinkMedia|replaceMedia|setCollection|removeCollection|setProject|setTrack|setBus|removeBus|setFx|setFxKeys|setGrade|setGradeLayer|setMask|removeMask|setMaskKeys|setMatte|removeTrack|setShape|removeShape|setParent|setMotionPath|setTextPath|" +
|
|
1085
|
+
"split|rippleDelete|closeGap|lift|extract|rippleTrim|roll|slip|slide|insert|overwrite|crossfade|" +
|
|
1086
|
+
"addComp|updateComp|removeComp|precompose|inComp|applyPreset|addTemplate|setTemplate|savePreset|saveTemplate)");
|
|
1087
|
+
}
|
|
1088
|
+
} };
|
|
1089
|
+
applyOps(ops);
|
|
1090
|
+
return { notes, toSave };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** opts.offline(media) → true when the media's file is missing;
|
|
1094
|
+
* opts.proxy(media) → its proxy's height (540, 720…) or null. */
|
|
1095
|
+
function compactView(doc, opts = {}) {
|
|
1096
|
+
const hex = (v) => typeof v === "string" && /^#[0-9a-f]{3}$/i.test(v)
|
|
1097
|
+
? "#" + [...v.slice(1)].map((c) => c + c).join("").toLowerCase()
|
|
1098
|
+
: (typeof v === "string" && /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : v);
|
|
1099
|
+
const fmtProps = (o, kind) => {
|
|
1100
|
+
if (!o) return "";
|
|
1101
|
+
const kept = {};
|
|
1102
|
+
for (const [k, v] of Object.entries(o)) {
|
|
1103
|
+
if (kind !== "text" && k === "text" && v === "Title") continue;
|
|
1104
|
+
// Always keep pan on linked stems — pan:0 (center) is meaningful and
|
|
1105
|
+
// must not be stripped, or agents rebuilding from compact can lose it.
|
|
1106
|
+
if (k === "pan" && Number.isInteger(o.audioChannel) && o.audioChannel >= 0) {
|
|
1107
|
+
kept[k] = v;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (k === "masks" || k === "tracks" || k === "shapes" || k === "textPath") continue; // summarized below — point lists and samples are long
|
|
1111
|
+
if (hex(PROP_DEFAULTS[k]) !== hex(v)) kept[k] = v;
|
|
1112
|
+
}
|
|
1113
|
+
const masks = Mask.describe(Mask.normalizeMasks(o.masks)), tracks = Tracker.describe(Tracker.normalizeTracks(o.tracks));
|
|
1114
|
+
const shapes = Motion.describe(Motion.normalizeShapes(o.shapes));
|
|
1115
|
+
const tp = o.textPath && Motion.normalizeTextPath(o.textPath);
|
|
1116
|
+
if (tp) kept.textPath = tp.type + (tp.points ? `(${tp.points.length})` : tp.w ? ` ${tp.w}×${tp.h}` : "");
|
|
1117
|
+
return (Object.keys(kept).length ? " " + JSON.stringify(kept) : "") + (shapes ? ` [${shapes}]` : "") + (masks ? ` [${masks}]` : "") + (tracks ? ` [tracks: ${tracks}]` : "");
|
|
1118
|
+
};
|
|
1119
|
+
// Mixer: only off-default faders / pans, so a fresh project adds no line.
|
|
1120
|
+
const signed = (v) => (v > 0 ? "+" : "") + v;
|
|
1121
|
+
const fxTag = (fx) => Array.isArray(fx) && fx.length ? ` fx:${FX.summarizeFx(fx)}` : "";
|
|
1122
|
+
const mixLine = (d) => {
|
|
1123
|
+
const parts = (Array.isArray(d.tracks) ? d.tracks : [])
|
|
1124
|
+
.filter((t) => t && (+t.gain || +t.pan || t.fx?.length || t.out))
|
|
1125
|
+
.map((t) => t.id + (t.out ? `→${t.out}` : "") + (+t.gain ? ` ${signed(+t.gain)}dB` : "") + (+t.pan ? ` pan:${+t.pan}` : "") + fxTag(t.fx));
|
|
1126
|
+
for (const b of Array.isArray(d.buses) ? d.buses : [])
|
|
1127
|
+
parts.push(`bus ${b.id}${b.name ? ` "${b.name}"` : ""}` + (+b.gain ? ` ${signed(+b.gain)}dB` : "") + (+b.pan ? ` pan:${+b.pan}` : "") +
|
|
1128
|
+
(b.mute ? " muted" : "") + fxTag(b.fx));
|
|
1129
|
+
if (d.master && (+d.master.gain || d.master.fx?.length))
|
|
1130
|
+
parts.push("master" + (+d.master.gain ? ` ${signed(+d.master.gain)}dB` : "") + fxTag(d.master.fx));
|
|
1131
|
+
return parts.length ? [`MIX: ${parts.join(" · ")}`] : [];
|
|
1132
|
+
};
|
|
1133
|
+
const lines = [
|
|
1134
|
+
`"${doc.name}" ${doc.width}x${doc.height}@${doc.fps} rev:${doc.revision}` +
|
|
1135
|
+
(doc.panSchema >= 1 ? " panSchema:1" : "") +
|
|
1136
|
+
(doc.background ? ` bg:${doc.background}` : "") +
|
|
1137
|
+
(doc.inPoint != null || doc.outPoint != null ? ` in/out:${doc.inPoint ?? "-"}→${doc.outPoint ?? "-"}` : "") +
|
|
1138
|
+
(doc.markers?.length ? ` markers:${doc.markers.length} [${doc.markers.slice(0, 12).map((m) => m.t).join(",")}${doc.markers.length > 12 ? ",…" : ""}]` : "") +
|
|
1139
|
+
(doc.lockedTracks?.length ? ` lockedTracks:[${doc.lockedTracks.join(",")}]` : "") +
|
|
1140
|
+
(doc.untargetedTracks?.length ? ` untargetedTracks:[${doc.untargetedTracks.join(",")}]` : "") +
|
|
1141
|
+
(doc.disabledTracks?.length ? ` disabledTracks:[${doc.disabledTracks.join(",")}]` : ""),
|
|
1142
|
+
...mixLine(doc),
|
|
1143
|
+
`MEDIA (${doc.media.length}):`,
|
|
1144
|
+
...(() => {
|
|
1145
|
+
const uses = MediaLib.usageCounts(doc);
|
|
1146
|
+
return doc.media.map((m) => {
|
|
1147
|
+
const info = MediaLib.describe(m);
|
|
1148
|
+
return ` ${m.id} ${m.kind} "${m.name}"${m.duration ? " " + m.duration + "s" : ""}` +
|
|
1149
|
+
(info ? ` ${info.replace(/ · /g, " ")}` : "") +
|
|
1150
|
+
(opts.offline && opts.offline(m) ? " OFFLINE (file missing — relinkMedia)" : "") +
|
|
1151
|
+
(opts.proxy && m.kind === "video" && opts.proxy(m) ? ` proxy:${opts.proxy(m)}p` : "") +
|
|
1152
|
+
(m.favorite ? " ★" : "") + (m.tags && m.tags.length ? " " + m.tags.map((t) => "#" + t.replace(/\s+/g, "_")).join(" ") : "") +
|
|
1153
|
+
(m.folderId ? ` folder:${m.folderId}` : "") + ` uses:${uses.get(m.id) || 0}` +
|
|
1154
|
+
(m.derivedFrom ? ` (${m.denoise ? "denoised " + m.denoise : "derived"} from ${m.derivedFrom})` : "");
|
|
1155
|
+
});
|
|
1156
|
+
})(),
|
|
1157
|
+
...(doc.collections && doc.collections.length
|
|
1158
|
+
? [`COLLECTIONS (${doc.collections.length}): ` + doc.collections.map((c) => `${c.id} "${c.name}" [${(c.media || []).join(",")}]`).join(" · ")]
|
|
1159
|
+
: []),
|
|
1160
|
+
`CLIPS (${doc.clips.length}), by track/time:`,
|
|
1161
|
+
...doc.clips
|
|
1162
|
+
.slice()
|
|
1163
|
+
.sort((a, b) => (a.track === b.track ? a.start - b.start : String(a.track).localeCompare(b.track)))
|
|
1164
|
+
.map((c) => {
|
|
1165
|
+
const kf = c.keyframes ? " kf:" + Object.entries(c.keyframes).map(([k, v]) => `${k}(${v.length})`).join(",") +
|
|
1166
|
+
(Motion.hasPathHandles(c) ? " path:curved" : "") : "";
|
|
1167
|
+
const tr = (c.transitionIn ? ` in:${c.transitionIn.type}/${c.transitionIn.duration}` : "") +
|
|
1168
|
+
(c.transitionOut ? ` out:${c.transitionOut.type}/${c.transitionOut.duration}` : "");
|
|
1169
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
1170
|
+
return ` ${c.id} ${c.track} ${r3(c.start)}s+${r3(c.duration)}s ${c.kind}` +
|
|
1171
|
+
(c.mediaId ? `(${c.mediaId}${c.in ? ` in:${r3(c.in)}` : ""})` : "") +
|
|
1172
|
+
(c.kind === "comp" ? `(${c.compId}${c.in ? ` in:${r3(c.in)}` : ""})` : "") +
|
|
1173
|
+
(c.name ? ` "${c.name}"` : "") + (c.parent ? ` parent:${c.parent}` : "") + fmtProps(c.props, c.kind) + kf + tr +
|
|
1174
|
+
(c.locked === true ? " [locked]" : "") + (c.disabled === true ? " [disabled]" : "") +
|
|
1175
|
+
(c.unlinked === true ? " [unlinked]" : "") + fxTag(c.fx);
|
|
1176
|
+
}),
|
|
1177
|
+
...(Array.isArray(doc.comps) && doc.comps.length ? [`COMPS (${doc.comps.length}) — edit their clips with {op:"inComp", comp, ops:[…]}:`,
|
|
1178
|
+
...doc.comps.flatMap((comp) => [` ${CompLib.describe(comp)}`,
|
|
1179
|
+
...(comp.template ? [` template ${comp.template.id} — fields (setTemplate): ` + (comp.template.fields || []).map((f) => {
|
|
1180
|
+
const v = Templates.fieldValue(comp, f);
|
|
1181
|
+
return `${f.key}=${typeof v === "string" ? JSON.stringify(v.length > 40 ? v.slice(0, 40) + "…" : v) : v}`;
|
|
1182
|
+
}).join(" ")] : []),
|
|
1183
|
+
...(comp.clips || []).slice().sort((a, b) => a.start - b.start).map((c) => {
|
|
1184
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
1185
|
+
return ` ${c.id} ${c.track} ${r3(c.start)}s+${r3(c.duration)}s ${c.kind}` + (c.mediaId ? `(${c.mediaId})` : "") + (c.kind === "comp" ? `(${c.compId})` : "") +
|
|
1186
|
+
(c.name ? ` "${c.name}"` : "") + (c.parent ? ` parent:${c.parent}` : "") + fmtProps(c.props, c.kind);
|
|
1187
|
+
})])] : []),
|
|
1188
|
+
`(compact view — full JSON: fablecut_get_project without compact; edit via fablecut_patch_project)`,
|
|
1189
|
+
];
|
|
1190
|
+
return lines.join("\n");
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function checkProject(doc) {
|
|
1194
|
+
if (doc.collections != null) {
|
|
1195
|
+
if (!Array.isArray(doc.collections)) throw new Error("collections must be a list of {id, name, media:[…]}");
|
|
1196
|
+
doc.collections = MediaLib.normalizeCollections(doc.collections, doc.media || []);
|
|
1197
|
+
}
|
|
1198
|
+
for (const m of doc.media || []) {
|
|
1199
|
+
if (m && m.tags != null) { const t = MediaLib.normalizeTags(m.tags); if (t.length) m.tags = t; else delete m.tags; }
|
|
1200
|
+
if (m && m.favorite != null && m.favorite !== true) delete m.favorite;
|
|
1201
|
+
}
|
|
1202
|
+
if (doc.comps != null) {
|
|
1203
|
+
if (!Array.isArray(doc.comps)) throw new Error("comps must be a list of compositions");
|
|
1204
|
+
// saved as normalized (trimmed id, default name and size, rounded duration)
|
|
1205
|
+
try { doc.comps = doc.comps.map((comp) => CompLib.normalizeComp(comp, true, doc)); } catch (err) { throw new Error(err.message); }
|
|
1206
|
+
const seen = new Set();
|
|
1207
|
+
for (const comp of doc.comps) {
|
|
1208
|
+
if (seen.has(comp.id)) throw new Error(`two comps have the id ${comp.id} — comp ids must be unique`);
|
|
1209
|
+
seen.add(comp.id);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// the same checks for clips on the timeline and clips inside comps
|
|
1213
|
+
const checkClip = (c, where) => {
|
|
1214
|
+
const at = where ? `comp ${where}: ` : "";
|
|
1215
|
+
if (!c.id || !c.track || typeof c.start !== "number" || typeof c.duration !== "number")
|
|
1216
|
+
throw new Error(`${at}clip ${c.id || "?"} needs id, track, numeric start and duration`);
|
|
1217
|
+
if (!["text", "adjust", "shape", "null", "comp"].includes(c.kind) && !doc.media.some((m) => m.id === c.mediaId))
|
|
1218
|
+
throw new Error(`${at}clip ${c.id} references unknown mediaId ${c.mediaId}`);
|
|
1219
|
+
if (c.kind === "comp" && !CompLib.getComp(doc, c.compId)) throw new Error(`${at}clip ${c.id} references unknown compId ${c.compId}`);
|
|
1220
|
+
if (c.kind === "comp" && where && CompLib.wouldCycle(doc, where, c.compId))
|
|
1221
|
+
throw new Error(`comp ${where}: clip ${c.id} uses comp ${c.compId}, which is missing or would contain itself`);
|
|
1222
|
+
if (c.kind === "shape" && c.props && c.props.shapes != null) {
|
|
1223
|
+
let n;
|
|
1224
|
+
try { n = Motion.normalizeShapes(c.props.shapes, true); } catch (err) { throw new Error(`${at}clip ${c.id}: props.${err.message}`); }
|
|
1225
|
+
if (n) c.props.shapes = n; else delete c.props.shapes;
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
for (const c of doc.clips) checkClip(c, null);
|
|
1229
|
+
for (const comp of doc.comps || []) for (const c of comp.clips) checkClip(c, comp.id);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const TEXT_ONLY_NOTE = [
|
|
1233
|
+
"Measured, not described: these are brightness, dominant colours, texture (detail within the",
|
|
1234
|
+
"frame) and change-since-the-previous-sample. They cannot tell you the subject — a person, a",
|
|
1235
|
+
"car, a landscape, text on screen. To choose footage by what it shows, use a vision-capable",
|
|
1236
|
+
"model and drop `text:true`, or stay with fablecut_analyze_reference for structure.",
|
|
1237
|
+
].join("\n");
|
|
1238
|
+
|
|
1239
|
+
/** color.js scopeStats → text lines, for fablecut_frame {text:true} in timeline
|
|
1240
|
+
* mode. Same measurements fablecut_scopes reports, so a text-only agent gets
|
|
1241
|
+
* the composed picture described on the same terms as a source frame. */
|
|
1242
|
+
function describeScopeLines(st) {
|
|
1243
|
+
const L = st.luma, cast = st.cast;
|
|
1244
|
+
const bright = L.mean < 0.12 ? "very dark" : L.mean < 0.35 ? "dark" : L.mean < 0.65 ? "mid" : L.mean < 0.85 ? "bright" : "very bright";
|
|
1245
|
+
return [
|
|
1246
|
+
`${bright} (luma mean ${L.mean}, median ${L.median}, range ${L.min}–${L.max})`,
|
|
1247
|
+
` clipped: black ${st.clipped.blackPct}% · white ${st.clipped.whitePct}%`,
|
|
1248
|
+
` mean rgb [${st.rgbMean.join(", ")}] · saturation ${st.saturation}`,
|
|
1249
|
+
` colour cast: ${cast.tone === "neutral" ? "neutral" : `${cast.tone} (hue ${cast.hue}°), strength ${cast.strength}`}`,
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/* ── Scopes (fablecut_scopes): one graded still, measured in the editor ── */
|
|
1254
|
+
function describeScopes(j) {
|
|
1255
|
+
if (j.status !== "done") return `Scope reading ${j.id} ${j.status}${j.error ? ": " + j.error : ""}`;
|
|
1256
|
+
const r = j.result || {}, st = r.stats;
|
|
1257
|
+
if (!st) return `Frame at ${r.time}s is empty (nothing drawn).`;
|
|
1258
|
+
const L = st.luma, cast = st.cast;
|
|
1259
|
+
const lines = [
|
|
1260
|
+
`Frame at ${r.time}s (${r.frame?.w}x${r.frame?.h}):`,
|
|
1261
|
+
` luma min ${L.min} · p1 ${L.p1} · median ${L.median} · mean ${L.mean} · p99 ${L.p99} · max ${L.max}`,
|
|
1262
|
+
` clipped: black ${st.clipped.blackPct}% · white ${st.clipped.whitePct}%`,
|
|
1263
|
+
` rgb mean [${st.rgbMean.join(", ")}] · saturation ${st.saturation}`,
|
|
1264
|
+
` cast: ${cast.tone === "neutral" ? "neutral" : `${cast.tone} (hue ${cast.hue}°)`} · strength ${cast.strength}`,
|
|
1265
|
+
`On screen (bottom → top): ${(r.clips || []).map((c) => `${c.id} ${c.track} ${c.kind}${c.grade !== "neutral" ? " grade:" + c.grade : ""}${c.masks ? ` masks:[${c.masks}]` : ""}${c.matte ? ` matte:${c.matte}` : ""}${c.isMatte ? " (a matte — not drawn)" : ""}${c.blend ? " blend:" + c.blend : ""}`).join(" | ") || "nothing"}`,
|
|
1266
|
+
];
|
|
1267
|
+
const m = r.matte;
|
|
1268
|
+
if (m) {
|
|
1269
|
+
lines.push(m.error ? `Matte ${m.clip} layer ${m.layer}: ${m.error}` : `Matte of ${m.clip} layer ${m.layer}: covers ${m.coverage}% of the clip's picture` +
|
|
1270
|
+
(m.selected ? ` · selected pixels: luma median ${m.selected.luma.median} (p1 ${m.selected.luma.p1}, p99 ${m.selected.luma.p99}) · rgb mean [${m.selected.rgbMean.join(", ")}] · saturation ${m.selected.saturation} · cast ${m.selected.cast.tone === "neutral" ? "neutral" : `${m.selected.cast.tone} (hue ${m.selected.cast.hue}°)`}` : " · nothing selected"));
|
|
1271
|
+
}
|
|
1272
|
+
const k = r.mask;
|
|
1273
|
+
if (k) lines.push(k.error ? `Masks of ${k.clip}: ${k.error}` : `Masks of ${k.clip} (${k.masks}): keep ${k.coverage}% of the clip's picture · the masked clip covers ${k.frameCoverage}% of the frame`);
|
|
1274
|
+
return lines.join("\n") + "\n" + JSON.stringify({ time: r.time, stats: st, ...(m ? { matte: m } : {}), ...(k ? { mask: k } : {}) });
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function describeTrack(t) {
|
|
1278
|
+
return [
|
|
1279
|
+
t.tracked ? `Tracked ${t.clip} "${t.track}" (${t.kind}) ${t.from}–${t.to} s: ${t.samples} samples, match quality min ${t.quality.min} · mean ${t.quality.mean}`
|
|
1280
|
+
: `Used the stored track "${t.track}" on ${t.clip} (${t.from}–${t.to} s, ${t.samples} samples)`,
|
|
1281
|
+
t.lostAt && t.lostAt.length ? `Lost the region at ${t.lostAt.join(", ")} s — the track stops there (pick a spot with more detail, or a larger region, and track on from there)` : "",
|
|
1282
|
+
t.cancelled ? "Cancelled — kept what was tracked so far." : "",
|
|
1283
|
+
t.applied ? `Applied → ${t.applied}` : "Stored only (apply it with apply:{mask} or apply:{clip}).",
|
|
1284
|
+
`Project revision ${t.revision}.`,
|
|
1285
|
+
].filter(Boolean).join("\n");
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return { applyPatch, compactView, checkProject, timelineEdit, PROP_DEFAULTS,
|
|
1289
|
+
TEXT_ONLY_NOTE, describeScopeLines, describeScopes, describeTrack };
|
|
1290
|
+
});
|