@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.
- package/fesm2022/vectoriox-iox-ui-media-core.mjs +148 -107
- package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +10 -8
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
- package/media-core/index.d.ts +36 -42
- package/package.json +1 -1
- package/src/media-core/frames.ts +145 -121
- package/src/scene-core/scroll-driver.ts +10 -8
package/media-core/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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` %,
|
|
182
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
481
|
-
export type { ConsentGrantDetail, ConsentTransport,
|
|
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
package/src/media-core/frames.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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` %,
|
|
364
|
-
*
|
|
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
|
|
367
|
-
if (played === this.played
|
|
337
|
+
seek(played: number): void {
|
|
338
|
+
if (played === this.played) return;
|
|
368
339
|
this.played = played;
|
|
369
|
-
this.
|
|
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.
|
|
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
|
|
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.
|
|
441
|
-
|
|
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
|
|
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 (
|
|
461
|
-
const blend = frameBlendAt(this.
|
|
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
|
|
465
|
-
if (!this.ensureCanvas(
|
|
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.
|
|
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(
|
|
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 =
|
|
486
|
-
canvas.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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
323
|
-
layer.player.seek(played
|
|
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. */
|