@alpha2-sh/editor 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -1
- package/dist/browser/bridge.d.ts +44 -0
- package/dist/browser/bridge.js +2176 -0
- package/dist/browser/extensions-worker-entry.js +3 -0
- package/dist/browser/extensions-worker.d.ts +46 -0
- package/dist/browser/extensions-worker.js +149 -0
- package/dist/browser/hash-worker-entry.d.ts +1 -0
- package/dist/browser/hash-worker-entry.js +3 -0
- package/dist/browser/hash-worker.d.ts +24 -0
- package/dist/browser/hash-worker.js +87 -0
- package/dist/browser/index.d.ts +11 -0
- package/dist/browser/index.js +9 -0
- package/dist/browser/jobs.d.ts +82 -0
- package/dist/browser/jobs.js +291 -0
- package/dist/browser/resources.d.ts +40 -0
- package/dist/browser/resources.js +83 -0
- package/dist/browser/runtime/assets.d.ts +233 -0
- package/dist/browser/runtime/assets.js +633 -0
- package/dist/browser/runtime/audio.d.ts +60 -0
- package/dist/browser/runtime/audio.js +549 -0
- package/dist/browser/runtime/budget.d.ts +11 -0
- package/dist/browser/runtime/budget.js +26 -0
- package/dist/browser/runtime/caption-fonts.d.ts +34 -0
- package/dist/browser/runtime/caption-fonts.js +69 -0
- package/dist/browser/runtime/color.d.ts +7 -0
- package/dist/browser/runtime/color.js +12 -0
- package/dist/browser/runtime/engine.d.ts +91 -0
- package/dist/browser/runtime/engine.js +352 -0
- package/dist/browser/runtime/export-audio.d.ts +23 -0
- package/dist/browser/runtime/export-audio.js +75 -0
- package/dist/browser/runtime/exporter.d.ts +140 -0
- package/dist/browser/runtime/exporter.js +751 -0
- package/dist/browser/runtime/extension-import.d.ts +196 -0
- package/dist/browser/runtime/extension-import.js +521 -0
- package/dist/browser/runtime/fingerprint.d.ts +57 -0
- package/dist/browser/runtime/fingerprint.js +157 -0
- package/dist/browser/runtime/generators.d.ts +188 -0
- package/dist/browser/runtime/generators.js +173 -0
- package/dist/browser/runtime/locks.d.ts +32 -0
- package/dist/browser/runtime/locks.js +86 -0
- package/dist/browser/runtime/output.d.ts +87 -0
- package/dist/browser/runtime/output.js +230 -0
- package/dist/browser/runtime/preview-utils.d.ts +3 -0
- package/dist/browser/runtime/preview-utils.js +49 -0
- package/dist/browser/runtime/proxies.d.ts +72 -0
- package/dist/browser/runtime/proxies.js +369 -0
- package/dist/browser/runtime/proxy-storage.d.ts +59 -0
- package/dist/browser/runtime/proxy-storage.js +119 -0
- package/dist/browser/runtime/sources.d.ts +82 -0
- package/dist/browser/runtime/sources.js +819 -0
- package/dist/browser/runtime/staging.d.ts +10 -0
- package/dist/browser/runtime/staging.js +32 -0
- package/dist/browser/runtime/still.d.ts +21 -0
- package/dist/browser/runtime/still.js +52 -0
- package/dist/browser/runtime/wasm.d.ts +9 -0
- package/dist/browser/runtime/wasm.js +20 -0
- package/dist/browser/types.d.ts +549 -0
- package/dist/browser/types.js +1 -0
- package/dist/browser/worker-entry.d.ts +1 -0
- package/dist/browser/worker-entry.js +2 -0
- package/dist/browser/worker.d.ts +24 -0
- package/dist/browser/worker.js +239 -0
- package/dist/core/captions.d.ts +34 -0
- package/dist/core/captions.js +108 -0
- package/dist/core/clip-time.d.ts +31 -0
- package/dist/core/clip-time.js +125 -0
- package/dist/core/commands.d.ts +413 -0
- package/dist/core/commands.js +1118 -0
- package/dist/core/compositions.d.ts +234 -0
- package/dist/core/compositions.js +851 -0
- package/dist/core/document.d.ts +245 -0
- package/dist/core/document.js +683 -0
- package/dist/core/keyframes.d.ts +82 -0
- package/dist/core/keyframes.js +153 -0
- package/dist/core/presets.d.ts +64 -0
- package/dist/core/presets.js +241 -0
- package/dist/core/session.d.ts +150 -0
- package/dist/core/session.js +541 -0
- package/dist/core/text.d.ts +17 -0
- package/dist/core/text.js +17 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +39 -0
- package/dist/core/timeline-index.d.ts +44 -0
- package/dist/core/timeline-index.js +59 -0
- package/dist/core/transitions.d.ts +20 -0
- package/dist/core/transitions.js +118 -0
- package/dist/core/validated.d.ts +23 -0
- package/dist/core/validated.js +52 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/native-client/index.d.ts +131 -0
- package/dist/native-client/index.js +354 -0
- package/dist/ports/index.d.ts +146 -0
- package/dist/ports/index.js +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.js +94 -0
- package/package.json +69 -8
- /package/{index.js → dist/browser/extensions-worker-entry.d.ts} +0 -0
|
@@ -0,0 +1,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;
|