@vectoriox/iox-ui 5.72.1 → 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 +6 -1
- 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/src/media-core/video.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ConsentCategory, ConsentDecision, hasConsent } from '@vectoriox/iox-ui/consent-core';
|
|
2
2
|
|
|
3
|
+
import { FrameSequence, FrameSet, frameUrl, normalizeFrameSequence, phoneFrameSrcset } from './frames';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* VIDEO (CC3 — complex-components plan §7b, board 5). A LEAF type with two sources:
|
|
5
7
|
* - an uploaded FILE renders as a real `<video>` (poster, `preload="metadata"`, captions, controls);
|
|
@@ -39,6 +41,8 @@ export enum VideoTrait {
|
|
|
39
41
|
Captions = 'captions',
|
|
40
42
|
CaptionsLang = 'captionsLang',
|
|
41
43
|
Consent = 'consentCategory',
|
|
44
|
+
/** Pictures the scroll plays (SC4): a `FrameSequence`. Written by the Scene panel, never typed. */
|
|
45
|
+
Frames = 'frames',
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** What a plan renders. */
|
|
@@ -49,6 +53,8 @@ export enum VideoKind {
|
|
|
49
53
|
Facade = 'facade',
|
|
50
54
|
/** No address yet: the poster blank (DS3) and nothing to play. */
|
|
51
55
|
Empty = 'empty',
|
|
56
|
+
/** Pictures the scroll plays: the poster, and the driver's canvas over it. No player, no controls. */
|
|
57
|
+
Frames = 'frames',
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/** A recognised embed: which provider, its video id and (Vimeo, unlisted) its privacy hash. */
|
|
@@ -210,6 +216,8 @@ export interface VideoPlan {
|
|
|
210
216
|
embedUrl: string | null;
|
|
211
217
|
/** "YouTube" / "Vimeo" ('' for a file). */
|
|
212
218
|
providerLabel: string;
|
|
219
|
+
/** Pictures the scroll plays (Frames). */
|
|
220
|
+
frames: FrameSequence | null;
|
|
213
221
|
}
|
|
214
222
|
|
|
215
223
|
/**
|
|
@@ -218,6 +226,8 @@ export interface VideoPlan {
|
|
|
218
226
|
* studio's own image (or the thumbnail the server fetched and stored for it), never a request to a third party.
|
|
219
227
|
*/
|
|
220
228
|
export function planVideo(node: VideoNodeLike | null | undefined, consent: ConsentDecision | null): VideoPlan {
|
|
229
|
+
const frames = normalizeFrameSequence(videoProp(node, VideoTrait.Frames));
|
|
230
|
+
if (frames) return planFrames(node, frames);
|
|
221
231
|
const rawSrc = str(videoProp(node, VideoTrait.Src));
|
|
222
232
|
const source = videoSourceOf(rawSrc, videoProp(node, VideoTrait.Source));
|
|
223
233
|
const ref = source === VideoSource.File ? null : parseVideoUrl(rawSrc);
|
|
@@ -245,6 +255,33 @@ export function planVideo(node: VideoNodeLike | null | undefined, consent: Conse
|
|
|
245
255
|
granted,
|
|
246
256
|
embedUrl: kind === VideoKind.Facade && granted && ref ? videoEmbedUrl(ref) : null,
|
|
247
257
|
providerLabel: ref ? VIDEO_PROVIDER_LABEL[ref.source] : '',
|
|
258
|
+
frames: null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Pictures win over any address the node also holds: it was turned into them. No player, no consent, nothing to press. */
|
|
263
|
+
function planFrames(node: VideoNodeLike | null | undefined, frames: FrameSequence): VideoPlan {
|
|
264
|
+
const poster = str(videoProp(node, VideoTrait.Poster));
|
|
265
|
+
return {
|
|
266
|
+
kind: VideoKind.Frames,
|
|
267
|
+
source: VideoSource.File,
|
|
268
|
+
ref: null,
|
|
269
|
+
src: '',
|
|
270
|
+
poster: poster && !isThirdPartyPoster(poster) ? poster : frameUrl(frames, FrameSet.Wide, 0),
|
|
271
|
+
title: str(videoProp(node, VideoTrait.Title)),
|
|
272
|
+
mode: VideoMode.Inline,
|
|
273
|
+
background: false,
|
|
274
|
+
autoplay: false,
|
|
275
|
+
muted: false,
|
|
276
|
+
loop: false,
|
|
277
|
+
controls: false,
|
|
278
|
+
captions: '',
|
|
279
|
+
captionsLang: 'en',
|
|
280
|
+
consentCategory: null,
|
|
281
|
+
granted: true,
|
|
282
|
+
embedUrl: null,
|
|
283
|
+
providerLabel: '',
|
|
284
|
+
frames,
|
|
248
285
|
};
|
|
249
286
|
}
|
|
250
287
|
|
|
@@ -357,6 +394,8 @@ export enum VideoAttribute {
|
|
|
357
394
|
Playing = 'data-iox-video-playing',
|
|
358
395
|
/** The consent prompt is showing. */
|
|
359
396
|
Asking = 'data-iox-video-asking',
|
|
397
|
+
/** The pictures the scroll plays, as JSON: what the driver reads, so it needs no node tree. */
|
|
398
|
+
Frames = 'data-iox-video-frames',
|
|
360
399
|
}
|
|
361
400
|
|
|
362
401
|
export enum VideoIcon {
|
|
@@ -376,6 +415,7 @@ export function videoRootAttributes(plan: VideoPlan): Record<string, string> {
|
|
|
376
415
|
if (plan.consentCategory) attrs[VideoAttribute.Consent] = plan.consentCategory;
|
|
377
416
|
if (plan.title) attrs[VideoAttribute.Title] = plan.title;
|
|
378
417
|
if (!plan.poster) attrs[VideoAttribute.PosterEmpty] = '';
|
|
418
|
+
if (plan.frames) attrs[VideoAttribute.Frames] = JSON.stringify(plan.frames);
|
|
379
419
|
return attrs;
|
|
380
420
|
}
|
|
381
421
|
|
|
@@ -394,6 +434,12 @@ const iconHtml = (cls: string) => `<i class="${cls}" aria-hidden="true"></i>`;
|
|
|
394
434
|
export function videoInnerHtml(plan: VideoPlan, options: VideoMarkupOptions): string {
|
|
395
435
|
const { esc } = options;
|
|
396
436
|
const words = options.words ?? VIDEO_WORDS.en;
|
|
437
|
+
// The same on the canvas and on the site: the first picture is real content, so it is fetched at once, named, and the
|
|
438
|
+
// size a phone needs. A driver's canvas covers it when pictures arrive; with no script it is what there is.
|
|
439
|
+
if (plan.kind === VideoKind.Frames) {
|
|
440
|
+
const srcset = plan.frames && !isAuthoredPoster(plan) ? phoneFrameSrcset(plan.frames) : '';
|
|
441
|
+
return `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''} alt="${esc(plan.title)}" decoding="async">`;
|
|
442
|
+
}
|
|
397
443
|
const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
|
|
398
444
|
const label = esc(videoPlayLabel(plan, words));
|
|
399
445
|
const playSpan = `<span class="${VideoClass.Play}" aria-hidden="true">${iconHtml(VideoIcon.Play)}</span>`;
|
|
@@ -437,6 +483,8 @@ export function videoInnerHtml(plan: VideoPlan, options: VideoMarkupOptions): st
|
|
|
437
483
|
return `${poster}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>${badge}${prompt}`;
|
|
438
484
|
}
|
|
439
485
|
|
|
486
|
+
const isAuthoredPoster = (plan: VideoPlan): boolean => !!plan.frames && plan.poster !== frameUrl(plan.frames, FrameSet.Wide, 0);
|
|
487
|
+
|
|
440
488
|
/** Serialise attributes for a string renderer (a present boolean attribute is written bare). */
|
|
441
489
|
export function serializeVideoAttributes(attrs: Readonly<Record<string, string>>, esc: (value: string) => string): string {
|
|
442
490
|
return Object.entries(attrs).map(([name, value]) => (value === '' ? ` ${name}` : ` ${name}="${esc(value)}"`)).join('');
|
|
@@ -45,6 +45,7 @@ export const SCENE_TRACK_DEFS: ReadonlyArray<SceneTrackDef> = [
|
|
|
45
45
|
{ prop: SceneProp.Opacity, colour: false, rest: 1, min: 0, max: 100, step: 1, factor: 100, unit: PERCENT },
|
|
46
46
|
{ prop: SceneProp.Clip, colour: false, rest: 100, min: 0, max: 100, step: 1, factor: 1, unit: PERCENT },
|
|
47
47
|
{ prop: SceneProp.Words, colour: false, rest: 100, min: 0, max: 100, step: 1, factor: 1, unit: PERCENT },
|
|
48
|
+
{ prop: SceneProp.Play, colour: false, rest: 0, min: 0, max: 100, step: 1, factor: 1, unit: PERCENT },
|
|
48
49
|
{ prop: SceneProp.Ground, colour: true, rest: 0, min: 0, max: 0, step: 0, factor: 1, unit: '' },
|
|
49
50
|
{ prop: SceneProp.Ink, colour: true, rest: 0, min: 0, max: 0, step: 0, factor: 1, unit: '' },
|
|
50
51
|
];
|
|
@@ -111,6 +112,20 @@ export function withSceneRevealFrom(tracks: ReadonlyArray<SceneTrack>, from: Sce
|
|
|
111
112
|
return tracks.map(track => (track.prop === SceneProp.Clip ? { ...track, from } : track));
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A layer's pictures playing across the whole Scene: the start of playing them. Keys the author has set are kept, so
|
|
117
|
+
* replacing the pictures keeps the timing.
|
|
118
|
+
*/
|
|
119
|
+
export function withScenePlayback(tracks: ReadonlyArray<SceneTrack>): SceneTrack[] {
|
|
120
|
+
if (trackOf(tracks, SceneProp.Play)) return [...tracks];
|
|
121
|
+
return [...tracks, { prop: SceneProp.Play, keys: [[0, 0], [1, 100]] }];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stop playing a layer's pictures. */
|
|
125
|
+
export function withoutScenePlayback(tracks: ReadonlyArray<SceneTrack>): SceneTrack[] {
|
|
126
|
+
return tracks.filter(track => track.prop !== SceneProp.Play);
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
/**
|
|
115
130
|
* 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,
|
|
116
131
|
* because `normalizeTracks` would drop it anyway and the author's other keys must survive.
|
package/src/scene-core/scene.ts
CHANGED
|
@@ -24,6 +24,8 @@ export enum SceneProp {
|
|
|
24
24
|
Clip = 'clip',
|
|
25
25
|
/** How many of the layer's words are lit, in % (100 = all): the word-by-word light-up. Split in the browser only. */
|
|
26
26
|
Words = 'words',
|
|
27
|
+
/** How much of a Video layer's pictures have played, in % (100 = the last picture). */
|
|
28
|
+
Play = 'play',
|
|
27
29
|
/** The layer's background colour. */
|
|
28
30
|
Ground = 'ground',
|
|
29
31
|
/** The layer's text colour. */
|
|
@@ -226,6 +228,12 @@ export function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): num
|
|
|
226
228
|
return track ? clampPercent(valueAt(track.keys, p)) : null;
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
/** How far a layer's pictures have played at `p`, in %; null when it has no such track. */
|
|
232
|
+
export function scenePlayed(tracks: ReadonlyArray<SceneTrack>, p: number): number | null {
|
|
233
|
+
const track = tracks.find(t => t.prop === SceneProp.Play);
|
|
234
|
+
return track ? clampPercent(valueAt(track.keys, p)) : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
229
237
|
/** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
|
|
230
238
|
export function sceneWordOpacity(index: number, count: number, lit: number): number {
|
|
231
239
|
return round(SCENE_WORD_DIM + (1 - SCENE_WORD_DIM) * clamp01((lit / PERCENT) * count - index));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SceneRole } from '@vectoriox/iox-ui/composite-core';
|
|
2
|
+
import { FramePlayer, normalizeFrameSequence, VideoAttribute } from '@vectoriox/iox-ui/media-core';
|
|
2
3
|
|
|
3
|
-
import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
|
|
4
|
+
import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
|
|
4
5
|
import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -70,6 +71,9 @@ interface Layer {
|
|
|
70
71
|
words: SplitText | null;
|
|
71
72
|
/** Too many words to split: it holds as it is, and is not counted again on every frame. */
|
|
72
73
|
wordsRefused: boolean;
|
|
74
|
+
/** Its pictures, while a Play track plays them, and what they were built from: a change starts a new player. */
|
|
75
|
+
player: FramePlayer | null;
|
|
76
|
+
playerKey: string;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
interface Scene {
|
|
@@ -120,7 +124,7 @@ export class ScrollDriver {
|
|
|
120
124
|
const Observer = (this.host as Window & { MutationObserver?: typeof MutationObserver }).MutationObserver;
|
|
121
125
|
if (Observer) {
|
|
122
126
|
scene.mutations = new Observer(() => this.readScene(scene));
|
|
123
|
-
scene.mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: [SceneAttribute.Tracks, SceneAttribute.PhoneTracks] });
|
|
127
|
+
scene.mutations.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: [SceneAttribute.Tracks, SceneAttribute.PhoneTracks, VideoAttribute.Frames] });
|
|
124
128
|
}
|
|
125
129
|
}
|
|
126
130
|
this.readScene(scene);
|
|
@@ -135,6 +139,7 @@ export class ScrollDriver {
|
|
|
135
139
|
for (const layer of scene.layers) {
|
|
136
140
|
layer.el.style.removeProperty('will-change');
|
|
137
141
|
this.restoreWords(layer);
|
|
142
|
+
this.stopPlayer(layer);
|
|
138
143
|
}
|
|
139
144
|
this.scenes.delete(root);
|
|
140
145
|
}
|
|
@@ -216,6 +221,13 @@ export class ScrollDriver {
|
|
|
216
221
|
layer.el.style.removeProperty('will-change');
|
|
217
222
|
for (const key of Object.keys(layer.last) as (keyof SceneFrame)[]) layer.el.style[key] = '';
|
|
218
223
|
this.restoreWords(layer);
|
|
224
|
+
this.stopPlayer(layer);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private stopPlayer(layer: Layer): void {
|
|
228
|
+
layer.player?.destroy();
|
|
229
|
+
layer.player = null;
|
|
230
|
+
layer.playerKey = '';
|
|
219
231
|
}
|
|
220
232
|
|
|
221
233
|
private restoreWords(layer: Layer): void {
|
|
@@ -226,7 +238,7 @@ export class ScrollDriver {
|
|
|
226
238
|
private readLayer(el: HTMLElement, scene: Scene): Layer {
|
|
227
239
|
const previous = scene.layers.find(layer => layer.el === el);
|
|
228
240
|
el.style.setProperty('will-change', TRANSFORM_WILL_CHANGE);
|
|
229
|
-
return { el, tracks: parseTracks(el.getAttribute(SceneAttribute.Tracks)), phoneTracks: parseTracks(el.getAttribute(SceneAttribute.PhoneTracks)), last: previous?.last ?? {}, stale: true, words: previous?.words ?? null, wordsRefused: false };
|
|
241
|
+
return { el, tracks: parseTracks(el.getAttribute(SceneAttribute.Tracks)), phoneTracks: parseTracks(el.getAttribute(SceneAttribute.PhoneTracks)), last: previous?.last ?? {}, stale: true, words: previous?.words ?? null, wordsRefused: false, player: previous?.player ?? null, playerKey: previous?.playerKey ?? '' };
|
|
230
242
|
}
|
|
231
243
|
|
|
232
244
|
private remeasureAll(): void {
|
|
@@ -275,6 +287,7 @@ export class ScrollDriver {
|
|
|
275
287
|
for (const layer of scene.layers) {
|
|
276
288
|
const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
|
|
277
289
|
this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
|
|
290
|
+
this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress));
|
|
278
291
|
const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
|
|
279
292
|
for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
|
|
280
293
|
if (layer.stale || frame[key] !== layer.last[key]) layer.el.style[key] = frame[key] ?? '';
|
|
@@ -288,6 +301,27 @@ export class ScrollDriver {
|
|
|
288
301
|
}
|
|
289
302
|
}
|
|
290
303
|
|
|
304
|
+
/**
|
|
305
|
+
* The picture this moment asks for. The attribute sits on the Video's own root, which is the layer on the string path
|
|
306
|
+
* and inside it on the component path. A layer with no pictures, or no Play track, holds its poster.
|
|
307
|
+
*/
|
|
308
|
+
private writePlayed(layer: Layer, scene: Scene, played: number | null): void {
|
|
309
|
+
const target = played === null ? null : (layer.el.hasAttribute(VideoAttribute.Frames) ? layer.el : layer.el.querySelector<HTMLElement>(`[${VideoAttribute.Frames}]`));
|
|
310
|
+
const raw = target?.getAttribute(VideoAttribute.Frames) ?? '';
|
|
311
|
+
const sequence = raw ? normalizeFrameSequence(raw) : null;
|
|
312
|
+
if (!target || !sequence) {
|
|
313
|
+
if (layer.player) this.stopPlayer(layer);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const key = `${scene.phone}|${raw}`;
|
|
317
|
+
if (layer.player && (layer.playerKey !== key || layer.player.detached)) this.stopPlayer(layer);
|
|
318
|
+
if (!layer.player) {
|
|
319
|
+
layer.player = new FramePlayer(target, sequence, { phone: scene.phone, near: scene.root, host: this.host });
|
|
320
|
+
layer.playerKey = key;
|
|
321
|
+
}
|
|
322
|
+
layer.player.seek(played!);
|
|
323
|
+
}
|
|
324
|
+
|
|
291
325
|
/** One opacity per word, and a write only for the few the scroll actually changed. */
|
|
292
326
|
private writeWords(layer: Layer, lit: number | null): void {
|
|
293
327
|
if (lit === null) {
|
package/styles/iox-design.css
CHANGED
|
@@ -350,7 +350,7 @@ dialog[data-iox-dialog]:not([open]) {
|
|
|
350
350
|
/* The poster blank (DS3): no poster yet = the token fill, never a request to the provider. */
|
|
351
351
|
.iox-video[data-iox-video-poster-empty]:not([data-iox-video-playing]) { background-color: var(--iox-surface-2); }
|
|
352
352
|
.iox-video[data-iox-video-playing] { background-color: var(--iox-inverse-bg); }
|
|
353
|
-
.iox-video > :is(.iox-video__poster, .iox-video__media, .iox-video__frame) {
|
|
353
|
+
.iox-video > :is(.iox-video__poster, .iox-video__media, .iox-video__frame, .iox-video__frames) {
|
|
354
354
|
position: absolute; inset: 0; width: 100%; height: 100%; border: 0; margin: 0; display: block; object-fit: cover;
|
|
355
355
|
}
|
|
356
356
|
.iox-video > .iox-video__poster { z-index: -1; }
|