@vectoriox/iox-ui 5.76.0 → 5.77.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.
@@ -38,8 +38,12 @@ declare enum FrameExtension {
38
38
  Webp = "webp",
39
39
  Jpeg = "jpg"
40
40
  }
41
- /** The most pictures one set may hold. Past this a phone runs out of memory before the visitor runs out of scroll. */
42
- declare const MAX_FRAMES = 300;
41
+ /**
42
+ * The most pictures one set may hold. A film's smoothness is its density, pictures per screen of scrolling: 210 over 18
43
+ * screens stepped on Doron's iPhone as a glide slowed, 1050 did not (2026-11-05). Memory is not what limits it: the player
44
+ * keeps the downloaded files (about 10 kB a phone picture) and only a window of them unpacked (`unpackedFrameCount`).
45
+ */
46
+ declare const MAX_FRAMES = 1200;
43
47
  /** The most one picture may weigh when uploaded. */
44
48
  declare const MAX_FRAME_BYTES: number;
45
49
  /** A sequence's name in storage: what the browser generates and what the server accepts in a key. */
@@ -85,6 +89,16 @@ declare function frameIndexAt(played: number, count: number): number;
85
89
  declare function frameLoadOrder(count: number): number[];
86
90
  /** The loaded picture nearest `index`, or -1 when none has arrived. Ties go backward: the picture already passed. */
87
91
  declare function nearestLoadedFrame(loaded: ReadonlyArray<unknown>, index: number): number;
92
+ /**
93
+ * How much unpacked picture one player holds at once. A browser keeps few of 300 unpacked pictures (a wide set is 1.7 GB
94
+ * unpacked) and unpacked again on the page's own thread, at the moment of drawing, 22 ms a 1600×900 picture: longer than a
95
+ * whole frame, so the film and the page beside it skipped a beat for every new picture (Doron's recording, 2026-11-04).
96
+ */
97
+ declare const FRAMES_UNPACKED_BYTES: number;
98
+ /** How many pictures of `width`×`height` a player holds unpacked. */
99
+ declare function unpackedFrameCount(width: number, height: number): number;
100
+ /** The `size` pictures to hold unpacked around `position`, nearest first, each side in turn starting ahead. */
101
+ declare function framesToHold(position: number, count: number, size: number): number[];
88
102
  interface FrameSizes {
89
103
  width: number;
90
104
  height: number;
@@ -99,8 +113,6 @@ declare function fittedFrameSize(width: number, height: number, longSide: number
99
113
  declare function videoFrameCount(seconds: number): number;
100
114
  /** Which of `sourceCount` pictures make a set of `count`, spread evenly, first and last always in. */
101
115
  declare function sampledFrameIndexes(sourceCount: number, count: number): number[];
102
- /** A phone plays half the pictures: it has less memory, and half is plenty on a small screen. */
103
- declare function phoneFrameCount(wideCount: number): number;
104
116
  /** Rendered frames in their real order: `frame2` before `frame10`. */
105
117
  declare function sortFrameFiles<T extends {
106
118
  name: string;
@@ -125,6 +137,11 @@ interface FramePlayerOptions {
125
137
  * The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
126
138
  * first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
127
139
  * Browser only. It writes and never reads, so the driver can call it in its write phase.
140
+ *
141
+ * Each picture is downloaded as a file and unpacked off the page's thread (`createImageBitmap` of the file), a window of
142
+ * them around where the film stands, so drawing one never waits for it. A browser that cannot (no `fetch` or
143
+ * `createImageBitmap`, or storage that refuses the download) plays from `<img>` pictures, as before: it works, and pays the
144
+ * unpacking on the page's thread.
128
145
  */
129
146
  declare class FramePlayer {
130
147
  private readonly el;
@@ -132,9 +149,14 @@ declare class FramePlayer {
132
149
  private played;
133
150
  private readonly set;
134
151
  private readonly info;
135
- private readonly images;
152
+ /** What a draw may use: an unpacked picture, or an `<img>` on a browser that plays from them. */
153
+ private readonly ready;
154
+ /** The downloaded files, unpacked when the film comes near them. */
155
+ private readonly files;
136
156
  private readonly order;
137
157
  private readonly host;
158
+ private readonly holdCount;
159
+ private fromFiles;
138
160
  private observer;
139
161
  private canvas;
140
162
  private context;
@@ -143,7 +165,11 @@ declare class FramePlayer {
143
165
  private loading;
144
166
  private started;
145
167
  private destroyed;
168
+ private held;
169
+ private readonly unpacked;
170
+ private readonly unpacking;
146
171
  private readonly pending;
172
+ private readonly downloads;
147
173
  constructor(el: HTMLElement, sequence: FrameSequence, options?: FramePlayerOptions);
148
174
  /**
149
175
  * Stand at `played` %, exactly. The film keeps no motion of its own: the scroll's glide, and the slowing at its end,
@@ -156,7 +182,12 @@ declare class FramePlayer {
156
182
  private allowed;
157
183
  private start;
158
184
  private pump;
159
- private load;
185
+ private loaded;
186
+ private download;
187
+ private loadImage;
188
+ /** Unpack the pictures around where the film stands, nearest first, and let go of the ones it has left. */
189
+ private hold;
190
+ private unpack;
160
191
  private draw;
161
192
  private ensureCanvas;
162
193
  }
@@ -442,5 +473,5 @@ declare function attachVideos(container: ParentNode, options?: VideoRuntimeOptio
442
473
  /** The runtime already running at `el`, if any. */
443
474
  declare function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null;
444
475
 
445
- export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
476
+ export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAMES_UNPACKED_BYTES, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, framesToHold, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, unpackedFrameCount, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
446
477
  export type { ConsentGrantDetail, ConsentTransport, FrameBlend, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.76.0",
3
+ "version": "5.77.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -218,6 +218,23 @@ declare const SCENE_MAX_SCREENS = 20;
218
218
  */
219
219
  declare function sceneScreensOf(height: string | null | undefined): number | null;
220
220
  declare function sceneHeightForScreens(screens: number): string;
221
+ /**
222
+ * Pictures per screen of scrolling at which a played film stays smooth as a glide slows to a stop. Measured on Doron's
223
+ * iPhone (2026-11-05): Studio Alma's film at about 11 a screen stepped, at about 55 it did not.
224
+ */
225
+ declare const SCENE_FILM_SMOOTH = 40;
226
+ interface SceneFilmDensity {
227
+ /** Pictures per screen of scrolling over most of the play: half its scroll holds at least this many. */
228
+ perScreen: number;
229
+ /** The screens of scrolling the film plays over; where it holds still is not counted. */
230
+ screens: number;
231
+ smooth: boolean;
232
+ }
233
+ /**
234
+ * How densely a film of `count` pictures plays on a Scene of `screens`, by its Play track; null when nothing plays. A
235
+ * film's smoothness is its density, never the drawing: spread thin, a slowing glide holds each picture for many refreshes.
236
+ */
237
+ declare function sceneFilmDensity(tracks: ReadonlyArray<SceneTrack> | null | undefined, screens: number | null, count: number): SceneFilmDensity | null;
221
238
  /** Motion v2 §3d's limits for scroll-linked work on one page. */
222
239
  declare const SCENE_BUDGET: {
223
240
  readonly heldSections: 1;
@@ -402,5 +419,5 @@ declare function revealStagger(groups: number, by: TextRevealBy): number;
402
419
  */
403
420
  declare function playTextReveal(el: HTMLElement, by: TextRevealBy, timing: KeyframeAnimationOptions): Animation | null;
404
421
 
405
- export { LIGHT_UP_PIECE_CAP, REVEAL_PIECE_CAP, SCENE_BUDGET, SCENE_DAMPING, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_MAX_SCREENS, SCENE_MIN_SCREENS, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_REVEAL_DEFAULT, SCENE_STAGE_HEIGHT, SCENE_TRACK_DEFS, SCENE_WORDS, SCENE_WORD_DIM, SceneAttribute, SceneProp, SceneRevealFrom, ScrollDriver, TextRevealBy, applySceneFirstFrame, attachScenes, buildSceneRecipe, colourAt, dampToward, lineIndexes, movedSceneKey, normalizeTracks, playTextReveal, revealClipPath, revealStagger, sceneBudget, sceneColourKeyAt, sceneDisplayValue, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneHasKeyAt, sceneHeightForScreens, sceneKeyPosition, sceneLayersOf, sceneNodeAttributes, scenePlayed, sceneRevealFromOf, sceneRootOnPath, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, sceneWordOpacity, sceneWordsLit, scenesIn, splitText, syncSceneAttributes, textPieces, valueAt, withSceneKey, withScenePlayback, withSceneRevealFrom, withoutSceneKey, withoutScenePlayback };
406
- export type { SceneBudget, SceneFrame, SceneFrameOptions, SceneFrameState, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneTrackDef, SceneTreeNode, SceneWords, ScrollDriverOptions, SplitText, SplitTextOptions, TextPiece };
422
+ export { LIGHT_UP_PIECE_CAP, REVEAL_PIECE_CAP, SCENE_BUDGET, SCENE_DAMPING, SCENE_FILM_SMOOTH, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_MAX_SCREENS, SCENE_MIN_SCREENS, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_REVEAL_DEFAULT, SCENE_STAGE_HEIGHT, SCENE_TRACK_DEFS, SCENE_WORDS, SCENE_WORD_DIM, SceneAttribute, SceneProp, SceneRevealFrom, ScrollDriver, TextRevealBy, applySceneFirstFrame, attachScenes, buildSceneRecipe, colourAt, dampToward, lineIndexes, movedSceneKey, normalizeTracks, playTextReveal, revealClipPath, revealStagger, sceneBudget, sceneColourKeyAt, sceneDisplayValue, sceneFilmDensity, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneHasKeyAt, sceneHeightForScreens, sceneKeyPosition, sceneLayersOf, sceneNodeAttributes, scenePlayed, sceneRevealFromOf, sceneRootOnPath, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, sceneWordOpacity, sceneWordsLit, scenesIn, splitText, syncSceneAttributes, textPieces, valueAt, withSceneKey, withScenePlayback, withSceneRevealFrom, withoutSceneKey, withoutScenePlayback };
423
+ export type { SceneBudget, SceneFilmDensity, SceneFrame, SceneFrameOptions, SceneFrameState, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneTrackDef, SceneTreeNode, SceneWords, ScrollDriverOptions, SplitText, SplitTextOptions, TextPiece };
@@ -42,8 +42,12 @@ export enum FrameExtension {
42
42
  Jpeg = 'jpg',
43
43
  }
44
44
 
45
- /** The most pictures one set may hold. Past this a phone runs out of memory before the visitor runs out of scroll. */
46
- export const MAX_FRAMES = 300;
45
+ /**
46
+ * The most pictures one set may hold. A film's smoothness is its density, pictures per screen of scrolling: 210 over 18
47
+ * screens stepped on Doron's iPhone as a glide slowed, 1050 did not (2026-11-05). Memory is not what limits it: the player
48
+ * keeps the downloaded files (about 10 kB a phone picture) and only a window of them unpacked (`unpackedFrameCount`).
49
+ */
50
+ export const MAX_FRAMES = 1200;
47
51
  /** The most one picture may weigh when uploaded. */
48
52
  export const MAX_FRAME_BYTES = 3 * 1024 * 1024;
49
53
  /** A sequence's name in storage: what the browser generates and what the server accepts in a key. */
@@ -172,6 +176,31 @@ export function nearestLoadedFrame(loaded: ReadonlyArray<unknown>, index: number
172
176
  return -1;
173
177
  }
174
178
 
179
+ /**
180
+ * How much unpacked picture one player holds at once. A browser keeps few of 300 unpacked pictures (a wide set is 1.7 GB
181
+ * unpacked) and unpacked again on the page's own thread, at the moment of drawing, 22 ms a 1600×900 picture: longer than a
182
+ * whole frame, so the film and the page beside it skipped a beat for every new picture (Doron's recording, 2026-11-04).
183
+ */
184
+ export const FRAMES_UNPACKED_BYTES = 96 * 1024 * 1024;
185
+ const MIN_UNPACKED = 6;
186
+ const MAX_UNPACKED = 24;
187
+
188
+ /** How many pictures of `width`×`height` a player holds unpacked. */
189
+ export function unpackedFrameCount(width: number, height: number): number {
190
+ return Math.min(MAX_UNPACKED, Math.max(MIN_UNPACKED, Math.floor(FRAMES_UNPACKED_BYTES / (width * height * 4))));
191
+ }
192
+
193
+ /** The `size` pictures to hold unpacked around `position`, nearest first, each side in turn starting ahead. */
194
+ export function framesToHold(position: number, count: number, size: number): number[] {
195
+ const from = Math.min(count - 1, Math.max(0, Math.floor(position)));
196
+ const held: number[] = [from];
197
+ for (let distance = 1; held.length < Math.min(size, count); distance++) {
198
+ if (from + distance < count) held.push(from + distance);
199
+ if (from - distance >= 0 && held.length < size) held.push(from - distance);
200
+ }
201
+ return held;
202
+ }
203
+
175
204
  export interface FrameSizes {
176
205
  width: number;
177
206
  height: number;
@@ -181,10 +210,10 @@ export interface FrameSizes {
181
210
  * tall stage by its height, so a picture sized to the phone's width would be enlarged five times. */
182
211
  export const WIDE_LONG_SIDE = 1600;
183
212
  export const PHONE_LONG_SIDE = 1100;
184
- /** How many pictures a second of video becomes, before the cap. */
185
- const VIDEO_FRAMES_PER_SECOND = 24;
186
- /** A video's cap: the pictures a scroll can show, at a weight a page can carry. A set of pictures is the author's own count. */
187
- const VIDEO_MAX_FRAMES = 180;
213
+ /** How many pictures a second of video becomes, before the cap: what most recordings hold, so few are the same twice. */
214
+ const VIDEO_FRAMES_PER_SECOND = 30;
215
+ /** A video's cap: half a minute at that rate. A set of pictures is the author's own count, up to `MAX_FRAMES`. */
216
+ const VIDEO_MAX_FRAMES = 900;
188
217
 
189
218
  /** `width`×`height` fitted inside `longSide`, never enlarged. */
190
219
  export function fittedFrameSize(width: number, height: number, longSide: number): FrameSizes {
@@ -204,11 +233,6 @@ export function sampledFrameIndexes(sourceCount: number, count: number): number[
204
233
  return Array.from({ length: count }, (_, i) => Math.round((i * (sourceCount - 1)) / (count - 1)));
205
234
  }
206
235
 
207
- /** A phone plays half the pictures: it has less memory, and half is plenty on a small screen. */
208
- export function phoneFrameCount(wideCount: number): number {
209
- return Math.max(2, Math.ceil(wideCount / 2));
210
- }
211
-
212
236
  const NATURAL = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
213
237
 
214
238
  /** Rendered frames in their real order: `frame2` before `frame10`. */
@@ -226,6 +250,7 @@ export enum FramesClass {
226
250
  }
227
251
 
228
252
  const LOAD_CONCURRENCY = 6;
253
+ const UNPACK_CONCURRENCY = 4;
229
254
  const NEAR_MARGIN = '100% 0px';
230
255
  /** Under these the visitor keeps the poster: nobody on a metered 2G connection asked to download a film. */
231
256
  const SLOW_CONNECTIONS: ReadonlySet<string> = new Set(['slow-2g', '2g']);
@@ -248,14 +273,24 @@ export interface FramePlayerOptions {
248
273
  * The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
249
274
  * first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
250
275
  * Browser only. It writes and never reads, so the driver can call it in its write phase.
276
+ *
277
+ * Each picture is downloaded as a file and unpacked off the page's thread (`createImageBitmap` of the file), a window of
278
+ * them around where the film stands, so drawing one never waits for it. A browser that cannot (no `fetch` or
279
+ * `createImageBitmap`, or storage that refuses the download) plays from `<img>` pictures, as before: it works, and pays the
280
+ * unpacking on the page's thread.
251
281
  */
252
282
  export class FramePlayer {
253
283
  private played: number | null = null;
254
284
  private readonly set: FrameSet;
255
285
  private readonly info: FrameSetInfo;
256
- private readonly images: Array<HTMLImageElement | null>;
286
+ /** What a draw may use: an unpacked picture, or an `<img>` on a browser that plays from them. */
287
+ private readonly ready: Array<CanvasImageSource | null>;
288
+ /** The downloaded files, unpacked when the film comes near them. */
289
+ private readonly files: Array<Blob | null>;
257
290
  private readonly order: number[];
258
291
  private readonly host: Window;
292
+ private readonly holdCount: number;
293
+ private fromFiles: boolean;
259
294
  private observer: IntersectionObserver | null = null;
260
295
  private canvas: HTMLCanvasElement | null = null;
261
296
  private context: CanvasRenderingContext2D | null = null;
@@ -264,15 +299,24 @@ export class FramePlayer {
264
299
  private loading = 0;
265
300
  private started = false;
266
301
  private destroyed = false;
302
+ private held = new Set<number>();
303
+ private readonly unpacked = new Map<number, ImageBitmap>();
304
+ private readonly unpacking = new Set<number>();
267
305
  private readonly pending = new Set<HTMLImageElement>();
306
+ private readonly downloads: AbortController | null;
268
307
 
269
308
  constructor(private readonly el: HTMLElement, private readonly sequence: FrameSequence, options: FramePlayerOptions = {}) {
270
309
  this.host = options.host ?? window;
271
310
  const { set, info } = frameSetFor(sequence, !!options.phone);
272
311
  this.set = set;
273
312
  this.info = info;
274
- this.images = new Array(info.count).fill(null);
313
+ this.ready = new Array(info.count).fill(null);
314
+ this.files = new Array(info.count).fill(null);
275
315
  this.order = frameLoadOrder(info.count);
316
+ this.holdCount = unpackedFrameCount(info.width, info.height);
317
+ this.fromFiles = typeof this.host.fetch === 'function' && typeof this.host.createImageBitmap === 'function';
318
+ const Controller = (this.host as Window & { AbortController?: typeof AbortController }).AbortController;
319
+ this.downloads = this.fromFiles && Controller ? new Controller() : null;
276
320
  if (!this.allowed()) return;
277
321
  const Observer = (this.host as Window & { IntersectionObserver?: typeof IntersectionObserver }).IntersectionObserver;
278
322
  if (!Observer) {
@@ -292,6 +336,7 @@ export class FramePlayer {
292
336
  seek(played: number): void {
293
337
  if (played === this.played) return;
294
338
  this.played = played;
339
+ this.hold();
295
340
  this.draw();
296
341
  }
297
342
 
@@ -305,12 +350,15 @@ export class FramePlayer {
305
350
  this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
306
351
  this.observer?.disconnect();
307
352
  this.observer = null;
353
+ this.downloads?.abort();
308
354
  for (const image of this.pending) {
309
355
  image.onload = null;
310
356
  image.onerror = null;
311
357
  image.src = '';
312
358
  }
313
359
  this.pending.clear();
360
+ for (const bitmap of this.unpacked.values()) bitmap.close();
361
+ this.unpacked.clear();
314
362
  this.canvas?.remove();
315
363
  this.canvas = null;
316
364
  this.context = null;
@@ -331,43 +379,104 @@ export class FramePlayer {
331
379
 
332
380
  private pump(): void {
333
381
  while (!this.destroyed && this.loading < LOAD_CONCURRENCY && this.nextToLoad < this.order.length) {
334
- this.load(this.order[this.nextToLoad++]);
382
+ const index = this.order[this.nextToLoad++];
383
+ if (this.fromFiles) this.download(index);
384
+ else this.loadImage(index);
335
385
  }
336
386
  }
337
387
 
338
- private load(index: number): void {
388
+ private loaded(arrived: boolean): void {
389
+ this.loading--;
390
+ if (this.destroyed) return;
391
+ if (arrived) this.draw();
392
+ this.pump();
393
+ }
394
+
395
+ private download(index: number): void {
396
+ this.loading++;
397
+ const url = frameUrl(this.sequence, this.set, index);
398
+ this.host.fetch(url, this.downloads ? { signal: this.downloads.signal } : undefined)
399
+ .then(response => (response.ok ? response.blob() : null))
400
+ .then(file => {
401
+ if (this.destroyed) return;
402
+ // A picture that failed is a gap: its neighbours stand in.
403
+ this.files[index] = file;
404
+ this.loading--;
405
+ this.hold();
406
+ this.pump();
407
+ }, () => {
408
+ if (this.destroyed) return;
409
+ // Storage that refuses a download from the page's address still serves an <img>: play from those.
410
+ this.fromFiles = false;
411
+ this.loading--;
412
+ this.loadImage(index);
413
+ });
414
+ }
415
+
416
+ private loadImage(index: number): void {
339
417
  const image = this.host.document.createElement('img');
340
418
  image.decoding = 'async';
341
419
  this.loading++;
342
420
  this.pending.add(image);
343
421
  const done = (arrived: boolean): void => {
344
422
  this.pending.delete(image);
345
- this.loading--;
346
- if (this.destroyed) return;
347
- if (arrived) {
348
- this.images[index] = image;
349
- this.draw();
350
- }
351
- this.pump();
423
+ if (!this.destroyed && arrived) this.ready[index] = image;
424
+ this.loaded(arrived);
352
425
  };
353
- // Decoded before it counts, or the first draw of each picture decodes on the driver's frame and the scroll stutters.
426
+ // Decoded before it counts, or its first draw decodes on the driver's frame.
354
427
  image.onload = () => {
355
428
  const decoded = image.decode?.();
356
429
  if (decoded) void decoded.then(() => done(true), () => done(true));
357
430
  else done(true);
358
431
  };
359
- // A picture that failed is a gap: its neighbours stand in.
360
432
  image.onerror = () => done(false);
361
433
  image.src = frameUrl(this.sequence, this.set, index);
362
434
  }
363
435
 
436
+ /** Unpack the pictures around where the film stands, nearest first, and let go of the ones it has left. */
437
+ private hold(): void {
438
+ if (this.destroyed) return;
439
+ const wanted = framesToHold(framePosition(this.played ?? 0, this.info.count), this.info.count, this.holdCount);
440
+ this.held = new Set(wanted);
441
+ for (const [index, bitmap] of this.unpacked) {
442
+ if (this.held.has(index)) continue;
443
+ bitmap.close();
444
+ this.unpacked.delete(index);
445
+ this.ready[index] = null;
446
+ }
447
+ for (const index of wanted) {
448
+ if (this.unpacking.size >= UNPACK_CONCURRENCY) return;
449
+ if (this.files[index] && !this.ready[index] && !this.unpacking.has(index)) this.unpack(index);
450
+ }
451
+ }
452
+
453
+ private unpack(index: number): void {
454
+ this.unpacking.add(index);
455
+ this.host.createImageBitmap(this.files[index]!).then(bitmap => {
456
+ this.unpacking.delete(index);
457
+ if (this.destroyed || !this.held.has(index)) {
458
+ bitmap.close();
459
+ } else {
460
+ this.unpacked.set(index, bitmap);
461
+ this.ready[index] = bitmap;
462
+ this.draw();
463
+ }
464
+ this.hold();
465
+ }, () => {
466
+ // A file that will not unpack is a gap, like one that never came.
467
+ this.unpacking.delete(index);
468
+ this.files[index] = null;
469
+ this.hold();
470
+ });
471
+ }
472
+
364
473
  private draw(): void {
365
474
  if (this.destroyed || this.played === null) return;
366
- const blend = frameBlendAt(this.images, framePosition(this.played, this.info.count));
475
+ const blend = frameBlendAt(this.ready, framePosition(this.played, this.info.count));
367
476
  const last = this.drawn;
368
477
  if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
369
- const image = this.images[blend.from]!;
370
- if (!this.ensureCanvas(image)) return;
478
+ const picture = this.ready[blend.from]!;
479
+ if (!this.ensureCanvas(picture)) return;
371
480
  const context = this.context!;
372
481
  const { width, height } = this.canvas!;
373
482
  context.clearRect(0, 0, width, height);
@@ -377,26 +486,27 @@ export class FramePlayer {
377
486
  // a time instead of moving.
378
487
  context.globalCompositeOperation = 'lighter';
379
488
  context.globalAlpha = 1 - blend.mix;
380
- context.drawImage(image, 0, 0, width, height);
489
+ context.drawImage(picture, 0, 0, width, height);
381
490
  context.globalAlpha = blend.mix;
382
- context.drawImage(this.images[blend.to]!, 0, 0, width, height);
491
+ context.drawImage(this.ready[blend.to]!, 0, 0, width, height);
383
492
  context.globalAlpha = 1;
384
493
  context.globalCompositeOperation = 'source-over';
385
494
  } else {
386
- context.drawImage(image, 0, 0, width, height);
495
+ context.drawImage(picture, 0, 0, width, height);
387
496
  }
388
497
  // Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
389
498
  if (!this.drawn) this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
390
499
  this.drawn = blend;
391
500
  }
392
501
 
393
- private ensureCanvas(image: HTMLImageElement): boolean {
502
+ private ensureCanvas(picture: CanvasImageSource): boolean {
394
503
  if (this.canvas) return !!this.context;
504
+ const size = picture as { naturalWidth?: number; naturalHeight?: number; width?: number; height?: number };
395
505
  const canvas = this.host.document.createElement('canvas');
396
506
  canvas.className = FramesClass.Canvas;
397
507
  canvas.setAttribute('aria-hidden', 'true');
398
- canvas.width = image.naturalWidth || this.info.width;
399
- canvas.height = image.naturalHeight || this.info.height;
508
+ canvas.width = size.naturalWidth || Number(size.width) || this.info.width;
509
+ canvas.height = size.naturalHeight || Number(size.height) || this.info.height;
400
510
  this.context = canvas.getContext('2d');
401
511
  if (!this.context) return false;
402
512
  this.canvas = canvas;
@@ -238,6 +238,41 @@ export function sceneHeightForScreens(screens: number): string {
238
238
  return `${roundTo((held + 1) * 100, 1)}vh`;
239
239
  }
240
240
 
241
+ /**
242
+ * Pictures per screen of scrolling at which a played film stays smooth as a glide slows to a stop. Measured on Doron's
243
+ * iPhone (2026-11-05): Studio Alma's film at about 11 a screen stepped, at about 55 it did not.
244
+ */
245
+ export const SCENE_FILM_SMOOTH = 40;
246
+
247
+ export interface SceneFilmDensity {
248
+ /** Pictures per screen of scrolling over most of the play: half its scroll holds at least this many. */
249
+ perScreen: number;
250
+ /** The screens of scrolling the film plays over; where it holds still is not counted. */
251
+ screens: number;
252
+ smooth: boolean;
253
+ }
254
+
255
+ /**
256
+ * How densely a film of `count` pictures plays on a Scene of `screens`, by its Play track; null when nothing plays. A
257
+ * film's smoothness is its density, never the drawing: spread thin, a slowing glide holds each picture for many refreshes.
258
+ */
259
+ export function sceneFilmDensity(tracks: ReadonlyArray<SceneTrack> | null | undefined, screens: number | null, count: number): SceneFilmDensity | null {
260
+ const keys = normalizeTracks(tracks).find(track => track.prop === SceneProp.Play)?.keys ?? [];
261
+ if (count < 2 || !screens || screens <= 0) return null;
262
+ const stretches: { perScreen: number; screens: number }[] = [];
263
+ for (let i = 1; i < keys.length; i++) {
264
+ const scrolled = (keys[i][0] - keys[i - 1][0]) * screens;
265
+ const played = Math.abs(Number(keys[i][1]) - Number(keys[i - 1][1]));
266
+ if (scrolled > 0 && played > 0) stretches.push({ perScreen: ((played / 100) * (count - 1)) / scrolled, screens: scrolled });
267
+ }
268
+ if (!stretches.length) return null;
269
+ stretches.sort((a, b) => a.perScreen - b.perScreen);
270
+ const total = stretches.reduce((sum, stretch) => sum + stretch.screens, 0);
271
+ let covered = 0;
272
+ const typical = stretches.find(stretch => (covered += stretch.screens) >= total / 2)!;
273
+ return { perScreen: Math.round(typical.perScreen), screens: roundTo(total, 1), smooth: typical.perScreen >= SCENE_FILM_SMOOTH };
274
+ }
275
+
241
276
  /** Motion v2 §3d's limits for scroll-linked work on one page. */
242
277
  export const SCENE_BUDGET = { heldSections: 1, movingLayers: 8 } as const;
243
278