@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,549 @@
1
+ // Timeline audio. The engine owns the mix (gain, fades, panning, buses, limiter); this module only
2
+ // moves samples, so preview and export cannot drift apart.
3
+ //
4
+ // Preview needs `SharedArrayBuffer`, so the page must be cross-origin isolated (COOP/COEP).
5
+ // Without isolation, preview is silent and export still writes engine-mixed audio.
6
+ import { AudioPlaybackClock, AudioPump, AudioRingBuffer, canShareAudioMemory, } from "alpha2/audio-timeline";
7
+ import { Alpha2AudioWorkletHost, configureEngineMixedAudio, disposeAudioWorklet, loadAlpha2AudioWorkletWasm, resetAudioWorklet, useAudioRing, } from "alpha2/audio-worklet";
8
+ import { MediabunnyAudioDecoder } from "alpha2/mediabunny";
9
+ import * as engineBinding from "alpha2/wasm";
10
+ const TICKS_PER_SECOND = 120000n;
11
+ const PREFETCH_TICKS = 2n * TICKS_PER_SECOND;
12
+ /** How much of a source is kept behind the earliest tick a mix still reads (backward prefetch). */
13
+ const KEEP_BEHIND_TICKS = PREFETCH_TICKS;
14
+ /** How far that point moves forward before the source is trimmed again. */
15
+ const TRIM_STEP_TICKS = PREFETCH_TICKS;
16
+ /**
17
+ * Sorts one source's needed tick ranges and joins the ones less than a prefetch apart, which are
18
+ * cheaper to decode as one window than as two.
19
+ */
20
+ function coalesceRanges(ranges) {
21
+ const sorted = [...ranges].sort((a, b) => a.start < b.start ? -1 : a.start > b.start ? 1 : 0);
22
+ const merged = [];
23
+ for (const range of sorted) {
24
+ const last = merged.at(-1);
25
+ if (last && range.start - last.end <= PREFETCH_TICKS) {
26
+ if (range.end > last.end)
27
+ last.end = range.end;
28
+ }
29
+ else {
30
+ merged.push({ ...range });
31
+ }
32
+ }
33
+ return merged;
34
+ }
35
+ function minBigInt(a, b) {
36
+ return a < b ? a : b;
37
+ }
38
+ /**
39
+ * Sources that contribute to the mix, paired with their browser-side asset.
40
+ *
41
+ * A video file with a sound track counts: it declares `has_audio`, so its clip on a video track is
42
+ * mixed without a second clip on an audio lane.
43
+ */
44
+ export function audibleSources(project, library) {
45
+ const items = [];
46
+ for (const source of project.sources) {
47
+ if (source.kind !== "audio" && source.has_audio !== true)
48
+ continue;
49
+ const asset = library.get(source.id);
50
+ if (asset?.hasAudio || asset?.audioBuffer)
51
+ items.push({ id: source.id, asset });
52
+ }
53
+ return items;
54
+ }
55
+ /**
56
+ * Supplies bounded source windows requested by the engine. Page-synthesized audio keeps its
57
+ * AudioBuffer, but only the requested slice enters wasm.
58
+ */
59
+ export class AudioWindowSupplier {
60
+ #library;
61
+ #engine = null;
62
+ #sampleRate = 48_000;
63
+ #generation = 0;
64
+ #controller = new AbortController();
65
+ #decoders = new Map();
66
+ /** Per source, the earliest tick the previous request needed. */
67
+ #lastStarts = new Map();
68
+ /** Per source, the tick the engine's audio was last trimmed below, or would have been. */
69
+ #trimmed = new Map();
70
+ /** The earliest tick each running `supply` needs, per source. */
71
+ #inFlight = new Set();
72
+ constructor(library) {
73
+ this.#library = library;
74
+ }
75
+ configure(engine, project) {
76
+ this.#controller.abort(new DOMException("Project reloaded", "AbortError"));
77
+ this.#controller = new AbortController();
78
+ this.#engine = engine;
79
+ this.#sampleRate = project.settings.sample_rate;
80
+ this.#generation += 1;
81
+ this.#lastStarts.clear();
82
+ this.#trimmed.clear();
83
+ const audible = new Set(audibleSources(project, this.#library).map(({ id }) => id));
84
+ for (const [sourceId, entry] of this.#decoders) {
85
+ const asset = this.#library.get(sourceId);
86
+ if (audible.has(sourceId) && asset?.file === entry.file)
87
+ continue;
88
+ closeDecoder(entry.promise);
89
+ this.#decoders.delete(sourceId);
90
+ }
91
+ }
92
+ async supply(startSample, frames, { signal, forceSources = [] } = {}) {
93
+ const engine = this.#engine;
94
+ if (!engine)
95
+ throw new Error("audio supplier has no loaded project");
96
+ throwIfAborted(signal);
97
+ const generation = this.#generation;
98
+ const combined = combineSignals(this.#controller.signal, signal);
99
+ let running = null;
100
+ try {
101
+ const needs = JSON.parse(engine.audioNeeds(BigInt(startSample), frames));
102
+ const forced = new Set(forceSources);
103
+ const bySource = new Map();
104
+ for (const need of needs) {
105
+ const ranges = bySource.get(need.sourceId) ?? [];
106
+ ranges.push({
107
+ start: BigInt(need.startTicks),
108
+ end: BigInt(need.endTicks),
109
+ });
110
+ bySource.set(need.sourceId, ranges);
111
+ }
112
+ const earliest = new Map([...bySource].map(([sourceId, ranges]) => [
113
+ sourceId,
114
+ ranges.reduce((least, { start }) => minBigInt(least, start), ranges[0].start),
115
+ ]));
116
+ running = earliest;
117
+ this.#inFlight.add(running);
118
+ for (const [sourceId, start] of earliest)
119
+ this.#trim(sourceId, start);
120
+ // The engine replaces any window an upload overlaps, so one source's requests run in order
121
+ // and each prefetch stops short of the next request.
122
+ await Promise.all([...bySource].map(async ([sourceId, ranges]) => {
123
+ const requests = coalesceRanges(ranges);
124
+ // A reversed clip or ping-pong backward pass reads backwards, so prefetch goes that way.
125
+ const previous = this.#lastStarts.get(sourceId);
126
+ const backward = previous !== undefined &&
127
+ requests[0].start < previous &&
128
+ previous - requests[0].start <= TICKS_PER_SECOND;
129
+ this.#lastStarts.set(sourceId, requests[0].start);
130
+ for (let index = 0; index < requests.length; index += 1) {
131
+ const { start, end } = requests[index];
132
+ const neighbour = requests[backward ? index - 1 : index + 1];
133
+ const gap = neighbour
134
+ ? backward
135
+ ? start - neighbour.end
136
+ : neighbour.start - end
137
+ : null;
138
+ const prefetch = gap === null
139
+ ? PREFETCH_TICKS
140
+ : minBigInt(PREFETCH_TICKS, gap / 2n);
141
+ const window = backward
142
+ ? { start: start > prefetch ? start - prefetch : 0n, end }
143
+ : { start, end: end + prefetch };
144
+ await this.#supplyRange(sourceId, start, end, window, {
145
+ forced: forced.has(sourceId),
146
+ generation,
147
+ signal: combined.signal,
148
+ });
149
+ }
150
+ }));
151
+ }
152
+ finally {
153
+ if (running)
154
+ this.#inFlight.delete(running);
155
+ combined.dispose();
156
+ }
157
+ }
158
+ /**
159
+ * Drops what the engine holds of `sourceId` well before `start`, stopping short of the earliest
160
+ * tick any running mix needs. Runs once per `TRIM_STEP_TICKS` of progress; a seek back resets it.
161
+ */
162
+ #trim(sourceId, start) {
163
+ let floor = start;
164
+ for (const running of this.#inFlight) {
165
+ const other = running.get(sourceId);
166
+ if (other !== undefined)
167
+ floor = minBigInt(floor, other);
168
+ }
169
+ const before = floor - KEEP_BEHIND_TICKS;
170
+ if (before <= 0n)
171
+ return;
172
+ const last = this.#trimmed.get(sourceId);
173
+ if (last !== undefined && before < last) {
174
+ this.#trimmed.set(sourceId, before);
175
+ return;
176
+ }
177
+ if (last !== undefined && before - last < TRIM_STEP_TICKS)
178
+ return;
179
+ this.#engine.trimAudioBefore(sourceId, before);
180
+ this.#trimmed.set(sourceId, before);
181
+ }
182
+ async #supplyRange(sourceId, startTicks, endTicks, decodeWindow, { forced, generation, signal }) {
183
+ const engine = this.#engine;
184
+ const requiredStart = Math.max(0, ticksToSampleFloor(startTicks, this.#sampleRate) - 2);
185
+ const requiredEnd = ticksToSampleCeil(endTicks, this.#sampleRate) + 2;
186
+ // Ask the engine what it holds: it extends, replaces and drops windows on its own terms.
187
+ if (!forced &&
188
+ JSON.parse(engine.audioRetainedWindows(sourceId)).some((window) => window.startSample <= requiredStart &&
189
+ window.endSample >= requiredEnd))
190
+ return;
191
+ const asset = this.#library.get(sourceId);
192
+ if (!asset)
193
+ throw new Error(`audio source ${sourceId} is unavailable`);
194
+ if (asset.audioDecodable === false && !asset.audioBuffer) {
195
+ // Undecodable here (`RuntimeAsset.audioDecodable`): silent, while the document keeps it.
196
+ engine.uploadAudioBlock(sourceId, BigInt(requiredStart), this.#sampleRate, 1, new Float32Array(Math.max(1, requiredEnd - requiredStart)));
197
+ return;
198
+ }
199
+ const block = asset.audioBuffer
200
+ ? blockFromAudioBuffer(sourceId, asset.audioBuffer, decodeWindow.start, decodeWindow.end, this.#sampleRate, generation)
201
+ : await this.#decode(sourceId, asset, decodeWindow.start, decodeWindow.end, generation, signal);
202
+ throwIfAborted(signal);
203
+ if (generation !== this.#generation)
204
+ return;
205
+ engine.uploadAudioBlock(sourceId, block.startSample, block.sampleRate, block.speakers ?? block.channels.length, flattenPlanes(block.channels));
206
+ }
207
+ async close() {
208
+ this.#controller.abort();
209
+ this.#generation += 1;
210
+ const closing = [...this.#decoders.values()].map((entry) => entry.promise.then((decoder) => decoder.close()).catch(() => { }));
211
+ this.#decoders.clear();
212
+ this.#engine = null;
213
+ await Promise.allSettled(closing);
214
+ }
215
+ async #decode(sourceId, asset, startTicks, endTicks, generation, signal) {
216
+ if (!asset.file)
217
+ throw new Error(`audio source ${sourceId} has no decodable file`);
218
+ let entry = this.#decoders.get(sourceId);
219
+ if (!entry || entry.file !== asset.file) {
220
+ if (entry)
221
+ closeDecoder(entry.promise);
222
+ entry = {
223
+ file: asset.file,
224
+ // The engine's own resampler, as on native; alpha2/wasm is loaded by the time this decodes.
225
+ promise: MediabunnyAudioDecoder.open(sourceId, asset.file, engineBinding),
226
+ };
227
+ this.#decoders.set(sourceId, entry);
228
+ }
229
+ const decoder = await entry.promise;
230
+ return decoder.refill({
231
+ sourceId,
232
+ startMediaTimeTicks: startTicks,
233
+ endMediaTimeTicks: endTicks,
234
+ generation,
235
+ }, this.#sampleRate, signal);
236
+ }
237
+ }
238
+ /** Mixes one block through the engine, returning planar channels. */
239
+ export function mixBlock(engine, startSample, frames, playing = true) {
240
+ const flat = engine.renderAudio(BigInt(startSample), frames, playing);
241
+ const channels = engine.audioChannels();
242
+ const planes = [];
243
+ for (let channel = 0; channel < channels; channel += 1) {
244
+ planes.push(flat.subarray(channel * frames, (channel + 1) * frames));
245
+ }
246
+ return planes;
247
+ }
248
+ /** Sources the most recent mix could not read, as reported by the engine. */
249
+ export function missingAudioSources(engine) {
250
+ try {
251
+ return JSON.parse(engine.audioMissingSources());
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ }
257
+ const RING_SECONDS = 0.5;
258
+ const PUMP_INTERVAL_MS = 40;
259
+ /**
260
+ * Preview transport for engine-mixed audio. A timer keeps the shared ring ahead of the audio
261
+ * thread; the worklet applies no second master chain.
262
+ */
263
+ export class EngineAudioMixer {
264
+ constructor(runtimeAssets = {}, { enabled = true } = {}) {
265
+ this.runtimeAssets = runtimeAssets;
266
+ this.#enabled = enabled;
267
+ }
268
+ #enabled = true;
269
+ #clock = null;
270
+ get tick() {
271
+ return this.#clock ? Number(this.#clock.timelineTicks) : null;
272
+ }
273
+ #context = null;
274
+ #node = null;
275
+ #setup = null;
276
+ #ring = null;
277
+ #pump = null;
278
+ #timer = 0;
279
+ #transport = new AbortController();
280
+ #engine = null;
281
+ #supplier = null;
282
+ #sampleRate = 48000;
283
+ #onUnderrun = null;
284
+ #onError = null;
285
+ #priming = false;
286
+ volume = 1;
287
+ get supported() {
288
+ return this.#enabled && canShareAudioMemory();
289
+ }
290
+ get context() {
291
+ return this.#context;
292
+ }
293
+ /**
294
+ * Associates a loaded project with the preview mixer. Safe to call again.
295
+ *
296
+ * Worklet creation is deferred to `resume()`: a suspended AudioContext may not construct an
297
+ * AudioWorkletProcessor until a user gesture.
298
+ */
299
+ async attach(engine, project, supplier, onUnderrun, onError) {
300
+ this.stop();
301
+ if (!this.supported)
302
+ return false;
303
+ const sampleRate = project.settings.sample_rate;
304
+ if (this.#context && this.#sampleRate !== sampleRate)
305
+ await this.close();
306
+ // Continue across a gap of a block or less instead of replaying effect history; seeks reset
307
+ // and export is exact either way.
308
+ engine.setAudioContinuity("continue-small-gaps");
309
+ this.#engine = engine;
310
+ this.#supplier = supplier;
311
+ this.#onUnderrun = onUnderrun ?? null;
312
+ this.#onError = onError ?? null;
313
+ this.#sampleRate = sampleRate;
314
+ if (this.#node)
315
+ await configureEngineMixedAudio(this.#node);
316
+ return true;
317
+ }
318
+ async #create(context) {
319
+ const wasm = await loadAlpha2AudioWorkletWasm(this.runtimeAssets.audioWasmUrl);
320
+ const node = await Alpha2AudioWorkletHost.create(context, wasm, {
321
+ processorUrl: this.runtimeAssets.workletUrl,
322
+ numberOfInputs: 0,
323
+ outputChannelCount: [2],
324
+ projectSampleRate: this.#sampleRate,
325
+ });
326
+ node.port.addEventListener("message", (event) => {
327
+ if (event.data?.type === "alpha2-underrun" && !this.#priming)
328
+ this.#onUnderrun?.();
329
+ });
330
+ node.connect(context.destination);
331
+ this.#ring = AudioRingBuffer.create(2, Math.ceil(this.#sampleRate * RING_SECONDS));
332
+ // Play only once the node plays the ring, or a quantum runs absent input through the master.
333
+ try {
334
+ await useAudioRing(node, this.#ring.transfer);
335
+ await configureEngineMixedAudio(node);
336
+ }
337
+ catch (error) {
338
+ disposeAudioWorklet(node);
339
+ this.#ring = null;
340
+ throw error;
341
+ }
342
+ this.#node = node;
343
+ }
344
+ #beginSetup(context) {
345
+ if (this.#setup)
346
+ return this.#setup;
347
+ const setup = this.#create(context);
348
+ this.#setup = setup;
349
+ // A rejected setup must be retryable on a later Play click, even if its caller was interrupted.
350
+ void setup.catch(() => {
351
+ if (this.#setup === setup)
352
+ this.#setup = null;
353
+ });
354
+ return setup;
355
+ }
356
+ setMasterVolume(value) {
357
+ this.volume = value;
358
+ }
359
+ async resume() {
360
+ if (!this.supported || !this.#engine)
361
+ return;
362
+ // Construct and resume before the first await to keep Play's user activation.
363
+ const context = this.#context ?? new AudioContext({ sampleRate: this.#sampleRate });
364
+ this.#context = context;
365
+ const resumed = context.state === "suspended" ? context.resume() : Promise.resolve();
366
+ const setup = this.#node ? Promise.resolve() : this.#beginSetup(context);
367
+ await Promise.all([resumed, setup]);
368
+ }
369
+ /** Starts pumping mixed audio from timeline tick `fromTick`, a non-negative integer. */
370
+ async start(fromTick, rate = 1) {
371
+ const startingSignal = this.#transport.signal;
372
+ await this.resume();
373
+ if (startingSignal.aborted)
374
+ return;
375
+ if (!this.#node || !this.#ring || !this.#engine)
376
+ return;
377
+ // Playback rate is a preview-only concern; the engine always mixes at 1x.
378
+ if (rate !== 1) {
379
+ this.stop();
380
+ return;
381
+ }
382
+ this.stop();
383
+ const signal = this.#transport.signal;
384
+ const startSample = playbackStartSample(fromTick, this.#sampleRate);
385
+ this.#pump = new AudioPump(this.#source(signal), this.#ring, startSample, {
386
+ blockFrames: 1024,
387
+ targetFrames: Math.floor(this.#ring.capacity * 0.75),
388
+ });
389
+ this.#ring.clear();
390
+ this.#priming = true;
391
+ try {
392
+ // Cleared before the first sample from the new position reaches the worklet.
393
+ await resetAudioWorklet(this.#node);
394
+ if (signal.aborted)
395
+ return;
396
+ this.#engine.resetAudio();
397
+ await this.#pump.fill();
398
+ }
399
+ catch (error) {
400
+ // A stop or close while the first block was decoding ends this start quietly.
401
+ if (signal.aborted)
402
+ return;
403
+ throw error;
404
+ }
405
+ finally {
406
+ if (this.#transport.signal === signal)
407
+ this.#priming = false;
408
+ }
409
+ if (signal.aborted)
410
+ return;
411
+ this.#clock = new AudioPlaybackClock(this.#pump, this.#ring, this.#context, Alpha2AudioWorkletHost.latency(this.#node));
412
+ this.#timer = setInterval(() => {
413
+ void this.#pump?.fill().catch((error) => {
414
+ if (error?.name === "AbortError")
415
+ return;
416
+ this.stop();
417
+ this.#onError?.(error);
418
+ });
419
+ }, PUMP_INTERVAL_MS);
420
+ }
421
+ stop() {
422
+ this.#clock = null;
423
+ this.#transport.abort(new DOMException("Audio transport stopped", "AbortError"));
424
+ this.#transport = new AbortController();
425
+ if (this.#timer)
426
+ clearInterval(this.#timer);
427
+ this.#timer = 0;
428
+ this.#pump = null;
429
+ this.#priming = false;
430
+ this.#ring?.clear();
431
+ }
432
+ async close() {
433
+ this.stop();
434
+ // Let any in-flight setup settle so it cannot resurrect a closed node.
435
+ await this.#setup?.catch(() => { });
436
+ this.#setup = null;
437
+ if (this.#node)
438
+ disposeAudioWorklet(this.#node);
439
+ this.#node = null;
440
+ this.#ring = null;
441
+ await this.#context?.close();
442
+ this.#context = null;
443
+ this.#supplier = null;
444
+ }
445
+ #source(signal) {
446
+ const engine = this.#engine;
447
+ const supplier = this.#supplier;
448
+ const volume = () => this.volume;
449
+ return {
450
+ sampleRate: this.#sampleRate,
451
+ channels: 2,
452
+ mix: async (startSample, frames) => {
453
+ await supplier.supply(startSample, frames, { signal });
454
+ let planes = mixBlock(engine, startSample, frames, true);
455
+ let missing = missingAudioSources(engine);
456
+ if (missing.length > 0) {
457
+ await supplier.supply(startSample, frames, {
458
+ signal,
459
+ forceSources: missing,
460
+ });
461
+ planes = mixBlock(engine, startSample, frames, true);
462
+ missing = missingAudioSources(engine);
463
+ }
464
+ if (missing.length > 0)
465
+ return { kind: "waiting", missingSources: missing };
466
+ // Preview volume is a monitor control, not part of the project.
467
+ const level = volume();
468
+ if (level !== 1) {
469
+ for (const plane of planes) {
470
+ for (let index = 0; index < plane.length; index += 1)
471
+ plane[index] *= level;
472
+ }
473
+ }
474
+ return { kind: "mixed", planes };
475
+ },
476
+ };
477
+ }
478
+ }
479
+ function blockFromAudioBuffer(sourceId, buffer, startTicks, endTicks, sampleRate, generation) {
480
+ if (buffer.sampleRate !== sampleRate)
481
+ throw new Error(`generated audio ${sourceId} uses ${buffer.sampleRate} Hz, expected ${sampleRate} Hz`);
482
+ const first = Math.max(0, ticksToSampleFloor(startTicks, sampleRate) - 2);
483
+ const last = Math.max(first + 1, ticksToSampleCeil(endTicks, sampleRate) + 2);
484
+ const channels = Array.from({ length: Math.min(2, buffer.numberOfChannels) }, (_, channel) => {
485
+ const output = new Float32Array(last - first);
486
+ const sourceStart = Math.min(buffer.length, first);
487
+ const sourceEnd = Math.min(buffer.length, last);
488
+ if (sourceEnd > sourceStart) {
489
+ output.set(buffer.getChannelData(channel).subarray(sourceStart, sourceEnd), sourceStart - first);
490
+ }
491
+ return output;
492
+ });
493
+ return {
494
+ sourceId,
495
+ startSample: BigInt(first),
496
+ generation,
497
+ sampleRate,
498
+ channels,
499
+ };
500
+ }
501
+ function flattenPlanes(channels) {
502
+ const frames = channels[0]?.length ?? 0;
503
+ const flat = new Float32Array(channels.length * frames);
504
+ for (let channel = 0; channel < channels.length; channel += 1)
505
+ flat.set(channels[channel], channel * frames);
506
+ return flat;
507
+ }
508
+ /**
509
+ * The sample playback from `tick` starts at: the first one at or after it. The audio clock reports
510
+ * the tick at or before the sample heard, so rounding to nearest could be heard a frame early.
511
+ */
512
+ export function playbackStartSample(tick, sampleRate) {
513
+ return ticksToSampleCeil(BigInt(Math.max(0, tick)), sampleRate);
514
+ }
515
+ function ticksToSampleFloor(ticks, sampleRate) {
516
+ return Number((ticks * BigInt(sampleRate)) / TICKS_PER_SECOND);
517
+ }
518
+ function ticksToSampleCeil(ticks, sampleRate) {
519
+ const rate = BigInt(sampleRate);
520
+ return Number((ticks * rate + TICKS_PER_SECOND - 1n) / TICKS_PER_SECOND);
521
+ }
522
+ function closeDecoder(promise) {
523
+ void promise.then((decoder) => decoder.close()).catch(() => { });
524
+ }
525
+ function combineSignals(primary, secondary) {
526
+ const controller = new AbortController();
527
+ const abort = (signal) => controller.abort(signal.reason ?? new DOMException("Audio decode aborted", "AbortError"));
528
+ const onPrimary = () => abort(primary);
529
+ const onSecondary = () => abort(secondary);
530
+ if (primary.aborted)
531
+ abort(primary);
532
+ else
533
+ primary.addEventListener("abort", onPrimary, { once: true });
534
+ if (secondary?.aborted)
535
+ abort(secondary);
536
+ else
537
+ secondary?.addEventListener("abort", onSecondary, { once: true });
538
+ return {
539
+ signal: controller.signal,
540
+ dispose() {
541
+ primary.removeEventListener("abort", onPrimary);
542
+ secondary?.removeEventListener("abort", onSecondary);
543
+ },
544
+ };
545
+ }
546
+ function throwIfAborted(signal) {
547
+ if (signal?.aborted)
548
+ throw (signal.reason ?? new DOMException("Audio decode aborted", "AbortError"));
549
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The export options one attempt runs with: a retry that asks for less in flight scales the byte
3
+ * budget by its profile, but never below one frame's working set, which would only turn the retry
4
+ * into a refusal.
5
+ */
6
+ export function attemptOptions(options: any, attempt: any, settings: any): any;
7
+ /**
8
+ * Bytes of rendered frames an export keeps in flight unless asked otherwise: the SDK's
9
+ * `maxInFlightBytes` default.
10
+ */
11
+ export const DEFAULT_IN_FLIGHT_BYTES: number;
@@ -0,0 +1,26 @@
1
+ // The browser export's byte budget, readable without loading the runtime.
2
+ import { minimumExportWorkingSet } from "./staging.js";
3
+ /**
4
+ * Bytes of rendered frames an export keeps in flight unless asked otherwise: the SDK's
5
+ * `maxInFlightBytes` default.
6
+ */
7
+ export const DEFAULT_IN_FLIGHT_BYTES = 128 * 1024 * 1024;
8
+ /**
9
+ * The export options one attempt runs with: a retry that asks for less in flight scales the byte
10
+ * budget by its profile, but never below one frame's working set, which would only turn the retry
11
+ * into a refusal.
12
+ */
13
+ export function attemptOptions(options, attempt, settings) {
14
+ const scale = attempt?.profile?.memoryScale ?? 1;
15
+ if (scale >= 1)
16
+ return options;
17
+ const least = minimumExportWorkingSet(settings.width, settings.height);
18
+ const budget = options.memory?.inFlightBytes ?? DEFAULT_IN_FLIGHT_BYTES;
19
+ return {
20
+ ...options,
21
+ memory: {
22
+ ...options.memory,
23
+ inFlightBytes: Math.max(least, Math.floor(budget * scale)),
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Replaces an engine registry's caption fonts with `fonts`, in order. A font the engine cannot
3
+ * parse costs only itself: it is left out and every other font is still registered, the built-in
4
+ * fallbacks among them. Returns what was left out, as `{ font, error }`, for the host to report.
5
+ */
6
+ export function registerCaptionFonts(registry: any, fonts: any): {
7
+ font: any;
8
+ error: unknown;
9
+ }[];
10
+ export class CaptionFonts {
11
+ constructor(defaultFontUrl: any);
12
+ defaultFontUrl: any;
13
+ get list(): {
14
+ id: any;
15
+ name: any;
16
+ builtIn: any;
17
+ }[];
18
+ /** The fonts in fallback order, for an engine to register. */
19
+ get stack(): any[];
20
+ loadDefault(): Promise<void>;
21
+ /**
22
+ * Adds a font file ahead of the others, so it draws every glyph it has and the fonts after it
23
+ * fill in the rest (a Korean font before DejaVu, say).
24
+ */
25
+ add(file: any): Promise<{
26
+ id: string;
27
+ name: any;
28
+ bytes: Uint8Array<any>;
29
+ faceIndex: number;
30
+ builtIn: boolean;
31
+ }>;
32
+ remove(id: any): void;
33
+ #private;
34
+ }
@@ -0,0 +1,69 @@
1
+ // Fonts caption tracks draw with, in fallback order. The engine rasterizes captions from these
2
+ // files, so preview and every export draw the same pixels; fonts are not part of the document.
3
+ export class CaptionFonts {
4
+ constructor(defaultFontUrl) {
5
+ this.defaultFontUrl = defaultFontUrl;
6
+ }
7
+ /** `{ id, name, bytes, faceIndex, builtIn }`, primary first. */
8
+ #fonts = [];
9
+ #nextId = 1;
10
+ get list() {
11
+ return this.#fonts.map(({ id, name, builtIn }) => ({ id, name, builtIn }));
12
+ }
13
+ /** The fonts in fallback order, for an engine to register. */
14
+ get stack() {
15
+ return this.#fonts;
16
+ }
17
+ async loadDefault() {
18
+ if (!this.defaultFontUrl)
19
+ return;
20
+ const response = await fetch(this.defaultFontUrl);
21
+ if (!response.ok)
22
+ throw new Error(`the default caption font is missing (${response.status})`);
23
+ this.#fonts.push({
24
+ id: "font-default",
25
+ name: "DejaVu Sans",
26
+ bytes: new Uint8Array(await response.arrayBuffer()),
27
+ faceIndex: 0,
28
+ builtIn: true,
29
+ });
30
+ }
31
+ /**
32
+ * Adds a font file ahead of the others, so it draws every glyph it has and the fonts after it
33
+ * fill in the rest (a Korean font before DejaVu, say).
34
+ */
35
+ async add(file) {
36
+ const bytes = new Uint8Array(await file.arrayBuffer());
37
+ const font = {
38
+ id: `font-${this.#nextId++}`,
39
+ name: file.name.replace(/\.(ttf|otf|ttc|otc)$/i, ""),
40
+ bytes,
41
+ faceIndex: 0,
42
+ builtIn: false,
43
+ };
44
+ this.#fonts.unshift(font);
45
+ return font;
46
+ }
47
+ remove(id) {
48
+ this.#fonts = this.#fonts.filter((font) => font.builtIn || font.id !== id);
49
+ }
50
+ }
51
+ /**
52
+ * Replaces an engine registry's caption fonts with `fonts`, in order. A font the engine cannot
53
+ * parse costs only itself: it is left out and every other font is still registered, the built-in
54
+ * fallbacks among them. Returns what was left out, as `{ font, error }`, for the host to report.
55
+ */
56
+ export function registerCaptionFonts(registry, fonts) {
57
+ registry.clearFonts();
58
+ const refused = [];
59
+ for (const font of fonts) {
60
+ try {
61
+ // wasm-bindgen takes the bytes by copy.
62
+ registry.addFont(font.name, font.bytes, font.faceIndex);
63
+ }
64
+ catch (error) {
65
+ refused.push({ font, error });
66
+ }
67
+ }
68
+ return refused;
69
+ }