@motionscript/browser 0.0.0-stage → 0.1.0-alpha.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 (120) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +201 -0
  3. package/README.md +68 -3
  4. package/dist/asset-bytes.d.ts +50 -0
  5. package/dist/asset-bytes.d.ts.map +1 -0
  6. package/dist/asset-bytes.js +60 -0
  7. package/dist/asset-bytes.js.map +1 -0
  8. package/dist/audio/bus-graph.d.ts +43 -0
  9. package/dist/audio/bus-graph.d.ts.map +1 -0
  10. package/dist/audio/bus-graph.js +104 -0
  11. package/dist/audio/bus-graph.js.map +1 -0
  12. package/dist/audio/filter-graph.d.ts +58 -0
  13. package/dist/audio/filter-graph.d.ts.map +1 -0
  14. package/dist/audio/filter-graph.js +235 -0
  15. package/dist/audio/filter-graph.js.map +1 -0
  16. package/dist/audio/index.d.ts +5 -0
  17. package/dist/audio/index.d.ts.map +1 -0
  18. package/dist/audio/index.js +12 -0
  19. package/dist/audio/index.js.map +1 -0
  20. package/dist/audio/mixer.d.ts +59 -0
  21. package/dist/audio/mixer.d.ts.map +1 -0
  22. package/dist/audio/mixer.js +193 -0
  23. package/dist/audio/mixer.js.map +1 -0
  24. package/dist/audio/player.d.ts +49 -0
  25. package/dist/audio/player.d.ts.map +1 -0
  26. package/dist/audio/player.js +226 -0
  27. package/dist/audio/player.js.map +1 -0
  28. package/dist/browser/audio.js +2 -0
  29. package/dist/browser/audio.js.map +7 -0
  30. package/dist/browser/chunks/GLTFLoader-PGXD4V5B.js +2 -0
  31. package/dist/browser/chunks/GLTFLoader-PGXD4V5B.js.map +7 -0
  32. package/dist/browser/chunks/SkeletonUtils-XYDMTGTM.js +2 -0
  33. package/dist/browser/chunks/SkeletonUtils-XYDMTGTM.js.map +7 -0
  34. package/dist/browser/chunks/chunk-CTLBXZSU.js +4119 -0
  35. package/dist/browser/chunks/chunk-CTLBXZSU.js.map +7 -0
  36. package/dist/browser/chunks/chunk-FQ32PDIU.js +2 -0
  37. package/dist/browser/chunks/chunk-FQ32PDIU.js.map +7 -0
  38. package/dist/browser/chunks/chunk-FY3BTKY4.js +2 -0
  39. package/dist/browser/chunks/chunk-FY3BTKY4.js.map +7 -0
  40. package/dist/browser/chunks/chunk-NSFLRI4P.js +2 -0
  41. package/dist/browser/chunks/chunk-NSFLRI4P.js.map +7 -0
  42. package/dist/browser/chunks/three.module-Z4EOVUWF.js +2 -0
  43. package/dist/browser/chunks/three.module-Z4EOVUWF.js.map +7 -0
  44. package/dist/browser/esbuild.wasm +0 -0
  45. package/dist/browser/index.js +2172 -0
  46. package/dist/browser/index.js.map +7 -0
  47. package/dist/browser/manifest.json +15 -0
  48. package/dist/concat.d.ts +95 -0
  49. package/dist/concat.d.ts.map +1 -0
  50. package/dist/concat.js +174 -0
  51. package/dist/concat.js.map +1 -0
  52. package/dist/decode/image-levels.d.ts +137 -0
  53. package/dist/decode/image-levels.d.ts.map +1 -0
  54. package/dist/decode/image-levels.js +477 -0
  55. package/dist/decode/image-levels.js.map +1 -0
  56. package/dist/decode/levels.d.ts +37 -0
  57. package/dist/decode/levels.d.ts.map +1 -0
  58. package/dist/decode/levels.js +68 -0
  59. package/dist/decode/levels.js.map +1 -0
  60. package/dist/decode/video-levels.d.ts +37 -0
  61. package/dist/decode/video-levels.d.ts.map +1 -0
  62. package/dist/decode/video-levels.js +95 -0
  63. package/dist/decode/video-levels.js.map +1 -0
  64. package/dist/dynamic-nodes.d.ts +9 -0
  65. package/dist/dynamic-nodes.d.ts.map +1 -0
  66. package/dist/dynamic-nodes.js +56 -0
  67. package/dist/dynamic-nodes.js.map +1 -0
  68. package/dist/engine.d.ts +59 -0
  69. package/dist/engine.d.ts.map +1 -0
  70. package/dist/engine.js +171 -0
  71. package/dist/engine.js.map +1 -0
  72. package/dist/exporter.d.ts +163 -0
  73. package/dist/exporter.d.ts.map +1 -0
  74. package/dist/exporter.js +263 -0
  75. package/dist/exporter.js.map +1 -0
  76. package/dist/getter.d.ts +10 -0
  77. package/dist/getter.d.ts.map +1 -0
  78. package/dist/getter.js +38 -0
  79. package/dist/getter.js.map +1 -0
  80. package/dist/index.d.ts +22 -0
  81. package/dist/index.d.ts.map +1 -0
  82. package/dist/index.js +72 -0
  83. package/dist/index.js.map +1 -0
  84. package/dist/measure-context.d.ts +12 -0
  85. package/dist/measure-context.d.ts.map +1 -0
  86. package/dist/measure-context.js +12 -0
  87. package/dist/measure-context.js.map +1 -0
  88. package/dist/mix-timeline-audio.d.ts +42 -0
  89. package/dist/mix-timeline-audio.d.ts.map +1 -0
  90. package/dist/mix-timeline-audio.js +63 -0
  91. package/dist/mix-timeline-audio.js.map +1 -0
  92. package/dist/output.d.ts +7 -0
  93. package/dist/output.d.ts.map +1 -0
  94. package/dist/output.js +6 -0
  95. package/dist/output.js.map +1 -0
  96. package/dist/render-context.d.ts +30 -0
  97. package/dist/render-context.d.ts.map +1 -0
  98. package/dist/render-context.js +55 -0
  99. package/dist/render-context.js.map +1 -0
  100. package/dist/screenshot.d.ts +45 -0
  101. package/dist/screenshot.d.ts.map +1 -0
  102. package/dist/screenshot.js +32 -0
  103. package/dist/screenshot.js.map +1 -0
  104. package/dist/still.d.ts +151 -0
  105. package/dist/still.d.ts.map +1 -0
  106. package/dist/still.js +222 -0
  107. package/dist/still.js.map +1 -0
  108. package/dist/storage-adapter.d.ts +367 -0
  109. package/dist/storage-adapter.d.ts.map +1 -0
  110. package/dist/storage-adapter.js +1278 -0
  111. package/dist/storage-adapter.js.map +1 -0
  112. package/dist/three/renderer.d.ts +48 -0
  113. package/dist/three/renderer.d.ts.map +1 -0
  114. package/dist/three/renderer.js +177 -0
  115. package/dist/three/renderer.js.map +1 -0
  116. package/dist/ticker.d.ts +42 -0
  117. package/dist/ticker.d.ts.map +1 -0
  118. package/dist/ticker.js +143 -0
  119. package/dist/ticker.js.map +1 -0
  120. package/package.json +74 -3
@@ -0,0 +1,1278 @@
1
+ import { StorageAdapter } from "@motionscript/core";
2
+ import { ALL_FORMATS, BlobSource, Input, UrlSource, VideoSampleSink } from "mediabunny";
3
+ import { ParagraphShapeCache } from "@motionscript/skia-render/shapes/paragraph-cache";
4
+ // Type-only three usage keeps this a real lazy boundary — see three/bridge.ts.
5
+ import { hasPendingCanvas3D, warmPendingCanvas3D } from "@motionscript/skia-render/three/bridge";
6
+ import { localAssetBytes } from "./asset-bytes";
7
+ import { ImageLevelCache } from "./decode/image-levels";
8
+ import { levelCap, levelSize, MAX_IMAGE_DECODE_EDGE } from "./decode/levels";
9
+ import { VideoLevel, videoWindowFrames } from "./decode/video-levels";
10
+ /** A view over a readback's own buffer — a copy of a level's pixels is a second full-size allocation. */
11
+ function pixelsOf(image) {
12
+ const { data } = image;
13
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
14
+ }
15
+ /** Whether a src points at an SVG (by extension, ignoring any query/hash). */
16
+ function isSvgSrc(src) {
17
+ return /\.svg(?:[?#]|$)/i.test(src);
18
+ }
19
+ /** The buckets {@link WebStorageAdapter.assetUrl} reads a source's address from, in order. */
20
+ const ASSET_URL_LOOKUPS = {
21
+ image: [(c, src) => c.getImageMeta(src)],
22
+ video: [(c, src) => c.getVideoMeta(src)],
23
+ media: [
24
+ (c, src) => c.getAudioMeta(src),
25
+ (c, src) => c.getVideoMeta(src),
26
+ ],
27
+ };
28
+ /**
29
+ * Browser implementation of {@link StorageAdapter} — owns all async asset
30
+ * decoding (images, video, audio, fonts) so the render loop can stay
31
+ * synchronous.
32
+ *
33
+ * Images and video are held at **levels** chosen from the footprints the
34
+ * renderer's getters report, not from declared layout boxes: a 4K photo drawn
35
+ * as a thumbnail holds a thumbnail's pixels, and the same photo zoomed to fill
36
+ * the screen is re-decoded to match. See `decode/image-levels.ts`.
37
+ */
38
+ export class WebStorageAdapter extends StorageAdapter {
39
+ canvasKit;
40
+ fontMgr;
41
+ disposed = false;
42
+ /**
43
+ * GPU surface the render context draws into; needed to upload decoded video
44
+ * frames straight to texture (Surface.makeImageFromTextureSource /
45
+ * updateTextureFromSource). Set by the render context on mount.
46
+ */
47
+ surface = null;
48
+ /** Open decode session per video src (opened once by loadVideo, deduped). */
49
+ videoSessions = new Map();
50
+ /** Decoded frame bitmaps per src, keyed by quantized source timestamp; bounded to the forward/back window. */
51
+ videoFrames = new Map();
52
+ /**
53
+ * Decoded past frames the Echo filter needs, kept in a SEPARATE cache so the
54
+ * forward/back window eviction (which is centered on the current playhead)
55
+ * never drops them — echo taps reach intentionally outside that window.
56
+ * Bounded by trimming to the most-recently requested {@link ECHO_CACHE_LIMIT}
57
+ * per src.
58
+ */
59
+ videoEchoFrames = new Map();
60
+ /** Last timestamp each src's playhead was asked to show, used to drive sequential decoding forward. */
61
+ videoPlayhead = new Map();
62
+ /**
63
+ * Exact `(src, timestamp)` pairs a render asked for but couldn't satisfy from
64
+ * the warm window. A blocking caller drains these via
65
+ * {@link warmPendingDecodes} and re-renders.
66
+ */
67
+ pendingVideoFrames = new Map();
68
+ /** Like {@link pendingVideoFrames}, for the several past frames an echo or a second playhead needs at once. */
69
+ pendingEchoFrames = new Map();
70
+ /** In-flight echo/extra-playhead decodes, keyed `src#quantizedTs`, so callers join one seek. */
71
+ echoDecodes = new Map();
72
+ /**
73
+ * Quantized timestamps claimed per src during the current render pass, in claim
74
+ * order (see {@link claimVideoFrame}): index 0 holds the session's own texture,
75
+ * index *n* holds {@link videoAltSlots} slot *n−1*. Cleared by
76
+ * {@link beginRenderPass}.
77
+ */
78
+ videoFrameClaims = new Map();
79
+ /**
80
+ * Extra textures per src — one per *simultaneously visible* time of that clip
81
+ * beyond the first. Persistent and updated in place exactly like a session's
82
+ * own texture, so a second playhead costs a blit per frame, not an allocation.
83
+ */
84
+ videoAltSlots = new Map();
85
+ /** The last footprint each clip was drawn at, for sizing a session opened later. */
86
+ videoDemand = new Map();
87
+ /**
88
+ * Whether playback is live. When false the adapter does no look-ahead
89
+ * prefetch, so a paused clip never drains a backlog of stale decodes.
90
+ */
91
+ playing = false;
92
+ decodePolicy = "interactive";
93
+ images;
94
+ imageSources = new Map();
95
+ reportedImageErrors = new Set();
96
+ audioBuffers = new Map();
97
+ audioCtx = null;
98
+ fps;
99
+ /** Families already registered with the font provider (registered once, all weights at once). */
100
+ registeredFontFamilies = new Set();
101
+ /**
102
+ * Families the catalog could not describe, so the warning is emitted once
103
+ * rather than on every `loadAt`. Cleared by {@link setCatalog}: a new
104
+ * manifest is exactly the event that can turn one of these into a real face.
105
+ */
106
+ unknownFontFamilies = new Set();
107
+ /** Cache of shaped straight-text runs; owns the cached Fonts. See {@link ParagraphShapeCache}. */
108
+ paragraphCache = new ParagraphShapeCache();
109
+ /** Bumped whenever a new font family registers, invalidating runs shaped against a fallback. */
110
+ fontEpoch = 0;
111
+ /** Past frames the Echo cache holds per src; caps the renderer's MAX_ECHOES (32) with slack. */
112
+ static ECHO_CACHE_LIMIT = 48;
113
+ /**
114
+ * Max extra textures per src (see {@link videoAltSlots}) — how many different
115
+ * times of one clip can be on screen together. Past it the last slot is reused.
116
+ */
117
+ static ALT_SLOT_LIMIT = 16;
118
+ constructor(canvasKit, catalog, viewport, fps) {
119
+ super(catalog, viewport);
120
+ this.canvasKit = canvasKit;
121
+ this.fps = fps;
122
+ this.fontMgr = canvasKit.TypefaceFontProvider.Make();
123
+ this.images = new ImageLevelCache({
124
+ now: () => performance.now(),
125
+ decode: (src, chooseEdge) => this.decodeImage(src, chooseEdge),
126
+ released: (_src, level) => {
127
+ level.gpu?.delete();
128
+ level.gpu = null;
129
+ },
130
+ sourceBytes: (src) => this.imageSources.get(src)?.bytes ?? 0,
131
+ dropSource: (src) => this.imageSources.delete(src),
132
+ surfaceEdge: () => this.surfaceEdge(),
133
+ onError: (src, error) => {
134
+ if (this.disposed || this.reportedImageErrors.has(src))
135
+ return;
136
+ this.reportedImageErrors.add(src);
137
+ console.error(`[WebStorageAdapter] image decode failed for ${src}:`, error);
138
+ },
139
+ });
140
+ }
141
+ getCanvasKit() {
142
+ return this.canvasKit;
143
+ }
144
+ /**
145
+ * Adopt a new manifest, and forget which families it previously could not
146
+ * describe — installing a typeface folds it into the live manifest without
147
+ * rebuilding this adapter, and that family deserves a fresh attempt.
148
+ */
149
+ setCatalog(catalog) {
150
+ super.setCatalog(catalog);
151
+ this.unknownFontFamilies.clear();
152
+ }
153
+ // ─── Decode policy ───────────────────────────────────────────────────────
154
+ setDecodePolicy(policy) {
155
+ this.decodePolicy = policy;
156
+ this.images.setPolicy(policy);
157
+ }
158
+ getDecodePolicy() {
159
+ return this.decodePolicy;
160
+ }
161
+ hasPendingDecodes() {
162
+ return this.pendingVideoFrames.size > 0
163
+ || this.pendingEchoFrames.size > 0
164
+ || this.echoDecodes.size > 0
165
+ || hasPendingCanvas3D()
166
+ || this.images.hasPending();
167
+ }
168
+ /**
169
+ * Settle what the last render could not draw acceptably: video frames, echo
170
+ * taps, the 3D runtime, and images — every exact level under `"exact"`, only
171
+ * a source drawn with nothing resident under `"interactive"`, whose upgrades
172
+ * are {@link warmPendingUpgrades}. Returns whether there was any, so a
173
+ * blocking caller re-renders only when something changed.
174
+ */
175
+ async warmPendingDecodes() {
176
+ const [video, images] = await Promise.all([this.warmVideo(), this.images.warm()]);
177
+ return video || images;
178
+ }
179
+ hasPendingUpgrades() {
180
+ return this.images.hasUpgrades();
181
+ }
182
+ warmPendingUpgrades() {
183
+ return this.images.warmUpgrades();
184
+ }
185
+ /** @deprecated Use {@link warmPendingDecodes}. */
186
+ warmPendingVideo() {
187
+ return this.warmPendingDecodes();
188
+ }
189
+ /** Resident decode memory, for tests and the e2e memory script. */
190
+ _decodeStats() {
191
+ let windowFrames = 0;
192
+ let echoFrames = 0;
193
+ let frameBytes = 0;
194
+ const counted = new Set();
195
+ const count = (frames) => {
196
+ for (const { bitmap } of frames.values()) {
197
+ if (counted.has(bitmap))
198
+ continue;
199
+ counted.add(bitmap);
200
+ frameBytes += (bitmap.width ?? 0) * (bitmap.height ?? 0) * 4;
201
+ }
202
+ };
203
+ for (const frames of this.videoFrames.values()) {
204
+ windowFrames += frames.size;
205
+ count(frames);
206
+ }
207
+ for (const frames of this.videoEchoFrames.values()) {
208
+ echoFrames += frames.size;
209
+ count(frames);
210
+ }
211
+ return {
212
+ images: this.images.stats(),
213
+ video: { sessions: this.videoSessions.size, windowFrames, echoFrames, frameBytes },
214
+ };
215
+ }
216
+ // ─── Image ───────────────────────────────────────────────────────────────
217
+ /**
218
+ * Make `src` drawable: decode its first level. Which level that is follows the
219
+ * policy — see `ImageLevelCache.load`. Every later level follows the
220
+ * footprints the draws report.
221
+ */
222
+ async loadImage(src) {
223
+ await this.images.load(src);
224
+ }
225
+ /** GPU-resident image for `src` at the level this draw needs, or null until it has loaded. */
226
+ getCKImage(src, footprint) {
227
+ const level = this.images.select(src, footprint);
228
+ if (!level)
229
+ return null;
230
+ level.gpu ??= this.makeCKImageFromPixels(level.pixels, level.width, level.height);
231
+ return level.gpu;
232
+ }
233
+ /**
234
+ * Decoded RGBA8888 pixels for `src` at the level this draw needs, or null
235
+ * until it has loaded. The same object comes back for as long as that level
236
+ * is resident, which is what the 3D texture cache keys its uploads on.
237
+ */
238
+ getImagePixels(src, footprint) {
239
+ return this.images.select(src, footprint);
240
+ }
241
+ getImageSize(src) {
242
+ return this.images.intrinsicSize(src) ?? this.catalogImageSize(src);
243
+ }
244
+ catalogImageSize(src) {
245
+ try {
246
+ const meta = this.catalog.getImageMeta(src);
247
+ return meta.width > 0 && meta.height > 0 ? { width: meta.width, height: meta.height } : null;
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ /**
254
+ * Decode one level of `src`.
255
+ *
256
+ * A raster decodes from its kept compressed bytes, resized by the browser's
257
+ * own high-quality scaler; with no manifest size it is decoded once at
258
+ * natural size to learn it. An SVG is rasterized from its kept `<img>` at
259
+ * any level, since it has no resolution of its own to cap at.
260
+ */
261
+ async decodeImage(src, chooseEdge) {
262
+ const url = this.assetUrl(src, "image");
263
+ if (isSvgSrc(url)) {
264
+ const svg = await this.svgSource(src, url);
265
+ const intrinsic = svg.natural;
266
+ const edge = chooseEdge(intrinsic, true);
267
+ const size = levelSize(edge, intrinsic);
268
+ const canvas = new OffscreenCanvas(size.width, size.height);
269
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
270
+ if (!ctx)
271
+ throw new Error(`loadImage(${src}): could not get 2d context`);
272
+ ctx.drawImage(svg.svg, 0, 0, size.width, size.height);
273
+ return { pixels: pixelsOf(ctx.getImageData(0, 0, size.width, size.height)), ...size, intrinsic, vector: true, edge };
274
+ }
275
+ const blob = await this.rasterSource(src, url);
276
+ let intrinsic = this.catalogImageSize(src);
277
+ let full = null;
278
+ if (!intrinsic) {
279
+ full = await createImageBitmap(blob);
280
+ intrinsic = { width: full.width, height: full.height };
281
+ }
282
+ const edge = chooseEdge(intrinsic, false);
283
+ const natural = edge >= levelCap(intrinsic, false)
284
+ && Math.max(intrinsic.width, intrinsic.height) <= MAX_IMAGE_DECODE_EDGE;
285
+ let bitmap;
286
+ if (natural) {
287
+ bitmap = full ?? await createImageBitmap(blob);
288
+ }
289
+ else {
290
+ const size = levelSize(edge, intrinsic);
291
+ try {
292
+ bitmap = await createImageBitmap(full ?? blob, {
293
+ resizeWidth: size.width,
294
+ resizeHeight: size.height,
295
+ resizeQuality: "high",
296
+ });
297
+ }
298
+ finally {
299
+ full?.close();
300
+ }
301
+ }
302
+ try {
303
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
304
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
305
+ if (!ctx)
306
+ throw new Error(`loadImage(${src}): could not get 2d context`);
307
+ ctx.drawImage(bitmap, 0, 0);
308
+ return {
309
+ pixels: pixelsOf(ctx.getImageData(0, 0, bitmap.width, bitmap.height)),
310
+ width: bitmap.width,
311
+ height: bitmap.height,
312
+ intrinsic,
313
+ vector: false,
314
+ edge,
315
+ };
316
+ }
317
+ finally {
318
+ bitmap.close();
319
+ }
320
+ }
321
+ async rasterSource(src, url) {
322
+ const kept = this.imageSources.get(src);
323
+ if (kept?.blob)
324
+ return kept.blob;
325
+ const blob = await this.fetchBlob(url);
326
+ this.imageSources.set(src, { blob, bytes: blob.size });
327
+ return blob;
328
+ }
329
+ /**
330
+ * Decodes an SVG through an `HTMLImageElement`, which every browser
331
+ * supports — `createImageBitmap` can't decode SVG reliably (Chrome throws on
332
+ * an SVG Blob). From a data URL rather than an object URL so a missing xmlns
333
+ * or external ref can't taint the image and fail the readback.
334
+ */
335
+ async svgSource(src, url) {
336
+ const kept = this.imageSources.get(src);
337
+ if (kept?.svg && kept.natural)
338
+ return kept;
339
+ const text = await (await this.fetchBlob(url)).text();
340
+ const img = new Image();
341
+ img.decoding = "sync";
342
+ await new Promise((resolve, reject) => {
343
+ img.onload = () => resolve();
344
+ img.onerror = () => reject(new Error(`loadImage(${src}): failed to decode SVG`));
345
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text)}`;
346
+ });
347
+ // 300 is the CSS default replaced-element size, which is what a browser
348
+ // itself gives an SVG with no intrinsic dimensions.
349
+ const natural = {
350
+ width: img.naturalWidth > 0 ? img.naturalWidth : 300,
351
+ height: img.naturalHeight > 0 ? img.naturalHeight : 300,
352
+ };
353
+ const source = { svg: img, natural, bytes: text.length };
354
+ this.imageSources.set(src, source);
355
+ return source;
356
+ }
357
+ /** Long edge of the draw surface in device px, or the viewport before one is attached. */
358
+ surfaceEdge() {
359
+ const surface = this.surface;
360
+ if (surface && typeof surface.width === "function" && typeof surface.height === "function") {
361
+ return Math.max(surface.width(), surface.height());
362
+ }
363
+ return Math.max(this.viewport.width, this.viewport.height);
364
+ }
365
+ /**
366
+ * The compressed bytes for `src`, from the host's local store if it has
367
+ * them and from the network otherwise. Every byte-reading path goes through
368
+ * this, so a source is local for all of images, video, audio and fonts or
369
+ * for none of them.
370
+ */
371
+ async fetchBlob(src) {
372
+ const local = await localAssetBytes(src);
373
+ if (local)
374
+ return local;
375
+ return (await fetch(src)).blob();
376
+ }
377
+ /**
378
+ * The address `src` is fetched from.
379
+ *
380
+ * A manifest key and the URL its bytes live at are the same string only
381
+ * while the host keys its manifest by URL; `meta.src` is the address. An
382
+ * undescribed source falls back to itself. `media` is audio-then-video
383
+ * because a video's own soundtrack is requested under the video's key.
384
+ */
385
+ assetUrl(src, kind) {
386
+ const lookup = ASSET_URL_LOOKUPS[kind];
387
+ for (const read of lookup) {
388
+ try {
389
+ const found = read(this.catalog, src).src;
390
+ if (found)
391
+ return found;
392
+ }
393
+ catch {
394
+ // Undescribed in this bucket; try the next, then `src` itself.
395
+ }
396
+ }
397
+ return src;
398
+ }
399
+ // ─── 3D compositing ──────────────────────────────────────────────────────
400
+ /** Persistent CanvasKit texture per 3D slot, so a 60 fps 3D scene doesn't allocate a texture a frame. */
401
+ canvas3DTextures = new Map();
402
+ /**
403
+ * Upload a 3D renderer's canvas into a GPU-resident CanvasKit image, creating
404
+ * the texture on first use and updating it in place afterwards.
405
+ *
406
+ * **The source arrives unpremultiplied, even though the canvas is not.**
407
+ * Uploading a canvas *as a texture source* goes through the browser's unpack
408
+ * pipeline, where `UNPACK_PREMULTIPLY_ALPHA_WEBGL` is false by default, so
409
+ * what reaches Skia is straight colour. Declaring it `Premul` made Skia
410
+ * composite additively — invisible at full alpha, and read as `opacity`
411
+ * being ignored on a translucent surface. `packages/e2e` has a probe in a
412
+ * colour that separates the two.
413
+ *
414
+ * The returned image is owned by this adapter (keyed by slot): it must NOT
415
+ * be deleted after the draw, or the next frame loses its texture.
416
+ */
417
+ upload3DFrame(key,
418
+ // In this backend always an HTMLCanvasElement | OffscreenCanvas | ImageBitmap;
419
+ // widened to the seam's opaque handle so skia-render never names a DOM type.
420
+ source, width, height) {
421
+ const surface = this.surface;
422
+ if (!surface)
423
+ return null;
424
+ const info = {
425
+ width,
426
+ height,
427
+ alphaType: this.canvasKit.AlphaType.Unpremul,
428
+ colorType: this.canvasKit.ColorType.RGBA_8888,
429
+ colorSpace: this.canvasKit.ColorSpace.SRGB,
430
+ };
431
+ let image = this.canvas3DTextures.get(key);
432
+ // The buffer can grow (see the renderer's size quantisation); a texture is
433
+ // fixed-size, so a size change needs a fresh one.
434
+ if (image && (image.width() !== width || image.height() !== height)) {
435
+ image.delete();
436
+ this.canvas3DTextures.delete(key);
437
+ image = undefined;
438
+ }
439
+ if (!image) {
440
+ // CanvasKit's TextureSource type omits canvas elements, though the
441
+ // underlying texImage2D upload accepts them.
442
+ const made = surface.makeImageFromTextureSource(source, info, false);
443
+ if (!made)
444
+ return null;
445
+ this.canvas3DTextures.set(key, made);
446
+ return made;
447
+ }
448
+ surface.updateTextureFromSource(image, source, false);
449
+ return image;
450
+ }
451
+ /** Release a 3D node's texture — called when its node is swept. */
452
+ release3DTexture(key) {
453
+ this.canvas3DTextures.get(key)?.delete();
454
+ this.canvas3DTextures.delete(key);
455
+ }
456
+ // ─── Render pass ─────────────────────────────────────────────────────────
457
+ /**
458
+ * Receive the GPU surface so decoded video frames can upload straight to
459
+ * texture. A texture-backed image is only valid for the surface that made it,
460
+ * so swapping surfaces drops every one; they are recreated on the next frame.
461
+ * Image levels are raster images and survive.
462
+ */
463
+ setSurface(surface) {
464
+ if (surface === this.surface)
465
+ return;
466
+ for (const session of this.videoSessions.values()) {
467
+ session.textureImage?.delete();
468
+ session.textureImage = null;
469
+ session.uploadedTs = null;
470
+ session.uploadedBitmap = null;
471
+ }
472
+ this.clearVideoAltSlots();
473
+ for (const image of this.canvas3DTextures.values())
474
+ image.delete();
475
+ this.canvas3DTextures.clear();
476
+ this.surface = surface;
477
+ }
478
+ /**
479
+ * Toggle live-playback mode. While playing, the adapter prefetches a window
480
+ * ahead; when paused it decodes only the exact frame the render needs, so
481
+ * pausing immediately quiesces.
482
+ */
483
+ setPlaying(playing) {
484
+ this.playing = playing;
485
+ }
486
+ /** Start a render pass: forget which timestamp each clip's texture was claimed for. */
487
+ beginRenderPass() {
488
+ this.videoFrameClaims.clear();
489
+ this.images.beginPass();
490
+ }
491
+ /**
492
+ * End a render pass: every draw has reported its footprint, so decide level
493
+ * changes now rather than chasing each draw.
494
+ */
495
+ endRenderPass() {
496
+ this.images.endPass();
497
+ const now = performance.now();
498
+ for (const [src, session] of this.videoSessions) {
499
+ const moved = session.levels?.endPass(this.decodePolicy, this.playing, now) ?? false;
500
+ if (!moved)
501
+ continue;
502
+ this.videoDemand.set(src, session.levels.level);
503
+ const size = levelSize(session.levels.level, session.intrinsic);
504
+ session.width = size.width;
505
+ session.height = size.height;
506
+ // What this pass drew is now at the wrong level: queue the exact
507
+ // frames again so a settling caller lands them at the new one.
508
+ const claims = this.videoFrameClaims.get(src) ?? [];
509
+ claims.forEach((key, index) => {
510
+ const ts = key * session.frameStep;
511
+ if (index === 0) {
512
+ // A miss already queued the time it asked for; the claim
513
+ // only records the fallback frame that was painted.
514
+ if (!this.pendingVideoFrames.has(src))
515
+ this.pendingVideoFrames.set(src, ts);
516
+ }
517
+ else {
518
+ let set = this.pendingEchoFrames.get(src);
519
+ if (!set) {
520
+ set = new Set();
521
+ this.pendingEchoFrames.set(src, set);
522
+ }
523
+ set.add(ts);
524
+ }
525
+ });
526
+ if (this.playing)
527
+ this.driveSequentialDecode(src, this.videoPlayhead.get(src) ?? 0);
528
+ }
529
+ }
530
+ // ─── Video ───────────────────────────────────────────────────────────────
531
+ /**
532
+ * Open a streaming decode session for `src` (deduped — runs once per clip)
533
+ * and warm the first frame around `trimStart` so the first synchronous render
534
+ * hits cache. The rest streams in as the playhead advances; the level
535
+ * follows the footprints claims report.
536
+ */
537
+ async loadVideo(src, _startFrame, _endFrame, trimStart = 0, _trimEnd) {
538
+ if (this.videoSessions.has(src))
539
+ return;
540
+ // A local blob is handed to mediabunny as a `BlobSource` rather than
541
+ // downloaded first: both are random-access, so the streaming decode and
542
+ // the frame window behave identically.
543
+ const url = this.assetUrl(src, "video");
544
+ const local = await localAssetBytes(url);
545
+ const input = new Input({
546
+ formats: ALL_FORMATS,
547
+ source: local ? new BlobSource(local) : new UrlSource(url),
548
+ });
549
+ const track = await input.getPrimaryVideoTrack();
550
+ if (!track) {
551
+ input.dispose();
552
+ throw new Error(`loadVideo(${src}): no video track found`);
553
+ }
554
+ const sink = new VideoSampleSink(track);
555
+ const durationSec = await input.computeDuration();
556
+ const probe = await this.probeFirstFrame(sink);
557
+ const intrinsic = this.catalogVideoSize(src) ?? await this.trackDisplaySize(track, probe);
558
+ const levels = new VideoLevel(intrinsic, this.decodePolicy, this.videoDemand.get(src), this.surfaceEdge());
559
+ const size = levelSize(levels.level, intrinsic);
560
+ this.videoSessions.set(src, {
561
+ input, sink, durationSec, frameStep: probe.frameStep,
562
+ width: size.width, height: size.height, intrinsic, levels, canvas: null,
563
+ textureImage: null, uploadedTs: null, decoding: false,
564
+ });
565
+ this.videoFrames.set(src, new Map());
566
+ // Warm the entry frame so the first render after load is never empty.
567
+ await this.decodeAt(src, trimStart);
568
+ }
569
+ getVideoSize(src) {
570
+ return this.videoSessions.get(src)?.intrinsic ?? this.catalogVideoSize(src);
571
+ }
572
+ catalogVideoSize(src) {
573
+ try {
574
+ const meta = this.catalog.getVideoMeta(src);
575
+ return meta.width > 0 && meta.height > 0 ? { width: meta.width, height: meta.height } : null;
576
+ }
577
+ catch {
578
+ return null;
579
+ }
580
+ }
581
+ async trackDisplaySize(track, probe) {
582
+ try {
583
+ const width = await track.getDisplayWidth();
584
+ const height = await track.getDisplayHeight();
585
+ if (width > 0 && height > 0)
586
+ return { width, height };
587
+ }
588
+ catch {
589
+ // fall through to the probed sample
590
+ }
591
+ return { width: Math.max(1, probe.width), height: Math.max(1, probe.height) };
592
+ }
593
+ /**
594
+ * Synchronously return the GPU image for the frame at `timestamp` (source
595
+ * seconds), uploaded in place into the session's one texture. On a miss it
596
+ * records the exact frame for a blocking re-render and, while playing, primes
597
+ * the sequential decoder; it returns the nearest decoded frame so the picture
598
+ * never goes blank mid-stream.
599
+ *
600
+ * A *drawing* caller should go through {@link claimVideoFrame}, which owns
601
+ * the rule for one frame needing two different times of the same clip.
602
+ */
603
+ getVideoFrame(src, timestamp) {
604
+ const session = this.videoSessions.get(src);
605
+ const frames = this.videoFrames.get(src);
606
+ if (!session || !frames || !this.surface)
607
+ return null;
608
+ if (!Number.isFinite(timestamp))
609
+ timestamp = 0;
610
+ this.videoPlayhead.set(src, timestamp);
611
+ const frame = this.nearestDecoded(frames, timestamp, session.frameStep);
612
+ const exactKey = this.quantizeTs(timestamp, session.frameStep);
613
+ const haveExact = !!frame
614
+ && this.quantizeTs(frame.timestamp, session.frameStep) === exactKey
615
+ && this.accepts(session, frame);
616
+ if (!haveExact) {
617
+ this.pendingVideoFrames.set(src, timestamp);
618
+ if (this.playing)
619
+ this.driveSequentialDecode(src, timestamp);
620
+ }
621
+ else if (this.playing) {
622
+ this.driveSequentialDecode(src, timestamp);
623
+ }
624
+ if (!frame)
625
+ return null;
626
+ return this.uploadFrame(src, session, frame);
627
+ }
628
+ /**
629
+ * The image to paint `src` at `timestamp` with, for one draw in the current
630
+ * render pass. **This, not {@link getVideoFrame}, is what a fill should call.**
631
+ *
632
+ * A session's texture is updated in place and Skia doesn't resolve a draw
633
+ * until the surface flushes, so two draws of the same clip at different times
634
+ * would both sample whatever was uploaded last. Each distinct timestamp a
635
+ * pass asks for therefore gets its own texture: the first claim takes the
636
+ * session's, the rest take {@link videoAltSlots} by claim order. Every
637
+ * returned image is adapter-owned.
638
+ */
639
+ claimVideoFrame(src, timestamp, footprint) {
640
+ const session = this.videoSessions.get(src);
641
+ if (!session || !this.surface)
642
+ return null;
643
+ if (!Number.isFinite(timestamp))
644
+ timestamp = 0;
645
+ session.levels?.note(footprint);
646
+ const key = this.quantizeTs(timestamp, session.frameStep);
647
+ let claims = this.videoFrameClaims.get(src);
648
+ if (!claims) {
649
+ claims = [];
650
+ this.videoFrameClaims.set(src, claims);
651
+ }
652
+ const claimed = claims.indexOf(key);
653
+ if (claimed === 0)
654
+ return session.textureImage;
655
+ if (claimed > 0)
656
+ return this.videoAltSlots.get(src)?.[claimed - 1]?.image ?? null;
657
+ if (claims.length === 0) {
658
+ const image = this.getVideoFrame(src, timestamp);
659
+ // Record what the texture actually holds, not what was asked for: a
660
+ // miss paints the nearest decoded frame, and a later draw wanting
661
+ // *that* frame can then share it.
662
+ if (image)
663
+ claims.push(session.uploadedTs ?? key);
664
+ return image;
665
+ }
666
+ const slot = Math.min(claims.length - 1, WebStorageAdapter.ALT_SLOT_LIMIT - 1);
667
+ const image = this.uploadAltFrame(src, session, timestamp, slot);
668
+ if (image)
669
+ claims.push(key);
670
+ return image;
671
+ }
672
+ /** Free every alt-slot texture (surface swap, teardown). */
673
+ clearVideoAltSlots() {
674
+ for (const slots of this.videoAltSlots.values()) {
675
+ for (const slot of slots)
676
+ slot.image?.delete();
677
+ }
678
+ this.videoAltSlots.clear();
679
+ }
680
+ /** Upload the frame for a non-primary timestamp into its slot — see {@link claimVideoFrame}. */
681
+ uploadAltFrame(src, session, timestamp, slotIndex) {
682
+ const surface = this.surface;
683
+ if (!surface)
684
+ return null;
685
+ const frame = this.decodedFrameFor(src, session, timestamp);
686
+ if (!frame)
687
+ return null;
688
+ let slots = this.videoAltSlots.get(src);
689
+ if (!slots) {
690
+ slots = [];
691
+ this.videoAltSlots.set(src, slots);
692
+ }
693
+ let slot = slots[slotIndex];
694
+ if (!slot) {
695
+ slot = { image: null, uploadedTs: null };
696
+ slots[slotIndex] = slot;
697
+ }
698
+ const key = this.quantizeTs(frame.timestamp, session.frameStep);
699
+ if (slot.image && slot.uploadedTs === key && (slot.bitmap === undefined || slot.bitmap === frame.bitmap)) {
700
+ return slot.image;
701
+ }
702
+ this.uploadInto(slot, frame, session);
703
+ slot.uploadedTs = slot.image ? key : null;
704
+ return slot.image;
705
+ }
706
+ /**
707
+ * Upload `frame` into `slot`'s texture — in place when the size held, fresh
708
+ * when a level change resized the frames, since a texture is fixed-size.
709
+ */
710
+ uploadInto(slot, frame, session) {
711
+ const surface = this.surface;
712
+ const width = frame.bitmap.width || session.width;
713
+ const height = frame.bitmap.height || session.height;
714
+ if (slot.image && (slot.width !== width || slot.height !== height)) {
715
+ slot.image.delete();
716
+ slot.image = null;
717
+ }
718
+ if (!slot.image) {
719
+ slot.image = surface.makeImageFromTextureSource(frame.bitmap, {
720
+ width,
721
+ height,
722
+ alphaType: this.canvasKit.AlphaType.Unpremul,
723
+ colorType: this.canvasKit.ColorType.RGBA_8888,
724
+ colorSpace: this.canvasKit.ColorSpace.SRGB,
725
+ });
726
+ }
727
+ else {
728
+ surface.updateTextureFromSource(slot.image, frame.bitmap);
729
+ }
730
+ slot.width = width;
731
+ slot.height = height;
732
+ slot.bitmap = slot.image ? frame.bitmap : null;
733
+ }
734
+ /**
735
+ * The decoded frame to show a non-primary playhead at `timestamp`: the exact
736
+ * one when it's warm at the clip's level, otherwise the nearest decoded, with
737
+ * the exact one queued. Null only when nothing at all is decoded yet.
738
+ */
739
+ decodedFrameFor(src, session, timestamp) {
740
+ const step = session.frameStep;
741
+ const clamped = Math.max(0, Math.min(timestamp, session.durationSec));
742
+ const accept = (f) => this.accepts(session, f);
743
+ // The echo cache first — its entries survive the playhead window's
744
+ // eviction, which is centred on the *primary* head.
745
+ const echo = this.videoEchoFrames.get(src);
746
+ const window = this.videoFrames.get(src);
747
+ const exact = this.nearestWithin(echo, clamped, step, accept) ??
748
+ this.nearestWithin(window, clamped, step, accept);
749
+ if (exact)
750
+ return exact;
751
+ let set = this.pendingEchoFrames.get(src);
752
+ if (!set) {
753
+ set = new Set();
754
+ this.pendingEchoFrames.set(src, set);
755
+ }
756
+ set.add(clamped);
757
+ // While playing there is no blocking caller to drain that set, and the
758
+ // sequential prefetch only runs ahead of the primary head.
759
+ if (this.playing)
760
+ void this.decodeEchoFrameAt(src, clamped);
761
+ const nearEcho = echo ? this.nearestDecoded(echo, clamped, step) : null;
762
+ const nearWindow = window ? this.nearestDecoded(window, clamped, step) : null;
763
+ if (!nearEcho)
764
+ return nearWindow;
765
+ if (!nearWindow)
766
+ return nearEcho;
767
+ return Math.abs(nearEcho.timestamp - clamped) <= Math.abs(nearWindow.timestamp - clamped)
768
+ ? nearEcho
769
+ : nearWindow;
770
+ }
771
+ /**
772
+ * The decoded length of `src` in seconds, or `0` when its session hasn't
773
+ * opened yet. The *container's* duration rather than the manifest's, so
774
+ * looping still works for a source whose manifest probe couldn't read one.
775
+ */
776
+ getVideoDuration(src) {
777
+ return this.videoSessions.get(src)?.durationSec ?? 0;
778
+ }
779
+ /**
780
+ * An INDEPENDENT, caller-owned image for a past `timestamp` — the Echo
781
+ * filter needs several frames resident at once, which the shared session
782
+ * texture can't give. The caller must push it to `transientImages`.
783
+ *
784
+ * On a miss it records the timestamp for {@link warmPendingDecodes} and
785
+ * returns null, so the tap is skipped this pass and a blocking caller lands it.
786
+ */
787
+ getVideoFrameImage(src, timestamp, footprint) {
788
+ const session = this.videoSessions.get(src);
789
+ if (!session || !this.surface || !Number.isFinite(timestamp))
790
+ return null;
791
+ session.levels?.note(footprint);
792
+ const clamped = Math.max(0, Math.min(timestamp, session.durationSec));
793
+ const step = session.frameStep;
794
+ const accept = (f) => this.accepts(session, f);
795
+ const frame = this.nearestWithin(this.videoEchoFrames.get(src), clamped, step, accept) ??
796
+ this.nearestWithin(this.videoFrames.get(src), clamped, step, accept);
797
+ if (!frame) {
798
+ let set = this.pendingEchoFrames.get(src);
799
+ if (!set) {
800
+ set = new Set();
801
+ this.pendingEchoFrames.set(src, set);
802
+ }
803
+ set.add(clamped);
804
+ return null;
805
+ }
806
+ return this.surface.makeImageFromTextureSource(frame.bitmap, {
807
+ width: frame.bitmap.width || session.width,
808
+ height: frame.bitmap.height || session.height,
809
+ alphaType: this.canvasKit.AlphaType.Unpremul,
810
+ colorType: this.canvasKit.ColorType.RGBA_8888,
811
+ colorSpace: this.canvasKit.ColorSpace.SRGB,
812
+ });
813
+ }
814
+ /**
815
+ * Upload `frame`'s bitmap into the session's persistent texture and return it.
816
+ * No upload when that bitmap is already resident, so a parked playhead costs
817
+ * nothing per render.
818
+ */
819
+ uploadFrame(src, session, frame) {
820
+ if (!this.surface)
821
+ return null;
822
+ const key = this.quantizeTs(frame.timestamp, session.frameStep);
823
+ if (session.textureImage && session.uploadedTs === key
824
+ && (session.uploadedBitmap == null || session.uploadedBitmap === frame.bitmap)) {
825
+ return session.textureImage;
826
+ }
827
+ const slot = {
828
+ image: session.textureImage,
829
+ uploadedTs: session.uploadedTs,
830
+ width: session.textureWidth,
831
+ height: session.textureHeight,
832
+ };
833
+ this.uploadInto(slot, frame, session);
834
+ session.textureImage = slot.image;
835
+ session.textureWidth = slot.width;
836
+ session.textureHeight = slot.height;
837
+ session.uploadedBitmap = slot.bitmap;
838
+ session.uploadedTs = session.textureImage ? key : null;
839
+ return session.textureImage;
840
+ }
841
+ /** Whether `frame` counts as the frame at the clip's current level. */
842
+ accepts(session, frame) {
843
+ return session.levels ? session.levels.accepts(frame.level, this.decodePolicy) : true;
844
+ }
845
+ /** Decode every exact video frame and echo tap a prior render asked for, plus the 3D runtime. */
846
+ async warmVideo() {
847
+ const pending = [...this.pendingVideoFrames];
848
+ this.pendingVideoFrames.clear();
849
+ const echo = [...this.pendingEchoFrames].flatMap(([src, set]) => [...set].map((ts) => [src, ts]));
850
+ this.pendingEchoFrames.clear();
851
+ // Drained before the early return below so a 3D-only scene, which queues
852
+ // no video work at all, still gets its runtime loaded.
853
+ const warmed3D = await warmPendingCanvas3D();
854
+ const inFlight = [...this.echoDecodes.values()];
855
+ if (pending.length === 0 && echo.length === 0 && inFlight.length === 0)
856
+ return warmed3D;
857
+ await Promise.all([
858
+ ...pending.map(([src, ts]) => this.decodeAt(src, ts)),
859
+ ...echo.map(([src, ts]) => this.decodeEchoFrameAt(src, ts)),
860
+ ...inFlight,
861
+ ]);
862
+ return true;
863
+ }
864
+ /**
865
+ * Sequentially decode forward from `timestamp` to fill the window ahead, using
866
+ * mediabunny's monotonic pipeline — far cheaper than a random seek per frame.
867
+ * At most one pass runs per src; it stops as soon as playback pauses or the
868
+ * playhead jumps away. Each sample is sized as it is snapshotted, so a level
869
+ * change mid-pass applies to the next frame without restarting the stream.
870
+ */
871
+ driveSequentialDecode(src, timestamp) {
872
+ const session = this.videoSessions.get(src);
873
+ if (!session || session.decoding || !session.sink)
874
+ return;
875
+ if (!this.videoFrames.get(src))
876
+ return;
877
+ const step = session.frameStep;
878
+ const { forward } = this.windowFrames(session);
879
+ const end = Math.min(session.durationSec, timestamp + forward * step);
880
+ if (timestamp >= end)
881
+ return;
882
+ session.decoding = true;
883
+ void (async () => {
884
+ try {
885
+ for await (const sample of session.sink.samples(timestamp, end + step)) {
886
+ const store = this.videoFrames.get(src);
887
+ if (this.disposed || !store) {
888
+ sample.close();
889
+ break;
890
+ }
891
+ const key = this.quantizeTs(sample.timestamp, step);
892
+ const existing = store.get(key);
893
+ if (existing && this.accepts(session, existing)) {
894
+ sample.close();
895
+ }
896
+ else {
897
+ this.storeFrame(src, store, key, await this.snapshot(session, sample));
898
+ }
899
+ const head = this.videoPlayhead.get(src) ?? timestamp;
900
+ if (!this.playing || head < timestamp - step || head > end)
901
+ break;
902
+ }
903
+ this.evictVideoWindow(src, this.videoPlayhead.get(src) ?? timestamp, step);
904
+ }
905
+ catch (err) {
906
+ if (!this.disposed)
907
+ console.error(`[WebStorageAdapter] sequential decode failed for ${src}:`, err);
908
+ }
909
+ finally {
910
+ session.decoding = false;
911
+ }
912
+ })();
913
+ }
914
+ /**
915
+ * Decode the single frame containing `timestampSec` (random access) and cache
916
+ * its bitmap — for cold warms, where streaming isn't appropriate. Idempotent
917
+ * per quantized ts at the clip's current level.
918
+ */
919
+ async decodeAt(src, timestampSec) {
920
+ const session = this.videoSessions.get(src);
921
+ if (!session || !Number.isFinite(timestampSec))
922
+ return;
923
+ const clamped = Math.max(0, Math.min(timestampSec, session.durationSec));
924
+ const key = this.quantizeTs(clamped, session.frameStep);
925
+ const store = this.videoFrames.get(src);
926
+ const existing = store?.get(key);
927
+ if (!store || (existing && this.accepts(session, existing)))
928
+ return;
929
+ try {
930
+ const sample = await session.sink.getSample(clamped);
931
+ if (!sample)
932
+ return;
933
+ if (this.disposed) {
934
+ sample.close();
935
+ return;
936
+ }
937
+ const frame = await this.snapshot(session, sample);
938
+ const dst = this.videoFrames.get(src);
939
+ if (!dst) {
940
+ frame.bitmap.close();
941
+ return;
942
+ }
943
+ this.storeFrame(src, dst, this.quantizeTs(frame.timestamp, session.frameStep), frame);
944
+ this.evictVideoWindow(src, clamped, session.frameStep);
945
+ }
946
+ catch (err) {
947
+ if (!this.disposed)
948
+ console.error(`[WebStorageAdapter] video decode failed for ${src}@${clamped}:`, err);
949
+ }
950
+ }
951
+ /** Put `frame` in the window, closing what it replaces unless the echo cache shares it. */
952
+ storeFrame(src, store, key, frame) {
953
+ const session = this.videoSessions.get(src);
954
+ const existing = store.get(key);
955
+ if (existing && session && this.accepts(session, existing) && !this.accepts(session, frame)) {
956
+ frame.bitmap.close();
957
+ return;
958
+ }
959
+ if (existing && existing !== frame && this.videoEchoFrames.get(src)?.get(key) !== existing) {
960
+ existing.bitmap.close();
961
+ }
962
+ store.set(key, frame);
963
+ }
964
+ /**
965
+ * Decode a single past frame into the dedicated {@link videoEchoFrames} cache.
966
+ * Like {@link decodeAt} but it does NOT window-evict — echo taps reach outside
967
+ * that window on purpose. Concurrent calls for the same frame join one decode.
968
+ */
969
+ decodeEchoFrameAt(src, timestampSec) {
970
+ const session = this.videoSessions.get(src);
971
+ if (!session || !Number.isFinite(timestampSec))
972
+ return Promise.resolve();
973
+ const clamped = Math.max(0, Math.min(timestampSec, session.durationSec));
974
+ const key = this.quantizeTs(clamped, session.frameStep);
975
+ const cached = this.videoEchoFrames.get(src)?.get(key);
976
+ if (cached && this.accepts(session, cached))
977
+ return Promise.resolve();
978
+ const id = `${src}#${key}`;
979
+ const inFlight = this.echoDecodes.get(id);
980
+ if (inFlight)
981
+ return inFlight;
982
+ const decode = this.decodeEchoFrameNow(src, session, clamped, key)
983
+ .finally(() => this.echoDecodes.delete(id));
984
+ this.echoDecodes.set(id, decode);
985
+ return decode;
986
+ }
987
+ /** {@link decodeEchoFrameAt}'s body, past the dedup gate. */
988
+ async decodeEchoFrameNow(src, session, clamped, key) {
989
+ let store = this.videoEchoFrames.get(src);
990
+ const windowed = this.videoFrames.get(src)?.get(key);
991
+ try {
992
+ if (!store) {
993
+ store = new Map();
994
+ this.videoEchoFrames.set(src, store);
995
+ }
996
+ if (windowed && this.accepts(session, windowed)) {
997
+ this.replaceEcho(src, store, key, windowed);
998
+ }
999
+ else {
1000
+ const sample = await session.sink.getSample(clamped);
1001
+ if (!sample)
1002
+ return;
1003
+ if (this.disposed) {
1004
+ sample.close();
1005
+ return;
1006
+ }
1007
+ const frame = await this.snapshot(session, sample);
1008
+ store = this.videoEchoFrames.get(src);
1009
+ if (!store) {
1010
+ frame.bitmap.close();
1011
+ return;
1012
+ }
1013
+ this.replaceEcho(src, store, this.quantizeTs(frame.timestamp, session.frameStep), frame);
1014
+ }
1015
+ this.trimEchoCache(src);
1016
+ }
1017
+ catch (err) {
1018
+ if (!this.disposed)
1019
+ console.error(`[WebStorageAdapter] echo decode failed for ${src}@${clamped}:`, err);
1020
+ }
1021
+ }
1022
+ replaceEcho(src, store, key, frame) {
1023
+ const existing = store.get(key);
1024
+ if (existing && existing !== frame && this.videoFrames.get(src)?.get(key) !== existing) {
1025
+ existing.bitmap.close();
1026
+ }
1027
+ store.set(key, frame);
1028
+ }
1029
+ /** Bound the echo cache to ECHO_CACHE_LIMIT frames per src, dropping oldest-inserted first. */
1030
+ trimEchoCache(src) {
1031
+ const store = this.videoEchoFrames.get(src);
1032
+ if (!store)
1033
+ return;
1034
+ while (store.size > WebStorageAdapter.ECHO_CACHE_LIMIT) {
1035
+ const oldest = store.keys().next().value;
1036
+ if (oldest === undefined)
1037
+ break;
1038
+ const frame = store.get(oldest);
1039
+ // Only close the bitmap if it isn't shared with the live playback window.
1040
+ if (frame && this.videoFrames.get(src)?.get(oldest) !== frame)
1041
+ frame.bitmap.close();
1042
+ store.delete(oldest);
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Resize `sample` to the clip's level and snapshot it to an immutable bitmap,
1047
+ * closing the sample. Drawn and snapshotted in one synchronous step, so the
1048
+ * pooled canvas can be shared by concurrent decodes of the same clip.
1049
+ */
1050
+ snapshot(session, sample) {
1051
+ const level = session.levels.level;
1052
+ const { width, height } = levelSize(level, session.intrinsic);
1053
+ try {
1054
+ let canvas = session.canvas;
1055
+ if (!canvas || canvas.width !== width || canvas.height !== height) {
1056
+ canvas = new OffscreenCanvas(width, height);
1057
+ session.canvas = canvas;
1058
+ }
1059
+ const ctx = canvas.getContext("2d");
1060
+ if (!ctx)
1061
+ throw new Error("video snapshot: could not get 2d context");
1062
+ ctx.imageSmoothingEnabled = true;
1063
+ ctx.imageSmoothingQuality = "high";
1064
+ ctx.clearRect(0, 0, width, height);
1065
+ sample.drawWithFit(ctx, { fit: "fill" });
1066
+ const bitmap = createImageBitmap(canvas);
1067
+ const timestamp = sample.timestamp;
1068
+ return bitmap.then((b) => ({ timestamp, bitmap: b, level }));
1069
+ }
1070
+ finally {
1071
+ sample.close();
1072
+ }
1073
+ }
1074
+ windowFrames(session) {
1075
+ return videoWindowFrames(session ? { width: session.width, height: session.height } : null);
1076
+ }
1077
+ /** Drop decoded frames (closing their bitmaps) outside the window around `timestamp` (source seconds). */
1078
+ evictVideoWindow(src, timestamp, step) {
1079
+ const frames = this.videoFrames.get(src);
1080
+ if (!frames)
1081
+ return;
1082
+ const { forward, back } = this.windowFrames(this.videoSessions.get(src));
1083
+ const lo = timestamp - back * step;
1084
+ const hi = timestamp + forward * step;
1085
+ const echo = this.videoEchoFrames.get(src);
1086
+ for (const [key, frame] of frames) {
1087
+ if (frame.timestamp < lo || frame.timestamp > hi) {
1088
+ if (echo?.get(key) !== frame)
1089
+ frame.bitmap.close();
1090
+ frames.delete(key);
1091
+ }
1092
+ }
1093
+ }
1094
+ /**
1095
+ * Closest decoded frame to `timestamp`, or null if none are decoded yet — at
1096
+ * any distance, so a not-yet-warm playhead paints the closest frame.
1097
+ */
1098
+ nearestDecoded(frames, timestamp, step) {
1099
+ const exact = frames.get(this.quantizeTs(timestamp, step));
1100
+ if (exact)
1101
+ return exact;
1102
+ let best = null;
1103
+ let bestDist = Infinity;
1104
+ for (const frame of frames.values()) {
1105
+ const dist = Math.abs(frame.timestamp - timestamp);
1106
+ if (dist < bestDist) {
1107
+ bestDist = dist;
1108
+ best = frame;
1109
+ }
1110
+ }
1111
+ return best;
1112
+ }
1113
+ /** Quantize a source timestamp to a stable integer ring key. */
1114
+ quantizeTs(timestamp, step) {
1115
+ return Math.round(timestamp / step);
1116
+ }
1117
+ /**
1118
+ * The frame in `store` closest to `timestamp`, but only within one frame step
1119
+ * (so a request matches the sample whose grid-aligned timestamp quantized to
1120
+ * an adjacent key, without returning a clearly wrong frame).
1121
+ */
1122
+ nearestWithin(store, timestamp, step, accept) {
1123
+ if (!store || store.size === 0)
1124
+ return null;
1125
+ let best = null;
1126
+ let bestDist = Infinity;
1127
+ for (const frame of store.values()) {
1128
+ if (accept && !accept(frame))
1129
+ continue;
1130
+ const dist = Math.abs(frame.timestamp - timestamp);
1131
+ if (dist < bestDist) {
1132
+ bestDist = dist;
1133
+ best = frame;
1134
+ }
1135
+ }
1136
+ return best && bestDist <= step ? best : null;
1137
+ }
1138
+ /** The first sample's duration and size; ~30fps when the track won't say. */
1139
+ async probeFirstFrame(sink) {
1140
+ try {
1141
+ let first = await sink.getSample(0);
1142
+ if (!first) {
1143
+ for await (const sample of sink.samples()) {
1144
+ first = sample;
1145
+ break;
1146
+ }
1147
+ }
1148
+ if (first) {
1149
+ const probe = {
1150
+ frameStep: first.duration > 0 ? first.duration : 1 / 30,
1151
+ width: first.displayWidth,
1152
+ height: first.displayHeight,
1153
+ };
1154
+ first.close();
1155
+ return probe;
1156
+ }
1157
+ }
1158
+ catch {
1159
+ // fall through to defaults
1160
+ }
1161
+ return { frameStep: 1 / 30, width: 1, height: 1 };
1162
+ }
1163
+ makeCKImageFromPixels(pixels, width, height) {
1164
+ return this.canvasKit.MakeImage({
1165
+ width,
1166
+ height,
1167
+ alphaType: this.canvasKit.AlphaType.Unpremul,
1168
+ colorType: this.canvasKit.ColorType.RGBA_8888,
1169
+ colorSpace: this.canvasKit.ColorSpace.SRGB,
1170
+ }, pixels, 4 * width);
1171
+ }
1172
+ // ─── Audio ───────────────────────────────────────────────────────────────
1173
+ /** Fetches and decodes `src` into a Web Audio `AudioBuffer`, cached for {@link getAudioBuffer}. No-op if already cached. */
1174
+ async loadAudio(src, _startFrame, _endFrame, _trimStart = 0, _trimEnd) {
1175
+ if (this.audioBuffers.has(src))
1176
+ return;
1177
+ if (!this.audioCtx)
1178
+ this.audioCtx = new AudioContext();
1179
+ const arrayBuffer = await (await this.fetchBlob(this.assetUrl(src, "media"))).arrayBuffer();
1180
+ const audioBuffer = await this.audioCtx.decodeAudioData(arrayBuffer);
1181
+ this.audioBuffers.set(src, audioBuffer);
1182
+ }
1183
+ async fetchAudioData(src) {
1184
+ return (await this.fetchBlob(this.assetUrl(src, "media"))).arrayBuffer();
1185
+ }
1186
+ getAudioBuffer(src) {
1187
+ return this.audioBuffers.get(src) ?? null;
1188
+ }
1189
+ // ─── Font ────────────────────────────────────────────────────────────────
1190
+ /**
1191
+ * Registers every weight/slant file for `fontFamily` under the bare family
1192
+ * name, once per family, so CanvasKit's matcher can pick the closest file for
1193
+ * any requested weight — which is what makes weights tweenable.
1194
+ */
1195
+ async loadFont(key, fontFamily, _fontWeight) {
1196
+ if (this.registeredFontFamilies.has(fontFamily))
1197
+ return true;
1198
+ const metas = this.catalog.getFontFamilyMetas(fontFamily);
1199
+ if (metas.length === 0) {
1200
+ // Not an error: an undescribed family shapes against the platform
1201
+ // fallback. Warned once per family per catalog, since the caller
1202
+ // retries on every `loadAt`.
1203
+ if (!this.unknownFontFamilies.has(fontFamily)) {
1204
+ this.unknownFontFamilies.add(fontFamily);
1205
+ console.warn(`[WebStorageAdapter] No font manifest entry for "${key}"; shaping against the fallback face. Add it to the manifest font record.`);
1206
+ }
1207
+ return false;
1208
+ }
1209
+ // Dedupe by src in case the same file is registered under multiple keys.
1210
+ const sources = [...new Set(metas.map(m => m.src))];
1211
+ await Promise.all(sources.map(async (src) => {
1212
+ const bytes = await (await this.fetchBlob(src)).arrayBuffer();
1213
+ this.fontMgr.registerFont(new Uint8Array(bytes), fontFamily);
1214
+ }));
1215
+ this.registeredFontFamilies.add(fontFamily);
1216
+ this.unknownFontFamilies.delete(fontFamily);
1217
+ this.fontEpoch++;
1218
+ return true;
1219
+ }
1220
+ getFontMgr() {
1221
+ return this.fontMgr;
1222
+ }
1223
+ /** The shared shaped-text cache (see {@link paragraphCache}). */
1224
+ getParagraphCache() {
1225
+ return this.paragraphCache;
1226
+ }
1227
+ /** Current font-registration epoch, folded into paragraph cache keys. */
1228
+ getFontEpoch() {
1229
+ return this.fontEpoch;
1230
+ }
1231
+ // ─── Lifecycle ───────────────────────────────────────────────────────────
1232
+ dispose() {
1233
+ if (this.disposed)
1234
+ return;
1235
+ this.disposed = true;
1236
+ this.paragraphCache.dispose();
1237
+ this.images.dispose();
1238
+ this.imageSources.clear();
1239
+ for (const image of this.canvas3DTextures.values())
1240
+ image.delete();
1241
+ this.canvas3DTextures.clear();
1242
+ // Close window bitmaps, tracking them so shared echo entries aren't
1243
+ // double-closed below.
1244
+ const closed = new Set();
1245
+ for (const frames of this.videoFrames.values()) {
1246
+ for (const { bitmap } of frames.values()) {
1247
+ bitmap.close();
1248
+ closed.add(bitmap);
1249
+ }
1250
+ }
1251
+ this.videoFrames.clear();
1252
+ for (const frames of this.videoEchoFrames.values()) {
1253
+ for (const { bitmap } of frames.values())
1254
+ if (!closed.has(bitmap))
1255
+ bitmap.close();
1256
+ }
1257
+ this.videoEchoFrames.clear();
1258
+ for (const session of this.videoSessions.values()) {
1259
+ session.textureImage?.delete();
1260
+ session.input.dispose();
1261
+ }
1262
+ this.videoSessions.clear();
1263
+ this.clearVideoAltSlots();
1264
+ this.videoPlayhead.clear();
1265
+ this.videoFrameClaims.clear();
1266
+ this.pendingVideoFrames.clear();
1267
+ this.pendingEchoFrames.clear();
1268
+ this.echoDecodes.clear();
1269
+ this.surface = null;
1270
+ this.audioBuffers.clear();
1271
+ if (this.audioCtx && this.audioCtx.state !== "closed") {
1272
+ this.audioCtx.close();
1273
+ }
1274
+ this.audioCtx = null;
1275
+ super.dispose();
1276
+ }
1277
+ }
1278
+ //# sourceMappingURL=storage-adapter.js.map