@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,633 @@
1
+ // Media library: import, probe, and keep browser-side resources for each source. The engine only
2
+ // learns that a source exists (`browser://<id>`); this module answers its per-frame requests.
3
+ import { colorMetadataFromVideoColorSpace } from "alpha2/hdr-frames";
4
+ import { OffscreenTextRasterizer } from "alpha2/text";
5
+ import { ALL_FORMATS, AudioSampleSink, BlobSource, Input } from "mediabunny";
6
+ const uid = (prefix) => `${prefix}-${crypto.randomUUID()}`;
7
+ import { drawGenerator, generatorLabel } from "./generators.js";
8
+ import { seekVideoFrame } from "./preview-utils.js";
9
+ import { decodeStill } from "./still.js";
10
+ import { EditorError } from "../../core/document.js";
11
+ import { TEXT_DEFAULTS } from "../../core/text.js";
12
+ import { displaySize } from "./fingerprint.js";
13
+ const THUMBNAIL_WIDTH = 168;
14
+ export const IMAGE_TYPES = [
15
+ "image/png",
16
+ "image/jpeg",
17
+ "image/webp",
18
+ "image/gif",
19
+ "image/avif",
20
+ ];
21
+ export const VIDEO_TYPES = [
22
+ "video/mp4",
23
+ "video/webm",
24
+ "video/quicktime",
25
+ "video/x-matroska",
26
+ ];
27
+ export const AUDIO_TYPES = [
28
+ "audio/mpeg",
29
+ "audio/mp4",
30
+ "audio/wav",
31
+ "audio/x-wav",
32
+ "audio/ogg",
33
+ "audio/flac",
34
+ "audio/aac",
35
+ ];
36
+ export const ACCEPTED_TYPES = [
37
+ ...VIDEO_TYPES,
38
+ ...IMAGE_TYPES,
39
+ ...AUDIO_TYPES,
40
+ ].join(",");
41
+ /** A title's style where its authoring leaves a field out (`TEXT_DEFAULTS`, exported from core). */
42
+ export const DEFAULT_TEXT = TEXT_DEFAULTS;
43
+ export class AssetLibrary {
44
+ #assets = new Map();
45
+ #urls = [];
46
+ #rasterizer = new OffscreenTextRasterizer();
47
+ #textVersion = 1n;
48
+ get map() {
49
+ return this.#assets;
50
+ }
51
+ get all() {
52
+ return [...this.#assets.values()];
53
+ }
54
+ get(id) {
55
+ return this.#assets.get(id) ?? null;
56
+ }
57
+ /** Metadata persisted in an editor project. Media bytes stay in local files. */
58
+ projectManifest() {
59
+ return this.all.map((asset) => ({
60
+ id: asset.id,
61
+ kind: asset.kind,
62
+ name: asset.name,
63
+ locator: asset.locator ?? localLocator(asset.file, asset.name),
64
+ type: asset.file?.type ?? asset.type ?? "",
65
+ size: asset.file?.size ?? asset.size ?? null,
66
+ lastModified: asset.file?.lastModified ?? asset.lastModified ?? null,
67
+ width: asset.width ?? null,
68
+ height: asset.height ?? null,
69
+ frameRate: asset.frameRate ?? null,
70
+ duration: asset.duration ?? 0,
71
+ hasAudio: Boolean(asset.hasAudio ?? asset.audioBuffer),
72
+ fit: asset.fit ?? null,
73
+ colorMetadata: asset.colorMetadata ?? null,
74
+ text: asset.kind === "text" ? structuredClone(asset.text) : null,
75
+ generator: asset.kind === "generated" ? structuredClone(asset.generator) : null,
76
+ }));
77
+ }
78
+ /** Replaces the library with embedded text assets and missing local-file references. */
79
+ restoreProjectManifest(manifest) {
80
+ this.clear();
81
+ for (const reference of manifest) {
82
+ if (reference.kind === "text" && reference.text) {
83
+ this.createText(reference.text, { id: reference.id });
84
+ continue;
85
+ }
86
+ if (reference.kind === "generated" && reference.generator) {
87
+ this.createGenerated(reference.generator, {
88
+ id: reference.id,
89
+ name: reference.name,
90
+ });
91
+ continue;
92
+ }
93
+ this.#assets.set(reference.id, {
94
+ ...reference,
95
+ missing: true,
96
+ thumbnail: null,
97
+ peaks: null,
98
+ audioBuffer: null,
99
+ });
100
+ }
101
+ return this.all.filter((asset) => asset.missing);
102
+ }
103
+ get missing() {
104
+ return this.all.filter((asset) => asset.missing);
105
+ }
106
+ /** Imports a non-media resource consumed by an engine effect. */
107
+ async importEffectResource(file, kind) {
108
+ if (kind !== "lut3d" || !file.name.toLowerCase().endsWith(".cube")) {
109
+ throw new Error("this effect slot requires a .cube 3D LUT file");
110
+ }
111
+ const reference = this.#matchingReference(file, kind);
112
+ const asset = {
113
+ id: reference?.id ?? uid("fxr"),
114
+ kind,
115
+ name: reference?.name ?? file.name,
116
+ locator: reference?.locator ?? localLocator(file, file.name),
117
+ file,
118
+ text: await file.text(),
119
+ };
120
+ this.#assets.set(asset.id, asset);
121
+ return asset;
122
+ }
123
+ usedBy(doc) {
124
+ return new Set(doc.clips.map((clip) => clip.assetId));
125
+ }
126
+ remove(id) {
127
+ const asset = this.#assets.get(id);
128
+ if (!asset)
129
+ return;
130
+ asset.video?.removeAttribute("src");
131
+ this.#assets.delete(id);
132
+ }
133
+ /**
134
+ * Imports browser files; each one that does not import is reported back with its name and why
135
+ * (see {@link importFile}).
136
+ */
137
+ async importFiles(files, onProgress) {
138
+ const imported = [];
139
+ const rejected = [];
140
+ const relinked = [];
141
+ const list = [...files];
142
+ for (const [index, file] of list.entries()) {
143
+ onProgress?.(index, list.length, file.name);
144
+ try {
145
+ const { asset, relinked: reference } = await this.#import(file);
146
+ imported.push(asset);
147
+ if (reference)
148
+ relinked.push(asset.id);
149
+ }
150
+ catch (error) {
151
+ rejected.push({ name: file.name, error });
152
+ }
153
+ }
154
+ return { imported, rejected, relinked };
155
+ }
156
+ /**
157
+ * Imports one browser file as an image, video or audio asset. Rejects with an `EditorError`:
158
+ * `unsupported_media` for a file that is none of those, `unreadable_media` for one that is but
159
+ * that this browser could not read (a codec it does not decode, a damaged file), with the
160
+ * decoder's or demuxer's reason in the message and as the `cause`.
161
+ */
162
+ async importFile(file) {
163
+ return (await this.#import(file)).asset;
164
+ }
165
+ async #import(file) {
166
+ const kind = mediaKindForFile(file);
167
+ if (!kind)
168
+ throw new EditorError("unsupported_media", `Cannot import ${file.name}: not an image, video or audio file`, "import");
169
+ let as = kind;
170
+ try {
171
+ // Probe the container first: browsers type by extension, so an audio-only .webm is `video/*`.
172
+ const probe = kind === "video" ? await probeVideo(file) : null;
173
+ if (probe && !probe.video && probe.hasAudio)
174
+ as = "audio";
175
+ const reference = this.#matchingReference(file, as);
176
+ const asset = as === "image"
177
+ ? await this.#importImage(file, reference)
178
+ : as === "video"
179
+ ? await this.#importVideo(file, reference, probe)
180
+ : await this.#importAudio(file, reference);
181
+ return { asset, relinked: !!reference };
182
+ }
183
+ catch (error) {
184
+ // A media element's MediaError is no Error, but says why in `message` too.
185
+ const reason = typeof error?.message === "string"
186
+ ? error.message
187
+ : String(error ?? "");
188
+ throw new EditorError("unreadable_media", `Cannot read ${file.name} as ${as}: ${reason || "this browser could not decode it"}`, "import", undefined, true, { cause: error });
189
+ }
190
+ }
191
+ /** A still keeps its file, size, and thumbnail; `SourcePixels` decodes it again per frame. */
192
+ async #importImage(file, reference = null) {
193
+ const bitmap = await decodeStill(file);
194
+ let width, height, thumbnail;
195
+ try {
196
+ ({ width, height } = bitmap);
197
+ thumbnail = thumbnailFrom(bitmap, width, height);
198
+ }
199
+ finally {
200
+ bitmap.close();
201
+ }
202
+ const asset = {
203
+ id: reference?.id ?? uid("src"),
204
+ kind: "image",
205
+ name: reference?.name ?? file.name,
206
+ locator: reference?.locator ?? localLocator(file, file.name),
207
+ file,
208
+ width,
209
+ height,
210
+ duration: 0,
211
+ fit: "contain",
212
+ thumbnail,
213
+ };
214
+ this.#assets.set(asset.id, asset);
215
+ return asset;
216
+ }
217
+ /** `probe` is null when the container was unreadable; an element with no picture is refused. */
218
+ async #importVideo(file, reference = null, probe = null) {
219
+ if (probe && !probe.video)
220
+ throw new Error("the file has neither a picture nor sound");
221
+ const url = URL.createObjectURL(file);
222
+ this.#urls.push(url);
223
+ const video = document.createElement("video");
224
+ Object.assign(video, {
225
+ src: url,
226
+ muted: true,
227
+ playsInline: true,
228
+ preload: "auto",
229
+ loop: false,
230
+ });
231
+ await new Promise((resolve, reject) => {
232
+ video.onloadeddata = resolve;
233
+ video.onerror = () => reject(video.error ?? new Error("video decode failed"));
234
+ });
235
+ if (!probe && !video.videoWidth)
236
+ throw new Error("the browser found no picture in it");
237
+ const read = probe?.video ?? {
238
+ width: video.videoWidth,
239
+ height: video.videoHeight,
240
+ frameRate: 30,
241
+ colorMetadata: null,
242
+ };
243
+ // A chunked WebM (MediaRecorder timeslice) has no Duration and the element reports +Infinity.
244
+ const duration = Number.isFinite(video.duration)
245
+ ? video.duration
246
+ : ((await containerDuration(file)) ?? 0);
247
+ const asset = {
248
+ id: reference?.id ?? uid("src"),
249
+ kind: "video",
250
+ name: reference?.name ?? file.name,
251
+ locator: reference?.locator ?? localLocator(file, file.name),
252
+ file,
253
+ url,
254
+ video,
255
+ width: read.width,
256
+ height: read.height,
257
+ frameRate: read.frameRate,
258
+ duration,
259
+ // Having audio is the document's fact; whether this browser decodes it is the session's.
260
+ hasAudio: probe?.hasAudio ?? false,
261
+ audioDecodable: probe?.audioDecodable ?? false,
262
+ colorMetadata: read.colorMetadata,
263
+ fit: "contain",
264
+ thumbnail: null,
265
+ peaks: null,
266
+ };
267
+ asset.thumbnail = await videoThumbnail(video, read.width, read.height);
268
+ this.#assets.set(asset.id, asset);
269
+ return asset;
270
+ }
271
+ async #importAudio(file, reference = null) {
272
+ const { duration, decodable } = await probeAudio(file);
273
+ const asset = {
274
+ id: reference?.id ?? uid("src"),
275
+ kind: "audio",
276
+ name: reference?.name ?? file.name,
277
+ locator: reference?.locator ?? localLocator(file, file.name),
278
+ file,
279
+ duration,
280
+ hasAudio: true,
281
+ audioDecodable: decodable,
282
+ peaks: null,
283
+ thumbnail: null,
284
+ };
285
+ this.#assets.set(asset.id, asset);
286
+ return asset;
287
+ }
288
+ /** Registers a project-rate audio buffer synthesized in the page rather than read from a file. */
289
+ addGeneratedAudio(name, audioBuffer) {
290
+ const asset = {
291
+ id: uid("src"),
292
+ kind: "audio",
293
+ name,
294
+ generated: true,
295
+ duration: audioBuffer.duration,
296
+ hasAudio: true,
297
+ audioBuffer,
298
+ peaks: computePeaks(audioBuffer),
299
+ thumbnail: null,
300
+ };
301
+ this.#assets.set(asset.id, asset);
302
+ return asset;
303
+ }
304
+ /**
305
+ * Creates a text source rasterized by the packaged `OffscreenTextRasterizer`, the browser host
306
+ * for the engine's host-rasterized text contract.
307
+ */
308
+ createText(style = {}, { id = uid("src") } = {}) {
309
+ const text = { ...DEFAULT_TEXT, ...style };
310
+ const asset = {
311
+ id,
312
+ kind: "text",
313
+ name: text.content.split("\n")[0].slice(0, 28) || "Text",
314
+ text,
315
+ duration: 0,
316
+ fit: "native",
317
+ raster: null,
318
+ thumbnail: null,
319
+ };
320
+ this.#assets.set(asset.id, asset);
321
+ this.rasterizeText(asset, text);
322
+ return asset;
323
+ }
324
+ /**
325
+ * Creates a source the engine draws itself from `generator` (see `generators.js`); it needs no
326
+ * file and has no duration.
327
+ */
328
+ createGenerated(generator, { id = uid("gen"), name = null } = {}) {
329
+ const asset = {
330
+ id,
331
+ kind: "generated",
332
+ name: name ?? generatorLabel(generator.kind),
333
+ generator: structuredClone(generator),
334
+ duration: 0,
335
+ thumbnail: null,
336
+ };
337
+ asset.thumbnail = generatedThumbnail(asset.generator);
338
+ this.#assets.set(asset.id, asset);
339
+ return asset;
340
+ }
341
+ updateGenerated(asset, generator) {
342
+ const renamed = asset.name === generatorLabel(asset.generator.kind);
343
+ asset.generator = structuredClone(generator);
344
+ if (renamed)
345
+ asset.name = generatorLabel(generator.kind);
346
+ asset.thumbnail = generatedThumbnail(asset.generator);
347
+ }
348
+ rasterizeText(asset, text) {
349
+ asset.text = { ...asset.text, ...text };
350
+ asset.name = asset.text.content.split("\n")[0].slice(0, 28) || "Text";
351
+ this.#textVersion += 1n;
352
+ const raster = this.#rasterizer.rasterize({
353
+ text: asset.text.content || " ",
354
+ contentVersion: this.#textVersion,
355
+ fontFamilies: asset.text.fontFamily
356
+ .split(",")
357
+ .map((family) => family.trim()),
358
+ fontSize: Math.max(4, asset.text.fontSize),
359
+ maxWidth: asset.text.maxWidth > 0 ? asset.text.maxWidth : undefined,
360
+ lineHeight: asset.text.lineHeight,
361
+ align: asset.text.align,
362
+ color: asset.text.color,
363
+ });
364
+ asset.raster = raster;
365
+ asset.width = raster.texture.width;
366
+ asset.height = raster.texture.height;
367
+ asset.thumbnail = textThumbnail(raster);
368
+ return asset;
369
+ }
370
+ dispose() {
371
+ this.clear();
372
+ this.#rasterizer.close();
373
+ }
374
+ clear() {
375
+ for (const url of this.#urls)
376
+ URL.revokeObjectURL(url);
377
+ this.#urls = [];
378
+ this.#assets.clear();
379
+ }
380
+ #matchingReference(file, kind) {
381
+ const candidates = this.missing.filter((asset) => asset.kind === kind);
382
+ const path = file.webkitRelativePath || file.name;
383
+ return (candidates.find((asset) => asset.locator === path) ??
384
+ candidates.find((asset) => asset.name === file.name && matchesFileIdentity(asset, file)) ??
385
+ candidates.find((asset) => basename(asset.locator) === file.name));
386
+ }
387
+ }
388
+ function localLocator(file, fallback) {
389
+ return file?.webkitRelativePath || file?.name || fallback || "";
390
+ }
391
+ function basename(locator) {
392
+ return String(locator ?? "")
393
+ .replaceAll("\\", "/")
394
+ .split("/")
395
+ .at(-1);
396
+ }
397
+ function matchesFileIdentity(reference, file) {
398
+ const sizeMatches = reference.size === null ||
399
+ reference.size === undefined ||
400
+ reference.size === file.size;
401
+ const modifiedMatches = reference.lastModified === null ||
402
+ reference.lastModified === undefined ||
403
+ reference.lastModified === file.lastModified;
404
+ return sizeMatches && modifiedMatches;
405
+ }
406
+ function mediaKindForFile(file) {
407
+ if (file.type.startsWith("image/"))
408
+ return "image";
409
+ if (file.type.startsWith("video/"))
410
+ return "video";
411
+ if (file.type.startsWith("audio/"))
412
+ return "audio";
413
+ const extension = file.name.toLowerCase().split(".").at(-1);
414
+ if (["png", "jpg", "jpeg", "webp", "gif", "avif"].includes(extension))
415
+ return "image";
416
+ if (["mp4", "webm", "mov", "mkv"].includes(extension))
417
+ return "video";
418
+ if (["mp3", "m4a", "wav", "ogg", "flac", "aac"].includes(extension))
419
+ return "audio";
420
+ return null;
421
+ }
422
+ /**
423
+ * What a video container holds, read by the demuxer: its picture (`video`: size, frame rate, an HDR
424
+ * declaration), or null when it has no video track, and whether it has an audio stream. Null when
425
+ * the demuxer cannot read the container.
426
+ */
427
+ export async function probeVideo(file) {
428
+ const input = new Input({
429
+ source: new BlobSource(file),
430
+ formats: ALL_FORMATS,
431
+ });
432
+ try {
433
+ const track = await input.getPrimaryVideoTrack();
434
+ const audio = await input.getPrimaryAudioTrack();
435
+ let video = null;
436
+ if (track) {
437
+ const stats = await track.computePacketStats(120).catch(() => null);
438
+ const space = await track.getColorSpace().catch(() => null);
439
+ const shown = await displaySize(track);
440
+ video = {
441
+ // Only an HDR declaration is recorded, so the project knows the source needs the tone map.
442
+ colorMetadata: space?.transfer === "pq" || space?.transfer === "hlg"
443
+ ? colorMetadataFromVideoColorSpace(space)
444
+ : null,
445
+ width: shown.width,
446
+ height: shown.height,
447
+ frameRate: stats?.averagePacketRate ?? 30,
448
+ };
449
+ }
450
+ return {
451
+ video,
452
+ hasAudio: audio !== null,
453
+ audioDecodable: Boolean(audio && (await audio.canDecode().catch(() => false))),
454
+ };
455
+ }
456
+ catch (error) {
457
+ console.warn("probe fell back to HTMLVideoElement metadata", error);
458
+ return null;
459
+ }
460
+ finally {
461
+ input.dispose();
462
+ }
463
+ }
464
+ /** A container's length in seconds, as its last packet ends; null when it cannot be read. */
465
+ async function containerDuration(file) {
466
+ const input = new Input({
467
+ source: new BlobSource(file),
468
+ formats: ALL_FORMATS,
469
+ });
470
+ try {
471
+ const seconds = await input.computeDuration();
472
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
473
+ }
474
+ catch {
475
+ return null;
476
+ }
477
+ finally {
478
+ input.dispose();
479
+ }
480
+ }
481
+ /**
482
+ * An audio file's duration, and whether this browser can decode it. A file this browser cannot
483
+ * decode (Safari before 26 has no `AudioDecoder`) still imports: the document records the audio,
484
+ * which a native host and another browser hear.
485
+ */
486
+ async function probeAudio(file) {
487
+ const input = new Input({
488
+ source: new BlobSource(file),
489
+ formats: ALL_FORMATS,
490
+ });
491
+ try {
492
+ const track = await input.getPrimaryAudioTrack();
493
+ if (!track)
494
+ throw new Error("media has no audio track");
495
+ const decodable = await track.canDecode().catch(() => false);
496
+ const duration = (await track.getDurationFromMetadata().catch(() => null)) ??
497
+ (decodable ? await track.computeDuration() : 0);
498
+ return { duration, decodable };
499
+ }
500
+ finally {
501
+ input.dispose();
502
+ }
503
+ }
504
+ /**
505
+ * Streams a file's primary audio track into per-bucket peak magnitudes (`bucketsPerSecond`, capped
506
+ * at `maxBuckets`) with bounded memory. A bucket's peak is its loudest sample in any channel,
507
+ * since decoders order channels differently. `null` without audio.
508
+ */
509
+ export async function streamPeaks(file, { signal, bucketsPerSecond = 100, maxBuckets = 360_000 } = {}) {
510
+ const input = new Input({
511
+ source: new BlobSource(file),
512
+ formats: ALL_FORMATS,
513
+ });
514
+ try {
515
+ const track = await input.getPrimaryAudioTrack();
516
+ if (!track || !(await track.canDecode()))
517
+ return null;
518
+ const duration = (await track.getDurationFromMetadata().catch(() => null)) ??
519
+ (await track.computeDuration());
520
+ if (!duration)
521
+ return null;
522
+ const buckets = Math.max(1, Math.min(maxBuckets, Math.ceil(duration * bucketsPerSecond)));
523
+ const peaks = new Float32Array(buckets);
524
+ const scale = buckets / duration;
525
+ let plane = new Float32Array(0);
526
+ for await (const sample of new AudioSampleSink(track).samples()) {
527
+ try {
528
+ signal?.throwIfAborted();
529
+ const frames = sample.numberOfFrames;
530
+ if (plane.length < frames)
531
+ plane = new Float32Array(frames);
532
+ for (let c = 0; c < sample.numberOfChannels; c++) {
533
+ sample.copyTo(plane, { planeIndex: c, format: "f32-planar" });
534
+ const start = sample.timestamp;
535
+ const step = 1 / sample.sampleRate;
536
+ for (let i = 0; i < frames; i++) {
537
+ const b = Math.floor((start + i * step) * scale);
538
+ if (b < 0 || b >= buckets)
539
+ continue;
540
+ const v = Math.abs(plane[i]);
541
+ if (v > peaks[b])
542
+ peaks[b] = v;
543
+ }
544
+ }
545
+ }
546
+ finally {
547
+ sample.close();
548
+ }
549
+ }
550
+ return peaks;
551
+ }
552
+ finally {
553
+ input.dispose();
554
+ }
555
+ }
556
+ /** Per-bucket peak magnitudes used to draw clip waveforms. */
557
+ export function computePeaks(buffer, buckets = 1_200) {
558
+ const channel = buffer.getChannelData(0);
559
+ const second = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : channel;
560
+ const peaks = new Float32Array(buckets);
561
+ const span = Math.max(1, Math.floor(channel.length / buckets));
562
+ for (let bucket = 0; bucket < buckets; bucket += 1) {
563
+ const start = bucket * span;
564
+ let peak = 0;
565
+ for (let index = start; index < Math.min(start + span, channel.length); index += 1) {
566
+ peak = Math.max(peak, Math.abs(channel[index]), Math.abs(second[index]));
567
+ }
568
+ peaks[bucket] = peak;
569
+ }
570
+ return peaks;
571
+ }
572
+ function thumbnailFrom(image, width, height) {
573
+ // Rendered once at import; the timeline re-reads `dataUrl` on every redraw.
574
+ const scale = THUMBNAIL_WIDTH / Math.max(1, width);
575
+ const canvas = document.createElement("canvas");
576
+ canvas.width = THUMBNAIL_WIDTH;
577
+ canvas.height = Math.max(1, Math.round(height * scale));
578
+ canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
579
+ canvas.dataUrl = canvas.toDataURL("image/jpeg", 0.72);
580
+ return canvas;
581
+ }
582
+ async function videoThumbnail(video, width, height) {
583
+ const clone = video.cloneNode(true);
584
+ clone.muted = true;
585
+ try {
586
+ await new Promise((resolve, reject) => {
587
+ clone.onloadeddata = resolve;
588
+ clone.onerror = () => reject(clone.error);
589
+ clone.load();
590
+ });
591
+ await seekVideoFrame(clone, Math.min(0.25 * (clone.duration || 1), 1));
592
+ return thumbnailFrom(clone, width, height);
593
+ }
594
+ catch {
595
+ return null;
596
+ }
597
+ finally {
598
+ clone.removeAttribute("src");
599
+ }
600
+ }
601
+ function generatedThumbnail(generator) {
602
+ const canvas = document.createElement("canvas");
603
+ canvas.width = THUMBNAIL_WIDTH;
604
+ canvas.height = Math.round((THUMBNAIL_WIDTH * 9) / 16);
605
+ drawGenerator(canvas.getContext("2d"), generator, canvas.width, canvas.height);
606
+ canvas.dataUrl = canvas.toDataURL("image/png");
607
+ return canvas;
608
+ }
609
+ function textThumbnail(raster) {
610
+ const canvas = document.createElement("canvas");
611
+ canvas.width = THUMBNAIL_WIDTH;
612
+ canvas.height = Math.max(1, Math.round((raster.texture.height / raster.texture.width) * THUMBNAIL_WIDTH));
613
+ const context = canvas.getContext("2d");
614
+ const source = new ImageData(new Uint8ClampedArray(unpremultiply(raster.rgba8)), raster.texture.width, raster.texture.height);
615
+ const staging = new OffscreenCanvas(raster.texture.width, raster.texture.height);
616
+ staging.getContext("2d").putImageData(source, 0, 0);
617
+ context.drawImage(staging, 0, 0, canvas.width, canvas.height);
618
+ canvas.dataUrl = canvas.toDataURL("image/png");
619
+ return canvas;
620
+ }
621
+ /** `putImageData` expects straight alpha; the rasterizer emits premultiplied. */
622
+ function unpremultiply(pixels) {
623
+ const output = new Uint8ClampedArray(pixels.length);
624
+ for (let index = 0; index < pixels.length; index += 4) {
625
+ const alpha = pixels[index + 3];
626
+ const scale = alpha === 0 ? 0 : 255 / alpha;
627
+ output[index] = pixels[index] * scale;
628
+ output[index + 1] = pixels[index + 1] * scale;
629
+ output[index + 2] = pixels[index + 2] * scale;
630
+ output[index + 3] = alpha;
631
+ }
632
+ return output;
633
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Sources that contribute to the mix, paired with their browser-side asset.
3
+ *
4
+ * A video file with a sound track counts: it declares `has_audio`, so its clip on a video track is
5
+ * mixed without a second clip on an audio lane.
6
+ */
7
+ export function audibleSources(project: any, library: any): {
8
+ id: any;
9
+ asset: any;
10
+ }[];
11
+ /** Mixes one block through the engine, returning planar channels. */
12
+ export function mixBlock(engine: any, startSample: any, frames: any, playing?: boolean): any[];
13
+ /** Sources the most recent mix could not read, as reported by the engine. */
14
+ export function missingAudioSources(engine: any): any;
15
+ /**
16
+ * The sample playback from `tick` starts at: the first one at or after it. The audio clock reports
17
+ * the tick at or before the sample heard, so rounding to nearest could be heard a frame early.
18
+ */
19
+ export function playbackStartSample(tick: any, sampleRate: any): number;
20
+ /**
21
+ * Supplies bounded source windows requested by the engine. Page-synthesized audio keeps its
22
+ * AudioBuffer, but only the requested slice enters wasm.
23
+ */
24
+ export class AudioWindowSupplier {
25
+ constructor(library: any);
26
+ configure(engine: any, project: any): void;
27
+ supply(startSample: any, frames: any, { signal, forceSources }?: {
28
+ forceSources?: never[] | undefined;
29
+ }): Promise<void>;
30
+ close(): Promise<void>;
31
+ #private;
32
+ }
33
+ /**
34
+ * Preview transport for engine-mixed audio. A timer keeps the shared ring ahead of the audio
35
+ * thread; the worklet applies no second master chain.
36
+ */
37
+ export class EngineAudioMixer {
38
+ constructor(runtimeAssets?: {}, { enabled }?: {
39
+ enabled?: boolean | undefined;
40
+ });
41
+ runtimeAssets: {};
42
+ get tick(): number | null;
43
+ volume: number;
44
+ get supported(): boolean;
45
+ get context(): null;
46
+ /**
47
+ * Associates a loaded project with the preview mixer. Safe to call again.
48
+ *
49
+ * Worklet creation is deferred to `resume()`: a suspended AudioContext may not construct an
50
+ * AudioWorkletProcessor until a user gesture.
51
+ */
52
+ attach(engine: any, project: any, supplier: any, onUnderrun: any, onError: any): Promise<boolean>;
53
+ setMasterVolume(value: any): void;
54
+ resume(): Promise<void>;
55
+ /** Starts pumping mixed audio from timeline tick `fromTick`, a non-negative integer. */
56
+ start(fromTick: any, rate?: number): Promise<void>;
57
+ stop(): void;
58
+ close(): Promise<void>;
59
+ #private;
60
+ }