@alpha2-sh/editor 0.0.0 → 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/README.md +107 -1
- package/dist/browser/bridge.d.ts +44 -0
- package/dist/browser/bridge.js +2176 -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 +69 -8
- /package/{index.js → dist/browser/extensions-worker-entry.d.ts} +0 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
import { MAX_COMPOSITION_DEPTH } from "alpha2/project";
|
|
2
|
+
import { copyComposition } from "alpha2/edit";
|
|
3
|
+
import { EditorError } from "./document.js";
|
|
4
|
+
/** Where each composition of a compositions array is, by id, for {@link findComposition}. */
|
|
5
|
+
const compositionIndexes = new WeakMap();
|
|
6
|
+
/**
|
|
7
|
+
* The composition `id` names in `project`. Looked up in an index of the project's compositions
|
|
8
|
+
* array, made once per array, so walks that find many compositions stay linear. An index a changed
|
|
9
|
+
* array no longer matches is made again, and an id it lacks is looked for in the array itself.
|
|
10
|
+
*/
|
|
11
|
+
export function findComposition(project, id) {
|
|
12
|
+
const compositions = project.compositions ?? [];
|
|
13
|
+
let index = compositionIndexes.get(compositions);
|
|
14
|
+
if (!index) {
|
|
15
|
+
index = new Map(compositions.map((c, i) => [c.id, i]));
|
|
16
|
+
compositionIndexes.set(compositions, index);
|
|
17
|
+
}
|
|
18
|
+
const at = index.get(id);
|
|
19
|
+
if (at !== undefined && compositions[at]?.id === id)
|
|
20
|
+
return compositions[at];
|
|
21
|
+
if (at !== undefined)
|
|
22
|
+
compositionIndexes.delete(compositions);
|
|
23
|
+
return compositions.find((c) => c.id === id);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A clip's effects as a stack: its composition when that is the clip's picture through a chain of
|
|
27
|
+
* effect nodes and nothing else, bottom first. A clip without a composition has an empty stack.
|
|
28
|
+
* `undefined` means the composition is a graph a stack cannot show: it branches, merges, reads
|
|
29
|
+
* other sources, or has loose nodes.
|
|
30
|
+
*/
|
|
31
|
+
export function effectStack(project, clip) {
|
|
32
|
+
if (clip.composition == null)
|
|
33
|
+
return [];
|
|
34
|
+
const composition = findComposition(project, clip.composition.id);
|
|
35
|
+
if (!composition)
|
|
36
|
+
return undefined;
|
|
37
|
+
const nodes = new Map((composition.nodes ?? []).map((n) => [n.id, n]));
|
|
38
|
+
const stack = [];
|
|
39
|
+
let at = composition.output ?? undefined;
|
|
40
|
+
while (at !== undefined) {
|
|
41
|
+
const node = nodes.get(at);
|
|
42
|
+
if (!node)
|
|
43
|
+
return undefined;
|
|
44
|
+
if (node.op.type === "clip") {
|
|
45
|
+
return stack.length + 1 === nodes.size ? stack.reverse() : undefined;
|
|
46
|
+
}
|
|
47
|
+
if (node.op.type !== "effect" || node.op.input == null)
|
|
48
|
+
return undefined;
|
|
49
|
+
stack.push({ id: node.id, effect: node.op.effect, bypass: !!node.bypass });
|
|
50
|
+
at = node.op.input;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
/** An id based on `base` that no composition in `project` has. */
|
|
55
|
+
export function freeCompositionId(project, base) {
|
|
56
|
+
const taken = new Set((project.compositions ?? []).map((c) => c.id));
|
|
57
|
+
let id = base;
|
|
58
|
+
for (let n = 2; taken.has(id); n += 1)
|
|
59
|
+
id = `${base}~${n}`;
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `project` with clip `clipId` showing its picture through `stack`, bottom first: its composition
|
|
64
|
+
* rewritten as that chain, or removed with the reference when the stack is empty. The composition
|
|
65
|
+
* keeps its id and the clip its time mapping; a clip without one gets `<clip id>-fx`.
|
|
66
|
+
* Throws when `clipId` names no clip.
|
|
67
|
+
*/
|
|
68
|
+
export function withEffectStack(project, clipId, stack) {
|
|
69
|
+
const clip = project.timeline.tracks
|
|
70
|
+
.flatMap((t) => t.clips ?? [])
|
|
71
|
+
.find((c) => c.id === clipId);
|
|
72
|
+
if (!clip)
|
|
73
|
+
throw new Error(`Unknown clip ${clipId}`);
|
|
74
|
+
// The composition no clip shows any more goes when the command ends (`executeCommand`).
|
|
75
|
+
if (stack.length === 0) {
|
|
76
|
+
if (clip.composition == null)
|
|
77
|
+
return project;
|
|
78
|
+
return setClipComposition(project, clipId, undefined);
|
|
79
|
+
}
|
|
80
|
+
const reference = clip.composition ?? {
|
|
81
|
+
id: freeCompositionId(project, `${clipId}-fx`),
|
|
82
|
+
};
|
|
83
|
+
const previous = findComposition(project, reference.id);
|
|
84
|
+
const taken = new Set(stack.map((entry) => entry.id));
|
|
85
|
+
const kept = previous?.nodes?.find((n) => n.op.type === "clip")?.id;
|
|
86
|
+
let pictureId = kept && !taken.has(kept) ? kept : "clip";
|
|
87
|
+
for (let n = 2; taken.has(pictureId); n += 1)
|
|
88
|
+
pictureId = `clip~${n}`;
|
|
89
|
+
const nodes = [{ id: pictureId, op: { type: "clip" } }];
|
|
90
|
+
let input = pictureId;
|
|
91
|
+
for (const entry of stack) {
|
|
92
|
+
nodes.push({
|
|
93
|
+
id: entry.id,
|
|
94
|
+
...(entry.bypass ? { bypass: true } : {}),
|
|
95
|
+
op: { type: "effect", input, effect: entry.effect },
|
|
96
|
+
});
|
|
97
|
+
input = entry.id;
|
|
98
|
+
}
|
|
99
|
+
const composition = { id: reference.id, nodes, output: input };
|
|
100
|
+
const compositions = project.compositions ?? [];
|
|
101
|
+
return setClipComposition({
|
|
102
|
+
...project,
|
|
103
|
+
compositions: previous
|
|
104
|
+
? compositions.map((c) => (c.id === reference.id ? composition : c))
|
|
105
|
+
: [...compositions, composition],
|
|
106
|
+
}, clipId, reference);
|
|
107
|
+
}
|
|
108
|
+
function setClipComposition(project, clipId, reference) {
|
|
109
|
+
return {
|
|
110
|
+
...project,
|
|
111
|
+
timeline: {
|
|
112
|
+
...project.timeline,
|
|
113
|
+
tracks: project.timeline.tracks.map((track) => (track.clips ?? []).some((c) => c.id === clipId)
|
|
114
|
+
? {
|
|
115
|
+
...track,
|
|
116
|
+
clips: (track.clips ?? []).map((c) => {
|
|
117
|
+
if (c.id !== clipId)
|
|
118
|
+
return c;
|
|
119
|
+
if (reference)
|
|
120
|
+
return { ...c, composition: reference };
|
|
121
|
+
const { composition: _removed, ...rest } = c;
|
|
122
|
+
return rest;
|
|
123
|
+
}),
|
|
124
|
+
}
|
|
125
|
+
: track),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The compositions `id` draws: itself and, through group nodes, the group definitions it uses, each
|
|
131
|
+
* once.
|
|
132
|
+
*/
|
|
133
|
+
export function compositionTree(project, id) {
|
|
134
|
+
const tree = [];
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
const pending = [id];
|
|
137
|
+
while (pending.length) {
|
|
138
|
+
const next = pending.pop();
|
|
139
|
+
if (seen.has(next))
|
|
140
|
+
continue;
|
|
141
|
+
seen.add(next);
|
|
142
|
+
const composition = findComposition(project, next);
|
|
143
|
+
if (!composition)
|
|
144
|
+
continue;
|
|
145
|
+
tree.push(composition);
|
|
146
|
+
for (const node of composition.nodes ?? [])
|
|
147
|
+
if (node.op.type === "group")
|
|
148
|
+
pending.push(node.op.composition);
|
|
149
|
+
}
|
|
150
|
+
return tree;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Ids of the compositions `project` draws: those clips show and composition sources show (Library
|
|
154
|
+
* items), and those these use through group nodes and composition sources, as far as they go.
|
|
155
|
+
*/
|
|
156
|
+
export function drawnCompositions(project) {
|
|
157
|
+
const sources = new Map(project.sources.map((s) => [s.id, s]));
|
|
158
|
+
const pending = project.sources.flatMap((s) => s.composition ? [s.composition] : []);
|
|
159
|
+
for (const track of project.timeline.tracks)
|
|
160
|
+
for (const clip of track.clips ?? [])
|
|
161
|
+
if (clip.composition != null)
|
|
162
|
+
pending.push(clip.composition.id);
|
|
163
|
+
const drawn = new Set();
|
|
164
|
+
while (pending.length) {
|
|
165
|
+
const id = pending.pop();
|
|
166
|
+
if (drawn.has(id))
|
|
167
|
+
continue;
|
|
168
|
+
drawn.add(id);
|
|
169
|
+
for (const node of findComposition(project, id)?.nodes ?? []) {
|
|
170
|
+
if (node.op.type === "group")
|
|
171
|
+
pending.push(node.op.composition);
|
|
172
|
+
if (node.op.type === "media") {
|
|
173
|
+
const shown = sources.get(node.op.source_id)?.composition;
|
|
174
|
+
if (shown)
|
|
175
|
+
pending.push(shown);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return drawn;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* `after` without the compositions `before` drew and `after` no longer does: a clip owns the
|
|
183
|
+
* composition it shows, so the edit that stops the last drawing of one (deleting its clip or track,
|
|
184
|
+
* removing its Library source or the group node that drew it, emptying an effect stack) removes it,
|
|
185
|
+
* and the group definitions only it drew with it. A composition `before` did not draw stays, so one
|
|
186
|
+
* added for a later edit (a macro's definition) waits for it. Returns the ids it removed.
|
|
187
|
+
*/
|
|
188
|
+
export function withoutUndrawn(before, after) {
|
|
189
|
+
const compositions = after.compositions ?? [];
|
|
190
|
+
if (!compositions.length)
|
|
191
|
+
return { project: after, removed: new Set() };
|
|
192
|
+
const was = drawnCompositions(before);
|
|
193
|
+
const is = drawnCompositions(after);
|
|
194
|
+
const removed = new Set(compositions.filter((c) => was.has(c.id) && !is.has(c.id)).map((c) => c.id));
|
|
195
|
+
if (!removed.size)
|
|
196
|
+
return { project: after, removed };
|
|
197
|
+
const kept = compositions.filter((c) => !removed.has(c.id));
|
|
198
|
+
if (kept.length)
|
|
199
|
+
return { project: { ...after, compositions: kept }, removed };
|
|
200
|
+
const { compositions: _removed, ...rest } = after;
|
|
201
|
+
return { project: rest, removed };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* `project` with `clip` given compositions no other clip draws, for a clip a command puts down (a
|
|
205
|
+
* paste, a duplicate). With `compositions`, those the clip drew when it was copied, it shows a copy
|
|
206
|
+
* of them whatever the project has now. Otherwise it shows a copy of its composition when anything
|
|
207
|
+
* else draws that composition or a group definition in it, and the composition itself when nothing
|
|
208
|
+
* does. A copy takes free ids based on `<root>~<clip id>` ({@link copyComposition}), so the clip is
|
|
209
|
+
* edited independently of its original. Returns the copies' ids by their originals'.
|
|
210
|
+
*/
|
|
211
|
+
export function ownComposition(project, clip, compositions) {
|
|
212
|
+
const reference = clip.composition;
|
|
213
|
+
const none = { project, clip, copies: new Map() };
|
|
214
|
+
if (reference == null)
|
|
215
|
+
return none;
|
|
216
|
+
if (!compositions) {
|
|
217
|
+
const tree = compositionTree(project, reference.id);
|
|
218
|
+
if (!tree.length)
|
|
219
|
+
return none;
|
|
220
|
+
const elsewhere = drawnCompositions(project);
|
|
221
|
+
if (!tree.some((c) => elsewhere.has(c.id)))
|
|
222
|
+
return none;
|
|
223
|
+
}
|
|
224
|
+
else if (!compositions.some((c) => c.id === reference.id))
|
|
225
|
+
return none;
|
|
226
|
+
const copy = copyComposition(project, reference.id, clip.id, compositions);
|
|
227
|
+
return {
|
|
228
|
+
project: copy.project,
|
|
229
|
+
clip: { ...clip, composition: { ...reference, id: copy.id } },
|
|
230
|
+
copies: copy.copies,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The compositions `copy` draws for those `original` draws, by id: the two themselves, and the
|
|
235
|
+
* definitions of their group nodes of the same id, as far as they go. A composition copied for a
|
|
236
|
+
* split or a paste corresponds to its original this way.
|
|
237
|
+
*/
|
|
238
|
+
export function correspondingCompositions(project, original, copy) {
|
|
239
|
+
const pairs = new Map();
|
|
240
|
+
const pending = [[original, copy]];
|
|
241
|
+
while (pending.length) {
|
|
242
|
+
const [from, to] = pending.pop();
|
|
243
|
+
if (pairs.has(from))
|
|
244
|
+
continue;
|
|
245
|
+
pairs.set(from, to);
|
|
246
|
+
const copied = new Map((findComposition(project, to)?.nodes ?? []).map((n) => [n.id, n]));
|
|
247
|
+
for (const node of findComposition(project, from)?.nodes ?? []) {
|
|
248
|
+
const other = copied.get(node.id);
|
|
249
|
+
if (node.op.type === "group" && other?.op.type === "group")
|
|
250
|
+
pending.push([node.op.composition, other.op.composition]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return pairs;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Composition time at timeline time `tick` for `clip`: `offset + (tick - start) * scale`, rounded
|
|
257
|
+
* down, as the engine maps it. Without a composition, timeline time. Time is whole ticks; a
|
|
258
|
+
* fraction (a curve sampled between ticks) reads as the tick it falls in.
|
|
259
|
+
*/
|
|
260
|
+
export function compositionTime(clip, tick) {
|
|
261
|
+
tick = Math.floor(finiteTime(tick));
|
|
262
|
+
const reference = clip.composition;
|
|
263
|
+
if (reference == null)
|
|
264
|
+
return tick;
|
|
265
|
+
const { num, den } = reference.scale ?? { num: 1, den: 1 };
|
|
266
|
+
return ((reference.offset ?? 0) +
|
|
267
|
+
divide(tick - clip.timeline_range.start, num, den, "floor"));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The first timeline time at which `clip` shows composition time `time`: the inverse of
|
|
271
|
+
* {@link compositionTime}, rounded up to a tick. A fractional `time` is first rounded up to the
|
|
272
|
+
* next whole tick.
|
|
273
|
+
*/
|
|
274
|
+
export function timelineTime(clip, time) {
|
|
275
|
+
time = Math.ceil(finiteTime(time));
|
|
276
|
+
const reference = clip.composition;
|
|
277
|
+
if (reference == null)
|
|
278
|
+
return time;
|
|
279
|
+
const { num, den } = reference.scale ?? { num: 1, den: 1 };
|
|
280
|
+
return (clip.timeline_range.start +
|
|
281
|
+
divide(time - (reference.offset ?? 0), den, num, "ceil"));
|
|
282
|
+
}
|
|
283
|
+
function finiteTime(time) {
|
|
284
|
+
if (!Number.isFinite(time))
|
|
285
|
+
throw new EditorError("invalid_time", `Time ${time} is not finite`, "time");
|
|
286
|
+
return time;
|
|
287
|
+
}
|
|
288
|
+
/** `value * num / den` rounded as asked, exact at any size. */
|
|
289
|
+
function divide(value, num, den, rounding) {
|
|
290
|
+
const product = BigInt(value) * BigInt(num);
|
|
291
|
+
const divisor = BigInt(den);
|
|
292
|
+
const quotient = product / divisor;
|
|
293
|
+
const remainder = product % divisor;
|
|
294
|
+
if (remainder === 0n)
|
|
295
|
+
return Number(quotient);
|
|
296
|
+
// BigInt division truncates toward zero.
|
|
297
|
+
if (rounding === "floor")
|
|
298
|
+
return Number(product < 0n ? quotient - 1n : quotient);
|
|
299
|
+
return Number(product > 0n ? quotient + 1n : quotient);
|
|
300
|
+
}
|
|
301
|
+
/** The node ids an operation takes its inputs from. */
|
|
302
|
+
function inputsOf(op) {
|
|
303
|
+
switch (op.type) {
|
|
304
|
+
case "effect":
|
|
305
|
+
case "transform":
|
|
306
|
+
case "shape_mask":
|
|
307
|
+
return op.input != null ? [op.input] : [];
|
|
308
|
+
case "transition":
|
|
309
|
+
return [op.from, op.to].filter((id) => id != null);
|
|
310
|
+
case "matte":
|
|
311
|
+
return [op.input, op.matte].filter((id) => id != null);
|
|
312
|
+
case "merge":
|
|
313
|
+
return (op.layers ?? [])
|
|
314
|
+
.map((layer) => layer.input)
|
|
315
|
+
.filter((id) => id != null);
|
|
316
|
+
case "group":
|
|
317
|
+
return (op.inputs ?? []).filter((id) => id != null);
|
|
318
|
+
default:
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Ids of the sources `tracks`' clips draw: each clip's own, and those the media nodes its
|
|
324
|
+
* compositions' outputs need read (the inputs {@link CompositionReads.neededInputs} names: of a
|
|
325
|
+
* group node only the ports its composition reads), through group nodes and composition sources
|
|
326
|
+
* too. A clip's composition source counts only where the clip's own composition draws the clip. The
|
|
327
|
+
* engine's `Project::used_sources` is the same set; `tests/cases/used-sources-corpus.json` holds both to
|
|
328
|
+
* it.
|
|
329
|
+
*/
|
|
330
|
+
export function shownSources(project, tracks = project.timeline.tracks) {
|
|
331
|
+
return drawn(project, tracks).sources;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Ids of the effect resources (`effect_resources`) what `tracks` draw reads: those the effect and
|
|
335
|
+
* transition nodes drawing {@link shownSources} evaluates name (not a bypassed node's), and those
|
|
336
|
+
* the tracks' transitions name. Only these must be registered for a render: the engine asks for a
|
|
337
|
+
* resource when a node it evaluates needs one.
|
|
338
|
+
*/
|
|
339
|
+
export function drawnEffectResources(project, tracks = project.timeline.tracks) {
|
|
340
|
+
return drawn(project, tracks).resources;
|
|
341
|
+
}
|
|
342
|
+
/** What {@link shownSources} and {@link drawnEffectResources} walk once. */
|
|
343
|
+
function drawn(project, tracks) {
|
|
344
|
+
const used = new Set();
|
|
345
|
+
const resources = new Set();
|
|
346
|
+
const named = (effect) => {
|
|
347
|
+
for (const id of Object.values(effect?.resources ?? {}))
|
|
348
|
+
resources.add(id);
|
|
349
|
+
};
|
|
350
|
+
const walked = new Set();
|
|
351
|
+
const sources = new Map(project.sources.map((s) => [s.id, s]));
|
|
352
|
+
const reads = compositionReads(project.compositions ?? []);
|
|
353
|
+
const walk = (id) => {
|
|
354
|
+
if (id == null || walked.has(id))
|
|
355
|
+
return;
|
|
356
|
+
walked.add(id);
|
|
357
|
+
const composition = findComposition(project, id);
|
|
358
|
+
const nodes = new Map((composition?.nodes ?? []).map((n) => [n.id, n]));
|
|
359
|
+
const pending = composition?.output != null ? [composition.output] : [];
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
while (pending.length) {
|
|
362
|
+
const node = nodes.get(pending.pop());
|
|
363
|
+
if (!node || seen.has(node.id))
|
|
364
|
+
continue;
|
|
365
|
+
seen.add(node.id);
|
|
366
|
+
if (node.op.type === "media") {
|
|
367
|
+
used.add(node.op.source_id);
|
|
368
|
+
walk(sources.get(node.op.source_id)?.composition);
|
|
369
|
+
}
|
|
370
|
+
if (node.op.type === "group")
|
|
371
|
+
walk(node.op.composition);
|
|
372
|
+
if ((node.op.type === "effect" || node.op.type === "transition") &&
|
|
373
|
+
!node.bypass)
|
|
374
|
+
named(node.op.effect);
|
|
375
|
+
for (const key of reads.neededInputs(node)) {
|
|
376
|
+
const input = inputOf(node.op, key);
|
|
377
|
+
if (input !== undefined)
|
|
378
|
+
pending.push(input);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
for (const track of tracks) {
|
|
383
|
+
for (const transition of track.transitions ?? [])
|
|
384
|
+
named(transition.effect);
|
|
385
|
+
for (const clip of track.clips ?? []) {
|
|
386
|
+
used.add(clip.source_id);
|
|
387
|
+
// A composition source is the clip's picture, drawn only where the clip's own composition
|
|
388
|
+
// draws the clip.
|
|
389
|
+
if (clip.composition == null || reads.drawsClip(clip.composition.id))
|
|
390
|
+
walk(sources.get(clip.source_id)?.composition);
|
|
391
|
+
walk(clip.composition?.id);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return { sources: used, resources };
|
|
395
|
+
}
|
|
396
|
+
/** The input keys of a node's operation, in order. */
|
|
397
|
+
export function inputKeys(op) {
|
|
398
|
+
switch (op.type) {
|
|
399
|
+
case "effect":
|
|
400
|
+
case "transform":
|
|
401
|
+
case "shape_mask":
|
|
402
|
+
return ["input"];
|
|
403
|
+
case "transition":
|
|
404
|
+
return ["from", "to"];
|
|
405
|
+
case "matte":
|
|
406
|
+
return ["input", "matte"];
|
|
407
|
+
case "merge":
|
|
408
|
+
return (op.layers ?? []).map((_, index) => index);
|
|
409
|
+
case "group":
|
|
410
|
+
return (op.inputs ?? []).map((_, index) => index);
|
|
411
|
+
default:
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The inputs drawing `node` reads: only its pass-through input (the first) when it is bypassed, and
|
|
417
|
+
* every input otherwise.
|
|
418
|
+
*/
|
|
419
|
+
export function neededInputs(node) {
|
|
420
|
+
const keys = inputKeys(node.op);
|
|
421
|
+
return node.bypass ? keys.slice(0, 1) : keys;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* What each of `compositions` reads: the group inputs and clip nodes a walk from its output
|
|
425
|
+
* reaches, along the inputs drawing each node reads, into the compositions its group nodes draw. A
|
|
426
|
+
* group draws what its nodes would inline, so a group node needs only the ports its composition
|
|
427
|
+
* reads: a port it never reads may be left unconnected, and what a connected one reads is not
|
|
428
|
+
* drawn. A composition that uses itself (which validation refuses) reads every port and draws the
|
|
429
|
+
* clip.
|
|
430
|
+
*/
|
|
431
|
+
export function compositionReads(compositions) {
|
|
432
|
+
const byId = new Map(compositions.map((c) => [c.id, c]));
|
|
433
|
+
const known = new Map();
|
|
434
|
+
const reads = {
|
|
435
|
+
readsPort: (id, port) => {
|
|
436
|
+
const read = known.get(id);
|
|
437
|
+
return read === undefined || (read.ports[port] ?? false);
|
|
438
|
+
},
|
|
439
|
+
drawsClip: (id) => known.get(id)?.clip ?? true,
|
|
440
|
+
neededInputs: (node) => !node.bypass && node.op.type === "group"
|
|
441
|
+
? neededInputs(node).filter((key) => typeof key !== "number" ||
|
|
442
|
+
reads.readsPort(node.op.composition, key))
|
|
443
|
+
: neededInputs(node),
|
|
444
|
+
};
|
|
445
|
+
const walk = (composition) => {
|
|
446
|
+
const nodes = new Map((composition.nodes ?? []).map((n) => [n.id, n]));
|
|
447
|
+
const read = {
|
|
448
|
+
ports: (composition.inputs ?? []).map(() => false),
|
|
449
|
+
clip: false,
|
|
450
|
+
};
|
|
451
|
+
const reached = new Set();
|
|
452
|
+
const pending = composition.output != null ? [composition.output] : [];
|
|
453
|
+
while (pending.length) {
|
|
454
|
+
const node = nodes.get(pending.pop());
|
|
455
|
+
if (!node || reached.has(node.id))
|
|
456
|
+
continue;
|
|
457
|
+
reached.add(node.id);
|
|
458
|
+
if (!node.bypass) {
|
|
459
|
+
if (node.op.type === "group_input" && node.op.index < read.ports.length)
|
|
460
|
+
read.ports[node.op.index] = true;
|
|
461
|
+
if (node.op.type === "clip")
|
|
462
|
+
read.clip = true;
|
|
463
|
+
if (node.op.type === "group" && reads.drawsClip(node.op.composition))
|
|
464
|
+
read.clip = true;
|
|
465
|
+
}
|
|
466
|
+
for (const key of reads.neededInputs(node)) {
|
|
467
|
+
const input = inputOf(node.op, key);
|
|
468
|
+
if (input !== undefined)
|
|
469
|
+
pending.push(input);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return read;
|
|
473
|
+
};
|
|
474
|
+
// In dependency order, without recursion: a composition is walked once the ones its group nodes
|
|
475
|
+
// draw are known.
|
|
476
|
+
const walking = new Set();
|
|
477
|
+
for (const root of compositions) {
|
|
478
|
+
const stack = [[root, false]];
|
|
479
|
+
while (stack.length) {
|
|
480
|
+
const [composition, expanded] = stack.pop();
|
|
481
|
+
if (known.has(composition.id))
|
|
482
|
+
continue;
|
|
483
|
+
if (!expanded) {
|
|
484
|
+
if (walking.has(composition.id))
|
|
485
|
+
continue;
|
|
486
|
+
walking.add(composition.id);
|
|
487
|
+
stack.push([composition, true]);
|
|
488
|
+
for (const node of composition.nodes ?? []) {
|
|
489
|
+
const inner = node.op.type === "group"
|
|
490
|
+
? byId.get(node.op.composition)
|
|
491
|
+
: undefined;
|
|
492
|
+
if (inner && !known.has(inner.id) && !walking.has(inner.id))
|
|
493
|
+
stack.push([inner, false]);
|
|
494
|
+
}
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
walking.delete(composition.id);
|
|
498
|
+
known.set(composition.id, walk(composition));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return reads;
|
|
502
|
+
}
|
|
503
|
+
/** The node an input reads, or `undefined` when it is unconnected or absent. */
|
|
504
|
+
export function inputOf(op, key) {
|
|
505
|
+
if (typeof key === "number")
|
|
506
|
+
return op.type === "merge"
|
|
507
|
+
? (op.layers?.[key]?.input ?? undefined)
|
|
508
|
+
: op.type === "group"
|
|
509
|
+
? (op.inputs?.[key] ?? undefined)
|
|
510
|
+
: undefined;
|
|
511
|
+
const value = op[key];
|
|
512
|
+
return typeof value === "string" ? value : undefined;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* `op` with input `key` reading `node` (`null` disconnects it). Throws when `op` has no input
|
|
516
|
+
* `key`.
|
|
517
|
+
*/
|
|
518
|
+
export function withInput(op, key, node) {
|
|
519
|
+
if (!inputKeys(op).includes(key))
|
|
520
|
+
throw new Error(`A ${op.type} node has no input ${String(key)}`);
|
|
521
|
+
if (typeof key === "number" && op.type === "merge")
|
|
522
|
+
return {
|
|
523
|
+
...op,
|
|
524
|
+
layers: (op.layers ?? []).map((layer, index) => index === key ? { ...layer, input: node } : layer),
|
|
525
|
+
};
|
|
526
|
+
if (typeof key === "number" && op.type === "group")
|
|
527
|
+
return {
|
|
528
|
+
...op,
|
|
529
|
+
inputs: (op.inputs ?? []).map((input, index) => index === key ? node : input),
|
|
530
|
+
};
|
|
531
|
+
return { ...op, [key]: node };
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* What stops `composition`'s output from rendering when it is drawn as `use`: no output, an
|
|
535
|
+
* unconnected input a node the output reaches uses (only the pass-through input of a bypassed node,
|
|
536
|
+
* and of a group node only the ports its composition reads), a group input nothing feeds, or a clip
|
|
537
|
+
* node where there is no clip. The engine refuses the same frames with `INCOMPLETE_COMPOSITION`;
|
|
538
|
+
* this lets an editor mark the nodes before it asks.
|
|
539
|
+
*
|
|
540
|
+
* With `project`, the issues of the compositions the output's group and media nodes draw are
|
|
541
|
+
* included, as the engine plans them inline; without it those nodes are taken as drawn.
|
|
542
|
+
*/
|
|
543
|
+
export function graphIssues(composition, use, project) {
|
|
544
|
+
const issues = [];
|
|
545
|
+
const compositions = new Map((project?.compositions ?? []).map((c) => [c.id, c]));
|
|
546
|
+
// Without the project every group port counts as read.
|
|
547
|
+
const reads = compositionReads(project?.compositions ?? []);
|
|
548
|
+
const sourceCompositions = new Map((project?.sources ?? []).flatMap((s) => s.composition != null ? [[s.id, s.composition]] : []));
|
|
549
|
+
const visit = (drawn, scope) => {
|
|
550
|
+
const named = (id) => scope.prefix + id;
|
|
551
|
+
if (drawn.output == null) {
|
|
552
|
+
issues.push(scope.prefix
|
|
553
|
+
? { kind: "no_output", node: scope.prefix.slice(0, -1) }
|
|
554
|
+
: { kind: "no_output" });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const nodes = new Map((drawn.nodes ?? []).map((n) => [n.id, n]));
|
|
558
|
+
const seen = new Set();
|
|
559
|
+
const pending = [drawn.output];
|
|
560
|
+
while (pending.length) {
|
|
561
|
+
const node = nodes.get(pending.pop());
|
|
562
|
+
if (!node || seen.has(node.id))
|
|
563
|
+
continue;
|
|
564
|
+
seen.add(node.id);
|
|
565
|
+
const needed = reads.neededInputs(node);
|
|
566
|
+
if (node.bypass && needed.length === 0 && node.op.type === "merge")
|
|
567
|
+
issues.push({ kind: "missing_input", node: named(node.id), input: 0 });
|
|
568
|
+
for (const key of needed) {
|
|
569
|
+
const input = inputOf(node.op, key);
|
|
570
|
+
if (input === undefined)
|
|
571
|
+
issues.push({
|
|
572
|
+
kind: "missing_input",
|
|
573
|
+
node: named(node.id),
|
|
574
|
+
input: key,
|
|
575
|
+
});
|
|
576
|
+
else
|
|
577
|
+
pending.push(input);
|
|
578
|
+
}
|
|
579
|
+
if (node.bypass)
|
|
580
|
+
continue;
|
|
581
|
+
const within = [...scope.within, drawn.id];
|
|
582
|
+
// Validation bounds the nesting and forbids cycles; an unvalidated draft stops here rather
|
|
583
|
+
// than recursing forever.
|
|
584
|
+
const nest = (id) => {
|
|
585
|
+
const inner = id === undefined ? undefined : compositions.get(id);
|
|
586
|
+
return inner &&
|
|
587
|
+
within.length < MAX_COMPOSITION_DEPTH &&
|
|
588
|
+
!within.includes(inner.id)
|
|
589
|
+
? inner
|
|
590
|
+
: undefined;
|
|
591
|
+
};
|
|
592
|
+
switch (node.op.type) {
|
|
593
|
+
case "clip":
|
|
594
|
+
if (!scope.clipAllowed)
|
|
595
|
+
issues.push({ kind: "clip_without_clip", node: named(node.id) });
|
|
596
|
+
break;
|
|
597
|
+
case "group_input":
|
|
598
|
+
if (!(node.op.index < scope.bound))
|
|
599
|
+
issues.push({ kind: "unbound_group_input", node: named(node.id) });
|
|
600
|
+
break;
|
|
601
|
+
case "group": {
|
|
602
|
+
const inner = nest(node.op.composition);
|
|
603
|
+
if (inner)
|
|
604
|
+
visit(inner, {
|
|
605
|
+
prefix: `${named(node.id)}/`,
|
|
606
|
+
bound: (node.op.inputs ?? []).length,
|
|
607
|
+
clipAllowed: scope.clipAllowed,
|
|
608
|
+
within,
|
|
609
|
+
});
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case "media": {
|
|
613
|
+
const inner = nest(sourceCompositions.get(node.op.source_id));
|
|
614
|
+
if (inner)
|
|
615
|
+
visit(inner, {
|
|
616
|
+
prefix: `${named(node.id)}/`,
|
|
617
|
+
bound: 0,
|
|
618
|
+
clipAllowed: false,
|
|
619
|
+
within,
|
|
620
|
+
});
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
default:
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
visit(composition, {
|
|
629
|
+
prefix: "",
|
|
630
|
+
bound: use === "group" ? Infinity : 0,
|
|
631
|
+
clipAllowed: use !== "source",
|
|
632
|
+
within: [],
|
|
633
|
+
});
|
|
634
|
+
return issues;
|
|
635
|
+
}
|
|
636
|
+
/** An id based on `base` that no node of `composition` has. */
|
|
637
|
+
export function freeNodeId(composition, base) {
|
|
638
|
+
const taken = new Set((composition.nodes ?? []).map((n) => n.id));
|
|
639
|
+
let id = base;
|
|
640
|
+
for (let n = 2; taken.has(id); n += 1)
|
|
641
|
+
id = `${base}-${n}`;
|
|
642
|
+
return id;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* `composition` without the nodes `ids`, every input that read one of them disconnected, no output
|
|
646
|
+
* if it was one of them, and no control that exposed one of them.
|
|
647
|
+
*/
|
|
648
|
+
export function withoutNodes(composition, ids) {
|
|
649
|
+
const drop = new Set(ids);
|
|
650
|
+
const nodes = (composition.nodes ?? [])
|
|
651
|
+
.filter((node) => !drop.has(node.id))
|
|
652
|
+
.map((node) => {
|
|
653
|
+
let op = node.op;
|
|
654
|
+
for (const key of inputKeys(op)) {
|
|
655
|
+
const input = inputOf(op, key);
|
|
656
|
+
if (input !== undefined && drop.has(input))
|
|
657
|
+
op = withInput(op, key, null);
|
|
658
|
+
}
|
|
659
|
+
return op === node.op ? node : { ...node, op };
|
|
660
|
+
});
|
|
661
|
+
const output = composition.output != null && drop.has(composition.output)
|
|
662
|
+
? null
|
|
663
|
+
: (composition.output ?? null);
|
|
664
|
+
const controls = composition.controls?.filter((c) => !drop.has(c.node));
|
|
665
|
+
return {
|
|
666
|
+
...composition,
|
|
667
|
+
nodes,
|
|
668
|
+
output,
|
|
669
|
+
...(controls && controls.length !== composition.controls?.length
|
|
670
|
+
? { controls }
|
|
671
|
+
: {}),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* `composition` with nodes `ids` folded into a group node `groupId` that draws a new composition
|
|
676
|
+
* `definitionId` of them: each node outside the selection that a selected node reads becomes one of
|
|
677
|
+
* its inputs, in the order first read, and the one selected node read from outside (or the output)
|
|
678
|
+
* becomes its output. A selected group input stays in `composition`: it reads `composition`'s own
|
|
679
|
+
* input, which a node inside the group reads through a port like any node outside. The picture does
|
|
680
|
+
* not change.
|
|
681
|
+
*
|
|
682
|
+
* @throws When the selection is empty or only group inputs, names an absent node, `groupId` names
|
|
683
|
+
* an unselected node, or other than one selected node's picture leaves the selection.
|
|
684
|
+
*/
|
|
685
|
+
export function groupNodes(composition, ids, groupId, definitionId) {
|
|
686
|
+
const nodes = composition.nodes ?? [];
|
|
687
|
+
if (ids.length === 0)
|
|
688
|
+
throw new Error("Choose the nodes to group");
|
|
689
|
+
for (const id of ids)
|
|
690
|
+
if (!nodes.some((node) => node.id === id))
|
|
691
|
+
throw new Error(`Unknown node ${id}`);
|
|
692
|
+
const selected = new Set(ids.filter((id) => nodes.find((node) => node.id === id)?.op.type !== "group_input"));
|
|
693
|
+
if (selected.size === 0)
|
|
694
|
+
throw new Error("A group input stays where it is: choose the nodes that read it");
|
|
695
|
+
if (nodes.some((node) => node.id === groupId && !selected.has(groupId)))
|
|
696
|
+
throw new Error(`A node is already called ${groupId}`);
|
|
697
|
+
const leaving = new Set();
|
|
698
|
+
for (const node of nodes)
|
|
699
|
+
if (!selected.has(node.id))
|
|
700
|
+
for (const input of inputsOf(node.op))
|
|
701
|
+
if (selected.has(input))
|
|
702
|
+
leaving.add(input);
|
|
703
|
+
if (composition.output != null && selected.has(composition.output))
|
|
704
|
+
leaving.add(composition.output);
|
|
705
|
+
if (leaving.size !== 1)
|
|
706
|
+
throw new Error(leaving.size === 0
|
|
707
|
+
? "Nothing outside the group reads its picture"
|
|
708
|
+
: "A group has one output: only one of its nodes may be read from outside it");
|
|
709
|
+
const [output] = [...leaving];
|
|
710
|
+
// A control exposes a node of this composition to the group nodes drawing it; a node in a nested
|
|
711
|
+
// group cannot be one.
|
|
712
|
+
const exposed = composition.controls?.find((c) => selected.has(c.node));
|
|
713
|
+
if (exposed)
|
|
714
|
+
throw new Error(`Node ${exposed.node} is exposed as control ${exposed.name}: remove the control before grouping it`);
|
|
715
|
+
const ports = [];
|
|
716
|
+
for (const node of nodes)
|
|
717
|
+
if (selected.has(node.id))
|
|
718
|
+
for (const input of inputsOf(node.op))
|
|
719
|
+
if (!selected.has(input) && !ports.includes(input))
|
|
720
|
+
ports.push(input);
|
|
721
|
+
const taken = new Set(selected);
|
|
722
|
+
const portNode = ports.map((_, index) => {
|
|
723
|
+
let id = `input-${index + 1}`;
|
|
724
|
+
for (let n = 2; taken.has(id); n += 1)
|
|
725
|
+
id = `input-${index + 1}~${n}`;
|
|
726
|
+
taken.add(id);
|
|
727
|
+
return id;
|
|
728
|
+
});
|
|
729
|
+
const inside = (op) => {
|
|
730
|
+
let next = op;
|
|
731
|
+
for (const key of inputKeys(op)) {
|
|
732
|
+
const input = inputOf(op, key);
|
|
733
|
+
const port = input === undefined ? -1 : ports.indexOf(input);
|
|
734
|
+
if (port >= 0)
|
|
735
|
+
next = withInput(next, key, portNode[port]);
|
|
736
|
+
}
|
|
737
|
+
return next;
|
|
738
|
+
};
|
|
739
|
+
const definition = {
|
|
740
|
+
id: definitionId,
|
|
741
|
+
nodes: [
|
|
742
|
+
...portNode.map((id, index) => ({
|
|
743
|
+
id,
|
|
744
|
+
op: { type: "group_input", index },
|
|
745
|
+
})),
|
|
746
|
+
...nodes
|
|
747
|
+
.filter((node) => selected.has(node.id))
|
|
748
|
+
.map((node) => ({ ...node, op: inside(node.op) })),
|
|
749
|
+
],
|
|
750
|
+
output,
|
|
751
|
+
...(ports.length ? { inputs: ports.map((_, i) => `Input ${i + 1}`) } : {}),
|
|
752
|
+
};
|
|
753
|
+
const outside = (op) => {
|
|
754
|
+
let next = op;
|
|
755
|
+
for (const key of inputKeys(op))
|
|
756
|
+
if (inputOf(op, key) === output)
|
|
757
|
+
next = withInput(next, key, groupId);
|
|
758
|
+
return next;
|
|
759
|
+
};
|
|
760
|
+
const parent = {
|
|
761
|
+
...composition,
|
|
762
|
+
nodes: [
|
|
763
|
+
...nodes
|
|
764
|
+
.filter((node) => !selected.has(node.id))
|
|
765
|
+
.map((node) => ({ ...node, op: outside(node.op) })),
|
|
766
|
+
{
|
|
767
|
+
id: groupId,
|
|
768
|
+
op: { type: "group", composition: definitionId, inputs: ports },
|
|
769
|
+
},
|
|
770
|
+
],
|
|
771
|
+
output: composition.output === output ? groupId : (composition.output ?? null),
|
|
772
|
+
};
|
|
773
|
+
return { parent, definition };
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* `composition` with group node `groupId` replaced by the nodes of the composition it draws, under
|
|
777
|
+
* ids based on `<group>-<node>`: its group inputs read what the group node's inputs read, its
|
|
778
|
+
* controls keep the values the group node set, and what read the group reads its output. Returns
|
|
779
|
+
* the new ids by inner id. The picture does not change.
|
|
780
|
+
* Throws when `groupId` is not a group node of `composition`.
|
|
781
|
+
*/
|
|
782
|
+
export function ungroupNode(composition, groupId, definition) {
|
|
783
|
+
const nodes = composition.nodes ?? [];
|
|
784
|
+
const group = nodes.find((node) => node.id === groupId);
|
|
785
|
+
if (!group || group.op.type !== "group")
|
|
786
|
+
throw new Error(`${groupId} is not a group node`);
|
|
787
|
+
const op = group.op;
|
|
788
|
+
const taken = new Set(nodes.map((node) => node.id));
|
|
789
|
+
const ids = new Map();
|
|
790
|
+
// Group inputs are not inlined: they stand for what the group node reads.
|
|
791
|
+
const bound = new Map();
|
|
792
|
+
for (const node of definition.nodes ?? []) {
|
|
793
|
+
if (node.op.type === "group_input") {
|
|
794
|
+
bound.set(node.id, op.inputs?.[node.op.index] ?? null);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
let id = `${groupId}-${node.id}`;
|
|
798
|
+
for (let n = 2; taken.has(id); n += 1)
|
|
799
|
+
id = `${groupId}-${node.id}~${n}`;
|
|
800
|
+
taken.add(id);
|
|
801
|
+
ids.set(node.id, id);
|
|
802
|
+
}
|
|
803
|
+
const resolve = (inner) => inner === undefined
|
|
804
|
+
? null
|
|
805
|
+
: bound.has(inner)
|
|
806
|
+
? (bound.get(inner) ?? null)
|
|
807
|
+
: (ids.get(inner) ?? null);
|
|
808
|
+
const values = op.controls ?? {};
|
|
809
|
+
const inlined = (definition.nodes ?? [])
|
|
810
|
+
.filter((node) => ids.has(node.id))
|
|
811
|
+
.map((node) => {
|
|
812
|
+
let next = node.op;
|
|
813
|
+
for (const key of inputKeys(next))
|
|
814
|
+
next = withInput(next, key, resolve(inputOf(node.op, key)));
|
|
815
|
+
for (const control of definition.controls ?? []) {
|
|
816
|
+
const value = values[control.name];
|
|
817
|
+
if (control.node !== node.id || value === undefined)
|
|
818
|
+
continue;
|
|
819
|
+
if (next.type === "effect" || next.type === "transition")
|
|
820
|
+
next = {
|
|
821
|
+
...next,
|
|
822
|
+
effect: {
|
|
823
|
+
...next.effect,
|
|
824
|
+
params: { ...next.effect.params, [control.param]: value },
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
return { ...node, id: ids.get(node.id), op: next };
|
|
829
|
+
});
|
|
830
|
+
const output = resolve(definition.output ?? undefined);
|
|
831
|
+
const reroute = (inner) => {
|
|
832
|
+
let next = inner;
|
|
833
|
+
for (const key of inputKeys(inner))
|
|
834
|
+
if (inputOf(inner, key) === groupId)
|
|
835
|
+
next = withInput(next, key, output);
|
|
836
|
+
return next;
|
|
837
|
+
};
|
|
838
|
+
return {
|
|
839
|
+
parent: {
|
|
840
|
+
...composition,
|
|
841
|
+
nodes: [
|
|
842
|
+
...nodes
|
|
843
|
+
.filter((node) => node.id !== groupId)
|
|
844
|
+
.map((node) => ({ ...node, op: reroute(node.op) })),
|
|
845
|
+
...inlined,
|
|
846
|
+
],
|
|
847
|
+
output: composition.output === groupId ? output : (composition.output ?? null),
|
|
848
|
+
},
|
|
849
|
+
ids,
|
|
850
|
+
};
|
|
851
|
+
}
|