@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.
- package/fesm2022/vectoriox-iox-ui-media-core.mjs +145 -33
- package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +29 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
- package/media-core/index.d.ts +38 -7
- package/package.json +1 -1
- package/scene-core/index.d.ts +19 -2
- package/src/media-core/frames.ts +143 -33
- package/src/scene-core/scene-edit.ts +35 -0
package/media-core/index.d.ts
CHANGED
|
@@ -38,8 +38,12 @@ declare enum FrameExtension {
|
|
|
38
38
|
Webp = "webp",
|
|
39
39
|
Jpeg = "jpg"
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
package/scene-core/index.d.ts
CHANGED
|
@@ -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 };
|
package/src/media-core/frames.ts
CHANGED
|
@@ -42,8 +42,12 @@ export enum FrameExtension {
|
|
|
42
42
|
Jpeg = 'jpg',
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
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 =
|
|
186
|
-
/** A video's cap:
|
|
187
|
-
const VIDEO_MAX_FRAMES =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
346
|
-
|
|
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
|
|
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.
|
|
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
|
|
370
|
-
if (!this.ensureCanvas(
|
|
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(
|
|
489
|
+
context.drawImage(picture, 0, 0, width, height);
|
|
381
490
|
context.globalAlpha = blend.mix;
|
|
382
|
-
context.drawImage(this.
|
|
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(
|
|
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(
|
|
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 =
|
|
399
|
-
canvas.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
|
|