@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.
Files changed (99) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +108 -2
  3. package/dist/browser/bridge.d.ts +44 -0
  4. package/dist/browser/bridge.js +2176 -0
  5. package/dist/browser/extensions-worker-entry.d.ts +1 -0
  6. package/dist/browser/extensions-worker-entry.js +3 -0
  7. package/dist/browser/extensions-worker.d.ts +46 -0
  8. package/dist/browser/extensions-worker.js +149 -0
  9. package/dist/browser/hash-worker-entry.d.ts +1 -0
  10. package/dist/browser/hash-worker-entry.js +3 -0
  11. package/dist/browser/hash-worker.d.ts +24 -0
  12. package/dist/browser/hash-worker.js +87 -0
  13. package/dist/browser/index.d.ts +11 -0
  14. package/dist/browser/index.js +9 -0
  15. package/dist/browser/jobs.d.ts +82 -0
  16. package/dist/browser/jobs.js +291 -0
  17. package/dist/browser/resources.d.ts +40 -0
  18. package/dist/browser/resources.js +83 -0
  19. package/dist/browser/runtime/assets.d.ts +233 -0
  20. package/dist/browser/runtime/assets.js +633 -0
  21. package/dist/browser/runtime/audio.d.ts +60 -0
  22. package/dist/browser/runtime/audio.js +549 -0
  23. package/dist/browser/runtime/budget.d.ts +11 -0
  24. package/dist/browser/runtime/budget.js +26 -0
  25. package/dist/browser/runtime/caption-fonts.d.ts +34 -0
  26. package/dist/browser/runtime/caption-fonts.js +69 -0
  27. package/dist/browser/runtime/color.d.ts +7 -0
  28. package/dist/browser/runtime/color.js +12 -0
  29. package/dist/browser/runtime/engine.d.ts +91 -0
  30. package/dist/browser/runtime/engine.js +352 -0
  31. package/dist/browser/runtime/export-audio.d.ts +23 -0
  32. package/dist/browser/runtime/export-audio.js +75 -0
  33. package/dist/browser/runtime/exporter.d.ts +140 -0
  34. package/dist/browser/runtime/exporter.js +751 -0
  35. package/dist/browser/runtime/extension-import.d.ts +196 -0
  36. package/dist/browser/runtime/extension-import.js +521 -0
  37. package/dist/browser/runtime/fingerprint.d.ts +57 -0
  38. package/dist/browser/runtime/fingerprint.js +157 -0
  39. package/dist/browser/runtime/generators.d.ts +188 -0
  40. package/dist/browser/runtime/generators.js +173 -0
  41. package/dist/browser/runtime/locks.d.ts +32 -0
  42. package/dist/browser/runtime/locks.js +86 -0
  43. package/dist/browser/runtime/output.d.ts +87 -0
  44. package/dist/browser/runtime/output.js +230 -0
  45. package/dist/browser/runtime/preview-utils.d.ts +3 -0
  46. package/dist/browser/runtime/preview-utils.js +49 -0
  47. package/dist/browser/runtime/proxies.d.ts +72 -0
  48. package/dist/browser/runtime/proxies.js +369 -0
  49. package/dist/browser/runtime/proxy-storage.d.ts +59 -0
  50. package/dist/browser/runtime/proxy-storage.js +119 -0
  51. package/dist/browser/runtime/sources.d.ts +82 -0
  52. package/dist/browser/runtime/sources.js +819 -0
  53. package/dist/browser/runtime/staging.d.ts +10 -0
  54. package/dist/browser/runtime/staging.js +32 -0
  55. package/dist/browser/runtime/still.d.ts +21 -0
  56. package/dist/browser/runtime/still.js +52 -0
  57. package/dist/browser/runtime/wasm.d.ts +9 -0
  58. package/dist/browser/runtime/wasm.js +20 -0
  59. package/dist/browser/types.d.ts +549 -0
  60. package/dist/browser/types.js +1 -0
  61. package/dist/browser/worker-entry.d.ts +1 -0
  62. package/dist/browser/worker-entry.js +2 -0
  63. package/dist/browser/worker.d.ts +24 -0
  64. package/dist/browser/worker.js +239 -0
  65. package/dist/core/captions.d.ts +34 -0
  66. package/dist/core/captions.js +108 -0
  67. package/dist/core/clip-time.d.ts +31 -0
  68. package/dist/core/clip-time.js +125 -0
  69. package/dist/core/commands.d.ts +413 -0
  70. package/dist/core/commands.js +1118 -0
  71. package/dist/core/compositions.d.ts +234 -0
  72. package/dist/core/compositions.js +851 -0
  73. package/dist/core/document.d.ts +245 -0
  74. package/dist/core/document.js +683 -0
  75. package/dist/core/keyframes.d.ts +82 -0
  76. package/dist/core/keyframes.js +153 -0
  77. package/dist/core/presets.d.ts +64 -0
  78. package/dist/core/presets.js +241 -0
  79. package/dist/core/session.d.ts +150 -0
  80. package/dist/core/session.js +541 -0
  81. package/dist/core/text.d.ts +17 -0
  82. package/dist/core/text.js +17 -0
  83. package/dist/core/time.d.ts +19 -0
  84. package/dist/core/time.js +39 -0
  85. package/dist/core/timeline-index.d.ts +44 -0
  86. package/dist/core/timeline-index.js +59 -0
  87. package/dist/core/transitions.d.ts +20 -0
  88. package/dist/core/transitions.js +118 -0
  89. package/dist/core/validated.d.ts +23 -0
  90. package/dist/core/validated.js +52 -0
  91. package/dist/index.d.ts +12 -0
  92. package/dist/index.js +11 -0
  93. package/dist/native-client/index.d.ts +131 -0
  94. package/dist/native-client/index.js +354 -0
  95. package/dist/ports/index.d.ts +146 -0
  96. package/dist/ports/index.js +1 -0
  97. package/dist/react/index.d.ts +37 -0
  98. package/dist/react/index.js +94 -0
  99. 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
+ }