@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,2176 @@
1
+ import { frameAt, frameStart } from "../core/time.js";
2
+ import { compileEditorDocument, EditorError, sequenceEnd, } from "../core/document.js";
3
+ import { captionsAsset } from "../core/captions.js";
4
+ import { MAX_CAPTION_FILE_BYTES } from "alpha2/project";
5
+ import { drawnEffectResources, shownSources } from "../core/compositions.js";
6
+ import { ResourceRegistry } from "./resources.js";
7
+ import { ExportJob, requireExportFrameRate } from "./jobs.js";
8
+ import { attemptOptions } from "./runtime/budget.js";
9
+ import { createWorkerPreview } from "./worker.js";
10
+ import { AssetLibrary, streamPeaks } from "./runtime/assets.js";
11
+ import { SourcePixels, providerSettings, sameProviderSettings, } from "./runtime/sources.js";
12
+ import { fingerprintFile } from "./runtime/fingerprint.js";
13
+ import { hashInWorker } from "./hash-worker.js";
14
+ import { callExtensions } from "./extensions-worker.js";
15
+ import { keepProviderChoices, mayBeExtension, providerDocumentLimit, stageProviderFile, stageSequence, } from "./runtime/extension-import.js";
16
+ import { ProxyStore } from "./runtime/proxies.js";
17
+ import { blockingIssues, compareFingerprints, conformProject, } from "alpha2/assets";
18
+ /**
19
+ * An export refused before it rendered: the engine's conform report says why. The message names
20
+ * each source as `nameOf` does (the document's asset names), the report and `entityId` by ID.
21
+ */
22
+ export class ConformError extends EditorError {
23
+ /**
24
+ * @param {import("alpha2/assets").ConformReport} report
25
+ * @param {{ operation?: string, nameOf?: (sourceId: string) => string | undefined }} [options]
26
+ */
27
+ constructor(report, { operation = "export", nameOf } = {}) {
28
+ const issues = blockingIssues(report);
29
+ super("conform_failed", `The media is not ready: ${issues
30
+ .map(({ sourceId, issue }) => `${nameOf?.(sourceId) ?? sourceId} ${issue.kind.replace(/_/g, " ")}`)
31
+ .join("; ")}`, operation, issues[0]?.sourceId);
32
+ this.name = "ConformError";
33
+ this.report = report;
34
+ }
35
+ }
36
+ import { EngineAudioMixer, AudioWindowSupplier } from "./runtime/audio.js";
37
+ /** The engine's playback policies (`PlaybackPolicy`), in its order. */
38
+ const PLAYBACK_POLICIES = Object.freeze([
39
+ "drop_video_keep_audio",
40
+ "preserve_every_video_frame",
41
+ "silent_scrub",
42
+ "offline_exact",
43
+ ]);
44
+ /**
45
+ * How long Play waits for sound to start before playing the picture without it. A first start loads
46
+ * and compiles the audio worklet and decodes the first block, about 1.5 s on Firefox.
47
+ */
48
+ const AUDIO_START_TIMEOUT_MS = 3000;
49
+ /**
50
+ * The default media adapter: stages each file in its own `AssetLibrary`, apart from the
51
+ * bridge's live one, so a failed or cancelled import leaves nothing behind. Files an extension
52
+ * provider reads (SVG, animated images, Lottie, OpenEXR) are probed, opened and drawn for their
53
+ * thumbnail by `alpha2/extensions`, loaded from `extensionsWasmUrl`: in a worker
54
+ * `extensionsWorkerFactory` makes for each file, which a cancelled import or a call past
55
+ * `EXTENSIONS_CALL_TIMEOUT_MS` terminates, or, without one, on this thread. A font is checked with
56
+ * the engine's parser (`alpha2/wasm`, from `wasmUrl`) and also registered with the page's
57
+ * `FontFace` set. A file's content hash is made in a worker `hashWorkerFactory` makes, where there
58
+ * is one.
59
+ * @param {{ extensionsWasmUrl?: string | URL, wasmUrl?: string | URL, hashWorkerFactory?: () => Worker, extensionsWorkerFactory?: () => Worker }} [options]
60
+ * @returns {import("./types.js").BrowserMediaAdapter}
61
+ */
62
+ export function createMediaAdapter({ extensionsWasmUrl, wasmUrl, hashWorkerFactory, extensionsWorkerFactory, } = {}) {
63
+ const hash = hashWorkerFactory
64
+ ? (file, options) => hashInWorker(hashWorkerFactory, file, options)
65
+ : undefined;
66
+ const module = {
67
+ factory: extensionsWorkerFactory,
68
+ wasmUrl: extensionsWasmUrl,
69
+ };
70
+ /**
71
+ * What the provider module makes of `file` with `call`. A file it refuses is `unreadable_media`
72
+ * with its reason; a cancellation or a stall is passed on as it is.
73
+ */
74
+ const read = async (call, file, signal) => {
75
+ try {
76
+ return await callExtensions(module, { call, file }, { signal });
77
+ }
78
+ catch (error) {
79
+ if (signal.aborted || error instanceof EditorError)
80
+ throw error;
81
+ throw new EditorError("unreadable_media", `Cannot read ${file.name}: ${error instanceof Error ? error.message : String(error)}`, "import", undefined, true, { cause: error });
82
+ }
83
+ };
84
+ return {
85
+ /** Stages numbered PNGs or OpenEXR images as one image sequence. */
86
+ async stageSequence(files, options, signal) {
87
+ signal.throwIfAborted();
88
+ const item = await stageSequence(files, {
89
+ ...options,
90
+ openExr: (file) => read("exr-first-frame", file, signal),
91
+ });
92
+ signal.throwIfAborted();
93
+ return item;
94
+ },
95
+ async stage(file, signal, { fingerprint: wanted = true } = {}) {
96
+ signal.throwIfAborted();
97
+ /** The file's fingerprint, unless the caller has no use for it (a proxy being adopted). */
98
+ const fingerprintOf = (kind, options) => wanted
99
+ ? fingerprintFile(file, kind, { signal, hash, ...options })
100
+ : undefined;
101
+ // Recognized by its bytes; the provider module loads only for bytes that could be one.
102
+ const head = new Uint8Array(await file.slice(0, 4096).arrayBuffer());
103
+ if (mayBeExtension(head)) {
104
+ // Parsed whole in provider memory that never shrinks, so an oversize file is refused unread.
105
+ const document = providerDocumentLimit(head);
106
+ if (document !== null && file.size > document.limit)
107
+ throw new EditorError("too_large", `${file.name} is ${file.size} bytes; a ${document.what} is read up to ${document.limit}`, "import");
108
+ const found = await read("read", file, signal);
109
+ signal.throwIfAborted();
110
+ if (found)
111
+ return stageProviderFile(file, found, await fingerprintOf("image"));
112
+ }
113
+ if (/\.(ttf|otf|ttc|otc)$/i.test(file.name)) {
114
+ const bytes = new Uint8Array(await file.arrayBuffer());
115
+ signal.throwIfAborted();
116
+ // The engine's parser decides: FontFace also loads WOFF and WOFF2, which the engine refuses.
117
+ const { loadEngineWasm } = await import("./runtime/wasm.js");
118
+ const { checkFont } = await loadEngineWasm(wasmUrl);
119
+ signal.throwIfAborted();
120
+ try {
121
+ checkFont(bytes, 0);
122
+ }
123
+ catch (error) {
124
+ throw new EditorError("unreadable_media", `Cannot read ${file.name} as a font: ${error instanceof Error ? error.message : String(error)}`, "import", undefined, true, { cause: error });
125
+ }
126
+ const id = crypto.randomUUID();
127
+ let face;
128
+ if (typeof FontFace !== "undefined") {
129
+ face = new FontFace(`alpha2-font-${id}`, bytes);
130
+ await face.load();
131
+ signal.throwIfAborted();
132
+ globalThis.document?.fonts.add(face);
133
+ }
134
+ return {
135
+ runtime: {
136
+ id,
137
+ kind: "font",
138
+ name: file.name,
139
+ bytes,
140
+ faceIndex: 0,
141
+ family: `alpha2-font-${id}`,
142
+ },
143
+ description: { id, kind: "font", name: file.name },
144
+ fingerprint: await fingerprintOf("font"),
145
+ dispose() {
146
+ if (face)
147
+ globalThis.document?.fonts.delete(face);
148
+ },
149
+ };
150
+ }
151
+ const library = new AssetLibrary();
152
+ try {
153
+ const runtime = file.name.toLowerCase().endsWith(".cube")
154
+ ? await library.importEffectResource(file, "lut3d")
155
+ : await library.importFile(file);
156
+ signal.throwIfAborted();
157
+ if (runtime.kind === "lut3d") {
158
+ // The engine's parser validates the LUT once here rather than at every load.
159
+ const { loadEngineWasm } = await import("./runtime/wasm.js");
160
+ const { checkLut3d } = await loadEngineWasm(wasmUrl);
161
+ signal.throwIfAborted();
162
+ try {
163
+ checkLut3d(runtime.text);
164
+ }
165
+ catch (error) {
166
+ throw new EditorError("unreadable_media", `Cannot read ${file.name} as a 3D LUT: ${error instanceof Error ? error.message : String(error)}`, "import", undefined, true, { cause: error });
167
+ }
168
+ }
169
+ const description = library.projectManifest()[0];
170
+ const fingerprint = await fingerprintOf(runtime.kind, {
171
+ size: runtime.width
172
+ ? { width: runtime.width, height: runtime.height }
173
+ : undefined,
174
+ });
175
+ signal.throwIfAborted();
176
+ return {
177
+ runtime,
178
+ description,
179
+ fingerprint,
180
+ dispose: () => library.dispose(),
181
+ };
182
+ }
183
+ catch (error) {
184
+ library.dispose();
185
+ throw error;
186
+ }
187
+ },
188
+ };
189
+ }
190
+ /**
191
+ * Creates the browser runtime for an editor session: preview, playback, import, proxies and
192
+ * export. No GPU device, AudioContext, worker or decoder is created until `bindPreview`, an
193
+ * import, `play` or `startExport` needs one. The bridge registers itself as an owner of
194
+ * `editor`, so disposing the session disposes it.
195
+ * @param {import("./types.js").BrowserBridgeOptions} options
196
+ * @returns {import("./types.js").BrowserBridge}
197
+ */
198
+ export function createBrowserBridge(options) {
199
+ const { editor, runtimeAssets = {}, mediaAdapter = createMediaAdapter(runtimeAssets), resourceLimit = 128, audio = true, onError = () => { }, assetDecisions, } = options;
200
+ // A test's stand-in for origin storage, kept out of the public options.
201
+ const proxyStore =
202
+ /** @type {{ proxyStore?: ProxyStore }} */ (options).proxyStore ??
203
+ new ProxyStore();
204
+ let decisionBinding = assetDecisions ?? null;
205
+ /**
206
+ * The wasm module's authoring functions, loaded on first use: the same instance the preview
207
+ * engine renders with.
208
+ */
209
+ const loadWasm = async () => {
210
+ const { loadEngineWasm } = await import("./runtime/wasm.js");
211
+ return loadEngineWasm(runtimeAssets.wasmUrl);
212
+ };
213
+ /** The engine's asset decisions, from the wasm module, loaded on first use. */
214
+ const decisions = async () => (decisionBinding ??= await loadWasm());
215
+ const registry = new ResourceRegistry(resourceLimit);
216
+ /**
217
+ * The runtimes this session holds, by {@link ownerKey}: a lease and the registry revision it was
218
+ * put under. Media is keyed by its content revision, which this bridge issues; text by what it
219
+ * draws, since an edit after an undo may give other text a revision an undone edit already used.
220
+ */
221
+ const owners = new Map();
222
+ /**
223
+ * The highest registry revision issued for each runtime ID: none is issued twice, so a runtime
224
+ * released while an export still leases it never collides with the one that replaces it.
225
+ */
226
+ const issued = new Map();
227
+ const issue = (id, atLeast = 0) => {
228
+ const revision = Math.max(atLeast, (issued.get(id) ?? -1) + 1);
229
+ issued.set(id, revision);
230
+ return revision;
231
+ };
232
+ /**
233
+ * Proxies by their original's hash: the kept record, a lease on the decoded runtime and its
234
+ * registry revision.
235
+ */
236
+ const proxies = new Map();
237
+ /** Proxies being made, by source ID: their progress from 0 to 1. */
238
+ const proxyJobs = new Map();
239
+ /** What preview reads for each source, as the engine last selected it. */
240
+ let previewSelection = new Map();
241
+ /** What that selection was decided from (see `loaded`). */
242
+ let previewConformKey = null;
243
+ const hashOf = (asset) => asset?.fingerprint?.content_sha256;
244
+ const originalOf = (document, id) => {
245
+ const descriptor = document.assets[id];
246
+ if (!descriptor || descriptor.missing)
247
+ return null;
248
+ return ownerOf(document, descriptor)?.lease.value ?? null;
249
+ };
250
+ /**
251
+ * The host's view of each source's media, for the engine to decide with: the original is online
252
+ * when this session holds its file, and a kept proxy is offered when it was made from the media
253
+ * the project recorded.
254
+ */
255
+ const catalogOf = (document) => ({
256
+ assets: document.project.sources
257
+ .filter((source) => !drawnByEngine(source))
258
+ .map((source) => {
259
+ const descriptor = document.assets[source.id];
260
+ const record = proxies.get(hashOf(descriptor))?.record;
261
+ const revision = descriptor?.contentRevision ?? 0;
262
+ return {
263
+ source_id: source.id,
264
+ revision,
265
+ original: originalOf(document, source.id)
266
+ ? { state: "online", fingerprint: descriptor?.fingerprint ?? {} }
267
+ : { state: "offline" },
268
+ proxies: record
269
+ ? [
270
+ {
271
+ id: `proxy:${hashOf(descriptor)}`,
272
+ // Kept by the original's hash, whichever revision relinked these bytes.
273
+ source_revision: revision,
274
+ ...record,
275
+ state: { state: "ready" },
276
+ },
277
+ ]
278
+ : [],
279
+ };
280
+ }),
281
+ });
282
+ /** Proxies being adopted, by hash, so two callers share one adoption. */
283
+ const adopting = new Map();
284
+ /**
285
+ * Adopts a kept proxy: its file staged like any import, under its own ID. `hold` (the store's
286
+ * `hold`) keeps the proxy in use until the runtime is disposed: once no document and no export
287
+ * reads it.
288
+ */
289
+ function adoptProxy(hash, found, hold) {
290
+ if (proxies.has(hash)) {
291
+ void hold.release();
292
+ return Promise.resolve();
293
+ }
294
+ let pending = adopting.get(hash);
295
+ if (pending) {
296
+ void hold.release();
297
+ return pending;
298
+ }
299
+ pending = (async () => {
300
+ let staged;
301
+ try {
302
+ // A proxy is known by its original's hash; hashing its own bytes again would be wasted.
303
+ staged = await mediaAdapter.stage(found.file, new AbortController().signal, { fingerprint: false });
304
+ }
305
+ catch (error) {
306
+ void hold.release();
307
+ throw error;
308
+ }
309
+ const proxy = {
310
+ record: found.record,
311
+ lease: null,
312
+ revision: 0,
313
+ unlocked: null,
314
+ };
315
+ try {
316
+ const runtime = { ...staged.runtime, id: `proxy:${hash}` };
317
+ proxy.revision = issue(runtime.id);
318
+ proxy.lease = registry.put(runtime.id, proxy.revision, runtime, () => {
319
+ staged.dispose();
320
+ proxy.unlocked = hold.release();
321
+ });
322
+ proxies.set(hash, proxy);
323
+ }
324
+ catch (error) {
325
+ staged.dispose();
326
+ void hold.release();
327
+ throw error;
328
+ }
329
+ })().finally(() => adopting.delete(hash));
330
+ adopting.set(hash, pending);
331
+ return pending;
332
+ }
333
+ /**
334
+ * Hashes the store had no proxy for, this session: not asked again until the session changes.
335
+ * A proxy this session makes is adopted directly.
336
+ */
337
+ let absentProxies = new Set();
338
+ /** Finds the kept proxies of sources this session has no original for. */
339
+ async function discoverProxies(document) {
340
+ for (const source of document.project.sources) {
341
+ const descriptor = document.assets[source.id];
342
+ const hash = hashOf(descriptor);
343
+ if (!hash ||
344
+ proxies.has(hash) ||
345
+ absentProxies.has(hash) ||
346
+ originalOf(document, source.id))
347
+ continue;
348
+ // Held before it is read, so no page removes it between.
349
+ const hold = await proxyStore.hold(hash);
350
+ const found = await proxyStore.find(hash).catch(() => null);
351
+ if (found)
352
+ await adoptProxy(hash, found, hold);
353
+ else {
354
+ absentProxies.add(hash);
355
+ void hold.release();
356
+ }
357
+ }
358
+ }
359
+ /** Whether `document`'s media is ready for `purpose`, as the engine decides. */
360
+ async function conform(purpose, document = editor.document) {
361
+ await discoverProxies(document);
362
+ const binding = await decisions();
363
+ materializeText(document);
364
+ const compiled = compileEditorDocument(document);
365
+ if (compiled.diagnostics.length)
366
+ throw compiled.diagnostics[0];
367
+ requireShownText(document, compiled.project);
368
+ return conformProject(binding, compiled.project, catalogOf(document), purpose);
369
+ }
370
+ const pendingImports = new Set();
371
+ const assetListeners = new Set();
372
+ let analysis = Promise.resolve();
373
+ const notifyAssets = () => {
374
+ for (const listener of assetListeners)
375
+ listener();
376
+ };
377
+ /** The waveform analysis running for each runtime, to abort when the runtime is let go. */
378
+ const analyses = new Map();
379
+ /** Whether this session still holds `runtime` for some document it can show. */
380
+ const held = (runtime) => [...owners.values()].some((owner) => owner.lease.value === runtime);
381
+ /**
382
+ * Waveform peaks are derived after import, one file at a time, so an import commits as soon as
383
+ * the media is readable. The analysis belongs to the runtime, not to the document that imported
384
+ * it: a document opened again over the same media keeps it, and only letting the runtime go (no
385
+ * reachable document reads it) drops it.
386
+ */
387
+ const analyze = (runtime) => {
388
+ if (!runtime.file ||
389
+ runtime.peaks ||
390
+ !(runtime.kind === "audio" ||
391
+ (runtime.kind === "video" && runtime.hasAudio)))
392
+ return;
393
+ analysis = analysis
394
+ .then(async () => {
395
+ if (disposed || runtime.peaks || !held(runtime))
396
+ return;
397
+ const controller = new AbortController();
398
+ analyses.set(runtime, controller);
399
+ try {
400
+ const peaks = await streamPeaks(runtime.file, {
401
+ signal: controller.signal,
402
+ });
403
+ if (peaks && !disposed && held(runtime)) {
404
+ runtime.peaks = peaks;
405
+ notifyAssets();
406
+ }
407
+ }
408
+ finally {
409
+ analyses.delete(runtime);
410
+ }
411
+ })
412
+ .catch(() => { });
413
+ };
414
+ const jobs = new Set();
415
+ let disposed = false, engine = null, workerPreview = null, boundCanvas = null, copySurface = false,
416
+ // The worker failed for a reason other than a lost GPU: later previews for this binding stay on
417
+ // the main thread. A new binding tries a worker again.
418
+ workerGivenUp = false, sources = null, mixer = null, supplier = null, binding = 0, queue = Promise.resolve(), renderKey = null, tick = 0, playing = false,
419
+ // How the current or last playback was asked for, so a recovered preview plays on alike.
420
+ playRequest = null, renderScheduled = false, raf = 0, playClock = null, loadRevision = 0, loadedAssets = new Map(), loadedSelection = "", loadedFonts = null, audioKey = null,
421
+ // What the preview worker holds from its last load.
422
+ workerSent = new Map(), workerFonts = null, refreshFrame = 0, monitorVolume = 1, playGeneration = 0, sessionGeneration = editor.generation;
423
+ /**
424
+ * Preview's media for a source of `document`: what the engine selected, the original unless it is
425
+ * offline, opened as the document's source says.
426
+ */
427
+ function previewMedia(document, id) {
428
+ const descriptor = document.assets[id];
429
+ const original = descriptor
430
+ ? sourceView(document, id, ownerOf(document, descriptor)?.lease.value)
431
+ : null;
432
+ const selection = previewSelection.get(id);
433
+ if (selection?.media === "proxy")
434
+ return proxies.get(hashOf(descriptor))?.lease.value ?? original;
435
+ return descriptor?.missing ? null : original;
436
+ }
437
+ const library = { get: (id) => previewMedia(editor.draft, id) };
438
+ const key = (id, revision) => JSON.stringify([id, revision]);
439
+ /**
440
+ * The font families a text style draws with: each imported font's by the family its runtime
441
+ * registered, other names as written.
442
+ */
443
+ function familiesOf(document, text) {
444
+ return (text?.fontFamily ?? "sans-serif").split(",").map((name) => {
445
+ const plain = name.trim().replace(/^["']|["']$/g, "");
446
+ const font = Object.values(document.assets).find((a) => a.kind === "font" && a.name.replace(/\.[^.]+$/, "") === plain);
447
+ return (font && ownerOf(document, font)?.lease.value.family) ?? plain;
448
+ });
449
+ }
450
+ /**
451
+ * Which runtime `asset` of `document` reads: media by its content revision, text by its authoring
452
+ * and the fonts it resolves to.
453
+ */
454
+ function ownerKey(document, asset) {
455
+ return asset.kind === "text"
456
+ ? JSON.stringify([
457
+ asset.id,
458
+ "text",
459
+ asset.authoring?.text ?? null,
460
+ familiesOf(document, asset.authoring?.text),
461
+ ])
462
+ : key(asset.id, asset.contentRevision);
463
+ }
464
+ function ownerOf(document, asset) {
465
+ return owners.get(ownerKey(document, asset)) ?? null;
466
+ }
467
+ /**
468
+ * A lease on the runtime `asset` of `document` reads, for work that outlives the owner's hold on
469
+ * it.
470
+ */
471
+ function acquireOwned(document, asset) {
472
+ const owner = asset && ownerOf(document, asset);
473
+ if (!owner && asset?.kind === "text")
474
+ throw undrawnText(document, asset);
475
+ if (!owner)
476
+ throw new EditorError("missing_asset", `Relink ${asset?.name ?? asset?.id}: this session has no file for it`, "resource", asset?.id);
477
+ return registry.acquire(asset.id, owner.revision);
478
+ }
479
+ function acquireProxy(hash, id) {
480
+ const proxy = proxies.get(hash);
481
+ if (!proxy)
482
+ throw new EditorError("missing_asset", `${editor.document.assets[id]?.name ?? id} has no proxy in this session`, "resource", id);
483
+ return registry.acquire(`proxy:${hash}`, proxy.revision);
484
+ }
485
+ /** Runtime views by runtime, then by provider settings, so one choice is one value. */
486
+ const views = new WeakMap();
487
+ /**
488
+ * `runtime` opened as source `id` of `document` says: a provider's settings (a package's
489
+ * animation, an OpenEXR still's primaries) are the document's, so undo and redo change what is
490
+ * drawn. A view reads everything else from the runtime itself, and a new choice is a new value.
491
+ */
492
+ function sourceView(document, id, runtime) {
493
+ if (!runtime?.provider)
494
+ return runtime ?? null;
495
+ const provider = document.project.sources.find((source) => source.id === id)?.provider;
496
+ if (!provider || sameProviderSettings(provider, runtime.provider))
497
+ return runtime;
498
+ const settings = providerSettings(provider);
499
+ let byProvider = views.get(runtime);
500
+ if (!byProvider)
501
+ views.set(runtime, (byProvider = new Map()));
502
+ let view = byProvider.get(settings);
503
+ if (!view) {
504
+ view = Object.create(runtime, {
505
+ provider: { value: provider, enumerable: true },
506
+ });
507
+ byProvider.set(settings, view);
508
+ }
509
+ return view;
510
+ }
511
+ /**
512
+ * Runs `fn` after the preview work already queued. Its failure goes to the caller, which reports
513
+ * it: to whoever awaits it, or to `onError` for work the bridge started itself, never to both.
514
+ */
515
+ const enqueue = (fn) => {
516
+ const next = queue.then(() => {
517
+ if (disposed)
518
+ throw new EditorError("disposed", "Bridge is disposed", "browser");
519
+ return fn();
520
+ });
521
+ queue = next.catch(() => { });
522
+ return next;
523
+ };
524
+ const context = (project, lib) => ({
525
+ width: project.settings.width,
526
+ height: project.settings.height,
527
+ liveSourceIds: new Set(project.sources.map((s) => s.id)),
528
+ getLayer: (id) => {
529
+ const asset = lib.get(id);
530
+ return asset?.kind === "text" ? { id, kind: "image", asset } : asset;
531
+ },
532
+ });
533
+ const features = Object.freeze({
534
+ preview: { available: typeof OffscreenCanvas !== "undefined" },
535
+ audioPreview: {
536
+ available: typeof SharedArrayBuffer !== "undefined" &&
537
+ globalThis.crossOriginIsolated === true,
538
+ },
539
+ transportRate: { available: true, reason: "Audio is silent outside 1x" },
540
+ worker: {
541
+ available: typeof Worker !== "undefined" &&
542
+ typeof runtimeAssets.workerFactory === "function",
543
+ reason: "Provide a bundled workerFactory; otherwise the main-thread adapter is used",
544
+ },
545
+ export: {
546
+ available: typeof VideoEncoder !== "undefined",
547
+ reason: "Codec availability is checked at export; output streams to a temporary origin-private file unless a file or memory output is chosen",
548
+ },
549
+ });
550
+ /** The page's capability report; the GPU and color are known once a preview is bound. */
551
+ let capabilities = capabilityReport(null);
552
+ function capabilityReport(rendered) {
553
+ return Object.freeze({
554
+ schema: "alpha2-capabilities",
555
+ version: 1,
556
+ runtime: "browser",
557
+ gpu: rendered?.gpu ?? null,
558
+ color: rendered?.color ?? null,
559
+ playback: rendered?.playback ?? {
560
+ policies: PLAYBACK_POLICIES,
561
+ defaultPolicy: "drop_video_keep_audio",
562
+ audioAtNonUnitRate: false,
563
+ },
564
+ decode: null,
565
+ encode: null,
566
+ features,
567
+ });
568
+ }
569
+ /**
570
+ * Why each text could not be drawn, by {@link ownerKey}: a failed raster is not retried until its
571
+ * authoring or fonts change.
572
+ */
573
+ const textFailures = new Map();
574
+ /**
575
+ * The error a render that shows `asset`, a text of `document` that could not be rasterized, fails
576
+ * with.
577
+ */
578
+ function undrawnText(document, asset) {
579
+ const failure = textFailures.get(ownerKey(document, asset));
580
+ return new EditorError("text_unrenderable", `Text "${asset.name}" cannot be drawn: ${failure?.message ?? "it was not rasterized"}`, "resource", asset.id);
581
+ }
582
+ /**
583
+ * Fails with {@link undrawnText} when `project`, compiled from `document`, shows a text that
584
+ * could not be drawn.
585
+ */
586
+ function requireShownText(document, project) {
587
+ const used = shownSources(project);
588
+ for (const asset of Object.values(document.assets))
589
+ if (asset.kind === "text" &&
590
+ used.has(asset.id) &&
591
+ textFailures.has(ownerKey(document, asset)))
592
+ throw undrawnText(document, asset);
593
+ }
594
+ /**
595
+ * Rasterizes each text of `document` that no held runtime draws yet: new authoring, or a font
596
+ * that resolves to another family now that it is linked. A text that cannot be drawn (too large
597
+ * for a texture, say) is recorded, not thrown: it fails only the renders that show it.
598
+ */
599
+ function materializeText(document) {
600
+ for (const asset of Object.values(document.assets)) {
601
+ if (asset.kind !== "text" || !asset.authoring?.text)
602
+ continue;
603
+ const resourceKey = ownerKey(document, asset);
604
+ if (owners.has(resourceKey) || textFailures.has(resourceKey))
605
+ continue;
606
+ const staged = new AssetLibrary();
607
+ try {
608
+ const runtime = staged.createText({
609
+ ...asset.authoring.text,
610
+ fontFamily: familiesOf(document, asset.authoring.text).join(", "),
611
+ }, { id: asset.id });
612
+ const revision = issue(asset.id);
613
+ owners.set(resourceKey, {
614
+ lease: registry.put(asset.id, revision, runtime, () => staged.dispose()),
615
+ revision,
616
+ });
617
+ }
618
+ catch (error) {
619
+ staged.dispose();
620
+ textFailures.set(resourceKey, error);
621
+ }
622
+ }
623
+ }
624
+ const hasContent = (project) => project?.timeline.tracks.some((track) => (track.clips?.length ?? 0) + (track.captions?.cues.length ?? 0) > 0);
625
+ // A node the viewer shows instead of a composition's output; preview only.
626
+ let previewOutput = null;
627
+ const withPreviewOutput = (project) => {
628
+ if (!previewOutput)
629
+ return project;
630
+ const { compositionId, nodeId } = previewOutput;
631
+ const composition = project.compositions?.find((c) => c.id === compositionId);
632
+ if (!composition?.nodes?.some((n) => n.id === nodeId))
633
+ return project;
634
+ return {
635
+ ...project,
636
+ compositions: project.compositions.map((c) => c.id === compositionId ? { ...c, output: nodeId } : c),
637
+ };
638
+ };
639
+ /**
640
+ * The fonts captions and an SVG's text draw with, in fallback order: the document's font assets,
641
+ * then the runtime's own. Native exports get the same stack (`nativeAssets`).
642
+ */
643
+ /**
644
+ * Reports each font of a registered stack the engine could not parse, by name: it was left out,
645
+ * and captions and SVG text draw with the fonts after it.
646
+ */
647
+ function reportRefusedFonts(refused, operation) {
648
+ for (const { font, error } of refused ?? [])
649
+ onError(new EditorError("invalid_font", `Font ${font.name} cannot be drawn (${error instanceof Error ? error.message : String(error)}); text draws with the other fonts`, operation, font.id, true, { cause: error }));
650
+ }
651
+ const fontStackOf = (document) => [
652
+ ...heldFonts(document).map((a) => previewMedia(document, a.id)),
653
+ ...(runtimeAssets.fonts ?? []),
654
+ ];
655
+ /**
656
+ * The document's font assets this session holds a file for. A font is no source the engine
657
+ * checks: one not linked is left out of the stack, by preview and exports alike, never refused.
658
+ */
659
+ function heldFonts(document) {
660
+ return Object.values(document.assets).filter((a) => a.kind === "font" && !a.missing && ownerOf(document, a));
661
+ }
662
+ const sameList = (a, b) => a !== null &&
663
+ a.length === b.length &&
664
+ a.every((item, index) => item === b[index]);
665
+ const renderKeyOf = (document) => compileEditorDocument(document).revisionKey +
666
+ JSON.stringify(previewOutput) +
667
+ JSON.stringify(Object.values(document.assets).map((a) => ownerKey(document, a)));
668
+ const loaded = async () => {
669
+ const document = editor.draft;
670
+ materializeText(document);
671
+ const checked = compileEditorDocument(document);
672
+ if (checked.diagnostics.length)
673
+ throw checked.diagnostics[0];
674
+ const compiled = {
675
+ ...checked,
676
+ project: withPreviewOutput(checked.project),
677
+ };
678
+ if (!engine || !hasContent(compiled.project))
679
+ return compiled.project;
680
+ // The engine selects original or proxy. The answer depends only on the projection, the shown
681
+ // node and the catalog, so a scrub over an unchanged document asks once.
682
+ await discoverProxies(document);
683
+ const catalog = catalogOf(document);
684
+ const conformKey = checked.revisionKey +
685
+ JSON.stringify(previewOutput) +
686
+ JSON.stringify(catalog);
687
+ if (conformKey !== previewConformKey) {
688
+ const selected = conformProject(await decisions(), compiled.project, catalog, "preview");
689
+ previewSelection = new Map(selected.sources.map((source) => [source.source_id, source.selection]));
690
+ previewConformKey = conformKey;
691
+ }
692
+ const selectionKey = JSON.stringify([...previewSelection]);
693
+ const used = shownSources(compiled.project);
694
+ for (const source of compiled.project.sources)
695
+ if (!drawnByEngine(source) &&
696
+ used.has(source.id) &&
697
+ !previewMedia(document, source.id))
698
+ throw document.assets[source.id]?.kind === "text"
699
+ ? undrawnText(document, document.assets[source.id])
700
+ : new EditorError("missing_asset", `Relink ${document.assets[source.id]?.name ?? source.id} before preview`, "preview", source.id);
701
+ const drawnResources = drawnEffectResources(compiled.project);
702
+ for (const resource of compiled.project.effect_resources ?? [])
703
+ if (drawnResources.has(resource.id) &&
704
+ !previewMedia(document, resource.id))
705
+ throw new EditorError("missing_asset", `Relink ${document.assets[resource.id]?.name ?? resource.id} before preview`, "preview", resource.id);
706
+ const allFonts = fontStackOf(document);
707
+ const key = renderKeyOf(document);
708
+ // Audio is configured once the edit settles: sound only plays after a gesture.
709
+ const settled = document === editor.document;
710
+ if (renderKey !== key || loadedSelection !== selectionKey) {
711
+ mixer.stop();
712
+ engine.invalidate();
713
+ // Keep decoders warm: only a source whose runtime changed is reopened.
714
+ const reads = new Map(Object.values(document.assets).map((a) => [
715
+ a.id,
716
+ previewMedia(document, a.id),
717
+ ]));
718
+ const unchanged = new Set(Object.values(document.assets)
719
+ .filter((a) => reads.get(a.id) && loadedAssets.get(a.id) === reads.get(a.id))
720
+ .flatMap((a) => previewSelection.get(a.id)?.media === "proxy"
721
+ ? [a.id, `proxy:${hashOf(a)}`]
722
+ : [a.id]));
723
+ await sources.release(unchanged);
724
+ loadedAssets = reads;
725
+ loadedSelection = selectionKey;
726
+ if (!sameList(loadedFonts, allFonts)) {
727
+ const refused = engine.setCaptionFonts(allFonts);
728
+ loadedFonts = allFonts;
729
+ // Reported only when the list changes.
730
+ reportRefusedFonts(refused, "preview");
731
+ }
732
+ engine.loadProject(compiled.project);
733
+ engine.resize(compiled.project.settings.width, compiled.project.settings.height);
734
+ if (boundCanvas.width !== compiled.project.settings.width)
735
+ boundCanvas.width = compiled.project.settings.width;
736
+ if (boundCanvas.height !== compiled.project.settings.height)
737
+ boundCanvas.height = compiled.project.settings.height;
738
+ sources.setOutputSize(compiled.project.settings.width, compiled.project.settings.height);
739
+ audioKey = null;
740
+ if (workerPreview) {
741
+ const used = [
742
+ ...compiled.project.sources,
743
+ ...(compiled.project.effect_resources ?? []),
744
+ ]
745
+ .map((source) => [source.id, previewMedia(document, source.id)])
746
+ .filter(([, asset]) => asset);
747
+ // The worker keeps runtime assets it already holds; only new or changed ones are sent.
748
+ const keep = used
749
+ .filter(([id, asset]) => workerSent.get(id) === asset)
750
+ .map(([id]) => id);
751
+ const kept = new Set(keep);
752
+ const workerAssets = used
753
+ .filter(([id]) => !kept.has(id))
754
+ // Keyed by the source it serves: a proxy's runtime has an ID of its own.
755
+ .map(([id, asset]) => {
756
+ const data = { id };
757
+ for (const field of [
758
+ "kind",
759
+ "name",
760
+ "file",
761
+ "raster",
762
+ "width",
763
+ "height",
764
+ "duration",
765
+ "hasAudio",
766
+ "audioDecodable",
767
+ "text",
768
+ "fit",
769
+ "provider",
770
+ "frameFiles",
771
+ ])
772
+ if (asset[field] !== undefined)
773
+ data[field] = asset[field];
774
+ return data;
775
+ });
776
+ const sendFonts = !sameList(workerFonts, allFonts);
777
+ // Until the worker confirms, what it holds is unknown: a failed load resends everything.
778
+ workerSent = new Map();
779
+ workerFonts = null;
780
+ try {
781
+ await workerPreview.replaceProject(compiled.project, ++loadRevision, workerAssets, sendFonts ? allFonts : null, keep);
782
+ workerSent = new Map(used);
783
+ workerFonts = allFonts;
784
+ }
785
+ catch (error) {
786
+ // The main renderer has just loaded the same project.
787
+ await abandonWorker(error);
788
+ }
789
+ }
790
+ renderKey = key;
791
+ }
792
+ if (settled && audioKey !== key) {
793
+ supplier.configure(engine.audioEngine, compiled.project);
794
+ await mixer.attach(engine.audioEngine, compiled.project, supplier, null, onError);
795
+ audioKey = key;
796
+ }
797
+ return compiled.project;
798
+ };
799
+ const off = editor.subscribe(() => {
800
+ if (editor.generation !== sessionGeneration) {
801
+ sessionGeneration = editor.generation;
802
+ for (const abort of pendingImports)
803
+ abort.abort();
804
+ pause();
805
+ renderKey = null;
806
+ workerSent = new Map();
807
+ workerFonts = null;
808
+ loadedAssets = new Map();
809
+ loadedFonts = null;
810
+ loadedSelection = "";
811
+ previewSelection = new Map();
812
+ previewConformKey = null;
813
+ absentProxies = new Set();
814
+ }
815
+ scheduleSweep();
816
+ scheduleTextReports();
817
+ // A drag changes the draft on every pointer move; the preview follows at most once a frame.
818
+ if (engine && !refreshFrame)
819
+ refreshFrame = nextFrame(refresh);
820
+ });
821
+ /** The font stack each SVG runtime's report was last computed with. */
822
+ const reportedFonts = new WeakMap();
823
+ /** Aborted when the bridge is disposed: work it started for no caller (text reports) stops. */
824
+ const lifetime = new AbortController();
825
+ let textReports = Promise.resolve(), textReportsQueued = false;
826
+ /**
827
+ * An SVG's report says which of its text no font draws (`missing_font`). Import probes it with no
828
+ * fonts at all, so it is computed again with the stack its text draws with whenever that stack or
829
+ * the SVG changes; `subscribeAssets` listeners hear of a report that changed.
830
+ */
831
+ function scheduleTextReports() {
832
+ if (textReportsQueued || disposed)
833
+ return;
834
+ textReportsQueued = true;
835
+ textReports = textReports
836
+ .then(() => {
837
+ textReportsQueued = false;
838
+ return refreshTextReports();
839
+ })
840
+ .catch(() => { });
841
+ }
842
+ async function refreshTextReports() {
843
+ const document = editor.document;
844
+ const fonts = fontStackOf(document);
845
+ const stale = Object.values(document.assets)
846
+ .map((asset) => ownerOf(document, asset)?.lease.value)
847
+ .filter((runtime) => runtime?.provider?.format === "svg" &&
848
+ runtime.report &&
849
+ runtime.file &&
850
+ !sameList(reportedFonts.get(runtime) ?? null, fonts));
851
+ if (!stale.length)
852
+ return;
853
+ let changed = false;
854
+ for (const runtime of new Set(stale)) {
855
+ let diagnostics;
856
+ try {
857
+ diagnostics = await callExtensions({
858
+ factory: runtimeAssets.extensionsWorkerFactory,
859
+ wasmUrl: runtimeAssets.extensionsWasmUrl,
860
+ }, {
861
+ call: "svg-diagnostics",
862
+ file: runtime.file,
863
+ provider: runtime.provider,
864
+ fonts: fonts.map((font) => font.bytes),
865
+ }, { signal: lifetime.signal, operation: "report" });
866
+ if (disposed)
867
+ return;
868
+ }
869
+ catch {
870
+ if (disposed)
871
+ return;
872
+ // Unreadable now: the report stays and the next change tries again.
873
+ continue;
874
+ }
875
+ reportedFonts.set(runtime, fonts);
876
+ if (JSON.stringify(diagnostics) !==
877
+ JSON.stringify(runtime.report.diagnostics)) {
878
+ runtime.report = { ...runtime.report, diagnostics };
879
+ changed = true;
880
+ }
881
+ }
882
+ if (changed)
883
+ notifyAssets();
884
+ }
885
+ let sweepQueued = false;
886
+ /** Releases, after the preview work already queued, what no reachable document reads any more. */
887
+ function scheduleSweep() {
888
+ if (sweepQueued || disposed)
889
+ return;
890
+ sweepQueued = true;
891
+ void enqueue(() => {
892
+ sweepQueued = false;
893
+ sweep();
894
+ }).catch((error) => {
895
+ if (!disposed)
896
+ onError(error);
897
+ });
898
+ }
899
+ /**
900
+ * Media and proxies stay held while any document the session can return to reads them: a relink
901
+ * or import undone, or a removed asset, comes back with its runtime. Text is held only while the
902
+ * document or the draft draws it, since its authoring makes it again. Work that leased a runtime
903
+ * (an export, a native upload) keeps it until it releases the lease.
904
+ */
905
+ function sweep() {
906
+ const media = new Set(), hashes = new Set(), seen = new Set();
907
+ for (const document of editor.reachableDocuments()) {
908
+ // Shared by commands that leave assets alone, so a long history is read once per media change.
909
+ if (seen.has(document.assets))
910
+ continue;
911
+ seen.add(document.assets);
912
+ for (const asset of Object.values(document.assets)) {
913
+ if (asset.kind !== "text")
914
+ media.add(key(asset.id, asset.contentRevision));
915
+ const hash = hashOf(asset);
916
+ if (hash)
917
+ hashes.add(hash);
918
+ }
919
+ }
920
+ const shown = new Set();
921
+ for (const document of new Set([editor.document, editor.draft]))
922
+ for (const asset of Object.values(document.assets))
923
+ if (asset.kind === "text")
924
+ shown.add(ownerKey(document, asset));
925
+ for (const [resourceKey, owner] of owners)
926
+ if (!media.has(resourceKey) && !shown.has(resourceKey)) {
927
+ owners.delete(resourceKey);
928
+ analyses.get(owner.lease.value)?.abort();
929
+ owner.lease.release();
930
+ }
931
+ for (const resourceKey of textFailures.keys())
932
+ if (!shown.has(resourceKey))
933
+ textFailures.delete(resourceKey);
934
+ for (const [hash, proxy] of proxies)
935
+ if (!hashes.has(hash)) {
936
+ proxies.delete(hash);
937
+ proxy.lease.release();
938
+ }
939
+ }
940
+ const nextFrame = (fn) => typeof requestAnimationFrame === "function"
941
+ ? requestAnimationFrame(fn)
942
+ : setTimeout(fn, 16);
943
+ function refresh() {
944
+ refreshFrame = 0;
945
+ if ((!engine && !recoveryPending) || disposed)
946
+ return;
947
+ if (engine && renderKeyOf(editor.draft) === renderKey)
948
+ return;
949
+ if (!playing) {
950
+ refreshPreview();
951
+ return;
952
+ }
953
+ // Playback reads the project it started with, so an edit restarts it from where it stood, at the
954
+ // same rate, loop and policy, unless a seek, pause or Play comes first.
955
+ const resume = playRequest;
956
+ pause();
957
+ refreshPreview();
958
+ const stopped = playGeneration;
959
+ void Promise.resolve()
960
+ .then(() => disposed || playing || playGeneration !== stopped
961
+ ? undefined
962
+ : play(resume.rate, { loop: resume.loop, policy: resume.policy }))
963
+ .catch(onError);
964
+ }
965
+ // At most one refresh waits, reading the draft when it starts. The render in flight is not
966
+ // cancelled, so a continuous drag keeps showing frames.
967
+ let refreshQueued = false;
968
+ function refreshPreview() {
969
+ if (refreshQueued)
970
+ return;
971
+ refreshQueued = true;
972
+ const requested = tick;
973
+ void enqueue(async () => {
974
+ refreshQueued = false;
975
+ if (requested !== tick || !(await previewReady()))
976
+ return false;
977
+ const project = await loaded();
978
+ return requested === tick ? renderAt(requested, project) : false;
979
+ }).catch(onError);
980
+ }
981
+ let previewIssue = null;
982
+ /** A lost GPU left no renderer and making it again failed; the next render tries again. */
983
+ let recoveryPending = false;
984
+ /** How many times a lost GPU's preview was made again. */
985
+ let previewRecoveries = 0;
986
+ const previewListeners = new Set();
987
+ const setPreviewIssue = (issue) => {
988
+ if (issue === previewIssue)
989
+ return;
990
+ previewIssue = issue;
991
+ for (const listener of previewListeners)
992
+ listener();
993
+ };
994
+ /**
995
+ * Renders the frame at `at`. A composition that cannot draw its output yet is not an error here:
996
+ * the previous frame stays up and `previewIssue` says why, until a render succeeds.
997
+ */
998
+ async function renderAt(at, project, recovered = false) {
999
+ try {
1000
+ const rendered = await renderFrame(at, project);
1001
+ setPreviewIssue(null);
1002
+ return rendered;
1003
+ }
1004
+ catch (error) {
1005
+ if (error?.code === "INCOMPLETE_COMPOSITION") {
1006
+ setPreviewIssue(error.message ?? String(error));
1007
+ return false;
1008
+ }
1009
+ const lost = error?.code === "GPU_DEVICE_LOST" || engine?.deviceLost === true;
1010
+ if (recovered || !lost || !boundCanvas || disposed)
1011
+ throw error;
1012
+ if (!(await recoverPreview()))
1013
+ return false;
1014
+ return renderAt(at, await loaded(), true);
1015
+ }
1016
+ }
1017
+ async function renderFrame(at, project) {
1018
+ if (boundCanvas?.style)
1019
+ boundCanvas.style.visibility = hasContent(project) ? "visible" : "hidden";
1020
+ if (!hasContent(project))
1021
+ return false;
1022
+ if (workerPreview) {
1023
+ try {
1024
+ const result = await workerPreview.seek(at);
1025
+ if (result.bitmap)
1026
+ boundCanvas
1027
+ .getContext("bitmaprenderer")
1028
+ .transferFromImageBitmap(result.bitmap);
1029
+ return result.rendered;
1030
+ }
1031
+ catch (error) {
1032
+ if (error?.code === "INCOMPLETE_COMPOSITION")
1033
+ throw error;
1034
+ // The main renderer has the same project loaded and draws this frame instead.
1035
+ await abandonWorker(error);
1036
+ }
1037
+ }
1038
+ const rendered = await engine.render(at / 120000, {
1039
+ generation: engine.generation,
1040
+ context: context(project, library),
1041
+ });
1042
+ if (rendered && copySurface)
1043
+ boundCanvas
1044
+ .getContext("bitmaprenderer")
1045
+ .transferFromImageBitmap(engine.canvas.transferToImageBitmap());
1046
+ return rendered;
1047
+ }
1048
+ /**
1049
+ * Gives up the preview worker after `error`, whether it failed to start, to load a project or to
1050
+ * draw: the main renderer, which loads every project the worker does, draws from then on, off
1051
+ * screen, and each frame is copied to the bound surface (a canvas element or an OffscreenCanvas
1052
+ * alike); a preview made again after a lost GPU does too, until the next binding. `onError` hears
1053
+ * once why, unless the worker lost its GPU: that is recovered from, as the main renderer recovers
1054
+ * from its own, without a report, and a preview made again tries a worker again.
1055
+ */
1056
+ async function abandonWorker(error) {
1057
+ const failed = workerPreview;
1058
+ if (!failed)
1059
+ return;
1060
+ workerPreview = null;
1061
+ workerSent = new Map();
1062
+ workerFonts = null;
1063
+ copySurface = true;
1064
+ await failed.dispose().catch(() => { });
1065
+ if (error?.code === "GPU_DEVICE_LOST")
1066
+ return;
1067
+ workerGivenUp = true;
1068
+ const reason = error instanceof Error ? error.message : String(error ?? "");
1069
+ onError(new EditorError("preview_worker_failed", `The preview worker failed (${reason || "it did not start"}); the preview renders on the main thread`, "preview", undefined, true, { cause: error }));
1070
+ }
1071
+ async function seek(nextTick) {
1072
+ if (!Number.isSafeInteger(nextTick) || nextTick < 0)
1073
+ throw new EditorError("invalid_time", "Seek requires non-negative integer ticks", "seek");
1074
+ if (playing)
1075
+ pause();
1076
+ else
1077
+ playGeneration++;
1078
+ tick = nextTick;
1079
+ engine?.invalidate();
1080
+ mixer?.stop();
1081
+ const requested = tick;
1082
+ // Only a newer seek supersedes this one: `loaded` reads the draft as it is when the task runs.
1083
+ return enqueue(async () => {
1084
+ if (requested !== tick)
1085
+ return false;
1086
+ if (!(await previewReady()))
1087
+ return false;
1088
+ const project = await loaded();
1089
+ if (requested !== tick)
1090
+ return false;
1091
+ return renderAt(requested, project);
1092
+ });
1093
+ }
1094
+ function pause() {
1095
+ playing = false;
1096
+ playGeneration++;
1097
+ if (raf)
1098
+ cancelAnimationFrame(raf);
1099
+ raf = 0;
1100
+ playClock?.free();
1101
+ playClock = null;
1102
+ mixer?.stop();
1103
+ engine?.invalidate();
1104
+ if (workerPreview)
1105
+ void workerPreview.cancel().catch(onError);
1106
+ }
1107
+ // An audio start that missed its deadline and has not settled. A context that never resumes
1108
+ // (Firefox without usable output) or a stuck worklet would hold Play forever; video plays silent.
1109
+ let stalledAudio = null;
1110
+ /** Whether `starting` settled within the deadline; a rejection propagates. */
1111
+ async function audioStarts(starting) {
1112
+ let timer = 0;
1113
+ const started = await Promise.race([
1114
+ starting.then(() => true),
1115
+ new Promise((resolve) => {
1116
+ timer = setTimeout(() => resolve(false), AUDIO_START_TIMEOUT_MS);
1117
+ }),
1118
+ ]).finally(() => clearTimeout(timer));
1119
+ if (started)
1120
+ return true;
1121
+ stalledAudio = starting;
1122
+ const settle = () => {
1123
+ if (stalledAudio === starting)
1124
+ stalledAudio = null;
1125
+ };
1126
+ starting.then(settle, settle);
1127
+ onError(new EditorError("audio_unavailable", `Audio output did not start within ${AUDIO_START_TIMEOUT_MS / 1000} s; playing without sound`, "play"));
1128
+ return false;
1129
+ }
1130
+ /**
1131
+ * Plays from the current time at `rate` (negative plays backwards) under a playback `policy`: the
1132
+ * engine's clock decides which frame shows when and whether sound plays. Sound that cannot start
1133
+ * in time is left out rather than holding the picture.
1134
+ */
1135
+ async function play(rate = 1, { loop = false, policy = "drop_video_keep_audio" } = {}) {
1136
+ if (!Number.isFinite(rate) || rate === 0)
1137
+ throw new EditorError("invalid_rate", "Rate must be finite and non-zero", "play");
1138
+ if (!PLAYBACK_POLICIES.includes(policy))
1139
+ throw new EditorError("invalid_policy", `Unknown playback policy ${policy}`, "play");
1140
+ pause();
1141
+ const generation = playGeneration;
1142
+ let audible = (engine?.audible(policy, rate) ?? false) && stalledAudio === null;
1143
+ if (audible)
1144
+ audible = await audioStarts(mixer.resume());
1145
+ // Plays the compiled projection preview shows, never a project the renderer loaded earlier.
1146
+ let project = null;
1147
+ await enqueue(async () => {
1148
+ if (!engine || generation !== playGeneration)
1149
+ return;
1150
+ project = await loaded();
1151
+ if (!hasContent(project))
1152
+ return;
1153
+ if (generation === playGeneration && audible) {
1154
+ audible = await audioStarts(mixer.start(tick, rate));
1155
+ if (!audible)
1156
+ mixer.stop();
1157
+ }
1158
+ });
1159
+ if (!engine || generation !== playGeneration)
1160
+ return;
1161
+ if (!hasContent(project)) {
1162
+ pause();
1163
+ return;
1164
+ }
1165
+ playing = true;
1166
+ playRequest = { rate, loop, policy };
1167
+ const clock = (playClock = engine.playbackClock(policy, project.settings.frame_rate, rate, tick, performance.now()));
1168
+ // For a policy that waits for frames: the tick last asked for, and whether it has been shown.
1169
+ let requested = null, presented = false;
1170
+ const step = (now) => {
1171
+ if (!playing || disposed || generation !== playGeneration)
1172
+ return;
1173
+ // At 1x under the default policy the audio clock leads while output consumes samples; the
1174
+ // engine's clock falls back to the wall clock while it stands still.
1175
+ const heard = audible ? (mixer.tick ?? undefined) : undefined;
1176
+ const wanted = clock.step(now, heard, presented);
1177
+ tick = wanted.tick;
1178
+ const waits = wanted.waitForFrame;
1179
+ wanted.free();
1180
+ const end = editor.draft.metadata.exportRange?.end ?? sequenceEnd(project);
1181
+ const lower = editor.draft.metadata.exportRange?.start ?? 0;
1182
+ if ((rate > 0 && tick >= end) || (rate < 0 && tick <= lower)) {
1183
+ pause();
1184
+ const last = frameStart(frameAt(Math.max(0, end - 1), project.settings.frame_rate), project.settings.frame_rate);
1185
+ tick = rate > 0 ? last : lower;
1186
+ const stoppedGeneration = playGeneration;
1187
+ void enqueue(() => renderAt(tick, project))
1188
+ .then(() => {
1189
+ if (loop && !disposed && playGeneration === stoppedGeneration) {
1190
+ tick = rate > 0 ? lower : last;
1191
+ void play(rate, { loop, policy }).catch(onError);
1192
+ }
1193
+ })
1194
+ .catch(onError);
1195
+ return;
1196
+ }
1197
+ // A waiting policy asks for each frame once and moves on only after it is shown; the others
1198
+ // ask for the newest time and let late frames go.
1199
+ const due = waits ? requested !== tick : true;
1200
+ if (due && !renderScheduled) {
1201
+ renderScheduled = true;
1202
+ if (waits) {
1203
+ requested = tick;
1204
+ presented = false;
1205
+ }
1206
+ const at = tick;
1207
+ void enqueue(() => generation !== playGeneration ? false : renderAt(at, project))
1208
+ .then(() => {
1209
+ if (requested === at)
1210
+ presented = true;
1211
+ })
1212
+ .catch((error) => {
1213
+ pause();
1214
+ onError(error);
1215
+ })
1216
+ .finally(() => {
1217
+ renderScheduled = false;
1218
+ });
1219
+ }
1220
+ raf = requestAnimationFrame(step);
1221
+ };
1222
+ raf = requestAnimationFrame(step);
1223
+ }
1224
+ const visibility = () => {
1225
+ if (document.hidden)
1226
+ pause();
1227
+ };
1228
+ /**
1229
+ * Makes the preview's renderer, audio, and worker for `canvas`, disposing what the last one made.
1230
+ * Runs inside the render queue. Returns false when a later binding or disposal superseded
1231
+ * `token`.
1232
+ */
1233
+ async function buildPreview(canvas, token) {
1234
+ pause();
1235
+ await mixer?.close();
1236
+ await supplier?.close();
1237
+ await engine?.dispose();
1238
+ await sources?.dispose();
1239
+ sources = new SourcePixels(library, {
1240
+ extensionsWasmUrl: runtimeAssets.extensionsWasmUrl,
1241
+ });
1242
+ const { Engine } = await import("./runtime/engine.js");
1243
+ canvas.width = editor.document.project.settings.width;
1244
+ canvas.height = editor.document.project.settings.height;
1245
+ boundCanvas = canvas;
1246
+ copySurface = false;
1247
+ await workerPreview?.dispose();
1248
+ workerPreview = null;
1249
+ const useWorker = features.worker.available && typeof canvas.getContext === "function";
1250
+ engine = new Engine(useWorker ? new OffscreenCanvas(canvas.width, canvas.height) : canvas, sources, library, runtimeAssets);
1251
+ mixer = new EngineAudioMixer(runtimeAssets, { enabled: audio });
1252
+ mixer.setMasterVolume(monitorVolume);
1253
+ stalledAudio = null;
1254
+ supplier = new AudioWindowSupplier(library);
1255
+ try {
1256
+ await engine.start();
1257
+ capabilities = capabilityReport(engine.capabilityReport());
1258
+ // The surface already shows copied frames (its context is a bitmap renderer's).
1259
+ if (useWorker && workerGivenUp)
1260
+ copySurface = true;
1261
+ else if (useWorker) {
1262
+ const workerRuntime = {
1263
+ ...(runtimeAssets.wasmUrl
1264
+ ? { wasmUrl: String(runtimeAssets.wasmUrl) }
1265
+ : {}),
1266
+ ...(runtimeAssets.extensionsWasmUrl
1267
+ ? { extensionsWasmUrl: String(runtimeAssets.extensionsWasmUrl) }
1268
+ : {}),
1269
+ };
1270
+ workerPreview = createWorkerPreview(runtimeAssets.workerFactory(), {
1271
+ runtimeAssets: workerRuntime,
1272
+ bitmapOutput: true,
1273
+ width: canvas.width,
1274
+ height: canvas.height,
1275
+ });
1276
+ // A worker that cannot start leaves the surface to the main renderer.
1277
+ try {
1278
+ await workerPreview.ready;
1279
+ }
1280
+ catch (error) {
1281
+ await abandonWorker(error);
1282
+ }
1283
+ }
1284
+ if (disposed || token !== binding) {
1285
+ await engine.dispose();
1286
+ engine = null;
1287
+ return false;
1288
+ }
1289
+ renderKey = null;
1290
+ workerSent = new Map();
1291
+ workerFonts = null;
1292
+ loadedAssets = new Map();
1293
+ loadedFonts = null;
1294
+ }
1295
+ catch (error) {
1296
+ await workerPreview?.dispose().catch(() => { });
1297
+ workerPreview = null;
1298
+ await engine.dispose();
1299
+ engine = null;
1300
+ throw error;
1301
+ }
1302
+ return true;
1303
+ }
1304
+ /**
1305
+ * A lost GPU (a WebGPU device, a WebGL context) is recovered here rather than reported: the
1306
+ * preview is made again on the same canvas, as binding it did, and the frame drawn once more. The
1307
+ * binding waits for the browser to restore a WebGL context on that canvas. When that fails the
1308
+ * error is reported and the next render tries again.
1309
+ *
1310
+ * Since nothing is reported, what the user sees goes on: playback that was running plays on from
1311
+ * where it stood, at the same rate, loop and policy, once the preview is made again, unless a
1312
+ * seek, a pause or another Play came first. Sound that had not started in time is not waited for
1313
+ * again: the audio output is not what was lost.
1314
+ */
1315
+ async function recoverPreview() {
1316
+ recoveryPending = true;
1317
+ const resume = playing ? playRequest : null;
1318
+ const stalled = stalledAudio;
1319
+ if (!(await buildPreview(boundCanvas, binding)))
1320
+ return false;
1321
+ stalledAudio = stalled;
1322
+ recoveryPending = false;
1323
+ previewRecoveries += 1;
1324
+ if (resume) {
1325
+ // Queued behind the render that is recovering, which draws the frame first.
1326
+ const stopped = playGeneration;
1327
+ void Promise.resolve()
1328
+ .then(() => disposed || playing || playGeneration !== stopped
1329
+ ? undefined
1330
+ : play(resume.rate, { loop: resume.loop, policy: resume.policy }))
1331
+ .catch(onError);
1332
+ }
1333
+ return true;
1334
+ }
1335
+ /** Whether a renderer is ready, making it again first if a lost GPU left none. */
1336
+ async function previewReady() {
1337
+ if (engine)
1338
+ return true;
1339
+ if (!recoveryPending || !boundCanvas || disposed)
1340
+ return false;
1341
+ return recoverPreview();
1342
+ }
1343
+ async function bindPreview(canvas) {
1344
+ const token = ++binding;
1345
+ await enqueue(async () => {
1346
+ recoveryPending = false;
1347
+ workerGivenUp = false;
1348
+ if (!(await buildPreview(canvas, token)))
1349
+ return;
1350
+ // The surface is bound; a failing first frame is reported like any later one.
1351
+ try {
1352
+ await renderAt(tick, await loaded());
1353
+ }
1354
+ catch (error) {
1355
+ onError(error);
1356
+ }
1357
+ });
1358
+ globalThis.document?.addEventListener("visibilitychange", visibility);
1359
+ let released = false;
1360
+ return {
1361
+ get canvas() {
1362
+ return boundCanvas;
1363
+ },
1364
+ async dispose() {
1365
+ if (released)
1366
+ return;
1367
+ released = true;
1368
+ if (token !== binding)
1369
+ return;
1370
+ ++binding;
1371
+ pause();
1372
+ globalThis.document?.removeEventListener("visibilitychange", visibility);
1373
+ await enqueue(async () => {
1374
+ await mixer?.close();
1375
+ await supplier?.close();
1376
+ await workerPreview?.dispose();
1377
+ workerPreview = null;
1378
+ await engine?.dispose();
1379
+ await sources?.dispose();
1380
+ engine = null;
1381
+ sources = null;
1382
+ renderKey = null;
1383
+ workerSent = new Map();
1384
+ workerFonts = null;
1385
+ loadedAssets = new Map();
1386
+ loadedFonts = null;
1387
+ });
1388
+ },
1389
+ };
1390
+ }
1391
+ function importFiles(files, options = {}) {
1392
+ return importUnits(files.map((file) => ({
1393
+ name: file.name,
1394
+ stage: (signal) => mediaAdapter.stage(file, signal),
1395
+ })), options);
1396
+ }
1397
+ /** Imports numbered PNGs as one image sequence at `frameRate` (the project's by default). */
1398
+ function importSequence(files, { frameRate, missing = "reject", signal } = {}) {
1399
+ const rate = frameRate ?? editor.document.project.settings.frame_rate;
1400
+ return importUnits([sequenceUnit(files, { frameRate: rate, missing })], {
1401
+ signal,
1402
+ });
1403
+ }
1404
+ /** Relinks the image sequence `id` to `files`, staged as the project recorded it. */
1405
+ function relinkSequence(id, files, { signal } = {}) {
1406
+ const provider = editor.document.project.sources.find((source) => source.id === id)?.provider;
1407
+ if (provider?.format !== "image_sequence")
1408
+ return Promise.reject(new EditorError("relink_conflict", "Only an image sequence relinks to frames", "relink", id));
1409
+ return importUnits([
1410
+ sequenceUnit(files, {
1411
+ frameRate: provider.frame_rate,
1412
+ missing: provider.missing,
1413
+ id,
1414
+ }),
1415
+ ], { signal, relinkId: id });
1416
+ }
1417
+ function sequenceUnit(files, options) {
1418
+ if (!mediaAdapter.stageSequence)
1419
+ throw new EditorError("unsupported_media", "This media adapter cannot import image sequences", "import");
1420
+ return {
1421
+ name: null,
1422
+ stage: (signal) => mediaAdapter.stageSequence(files, options, signal),
1423
+ };
1424
+ }
1425
+ async function importUnits(units, { signal, relinkId } = {}) {
1426
+ if (disposed)
1427
+ throw new EditorError("disposed", "Bridge is disposed", "import");
1428
+ const generation = editor.generation, controller = new AbortController();
1429
+ pendingImports.add(controller);
1430
+ const abort = () => controller.abort(signal?.reason);
1431
+ signal?.addEventListener("abort", abort, { once: true });
1432
+ if (signal?.aborted)
1433
+ abort();
1434
+ const staged = [], leases = [], commands = [];
1435
+ try {
1436
+ if (relinkId && units.length !== 1)
1437
+ throw new EditorError("relink_conflict", "Relink accepts exactly one file", "relink", relinkId);
1438
+ for (const unit of units) {
1439
+ const item = await unit.stage(controller.signal);
1440
+ const fileName = unit.name ?? item.name ?? item.runtime.name;
1441
+ staged.push(item);
1442
+ controller.signal.throwIfAborted();
1443
+ if (disposed || editor.disposed || editor.generation !== generation)
1444
+ throw new EditorError("stale_import", "Project changed during import", "import");
1445
+ const old = relinkId ? editor.document.assets[relinkId] : null;
1446
+ if (relinkId && !old)
1447
+ throw new EditorError("missing_asset", "Unknown relink target", "relink", relinkId);
1448
+ // The engine decides whether the file is the recorded media: equal hashes match whatever else
1449
+ // differs; without them any differing recorded part means other media.
1450
+ if (old?.fingerprint && item.fingerprint) {
1451
+ const match = compareFingerprints(await decisions(), old.fingerprint, item.fingerprint);
1452
+ if (match.kind === "mismatch")
1453
+ throw new EditorError("relink_conflict", `${fileName} is not the saved media: its ${match.fields
1454
+ .map((field) => field.replace(/_/g, " "))
1455
+ .join(", ")} differ`, "relink", relinkId);
1456
+ }
1457
+ if (old && old.kind !== item.runtime.kind)
1458
+ throw new EditorError("relink_conflict", "Media kind differs", "relink", relinkId);
1459
+ const id = relinkId ?? item.runtime.id;
1460
+ // Above every revision this bridge issued: after an undo the next one may be an undone relink's.
1461
+ const contentRevision = issue(id, (old?.contentRevision ?? -1) + 1);
1462
+ // A relink reads the same media, so it opens as the project chose, not as a fresh import would.
1463
+ const provider = relinkId
1464
+ ? keepProviderChoices(editor.document.project.sources.find((s) => s.id === relinkId)
1465
+ ?.provider, item.runtime.provider, item.runtime.report)
1466
+ : item.runtime.provider;
1467
+ const runtime = {
1468
+ ...item.runtime,
1469
+ id,
1470
+ ...(provider ? { provider } : {}),
1471
+ };
1472
+ const authoring = JSON.parse(JSON.stringify(item.description));
1473
+ if (provider && authoring.provider)
1474
+ authoring.provider = provider;
1475
+ const asset = {
1476
+ id,
1477
+ kind: runtime.kind,
1478
+ name: fileName,
1479
+ contentRevision,
1480
+ missing: false,
1481
+ locator: `browser://${id}`,
1482
+ ...(item.fingerprint ? { fingerprint: item.fingerprint } : {}),
1483
+ authoring,
1484
+ };
1485
+ // Called as a method: an adapter's staged asset may be a class instance.
1486
+ const lease = registry.put(id, contentRevision, runtime, () => item.dispose());
1487
+ leases.push({ id, contentRevision, lease });
1488
+ commands.push({
1489
+ type: "asset.put",
1490
+ asset,
1491
+ ...(runtime.kind === "font"
1492
+ ? {}
1493
+ : runtime.kind === "lut3d"
1494
+ ? { resource: { id, kind: "lut3d", locator: asset.locator } }
1495
+ : {
1496
+ source: {
1497
+ id,
1498
+ kind: runtime.kind === "text" ? "image" : runtime.kind,
1499
+ locator: asset.locator,
1500
+ has_audio: !!runtime.hasAudio,
1501
+ color_metadata: runtime.colorMetadata ?? null,
1502
+ ...(item.fingerprint
1503
+ ? { fingerprint: item.fingerprint }
1504
+ : {}),
1505
+ ...(runtime.provider ? { provider: runtime.provider } : {}),
1506
+ },
1507
+ }),
1508
+ });
1509
+ }
1510
+ for (const entry of leases)
1511
+ owners.set(key(entry.id, entry.contentRevision), {
1512
+ lease: entry.lease,
1513
+ revision: entry.contentRevision,
1514
+ });
1515
+ const result = editor.dispatch({ type: "transaction", commands });
1516
+ if (!result.ok)
1517
+ throw result.error;
1518
+ for (const entry of leases)
1519
+ analyze(entry.lease.value);
1520
+ return { assetIds: leases.map((e) => e.id) };
1521
+ }
1522
+ catch (error) {
1523
+ for (const entry of leases) {
1524
+ owners.delete(key(entry.id, entry.contentRevision));
1525
+ entry.lease.release();
1526
+ }
1527
+ for (const item of staged.slice(leases.length))
1528
+ item.dispose();
1529
+ throw error;
1530
+ }
1531
+ finally {
1532
+ pendingImports.delete(controller);
1533
+ signal?.removeEventListener("abort", abort);
1534
+ }
1535
+ }
1536
+ /**
1537
+ * The proxy conversion running: `preempt` stops it for an export, and `done` settles when it
1538
+ * ends.
1539
+ */
1540
+ let proxyRun = null;
1541
+ /**
1542
+ * Waits until no export runs and no other proxy is being made, or `signal` aborts, and takes the
1543
+ * turn: `signal` aborts when an export takes its place, and `end()` gives the turn back.
1544
+ */
1545
+ async function proxyTurn(signal) {
1546
+ for (;;) {
1547
+ signal?.throwIfAborted();
1548
+ if (jobs.size > 0)
1549
+ await settledOrAborted(exportSettled, signal);
1550
+ else if (proxyRun)
1551
+ await settledOrAborted(proxyRun.done, signal);
1552
+ else
1553
+ break;
1554
+ }
1555
+ let finish;
1556
+ const run = {
1557
+ preempt: new AbortController(),
1558
+ done: new Promise((resolve) => (finish = resolve)),
1559
+ };
1560
+ proxyRun = run;
1561
+ return {
1562
+ signal: run.preempt.signal,
1563
+ end() {
1564
+ if (proxyRun === run)
1565
+ proxyRun = null;
1566
+ finish();
1567
+ },
1568
+ };
1569
+ }
1570
+ let sweptExports = false;
1571
+ /** Settles when the running export does. */
1572
+ let exportSettled = Promise.resolve();
1573
+ function startExport(options = {}) {
1574
+ if (disposed)
1575
+ throw new EditorError("disposed", "Bridge is disposed", "export");
1576
+ if (!sweptExports) {
1577
+ sweptExports = true;
1578
+ // Temporary files a closed page left: a day old at least and held by no page.
1579
+ void import("./runtime/output.js").then(({ sweepStaleExports }) => sweepStaleExports());
1580
+ }
1581
+ if (jobs.size)
1582
+ throw new EditorError("export_busy", "Only one export may run per bridge", "export");
1583
+ requireExportFrameRate(options.frameRate);
1584
+ materializeText(editor.document);
1585
+ const snapshot = editor.document, revision = editor.revision;
1586
+ const compiled = compileEditorDocument(snapshot);
1587
+ if (compiled.diagnostics.length)
1588
+ throw compiled.diagnostics[0];
1589
+ requireShownText(snapshot, compiled.project);
1590
+ const selection = exportSelection(snapshot, compiled.project, options);
1591
+ const held = [], map = new Map(), fontStack = [];
1592
+ const used = shownSources(compiled.project);
1593
+ const drawnResources = drawnEffectResources(compiled.project);
1594
+ try {
1595
+ for (const source of [
1596
+ ...compiled.project.sources.filter((s) => used.has(s.id)),
1597
+ ...(compiled.project.effect_resources ?? []).filter((r) => drawnResources.has(r.id)),
1598
+ ]) {
1599
+ if (drawnByEngine(source))
1600
+ continue;
1601
+ const asset = snapshot.assets[source.id];
1602
+ const lease = selection.get(source.id)?.media === "proxy"
1603
+ ? acquireProxy(hashOf(asset), source.id)
1604
+ : acquireOwned(snapshot, asset);
1605
+ held.push(lease);
1606
+ map.set(source.id, sourceView(snapshot, source.id, lease.value));
1607
+ }
1608
+ for (const font of heldFonts(snapshot)) {
1609
+ const lease = acquireOwned(snapshot, font);
1610
+ held.push(lease);
1611
+ fontStack.push(lease.value);
1612
+ }
1613
+ fontStack.push(...(runtimeAssets.fonts ?? []));
1614
+ }
1615
+ catch (error) {
1616
+ held.forEach((l) => l.release());
1617
+ throw error;
1618
+ }
1619
+ let fontsReported = false;
1620
+ const backend = {
1621
+ // Each attempt builds its own engine, device, decoders and output; nothing failed is reused.
1622
+ async run(document, signal, progress, attempt) {
1623
+ const { Engine } = await import("./runtime/engine.js");
1624
+ const { exportTimeline } = await import("./runtime/exporter.js");
1625
+ const { openExportOutput } = await import("./runtime/output.js");
1626
+ const project = compiled.project, lib = { get: (id) => map.get(id) };
1627
+ const pixels = new SourcePixels(lib, {
1628
+ extensionsWasmUrl: runtimeAssets.extensionsWasmUrl,
1629
+ });
1630
+ const renderer = new Engine(new OffscreenCanvas(project.settings.width, project.settings.height), pixels, lib, runtimeAssets);
1631
+ const audio = new AudioWindowSupplier(lib);
1632
+ try {
1633
+ signal.throwIfAborted();
1634
+ await renderer.start();
1635
+ signal.throwIfAborted();
1636
+ renderer.loadProject(project, "export");
1637
+ const refused = renderer.setCaptionFonts(fontStack);
1638
+ // Heard once per job, not once per attempt.
1639
+ if (!fontsReported)
1640
+ reportRefusedFonts(refused, "export");
1641
+ fontsReported = true;
1642
+ pixels.setOutputSize(project.settings.width, project.settings.height);
1643
+ audio.configure(renderer.audioEngine, project);
1644
+ const range = document.metadata.exportRange ?? {
1645
+ start: 0,
1646
+ end: sequenceEnd(project),
1647
+ };
1648
+ if (range.end <= range.start)
1649
+ throw new EditorError("empty_export", "Project has no exportable range", "export");
1650
+ const settings = project.settings;
1651
+ const name = `${project.name}.mp4`;
1652
+ const { written, output, result } = await exportTimeline({
1653
+ engine: renderer.wasm,
1654
+ project,
1655
+ doc: {
1656
+ settings: {
1657
+ width: settings.width,
1658
+ height: settings.height,
1659
+ fpsNum: settings.frame_rate.num,
1660
+ fpsDen: settings.frame_rate.den,
1661
+ sampleRate: settings.sample_rate,
1662
+ },
1663
+ },
1664
+ library: lib,
1665
+ audioSupplier: audio,
1666
+ range: { start: range.start / 120000, end: range.end / 120000 },
1667
+ options: attemptOptions(options, attempt, project.settings),
1668
+ openOutput: (estimate) => openExportOutput({
1669
+ jobId: attempt.id.replace(/[^\w-]/g, "_"),
1670
+ name,
1671
+ target: options.output,
1672
+ ...estimate,
1673
+ }),
1674
+ signal,
1675
+ onProgress: (p) => progress({
1676
+ phase: p.phase === "finalizing" ? "finalizing" : "rendering",
1677
+ fraction: p.ratio,
1678
+ ...(p.frameCount === undefined
1679
+ ? {}
1680
+ : { frames: { completed: p.frame, total: p.frameCount } }),
1681
+ }),
1682
+ supplyFrame: (seconds) => renderer.supply(seconds, { context: context(project, lib) }),
1683
+ renderFrame: () => renderer.beginExportFrame(),
1684
+ sourceTransfer: () => renderer.sourceTransfer,
1685
+ });
1686
+ progress({ phase: "publishing", fraction: 0.99 });
1687
+ let artifact;
1688
+ try {
1689
+ signal.throwIfAborted();
1690
+ artifact = await output.finish(written);
1691
+ }
1692
+ catch (error) {
1693
+ await output.abort(error).catch(() => { });
1694
+ throw error;
1695
+ }
1696
+ return {
1697
+ ...artifact,
1698
+ media: Object.fromEntries(selection),
1699
+ report: { ...result, decodeFallbacks: pixels.decodeFallbacks },
1700
+ };
1701
+ }
1702
+ finally {
1703
+ await audio.close();
1704
+ await renderer.dispose();
1705
+ await pixels.dispose();
1706
+ }
1707
+ },
1708
+ };
1709
+ const job = new ExportJob(revision, snapshot, backend, () => {
1710
+ held.forEach((l) => l.release());
1711
+ jobs.delete(job);
1712
+ });
1713
+ jobs.add(job);
1714
+ exportSettled = job.result.then(() => { }, () => { });
1715
+ // A proxy being made yields (encoder, GPU, memory) and is made again once the export ends.
1716
+ proxyRun?.preempt.abort(new DOMException("An export takes the proxy's place", "AbortError"));
1717
+ return job;
1718
+ }
1719
+ /**
1720
+ * What an export of `snapshot` reads for each source, as the engine selects it: originals, unless
1721
+ * `options.media` asks for proxies. Refuses before anything is held when the media is not ready.
1722
+ */
1723
+ function exportSelection(snapshot, project, options) {
1724
+ if (!decisionBinding)
1725
+ throw new EditorError("conform_pending", "Check the media with conform() before exporting", "export");
1726
+ const report = conformProject(decisionBinding, project, catalogOf(snapshot), options.media === "proxies" ? "export_proxy" : "export_original");
1727
+ if (!report.ready)
1728
+ throw new ConformError(report, {
1729
+ nameOf: (id) => snapshot.assets[id]?.name,
1730
+ });
1731
+ return new Map(report.sources.map((source) => [source.source_id, source.selection]));
1732
+ }
1733
+ let disposal = null;
1734
+ function dispose() {
1735
+ if (disposal)
1736
+ return disposal;
1737
+ disposed = true;
1738
+ lifetime.abort();
1739
+ disposal = (async () => {
1740
+ pause();
1741
+ off();
1742
+ if (refreshFrame)
1743
+ (typeof cancelAnimationFrame === "function"
1744
+ ? cancelAnimationFrame
1745
+ : clearTimeout)(refreshFrame);
1746
+ ++binding;
1747
+ for (const controller of pendingImports)
1748
+ controller.abort();
1749
+ for (const controller of analyses.values())
1750
+ controller.abort();
1751
+ globalThis.document?.removeEventListener("visibilitychange", visibility);
1752
+ await Promise.all([...jobs].map((job) => job.cancel()));
1753
+ await queue;
1754
+ await mixer?.close();
1755
+ await supplier?.close();
1756
+ await workerPreview?.dispose();
1757
+ workerPreview = null;
1758
+ await engine?.dispose();
1759
+ await sources?.dispose();
1760
+ owners.forEach((owner) => owner.lease.release());
1761
+ owners.clear();
1762
+ proxies.forEach((proxy) => proxy.lease.release());
1763
+ proxies.clear();
1764
+ registry.dispose();
1765
+ engine = null;
1766
+ detachOwner();
1767
+ })();
1768
+ return disposal;
1769
+ }
1770
+ const detachOwner = editor.own({ dispose });
1771
+ return {
1772
+ get capabilities() {
1773
+ return capabilities;
1774
+ },
1775
+ bindPreview,
1776
+ importFiles,
1777
+ relink: (id, file, options) => importFiles([file], { ...options, relinkId: id }),
1778
+ importSequence,
1779
+ relinkSequence,
1780
+ /**
1781
+ * Shows another animation of a package source (a dotLottie's), keeping its clips: an undoable
1782
+ * document change.
1783
+ */
1784
+ selectAnimation(id, animation) {
1785
+ const descriptor = editor.document.assets[id];
1786
+ const source = editor.document.project.sources.find((candidate) => candidate.id === id);
1787
+ if (!descriptor || source?.provider?.format !== "lottie")
1788
+ throw new EditorError("unsupported_media", "Only a Lottie source has animations to choose", "provider", id);
1789
+ const items = ownerOf(editor.document, descriptor)?.lease.value.report?.items ?? [];
1790
+ if (!items.includes(animation))
1791
+ throw new EditorError("unsupported_media", `The package has no animation ${animation}`, "provider", id);
1792
+ const provider = { format: "lottie", animation };
1793
+ const result = editor.dispatch({
1794
+ type: "asset.put",
1795
+ asset: descriptor,
1796
+ source: { ...source, provider },
1797
+ });
1798
+ if (!result.ok)
1799
+ throw result.error;
1800
+ notifyAssets();
1801
+ },
1802
+ setLinearPrimaries(id, primaries) {
1803
+ const descriptor = editor.document.assets[id];
1804
+ const source = editor.document.project.sources.find((candidate) => candidate.id === id);
1805
+ const runtime = descriptor
1806
+ ? ownerOf(editor.document, descriptor)?.lease.value
1807
+ : undefined;
1808
+ if (!descriptor || !source?.provider || !runtime?.report?.color)
1809
+ throw new EditorError("unsupported_media", "Only an OpenEXR source has primaries to choose", "provider", id);
1810
+ const { primaries: _previous, ...rest } = source.provider;
1811
+ const provider = primaries === null ? rest : { ...rest, primaries };
1812
+ const result = editor.dispatch({
1813
+ type: "asset.put",
1814
+ asset: descriptor,
1815
+ source: { ...source, provider },
1816
+ });
1817
+ if (!result.ok)
1818
+ throw result.error;
1819
+ notifyAssets();
1820
+ },
1821
+ seek,
1822
+ play,
1823
+ pause,
1824
+ startExport,
1825
+ async importCaptions(file) {
1826
+ const generation = editor.generation;
1827
+ // A larger file holds more cues than a project can, so it is not read at all.
1828
+ if (file.size > MAX_CAPTION_FILE_BYTES)
1829
+ throw new EditorError("invalid_captions", `${file.name} is ${file.size} bytes; a subtitle file is read up to ${MAX_CAPTION_FILE_BYTES}`, "import");
1830
+ const { parseCaptions } = await loadWasm();
1831
+ const text = await file.text();
1832
+ let imported;
1833
+ try {
1834
+ imported = JSON.parse(parseCaptions(text, /\.vtt$/i.test(file.name) ? "webvtt" : "srt"));
1835
+ }
1836
+ catch (error) {
1837
+ throw new EditorError("invalid_captions", `${file.name}: ${error instanceof Error ? error.message : String(error)}`, "import", undefined, true, { cause: error });
1838
+ }
1839
+ if (disposed || editor.generation !== generation)
1840
+ throw Error("Project changed during caption import");
1841
+ const assetId = crypto.randomUUID();
1842
+ const result = editor.dispatch({
1843
+ type: "asset.put",
1844
+ asset: captionsAsset(assetId, file.name, imported.rows),
1845
+ });
1846
+ if (!result.ok)
1847
+ throw result.error;
1848
+ return { assetId, skipped: imported.skipped };
1849
+ },
1850
+ async captionFontFamilies() {
1851
+ const { fontFamily } = await loadWasm();
1852
+ const families = [];
1853
+ for (const font of [
1854
+ ...Object.values(editor.document.assets)
1855
+ .filter((a) => a.kind === "font")
1856
+ .map((a) => library.get(a.id))
1857
+ .filter(Boolean),
1858
+ ...(runtimeAssets.fonts ?? []),
1859
+ ]) {
1860
+ const family = fontFamily(font.bytes, font.faceIndex ?? 0);
1861
+ if (family && !families.includes(family))
1862
+ families.push(family);
1863
+ }
1864
+ return families;
1865
+ },
1866
+ conform,
1867
+ /**
1868
+ * Makes a proxy of a source whose original this session holds, keeps it, and previews from it
1869
+ * wherever the original goes offline.
1870
+ */
1871
+ async generateProxy(id, { signal } = {}) {
1872
+ const descriptor = editor.document.assets[id];
1873
+ // No single video file for the proxy maker to read.
1874
+ const provider = editor.document.project.sources.find((source) => source.id === id)?.provider;
1875
+ if (provider)
1876
+ throw new EditorError("unsupported_media", "Proxies are made from video files; this source has none", "proxy", id);
1877
+ const runtime = originalOf(editor.document, id);
1878
+ const hash = hashOf(descriptor);
1879
+ if (!runtime?.file || !hash)
1880
+ throw new EditorError("missing_asset", "Relink the original to make its proxy", "proxy", id);
1881
+ if (proxyJobs.has(id))
1882
+ throw new EditorError("proxy_busy", "A proxy of this source is being made", "proxy", id);
1883
+ proxyJobs.set(id, 0);
1884
+ notifyAssets();
1885
+ let hold = null;
1886
+ try {
1887
+ for (;;) {
1888
+ // One proxy at a time, none while an export runs; an export that starts takes its place.
1889
+ const turn = await proxyTurn(signal);
1890
+ try {
1891
+ // Held until its runtime is let go, so no page removes it meanwhile.
1892
+ hold = await proxyStore.hold(hash);
1893
+ // Listeners hear each whole percent once.
1894
+ let percent = 0;
1895
+ const found = await proxyStore.generate(runtime.file, descriptor.fingerprint, {
1896
+ signal: signal
1897
+ ? AbortSignal.any([signal, turn.signal])
1898
+ : turn.signal,
1899
+ onProgress: (fraction) => {
1900
+ proxyJobs.set(id, fraction);
1901
+ const next = Math.floor(fraction * 100);
1902
+ if (next === percent)
1903
+ return;
1904
+ percent = next;
1905
+ notifyAssets();
1906
+ },
1907
+ });
1908
+ const adopted = adoptProxy(hash, found, hold);
1909
+ hold = null;
1910
+ await adopted;
1911
+ break;
1912
+ }
1913
+ catch (error) {
1914
+ if (!turn.signal.aborted || signal?.aborted)
1915
+ throw error;
1916
+ void hold?.release();
1917
+ hold = null;
1918
+ proxyJobs.set(id, 0);
1919
+ notifyAssets();
1920
+ }
1921
+ finally {
1922
+ turn.end();
1923
+ }
1924
+ }
1925
+ }
1926
+ finally {
1927
+ void hold?.release();
1928
+ proxyJobs.delete(id);
1929
+ notifyAssets();
1930
+ }
1931
+ // The next load asks the engine again. Playback is left alone: every source it reads is online.
1932
+ if (engine && !playing)
1933
+ refreshPreview();
1934
+ },
1935
+ /**
1936
+ * Shows node `nodeId` of composition `compositionId` in the viewer instead of the composition's
1937
+ * output, rendered by the same engine compiler; `null` shows the outputs again. Preview only:
1938
+ * the document and export are unchanged.
1939
+ */
1940
+ setPreviewOutput(target) {
1941
+ const next = target
1942
+ ? { compositionId: target.compositionId, nodeId: target.nodeId }
1943
+ : null;
1944
+ if (JSON.stringify(next) === JSON.stringify(previewOutput))
1945
+ return;
1946
+ previewOutput = next;
1947
+ renderKey = null;
1948
+ if (engine)
1949
+ void seek(tick).catch(onError);
1950
+ },
1951
+ get previewOutput() {
1952
+ return previewOutput;
1953
+ },
1954
+ hasOriginal: (id) => !!originalOf(editor.document, id),
1955
+ /**
1956
+ * Which video codecs this browser encodes for an export of the current document at
1957
+ * `videoQuality`: the question an export asks before it opens its encoder.
1958
+ */
1959
+ async videoEncoders({ videoQuality } = {}) {
1960
+ await decisions();
1961
+ const { probeEncoders } = await import("./runtime/exporter.js");
1962
+ const { width, height, frame_rate } = editor.document.project.settings;
1963
+ return probeEncoders(width, height, {
1964
+ frameRate: frame_rate,
1965
+ videoQuality,
1966
+ });
1967
+ },
1968
+ /** Whether a source has a kept proxy, and the progress of one being made. */
1969
+ proxyStatus(id) {
1970
+ const descriptor = editor.document.assets[id];
1971
+ const hash = hashOf(descriptor);
1972
+ return {
1973
+ available: !!hash && proxies.has(hash),
1974
+ generating: proxyJobs.get(id) ?? null,
1975
+ reading: previewSelection.get(id)?.media ?? "original",
1976
+ };
1977
+ },
1978
+ /**
1979
+ * The proxies this browser keeps, for every project: each original's hash and the bytes its
1980
+ * proxy takes.
1981
+ */
1982
+ listProxies: () => proxyStore.list(),
1983
+ /**
1984
+ * Removes the kept proxies of `hashes`. This session lets go of its own first and refreshes the
1985
+ * preview; a proxy still read after that (by the preview, for a source whose original is
1986
+ * offline, by an export of this session, or by another page of the origin) is left and listed
1987
+ * in `inUse`.
1988
+ */
1989
+ async removeProxies(hashes) {
1990
+ const wanted = new Set(hashes);
1991
+ const unlocked = [];
1992
+ for (const [hash, proxy] of proxies)
1993
+ if (wanted.has(hash)) {
1994
+ proxies.delete(hash);
1995
+ proxy.lease.release();
1996
+ if (proxy.unlocked)
1997
+ unlocked.push(proxy.unlocked);
1998
+ }
1999
+ await Promise.all(unlocked);
2000
+ notifyAssets();
2001
+ renderKey = null;
2002
+ if (engine)
2003
+ await seek(tick).catch(onError);
2004
+ const result = await proxyStore.remove([...wanted]);
2005
+ return result;
2006
+ },
2007
+ async nativeAssets(snapshot = editor.document, options = {}) {
2008
+ const compiled = compileEditorDocument(snapshot);
2009
+ if (compiled.diagnostics.length)
2010
+ throw compiled.diagnostics[0];
2011
+ await discoverProxies(snapshot);
2012
+ await decisions();
2013
+ // Leased after the waits and before the next, so no release can come between.
2014
+ materializeText(snapshot);
2015
+ requireShownText(snapshot, compiled.project);
2016
+ const selection = exportSelection(snapshot, compiled.project, options);
2017
+ const used = shownSources(compiled.project);
2018
+ for (const resource of compiled.project.effect_resources ?? [])
2019
+ used.add(resource.id);
2020
+ const held = [];
2021
+ try {
2022
+ const result = [];
2023
+ const fonts = new Set(heldFonts(snapshot));
2024
+ for (const asset of Object.values(snapshot.assets)) {
2025
+ if (drawnByEngine(asset) ||
2026
+ (asset.kind === "font" ? !fonts.has(asset) : !used.has(asset.id)))
2027
+ continue;
2028
+ const proxied = selection.get(asset.id)?.media === "proxy";
2029
+ const lease = proxied
2030
+ ? acquireProxy(hashOf(asset), asset.id)
2031
+ : acquireOwned(snapshot, asset);
2032
+ held.push(lease);
2033
+ const runtime = lease.value;
2034
+ // An image sequence is its frames' files, each uploaded under its locator.
2035
+ if (!proxied && runtime.frameFiles) {
2036
+ for (const [locator, file] of runtime.frameFiles)
2037
+ result.push({
2038
+ id: `${asset.id}:${result.length}`,
2039
+ kind: "frames",
2040
+ blob: file,
2041
+ name: file.name,
2042
+ locator,
2043
+ });
2044
+ continue;
2045
+ }
2046
+ if (proxied) {
2047
+ result.push({
2048
+ id: asset.id,
2049
+ kind: "proxies",
2050
+ blob: runtime.file,
2051
+ name: `${asset.id}.proxy.mp4`,
2052
+ madeFrom: proxies.get(hashOf(asset)).record.made_from,
2053
+ });
2054
+ continue;
2055
+ }
2056
+ let blob = runtime.file;
2057
+ if (asset.kind === "font")
2058
+ blob = new Blob([runtime.bytes]);
2059
+ if (asset.kind === "lut3d")
2060
+ blob = new Blob([runtime.text]);
2061
+ if (asset.kind === "text") {
2062
+ const raster = runtime.raster;
2063
+ const rgba = new Uint8ClampedArray(raster.rgba8);
2064
+ for (let i = 0; i < rgba.length; i += 4)
2065
+ if (rgba[i + 3]) {
2066
+ const factor = 255 / rgba[i + 3];
2067
+ rgba[i] *= factor;
2068
+ rgba[i + 1] *= factor;
2069
+ rgba[i + 2] *= factor;
2070
+ }
2071
+ const canvas = new OffscreenCanvas(snapshot.project.settings.width, snapshot.project.settings.height);
2072
+ canvas
2073
+ .getContext("2d")
2074
+ .putImageData(new ImageData(rgba, raster.texture.width, raster.texture.height), Math.round((canvas.width - raster.texture.width) / 2), Math.round((canvas.height - raster.texture.height) / 2));
2075
+ blob = await canvas.convertToBlob({ type: "image/png" });
2076
+ }
2077
+ if (!blob)
2078
+ throw Error(`Missing native bytes: ${asset.name}`);
2079
+ result.push({
2080
+ id: asset.id,
2081
+ kind: asset.kind === "font"
2082
+ ? "caption-fonts"
2083
+ : asset.kind === "lut3d"
2084
+ ? "effect-assets"
2085
+ : "assets",
2086
+ blob,
2087
+ name: asset.kind === "text" ? "text.png" : asset.name,
2088
+ });
2089
+ }
2090
+ result.push(...(runtimeAssets.fonts ?? []).map((font) => ({
2091
+ id: font.id,
2092
+ kind: "caption-fonts",
2093
+ blob: new Blob([font.bytes]),
2094
+ name: `${font.name}.ttf`,
2095
+ })));
2096
+ return result;
2097
+ }
2098
+ finally {
2099
+ held.forEach((lease) => lease.release());
2100
+ }
2101
+ },
2102
+ dispose,
2103
+ setMonitorVolume(value) {
2104
+ if (!Number.isFinite(value) || value < 0 || value > 1)
2105
+ throw Error("Monitor volume must be between 0 and 1");
2106
+ // The bridge's, not one mixer's: each preview binding's mixer starts at it.
2107
+ monitorVolume = value;
2108
+ mixer?.setMasterVolume(value);
2109
+ },
2110
+ get monitorVolume() {
2111
+ return monitorVolume;
2112
+ },
2113
+ setTraceUploads(value) {
2114
+ if (engine)
2115
+ engine.traceUploads = value;
2116
+ },
2117
+ get diagnostics() {
2118
+ return engine?.lastDiagnostics ?? null;
2119
+ },
2120
+ get tick() {
2121
+ return tick;
2122
+ },
2123
+ get playing() {
2124
+ return playing;
2125
+ },
2126
+ get previewBackend() {
2127
+ return !engine ? "unbound" : workerPreview ? "worker" : "main-thread";
2128
+ },
2129
+ get previewRecoveries() {
2130
+ return previewRecoveries;
2131
+ },
2132
+ get resourceCount() {
2133
+ return registry.size;
2134
+ },
2135
+ subscribeAssets(listener) {
2136
+ assetListeners.add(listener);
2137
+ return () => assetListeners.delete(listener);
2138
+ },
2139
+ get previewIssue() {
2140
+ return previewIssue;
2141
+ },
2142
+ subscribePreview(listener) {
2143
+ previewListeners.add(listener);
2144
+ return () => previewListeners.delete(listener);
2145
+ },
2146
+ get catalog() {
2147
+ return engine?.catalog ?? null;
2148
+ },
2149
+ getAsset: (id) => library.get(id),
2150
+ };
2151
+ }
2152
+ /**
2153
+ * Whether the engine draws a source itself, so no file backs it: a generated source or a
2154
+ * composition.
2155
+ */
2156
+ function drawnByEngine(source) {
2157
+ return source.kind === "generated" || source.kind === "composition";
2158
+ }
2159
+ /** Waits for `promise`, or rejects when `signal` aborts first. */
2160
+ function settledOrAborted(promise, signal) {
2161
+ if (!signal)
2162
+ return promise;
2163
+ return new Promise((resolve, reject) => {
2164
+ const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
2165
+ if (signal.aborted)
2166
+ return abort();
2167
+ signal.addEventListener("abort", abort, { once: true });
2168
+ promise.then(() => {
2169
+ signal.removeEventListener("abort", abort);
2170
+ resolve();
2171
+ }, (error) => {
2172
+ signal.removeEventListener("abort", abort);
2173
+ reject(error);
2174
+ });
2175
+ });
2176
+ }