@vectoriox/iox-ui 5.72.0 → 5.73.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-generation-core.mjs +2 -0
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-media-core.mjs +338 -1
- package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +55 -3
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +11 -2
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/index.d.ts +3 -1
- package/media-core/index.d.ts +146 -5
- package/package.json +1 -1
- package/scene-core/index.d.ts +18 -1
- package/src/generation-core/node-baselines.generated.ts +2 -0
- package/src/media-core/frames.ts +339 -0
- package/src/media-core/public-api.ts +1 -0
- package/src/media-core/video.ts +48 -0
- package/src/scene-core/scene-edit.ts +15 -0
- package/src/scene-core/scene.ts +8 -0
- package/src/scene-core/scroll-driver.ts +37 -3
- package/styles/iox-design.css +1 -1
package/index.d.ts
CHANGED
|
@@ -954,6 +954,8 @@ declare class IoxVideoComponent implements AfterViewInit, OnDestroy {
|
|
|
954
954
|
get captionsLang(): unknown;
|
|
955
955
|
set consentCategory(v: unknown);
|
|
956
956
|
get consentCategory(): unknown;
|
|
957
|
+
set frames(v: unknown);
|
|
958
|
+
get frames(): unknown;
|
|
957
959
|
private root?;
|
|
958
960
|
plan: VideoPlan;
|
|
959
961
|
attrs: Record<string, string>;
|
|
@@ -969,7 +971,7 @@ declare class IoxVideoComponent implements AfterViewInit, OnDestroy {
|
|
|
969
971
|
private render;
|
|
970
972
|
private attach;
|
|
971
973
|
static ɵfac: i0.ɵɵFactoryDeclaration<IoxVideoComponent, [null, null, { optional: true; }]>;
|
|
972
|
-
static ɵcmp: i0.ɵɵComponentDeclaration<IoxVideoComponent, "iox-video", never, { "nodeId": { "alias": "nodeId"; "required": false; }; "videoSource": { "alias": "videoSource"; "required": false; }; "src": { "alias": "src"; "required": false; }; "poster": { "alias": "poster"; "required": false; }; "mode": { "alias": "mode"; "required": false; }; "autoplay": { "alias": "autoplay"; "required": false; }; "title": { "alias": "title"; "required": false; }; "captions": { "alias": "captions"; "required": false; }; "captionsLang": { "alias": "captionsLang"; "required": false; }; "consentCategory": { "alias": "consentCategory"; "required": false; }; }, {}, never, never, false, never>;
|
|
974
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<IoxVideoComponent, "iox-video", never, { "nodeId": { "alias": "nodeId"; "required": false; }; "videoSource": { "alias": "videoSource"; "required": false; }; "src": { "alias": "src"; "required": false; }; "poster": { "alias": "poster"; "required": false; }; "mode": { "alias": "mode"; "required": false; }; "autoplay": { "alias": "autoplay"; "required": false; }; "title": { "alias": "title"; "required": false; }; "captions": { "alias": "captions"; "required": false; }; "captionsLang": { "alias": "captionsLang"; "required": false; }; "consentCategory": { "alias": "consentCategory"; "required": false; }; "frames": { "alias": "frames"; "required": false; }; }, {}, never, never, false, never>;
|
|
973
975
|
}
|
|
974
976
|
|
|
975
977
|
/** What the visitor actually types into. Chosen by a trait; it also implies a validation rule. */
|
package/media-core/index.d.ts
CHANGED
|
@@ -1,5 +1,138 @@
|
|
|
1
1
|
import { ConsentCategory, ConsentDecision } from '@vectoriox/iox-ui/consent-core';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A video played by the scroll, as pictures (premium sites SC4). Every source, a video file or a folder of rendered frames,
|
|
5
|
+
* becomes the same numbered set of pictures, prepared once in the author's browser, one set for wide screens and a lighter
|
|
6
|
+
* one for phones. So there is one player and it is the same on every browser: a `<video>` seeked by the scroll needs a
|
|
7
|
+
* dense-keyframe encode no pod here can run, and on an iPhone it accepts seeks it never answers.
|
|
8
|
+
*
|
|
9
|
+
* The saved value is a small description, never a list of addresses: a page holding 300 URLs would ship them in its HTML.
|
|
10
|
+
*/
|
|
11
|
+
declare enum FrameSet {
|
|
12
|
+
Wide = "w",
|
|
13
|
+
Phone = "p"
|
|
14
|
+
}
|
|
15
|
+
interface FrameSetInfo {
|
|
16
|
+
count: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
interface FrameSequence {
|
|
21
|
+
/** Where the numbered pictures live: `<base>/<set>/0001.<ext>`. */
|
|
22
|
+
base: string;
|
|
23
|
+
ext: FrameExtension;
|
|
24
|
+
wide: FrameSetInfo;
|
|
25
|
+
/** Absent = phones play the wide set. */
|
|
26
|
+
phone?: FrameSetInfo;
|
|
27
|
+
/** What the sequence weighs in storage, both sets. Shown to the author. */
|
|
28
|
+
bytes?: number;
|
|
29
|
+
}
|
|
30
|
+
declare enum FrameExtension {
|
|
31
|
+
Webp = "webp",
|
|
32
|
+
Jpeg = "jpg"
|
|
33
|
+
}
|
|
34
|
+
/** The most pictures one set may hold. Past this a phone runs out of memory before the visitor runs out of scroll. */
|
|
35
|
+
declare const MAX_FRAMES = 300;
|
|
36
|
+
/** The most one picture may weigh when uploaded. */
|
|
37
|
+
declare const MAX_FRAME_BYTES: number;
|
|
38
|
+
/** A sequence's name in storage: what the browser generates and what the server accepts in a key. */
|
|
39
|
+
declare const FRAME_SEQUENCE_ID: RegExp;
|
|
40
|
+
/** The sequence a saved value really describes, or null. Never throws on what a page document holds. */
|
|
41
|
+
declare function normalizeFrameSequence(raw: unknown): FrameSequence | null;
|
|
42
|
+
/** The name a picture is stored under: its place in the set, from 1. */
|
|
43
|
+
declare function frameFileName(index: number, ext: FrameExtension): string;
|
|
44
|
+
/** Picture `index` (from 0) of a set. */
|
|
45
|
+
declare function frameUrl(sequence: FrameSequence, set: FrameSet, index: number): string;
|
|
46
|
+
/** The first picture of each set, for a poster's `srcset`; '' when there is only one set. */
|
|
47
|
+
declare function phoneFrameSrcset(sequence: FrameSequence): string;
|
|
48
|
+
/** Which set plays: the phone's on a phone, when there is one. */
|
|
49
|
+
declare function frameSetFor(sequence: FrameSequence, phone: boolean): {
|
|
50
|
+
set: FrameSet;
|
|
51
|
+
info: FrameSetInfo;
|
|
52
|
+
};
|
|
53
|
+
/** The picture at `played` % of a set of `count`. */
|
|
54
|
+
declare function frameIndexAt(played: number, count: number): number;
|
|
55
|
+
/**
|
|
56
|
+
* The order to fetch a set in: the two ends, then every 16th, every 8th and so on down to all of them. Any scroll position
|
|
57
|
+
* then has a near picture almost at once, and the movement sharpens as the rest arrive. Fetching in sequence would leave a
|
|
58
|
+
* visitor who scrolls fast looking at the first frames.
|
|
59
|
+
*/
|
|
60
|
+
declare function frameLoadOrder(count: number): number[];
|
|
61
|
+
/** The loaded picture nearest `index`, or -1 when none has arrived. Ties go backward: the picture already passed. */
|
|
62
|
+
declare function nearestLoadedFrame(loaded: ReadonlyArray<unknown>, index: number): number;
|
|
63
|
+
interface FrameSizes {
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
}
|
|
67
|
+
/** The longest side of a wide picture and of a phone picture. A phone's is not its width: a landscape video covers a
|
|
68
|
+
* tall stage by its height, so a picture sized to the phone's width would be enlarged five times. */
|
|
69
|
+
declare const WIDE_LONG_SIDE = 1600;
|
|
70
|
+
declare const PHONE_LONG_SIDE = 1100;
|
|
71
|
+
/** `width`×`height` fitted inside `longSide`, never enlarged. */
|
|
72
|
+
declare function fittedFrameSize(width: number, height: number, longSide: number): FrameSizes;
|
|
73
|
+
/** How many pictures a video of `seconds` becomes. */
|
|
74
|
+
declare function videoFrameCount(seconds: number): number;
|
|
75
|
+
/** Which of `sourceCount` pictures make a set of `count`, spread evenly, first and last always in. */
|
|
76
|
+
declare function sampledFrameIndexes(sourceCount: number, count: number): number[];
|
|
77
|
+
/** A phone plays half the pictures: it has less memory, and half is plenty on a small screen. */
|
|
78
|
+
declare function phoneFrameCount(wideCount: number): number;
|
|
79
|
+
/** Rendered frames in their real order: `frame2` before `frame10`. */
|
|
80
|
+
declare function sortFrameFiles<T extends {
|
|
81
|
+
name: string;
|
|
82
|
+
}>(files: ReadonlyArray<T>): T[];
|
|
83
|
+
/** A sequence's weight, for the author. */
|
|
84
|
+
declare function frameSummary(sequence: FrameSequence): {
|
|
85
|
+
frames: number;
|
|
86
|
+
megabytes: number | null;
|
|
87
|
+
};
|
|
88
|
+
declare enum FramesClass {
|
|
89
|
+
Canvas = "iox-video__frames"
|
|
90
|
+
}
|
|
91
|
+
interface FramePlayerOptions {
|
|
92
|
+
/** Play the phone's set. */
|
|
93
|
+
phone?: boolean;
|
|
94
|
+
/** Start fetching when this comes near the screen. Not the layer itself: a layer clipped shut intersects nothing. */
|
|
95
|
+
near?: Element;
|
|
96
|
+
/** The window, for a test. */
|
|
97
|
+
host?: Window;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
|
|
101
|
+
* first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
|
|
102
|
+
* Browser only. It writes and never reads, so the driver can call it in its write phase.
|
|
103
|
+
*/
|
|
104
|
+
declare class FramePlayer {
|
|
105
|
+
private readonly el;
|
|
106
|
+
private readonly sequence;
|
|
107
|
+
private readonly set;
|
|
108
|
+
private readonly info;
|
|
109
|
+
private readonly images;
|
|
110
|
+
private readonly order;
|
|
111
|
+
private readonly host;
|
|
112
|
+
private observer;
|
|
113
|
+
private canvas;
|
|
114
|
+
private context;
|
|
115
|
+
private wanted;
|
|
116
|
+
private drawn;
|
|
117
|
+
private nextToLoad;
|
|
118
|
+
private loading;
|
|
119
|
+
private started;
|
|
120
|
+
private destroyed;
|
|
121
|
+
private readonly pending;
|
|
122
|
+
constructor(el: HTMLElement, sequence: FrameSequence, options?: FramePlayerOptions);
|
|
123
|
+
/** Stand at `played` %. */
|
|
124
|
+
seek(played: number): void;
|
|
125
|
+
/** Whether a re-render took the canvas away with the markup it sat in. */
|
|
126
|
+
get detached(): boolean;
|
|
127
|
+
destroy(): void;
|
|
128
|
+
private allowed;
|
|
129
|
+
private start;
|
|
130
|
+
private pump;
|
|
131
|
+
private load;
|
|
132
|
+
private draw;
|
|
133
|
+
private ensureCanvas;
|
|
134
|
+
}
|
|
135
|
+
|
|
3
136
|
/**
|
|
4
137
|
* VIDEO (CC3 — complex-components plan §7b, board 5). A LEAF type with two sources:
|
|
5
138
|
* - an uploaded FILE renders as a real `<video>` (poster, `preload="metadata"`, captions, controls);
|
|
@@ -34,7 +167,9 @@ declare enum VideoTrait {
|
|
|
34
167
|
Title = "title",
|
|
35
168
|
Captions = "captions",
|
|
36
169
|
CaptionsLang = "captionsLang",
|
|
37
|
-
Consent = "consentCategory"
|
|
170
|
+
Consent = "consentCategory",
|
|
171
|
+
/** Pictures the scroll plays (SC4): a `FrameSequence`. Written by the Scene panel, never typed. */
|
|
172
|
+
Frames = "frames"
|
|
38
173
|
}
|
|
39
174
|
/** What a plan renders. */
|
|
40
175
|
declare enum VideoKind {
|
|
@@ -43,7 +178,9 @@ declare enum VideoKind {
|
|
|
43
178
|
/** YouTube / Vimeo: poster + play, the player loaded on a click after consent. */
|
|
44
179
|
Facade = "facade",
|
|
45
180
|
/** No address yet: the poster blank (DS3) and nothing to play. */
|
|
46
|
-
Empty = "empty"
|
|
181
|
+
Empty = "empty",
|
|
182
|
+
/** Pictures the scroll plays: the poster, and the driver's canvas over it. No player, no controls. */
|
|
183
|
+
Frames = "frames"
|
|
47
184
|
}
|
|
48
185
|
/** A recognised embed: which provider, its video id and (Vimeo, unlisted) its privacy hash. */
|
|
49
186
|
interface VideoRef {
|
|
@@ -114,6 +251,8 @@ interface VideoPlan {
|
|
|
114
251
|
embedUrl: string | null;
|
|
115
252
|
/** "YouTube" / "Vimeo" ('' for a file). */
|
|
116
253
|
providerLabel: string;
|
|
254
|
+
/** Pictures the scroll plays (Frames). */
|
|
255
|
+
frames: FrameSequence | null;
|
|
117
256
|
}
|
|
118
257
|
/**
|
|
119
258
|
* The ONE decision for a Video node and a visitor's consent (null = none recorded — the server always passes null,
|
|
@@ -181,7 +320,9 @@ declare enum VideoAttribute {
|
|
|
181
320
|
/** The player is loaded (after the click and the grant). */
|
|
182
321
|
Playing = "data-iox-video-playing",
|
|
183
322
|
/** The consent prompt is showing. */
|
|
184
|
-
Asking = "data-iox-video-asking"
|
|
323
|
+
Asking = "data-iox-video-asking",
|
|
324
|
+
/** The pictures the scroll plays, as JSON: what the driver reads, so it needs no node tree. */
|
|
325
|
+
Frames = "data-iox-video-frames"
|
|
185
326
|
}
|
|
186
327
|
declare enum VideoIcon {
|
|
187
328
|
Play = "ph-fill ph-play",
|
|
@@ -273,5 +414,5 @@ declare function attachVideos(container: ParentNode, options?: VideoRuntimeOptio
|
|
|
273
414
|
/** The runtime already running at `el`, if any. */
|
|
274
415
|
declare function videoRuntimeOf(el: Element | null | undefined): VideoRuntime | null;
|
|
275
416
|
|
|
276
|
-
export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, attachVideoAt, attachVideos, documentConsentTransport, isThirdPartyPoster, parseVideoUrl, planVideo, readConsentRecord, serializeVideoAttributes, videoConsentCategory, videoEmbedUrl, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
|
|
277
|
-
export type { ConsentGrantDetail, ConsentTransport, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
|
|
417
|
+
export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, 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, frameFileName, frameIndexAt, frameLoadOrder, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
|
|
418
|
+
export type { ConsentGrantDetail, ConsentTransport, FramePlayerOptions, FrameSequence, FrameSetInfo, FrameSizes, VideoMarkupOptions, VideoNodeLike, VideoPlan, VideoRef, VideoRuntimeOptions, VideoThumbnailRequest, VideoWords };
|
package/package.json
CHANGED
package/scene-core/index.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ declare enum SceneProp {
|
|
|
23
23
|
Clip = "clip",
|
|
24
24
|
/** How many of the layer's words are lit, in % (100 = all): the word-by-word light-up. Split in the browser only. */
|
|
25
25
|
Words = "words",
|
|
26
|
+
/** How much of a Video layer's pictures have played, in % (100 = the last picture). */
|
|
27
|
+
Play = "play",
|
|
26
28
|
/** The layer's background colour. */
|
|
27
29
|
Ground = "ground",
|
|
28
30
|
/** The layer's text colour. */
|
|
@@ -102,6 +104,8 @@ declare function sceneFrame(tracks: ReadonlyArray<SceneTrack>, p: number, option
|
|
|
102
104
|
declare const SCENE_WORD_DIM = 0.16;
|
|
103
105
|
/** How many of a layer's words are lit at `p`, in %; null when it has no such track. */
|
|
104
106
|
declare function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): number | null;
|
|
107
|
+
/** How far a layer's pictures have played at `p`, in %; null when it has no such track. */
|
|
108
|
+
declare function scenePlayed(tracks: ReadonlyArray<SceneTrack>, p: number): number | null;
|
|
105
109
|
/** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
|
|
106
110
|
declare function sceneWordOpacity(index: number, count: number, lit: number): number;
|
|
107
111
|
/** A frame as kebab-case style declarations, for a string renderer. */
|
|
@@ -161,6 +165,13 @@ declare function sceneRevealFromOf(tracks: ReadonlyArray<SceneTrack>): SceneReve
|
|
|
161
165
|
* direction is kept and nothing on screen changes until the author sets a second key.
|
|
162
166
|
*/
|
|
163
167
|
declare function withSceneRevealFrom(tracks: ReadonlyArray<SceneTrack>, from: SceneRevealFrom, p: number): SceneTrack[];
|
|
168
|
+
/**
|
|
169
|
+
* A layer's pictures playing across the whole Scene: the start of playing them. Keys the author has set are kept, so
|
|
170
|
+
* replacing the pictures keeps the timing.
|
|
171
|
+
*/
|
|
172
|
+
declare function withScenePlayback(tracks: ReadonlyArray<SceneTrack>): SceneTrack[];
|
|
173
|
+
/** Stop playing a layer's pictures. */
|
|
174
|
+
declare function withoutScenePlayback(tracks: ReadonlyArray<SceneTrack>): SceneTrack[];
|
|
164
175
|
/**
|
|
165
176
|
* Set a key at `p`, in the author's units. A number is clamped to its bounds; a colour that is no colour changes nothing,
|
|
166
177
|
* because `normalizeTracks` would drop it anyway and the author's other keys must survive.
|
|
@@ -319,6 +330,7 @@ declare class ScrollDriver {
|
|
|
319
330
|
/** Read a root's stage and layers from its DOM: its own Scene's only, never a nested one's. */
|
|
320
331
|
private readScene;
|
|
321
332
|
private letGo;
|
|
333
|
+
private stopPlayer;
|
|
322
334
|
private restoreWords;
|
|
323
335
|
private readLayer;
|
|
324
336
|
private remeasureAll;
|
|
@@ -326,6 +338,11 @@ declare class ScrollDriver {
|
|
|
326
338
|
private readonly tick;
|
|
327
339
|
private read;
|
|
328
340
|
private write;
|
|
341
|
+
/**
|
|
342
|
+
* The picture this moment asks for. The attribute sits on the Video's own root, which is the layer on the string path
|
|
343
|
+
* and inside it on the component path. A layer with no pictures, or no Play track, holds its poster.
|
|
344
|
+
*/
|
|
345
|
+
private writePlayed;
|
|
329
346
|
/** One opacity per word, and a write only for the few the scroll actually changed. */
|
|
330
347
|
private writeWords;
|
|
331
348
|
}
|
|
@@ -382,5 +399,5 @@ declare function revealStagger(groups: number, by: TextRevealBy): number;
|
|
|
382
399
|
*/
|
|
383
400
|
declare function playTextReveal(el: HTMLElement, by: TextRevealBy, timing: KeyframeAnimationOptions): Animation | null;
|
|
384
401
|
|
|
385
|
-
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, sceneRevealFromOf, sceneRootOnPath, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, sceneWordOpacity, sceneWordsLit, scenesIn, splitText, syncSceneAttributes, textPieces, valueAt, withSceneKey, withSceneRevealFrom, withoutSceneKey };
|
|
402
|
+
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 };
|
|
386
403
|
export type { SceneBudget, SceneFrame, SceneFrameOptions, SceneFrameState, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneTrackDef, SceneTreeNode, SceneWords, ScrollDriverOptions, SplitText, SplitTextOptions, TextPiece };
|
|
@@ -4912,6 +4912,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
4912
4912
|
"videoSource": "youtube",
|
|
4913
4913
|
"src": "",
|
|
4914
4914
|
"poster": "",
|
|
4915
|
+
"frames": null,
|
|
4915
4916
|
"mode": "inline",
|
|
4916
4917
|
"autoplay": false,
|
|
4917
4918
|
"title": "",
|
|
@@ -4976,6 +4977,7 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
4976
4977
|
"videoSource",
|
|
4977
4978
|
"src",
|
|
4978
4979
|
"poster",
|
|
4980
|
+
"frames",
|
|
4979
4981
|
"__behaviourLabel",
|
|
4980
4982
|
"mode",
|
|
4981
4983
|
"autoplay",
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A video played by the scroll, as pictures (premium sites SC4). Every source, a video file or a folder of rendered frames,
|
|
3
|
+
* becomes the same numbered set of pictures, prepared once in the author's browser, one set for wide screens and a lighter
|
|
4
|
+
* one for phones. So there is one player and it is the same on every browser: a `<video>` seeked by the scroll needs a
|
|
5
|
+
* dense-keyframe encode no pod here can run, and on an iPhone it accepts seeks it never answers.
|
|
6
|
+
*
|
|
7
|
+
* The saved value is a small description, never a list of addresses: a page holding 300 URLs would ship them in its HTML.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export enum FrameSet {
|
|
11
|
+
Wide = 'w',
|
|
12
|
+
Phone = 'p',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FrameSetInfo {
|
|
16
|
+
count: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FrameSequence {
|
|
22
|
+
/** Where the numbered pictures live: `<base>/<set>/0001.<ext>`. */
|
|
23
|
+
base: string;
|
|
24
|
+
ext: FrameExtension;
|
|
25
|
+
wide: FrameSetInfo;
|
|
26
|
+
/** Absent = phones play the wide set. */
|
|
27
|
+
phone?: FrameSetInfo;
|
|
28
|
+
/** What the sequence weighs in storage, both sets. Shown to the author. */
|
|
29
|
+
bytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export enum FrameExtension {
|
|
33
|
+
Webp = 'webp',
|
|
34
|
+
Jpeg = 'jpg',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The most pictures one set may hold. Past this a phone runs out of memory before the visitor runs out of scroll. */
|
|
38
|
+
export const MAX_FRAMES = 300;
|
|
39
|
+
/** The most one picture may weigh when uploaded. */
|
|
40
|
+
export const MAX_FRAME_BYTES = 3 * 1024 * 1024;
|
|
41
|
+
/** A sequence's name in storage: what the browser generates and what the server accepts in a key. */
|
|
42
|
+
export const FRAME_SEQUENCE_ID = /^[a-z0-9]{8,40}$/;
|
|
43
|
+
|
|
44
|
+
const SAFE_BASE = /^https:\/\/[^\s"'<>\\]+$/;
|
|
45
|
+
const PAD = 4;
|
|
46
|
+
const ALL_EXTENSIONS: ReadonlySet<string> = new Set(Object.values(FrameExtension));
|
|
47
|
+
|
|
48
|
+
function setInfo(raw: unknown): FrameSetInfo | null {
|
|
49
|
+
const { count, width, height } = (raw ?? {}) as Partial<FrameSetInfo>;
|
|
50
|
+
const valid = (v: unknown, max: number): v is number => Number.isInteger(v) && (v as number) > 0 && (v as number) <= max;
|
|
51
|
+
return valid(count, MAX_FRAMES) && valid(width, 8192) && valid(height, 8192) ? { count, width, height } : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The sequence a saved value really describes, or null. Never throws on what a page document holds. */
|
|
55
|
+
export function normalizeFrameSequence(raw: unknown): FrameSequence | null {
|
|
56
|
+
const value = (typeof raw === 'string' ? parseJson(raw) : raw) as Partial<FrameSequence> | null;
|
|
57
|
+
if (!value || typeof value !== 'object') return null;
|
|
58
|
+
const wide = setInfo(value.wide);
|
|
59
|
+
if (!wide || typeof value.base !== 'string' || !SAFE_BASE.test(value.base) || !ALL_EXTENSIONS.has(String(value.ext))) return null;
|
|
60
|
+
const phone = setInfo(value.phone);
|
|
61
|
+
return {
|
|
62
|
+
base: value.base.replace(/\/+$/, ''),
|
|
63
|
+
ext: value.ext as FrameExtension,
|
|
64
|
+
wide,
|
|
65
|
+
...(phone ? { phone } : {}),
|
|
66
|
+
...(Number.isFinite(value.bytes) && (value.bytes as number) > 0 ? { bytes: value.bytes } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseJson(raw: string): unknown {
|
|
71
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The name a picture is stored under: its place in the set, from 1. */
|
|
75
|
+
export function frameFileName(index: number, ext: FrameExtension): string {
|
|
76
|
+
return `${String(index + 1).padStart(PAD, '0')}.${ext}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Picture `index` (from 0) of a set. */
|
|
80
|
+
export function frameUrl(sequence: FrameSequence, set: FrameSet, index: number): string {
|
|
81
|
+
return `${sequence.base}/${set}/${frameFileName(index, sequence.ext)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The first picture of each set, for a poster's `srcset`; '' when there is only one set. */
|
|
85
|
+
export function phoneFrameSrcset(sequence: FrameSequence): string {
|
|
86
|
+
if (!sequence.phone) return '';
|
|
87
|
+
return `${frameUrl(sequence, FrameSet.Phone, 0)} ${sequence.phone.width}w, ${frameUrl(sequence, FrameSet.Wide, 0)} ${sequence.wide.width}w`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Which set plays: the phone's on a phone, when there is one. */
|
|
91
|
+
export function frameSetFor(sequence: FrameSequence, phone: boolean): { set: FrameSet; info: FrameSetInfo } {
|
|
92
|
+
return phone && sequence.phone ? { set: FrameSet.Phone, info: sequence.phone } : { set: FrameSet.Wide, info: sequence.wide };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The picture at `played` % of a set of `count`. */
|
|
96
|
+
export function frameIndexAt(played: number, count: number): number {
|
|
97
|
+
if (count <= 1) return 0;
|
|
98
|
+
return Math.round((Math.min(100, Math.max(0, played)) / 100) * (count - 1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The order to fetch a set in: the two ends, then every 16th, every 8th and so on down to all of them. Any scroll position
|
|
103
|
+
* then has a near picture almost at once, and the movement sharpens as the rest arrive. Fetching in sequence would leave a
|
|
104
|
+
* visitor who scrolls fast looking at the first frames.
|
|
105
|
+
*/
|
|
106
|
+
export function frameLoadOrder(count: number): number[] {
|
|
107
|
+
const order: number[] = [];
|
|
108
|
+
const seen = new Set<number>();
|
|
109
|
+
const add = (i: number): void => {
|
|
110
|
+
if (i >= 0 && i < count && !seen.has(i)) {
|
|
111
|
+
seen.add(i);
|
|
112
|
+
order.push(i);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
add(0);
|
|
116
|
+
add(count - 1);
|
|
117
|
+
for (let stride = 16; stride >= 1; stride /= 2) {
|
|
118
|
+
for (let i = 0; i < count; i += stride) add(i);
|
|
119
|
+
}
|
|
120
|
+
return order;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The loaded picture nearest `index`, or -1 when none has arrived. Ties go backward: the picture already passed. */
|
|
124
|
+
export function nearestLoadedFrame(loaded: ReadonlyArray<unknown>, index: number): number {
|
|
125
|
+
for (let distance = 0; distance < loaded.length; distance++) {
|
|
126
|
+
if (loaded[index - distance]) return index - distance;
|
|
127
|
+
if (loaded[index + distance]) return index + distance;
|
|
128
|
+
}
|
|
129
|
+
return -1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface FrameSizes {
|
|
133
|
+
width: number;
|
|
134
|
+
height: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The longest side of a wide picture and of a phone picture. A phone's is not its width: a landscape video covers a
|
|
138
|
+
* tall stage by its height, so a picture sized to the phone's width would be enlarged five times. */
|
|
139
|
+
export const WIDE_LONG_SIDE = 1600;
|
|
140
|
+
export const PHONE_LONG_SIDE = 1100;
|
|
141
|
+
/** How many pictures a second of video becomes, before the cap. */
|
|
142
|
+
const VIDEO_FRAMES_PER_SECOND = 24;
|
|
143
|
+
/** A video's cap: the pictures a scroll can show, at a weight a page can carry. A set of pictures is the author's own count. */
|
|
144
|
+
const VIDEO_MAX_FRAMES = 180;
|
|
145
|
+
|
|
146
|
+
/** `width`×`height` fitted inside `longSide`, never enlarged. */
|
|
147
|
+
export function fittedFrameSize(width: number, height: number, longSide: number): FrameSizes {
|
|
148
|
+
const scale = Math.min(1, longSide / Math.max(width, height));
|
|
149
|
+
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** How many pictures a video of `seconds` becomes. */
|
|
153
|
+
export function videoFrameCount(seconds: number): number {
|
|
154
|
+
return Math.min(VIDEO_MAX_FRAMES, Math.max(2, Math.ceil(seconds * VIDEO_FRAMES_PER_SECOND)));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Which of `sourceCount` pictures make a set of `count`, spread evenly, first and last always in. */
|
|
158
|
+
export function sampledFrameIndexes(sourceCount: number, count: number): number[] {
|
|
159
|
+
if (count >= sourceCount) return Array.from({ length: sourceCount }, (_, i) => i);
|
|
160
|
+
if (count <= 1) return [0];
|
|
161
|
+
return Array.from({ length: count }, (_, i) => Math.round((i * (sourceCount - 1)) / (count - 1)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A phone plays half the pictures: it has less memory, and half is plenty on a small screen. */
|
|
165
|
+
export function phoneFrameCount(wideCount: number): number {
|
|
166
|
+
return Math.max(2, Math.ceil(wideCount / 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const NATURAL = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
|
|
170
|
+
|
|
171
|
+
/** Rendered frames in their real order: `frame2` before `frame10`. */
|
|
172
|
+
export function sortFrameFiles<T extends { name: string }>(files: ReadonlyArray<T>): T[] {
|
|
173
|
+
return [...files].sort((a, b) => NATURAL.compare(a.name, b.name));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A sequence's weight, for the author. */
|
|
177
|
+
export function frameSummary(sequence: FrameSequence): { frames: number; megabytes: number | null } {
|
|
178
|
+
return { frames: sequence.wide.count, megabytes: sequence.bytes ? Math.round((sequence.bytes / (1024 * 1024)) * 10) / 10 : null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export enum FramesClass {
|
|
182
|
+
Canvas = 'iox-video__frames',
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const LOAD_CONCURRENCY = 6;
|
|
186
|
+
const NEAR_MARGIN = '100% 0px';
|
|
187
|
+
/** Under these the visitor keeps the poster: nobody on a metered 2G connection asked to download a film. */
|
|
188
|
+
const SLOW_CONNECTIONS: ReadonlySet<string> = new Set(['slow-2g', '2g']);
|
|
189
|
+
|
|
190
|
+
interface NetworkInformationLike {
|
|
191
|
+
saveData?: boolean;
|
|
192
|
+
effectiveType?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface FramePlayerOptions {
|
|
196
|
+
/** Play the phone's set. */
|
|
197
|
+
phone?: boolean;
|
|
198
|
+
/** Start fetching when this comes near the screen. Not the layer itself: a layer clipped shut intersects nothing. */
|
|
199
|
+
near?: Element;
|
|
200
|
+
/** The window, for a test. */
|
|
201
|
+
host?: Window;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
|
|
206
|
+
* first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
|
|
207
|
+
* Browser only. It writes and never reads, so the driver can call it in its write phase.
|
|
208
|
+
*/
|
|
209
|
+
export class FramePlayer {
|
|
210
|
+
private readonly set: FrameSet;
|
|
211
|
+
private readonly info: FrameSetInfo;
|
|
212
|
+
private readonly images: Array<HTMLImageElement | null>;
|
|
213
|
+
private readonly order: number[];
|
|
214
|
+
private readonly host: Window;
|
|
215
|
+
private observer: IntersectionObserver | null = null;
|
|
216
|
+
private canvas: HTMLCanvasElement | null = null;
|
|
217
|
+
private context: CanvasRenderingContext2D | null = null;
|
|
218
|
+
private wanted = 0;
|
|
219
|
+
private drawn = -1;
|
|
220
|
+
private nextToLoad = 0;
|
|
221
|
+
private loading = 0;
|
|
222
|
+
private started = false;
|
|
223
|
+
private destroyed = false;
|
|
224
|
+
private readonly pending = new Set<HTMLImageElement>();
|
|
225
|
+
|
|
226
|
+
constructor(private readonly el: HTMLElement, private readonly sequence: FrameSequence, options: FramePlayerOptions = {}) {
|
|
227
|
+
this.host = options.host ?? window;
|
|
228
|
+
const { set, info } = frameSetFor(sequence, !!options.phone);
|
|
229
|
+
this.set = set;
|
|
230
|
+
this.info = info;
|
|
231
|
+
this.images = new Array(info.count).fill(null);
|
|
232
|
+
this.order = frameLoadOrder(info.count);
|
|
233
|
+
if (!this.allowed()) return;
|
|
234
|
+
const Observer = (this.host as Window & { IntersectionObserver?: typeof IntersectionObserver }).IntersectionObserver;
|
|
235
|
+
if (!Observer) {
|
|
236
|
+
this.start();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.observer = new Observer(entries => {
|
|
240
|
+
if (entries.some(entry => entry.isIntersecting)) this.start();
|
|
241
|
+
}, { rootMargin: NEAR_MARGIN });
|
|
242
|
+
this.observer.observe(options.near ?? el);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Stand at `played` %. */
|
|
246
|
+
seek(played: number): void {
|
|
247
|
+
this.wanted = frameIndexAt(played, this.info.count);
|
|
248
|
+
this.draw();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Whether a re-render took the canvas away with the markup it sat in. */
|
|
252
|
+
get detached(): boolean {
|
|
253
|
+
return !!this.canvas && !this.canvas.isConnected;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
destroy(): void {
|
|
257
|
+
this.destroyed = true;
|
|
258
|
+
this.observer?.disconnect();
|
|
259
|
+
this.observer = null;
|
|
260
|
+
for (const image of this.pending) {
|
|
261
|
+
image.onload = null;
|
|
262
|
+
image.onerror = null;
|
|
263
|
+
image.src = '';
|
|
264
|
+
}
|
|
265
|
+
this.pending.clear();
|
|
266
|
+
this.canvas?.remove();
|
|
267
|
+
this.canvas = null;
|
|
268
|
+
this.context = null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private allowed(): boolean {
|
|
272
|
+
const connection = (this.host.navigator as Navigator & { connection?: NetworkInformationLike } | undefined)?.connection;
|
|
273
|
+
return !connection?.saveData && !SLOW_CONNECTIONS.has(connection?.effectiveType ?? '');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private start(): void {
|
|
277
|
+
if (this.started) return;
|
|
278
|
+
this.started = true;
|
|
279
|
+
this.observer?.disconnect();
|
|
280
|
+
this.observer = null;
|
|
281
|
+
this.pump();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private pump(): void {
|
|
285
|
+
while (!this.destroyed && this.loading < LOAD_CONCURRENCY && this.nextToLoad < this.order.length) {
|
|
286
|
+
this.load(this.order[this.nextToLoad++]);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private load(index: number): void {
|
|
291
|
+
const image = this.host.document.createElement('img');
|
|
292
|
+
image.decoding = 'async';
|
|
293
|
+
this.loading++;
|
|
294
|
+
this.pending.add(image);
|
|
295
|
+
const done = (arrived: boolean): void => {
|
|
296
|
+
this.pending.delete(image);
|
|
297
|
+
this.loading--;
|
|
298
|
+
if (this.destroyed) return;
|
|
299
|
+
if (arrived) {
|
|
300
|
+
this.images[index] = image;
|
|
301
|
+
this.draw();
|
|
302
|
+
}
|
|
303
|
+
this.pump();
|
|
304
|
+
};
|
|
305
|
+
// Decoded before it counts, or the first draw of each picture decodes on the driver's frame and the scroll stutters.
|
|
306
|
+
image.onload = () => {
|
|
307
|
+
const decoded = image.decode?.();
|
|
308
|
+
if (decoded) void decoded.then(() => done(true), () => done(true));
|
|
309
|
+
else done(true);
|
|
310
|
+
};
|
|
311
|
+
// A picture that failed is a gap: its neighbours stand in.
|
|
312
|
+
image.onerror = () => done(false);
|
|
313
|
+
image.src = frameUrl(this.sequence, this.set, index);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private draw(): void {
|
|
317
|
+
const index = nearestLoadedFrame(this.images, this.wanted);
|
|
318
|
+
if (index < 0 || index === this.drawn) return;
|
|
319
|
+
const image = this.images[index]!;
|
|
320
|
+
if (!this.ensureCanvas(image)) return;
|
|
321
|
+
this.context!.clearRect(0, 0, this.canvas!.width, this.canvas!.height);
|
|
322
|
+
this.context!.drawImage(image, 0, 0, this.canvas!.width, this.canvas!.height);
|
|
323
|
+
this.drawn = index;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private ensureCanvas(image: HTMLImageElement): boolean {
|
|
327
|
+
if (this.canvas) return !!this.context;
|
|
328
|
+
const canvas = this.host.document.createElement('canvas');
|
|
329
|
+
canvas.className = FramesClass.Canvas;
|
|
330
|
+
canvas.setAttribute('aria-hidden', 'true');
|
|
331
|
+
canvas.width = image.naturalWidth || this.info.width;
|
|
332
|
+
canvas.height = image.naturalHeight || this.info.height;
|
|
333
|
+
this.context = canvas.getContext('2d');
|
|
334
|
+
if (!this.context) return false;
|
|
335
|
+
this.canvas = canvas;
|
|
336
|
+
this.el.appendChild(canvas);
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
* fetch and store an embed's poster (the visitor never asks the provider for it).
|
|
6
6
|
* ⚠️ Consumers wire the usual trio — ng-package raw-ts assets, jest moduleNameMapper, tsconfig paths.
|
|
7
7
|
*/
|
|
8
|
+
export * from './frames';
|
|
8
9
|
export * from './video';
|
|
9
10
|
export * from './video-runtime';
|