@alpha2-sh/editor 0.0.0-stage → 0.1.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/LICENSE.md +21 -0
- package/README.md +108 -2
- package/dist/browser/bridge.d.ts +44 -0
- package/dist/browser/bridge.js +2176 -0
- package/dist/browser/extensions-worker-entry.d.ts +1 -0
- package/dist/browser/extensions-worker-entry.js +3 -0
- package/dist/browser/extensions-worker.d.ts +46 -0
- package/dist/browser/extensions-worker.js +149 -0
- package/dist/browser/hash-worker-entry.d.ts +1 -0
- package/dist/browser/hash-worker-entry.js +3 -0
- package/dist/browser/hash-worker.d.ts +24 -0
- package/dist/browser/hash-worker.js +87 -0
- package/dist/browser/index.d.ts +11 -0
- package/dist/browser/index.js +9 -0
- package/dist/browser/jobs.d.ts +82 -0
- package/dist/browser/jobs.js +291 -0
- package/dist/browser/resources.d.ts +40 -0
- package/dist/browser/resources.js +83 -0
- package/dist/browser/runtime/assets.d.ts +233 -0
- package/dist/browser/runtime/assets.js +633 -0
- package/dist/browser/runtime/audio.d.ts +60 -0
- package/dist/browser/runtime/audio.js +549 -0
- package/dist/browser/runtime/budget.d.ts +11 -0
- package/dist/browser/runtime/budget.js +26 -0
- package/dist/browser/runtime/caption-fonts.d.ts +34 -0
- package/dist/browser/runtime/caption-fonts.js +69 -0
- package/dist/browser/runtime/color.d.ts +7 -0
- package/dist/browser/runtime/color.js +12 -0
- package/dist/browser/runtime/engine.d.ts +91 -0
- package/dist/browser/runtime/engine.js +352 -0
- package/dist/browser/runtime/export-audio.d.ts +23 -0
- package/dist/browser/runtime/export-audio.js +75 -0
- package/dist/browser/runtime/exporter.d.ts +140 -0
- package/dist/browser/runtime/exporter.js +751 -0
- package/dist/browser/runtime/extension-import.d.ts +196 -0
- package/dist/browser/runtime/extension-import.js +521 -0
- package/dist/browser/runtime/fingerprint.d.ts +57 -0
- package/dist/browser/runtime/fingerprint.js +157 -0
- package/dist/browser/runtime/generators.d.ts +188 -0
- package/dist/browser/runtime/generators.js +173 -0
- package/dist/browser/runtime/locks.d.ts +32 -0
- package/dist/browser/runtime/locks.js +86 -0
- package/dist/browser/runtime/output.d.ts +87 -0
- package/dist/browser/runtime/output.js +230 -0
- package/dist/browser/runtime/preview-utils.d.ts +3 -0
- package/dist/browser/runtime/preview-utils.js +49 -0
- package/dist/browser/runtime/proxies.d.ts +72 -0
- package/dist/browser/runtime/proxies.js +369 -0
- package/dist/browser/runtime/proxy-storage.d.ts +59 -0
- package/dist/browser/runtime/proxy-storage.js +119 -0
- package/dist/browser/runtime/sources.d.ts +82 -0
- package/dist/browser/runtime/sources.js +819 -0
- package/dist/browser/runtime/staging.d.ts +10 -0
- package/dist/browser/runtime/staging.js +32 -0
- package/dist/browser/runtime/still.d.ts +21 -0
- package/dist/browser/runtime/still.js +52 -0
- package/dist/browser/runtime/wasm.d.ts +9 -0
- package/dist/browser/runtime/wasm.js +20 -0
- package/dist/browser/types.d.ts +549 -0
- package/dist/browser/types.js +1 -0
- package/dist/browser/worker-entry.d.ts +1 -0
- package/dist/browser/worker-entry.js +2 -0
- package/dist/browser/worker.d.ts +24 -0
- package/dist/browser/worker.js +239 -0
- package/dist/core/captions.d.ts +34 -0
- package/dist/core/captions.js +108 -0
- package/dist/core/clip-time.d.ts +31 -0
- package/dist/core/clip-time.js +125 -0
- package/dist/core/commands.d.ts +413 -0
- package/dist/core/commands.js +1118 -0
- package/dist/core/compositions.d.ts +234 -0
- package/dist/core/compositions.js +851 -0
- package/dist/core/document.d.ts +245 -0
- package/dist/core/document.js +683 -0
- package/dist/core/keyframes.d.ts +82 -0
- package/dist/core/keyframes.js +153 -0
- package/dist/core/presets.d.ts +64 -0
- package/dist/core/presets.js +241 -0
- package/dist/core/session.d.ts +150 -0
- package/dist/core/session.js +541 -0
- package/dist/core/text.d.ts +17 -0
- package/dist/core/text.js +17 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +39 -0
- package/dist/core/timeline-index.d.ts +44 -0
- package/dist/core/timeline-index.js +59 -0
- package/dist/core/transitions.d.ts +20 -0
- package/dist/core/transitions.js +118 -0
- package/dist/core/validated.d.ts +23 -0
- package/dist/core/validated.js +52 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/native-client/index.d.ts +131 -0
- package/dist/native-client/index.js +354 -0
- package/dist/ports/index.d.ts +146 -0
- package/dist/ports/index.js +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.js +94 -0
- package/package.json +79 -4
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
import { addTransition, resizeTransition, removeTransition, } from "./transitions.js";
|
|
2
|
+
import * as edit from "alpha2/edit";
|
|
3
|
+
import { drawnEffectResources, effectStack, findComposition, freeCompositionId, freeNodeId, groupNodes, ungroupNode, inputKeys, correspondingCompositions, ownComposition, withEffectStack, withoutUndrawn, withInput, withoutNodes, shownSources, } from "./compositions.js";
|
|
4
|
+
import { rememberValid, withValidated } from "./validated.js";
|
|
5
|
+
import { EditorError, validateDocument, validateDocumentShape, assertProjection, indexDocument, transitionInstanceId, withInstanceIds, } from "./document.js";
|
|
6
|
+
const NO_NAMES = Object.freeze({});
|
|
7
|
+
/** The names the node editor shows for group compositions, by composition id. */
|
|
8
|
+
export function graphNames(document) {
|
|
9
|
+
return document.metadata.graphNames ?? NO_NAMES;
|
|
10
|
+
}
|
|
11
|
+
function withGraphName(document, id, name) {
|
|
12
|
+
if (!name)
|
|
13
|
+
return document;
|
|
14
|
+
return {
|
|
15
|
+
...document,
|
|
16
|
+
metadata: {
|
|
17
|
+
...document.metadata,
|
|
18
|
+
graphNames: {
|
|
19
|
+
...graphNames(document),
|
|
20
|
+
[id]: name,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** The node editor positions a document stores. */
|
|
26
|
+
export function graphLayout(document) {
|
|
27
|
+
return document.metadata.graphLayout ?? {};
|
|
28
|
+
}
|
|
29
|
+
function withLayout(document, compositionId, positions) {
|
|
30
|
+
for (const position of Object.values(positions))
|
|
31
|
+
if (!Number.isFinite(position.x) || !Number.isFinite(position.y))
|
|
32
|
+
fail("Node positions must be finite");
|
|
33
|
+
const layout = graphLayout(document);
|
|
34
|
+
return {
|
|
35
|
+
...document,
|
|
36
|
+
metadata: {
|
|
37
|
+
...document.metadata,
|
|
38
|
+
graphLayout: {
|
|
39
|
+
...layout,
|
|
40
|
+
[compositionId]: { ...layout[compositionId], ...positions },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function changeComposition(document, id, update) {
|
|
46
|
+
const composition = findComposition(document.project, id);
|
|
47
|
+
if (!composition)
|
|
48
|
+
fail("Unknown composition", id);
|
|
49
|
+
const next = update(composition);
|
|
50
|
+
return {
|
|
51
|
+
...document,
|
|
52
|
+
project: {
|
|
53
|
+
...document.project,
|
|
54
|
+
compositions: (document.project.compositions ?? []).map((c) => c.id === id ? next : c),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `document` with `update` applied to every group node that draws composition `id`. A composition
|
|
60
|
+
* without one stays the same object: the track lock tells what an edit changed by identity.
|
|
61
|
+
*/
|
|
62
|
+
function mapGroupNodes(document, id, update) {
|
|
63
|
+
const draws = (node) => node.op.type === "group" && node.op.composition === id;
|
|
64
|
+
return {
|
|
65
|
+
...document,
|
|
66
|
+
project: {
|
|
67
|
+
...document.project,
|
|
68
|
+
compositions: (document.project.compositions ?? []).map((c) => (c.nodes ?? []).some(draws)
|
|
69
|
+
? {
|
|
70
|
+
...c,
|
|
71
|
+
nodes: (c.nodes ?? []).map((node) => node.op.type === "group" && node.op.composition === id
|
|
72
|
+
? { ...node, op: update(node.op) }
|
|
73
|
+
: node),
|
|
74
|
+
}
|
|
75
|
+
: c),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function nodeIn(composition, id) {
|
|
80
|
+
const node = (composition.nodes ?? []).find((n) => n.id === id);
|
|
81
|
+
if (!node)
|
|
82
|
+
fail("Unknown node", id);
|
|
83
|
+
return node;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The tracks a ripple edit on `trackId` moves too: every other sync-locked track. A locked one
|
|
87
|
+
* among them still refuses the edit, as any change to a locked track does.
|
|
88
|
+
*/
|
|
89
|
+
function syncTrackIds(document, trackId) {
|
|
90
|
+
return document.project.timeline.tracks
|
|
91
|
+
.filter((t) => t.id !== trackId && document.metadata.tracks[t.id]?.syncLock)
|
|
92
|
+
.map((t) => t.id);
|
|
93
|
+
}
|
|
94
|
+
/** `marker` with `patch` applied; a field patched to `undefined` is removed. */
|
|
95
|
+
function patchedMarker(marker, patch) {
|
|
96
|
+
const next = { ...marker, ...patch };
|
|
97
|
+
for (const [key, value] of Object.entries(next))
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
delete next[key];
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `document` with every timeline marker (and range marker end) at or after `pivot` moved by
|
|
104
|
+
* `delta`. A negative `delta` closes the time `[pivot + delta, pivot)`: a marker with any time
|
|
105
|
+
* there refuses the edit (`sync_conflict`), as content there on a sync-locked track does, rather
|
|
106
|
+
* than land on what the edit pulls in.
|
|
107
|
+
*/
|
|
108
|
+
function shiftMarkers(document, pivot, delta) {
|
|
109
|
+
if (!delta)
|
|
110
|
+
return document;
|
|
111
|
+
if (delta < 0) {
|
|
112
|
+
const closed = { start: pivot + delta, end: pivot };
|
|
113
|
+
const inside = document.metadata.markers.find((m) => m.end === undefined
|
|
114
|
+
? m.time >= closed.start && m.time < closed.end
|
|
115
|
+
: m.time < closed.end && m.end > closed.start);
|
|
116
|
+
if (inside)
|
|
117
|
+
throw new EditorError("sync_conflict", `Marker ${inside.name || inside.id} is in the time the edit closes`, "dispatch", inside.id);
|
|
118
|
+
}
|
|
119
|
+
const markers = document.metadata.markers.map((m) => {
|
|
120
|
+
const time = m.time >= pivot ? m.time + delta : m.time;
|
|
121
|
+
if (m.end === undefined)
|
|
122
|
+
return time === m.time ? m : { ...m, time };
|
|
123
|
+
const end = m.end >= pivot ? m.end + delta : m.end;
|
|
124
|
+
return time === m.time && end === m.end ? m : { ...m, time, end };
|
|
125
|
+
});
|
|
126
|
+
if (markers.some((m) => m.time < 0))
|
|
127
|
+
fail("The edit would move a marker before the timeline start");
|
|
128
|
+
return { ...document, metadata: { ...document.metadata, markers } };
|
|
129
|
+
}
|
|
130
|
+
/** The metadata the right part `newId` of a clip `leftId` cut in two inherits. */
|
|
131
|
+
function afterSplit(document, newId, leftId) {
|
|
132
|
+
if (!indexDocument(document).clips.has(newId))
|
|
133
|
+
return document;
|
|
134
|
+
let next = {
|
|
135
|
+
...document,
|
|
136
|
+
metadata: {
|
|
137
|
+
...document.metadata,
|
|
138
|
+
clips: {
|
|
139
|
+
...document.metadata.clips,
|
|
140
|
+
[newId]: document.metadata.clips[leftId] ?? {},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
next = ids(next, `${newId}/masks`, (next.metadata.instanceIds[`${leftId}/masks`] ?? []).map((_, i) => `${newId}/masks/${i}`));
|
|
145
|
+
const index = indexDocument(next);
|
|
146
|
+
const left = index.clips.get(leftId)?.clip.composition?.id;
|
|
147
|
+
const right = index.clips.get(newId)?.clip.composition?.id;
|
|
148
|
+
return left && right && left !== right
|
|
149
|
+
? withCopiedGraphs(next, correspondingCompositions(next.project, left, right))
|
|
150
|
+
: next;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* `document` with each copy in `copies` (by original id) drawn in the node editor where its
|
|
154
|
+
* original is, under its name.
|
|
155
|
+
*/
|
|
156
|
+
function withCopiedGraphs(document, copies) {
|
|
157
|
+
const layout = graphLayout(document);
|
|
158
|
+
const names = graphNames(document);
|
|
159
|
+
const positions = {};
|
|
160
|
+
const named = {};
|
|
161
|
+
for (const [original, copy] of copies) {
|
|
162
|
+
if (original === copy)
|
|
163
|
+
continue;
|
|
164
|
+
if (layout[original])
|
|
165
|
+
positions[copy] = layout[original];
|
|
166
|
+
if (names[original])
|
|
167
|
+
named[copy] = names[original];
|
|
168
|
+
}
|
|
169
|
+
if (!Object.keys(positions).length && !Object.keys(named).length)
|
|
170
|
+
return document;
|
|
171
|
+
return {
|
|
172
|
+
...document,
|
|
173
|
+
metadata: {
|
|
174
|
+
...document.metadata,
|
|
175
|
+
graphLayout: {
|
|
176
|
+
...layout,
|
|
177
|
+
...positions,
|
|
178
|
+
},
|
|
179
|
+
graphNames: { ...names, ...named },
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** `document` without the node editor's positions and names of compositions `removed`. */
|
|
184
|
+
function withoutGraphs(document, removed) {
|
|
185
|
+
const layout = graphLayout(document);
|
|
186
|
+
const names = graphNames(document);
|
|
187
|
+
if (![...removed].some((id) => Object.hasOwn(layout, id) || Object.hasOwn(names, id)))
|
|
188
|
+
return document;
|
|
189
|
+
const without = (map) => Object.fromEntries(Object.entries(map).filter(([id]) => !removed.has(id)));
|
|
190
|
+
return {
|
|
191
|
+
...document,
|
|
192
|
+
metadata: {
|
|
193
|
+
...document.metadata,
|
|
194
|
+
graphLayout: without(layout),
|
|
195
|
+
graphNames: without(names),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The project an `alpha2/edit` operation made for `command`, or its refusal as an `EditorError`
|
|
201
|
+
* about `id`. Outside a transaction the edit validated what it made, so `executeCommand` does not
|
|
202
|
+
* validate the same project again.
|
|
203
|
+
*/
|
|
204
|
+
function edited(result, command, id) {
|
|
205
|
+
if (!result.ok)
|
|
206
|
+
throw new EditorError(result.error.code, result.error.message, command.type, id);
|
|
207
|
+
if (transactions === 0)
|
|
208
|
+
rememberValid(result.project);
|
|
209
|
+
return result.project;
|
|
210
|
+
}
|
|
211
|
+
/** How many transactions `apply` is inside: their edits leave validation to `executeCommand`. */
|
|
212
|
+
let transactions = 0;
|
|
213
|
+
function fail(message, id) {
|
|
214
|
+
throw new EditorError("invalid_command", message, "dispatch", id);
|
|
215
|
+
}
|
|
216
|
+
function replaceTrack(project, track) {
|
|
217
|
+
return {
|
|
218
|
+
...project,
|
|
219
|
+
timeline: {
|
|
220
|
+
...project.timeline,
|
|
221
|
+
tracks: project.timeline.tracks.map((t) => t.id === track.id ? track : t),
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function changeClip(document, id, update) {
|
|
226
|
+
const found = indexDocument(document).clips.get(id);
|
|
227
|
+
if (!found)
|
|
228
|
+
fail("Unknown clip", id);
|
|
229
|
+
const track = indexDocument(document).tracks.get(found.trackId);
|
|
230
|
+
return {
|
|
231
|
+
...document,
|
|
232
|
+
project: replaceTrack(document.project, {
|
|
233
|
+
...track,
|
|
234
|
+
clips: (track.clips ?? []).map((c) => (c.id === id ? update(c) : c)),
|
|
235
|
+
}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function ids(document, key, values) {
|
|
239
|
+
return {
|
|
240
|
+
...document,
|
|
241
|
+
metadata: {
|
|
242
|
+
...document.metadata,
|
|
243
|
+
instanceIds: { ...document.metadata.instanceIds, [key]: values },
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function sameSet(a, b) {
|
|
248
|
+
return (a.length === b.length &&
|
|
249
|
+
new Set(b).size === b.length &&
|
|
250
|
+
a.every((x) => b.includes(x)));
|
|
251
|
+
}
|
|
252
|
+
function reduce(document, command) {
|
|
253
|
+
const project = document.project;
|
|
254
|
+
const trackId = "trackId" in command
|
|
255
|
+
? command.trackId
|
|
256
|
+
: command.type.startsWith("track.") && "id" in command
|
|
257
|
+
? command.id
|
|
258
|
+
: undefined;
|
|
259
|
+
const track = trackId
|
|
260
|
+
? indexDocument(document).tracks.get(trackId)
|
|
261
|
+
: undefined;
|
|
262
|
+
if (trackId && !track)
|
|
263
|
+
fail("Unknown track", trackId);
|
|
264
|
+
switch (command.type) {
|
|
265
|
+
case "transition.add":
|
|
266
|
+
return {
|
|
267
|
+
...document,
|
|
268
|
+
project: replaceTrack(project, addTransition(track, command.fromId, command.toId, command.duration, command.effect)),
|
|
269
|
+
};
|
|
270
|
+
case "transition.resize":
|
|
271
|
+
case "transition.remove": {
|
|
272
|
+
const index = (track.transitions ?? []).findIndex((t) => transitionInstanceId(track.id, t) === command.instanceId);
|
|
273
|
+
if (index < 0)
|
|
274
|
+
fail("Unknown transition", command.instanceId);
|
|
275
|
+
return {
|
|
276
|
+
...document,
|
|
277
|
+
project: replaceTrack(project, command.type === "transition.resize"
|
|
278
|
+
? resizeTransition(track, index, command.duration)
|
|
279
|
+
: removeTransition(track, index)),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
case "project.update":
|
|
283
|
+
return { ...document, project: { ...project, ...command.patch } };
|
|
284
|
+
case "metadata.update":
|
|
285
|
+
return {
|
|
286
|
+
...document,
|
|
287
|
+
metadata: { ...document.metadata, ...command.patch },
|
|
288
|
+
};
|
|
289
|
+
case "track.add": {
|
|
290
|
+
// Each brought clip shows its own copy of a shared composition, as clip.insert does.
|
|
291
|
+
const withTrack = (on, track) => ({
|
|
292
|
+
...on,
|
|
293
|
+
timeline: {
|
|
294
|
+
...on.timeline,
|
|
295
|
+
tracks: [...project.timeline.tracks, track],
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
let next = withTrack(project, command.track);
|
|
299
|
+
if (!command.track.clips?.length)
|
|
300
|
+
return { ...document, project: next };
|
|
301
|
+
const copies = new Map();
|
|
302
|
+
const clips = [];
|
|
303
|
+
for (const clip of command.track.clips) {
|
|
304
|
+
const owned = ownComposition(withTrack(next, { ...command.track, clips: [...clips] }), clip);
|
|
305
|
+
for (const [original, copy] of owned.copies)
|
|
306
|
+
copies.set(original, copy);
|
|
307
|
+
clips.push(owned.clip);
|
|
308
|
+
next = owned.project;
|
|
309
|
+
}
|
|
310
|
+
next = withTrack(next, { ...command.track, clips });
|
|
311
|
+
return withCopiedGraphs({ ...document, project: next }, copies);
|
|
312
|
+
}
|
|
313
|
+
case "track.remove":
|
|
314
|
+
return {
|
|
315
|
+
...document,
|
|
316
|
+
project: {
|
|
317
|
+
...project,
|
|
318
|
+
timeline: {
|
|
319
|
+
...project.timeline,
|
|
320
|
+
tracks: project.timeline.tracks.filter((t) => t.id !== command.id),
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
case "track.reorder": {
|
|
325
|
+
if (!sameSet(project.timeline.tracks.map((t) => t.id), command.ids))
|
|
326
|
+
fail("Track order must be a permutation");
|
|
327
|
+
return {
|
|
328
|
+
...document,
|
|
329
|
+
project: {
|
|
330
|
+
...project,
|
|
331
|
+
timeline: {
|
|
332
|
+
...project.timeline,
|
|
333
|
+
tracks: command.ids.map((id) => indexDocument(document).tracks.get(id)),
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
case "track.update":
|
|
339
|
+
return {
|
|
340
|
+
...document,
|
|
341
|
+
project: replaceTrack(project, { ...track, ...command.patch }),
|
|
342
|
+
};
|
|
343
|
+
case "track.metadata":
|
|
344
|
+
return {
|
|
345
|
+
...document,
|
|
346
|
+
metadata: {
|
|
347
|
+
...document.metadata,
|
|
348
|
+
tracks: {
|
|
349
|
+
...document.metadata.tracks,
|
|
350
|
+
[command.id]: {
|
|
351
|
+
...document.metadata.tracks[command.id],
|
|
352
|
+
...command.patch,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
case "clip.metadata":
|
|
358
|
+
if (!indexDocument(document).clips.has(command.id))
|
|
359
|
+
fail("Unknown clip", command.id);
|
|
360
|
+
return {
|
|
361
|
+
...document,
|
|
362
|
+
metadata: {
|
|
363
|
+
...document.metadata,
|
|
364
|
+
clips: {
|
|
365
|
+
...document.metadata.clips,
|
|
366
|
+
[command.id]: {
|
|
367
|
+
...document.metadata.clips[command.id],
|
|
368
|
+
...command.patch,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
case "clip.insert": {
|
|
374
|
+
// A pasted or duplicated clip edits its own copy of a composition.
|
|
375
|
+
const owned = ownComposition(project, command.clip, command.compositions);
|
|
376
|
+
return withCopiedGraphs({
|
|
377
|
+
...document,
|
|
378
|
+
project: replaceTrack(owned.project, {
|
|
379
|
+
...track,
|
|
380
|
+
clips: [...(track.clips ?? []), owned.clip],
|
|
381
|
+
}),
|
|
382
|
+
metadata: {
|
|
383
|
+
...document.metadata,
|
|
384
|
+
clips: {
|
|
385
|
+
...document.metadata.clips,
|
|
386
|
+
// Unnamed clips show their source's name; never invent one.
|
|
387
|
+
[command.clip.id]: command.name ? { name: command.name } : {},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
}, owned.copies);
|
|
391
|
+
}
|
|
392
|
+
case "clip.place": {
|
|
393
|
+
const syncTracks = syncTrackIds(document, command.trackId);
|
|
394
|
+
const options = { splitId: command.splitId, syncTracks };
|
|
395
|
+
const owned = ownComposition(project, command.clip);
|
|
396
|
+
const result = command.mode === "insert"
|
|
397
|
+
? edit.insert(owned.project, command.trackId, owned.clip, command.at, options)
|
|
398
|
+
: command.mode === "overwrite"
|
|
399
|
+
? edit.overwrite(owned.project, command.trackId, owned.clip, command.at, options)
|
|
400
|
+
: edit.append(owned.project, command.trackId, owned.clip);
|
|
401
|
+
const made = edited(result, command, command.clip.id);
|
|
402
|
+
let next = {
|
|
403
|
+
...document,
|
|
404
|
+
project: made,
|
|
405
|
+
metadata: {
|
|
406
|
+
...document.metadata,
|
|
407
|
+
clips: {
|
|
408
|
+
...document.metadata.clips,
|
|
409
|
+
[command.clip.id]: command.name ? { name: command.name } : {},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
if (command.splitId) {
|
|
414
|
+
const cut = (indexDocument(document).tracks.get(command.trackId)?.clips ?? []).find((c) => c.timeline_range.start < command.at &&
|
|
415
|
+
c.timeline_range.end > command.at);
|
|
416
|
+
if (cut)
|
|
417
|
+
next = afterSplit(next, command.splitId, cut.id);
|
|
418
|
+
}
|
|
419
|
+
if (command.mode === "insert" && syncTracks.length)
|
|
420
|
+
next = shiftMarkers(next, command.at, command.clip.timeline_range.end - command.clip.timeline_range.start);
|
|
421
|
+
return withCopiedGraphs(next, owned.copies);
|
|
422
|
+
}
|
|
423
|
+
case "clip.slip":
|
|
424
|
+
case "clip.slide": {
|
|
425
|
+
const result = command.type === "clip.slip"
|
|
426
|
+
? edit.slip(project, command.id, command.delta)
|
|
427
|
+
: edit.slide(project, command.id, command.delta);
|
|
428
|
+
const made = edited(result, command, command.id);
|
|
429
|
+
return { ...document, project: made };
|
|
430
|
+
}
|
|
431
|
+
case "clip.retime": {
|
|
432
|
+
const found = indexDocument(document).clips.get(command.id);
|
|
433
|
+
if (!found)
|
|
434
|
+
fail("Unknown clip", command.id);
|
|
435
|
+
const syncTracks = command.ripple
|
|
436
|
+
? syncTrackIds(document, found.trackId)
|
|
437
|
+
: [];
|
|
438
|
+
const result = edit.retime(project, command.id, command.duration, {
|
|
439
|
+
...(command.reverse === undefined ? {} : { reverse: command.reverse }),
|
|
440
|
+
...(command.ripple ? { ripple: true, syncTracks } : {}),
|
|
441
|
+
...(command.keyframes ? { keyframes: command.keyframes } : {}),
|
|
442
|
+
});
|
|
443
|
+
const made = edited(result, command, command.id);
|
|
444
|
+
const next = { ...document, project: made };
|
|
445
|
+
// Timeline markers move with a ripple that reaches other tracks.
|
|
446
|
+
const before = found.clip.timeline_range;
|
|
447
|
+
return syncTracks.length
|
|
448
|
+
? shiftMarkers(next, before.end, before.start + command.duration - before.end)
|
|
449
|
+
: next;
|
|
450
|
+
}
|
|
451
|
+
case "marker.add":
|
|
452
|
+
if (document.metadata.markers.some((m) => m.id === command.marker.id))
|
|
453
|
+
fail("A marker already has this id", command.marker.id);
|
|
454
|
+
return {
|
|
455
|
+
...document,
|
|
456
|
+
metadata: {
|
|
457
|
+
...document.metadata,
|
|
458
|
+
markers: [...document.metadata.markers, command.marker],
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
case "marker.update":
|
|
462
|
+
case "marker.remove": {
|
|
463
|
+
if (!document.metadata.markers.some((m) => m.id === command.id))
|
|
464
|
+
fail("Unknown marker", command.id);
|
|
465
|
+
return {
|
|
466
|
+
...document,
|
|
467
|
+
metadata: {
|
|
468
|
+
...document.metadata,
|
|
469
|
+
markers: command.type === "marker.remove"
|
|
470
|
+
? document.metadata.markers.filter((m) => m.id !== command.id)
|
|
471
|
+
: document.metadata.markers.map((m) => m.id === command.id ? patchedMarker(m, command.patch) : m),
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
case "clip.roll": {
|
|
476
|
+
const result = edit.rollTrim(project, command.leftId, command.rightId, command.at);
|
|
477
|
+
const made = edited(result, command, command.leftId);
|
|
478
|
+
return { ...document, project: made };
|
|
479
|
+
}
|
|
480
|
+
case "clips.move": {
|
|
481
|
+
const result = edit.moveClips(project, command.moves.map((m) => ({ clip: m.id, to: m.at, track: m.trackId })));
|
|
482
|
+
const made = edited(result, command, command.moves[0]?.id);
|
|
483
|
+
return { ...document, project: made };
|
|
484
|
+
}
|
|
485
|
+
case "clip.move":
|
|
486
|
+
case "clip.trim":
|
|
487
|
+
case "clip.split":
|
|
488
|
+
case "clip.delete": {
|
|
489
|
+
let result;
|
|
490
|
+
const found = indexDocument(document).clips.get(command.id);
|
|
491
|
+
const syncTracks = "ripple" in command && command.ripple && found
|
|
492
|
+
? syncTrackIds(document, found.trackId)
|
|
493
|
+
: [];
|
|
494
|
+
if (command.type === "clip.move")
|
|
495
|
+
result = command.trackId
|
|
496
|
+
? edit.moveToTrack(project, command.id, command.trackId, command.at)
|
|
497
|
+
: edit.move(project, command.id, command.at);
|
|
498
|
+
else if (command.type === "clip.trim")
|
|
499
|
+
result = command.ripple
|
|
500
|
+
? edit.rippleTrim(project, command.id, command.edge, command.at, {
|
|
501
|
+
syncTracks,
|
|
502
|
+
})
|
|
503
|
+
: edit.trim(project, command.id, command.edge, command.at);
|
|
504
|
+
else if (command.type === "clip.split")
|
|
505
|
+
result = edit.split(project, command.id, command.at, command.newId);
|
|
506
|
+
else if (command.ripple)
|
|
507
|
+
result = edit.rippleDelete(project, command.id, { syncTracks });
|
|
508
|
+
else {
|
|
509
|
+
if (!found)
|
|
510
|
+
fail("Unknown clip", command.id);
|
|
511
|
+
const old = indexDocument(document).tracks.get(found.trackId);
|
|
512
|
+
result = {
|
|
513
|
+
ok: true,
|
|
514
|
+
project: replaceTrack(project, {
|
|
515
|
+
...old,
|
|
516
|
+
clips: (old.clips ?? []).filter((c) => c.id !== command.id),
|
|
517
|
+
transitions: (old.transitions ?? []).filter((t) => t.from_clip !== command.id && t.to_clip !== command.id),
|
|
518
|
+
}),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// A delete that closes nothing is validated with the rest.
|
|
522
|
+
const made = command.type === "clip.delete" && !command.ripple && result.ok
|
|
523
|
+
? result.project
|
|
524
|
+
: edited(result, command, command.id);
|
|
525
|
+
let next = { ...document, project: made };
|
|
526
|
+
// Timeline markers move with a ripple that reaches other tracks.
|
|
527
|
+
if (syncTracks.length && found) {
|
|
528
|
+
const before = found.clip.timeline_range;
|
|
529
|
+
if (command.type === "clip.trim") {
|
|
530
|
+
const delta = command.edge === "end"
|
|
531
|
+
? command.at - before.end
|
|
532
|
+
: before.start - command.at;
|
|
533
|
+
next = shiftMarkers(next, before.end, delta);
|
|
534
|
+
}
|
|
535
|
+
else if (command.type === "clip.delete") {
|
|
536
|
+
const gap = edit.rippleDeleteSpan(project, command.id);
|
|
537
|
+
next = shiftMarkers(next, gap.end, gap.start - gap.end);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (command.type === "clip.split")
|
|
541
|
+
next = afterSplit(next, command.newId, command.id);
|
|
542
|
+
return next;
|
|
543
|
+
}
|
|
544
|
+
case "clip.update":
|
|
545
|
+
return changeClip(document, command.id, (c) => {
|
|
546
|
+
const next = { ...c, ...command.patch };
|
|
547
|
+
// `auto` is the default the engine writes by leaving the field out.
|
|
548
|
+
if (next.cache === "auto")
|
|
549
|
+
delete next.cache;
|
|
550
|
+
return next;
|
|
551
|
+
});
|
|
552
|
+
case "effect.add":
|
|
553
|
+
case "effect.remove":
|
|
554
|
+
case "effect.update":
|
|
555
|
+
case "effect.reorder": {
|
|
556
|
+
const found = indexDocument(document).clips.get(command.id);
|
|
557
|
+
if (!found)
|
|
558
|
+
fail("Unknown clip", command.id);
|
|
559
|
+
const current = effectStack(project, found.clip);
|
|
560
|
+
if (!current)
|
|
561
|
+
fail("The clip's composition is a graph, not an effect stack; edit it as a graph", command.id);
|
|
562
|
+
const order = current.map((entry) => entry.id);
|
|
563
|
+
let stack = [...current];
|
|
564
|
+
if (command.type === "effect.add") {
|
|
565
|
+
if (order.includes(command.instanceId))
|
|
566
|
+
fail("Duplicate instance ID");
|
|
567
|
+
stack.push({
|
|
568
|
+
id: command.instanceId,
|
|
569
|
+
effect: command.effect,
|
|
570
|
+
bypass: false,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
else if (command.type === "effect.reorder") {
|
|
574
|
+
if (!sameSet(order, command.ids))
|
|
575
|
+
fail("Effect order must be a permutation");
|
|
576
|
+
stack = command.ids.map((id) => current[order.indexOf(id)]);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
const i = order.indexOf(command.instanceId);
|
|
580
|
+
if (i < 0)
|
|
581
|
+
fail("Unknown effect instance");
|
|
582
|
+
if (command.type === "effect.remove")
|
|
583
|
+
stack.splice(i, 1);
|
|
584
|
+
else
|
|
585
|
+
stack[i] = {
|
|
586
|
+
id: command.instanceId,
|
|
587
|
+
effect: command.effect,
|
|
588
|
+
bypass: command.bypass ?? stack[i].bypass,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
...document,
|
|
593
|
+
project: withEffectStack(project, command.id, stack),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
case "graph.create": {
|
|
597
|
+
const found = indexDocument(document).clips.get(command.clipId);
|
|
598
|
+
if (!found)
|
|
599
|
+
fail("Unknown clip", command.clipId);
|
|
600
|
+
if (found.clip.composition != null)
|
|
601
|
+
fail("The clip already has a composition", command.clipId);
|
|
602
|
+
const id = command.compositionId ??
|
|
603
|
+
freeCompositionId(project, `${command.clipId}-fx`);
|
|
604
|
+
if (findComposition(project, id))
|
|
605
|
+
fail("Duplicate composition", id);
|
|
606
|
+
const created = {
|
|
607
|
+
id,
|
|
608
|
+
nodes: [{ id: "clip", op: { type: "clip" } }],
|
|
609
|
+
output: "clip",
|
|
610
|
+
};
|
|
611
|
+
return changeClip({
|
|
612
|
+
...document,
|
|
613
|
+
project: {
|
|
614
|
+
...project,
|
|
615
|
+
compositions: [...(project.compositions ?? []), created],
|
|
616
|
+
},
|
|
617
|
+
}, command.clipId, (c) => ({ ...c, composition: { id } }));
|
|
618
|
+
}
|
|
619
|
+
case "graph.node.add": {
|
|
620
|
+
const next = changeComposition(document, command.compositionId, (c) => {
|
|
621
|
+
if ((c.nodes ?? []).some((n) => n.id === command.node.id))
|
|
622
|
+
fail("Duplicate node", command.node.id);
|
|
623
|
+
return { ...c, nodes: [...(c.nodes ?? []), command.node] };
|
|
624
|
+
});
|
|
625
|
+
return command.position
|
|
626
|
+
? withLayout(next, command.compositionId, {
|
|
627
|
+
[command.node.id]: command.position,
|
|
628
|
+
})
|
|
629
|
+
: next;
|
|
630
|
+
}
|
|
631
|
+
case "graph.node.update":
|
|
632
|
+
return changeComposition(document, command.compositionId, (c) => {
|
|
633
|
+
nodeIn(c, command.node.id);
|
|
634
|
+
return {
|
|
635
|
+
...c,
|
|
636
|
+
nodes: (c.nodes ?? []).map((n) => n.id === command.node.id ? command.node : n),
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
case "graph.node.remove": {
|
|
640
|
+
// A control goes with the node it exposes, and so do the values group nodes set for it.
|
|
641
|
+
let dropped = [];
|
|
642
|
+
const next = changeComposition(document, command.compositionId, (c) => {
|
|
643
|
+
for (const id of command.ids)
|
|
644
|
+
nodeIn(c, id);
|
|
645
|
+
const kept = withoutNodes(c, command.ids);
|
|
646
|
+
const names = new Set((kept.controls ?? []).map((k) => k.name));
|
|
647
|
+
dropped = (c.controls ?? [])
|
|
648
|
+
.map((k) => k.name)
|
|
649
|
+
.filter((name) => !names.has(name));
|
|
650
|
+
return kept;
|
|
651
|
+
});
|
|
652
|
+
if (!dropped.length)
|
|
653
|
+
return next;
|
|
654
|
+
return mapGroupNodes(next, command.compositionId, (op) => ({
|
|
655
|
+
...op,
|
|
656
|
+
controls: Object.fromEntries(Object.entries(op.controls ?? {}).filter(([name]) => !dropped.includes(name))),
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
case "graph.connect":
|
|
660
|
+
case "graph.disconnect":
|
|
661
|
+
return changeComposition(document, command.compositionId, (c) => {
|
|
662
|
+
const target = nodeIn(c, command.to);
|
|
663
|
+
let op = target.op;
|
|
664
|
+
let key = command.input;
|
|
665
|
+
if (command.type === "graph.connect") {
|
|
666
|
+
nodeIn(c, command.from);
|
|
667
|
+
if (command.from === command.to)
|
|
668
|
+
fail("A node cannot read its own picture", command.to);
|
|
669
|
+
if (key === "layer") {
|
|
670
|
+
if (op.type !== "merge")
|
|
671
|
+
fail("Only a merge has layers", command.to);
|
|
672
|
+
op = { ...op, layers: [...(op.layers ?? []), { input: null }] };
|
|
673
|
+
key = (op.layers ?? []).length - 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
else if (op.type === "merge" && typeof key === "number") {
|
|
677
|
+
return {
|
|
678
|
+
...c,
|
|
679
|
+
nodes: (c.nodes ?? []).map((n) => n.id === command.to && op.type === "merge"
|
|
680
|
+
? {
|
|
681
|
+
...n,
|
|
682
|
+
op: {
|
|
683
|
+
...op,
|
|
684
|
+
layers: (op.layers ?? []).filter((_, i) => i !== key),
|
|
685
|
+
},
|
|
686
|
+
}
|
|
687
|
+
: n),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
if (!inputKeys(op).includes(key))
|
|
691
|
+
fail(`No input ${String(key)} on node ${command.to}`, command.to);
|
|
692
|
+
const connected = withInput(op, key, command.type === "graph.connect" ? command.from : null);
|
|
693
|
+
return {
|
|
694
|
+
...c,
|
|
695
|
+
nodes: (c.nodes ?? []).map((n) => n.id === command.to ? { ...n, op: connected } : n),
|
|
696
|
+
};
|
|
697
|
+
});
|
|
698
|
+
case "graph.output":
|
|
699
|
+
return changeComposition(document, command.compositionId, (c) => {
|
|
700
|
+
if (command.nodeId !== null)
|
|
701
|
+
nodeIn(c, command.nodeId);
|
|
702
|
+
return { ...c, output: command.nodeId };
|
|
703
|
+
});
|
|
704
|
+
case "graph.group": {
|
|
705
|
+
const composition = findComposition(project, command.compositionId);
|
|
706
|
+
if (!composition)
|
|
707
|
+
fail("Unknown composition", command.compositionId);
|
|
708
|
+
const groupId = command.groupId ?? freeNodeId(composition, "group");
|
|
709
|
+
const definitionId = command.definitionId ??
|
|
710
|
+
freeCompositionId(project, `${command.compositionId}-group`);
|
|
711
|
+
if (findComposition(project, definitionId))
|
|
712
|
+
fail("A composition already has this id", definitionId);
|
|
713
|
+
let grouped;
|
|
714
|
+
try {
|
|
715
|
+
grouped = groupNodes(composition, command.ids, groupId, definitionId);
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
fail(error.message, command.compositionId);
|
|
719
|
+
}
|
|
720
|
+
const layout = graphLayout(document)[command.compositionId] ?? {};
|
|
721
|
+
// What moved into the definition: a selected group input stays where it is.
|
|
722
|
+
const moved = new Set((grouped.definition.nodes ?? []).map((node) => node.id));
|
|
723
|
+
const grouping = command.ids.filter((id) => moved.has(id));
|
|
724
|
+
const placed = grouping
|
|
725
|
+
.map((id) => layout[id])
|
|
726
|
+
.filter((p) => p !== undefined);
|
|
727
|
+
const centre = placed.length
|
|
728
|
+
? {
|
|
729
|
+
x: placed.reduce((sum, p) => sum + p.x, 0) / placed.length,
|
|
730
|
+
y: placed.reduce((sum, p) => sum + p.y, 0) / placed.length,
|
|
731
|
+
}
|
|
732
|
+
: { x: 0, y: 0 };
|
|
733
|
+
const inner = {};
|
|
734
|
+
for (const id of grouping)
|
|
735
|
+
if (layout[id])
|
|
736
|
+
inner[id] = layout[id];
|
|
737
|
+
const left = Math.min(...placed.map((p) => p.x), centre.x) - 220;
|
|
738
|
+
(grouped.definition.nodes ?? [])
|
|
739
|
+
.filter((node) => node.op.type === "group_input")
|
|
740
|
+
.forEach((node, index) => {
|
|
741
|
+
inner[node.id] = { x: left, y: centre.y + index * 110 };
|
|
742
|
+
});
|
|
743
|
+
let next = {
|
|
744
|
+
...document,
|
|
745
|
+
project: {
|
|
746
|
+
...project,
|
|
747
|
+
compositions: [
|
|
748
|
+
...(project.compositions ?? []).map((c) => c.id === command.compositionId ? grouped.parent : c),
|
|
749
|
+
grouped.definition,
|
|
750
|
+
],
|
|
751
|
+
},
|
|
752
|
+
};
|
|
753
|
+
next = withLayout(next, command.compositionId, { [groupId]: centre });
|
|
754
|
+
next = withGraphName(next, definitionId, command.name);
|
|
755
|
+
return withLayout(next, definitionId, inner);
|
|
756
|
+
}
|
|
757
|
+
case "graph.ungroup": {
|
|
758
|
+
const composition = findComposition(project, command.compositionId);
|
|
759
|
+
if (!composition)
|
|
760
|
+
fail("Unknown composition", command.compositionId);
|
|
761
|
+
const group = nodeIn(composition, command.nodeId);
|
|
762
|
+
if (group.op.type !== "group")
|
|
763
|
+
fail("Only a group node ungroups", command.nodeId);
|
|
764
|
+
// A bypassed group passes its first input through and draws nothing of its definition, which
|
|
765
|
+
// inlined nodes cannot say in general.
|
|
766
|
+
if (group.bypass)
|
|
767
|
+
fail(`Turn group ${command.nodeId} on before ungrouping it: ungrouped, its nodes would draw`, command.nodeId);
|
|
768
|
+
const definition = findComposition(project, group.op.composition);
|
|
769
|
+
if (!definition)
|
|
770
|
+
fail("Unknown composition", group.op.composition);
|
|
771
|
+
const { parent, ids } = ungroupNode(composition, command.nodeId, definition);
|
|
772
|
+
let next = {
|
|
773
|
+
...document,
|
|
774
|
+
project: {
|
|
775
|
+
...project,
|
|
776
|
+
compositions: (project.compositions ?? []).map((c) => c.id === command.compositionId ? parent : c),
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
const layout = graphLayout(document);
|
|
780
|
+
const at = layout[command.compositionId]?.[command.nodeId] ?? {
|
|
781
|
+
x: 0,
|
|
782
|
+
y: 0,
|
|
783
|
+
};
|
|
784
|
+
const inner = layout[definition.id] ?? {};
|
|
785
|
+
const placed = [...ids].filter(([id]) => inner[id]);
|
|
786
|
+
const cx = placed.reduce((sum, [id]) => sum + inner[id].x, 0) /
|
|
787
|
+
Math.max(1, placed.length);
|
|
788
|
+
const cy = placed.reduce((sum, [id]) => sum + inner[id].y, 0) /
|
|
789
|
+
Math.max(1, placed.length);
|
|
790
|
+
next = withLayout(next, command.compositionId, Object.fromEntries([...ids].map(([id, outer], index) => [
|
|
791
|
+
outer,
|
|
792
|
+
inner[id]
|
|
793
|
+
? { x: at.x + inner[id].x - cx, y: at.y + inner[id].y - cy }
|
|
794
|
+
: { x: at.x + index * 40, y: at.y + 120 },
|
|
795
|
+
])));
|
|
796
|
+
return next;
|
|
797
|
+
}
|
|
798
|
+
case "composition.add":
|
|
799
|
+
if (findComposition(project, command.composition.id))
|
|
800
|
+
fail("A composition already has this id", command.composition.id);
|
|
801
|
+
return {
|
|
802
|
+
...document,
|
|
803
|
+
project: {
|
|
804
|
+
...project,
|
|
805
|
+
compositions: [...(project.compositions ?? []), command.composition],
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
case "graph.port.add": {
|
|
809
|
+
const name = command.name.trim();
|
|
810
|
+
if (!name)
|
|
811
|
+
fail("A group input needs a name", command.compositionId);
|
|
812
|
+
const next = changeComposition(document, command.compositionId, (c) => {
|
|
813
|
+
if ((c.inputs ?? []).includes(name))
|
|
814
|
+
fail("A group input already has this name", command.compositionId);
|
|
815
|
+
return { ...c, inputs: [...(c.inputs ?? []), name] };
|
|
816
|
+
});
|
|
817
|
+
return mapGroupNodes(next, command.compositionId, (op) => ({
|
|
818
|
+
...op,
|
|
819
|
+
inputs: [...(op.inputs ?? []), null],
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
case "graph.control.add":
|
|
823
|
+
return changeComposition(document, command.compositionId, (c) => {
|
|
824
|
+
const target = nodeIn(c, command.control.node);
|
|
825
|
+
if (target.op.type !== "effect" && target.op.type !== "transition")
|
|
826
|
+
fail("Only an effect's settings are controls", command.control.node);
|
|
827
|
+
const controls = c.controls ?? [];
|
|
828
|
+
if (controls.some((control) => control.name === command.control.name))
|
|
829
|
+
fail("A control already has this name", command.compositionId);
|
|
830
|
+
return { ...c, controls: [...controls, { ...command.control }] };
|
|
831
|
+
});
|
|
832
|
+
case "graph.control.remove": {
|
|
833
|
+
const next = changeComposition(document, command.compositionId, (c) => {
|
|
834
|
+
const controls = (c.controls ?? []).filter((control) => control.name !== command.name);
|
|
835
|
+
if (controls.length === (c.controls ?? []).length)
|
|
836
|
+
fail("Unknown control", command.name);
|
|
837
|
+
return { ...c, controls };
|
|
838
|
+
});
|
|
839
|
+
return mapGroupNodes(next, command.compositionId, (op) => {
|
|
840
|
+
const { [command.name]: _dropped, ...controls } = op.controls ?? {};
|
|
841
|
+
return { ...op, controls };
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
case "composition.create": {
|
|
845
|
+
if (findComposition(project, command.compositionId))
|
|
846
|
+
fail("A composition already has this id", command.compositionId);
|
|
847
|
+
if (project.sources.some((s) => s.id === command.sourceId) ||
|
|
848
|
+
document.assets[command.sourceId])
|
|
849
|
+
fail("A source already has this id", command.sourceId);
|
|
850
|
+
const composition = {
|
|
851
|
+
id: command.compositionId,
|
|
852
|
+
nodes: [
|
|
853
|
+
{
|
|
854
|
+
id: "background",
|
|
855
|
+
op: {
|
|
856
|
+
type: "generator",
|
|
857
|
+
generator: {
|
|
858
|
+
type: "fill",
|
|
859
|
+
paint: {
|
|
860
|
+
type: "solid",
|
|
861
|
+
color: { red: 0.12, green: 0.12, blue: 0.14, alpha: 1 },
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
},
|
|
866
|
+
],
|
|
867
|
+
output: "background",
|
|
868
|
+
};
|
|
869
|
+
return withLayout({
|
|
870
|
+
...document,
|
|
871
|
+
assets: {
|
|
872
|
+
...document.assets,
|
|
873
|
+
[command.sourceId]: {
|
|
874
|
+
id: command.sourceId,
|
|
875
|
+
kind: "composition",
|
|
876
|
+
name: command.name,
|
|
877
|
+
contentRevision: 0,
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
project: {
|
|
881
|
+
...project,
|
|
882
|
+
sources: [
|
|
883
|
+
...project.sources,
|
|
884
|
+
{
|
|
885
|
+
id: command.sourceId,
|
|
886
|
+
kind: "composition",
|
|
887
|
+
composition: command.compositionId,
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
compositions: [...(project.compositions ?? []), composition],
|
|
891
|
+
},
|
|
892
|
+
}, command.compositionId, { background: { x: 0, y: 0 } });
|
|
893
|
+
}
|
|
894
|
+
case "graph.layout":
|
|
895
|
+
if (!findComposition(project, command.compositionId))
|
|
896
|
+
fail("Unknown composition", command.compositionId);
|
|
897
|
+
return withLayout(document, command.compositionId, command.positions);
|
|
898
|
+
case "cue.add":
|
|
899
|
+
case "cue.update":
|
|
900
|
+
case "cue.remove": {
|
|
901
|
+
if (!track?.captions)
|
|
902
|
+
fail("Not a caption track", command.trackId);
|
|
903
|
+
const key = `${track.id}/cues`;
|
|
904
|
+
const nextIds = [...(document.metadata.instanceIds[key] ?? [])];
|
|
905
|
+
const cues = [...track.captions.cues];
|
|
906
|
+
// Cues stay in start order; overlaps are for validation to report.
|
|
907
|
+
const place = (instanceId, cue) => {
|
|
908
|
+
const at = cues.findIndex((c) => c.timeline_range.start > cue.timeline_range.start);
|
|
909
|
+
const index = at < 0 ? cues.length : at;
|
|
910
|
+
cues.splice(index, 0, cue);
|
|
911
|
+
nextIds.splice(index, 0, instanceId);
|
|
912
|
+
};
|
|
913
|
+
if (command.type === "cue.add") {
|
|
914
|
+
if (nextIds.includes(command.instanceId))
|
|
915
|
+
fail("Duplicate cue");
|
|
916
|
+
place(command.instanceId, command.cue);
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
const i = nextIds.indexOf(command.instanceId);
|
|
920
|
+
if (i < 0)
|
|
921
|
+
fail("Unknown cue");
|
|
922
|
+
cues.splice(i, 1);
|
|
923
|
+
nextIds.splice(i, 1);
|
|
924
|
+
if (command.type === "cue.update")
|
|
925
|
+
place(command.instanceId, command.cue);
|
|
926
|
+
}
|
|
927
|
+
return ids({
|
|
928
|
+
...document,
|
|
929
|
+
project: replaceTrack(project, {
|
|
930
|
+
...track,
|
|
931
|
+
captions: { ...track.captions, cues },
|
|
932
|
+
}),
|
|
933
|
+
}, key, nextIds);
|
|
934
|
+
}
|
|
935
|
+
case "asset.put": {
|
|
936
|
+
const previous = document.assets[command.asset.id];
|
|
937
|
+
if (previous && command.asset.contentRevision < previous.contentRevision)
|
|
938
|
+
fail("Asset revision cannot go backwards");
|
|
939
|
+
const sources = command.source
|
|
940
|
+
? [
|
|
941
|
+
...project.sources.filter((s) => s.id !== command.source.id),
|
|
942
|
+
command.source,
|
|
943
|
+
]
|
|
944
|
+
: project.sources;
|
|
945
|
+
const resources = command.resource
|
|
946
|
+
? [
|
|
947
|
+
...(project.effect_resources ?? []).filter((r) => r.id !== command.resource.id),
|
|
948
|
+
command.resource,
|
|
949
|
+
]
|
|
950
|
+
: project.effect_resources;
|
|
951
|
+
return {
|
|
952
|
+
...document,
|
|
953
|
+
assets: { ...document.assets, [command.asset.id]: command.asset },
|
|
954
|
+
project: {
|
|
955
|
+
...project,
|
|
956
|
+
sources,
|
|
957
|
+
...(resources ? { effect_resources: resources } : {}),
|
|
958
|
+
},
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
case "asset.remove": {
|
|
962
|
+
const assets = { ...document.assets };
|
|
963
|
+
delete assets[command.id];
|
|
964
|
+
return {
|
|
965
|
+
...document,
|
|
966
|
+
assets,
|
|
967
|
+
project: {
|
|
968
|
+
...project,
|
|
969
|
+
sources: project.sources.filter((s) => s.id !== command.id),
|
|
970
|
+
effect_resources: (project.effect_resources ?? []).filter((s) => s.id !== command.id),
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Ids of the compositions `clips` draw: their own and their sources', and those these use through
|
|
978
|
+
* group nodes and composition sources.
|
|
979
|
+
*/
|
|
980
|
+
function reachedCompositions(project, clips) {
|
|
981
|
+
const sources = new Map(project.sources.map((s) => [s.id, s]));
|
|
982
|
+
const reached = new Set();
|
|
983
|
+
const pending = [];
|
|
984
|
+
for (const clip of clips) {
|
|
985
|
+
if (clip.composition)
|
|
986
|
+
pending.push(clip.composition.id);
|
|
987
|
+
const source = sources.get(clip.source_id)?.composition;
|
|
988
|
+
if (source)
|
|
989
|
+
pending.push(source);
|
|
990
|
+
}
|
|
991
|
+
while (pending.length) {
|
|
992
|
+
const id = pending.pop();
|
|
993
|
+
if (reached.has(id))
|
|
994
|
+
continue;
|
|
995
|
+
reached.add(id);
|
|
996
|
+
for (const node of findComposition(project, id)?.nodes ?? []) {
|
|
997
|
+
if (node.op.type === "group")
|
|
998
|
+
pending.push(node.op.composition);
|
|
999
|
+
if (node.op.type === "media") {
|
|
1000
|
+
const used = sources.get(node.op.source_id)?.composition;
|
|
1001
|
+
if (used)
|
|
1002
|
+
pending.push(used);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return reached;
|
|
1007
|
+
}
|
|
1008
|
+
function apply(document, command) {
|
|
1009
|
+
if (command.type === "transaction") {
|
|
1010
|
+
// Only the transaction's result must be valid; `executeCommand` validates it once.
|
|
1011
|
+
transactions += 1;
|
|
1012
|
+
try {
|
|
1013
|
+
return edit.deferValidation(() => command.commands.reduce(apply, document));
|
|
1014
|
+
}
|
|
1015
|
+
finally {
|
|
1016
|
+
transactions -= 1;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const next = reduce(document, command);
|
|
1020
|
+
assertLocksKept(document, next, command);
|
|
1021
|
+
return next;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Refuses `command` when it changed what a locked track renders, directly or through a ripple,
|
|
1025
|
+
* matte, graph or asset edit: the track and its clips, their metadata, the compositions its clips
|
|
1026
|
+
* reach (with the groups and composition sources they draw), and the sources it draws and the
|
|
1027
|
+
* effect resources its drawn nodes and transitions read, with their bin assets. Fonts are the whole
|
|
1028
|
+
* project's fallback stack, which no clip names, so they are not among them. A part the edit left
|
|
1029
|
+
* as the same object is not looked into.
|
|
1030
|
+
*/
|
|
1031
|
+
function assertLocksKept(document, next, command) {
|
|
1032
|
+
const locked = document.project.timeline.tracks.filter((t) => document.metadata.tracks[t.id]?.locked);
|
|
1033
|
+
if (!locked.length)
|
|
1034
|
+
return;
|
|
1035
|
+
const before = document.project;
|
|
1036
|
+
const after = next.project;
|
|
1037
|
+
const assetsSame = next.assets === document.assets;
|
|
1038
|
+
const sourcesSame = assetsSame && after.sources === before.sources;
|
|
1039
|
+
const resourcesSame = assetsSame && after.effect_resources === before.effect_resources;
|
|
1040
|
+
const compositionsSame = after.compositions === before.compositions;
|
|
1041
|
+
const tracksAfter = after.timeline.tracks === before.timeline.tracks
|
|
1042
|
+
? null
|
|
1043
|
+
: new Map(after.timeline.tracks.map((t) => [t.id, t]));
|
|
1044
|
+
const byId = (items) => new Map(items.map((item) => [item.id, item]));
|
|
1045
|
+
let sources = null;
|
|
1046
|
+
let resources = null;
|
|
1047
|
+
const assetChanged = (id) => next.assets[id] !== document.assets[id];
|
|
1048
|
+
for (const track of locked) {
|
|
1049
|
+
const unlock = command.type === "track.metadata" &&
|
|
1050
|
+
command.id === track.id &&
|
|
1051
|
+
Object.keys(command.patch).length === 1 &&
|
|
1052
|
+
command.patch.locked === false;
|
|
1053
|
+
const metadataChanged = next.metadata.tracks[track.id] !== document.metadata.tracks[track.id] ||
|
|
1054
|
+
(next.metadata.clips !== document.metadata.clips &&
|
|
1055
|
+
(track.clips ?? []).some((c) => next.metadata.clips[c.id] !== document.metadata.clips[c.id]));
|
|
1056
|
+
const changed = () => {
|
|
1057
|
+
if (tracksAfter && tracksAfter.get(track.id) !== track)
|
|
1058
|
+
return true;
|
|
1059
|
+
if (!compositionsSame &&
|
|
1060
|
+
[...reachedCompositions(before, track.clips ?? [])].some((id) => findComposition(after, id) !== findComposition(before, id)))
|
|
1061
|
+
return true;
|
|
1062
|
+
if (!sourcesSame) {
|
|
1063
|
+
sources ??= [byId(before.sources), byId(after.sources)];
|
|
1064
|
+
const [was, is] = sources;
|
|
1065
|
+
if ([...shownSources(before, [track])].some((id) => is.get(id) !== was.get(id) || assetChanged(id)))
|
|
1066
|
+
return true;
|
|
1067
|
+
}
|
|
1068
|
+
if (!resourcesSame) {
|
|
1069
|
+
resources ??= [
|
|
1070
|
+
byId(before.effect_resources ?? []),
|
|
1071
|
+
byId(after.effect_resources ?? []),
|
|
1072
|
+
];
|
|
1073
|
+
const [was, is] = resources;
|
|
1074
|
+
if ([...drawnEffectResources(before, [track])].some((id) => is.get(id) !== was.get(id) || assetChanged(id)))
|
|
1075
|
+
return true;
|
|
1076
|
+
}
|
|
1077
|
+
return false;
|
|
1078
|
+
};
|
|
1079
|
+
if ((metadataChanged && !unlock) || changed())
|
|
1080
|
+
throw new EditorError("locked", "Track is locked", command.type, track.id);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Applies `command` to `document` and validates the result. `gesture` checks only what a pointer
|
|
1085
|
+
* move can change (see `validateDocumentShape`); the gesture validates in full once, when it
|
|
1086
|
+
* commits.
|
|
1087
|
+
*
|
|
1088
|
+
* Never throws: a refusal is `{ ok: false }` with an `EditorError` (`invalid_command`, `locked`, a
|
|
1089
|
+
* validation code, or an edit code from `alpha2/edit`). `document` and `command` are not modified;
|
|
1090
|
+
* compositions the edit leaves undrawn are removed from the result.
|
|
1091
|
+
*/
|
|
1092
|
+
export function executeCommand(document, command, { gesture = false } = {}) {
|
|
1093
|
+
try {
|
|
1094
|
+
return withValidated(() => {
|
|
1095
|
+
const reduced = apply(document, structuredClone(command));
|
|
1096
|
+
// A clip owns the composition it shows: what the command stopped drawing goes.
|
|
1097
|
+
const undrawn = withoutUndrawn(document.project, reduced.project);
|
|
1098
|
+
const next = withInstanceIds(withoutGraphs(undrawn.removed.size
|
|
1099
|
+
? { ...reduced, project: undrawn.project }
|
|
1100
|
+
: reduced, undrawn.removed));
|
|
1101
|
+
if (gesture) {
|
|
1102
|
+
validateDocumentShape(next);
|
|
1103
|
+
assertProjection(next);
|
|
1104
|
+
}
|
|
1105
|
+
else
|
|
1106
|
+
validateDocument(next);
|
|
1107
|
+
return { ok: true, value: next };
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
catch (cause) {
|
|
1111
|
+
return {
|
|
1112
|
+
ok: false,
|
|
1113
|
+
error: cause instanceof EditorError
|
|
1114
|
+
? cause
|
|
1115
|
+
: new EditorError("invalid_command", String(cause), "dispatch", undefined, true, { cause }),
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
}
|