@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,819 @@
|
|
|
1
|
+
// Answers per-frame source requests with decoded VideoFrames; Canvas2D premultiplied RGBA remains
|
|
2
|
+
// for text rasters and for an engine that cannot take a VideoFrame.
|
|
3
|
+
//
|
|
4
|
+
// Still and text rasters share one LRU cache bounded by bytes, but what the frame being drawn reads
|
|
5
|
+
// is never evicted, even past the budget. Stills are decoded per read and closed once read.
|
|
6
|
+
//
|
|
7
|
+
// Decoder cursors are scoped by clip consumer so overlapping uses of one source cannot seek each
|
|
8
|
+
// other. WebCodecs is the primary path; an `HTMLVideoElement` seek is the fallback for codecs it
|
|
9
|
+
// cannot open or frames that keep failing (`decodeFallbacks`).
|
|
10
|
+
import { decoderCursorKey } from "alpha2/frame-supply";
|
|
11
|
+
import { MediabunnyVideoDecoder } from "alpha2/mediabunny";
|
|
12
|
+
import { containRect, seekVideoFrame } from "./preview-utils.js";
|
|
13
|
+
import { decodeStill, stillRaster } from "./still.js";
|
|
14
|
+
const TICKS_PER_SECOND = 120_000;
|
|
15
|
+
/** Failed frames in a row, each retried with a reopened decoder, before element seeks take over. */
|
|
16
|
+
const MAX_DECODE_FAILURES = 3;
|
|
17
|
+
/** Default byte budget of still and text rasters, matching the frame supply's image cache. */
|
|
18
|
+
export const DEFAULT_RASTER_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* The default byte budget of what open extension sources hold (their decoded frames in the provider
|
|
21
|
+
* module and the frame each showed last): the native host's `providerSourceBytes` default.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_PROVIDER_SOURCE_BYTES = 512 * 1024 * 1024;
|
|
24
|
+
/** The host side of the engine's per-frame source requests for one renderer (preview or export). */
|
|
25
|
+
export class SourcePixels {
|
|
26
|
+
#width = 1920;
|
|
27
|
+
#height = 1080;
|
|
28
|
+
#decoders = new Map();
|
|
29
|
+
#seekVideos = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Rasters by asset ID in LRU order: `{ key, raster, bytes, frame }`, where `key` is what the
|
|
32
|
+
* raster was made from and `frame` the frame that last read it.
|
|
33
|
+
*/
|
|
34
|
+
#rasters = new Map();
|
|
35
|
+
#rasterBytes = 0;
|
|
36
|
+
/** Counts the frames drawn: the engine releases once before each frame reads its sources. */
|
|
37
|
+
#frame = 0;
|
|
38
|
+
#staging = null;
|
|
39
|
+
/** What fell back from WebCodecs decoding, by source (see `decodeFallbacks`). */
|
|
40
|
+
#fallbacks = new Map();
|
|
41
|
+
/** Extension sources opened, by `providerKey`, in least-recently-shown order. */
|
|
42
|
+
#providers = new Map();
|
|
43
|
+
/** Sources the provider budget closed and a later frame opened again. */
|
|
44
|
+
#providerReopens = 0;
|
|
45
|
+
/** Asset IDs by `providerKey` of sources the provider budget closed, until they reopen. */
|
|
46
|
+
#providersClosed = new Map();
|
|
47
|
+
/**
|
|
48
|
+
* Per animated-image asset, `{ asset, provider, opened }`: a reader opened once whose file and
|
|
49
|
+
* kept frames every reader made from it shares (see `#openReader`).
|
|
50
|
+
*/
|
|
51
|
+
#readerMakers = new Map();
|
|
52
|
+
/** The fonts an SVG's text draws with: the engine's caption fonts (see `setFonts`). */
|
|
53
|
+
#fonts = [];
|
|
54
|
+
/** What each SVG's pixels are with the fonts it was drawn with, for `contentOf`. */
|
|
55
|
+
#fontContents = new WeakMap();
|
|
56
|
+
constructor(library, { decoderLimit = 32, rasterBudgetBytes = DEFAULT_RASTER_BUDGET_BYTES, providerSourceBytes = DEFAULT_PROVIDER_SOURCE_BYTES, extensionsWasmUrl, } = {}) {
|
|
57
|
+
if (!Number.isSafeInteger(rasterBudgetBytes) || rasterBudgetBytes <= 0)
|
|
58
|
+
throw new RangeError("rasterBudgetBytes must be a positive integer");
|
|
59
|
+
if (!Number.isSafeInteger(providerSourceBytes) || providerSourceBytes <= 0)
|
|
60
|
+
throw new RangeError("providerSourceBytes must be a positive integer");
|
|
61
|
+
this.decoderLimit = decoderLimit;
|
|
62
|
+
this.rasterBudgetBytes = rasterBudgetBytes;
|
|
63
|
+
this.providerSourceBytes = providerSourceBytes;
|
|
64
|
+
this.library = library;
|
|
65
|
+
this.extensionsWasmUrl = extensionsWasmUrl;
|
|
66
|
+
}
|
|
67
|
+
get diagnostics() {
|
|
68
|
+
const decoders = [...this.#decoders.values()]
|
|
69
|
+
.map((decoder) => decoder.video)
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
return {
|
|
72
|
+
liveDecoders: this.#decoders.size,
|
|
73
|
+
retainedFrameBytes: decoders.reduce((bytes, video) => bytes + video.cachedFrameBytes, 0),
|
|
74
|
+
windowedFrames: decoders.reduce((hits, video) => hits + video.reverseCacheHits, 0),
|
|
75
|
+
reverseWindows: decoders.reduce((windows, video) => windows + video.reverseWindowsBuilt, 0),
|
|
76
|
+
cachedRasters: this.#rasters.size,
|
|
77
|
+
cachedRasterBytes: this.#rasterBytes,
|
|
78
|
+
fallbackVideos: this.#seekVideos.size,
|
|
79
|
+
decodeFallbackFrames: [...this.#fallbacks.values()].reduce((frames, entry) => frames + entry.frames, 0),
|
|
80
|
+
openProviderSources: this.#providers.size,
|
|
81
|
+
providerSourceBytes: this.#providerBytes(),
|
|
82
|
+
providerReopens: this.#providerReopens,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
setOutputSize(width, height) {
|
|
86
|
+
if (width === this.#width && height === this.#height)
|
|
87
|
+
return;
|
|
88
|
+
this.#width = width;
|
|
89
|
+
this.#height = height;
|
|
90
|
+
this.#staging = null;
|
|
91
|
+
for (const [id, entry] of this.#rasters)
|
|
92
|
+
if (entry.key.width !== undefined)
|
|
93
|
+
this.#dropRaster(id, entry);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The fonts an SVG's text draws with, in fallback order: the stack the engine draws captions with
|
|
97
|
+
* (`{ bytes }` each), as the native host draws an SVG's text with its caption fonts. An SVG
|
|
98
|
+
* opened with other fonts opens again on its next frame; other sources are not touched.
|
|
99
|
+
*/
|
|
100
|
+
setFonts(fonts) {
|
|
101
|
+
if (sameFonts(this.#fonts, fonts))
|
|
102
|
+
return;
|
|
103
|
+
this.#fonts = [...fonts];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* What a source's pixels are, for `FrameSupply`'s `contentOf`: its runtime asset, and for an SVG
|
|
107
|
+
* also the fonts its text draws with, so a change of fonts draws it again. The same object while
|
|
108
|
+
* neither changes.
|
|
109
|
+
*/
|
|
110
|
+
contentOf(asset) {
|
|
111
|
+
if (!asset || !drawsText(asset.provider))
|
|
112
|
+
return asset;
|
|
113
|
+
const known = this.#fontContents.get(asset);
|
|
114
|
+
if (known?.fonts === this.#fonts)
|
|
115
|
+
return known;
|
|
116
|
+
const content = { asset, fonts: this.#fonts };
|
|
117
|
+
this.#fontContents.set(asset, content);
|
|
118
|
+
return content;
|
|
119
|
+
}
|
|
120
|
+
/** Drops the cached raster of `assetId`, so its next read makes it again. */
|
|
121
|
+
invalidate(assetId) {
|
|
122
|
+
const entry = this.#rasters.get(assetId);
|
|
123
|
+
if (entry)
|
|
124
|
+
this.#dropRaster(assetId, entry);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Bound method handed to `FrameSupply` as its `pixelsFor` callback. `sourceId` is the source the
|
|
128
|
+
* engine asked for, which the layer may stand in for (a proxy has an ID of its own): decoders are
|
|
129
|
+
* kept per source, as `release` names them.
|
|
130
|
+
*/
|
|
131
|
+
pixelsFor = async (layer, seconds, { preferVideoFrame = false, consumerId = layer.id, sourceId } = {}) => {
|
|
132
|
+
const asset = layer.asset ?? layer;
|
|
133
|
+
if (asset.provider)
|
|
134
|
+
return this.#providerPixels(asset, providerKey(asset, consumerId), seconds);
|
|
135
|
+
if (asset.kind === "text")
|
|
136
|
+
return this.#cachedRaster(asset.id, { raster: asset.raster, width: this.#width, height: this.#height }, () => this.#textPixels(asset));
|
|
137
|
+
if (asset.kind === "image")
|
|
138
|
+
return this.#cachedRaster(asset.id, { file: asset.file }, () => this.#imagePixels(asset));
|
|
139
|
+
return this.#videoPixels(asset, seconds, preferVideoFrame, consumerId, sourceId ?? asset.id);
|
|
140
|
+
};
|
|
141
|
+
/** A frame an extension provider draws at its own size; `key` is its open source. */
|
|
142
|
+
async #providerPixels(asset, key, seconds, reopened = false) {
|
|
143
|
+
let opened = this.#providers.get(key);
|
|
144
|
+
// Reopened when the asset, its provider settings, or an SVG's fonts change; providers are
|
|
145
|
+
// compared by identity first, so a shown frame does not serialize them.
|
|
146
|
+
const { provider } = asset;
|
|
147
|
+
const fonts = drawsText(provider) ? this.#fonts : null;
|
|
148
|
+
if (opened?.asset !== asset ||
|
|
149
|
+
!sameProviderSettings(opened.provider, provider) ||
|
|
150
|
+
opened.fonts !== fonts) {
|
|
151
|
+
opened?.source.then(({ source }) => source.release()).catch(() => { });
|
|
152
|
+
const opening = {
|
|
153
|
+
asset,
|
|
154
|
+
assetId: asset.id,
|
|
155
|
+
provider,
|
|
156
|
+
fonts,
|
|
157
|
+
source: provider.format === "animated_image"
|
|
158
|
+
? this.#openReader(asset)
|
|
159
|
+
: openProvider(asset, this.extensionsWasmUrl, fonts),
|
|
160
|
+
};
|
|
161
|
+
// Keep what a provider reports, but not a load or read failure: retry next frame.
|
|
162
|
+
opening.source.catch((error) => {
|
|
163
|
+
if (retryableOpen.has(error) && this.#providers.get(key) === opening)
|
|
164
|
+
this.#providers.delete(key);
|
|
165
|
+
});
|
|
166
|
+
opened = opening;
|
|
167
|
+
this.#providers.set(key, opened);
|
|
168
|
+
if (this.#providersClosed.delete(key))
|
|
169
|
+
this.#providerReopens += 1;
|
|
170
|
+
}
|
|
171
|
+
// Kept by the budget for this frame and the next.
|
|
172
|
+
opened.frame = this.#frame;
|
|
173
|
+
this.#providers.delete(key);
|
|
174
|
+
this.#providers.set(key, opened);
|
|
175
|
+
const { source, extensions, read } = await opened.source;
|
|
176
|
+
opened.open = source;
|
|
177
|
+
const ticks = BigInt(Math.round(seconds * TICKS_PER_SECOND));
|
|
178
|
+
const fit = { width: this.#width, height: this.#height };
|
|
179
|
+
// The frame shown last answers every time it covers, as the same object, so a still is not
|
|
180
|
+
// uploaded again each frame.
|
|
181
|
+
const shown = opened.shown;
|
|
182
|
+
if (shown &&
|
|
183
|
+
shown.width === fit.width &&
|
|
184
|
+
shown.height === fit.height &&
|
|
185
|
+
ticks >= shown.start &&
|
|
186
|
+
(shown.end === undefined || ticks < shown.end))
|
|
187
|
+
return shown.result;
|
|
188
|
+
let frame;
|
|
189
|
+
try {
|
|
190
|
+
// An image sequence's frame enters the provider module for this render only.
|
|
191
|
+
frame = await extensions.withResources(read, () => source.needs(ticks), () => source.render(ticks, fit));
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
// The provider module restarted after another source stopped it: this one opens again.
|
|
195
|
+
if (error?.name !== "StaleExtensionSourceError" || reopened)
|
|
196
|
+
throw error;
|
|
197
|
+
if (this.#providers.get(key) === opened)
|
|
198
|
+
this.#providers.delete(key);
|
|
199
|
+
return this.#providerPixels(asset, key, seconds, true);
|
|
200
|
+
}
|
|
201
|
+
// Microseconds identify the physical frame.
|
|
202
|
+
const micros = (value) => Math.round((value * 1_000_000) / TICKS_PER_SECOND);
|
|
203
|
+
const result = {
|
|
204
|
+
kind: "raster-frame",
|
|
205
|
+
precision: frame.precision,
|
|
206
|
+
pixels: frame.pixels,
|
|
207
|
+
width: frame.width,
|
|
208
|
+
height: frame.height,
|
|
209
|
+
physicalTimeMicroseconds: micros(frame.startTicks),
|
|
210
|
+
...(frame.endTicks === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { durationMicroseconds: micros(frame.endTicks - frame.startTicks) }),
|
|
213
|
+
};
|
|
214
|
+
// The first frame also answers every earlier time.
|
|
215
|
+
opened.shown = {
|
|
216
|
+
...fit,
|
|
217
|
+
start: frame.index === 0 ? -(2n ** 63n) : BigInt(frame.startTicks),
|
|
218
|
+
end: frame.endTicks === undefined ? undefined : BigInt(frame.endTicks),
|
|
219
|
+
result,
|
|
220
|
+
};
|
|
221
|
+
this.#boundProviders();
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A reader of an animated image, of its own (see `providerKey`), made from what the asset's
|
|
226
|
+
* readers share so the file is decoded once; as the native host does.
|
|
227
|
+
*/
|
|
228
|
+
async #openReader(asset) {
|
|
229
|
+
const { provider } = asset;
|
|
230
|
+
const made = this.#readerMakers.get(asset.id);
|
|
231
|
+
if (made?.asset === asset &&
|
|
232
|
+
sameProviderSettings(made.provider, provider)) {
|
|
233
|
+
try {
|
|
234
|
+
const { maker, extensions, read } = await made.opened;
|
|
235
|
+
const source = maker?.reader();
|
|
236
|
+
if (source)
|
|
237
|
+
return { source, extensions, read };
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// What made the readers did not open, or a module restart left it behind: open again.
|
|
241
|
+
}
|
|
242
|
+
if (this.#readerMakers.get(asset.id) === made)
|
|
243
|
+
this.#dropReaderMaker(asset.id);
|
|
244
|
+
}
|
|
245
|
+
else if (made)
|
|
246
|
+
this.#dropReaderMaker(asset.id);
|
|
247
|
+
const opening = openProvider(asset, this.extensionsWasmUrl, null);
|
|
248
|
+
const entry = {
|
|
249
|
+
asset,
|
|
250
|
+
provider,
|
|
251
|
+
opened: opening.then(({ source, extensions, read }) => ({
|
|
252
|
+
maker: source.reader(),
|
|
253
|
+
extensions,
|
|
254
|
+
read,
|
|
255
|
+
})),
|
|
256
|
+
};
|
|
257
|
+
entry.opened.catch(() => {
|
|
258
|
+
if (this.#readerMakers.get(asset.id) === entry)
|
|
259
|
+
this.#readerMakers.delete(asset.id);
|
|
260
|
+
});
|
|
261
|
+
this.#readerMakers.set(asset.id, entry);
|
|
262
|
+
return opening;
|
|
263
|
+
}
|
|
264
|
+
/** Lets go of what makes an animated image's readers; the readers it made stay open. */
|
|
265
|
+
#dropReaderMaker(assetId) {
|
|
266
|
+
const made = this.#readerMakers.get(assetId);
|
|
267
|
+
if (!made)
|
|
268
|
+
return;
|
|
269
|
+
this.#readerMakers.delete(assetId);
|
|
270
|
+
made.opened.then(({ maker }) => maker?.release()).catch(() => { });
|
|
271
|
+
}
|
|
272
|
+
/** Lets go of the reader makers of animated images no open source reads any more. */
|
|
273
|
+
#dropUnreadMakers() {
|
|
274
|
+
if (this.#readerMakers.size === 0)
|
|
275
|
+
return;
|
|
276
|
+
const read = new Set();
|
|
277
|
+
for (const opened of this.#providers.values())
|
|
278
|
+
read.add(opened.assetId);
|
|
279
|
+
for (const assetId of [...this.#readerMakers.keys()])
|
|
280
|
+
if (!read.has(assetId))
|
|
281
|
+
this.#dropReaderMaker(assetId);
|
|
282
|
+
}
|
|
283
|
+
/** What open extension sources hold; an animated image's shared file counts once per asset. */
|
|
284
|
+
#providerBytes() {
|
|
285
|
+
let bytes = 0;
|
|
286
|
+
const shared = new Map();
|
|
287
|
+
for (const opened of this.#providers.values()) {
|
|
288
|
+
const resident = opened.open?.residentBytes ?? 0;
|
|
289
|
+
const common = Math.min(opened.open?.sharedBytes ?? 0, resident);
|
|
290
|
+
if (common > 0)
|
|
291
|
+
shared.set(opened.assetId, Math.max(shared.get(opened.assetId) ?? 0, common));
|
|
292
|
+
bytes +=
|
|
293
|
+
resident - common + (opened.shown?.result.pixels.byteLength ?? 0);
|
|
294
|
+
}
|
|
295
|
+
for (const common of shared.values())
|
|
296
|
+
bytes += common;
|
|
297
|
+
return bytes;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Keeps open extension sources within `providerSourceBytes` by closing the least recently shown
|
|
301
|
+
* ones; sources this frame or the previous one showed stay open even past the budget.
|
|
302
|
+
*/
|
|
303
|
+
#boundProviders() {
|
|
304
|
+
let bytes = this.#providerBytes();
|
|
305
|
+
let closed = false;
|
|
306
|
+
for (const [key, opened] of this.#providers) {
|
|
307
|
+
if (bytes <= this.providerSourceBytes)
|
|
308
|
+
break;
|
|
309
|
+
if (opened.frame >= this.#frame - 1)
|
|
310
|
+
continue;
|
|
311
|
+
this.#providers.delete(key);
|
|
312
|
+
this.#providersClosed.set(key, opened.assetId);
|
|
313
|
+
opened.source.then(({ source }) => source.release()).catch(() => { });
|
|
314
|
+
closed = true;
|
|
315
|
+
bytes = this.#providerBytes();
|
|
316
|
+
}
|
|
317
|
+
if (closed)
|
|
318
|
+
this.#dropUnreadMakers();
|
|
319
|
+
}
|
|
320
|
+
#context() {
|
|
321
|
+
if (!this.#staging)
|
|
322
|
+
this.#staging = new OffscreenCanvas(this.#width, this.#height);
|
|
323
|
+
if (this.#staging.width !== this.#width ||
|
|
324
|
+
this.#staging.height !== this.#height) {
|
|
325
|
+
this.#staging = new OffscreenCanvas(this.#width, this.#height);
|
|
326
|
+
}
|
|
327
|
+
return this.#staging.getContext("2d", { willReadFrequently: true });
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The raster `make` makes for `id` from `key`, cached while `key` is unchanged. A raster read by
|
|
331
|
+
* the frame being drawn is not evicted until a later frame; failures are not kept.
|
|
332
|
+
*/
|
|
333
|
+
#cachedRaster(id, key, make) {
|
|
334
|
+
let entry = this.#rasters.get(id);
|
|
335
|
+
if (entry && !sameKey(entry.key, key)) {
|
|
336
|
+
this.#dropRaster(id, entry);
|
|
337
|
+
entry = undefined;
|
|
338
|
+
}
|
|
339
|
+
if (entry) {
|
|
340
|
+
this.#rasters.delete(id);
|
|
341
|
+
this.#rasters.set(id, entry);
|
|
342
|
+
entry.frame = this.#frame;
|
|
343
|
+
return entry.raster;
|
|
344
|
+
}
|
|
345
|
+
entry = { key, raster: null, bytes: 0, frame: this.#frame };
|
|
346
|
+
entry.raster = new Promise((resolve) => resolve(make())).then((raster) => {
|
|
347
|
+
if (this.#rasters.get(id) === entry) {
|
|
348
|
+
entry.bytes = rasterBytes(raster);
|
|
349
|
+
this.#rasterBytes += entry.bytes;
|
|
350
|
+
this.#evictRasters();
|
|
351
|
+
}
|
|
352
|
+
return raster;
|
|
353
|
+
}, (error) => {
|
|
354
|
+
if (this.#rasters.get(id) === entry)
|
|
355
|
+
this.#rasters.delete(id);
|
|
356
|
+
throw error;
|
|
357
|
+
});
|
|
358
|
+
this.#rasters.set(id, entry);
|
|
359
|
+
return entry.raster;
|
|
360
|
+
}
|
|
361
|
+
#dropRaster(id, entry) {
|
|
362
|
+
this.#rasterBytes -= entry.bytes;
|
|
363
|
+
this.#rasters.delete(id);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Evicts the least recently used rasters the frame being drawn has not read, until the cache is
|
|
367
|
+
* within its budget or only that frame's rasters are left.
|
|
368
|
+
*/
|
|
369
|
+
#evictRasters() {
|
|
370
|
+
for (const [id, entry] of this.#rasters) {
|
|
371
|
+
if (this.#rasterBytes <= this.rasterBudgetBytes)
|
|
372
|
+
return;
|
|
373
|
+
if (entry.frame === this.#frame || entry.bytes === 0)
|
|
374
|
+
continue;
|
|
375
|
+
this.#dropRaster(id, entry);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** A still at its own size in stored code values (see `still.js`), decoded from its file. */
|
|
379
|
+
async #imagePixels(asset) {
|
|
380
|
+
if (!asset.file)
|
|
381
|
+
throw new Error(`${asset.id} has no file to decode`);
|
|
382
|
+
const bitmap = await decodeStill(asset.file);
|
|
383
|
+
try {
|
|
384
|
+
return stillRaster(bitmap);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
bitmap.close();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/** Text rasters arrive premultiplied, so they are blitted at native size, not resampled. */
|
|
391
|
+
#textPixels(asset) {
|
|
392
|
+
const pixels = new Uint8ClampedArray(this.#width * this.#height * 4);
|
|
393
|
+
const raster = asset.raster;
|
|
394
|
+
if (raster) {
|
|
395
|
+
const sourceWidth = raster.texture.width;
|
|
396
|
+
const sourceHeight = raster.texture.height;
|
|
397
|
+
const offsetX = Math.round((this.#width - sourceWidth) / 2);
|
|
398
|
+
const offsetY = Math.round((this.#height - sourceHeight) / 2);
|
|
399
|
+
for (let row = 0; row < sourceHeight; row += 1) {
|
|
400
|
+
const y = offsetY + row;
|
|
401
|
+
if (y < 0 || y >= this.#height)
|
|
402
|
+
continue;
|
|
403
|
+
for (let column = 0; column < sourceWidth; column += 1) {
|
|
404
|
+
const x = offsetX + column;
|
|
405
|
+
if (x < 0 || x >= this.#width)
|
|
406
|
+
continue;
|
|
407
|
+
const from = (row * sourceWidth + column) * 4;
|
|
408
|
+
const to = (y * this.#width + x) * 4;
|
|
409
|
+
pixels[to] = raster.rgba8[from];
|
|
410
|
+
pixels[to + 1] = raster.rgba8[from + 1];
|
|
411
|
+
pixels[to + 2] = raster.rgba8[from + 2];
|
|
412
|
+
pixels[to + 3] = raster.rgba8[from + 3];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return pixels;
|
|
417
|
+
}
|
|
418
|
+
async #videoPixels(asset, seconds, preferVideoFrame, consumerId, sourceId) {
|
|
419
|
+
const decoded = await this.#decodeFrame(asset, seconds, consumerId, sourceId);
|
|
420
|
+
const fallback = decoded
|
|
421
|
+
? null
|
|
422
|
+
: await this.#seekFallback(asset, seconds, consumerId, sourceId);
|
|
423
|
+
const imported = decoded ?? {
|
|
424
|
+
frame: new VideoFrame(fallback),
|
|
425
|
+
rotation: 0,
|
|
426
|
+
};
|
|
427
|
+
const physicalTimeMicroseconds = decoded
|
|
428
|
+
? imported.frame.timestamp
|
|
429
|
+
: Math.round(fallback.currentTime * 1_000_000);
|
|
430
|
+
const durationMicroseconds = imported.frame.duration ?? undefined;
|
|
431
|
+
const sourceWidth = imported.frame.displayWidth;
|
|
432
|
+
const sourceHeight = imported.frame.displayHeight;
|
|
433
|
+
if (preferVideoFrame) {
|
|
434
|
+
return {
|
|
435
|
+
kind: "video-frame",
|
|
436
|
+
frame: imported.frame,
|
|
437
|
+
rotation: imported.rotation,
|
|
438
|
+
mirror: imported.mirror ?? false,
|
|
439
|
+
chromaSiting: imported.chromaSiting,
|
|
440
|
+
physicalTimeMicroseconds,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const image = imported.frame;
|
|
445
|
+
const width = image.displayWidth ?? image.videoWidth ?? image.width;
|
|
446
|
+
const height = image.displayHeight ?? image.videoHeight ?? image.height;
|
|
447
|
+
return {
|
|
448
|
+
kind: "rgba-frame",
|
|
449
|
+
pixels: this.#drawToBuffer(image, width, height),
|
|
450
|
+
physicalTimeMicroseconds,
|
|
451
|
+
durationMicroseconds,
|
|
452
|
+
sourceWidth,
|
|
453
|
+
sourceHeight,
|
|
454
|
+
rotation: imported.rotation,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
imported.frame.close();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
#drawToBuffer(image, sourceWidth, sourceHeight) {
|
|
462
|
+
const context = this.#context();
|
|
463
|
+
context.clearRect(0, 0, this.#width, this.#height);
|
|
464
|
+
context.imageSmoothingEnabled = true;
|
|
465
|
+
context.imageSmoothingQuality = "high";
|
|
466
|
+
context.drawImage(image, ...containRect(sourceWidth, sourceHeight, this.#width, this.#height));
|
|
467
|
+
const pixels = context.getImageData(0, 0, this.#width, this.#height).data;
|
|
468
|
+
premultiplyInPlace(pixels);
|
|
469
|
+
return pixels;
|
|
470
|
+
}
|
|
471
|
+
async #decodeFrame(asset, seconds, consumerId, sourceId) {
|
|
472
|
+
if (!asset.file)
|
|
473
|
+
return null;
|
|
474
|
+
const cursor = decoderCursorKey(sourceId, consumerId);
|
|
475
|
+
let decoder = this.#decoders.get(cursor);
|
|
476
|
+
// The source now reads other media (its proxy, or the original again): the cursor starts over.
|
|
477
|
+
if (decoder && decoder.asset !== asset) {
|
|
478
|
+
await closeDecoder(decoder);
|
|
479
|
+
this.#decoders.delete(cursor);
|
|
480
|
+
decoder = undefined;
|
|
481
|
+
}
|
|
482
|
+
if (!decoder) {
|
|
483
|
+
if (this.#decoders.size >= this.decoderLimit)
|
|
484
|
+
throw Object.assign(new Error("Video decoder budget reached"), {
|
|
485
|
+
code: "resource_budget",
|
|
486
|
+
});
|
|
487
|
+
decoder = {
|
|
488
|
+
sourceId,
|
|
489
|
+
asset,
|
|
490
|
+
consumerId,
|
|
491
|
+
opening: null,
|
|
492
|
+
video: null,
|
|
493
|
+
failed: false,
|
|
494
|
+
failures: 0,
|
|
495
|
+
};
|
|
496
|
+
this.#decoders.set(cursor, decoder);
|
|
497
|
+
}
|
|
498
|
+
if (decoder.failed) {
|
|
499
|
+
this.#fallbacks.get(sourceId).frames += 1;
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
// A decoder that cannot open (a codec the browser cannot decode) is the fallback's for good.
|
|
503
|
+
try {
|
|
504
|
+
decoder.video ??= await (decoder.opening ??= MediabunnyVideoDecoder.open(asset.id, asset.file));
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
decoder.opening = null;
|
|
508
|
+
return this.#fallBack(decoder, error, true);
|
|
509
|
+
}
|
|
510
|
+
const request = {
|
|
511
|
+
sourceId: asset.id,
|
|
512
|
+
mediaTimeTicks: BigInt(Math.round(seconds * TICKS_PER_SECOND)),
|
|
513
|
+
reason: "prefetch",
|
|
514
|
+
generation: 0,
|
|
515
|
+
};
|
|
516
|
+
let decoded;
|
|
517
|
+
try {
|
|
518
|
+
decoded = await decoder.video.refill(request);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// A failed read (stall, decoder error) is retried once with a new decoder, which a stalled
|
|
522
|
+
// decoder's surfaces do not hold up.
|
|
523
|
+
closeOpened(decoder);
|
|
524
|
+
try {
|
|
525
|
+
decoder.opening = MediabunnyVideoDecoder.open(asset.id, asset.file);
|
|
526
|
+
decoder.video = await decoder.opening;
|
|
527
|
+
decoded = await decoder.video.refill(request);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
closeOpened(decoder);
|
|
531
|
+
decoder.failures += 1;
|
|
532
|
+
return this.#fallBack(decoder, error, decoder.failures >= MAX_DECODE_FAILURES);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
decoder.failures = 0;
|
|
536
|
+
if (decoder.video.softwareFallback && !decoder.reportedSoftware) {
|
|
537
|
+
decoder.reportedSoftware = true;
|
|
538
|
+
console.info(`preview decodes ${asset.id} in software: ${decoder.video.softwareFallback}`);
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
frame: decoded.frame,
|
|
542
|
+
rotation: decoded.rotationDegrees ?? 0,
|
|
543
|
+
mirror: decoded.mirror ?? false,
|
|
544
|
+
chromaSiting: decoded.chromaSiting,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Records that `decoder`'s frame falls back to an element seek because of `error`; `permanent`
|
|
549
|
+
* applies it to every later frame of the cursor.
|
|
550
|
+
*/
|
|
551
|
+
#fallBack(decoder, error, permanent) {
|
|
552
|
+
if (permanent)
|
|
553
|
+
decoder.failed = true;
|
|
554
|
+
const reason = String(error?.message ?? error);
|
|
555
|
+
const known = this.#fallbacks.get(decoder.sourceId);
|
|
556
|
+
this.#fallbacks.set(decoder.sourceId, {
|
|
557
|
+
sourceId: decoder.sourceId,
|
|
558
|
+
reason,
|
|
559
|
+
frames: (known?.frames ?? 0) + 1,
|
|
560
|
+
permanent: permanent || (known?.permanent ?? false),
|
|
561
|
+
});
|
|
562
|
+
console.warn(permanent
|
|
563
|
+
? `preview falls back to HTMLVideoElement seeking for ${decoder.sourceId}: ${reason}`
|
|
564
|
+
: `a frame of ${decoder.sourceId} falls back to HTMLVideoElement seeking: ${reason}`);
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The sources whose frames fell back from WebCodecs decoding, one entry each: the last reason,
|
|
569
|
+
* how many frames, and whether its decoding stopped for good (it could not open, or failed
|
|
570
|
+
* {@link MAX_DECODE_FAILURES} frames running). A browser export reports it.
|
|
571
|
+
*/
|
|
572
|
+
get decodeFallbacks() {
|
|
573
|
+
return [...this.#fallbacks.values()].map((entry) => ({ ...entry }));
|
|
574
|
+
}
|
|
575
|
+
async #seekFallback(asset, seconds, consumerId, sourceId) {
|
|
576
|
+
if (!asset.video)
|
|
577
|
+
throw Object.assign(new Error(`No worker decoder for ${asset.id}; use the main-thread media fallback`), { code: "unsupported_codec" });
|
|
578
|
+
const cursor = decoderCursorKey(sourceId, consumerId);
|
|
579
|
+
let fallback = this.#seekVideos.get(cursor);
|
|
580
|
+
if (fallback && fallback.asset !== asset) {
|
|
581
|
+
unloadVideo(fallback.video);
|
|
582
|
+
this.#seekVideos.delete(cursor);
|
|
583
|
+
fallback = undefined;
|
|
584
|
+
}
|
|
585
|
+
if (!fallback) {
|
|
586
|
+
// Playback audio must never move the cursor used by fallback frame seeks.
|
|
587
|
+
const video = asset.video.cloneNode(true);
|
|
588
|
+
Object.assign(video, {
|
|
589
|
+
autoplay: false,
|
|
590
|
+
muted: true,
|
|
591
|
+
loop: false,
|
|
592
|
+
preload: "auto",
|
|
593
|
+
});
|
|
594
|
+
fallback = { sourceId, asset, consumerId, video };
|
|
595
|
+
this.#seekVideos.set(cursor, fallback);
|
|
596
|
+
await waitForLoad(video);
|
|
597
|
+
}
|
|
598
|
+
const { video } = fallback;
|
|
599
|
+
const duration = Number.isFinite(video.duration)
|
|
600
|
+
? video.duration
|
|
601
|
+
: asset.duration || 1;
|
|
602
|
+
await seekVideoFrame(video, Math.min(Math.max(0, seconds), Math.max(0, duration - 1e-3)));
|
|
603
|
+
return video;
|
|
604
|
+
}
|
|
605
|
+
/** Keeps decoder sessions bounded to consumers needed by the current frame. */
|
|
606
|
+
retainConsumers = async (liveConsumerIds) => {
|
|
607
|
+
for (const [cursor, decoder] of this.#decoders) {
|
|
608
|
+
if (liveConsumerIds.has(decoder.consumerId))
|
|
609
|
+
continue;
|
|
610
|
+
await closeDecoder(decoder);
|
|
611
|
+
this.#decoders.delete(cursor);
|
|
612
|
+
}
|
|
613
|
+
for (const [cursor, fallback] of this.#seekVideos) {
|
|
614
|
+
if (liveConsumerIds.has(fallback.consumerId))
|
|
615
|
+
continue;
|
|
616
|
+
unloadVideo(fallback.video);
|
|
617
|
+
this.#seekVideos.delete(cursor);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Drops decoders, extension sources, and rasters for source IDs not in `liveIds`. The engine
|
|
622
|
+
* calls it before each frame reads its sources, so the previous frame's reads may be evicted.
|
|
623
|
+
*/
|
|
624
|
+
async release(liveIds) {
|
|
625
|
+
this.#frame += 1;
|
|
626
|
+
for (const [key, opened] of this.#providers) {
|
|
627
|
+
if (liveIds.has(opened.assetId))
|
|
628
|
+
continue;
|
|
629
|
+
this.#providers.delete(key);
|
|
630
|
+
opened.source.then(({ source }) => source.release()).catch(() => { });
|
|
631
|
+
}
|
|
632
|
+
for (const [key, assetId] of this.#providersClosed)
|
|
633
|
+
if (!liveIds.has(assetId))
|
|
634
|
+
this.#providersClosed.delete(key);
|
|
635
|
+
this.#dropUnreadMakers();
|
|
636
|
+
for (const [cursor, decoder] of this.#decoders) {
|
|
637
|
+
if (liveIds.has(decoder.sourceId))
|
|
638
|
+
continue;
|
|
639
|
+
await closeDecoder(decoder);
|
|
640
|
+
this.#decoders.delete(cursor);
|
|
641
|
+
}
|
|
642
|
+
for (const [cursor, fallback] of this.#seekVideos) {
|
|
643
|
+
if (liveIds.has(fallback.sourceId))
|
|
644
|
+
continue;
|
|
645
|
+
unloadVideo(fallback.video);
|
|
646
|
+
this.#seekVideos.delete(cursor);
|
|
647
|
+
}
|
|
648
|
+
for (const [id, entry] of this.#rasters)
|
|
649
|
+
if (!liveIds.has(id))
|
|
650
|
+
this.#dropRaster(id, entry);
|
|
651
|
+
this.#evictRasters();
|
|
652
|
+
}
|
|
653
|
+
async dispose() {
|
|
654
|
+
await this.release(new Set());
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/** Whether two raster keys name the same raster: the same objects and sizes, field by field. */
|
|
658
|
+
function sameKey(a, b) {
|
|
659
|
+
const fields = Object.keys(a);
|
|
660
|
+
return (fields.length === Object.keys(b).length &&
|
|
661
|
+
fields.every((field) => Object.is(a[field], b[field])));
|
|
662
|
+
}
|
|
663
|
+
/** The bytes a raster's pixels hold: a text raster is its pixels, a still a raster frame. */
|
|
664
|
+
function rasterBytes(raster) {
|
|
665
|
+
return (raster.pixels ?? raster).byteLength;
|
|
666
|
+
}
|
|
667
|
+
/** Closes a cursor's decoder, including one still opening. */
|
|
668
|
+
async function closeDecoder(decoder) {
|
|
669
|
+
const video = decoder.video ?? (await decoder.opening?.catch(() => null)) ?? null;
|
|
670
|
+
video?.close();
|
|
671
|
+
decoder.video = null;
|
|
672
|
+
decoder.opening = null;
|
|
673
|
+
}
|
|
674
|
+
/** Closes a cursor's opened decoder, so its next read opens another. */
|
|
675
|
+
function closeOpened(decoder) {
|
|
676
|
+
decoder.video?.close();
|
|
677
|
+
decoder.video = null;
|
|
678
|
+
decoder.opening = null;
|
|
679
|
+
}
|
|
680
|
+
/** Lets a fallback seek element go of its media. */
|
|
681
|
+
function unloadVideo(video) {
|
|
682
|
+
video.removeAttribute("src");
|
|
683
|
+
video.load();
|
|
684
|
+
}
|
|
685
|
+
function premultiplyInPlace(pixels) {
|
|
686
|
+
for (let index = 0; index < pixels.length; index += 4) {
|
|
687
|
+
const alpha = pixels[index + 3];
|
|
688
|
+
if (alpha === 255)
|
|
689
|
+
continue;
|
|
690
|
+
pixels[index] = Math.round((pixels[index] * alpha) / 255);
|
|
691
|
+
pixels[index + 1] = Math.round((pixels[index + 1] * alpha) / 255);
|
|
692
|
+
pixels[index + 2] = Math.round((pixels[index + 2] * alpha) / 255);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function waitForLoad(video) {
|
|
696
|
+
return new Promise((resolve, reject) => {
|
|
697
|
+
const cleanup = () => {
|
|
698
|
+
clearTimeout(timer);
|
|
699
|
+
video.removeEventListener("loadeddata", ready);
|
|
700
|
+
video.removeEventListener("error", failed);
|
|
701
|
+
};
|
|
702
|
+
const ready = () => {
|
|
703
|
+
cleanup();
|
|
704
|
+
resolve();
|
|
705
|
+
};
|
|
706
|
+
const failed = () => {
|
|
707
|
+
cleanup();
|
|
708
|
+
reject(video.error ?? new Error("video load failed"));
|
|
709
|
+
};
|
|
710
|
+
const timer = setTimeout(() => {
|
|
711
|
+
cleanup();
|
|
712
|
+
reject(new Error("video load timed out"));
|
|
713
|
+
}, 10_000);
|
|
714
|
+
video.addEventListener("loadeddata", ready, { once: true });
|
|
715
|
+
video.addEventListener("error", failed, { once: true });
|
|
716
|
+
video.load();
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Which open extension source answers `consumerId`'s read of `asset`. An animated image decodes in
|
|
721
|
+
* order and keeps only a window of frames, so each reader gets its own (sharing one would rewind
|
|
722
|
+
* it); every other provider serves all readers from one source, as on the native host.
|
|
723
|
+
*/
|
|
724
|
+
function providerKey(asset, consumerId) {
|
|
725
|
+
return asset.provider.format === "animated_image"
|
|
726
|
+
? decoderCursorKey(asset.id, consumerId)
|
|
727
|
+
: asset.id;
|
|
728
|
+
}
|
|
729
|
+
/** Serialized settings by provider object; settings are never mutated, so each serializes once. */
|
|
730
|
+
const providerSettingsKeys = new WeakMap();
|
|
731
|
+
/**
|
|
732
|
+
* `provider`'s settings as one comparable string, serialized once per provider object: an image
|
|
733
|
+
* sequence's holds every frame's locator.
|
|
734
|
+
*/
|
|
735
|
+
export function providerSettings(provider) {
|
|
736
|
+
if (provider === null || typeof provider !== "object")
|
|
737
|
+
return JSON.stringify(provider ?? null);
|
|
738
|
+
let key = providerSettingsKeys.get(provider);
|
|
739
|
+
if (key === undefined) {
|
|
740
|
+
key = JSON.stringify(provider);
|
|
741
|
+
providerSettingsKeys.set(provider, key);
|
|
742
|
+
}
|
|
743
|
+
return key;
|
|
744
|
+
}
|
|
745
|
+
/** Results of {@link sameProviderSettings}, by the first provider, then the second. */
|
|
746
|
+
const providerSettingsEqual = new WeakMap();
|
|
747
|
+
/**
|
|
748
|
+
* Whether two providers have the same settings: the same object, or equal ones. Two objects are
|
|
749
|
+
* compared once, so a pair asked about for every frame costs no serialization or string compare.
|
|
750
|
+
*/
|
|
751
|
+
export function sameProviderSettings(a, b) {
|
|
752
|
+
if (a === b)
|
|
753
|
+
return true;
|
|
754
|
+
const objects = a !== null && typeof a === "object" && b !== null && typeof b === "object";
|
|
755
|
+
if (!objects)
|
|
756
|
+
return providerSettings(a) === providerSettings(b);
|
|
757
|
+
let known = providerSettingsEqual.get(a);
|
|
758
|
+
let same = known?.get(b);
|
|
759
|
+
if (same === undefined) {
|
|
760
|
+
same = providerSettings(a) === providerSettings(b);
|
|
761
|
+
if (!known)
|
|
762
|
+
providerSettingsEqual.set(a, (known = new WeakMap()));
|
|
763
|
+
known.set(b, same);
|
|
764
|
+
}
|
|
765
|
+
return same;
|
|
766
|
+
}
|
|
767
|
+
/** Whether a provider source's pixels depend on the fonts: an SVG's text. */
|
|
768
|
+
function drawsText(provider) {
|
|
769
|
+
return provider?.format === "svg";
|
|
770
|
+
}
|
|
771
|
+
/** Whether two font stacks are the same fonts in the same order. */
|
|
772
|
+
function sameFonts(a, b) {
|
|
773
|
+
return (a.length === b.length &&
|
|
774
|
+
a.every((font, index) => font.bytes === b[index].bytes));
|
|
775
|
+
}
|
|
776
|
+
/** Errors of {@link openProvider} (module load or file read) that a later open may not meet. */
|
|
777
|
+
const retryableOpen = new WeakSet();
|
|
778
|
+
/**
|
|
779
|
+
* Opens an asset's extension source, an SVG with `fonts`. An image sequence's frames enter the
|
|
780
|
+
* provider module only for the call that reads them.
|
|
781
|
+
*/
|
|
782
|
+
async function openProvider(asset, wasmUrl, fonts) {
|
|
783
|
+
const retryable = (error) => {
|
|
784
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
785
|
+
retryableOpen.add(failure);
|
|
786
|
+
return failure;
|
|
787
|
+
};
|
|
788
|
+
const read = async (locator) => {
|
|
789
|
+
const file = asset.frameFiles?.get(locator);
|
|
790
|
+
if (!file)
|
|
791
|
+
throw new Error(`${asset.id} has no file for frame ${locator}`);
|
|
792
|
+
try {
|
|
793
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
throw retryable(error);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
let extensions;
|
|
800
|
+
let own;
|
|
801
|
+
try {
|
|
802
|
+
const { loadExtensions } = await import("alpha2/extensions");
|
|
803
|
+
extensions = await loadExtensions(wasmUrl);
|
|
804
|
+
own = asset.file
|
|
805
|
+
? new Uint8Array(await asset.file.arrayBuffer())
|
|
806
|
+
: undefined;
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
throw retryable(error);
|
|
810
|
+
}
|
|
811
|
+
const source = await extensions.withResources(read, () => extensions.openNeeds(asset.provider), () => {
|
|
812
|
+
// One module serves preview and export sources whose fonts may differ, so set them in the
|
|
813
|
+
// same task as the open.
|
|
814
|
+
if (fonts)
|
|
815
|
+
extensions.setFonts(fonts.map((font) => font.bytes));
|
|
816
|
+
return extensions.open(asset.provider, own);
|
|
817
|
+
});
|
|
818
|
+
return { source, extensions, read };
|
|
819
|
+
}
|