@vectoriox/iox-ui 5.75.3 → 5.76.1

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.
@@ -61,12 +61,6 @@ declare function frameSetFor(sequence: FrameSequence, phone: boolean): {
61
61
  declare function framePosition(played: number, count: number): number;
62
62
  /** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
63
63
  declare const FRAMES_SHOWING_ATTRIBUTE = "data-iox-frames-showing";
64
- /**
65
- * Below this many pictures a second the film stops following the scroll exactly and lands on its own. A landing scroll
66
- * slows exponentially, so the last picture or two would take most of a second as one slow dissolve between two places:
67
- * the subject showing twice, and then settling. Landing at least this fast keeps the end of the move a move.
68
- */
69
- declare const FILM_LANDING_SPEED = 10;
70
64
  /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
71
65
  declare const FRAME_BLEND_STEPS = 32;
72
66
  /** What to draw: picture `from`, with `to` over it at `mix` when the moment falls between two that have arrived. */
@@ -81,28 +75,6 @@ interface FrameBlend {
81
75
  * A neighbour still loading never blends: the nearest arrived picture stands in, and null means none has.
82
76
  */
83
77
  declare function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number): FrameBlend | null;
84
- /** Where the film stands and how the scroll under it moves: what one landing step reads and writes. */
85
- interface FilmMotion {
86
- /** The moment drawn, in pictures (2.5 = half way from the third to the fourth). */
87
- position: number;
88
- /** The moment the scroll asks for. */
89
- wanted: number;
90
- /** The scroll's speed, in pictures a second, smoothed. */
91
- speed: number;
92
- /** The way the scroll last moved: 1 onward, -1 back. */
93
- direction: 1 | -1;
94
- }
95
- /** Where a film stands before its first step: on the moment asked for, still. */
96
- declare function filmAt(wanted: number): FilmMotion;
97
- /**
98
- * One step of the film. Moving fast, it stands exactly where the scroll asks. Landing (slower than FILM_LANDING_SPEED
99
- * pictures a second) it runs on at that speed to the whole picture nearest where the scroll will stop, and stands there:
100
- * it never crawls through a dissolve, never stops between two pictures, and never steps back over a picture it reached
101
- * unless the scroll turns. `aim` is where the scroll is heading, when the driver knows it (its undamped target); without it, it is guessed.
102
- */
103
- declare function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number, aim?: number): FilmMotion;
104
- /** Whether a film has landed: still, on a whole picture beside where the scroll stands. */
105
- declare function filmLanded(motion: FilmMotion): boolean;
106
78
  /** The picture at `played` % of a set of `count`. */
107
79
  declare function frameIndexAt(played: number, count: number): number;
108
80
  /**
@@ -113,6 +85,16 @@ declare function frameIndexAt(played: number, count: number): number;
113
85
  declare function frameLoadOrder(count: number): number[];
114
86
  /** The loaded picture nearest `index`, or -1 when none has arrived. Ties go backward: the picture already passed. */
115
87
  declare function nearestLoadedFrame(loaded: ReadonlyArray<unknown>, index: number): number;
88
+ /**
89
+ * How much unpacked picture one player holds at once. A browser keeps few of 300 unpacked pictures (a wide set is 1.7 GB
90
+ * unpacked) and unpacked again on the page's own thread, at the moment of drawing, 22 ms a 1600×900 picture: longer than a
91
+ * whole frame, so the film and the page beside it skipped a beat for every new picture (Doron's recording, 2026-11-04).
92
+ */
93
+ declare const FRAMES_UNPACKED_BYTES: number;
94
+ /** How many pictures of `width`×`height` a player holds unpacked. */
95
+ declare function unpackedFrameCount(width: number, height: number): number;
96
+ /** The `size` pictures to hold unpacked around `position`, nearest first, each side in turn starting ahead. */
97
+ declare function framesToHold(position: number, count: number, size: number): number[];
116
98
  interface FrameSizes {
117
99
  width: number;
118
100
  height: number;
@@ -153,20 +135,26 @@ interface FramePlayerOptions {
153
135
  * The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
154
136
  * first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
155
137
  * Browser only. It writes and never reads, so the driver can call it in its write phase.
138
+ *
139
+ * Each picture is downloaded as a file and unpacked off the page's thread (`createImageBitmap` of the file), a window of
140
+ * them around where the film stands, so drawing one never waits for it. A browser that cannot (no `fetch` or
141
+ * `createImageBitmap`, or storage that refuses the download) plays from `<img>` pictures, as before: it works, and pays the
142
+ * unpacking on the page's thread.
156
143
  */
157
144
  declare class FramePlayer {
158
145
  private readonly el;
159
146
  private readonly sequence;
160
147
  private played;
161
- private aim;
162
- private motion;
163
- private steppedAt;
164
- private landingFrame;
165
148
  private readonly set;
166
149
  private readonly info;
167
- private readonly images;
150
+ /** What a draw may use: an unpacked picture, or an `<img>` on a browser that plays from them. */
151
+ private readonly ready;
152
+ /** The downloaded files, unpacked when the film comes near them. */
153
+ private readonly files;
168
154
  private readonly order;
169
155
  private readonly host;
156
+ private readonly holdCount;
157
+ private fromFiles;
170
158
  private observer;
171
159
  private canvas;
172
160
  private context;
@@ -175,23 +163,29 @@ declare class FramePlayer {
175
163
  private loading;
176
164
  private started;
177
165
  private destroyed;
166
+ private held;
167
+ private readonly unpacked;
168
+ private readonly unpacking;
178
169
  private readonly pending;
170
+ private readonly downloads;
179
171
  constructor(el: HTMLElement, sequence: FrameSequence, options?: FramePlayerOptions);
180
172
  /**
181
- * Stand at `played` %, on the way to `aim` % when the caller knows where the scroll is going. `now` is the caller's
182
- * frame time: the player keeps time by frames, so a frame asked twice moves once however long the page took.
173
+ * Stand at `played` %, exactly. The film keeps no motion of its own: the scroll's glide, and the slowing at its end,
174
+ * are the caller's, so the film slows with the page and stops when it stops. Between two pictures it blends them.
183
175
  */
184
- seek(played: number, aim?: number, now?: number): void;
185
- /** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
186
- private step;
187
- private stopLanding;
176
+ seek(played: number): void;
188
177
  /** Whether a re-render took the canvas away with the markup it sat in. */
189
178
  get detached(): boolean;
190
179
  destroy(): void;
191
180
  private allowed;
192
181
  private start;
193
182
  private pump;
194
- private load;
183
+ private loaded;
184
+ private download;
185
+ private loadImage;
186
+ /** Unpack the pictures around where the film stands, nearest first, and let go of the ones it has left. */
187
+ private hold;
188
+ private unpack;
195
189
  private draw;
196
190
  private ensureCanvas;
197
191
  }
@@ -477,5 +471,5 @@ declare function attachVideos(container: ParentNode, options?: VideoRuntimeOptio
477
471
  /** The runtime already running at `el`, if any. */
478
472
  declare function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null;
479
473
 
480
- export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FILM_LANDING_SPEED, 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, filmAt, filmLanded, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, stepFilm, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
481
- export type { ConsentGrantDetail, ConsentTransport, FilmMotion, FrameBlend, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
474
+ 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, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, unpackedFrameCount, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
475
+ 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.75.3",
3
+ "version": "5.76.1",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -111,23 +111,6 @@ export function framePosition(played: number, count: number): number {
111
111
  /** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
112
112
  export const FRAMES_SHOWING_ATTRIBUTE = 'data-iox-frames-showing';
113
113
 
114
- /**
115
- * Below this many pictures a second the film stops following the scroll exactly and lands on its own. A landing scroll
116
- * slows exponentially, so the last picture or two would take most of a second as one slow dissolve between two places:
117
- * the subject showing twice, and then settling. Landing at least this fast keeps the end of the move a move.
118
- */
119
- export const FILM_LANDING_SPEED = 10;
120
- /** With no word from the driver on where the scroll is going, how fast a landing scroll is assumed to slow (per second). */
121
- const FILM_LANDING_DECAY = 5;
122
- /** How quickly the film catches up with a scroll that turned back while it was landing ahead of it (per second). */
123
- const FILM_CATCH_RATE = 25;
124
- /** How quickly the film's reading of the scroll's speed follows it (per second). */
125
- const FILM_SPEED_RATE = 30;
126
- /** A step this short is the same frame asked twice: it moves nothing and reads no speed. */
127
- const FILM_SAME_FRAME_SECONDS = 0.002;
128
- const FILM_MAX_STEP_SECONDS = 0.1;
129
- const FILM_EPSILON = 1e-6;
130
-
131
114
  /** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
132
115
  export const FRAME_BLEND_STEPS = 32;
133
116
 
@@ -152,59 +135,6 @@ export function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number):
152
135
  return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
153
136
  }
154
137
 
155
- /** Where the film stands and how the scroll under it moves: what one landing step reads and writes. */
156
- export interface FilmMotion {
157
- /** The moment drawn, in pictures (2.5 = half way from the third to the fourth). */
158
- position: number;
159
- /** The moment the scroll asks for. */
160
- wanted: number;
161
- /** The scroll's speed, in pictures a second, smoothed. */
162
- speed: number;
163
- /** The way the scroll last moved: 1 onward, -1 back. */
164
- direction: 1 | -1;
165
- }
166
-
167
- /** Where a film stands before its first step: on the moment asked for, still. */
168
- export function filmAt(wanted: number): FilmMotion {
169
- return { position: wanted, wanted, speed: 0, direction: 1 };
170
- }
171
-
172
- /**
173
- * One step of the film. Moving fast, it stands exactly where the scroll asks. Landing (slower than FILM_LANDING_SPEED
174
- * pictures a second) it runs on at that speed to the whole picture nearest where the scroll will stop, and stands there:
175
- * it never crawls through a dissolve, never stops between two pictures, and never steps back over a picture it reached
176
- * unless the scroll turns. `aim` is where the scroll is heading, when the driver knows it (its undamped target); without it, it is guessed.
177
- */
178
- export function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number, aim?: number): FilmMotion {
179
- const sameFrame = seconds < FILM_SAME_FRAME_SECONDS;
180
- const step = Math.min(FILM_MAX_STEP_SECONDS, seconds);
181
- const moved = wanted - motion.wanted;
182
- const speed = sameFrame ? motion.speed : motion.speed + (moved / step - motion.speed) * (1 - Math.exp(-FILM_SPEED_RATE * step));
183
- const heading = aim === undefined ? moved : aim - wanted;
184
- const direction: 1 | -1 = Math.abs(heading) > FILM_EPSILON ? (heading > 0 ? 1 : -1) : Math.abs(moved) > FILM_EPSILON ? (moved > 0 ? 1 : -1) : motion.direction;
185
- // Moving: with the scroll, but never back over a picture a landing already reached unless the scroll turned.
186
- if (Math.abs(speed) >= FILM_LANDING_SPEED) {
187
- const position = direction > 0 ? Math.max(wanted, Math.min(motion.position, Math.ceil(wanted))) : Math.min(wanted, Math.max(motion.position, Math.floor(wanted)));
188
- return { position, wanted, speed, direction };
189
- }
190
- if (sameFrame) return { ...motion, wanted, direction };
191
- const clamp = (value: number): number => Math.min(last, Math.max(0, value));
192
- const stop = aim ?? wanted + speed / FILM_LANDING_DECAY;
193
- // The picture nearest the stop, unless reaching it would show a step back: then the next whole one on the way.
194
- const nearest = Math.round(stop);
195
- const back = (motion.position - nearest) * direction > 1 / FRAME_BLEND_STEPS;
196
- const goal = clamp(back ? (direction > 0 ? Math.ceil(motion.position) : Math.floor(motion.position)) : nearest);
197
- const behind = (wanted - motion.position) * direction;
198
- const reach = Math.max(FILM_LANDING_SPEED * step, behind > 0 ? behind * (1 - Math.exp(-FILM_CATCH_RATE * step)) : 0);
199
- const position = direction > 0 ? Math.min(goal, motion.position + reach) : Math.max(goal, motion.position - reach);
200
- return { position, wanted, speed, direction };
201
- }
202
-
203
- /** Whether a film has landed: still, on a whole picture beside where the scroll stands. */
204
- export function filmLanded(motion: FilmMotion): boolean {
205
- return Math.abs(motion.speed) < 0.01 && Number.isInteger(motion.position) && Math.abs(motion.position - motion.wanted) < 1;
206
- }
207
-
208
138
  /** The picture at `played` % of a set of `count`. */
209
139
  export function frameIndexAt(played: number, count: number): number {
210
140
  if (count <= 1) return 0;
@@ -242,6 +172,31 @@ export function nearestLoadedFrame(loaded: ReadonlyArray<unknown>, index: number
242
172
  return -1;
243
173
  }
244
174
 
175
+ /**
176
+ * How much unpacked picture one player holds at once. A browser keeps few of 300 unpacked pictures (a wide set is 1.7 GB
177
+ * unpacked) and unpacked again on the page's own thread, at the moment of drawing, 22 ms a 1600×900 picture: longer than a
178
+ * whole frame, so the film and the page beside it skipped a beat for every new picture (Doron's recording, 2026-11-04).
179
+ */
180
+ export const FRAMES_UNPACKED_BYTES = 96 * 1024 * 1024;
181
+ const MIN_UNPACKED = 6;
182
+ const MAX_UNPACKED = 24;
183
+
184
+ /** How many pictures of `width`×`height` a player holds unpacked. */
185
+ export function unpackedFrameCount(width: number, height: number): number {
186
+ return Math.min(MAX_UNPACKED, Math.max(MIN_UNPACKED, Math.floor(FRAMES_UNPACKED_BYTES / (width * height * 4))));
187
+ }
188
+
189
+ /** The `size` pictures to hold unpacked around `position`, nearest first, each side in turn starting ahead. */
190
+ export function framesToHold(position: number, count: number, size: number): number[] {
191
+ const from = Math.min(count - 1, Math.max(0, Math.floor(position)));
192
+ const held: number[] = [from];
193
+ for (let distance = 1; held.length < Math.min(size, count); distance++) {
194
+ if (from + distance < count) held.push(from + distance);
195
+ if (from - distance >= 0 && held.length < size) held.push(from - distance);
196
+ }
197
+ return held;
198
+ }
199
+
245
200
  export interface FrameSizes {
246
201
  width: number;
247
202
  height: number;
@@ -296,6 +251,7 @@ export enum FramesClass {
296
251
  }
297
252
 
298
253
  const LOAD_CONCURRENCY = 6;
254
+ const UNPACK_CONCURRENCY = 4;
299
255
  const NEAR_MARGIN = '100% 0px';
300
256
  /** Under these the visitor keeps the poster: nobody on a metered 2G connection asked to download a film. */
301
257
  const SLOW_CONNECTIONS: ReadonlySet<string> = new Set(['slow-2g', '2g']);
@@ -318,18 +274,24 @@ export interface FramePlayerOptions {
318
274
  * The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
319
275
  * first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
320
276
  * Browser only. It writes and never reads, so the driver can call it in its write phase.
277
+ *
278
+ * Each picture is downloaded as a file and unpacked off the page's thread (`createImageBitmap` of the file), a window of
279
+ * them around where the film stands, so drawing one never waits for it. A browser that cannot (no `fetch` or
280
+ * `createImageBitmap`, or storage that refuses the download) plays from `<img>` pictures, as before: it works, and pays the
281
+ * unpacking on the page's thread.
321
282
  */
322
283
  export class FramePlayer {
323
284
  private played: number | null = null;
324
- private aim: number | undefined;
325
- private motion: FilmMotion | null = null;
326
- private steppedAt = 0;
327
- private landingFrame: number | null = null;
328
285
  private readonly set: FrameSet;
329
286
  private readonly info: FrameSetInfo;
330
- private readonly images: Array<HTMLImageElement | null>;
287
+ /** What a draw may use: an unpacked picture, or an `<img>` on a browser that plays from them. */
288
+ private readonly ready: Array<CanvasImageSource | null>;
289
+ /** The downloaded files, unpacked when the film comes near them. */
290
+ private readonly files: Array<Blob | null>;
331
291
  private readonly order: number[];
332
292
  private readonly host: Window;
293
+ private readonly holdCount: number;
294
+ private fromFiles: boolean;
333
295
  private observer: IntersectionObserver | null = null;
334
296
  private canvas: HTMLCanvasElement | null = null;
335
297
  private context: CanvasRenderingContext2D | null = null;
@@ -338,15 +300,24 @@ export class FramePlayer {
338
300
  private loading = 0;
339
301
  private started = false;
340
302
  private destroyed = false;
303
+ private held = new Set<number>();
304
+ private readonly unpacked = new Map<number, ImageBitmap>();
305
+ private readonly unpacking = new Set<number>();
341
306
  private readonly pending = new Set<HTMLImageElement>();
307
+ private readonly downloads: AbortController | null;
342
308
 
343
309
  constructor(private readonly el: HTMLElement, private readonly sequence: FrameSequence, options: FramePlayerOptions = {}) {
344
310
  this.host = options.host ?? window;
345
311
  const { set, info } = frameSetFor(sequence, !!options.phone);
346
312
  this.set = set;
347
313
  this.info = info;
348
- this.images = new Array(info.count).fill(null);
314
+ this.ready = new Array(info.count).fill(null);
315
+ this.files = new Array(info.count).fill(null);
349
316
  this.order = frameLoadOrder(info.count);
317
+ this.holdCount = unpackedFrameCount(info.width, info.height);
318
+ this.fromFiles = typeof this.host.fetch === 'function' && typeof this.host.createImageBitmap === 'function';
319
+ const Controller = (this.host as Window & { AbortController?: typeof AbortController }).AbortController;
320
+ this.downloads = this.fromFiles && Controller ? new Controller() : null;
350
321
  if (!this.allowed()) return;
351
322
  const Observer = (this.host as Window & { IntersectionObserver?: typeof IntersectionObserver }).IntersectionObserver;
352
323
  if (!Observer) {
@@ -360,33 +331,14 @@ export class FramePlayer {
360
331
  }
361
332
 
362
333
  /**
363
- * Stand at `played` %, on the way to `aim` % when the caller knows where the scroll is going. `now` is the caller's
364
- * frame time: the player keeps time by frames, so a frame asked twice moves once however long the page took.
334
+ * Stand at `played` %, exactly. The film keeps no motion of its own: the scroll's glide, and the slowing at its end,
335
+ * are the caller's, so the film slows with the page and stops when it stops. Between two pictures it blends them.
365
336
  */
366
- seek(played: number, aim?: number, now?: number): void {
367
- if (played === this.played && aim === this.aim) return;
337
+ seek(played: number): void {
338
+ if (played === this.played) return;
368
339
  this.played = played;
369
- this.aim = aim;
370
- this.step(now ?? this.host.performance?.now() ?? 0);
371
- }
372
-
373
- /** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
374
- private step(now: number): void {
375
- if (this.destroyed || this.played === null) return;
376
- const wanted = framePosition(this.played, this.info.count);
377
- const aim = this.aim === undefined ? undefined : framePosition(this.aim, this.info.count);
378
- this.motion = this.motion ? stepFilm(this.motion, wanted, (now - this.steppedAt) / 1000, this.info.count - 1, aim) : filmAt(wanted);
379
- this.steppedAt = now;
340
+ this.hold();
380
341
  this.draw();
381
- if (!filmLanded(this.motion) && this.landingFrame === null) this.landingFrame = this.host.requestAnimationFrame(time => {
382
- this.landingFrame = null;
383
- this.step(time);
384
- });
385
- }
386
-
387
- private stopLanding(): void {
388
- if (this.landingFrame !== null) this.host.cancelAnimationFrame?.(this.landingFrame);
389
- this.landingFrame = null;
390
342
  }
391
343
 
392
344
  /** Whether a re-render took the canvas away with the markup it sat in. */
@@ -396,16 +348,18 @@ export class FramePlayer {
396
348
 
397
349
  destroy(): void {
398
350
  this.destroyed = true;
399
- this.stopLanding();
400
351
  this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
401
352
  this.observer?.disconnect();
402
353
  this.observer = null;
354
+ this.downloads?.abort();
403
355
  for (const image of this.pending) {
404
356
  image.onload = null;
405
357
  image.onerror = null;
406
358
  image.src = '';
407
359
  }
408
360
  this.pending.clear();
361
+ for (const bitmap of this.unpacked.values()) bitmap.close();
362
+ this.unpacked.clear();
409
363
  this.canvas?.remove();
410
364
  this.canvas = null;
411
365
  this.context = null;
@@ -426,64 +380,134 @@ export class FramePlayer {
426
380
 
427
381
  private pump(): void {
428
382
  while (!this.destroyed && this.loading < LOAD_CONCURRENCY && this.nextToLoad < this.order.length) {
429
- this.load(this.order[this.nextToLoad++]);
383
+ const index = this.order[this.nextToLoad++];
384
+ if (this.fromFiles) this.download(index);
385
+ else this.loadImage(index);
430
386
  }
431
387
  }
432
388
 
433
- private load(index: number): void {
389
+ private loaded(arrived: boolean): void {
390
+ this.loading--;
391
+ if (this.destroyed) return;
392
+ if (arrived) this.draw();
393
+ this.pump();
394
+ }
395
+
396
+ private download(index: number): void {
397
+ this.loading++;
398
+ const url = frameUrl(this.sequence, this.set, index);
399
+ this.host.fetch(url, this.downloads ? { signal: this.downloads.signal } : undefined)
400
+ .then(response => (response.ok ? response.blob() : null))
401
+ .then(file => {
402
+ if (this.destroyed) return;
403
+ // A picture that failed is a gap: its neighbours stand in.
404
+ this.files[index] = file;
405
+ this.loading--;
406
+ this.hold();
407
+ this.pump();
408
+ }, () => {
409
+ if (this.destroyed) return;
410
+ // Storage that refuses a download from the page's address still serves an <img>: play from those.
411
+ this.fromFiles = false;
412
+ this.loading--;
413
+ this.loadImage(index);
414
+ });
415
+ }
416
+
417
+ private loadImage(index: number): void {
434
418
  const image = this.host.document.createElement('img');
435
419
  image.decoding = 'async';
436
420
  this.loading++;
437
421
  this.pending.add(image);
438
422
  const done = (arrived: boolean): void => {
439
423
  this.pending.delete(image);
440
- this.loading--;
441
- if (this.destroyed) return;
442
- if (arrived) {
443
- this.images[index] = image;
444
- this.draw();
445
- }
446
- this.pump();
424
+ if (!this.destroyed && arrived) this.ready[index] = image;
425
+ this.loaded(arrived);
447
426
  };
448
- // Decoded before it counts, or the first draw of each picture decodes on the driver's frame and the scroll stutters.
427
+ // Decoded before it counts, or its first draw decodes on the driver's frame.
449
428
  image.onload = () => {
450
429
  const decoded = image.decode?.();
451
430
  if (decoded) void decoded.then(() => done(true), () => done(true));
452
431
  else done(true);
453
432
  };
454
- // A picture that failed is a gap: its neighbours stand in.
455
433
  image.onerror = () => done(false);
456
434
  image.src = frameUrl(this.sequence, this.set, index);
457
435
  }
458
436
 
437
+ /** Unpack the pictures around where the film stands, nearest first, and let go of the ones it has left. */
438
+ private hold(): void {
439
+ if (this.destroyed) return;
440
+ const wanted = framesToHold(framePosition(this.played ?? 0, this.info.count), this.info.count, this.holdCount);
441
+ this.held = new Set(wanted);
442
+ for (const [index, bitmap] of this.unpacked) {
443
+ if (this.held.has(index)) continue;
444
+ bitmap.close();
445
+ this.unpacked.delete(index);
446
+ this.ready[index] = null;
447
+ }
448
+ for (const index of wanted) {
449
+ if (this.unpacking.size >= UNPACK_CONCURRENCY) return;
450
+ if (this.files[index] && !this.ready[index] && !this.unpacking.has(index)) this.unpack(index);
451
+ }
452
+ }
453
+
454
+ private unpack(index: number): void {
455
+ this.unpacking.add(index);
456
+ this.host.createImageBitmap(this.files[index]!).then(bitmap => {
457
+ this.unpacking.delete(index);
458
+ if (this.destroyed || !this.held.has(index)) {
459
+ bitmap.close();
460
+ } else {
461
+ this.unpacked.set(index, bitmap);
462
+ this.ready[index] = bitmap;
463
+ this.draw();
464
+ }
465
+ this.hold();
466
+ }, () => {
467
+ // A file that will not unpack is a gap, like one that never came.
468
+ this.unpacking.delete(index);
469
+ this.files[index] = null;
470
+ this.hold();
471
+ });
472
+ }
473
+
459
474
  private draw(): void {
460
- if (!this.motion) return;
461
- const blend = frameBlendAt(this.images, this.motion.position);
475
+ if (this.destroyed || this.played === null) return;
476
+ const blend = frameBlendAt(this.ready, framePosition(this.played, this.info.count));
462
477
  const last = this.drawn;
463
478
  if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
464
- const image = this.images[blend.from]!;
465
- if (!this.ensureCanvas(image)) return;
479
+ const picture = this.ready[blend.from]!;
480
+ if (!this.ensureCanvas(picture)) return;
466
481
  const context = this.context!;
467
482
  const { width, height } = this.canvas!;
468
483
  context.clearRect(0, 0, width, height);
469
- context.drawImage(image, 0, 0, width, height);
470
484
  if (blend.mix > 0) {
485
+ // A true mix, (1 − mix)·from + mix·to, even where a see-through film has nothing on one side. Drawing `to` over
486
+ // an opaque `from` kept `from`'s outline whole until the next picture, so the subject's edge jumped a picture at
487
+ // a time instead of moving.
488
+ context.globalCompositeOperation = 'lighter';
489
+ context.globalAlpha = 1 - blend.mix;
490
+ context.drawImage(picture, 0, 0, width, height);
471
491
  context.globalAlpha = blend.mix;
472
- context.drawImage(this.images[blend.to]!, 0, 0, width, height);
492
+ context.drawImage(this.ready[blend.to]!, 0, 0, width, height);
473
493
  context.globalAlpha = 1;
494
+ context.globalCompositeOperation = 'source-over';
495
+ } else {
496
+ context.drawImage(picture, 0, 0, width, height);
474
497
  }
475
498
  // Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
476
499
  if (!this.drawn) this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
477
500
  this.drawn = blend;
478
501
  }
479
502
 
480
- private ensureCanvas(image: HTMLImageElement): boolean {
503
+ private ensureCanvas(picture: CanvasImageSource): boolean {
481
504
  if (this.canvas) return !!this.context;
505
+ const size = picture as { naturalWidth?: number; naturalHeight?: number; width?: number; height?: number };
482
506
  const canvas = this.host.document.createElement('canvas');
483
507
  canvas.className = FramesClass.Canvas;
484
508
  canvas.setAttribute('aria-hidden', 'true');
485
- canvas.width = image.naturalWidth || this.info.width;
486
- canvas.height = image.naturalHeight || this.info.height;
509
+ canvas.width = size.naturalWidth || Number(size.width) || this.info.width;
510
+ canvas.height = size.naturalHeight || Number(size.height) || this.info.height;
487
511
  this.context = canvas.getContext('2d');
488
512
  if (!this.context) return false;
489
513
  this.canvas = canvas;
@@ -19,7 +19,8 @@ import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
19
19
  */
20
20
 
21
21
  export const SCENE_DAMPING = 9;
22
- const SETTLED = 0.0005;
22
+ /** Page px from the scroll at which the damping stops and stands exactly on it: a step this small never shows. */
23
+ const SETTLED_PX = 0.25;
23
24
  const MAX_FRAME_SECONDS = 0.1;
24
25
  /** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
25
26
  const WAKING_FRAME_SECONDS = 1 / 60;
@@ -263,11 +264,12 @@ export class ScrollDriver {
263
264
  for (const { scene, local, target } of reads) {
264
265
  if (this.options.pin && scene.stage) scene.stage.style.setProperty('translate', `0 ${local}px`);
265
266
  const next = scene.fresh ? target : dampToward(scene.progress, target, seconds, this.options.damping);
266
- const settled = Math.abs(next - target) < SETTLED;
267
+ // The step onto the scroll is what is left: taken once it is under SETTLED_PX, so it never shows.
268
+ const settled = scene.fresh || Math.abs(target - scene.progress) * Math.max(1, scene.travel) < SETTLED_PX;
267
269
  scene.progress = settled ? target : next;
268
270
  scene.target = target;
269
271
  scene.fresh = false;
270
- this.write(scene, now);
272
+ this.write(scene);
271
273
  this.options.onFrame?.(scene.root, { target, progress: scene.progress });
272
274
  moving = moving || !settled;
273
275
  }
@@ -283,11 +285,11 @@ export class ScrollDriver {
283
285
  return { scene, local, target: progress };
284
286
  }
285
287
 
286
- private write(scene: Scene, now: number): void {
288
+ private write(scene: Scene): void {
287
289
  for (const layer of scene.layers) {
288
290
  const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
289
291
  this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
290
- this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress), scenePlayed(tracks, scene.target), now);
292
+ this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress));
291
293
  const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
292
294
  for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
293
295
  if (layer.stale || frame[key] !== layer.last[key]) layer.el.style[key] = frame[key] ?? '';
@@ -305,7 +307,7 @@ export class ScrollDriver {
305
307
  * The picture this moment asks for. The attribute sits on the Video's own root, which is the layer on the string path
306
308
  * and inside it on the component path. A layer with no pictures, or no Play track, holds its poster.
307
309
  */
308
- private writePlayed(layer: Layer, scene: Scene, played: number | null, aim: number | null, now: number): void {
310
+ private writePlayed(layer: Layer, scene: Scene, played: number | null): void {
309
311
  const target = played === null ? null : (layer.el.hasAttribute(VideoAttribute.Frames) ? layer.el : layer.el.querySelector<HTMLElement>(`[${VideoAttribute.Frames}]`));
310
312
  const raw = target?.getAttribute(VideoAttribute.Frames) ?? '';
311
313
  const sequence = raw ? normalizeFrameSequence(raw) : null;
@@ -319,8 +321,8 @@ export class ScrollDriver {
319
321
  layer.player = new FramePlayer(target, sequence, { phone: scene.phone, near: scene.root, host: this.host });
320
322
  layer.playerKey = key;
321
323
  }
322
- // Where the damping is heading, so a landing film stops on the picture the scroll stops on, not one past it.
323
- layer.player.seek(played!, aim ?? undefined, now);
324
+ // The damped moment, like every other layer: the film slows with the page and stops when it stops.
325
+ layer.player.seek(played!);
324
326
  }
325
327
 
326
328
  /** One opacity per word, and a write only for the few the scroll actually changed. */