@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,521 @@
1
+ // Importing media an extension provider reads. The provider is chosen by probing the bytes, never
2
+ // by file name; the extensions wasm module loads only when the bytes could be one of its formats.
3
+ import { MAX_LOTTIE_DOCUMENT_BYTES, MAX_PROVIDER_DOCUMENT_BYTES, MAX_SEQUENCE_FRAMES, MAX_SVG_DOCUMENT_BYTES, } from "alpha2/project";
4
+ import { EditorError } from "../../core/document.js";
5
+ const TICKS_PER_SECOND = 120_000;
6
+ /**
7
+ * Whether the start of a file could be an SVG, a GIF, an animated WebP, an APNG, a Lottie or
8
+ * dotLottie, or an OpenEXR image.
9
+ */
10
+ export function mayBeExtension(head) {
11
+ const ascii = (at, text) => [...text].every((char, index) => head[at + index] === char.charCodeAt(0));
12
+ if (ascii(0, "GIF8") || isExr(head))
13
+ return true;
14
+ if (ascii(0, "RIFF") && ascii(8, "WEBP"))
15
+ return ascii(12, "VP8X") && (head[20] & 0x02) !== 0;
16
+ if (head[0] === 0x89 && ascii(1, "PNG"))
17
+ return pngDeclaresAnimation(head);
18
+ if (ascii(0, "PK\u0003\u0004"))
19
+ return true;
20
+ const text = new TextDecoder().decode(head);
21
+ if (/^\s*\{/.test(text))
22
+ return true;
23
+ // An SVG root may follow a prolog longer than the head, so XML that starts with one is probed.
24
+ return /<svg[\s>/]/.test(text) || /^\s*<[?!]/.test(text);
25
+ }
26
+ /**
27
+ * The most bytes the engine reads of a file that could be one of the document formats a provider
28
+ * parses whole, by its start, with what the file is called: a dotLottie package (a ZIP,
29
+ * `MAX_PROVIDER_DOCUMENT_BYTES`), a Lottie animation (a JSON object, `MAX_LOTTIE_DOCUMENT_BYTES`)
30
+ * or an SVG (XML, `MAX_SVG_DOCUMENT_BYTES`); `null` for any other file. A larger file is refused
31
+ * before it is read.
32
+ */
33
+ export function providerDocumentLimit(head) {
34
+ if (head[0] === 0x50 &&
35
+ head[1] === 0x4b &&
36
+ head[2] === 0x03 &&
37
+ head[3] === 0x04)
38
+ return { limit: MAX_PROVIDER_DOCUMENT_BYTES, what: "dotLottie package" };
39
+ const text = new TextDecoder().decode(head);
40
+ if (/^\s*\{/.test(text))
41
+ return { limit: MAX_LOTTIE_DOCUMENT_BYTES, what: "Lottie file" };
42
+ if (/<svg[\s>/]/.test(text) || /^\s*<[?!]/.test(text))
43
+ return { limit: MAX_SVG_DOCUMENT_BYTES, what: "SVG document" };
44
+ return null;
45
+ }
46
+ /** Whether bytes start as an OpenEXR file does. */
47
+ function isExr(head) {
48
+ return (head[0] === 0x76 && head[1] === 0x2f && head[2] === 0x31 && head[3] === 0x01);
49
+ }
50
+ /** Whether a PNG's chunks before its image data include an animation control. */
51
+ function pngDeclaresAnimation(head) {
52
+ const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
53
+ for (let at = 8; at + 8 <= head.length;) {
54
+ const kind = String.fromCharCode(...head.subarray(at + 4, at + 8));
55
+ if (kind === "acTL")
56
+ return true;
57
+ if (kind === "IDAT")
58
+ return false;
59
+ at += 12 + view.getUint32(at);
60
+ }
61
+ return false;
62
+ }
63
+ /** The duration of a provider's timing in seconds, or 0 for a still. */
64
+ export function timingSeconds(timing) {
65
+ if (timing.kind === "frames")
66
+ return (timing.durations.reduce((sum, ticks) => sum + ticks, 0) / TICKS_PER_SECOND);
67
+ if (timing.kind === "rate")
68
+ return (timing.frames * timing.rate.den) / timing.rate.num;
69
+ return 0;
70
+ }
71
+ /** A half float's value from its bits. */
72
+ function halfValue(bits) {
73
+ const exponent = (bits >> 10) & 0x1f;
74
+ const fraction = bits & 0x3ff;
75
+ const sign = bits & 0x8000 ? -1 : 1;
76
+ if (exponent === 0)
77
+ return sign * fraction * 2 ** -24;
78
+ if (exponent === 31)
79
+ return fraction ? Number.NaN : sign * Infinity;
80
+ return sign * (1 + fraction / 1024) * 2 ** (exponent - 15);
81
+ }
82
+ /** An sRGB code, 0 to 255, of a linear value, clipped to SDR as a picture shows it. */
83
+ function srgbCode(linear) {
84
+ const value = Math.min(1, Math.max(0, linear));
85
+ const encoded = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
86
+ return Math.round(encoded * 255);
87
+ }
88
+ /** A thumbnail's width, as an image sequence's PNG thumbnail has it. */
89
+ const THUMBNAIL_WIDTH = 160;
90
+ /**
91
+ * A small preview of a rendered frame (premultiplied, sRGB RGBA8 or linear half floats) as an image
92
+ * URL, {@link THUMBNAIL_WIDTH} wide at most. Only one pixel per thumbnail pixel is converted.
93
+ */
94
+ export async function thumbnailOf(frame) {
95
+ const width = Math.min(THUMBNAIL_WIDTH, frame.width);
96
+ const height = Math.max(1, Math.min(frame.height, Math.round((width * frame.height) / frame.width)));
97
+ const straight = new Uint8ClampedArray(width * height * 4);
98
+ for (let y = 0; y < height; y += 1) {
99
+ const sourceY = Math.floor(((y + 0.5) * frame.height) / height);
100
+ for (let x = 0; x < width; x += 1) {
101
+ const sourceX = Math.floor(((x + 0.5) * frame.width) / width);
102
+ const from = (sourceY * frame.width + sourceX) * 4;
103
+ const at = (y * width + x) * 4;
104
+ if (frame.precision === "linear_f16") {
105
+ const alpha = Math.min(1, Math.max(0, halfValue(frame.pixels[from + 3])));
106
+ for (let channel = 0; channel < 3; channel += 1)
107
+ straight[at + channel] = alpha
108
+ ? srgbCode(halfValue(frame.pixels[from + channel]) / alpha)
109
+ : 0;
110
+ straight[at + 3] = Math.round(alpha * 255);
111
+ continue;
112
+ }
113
+ const alpha = frame.pixels[from + 3];
114
+ for (let channel = 0; channel < 3; channel += 1)
115
+ straight[at + channel] = alpha
116
+ ? Math.min(255, Math.round((frame.pixels[from + channel] * 255) / alpha))
117
+ : 0;
118
+ straight[at + 3] = alpha;
119
+ }
120
+ }
121
+ const canvas = new OffscreenCanvas(width, height);
122
+ canvas
123
+ .getContext("2d")
124
+ .putImageData(new ImageData(straight, width, height), 0, 0);
125
+ const blob = await canvas.convertToBlob({ type: "image/png" });
126
+ const dataUrl = await new Promise((resolve, reject) => {
127
+ const reader = new FileReader();
128
+ reader.onload = () => resolve(reader.result);
129
+ reader.onerror = () => reject(reader.error);
130
+ reader.readAsDataURL(blob);
131
+ });
132
+ return { dataUrl, width, height };
133
+ }
134
+ /**
135
+ * The provider a relinked file opens with: the defaults {@link stageProviderFile} staged it with,
136
+ * carrying the choices the project `recorded` for that source where the new file still offers them.
137
+ * A package's animation stays while the package has it; an OpenEXR still's primaries stay while the
138
+ * file is one whose primaries can be chosen.
139
+ */
140
+ export function keepProviderChoices(recorded, staged, report) {
141
+ if (!recorded || !staged || recorded.format !== staged.format)
142
+ return staged;
143
+ if (staged.format === "lottie" &&
144
+ recorded.animation !== undefined &&
145
+ report?.items?.includes(recorded.animation))
146
+ return { ...staged, animation: recorded.animation };
147
+ if (staged.format === "exr" &&
148
+ recorded.primaries !== undefined &&
149
+ report?.color)
150
+ return { ...staged, primaries: recorded.primaries };
151
+ return staged;
152
+ }
153
+ /** The provider source import records for what probing a file found. */
154
+ function providerOf(report) {
155
+ if (report.format === "svg" || report.format === "exr")
156
+ return { format: report.format };
157
+ if (report.format === "lottie" || report.format === "dot_lottie")
158
+ return {
159
+ format: "lottie",
160
+ // A package's first animation that opens, until the user picks another.
161
+ ...(report.items.length
162
+ ? { animation: report.item ?? report.items[0] }
163
+ : {}),
164
+ };
165
+ return { format: "animated_image" };
166
+ }
167
+ /**
168
+ * What import reads of a file with the provider module `extensions`: the provider that claims its
169
+ * `bytes`, what probing found, and a thumbnail of its first frame; `null` when no provider claims
170
+ * them. The source is opened as import records it, so a file that probes but does not open is
171
+ * refused here.
172
+ *
173
+ * @throws When a provider claims the bytes and cannot read them.
174
+ */
175
+ export async function readProviderFile(extensions, bytes) {
176
+ const found = extensions.probe(bytes);
177
+ if (!found)
178
+ return null;
179
+ const provider = providerOf(found.report);
180
+ const source = extensions.open(provider, bytes);
181
+ try {
182
+ return {
183
+ report: found.report,
184
+ provider,
185
+ thumbnail: await thumbnailOf(source.render(0n, { width: 160, height: 90 })),
186
+ };
187
+ }
188
+ finally {
189
+ source.release();
190
+ }
191
+ }
192
+ /**
193
+ * The diagnostics an SVG's report gives when it is opened with `fonts`, the stack its text draws
194
+ * with: which of its text no font draws (`missing_font`).
195
+ */
196
+ export function svgDiagnostics(extensions, provider, bytes, fonts) {
197
+ extensions.setFonts(fonts);
198
+ const source = extensions.open(provider, bytes);
199
+ try {
200
+ return source.report.diagnostics;
201
+ }
202
+ finally {
203
+ source.release();
204
+ }
205
+ }
206
+ /**
207
+ * Stages a file a provider claimed, from what {@link readProviderFile} read of it: a still SVG is
208
+ * an image source, an animation a video source of one cycle (repetition is the clip's).
209
+ */
210
+ export function stageProviderFile(file, { report, provider, thumbnail }, fingerprint) {
211
+ const kind = report.timing.kind === "static" ? "image" : "video";
212
+ const duration = timingSeconds(report.timing);
213
+ const id = crypto.randomUUID();
214
+ const runtime = {
215
+ id,
216
+ kind,
217
+ name: file.name,
218
+ file,
219
+ provider,
220
+ report,
221
+ width: report.width,
222
+ height: report.height,
223
+ duration,
224
+ hasAudio: false,
225
+ fit: "contain",
226
+ thumbnail,
227
+ ...(report ? { report } : {}),
228
+ };
229
+ return {
230
+ runtime,
231
+ description: {
232
+ id,
233
+ kind,
234
+ name: file.name,
235
+ type: file.type,
236
+ size: file.size,
237
+ lastModified: file.lastModified,
238
+ width: report.width,
239
+ height: report.height,
240
+ duration,
241
+ provider,
242
+ },
243
+ ...(fingerprint
244
+ ? {
245
+ fingerprint: {
246
+ ...fingerprint,
247
+ streams: { video: { width: report.width, height: report.height } },
248
+ ...(duration > 0
249
+ ? { duration_ticks: Math.round(duration * TICKS_PER_SECOND) }
250
+ : {}),
251
+ },
252
+ }
253
+ : {}),
254
+ dispose() { },
255
+ };
256
+ }
257
+ /** The frame number a file's name ends with, before its extension. */
258
+ function frameNumber(name) {
259
+ const match = /(\d+)\.[^.]+$/.exec(name);
260
+ return match ? Number(match[1]) : null;
261
+ }
262
+ /** Missing numbers a description of a sequence lists. */
263
+ const LISTED_GAPS = 5;
264
+ /**
265
+ * How numbered files make one image sequence, from their names alone: the frame number each name
266
+ * ends with, before its extension, fixes the order. Answers the first and last numbers, how many
267
+ * frames the sequence has (every number between them), how many of those no file has, and the first
268
+ * few of them; or, when the names cannot make a sequence, why: a name without a number, a number
269
+ * listed twice, a number past 2^53 - 1 (a project document cannot hold it exactly), or more numbers
270
+ * between the first and last than a sequence may have. Nothing here walks the numbers between them.
271
+ *
272
+ * @param {readonly string[]} names
273
+ * @returns {{ ok: true, first: number, last: number, frames: number, missing: number, gaps: number[] } | { ok: false, code: string, message: string }}
274
+ */
275
+ export function sequenceNumbering(names) {
276
+ const numbers = [];
277
+ for (const name of names) {
278
+ const number = frameNumber(name);
279
+ if (number === null)
280
+ return {
281
+ ok: false,
282
+ code: "sequence_numbering",
283
+ message: `${name} has no frame number`,
284
+ };
285
+ if (!Number.isSafeInteger(number))
286
+ return {
287
+ ok: false,
288
+ code: "sequence_numbering",
289
+ message: `${name}'s frame number is larger than ${Number.MAX_SAFE_INTEGER}`,
290
+ };
291
+ numbers.push(number);
292
+ }
293
+ if (numbers.length === 0)
294
+ return {
295
+ ok: false,
296
+ code: "sequence_empty",
297
+ message: "Choose the sequence's frames",
298
+ };
299
+ numbers.sort((a, b) => a - b);
300
+ for (let index = 1; index < numbers.length; index += 1)
301
+ if (numbers[index] === numbers[index - 1])
302
+ return {
303
+ ok: false,
304
+ code: "sequence_numbering",
305
+ message: `Frame ${numbers[index]} is listed twice`,
306
+ };
307
+ const first = numbers[0];
308
+ const last = numbers.at(-1);
309
+ const frames = last - first + 1;
310
+ if (frames > MAX_SEQUENCE_FRAMES)
311
+ return {
312
+ ok: false,
313
+ code: "sequence_range",
314
+ message: `Frames ${first} to ${last} are ${frames} frames; a sequence has at most ${MAX_SEQUENCE_FRAMES}`,
315
+ };
316
+ const gaps = [];
317
+ for (let index = 1; index < numbers.length && gaps.length < LISTED_GAPS; index += 1)
318
+ for (let number = numbers[index - 1] + 1; number < numbers[index] && gaps.length < LISTED_GAPS; number += 1)
319
+ gaps.push(number);
320
+ return {
321
+ ok: true,
322
+ first,
323
+ last,
324
+ frames,
325
+ missing: frames - numbers.length,
326
+ gaps,
327
+ };
328
+ }
329
+ /** A PNG's size from its header, or `null` when it is not a PNG. */
330
+ async function pngSize(file) {
331
+ const head = new Uint8Array(await file.slice(0, 24).arrayBuffer());
332
+ if (head[0] !== 0x89 || head[1] !== 0x50)
333
+ return null;
334
+ const view = new DataView(head.buffer);
335
+ return { width: view.getUint32(16), height: view.getUint32(20) };
336
+ }
337
+ /** Header bytes read for an OpenEXR file's display window. */
338
+ const EXR_HEADER_BYTES = 64 * 1024;
339
+ /**
340
+ * The most attributes read before the display window: OpenEXR writers put a few dozen; past this,
341
+ * the header is no image's.
342
+ */
343
+ const EXR_MAX_ATTRIBUTES = 1024;
344
+ /**
345
+ * An OpenEXR image's display window size from its header; `null` when it is not an OpenEXR file.
346
+ * The walk stops at an attribute that does not end past its start, so a crafted size cannot loop.
347
+ */
348
+ async function exrSize(file) {
349
+ const head = new Uint8Array(await file.slice(0, EXR_HEADER_BYTES).arrayBuffer());
350
+ if (!isExr(head))
351
+ return null;
352
+ const view = new DataView(head.buffer);
353
+ const text = (at) => {
354
+ const end = head.indexOf(0, at);
355
+ if (end < 0)
356
+ return null;
357
+ return {
358
+ value: String.fromCharCode(...head.subarray(at, end)),
359
+ next: end + 1,
360
+ };
361
+ };
362
+ for (let at = 8, attributes = 0; at < head.length && attributes < EXR_MAX_ATTRIBUTES; attributes += 1) {
363
+ const name = text(at);
364
+ if (!name || name.value === "")
365
+ return null;
366
+ const type = text(name.next);
367
+ if (!type || type.next + 4 > head.length)
368
+ return null;
369
+ const size = view.getInt32(type.next, true);
370
+ const value = type.next + 4;
371
+ if (name.value === "displayWindow" && type.value === "box2i") {
372
+ if (value + 16 > head.length)
373
+ return null;
374
+ const [xMin, yMin, xMax, yMax] = [0, 4, 8, 12].map((offset) => view.getInt32(value + offset, true));
375
+ return { width: xMax - xMin + 1, height: yMax - yMin + 1 };
376
+ }
377
+ if (size < 0)
378
+ return null;
379
+ at = value + size;
380
+ }
381
+ return null;
382
+ }
383
+ /** A still frame's format and size from its header: a PNG or an OpenEXR image. */
384
+ async function frameShape(file) {
385
+ const png = await pngSize(file);
386
+ if (png)
387
+ return { format: "png", ...png };
388
+ const exr = await exrSize(file);
389
+ if (exr)
390
+ return { format: "exr", ...exr };
391
+ return null;
392
+ }
393
+ /**
394
+ * Stages numbered PNGs or OpenEXR images as one image sequence at `frameRate`. The numbers fix the
395
+ * order and the first frame; a gap is refused unless `missing` says to hold or blank it, and every
396
+ * frame must be the first one's format and size. `openExr(file)` answers what the provider module
397
+ * makes of an OpenEXR sequence's first frame (see {@link exrFirstFrame}): its thumbnail and report.
398
+ * `id` names the source (a relink stages under the asset's own), a new one by default.
399
+ */
400
+ export async function stageSequence(files, { frameRate, missing = "reject", openExr, id = crypto.randomUUID() }) {
401
+ const numbering = sequenceNumbering(files.map((file) => file.name));
402
+ if (!numbering.ok)
403
+ throw new EditorError(numbering.code, numbering.message, "import");
404
+ const numbered = files
405
+ .map((file) => ({ file, number: frameNumber(file.name) }))
406
+ .sort((a, b) => a.number - b.number);
407
+ const { first, last } = numbering;
408
+ const size = await frameShape(numbered[0].file);
409
+ if (!size)
410
+ throw new EditorError("unsupported_media", `${numbered[0].file.name} is not a PNG or an OpenEXR image`, "import");
411
+ const kind = size.format === "exr" ? "OpenEXR image" : "PNG";
412
+ for (const { file } of numbered.slice(1)) {
413
+ const other = await frameShape(file);
414
+ if (!other ||
415
+ other.format !== size.format ||
416
+ other.width !== size.width ||
417
+ other.height !== size.height)
418
+ throw new EditorError("sequence_size", `${file.name} is not a ${size.width}x${size.height} ${kind} like the first frame`, "import");
419
+ }
420
+ if (numbering.missing > 0 && missing === "reject")
421
+ throw new EditorError("sequence_gap", `Frames ${numbering.gaps.join(", ")}${numbering.missing > numbering.gaps.length ? "..." : ""} are missing; hold or blank them to import anyway`, "import");
422
+ const locator = (number) => `browser://${id}/frame/${number}`;
423
+ // Every number from first to last is a frame; a gap is null.
424
+ const frames = new Array(numbering.frames).fill(null);
425
+ const frameFiles = new Map();
426
+ for (const { file, number } of numbered) {
427
+ frames[number - first] = locator(number);
428
+ frameFiles.set(locator(number), file);
429
+ }
430
+ const provider = {
431
+ format: "image_sequence",
432
+ frames,
433
+ frame_rate: frameRate,
434
+ start_number: first,
435
+ missing,
436
+ };
437
+ const duration = (frames.length * frameRate.den) / frameRate.num;
438
+ const stem = numbered[0].file.name.replace(/\d+\.[^.]+$/, "");
439
+ const name = `${stem}[${first}-${last}].${size.format}`;
440
+ const opened = size.format === "exr"
441
+ ? await openExr(numbered[0].file)
442
+ : { thumbnail: await pngThumbnail(numbered[0].file, size), report: null };
443
+ const { thumbnail } = opened;
444
+ // An OpenEXR sequence's color and report are its first frame's.
445
+ const report = opened.report && {
446
+ ...opened.report,
447
+ format: "exr_sequence",
448
+ timing: { kind: "rate", rate: frameRate, frames: frames.length },
449
+ };
450
+ const runtime = {
451
+ id,
452
+ kind: "video",
453
+ name,
454
+ provider,
455
+ frameFiles,
456
+ width: size.width,
457
+ height: size.height,
458
+ frameRate: frameRate.num / frameRate.den,
459
+ duration,
460
+ hasAudio: false,
461
+ fit: "contain",
462
+ thumbnail,
463
+ ...(report ? { report } : {}),
464
+ };
465
+ return {
466
+ runtime,
467
+ name,
468
+ description: {
469
+ id,
470
+ kind: "video",
471
+ name,
472
+ width: size.width,
473
+ height: size.height,
474
+ duration,
475
+ provider,
476
+ },
477
+ // A sequence records the frames' shape, rate and duration, not one file's hash or length.
478
+ fingerprint: {
479
+ streams: {
480
+ video: {
481
+ width: size.width,
482
+ height: size.height,
483
+ frame_rate: frameRate,
484
+ },
485
+ },
486
+ duration_ticks: Math.round(duration * TICKS_PER_SECOND),
487
+ },
488
+ dispose() { },
489
+ };
490
+ }
491
+ /** A PNG frame's thumbnail, {@link THUMBNAIL_WIDTH} pixels wide. */
492
+ async function pngThumbnail(file, size) {
493
+ const bitmap = await createImageBitmap(file);
494
+ const canvas = new OffscreenCanvas(THUMBNAIL_WIDTH, Math.max(1, Math.round((THUMBNAIL_WIDTH * size.height) / size.width)));
495
+ canvas.getContext("2d").drawImage(bitmap, 0, 0, canvas.width, canvas.height);
496
+ bitmap.close();
497
+ const blob = await canvas.convertToBlob({ type: "image/png" });
498
+ const dataUrl = await new Promise((resolve, reject) => {
499
+ const reader = new FileReader();
500
+ reader.onload = () => resolve(reader.result);
501
+ reader.onerror = () => reject(reader.error);
502
+ reader.readAsDataURL(blob);
503
+ });
504
+ return { dataUrl, width: canvas.width, height: canvas.height };
505
+ }
506
+ /**
507
+ * An OpenEXR frame's thumbnail, sampled from the frame the provider module `extensions` renders at
508
+ * its own size from `bytes`, and what the provider reports of it.
509
+ */
510
+ export async function exrFirstFrame(extensions, bytes) {
511
+ const source = extensions.open({ format: "exr" }, bytes);
512
+ try {
513
+ return {
514
+ thumbnail: await thumbnailOf(source.render(0n, { width: 160, height: 90 })),
515
+ report: source.report,
516
+ };
517
+ }
518
+ finally {
519
+ source.release();
520
+ }
521
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The fingerprint of `file` as media of `kind`. `size` is an image's decoded size; video and audio
3
+ * are probed. `hash` makes the content hash, `sha256Blob` on this thread unless the caller hashes
4
+ * elsewhere (`hashInWorker`).
5
+ * @param {File} file
6
+ * @param {string} kind
7
+ * @param {{ signal?: AbortSignal, onProgress?: (bytes: number) => void, size?: { width: number, height: number }, hash?: (file: Blob, options: { signal?: AbortSignal, onProgress?: (bytes: number) => void }) => Promise<string> }} [options]
8
+ * @returns {Promise<import("alpha2/project").MediaFingerprint>}
9
+ */
10
+ export function fingerprintFile(file: File, kind: string, options?: {
11
+ signal?: AbortSignal;
12
+ onProgress?: (bytes: number) => void;
13
+ size?: {
14
+ width: number;
15
+ height: number;
16
+ };
17
+ hash?: (file: Blob, options: {
18
+ signal?: AbortSignal;
19
+ onProgress?: (bytes: number) => void;
20
+ }) => Promise<string>;
21
+ }): Promise<import("alpha2/project").MediaFingerprint>;
22
+ /** What the demuxer sees: the primary streams' shapes, the duration, and a declared color. */
23
+ export function probeEvidence(file: any): Promise<{
24
+ color?: import("alpha2/project").ColorMetadata | undefined;
25
+ duration_ticks?: number | undefined;
26
+ streams?: {
27
+ audio: {
28
+ channels: number;
29
+ sample_rate: number;
30
+ };
31
+ } | undefined;
32
+ }>;
33
+ /**
34
+ * The size a video track is shown at, in square pixels and turned as it is shown, as every host
35
+ * finds it: the sample aspect ratio is the container's when it declares one that is not square,
36
+ * else the one the H.264 or HEVC parameter sets signal (FFmpeg's `av_guess_sample_aspect_ratio`);
37
+ * mediabunny reads only the container's. The stretch is the engine's
38
+ * (`SampleAspectRatio::square_pixel_size`): the longer side of a sample grows, rounded to the
39
+ * nearest pixel.
40
+ * @param {import("mediabunny").InputVideoTrack} track
41
+ * @returns {Promise<{ width: number, height: number }>}
42
+ */
43
+ export function displaySize(track: import("mediabunny").InputVideoTrack): Promise<{
44
+ width: number;
45
+ height: number;
46
+ }>;
47
+ /**
48
+ * A measured frame rate as the rational a stream declares: the NTSC rates and whole rates exactly,
49
+ * others to a thousandth (the engine's `alpha2_assets::rational_rate` is its twin). `null` when the
50
+ * measure is no rate a project holds (so slow it rounds to no frames, or a frame shorter than a
51
+ * tick, which only broken timestamps give): the fingerprint then records no rate rather than one
52
+ * the engine refuses.
53
+ */
54
+ export function rationalRate(rate: any): {
55
+ num: number;
56
+ den: number;
57
+ } | null;