@vectoriox/iox-ui 5.72.1 → 5.73.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.
@@ -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,15 @@ 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 own = !!plan.frames && !isAuthoredPoster(plan);
441
+ const srcset = own ? phoneFrameSrcset(plan.frames!) : '';
442
+ // Its real size, so where a Scene flows (reduced motion, no script) the picture holds its height before it loads.
443
+ const size = own ? ` width="${plan.frames!.wide.width}" height="${plan.frames!.wide.height}"` : '';
444
+ return `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''}${size} alt="${esc(plan.title)}" decoding="async">`;
445
+ }
397
446
  const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
398
447
  const label = esc(videoPlayLabel(plan, words));
399
448
  const playSpan = `<span class="${VideoClass.Play}" aria-hidden="true">${iconHtml(VideoIcon.Play)}</span>`;
@@ -437,6 +486,8 @@ export function videoInnerHtml(plan: VideoPlan, options: VideoMarkupOptions): st
437
486
  return `${poster}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>${badge}${prompt}`;
438
487
  }
439
488
 
489
+ const isAuthoredPoster = (plan: VideoPlan): boolean => !!plan.frames && plan.poster !== frameUrl(plan.frames, FrameSet.Wide, 0);
490
+
440
491
  /** Serialise attributes for a string renderer (a present boolean attribute is written bare). */
441
492
  export function serializeVideoAttributes(attrs: Readonly<Record<string, string>>, esc: (value: string) => string): string {
442
493
  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.
@@ -1,4 +1,5 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
+ import { VideoAttribute, VideoClass } from '@vectoriox/iox-ui/media-core';
2
3
 
3
4
  /**
4
5
  * A Scene's data and maths (premium-sites plan §5b). A Scene is a recipe of ordinary nodes — a tall Section, a pinned
@@ -24,6 +25,8 @@ export enum SceneProp {
24
25
  Clip = 'clip',
25
26
  /** How many of the layer's words are lit, in % (100 = all): the word-by-word light-up. Split in the browser only. */
26
27
  Words = 'words',
28
+ /** How much of a Video layer's pictures have played, in % (100 = the last picture). */
29
+ Play = 'play',
27
30
  /** The layer's background colour. */
28
31
  Ground = 'ground',
29
32
  /** The layer's text colour. */
@@ -226,6 +229,12 @@ export function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): num
226
229
  return track ? clampPercent(valueAt(track.keys, p)) : null;
227
230
  }
228
231
 
232
+ /** How far a layer's pictures have played at `p`, in %; null when it has no such track. */
233
+ export function scenePlayed(tracks: ReadonlyArray<SceneTrack>, p: number): number | null {
234
+ const track = tracks.find(t => t.prop === SceneProp.Play);
235
+ return track ? clampPercent(valueAt(track.keys, p)) : null;
236
+ }
237
+
229
238
  /** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
230
239
  export function sceneWordOpacity(index: number, count: number, lit: number): number {
231
240
  return round(SCENE_WORD_DIM + (1 - SCENE_WORD_DIM) * clamp01((lit / PERCENT) * count - index));
@@ -295,7 +304,10 @@ export function applySceneFirstFrame(el: HTMLElement | null | undefined, node: S
295
304
  */
296
305
  export const SCENE_FLOW_RULES = `.iox-role-scene{height:auto!important}`
297
306
  + `.iox-role-scene-stage{position:relative!important;top:auto!important;height:auto!important;overflow:visible!important;container-type:normal!important}`
298
- + `[${SceneAttribute.Tracks}]{transform:none!important;opacity:1!important;clip-path:none!important;pointer-events:auto!important}`;
307
+ + `[${SceneAttribute.Tracks}]{transform:none!important;opacity:1!important;clip-path:none!important;pointer-events:auto!important}`
308
+ // A Video the scroll plays stands as its poster, at the poster's own height: a percent of a stage that now flows is nothing.
309
+ + `[${VideoAttribute.Frames}]{height:auto!important}`
310
+ + `[${VideoAttribute.Frames}]>.${VideoClass.Poster}{position:static!important;height:auto!important}`;
299
311
 
300
312
  /** Under reduced motion the driver never starts, so the same rules hold for the whole visit. */
301
313
  export const SCENE_REDUCED_MOTION_CSS = `@media (prefers-reduced-motion: reduce){${SCENE_FLOW_RULES}}`;
@@ -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) {
@@ -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; }