@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,298 @@
1
1
  import { ConsentCategory, hasConsent, CONSENT_COOKIE_NAME, decode, setCategoryDecision, encode, cookieAttributes } 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
+ var FrameSet;
12
+ (function (FrameSet) {
13
+ FrameSet["Wide"] = "w";
14
+ FrameSet["Phone"] = "p";
15
+ })(FrameSet || (FrameSet = {}));
16
+ var FrameExtension;
17
+ (function (FrameExtension) {
18
+ FrameExtension["Webp"] = "webp";
19
+ FrameExtension["Jpeg"] = "jpg";
20
+ })(FrameExtension || (FrameExtension = {}));
21
+ /** The most pictures one set may hold. Past this a phone runs out of memory before the visitor runs out of scroll. */
22
+ const MAX_FRAMES = 300;
23
+ /** The most one picture may weigh when uploaded. */
24
+ const MAX_FRAME_BYTES = 3 * 1024 * 1024;
25
+ /** A sequence's name in storage: what the browser generates and what the server accepts in a key. */
26
+ const FRAME_SEQUENCE_ID = /^[a-z0-9]{8,40}$/;
27
+ const SAFE_BASE = /^https:\/\/[^\s"'<>\\]+$/;
28
+ const PAD = 4;
29
+ const ALL_EXTENSIONS = new Set(Object.values(FrameExtension));
30
+ function setInfo(raw) {
31
+ const { count, width, height } = (raw ?? {});
32
+ const valid = (v, max) => Number.isInteger(v) && v > 0 && v <= max;
33
+ return valid(count, MAX_FRAMES) && valid(width, 8192) && valid(height, 8192) ? { count, width, height } : null;
34
+ }
35
+ /** The sequence a saved value really describes, or null. Never throws on what a page document holds. */
36
+ function normalizeFrameSequence(raw) {
37
+ const value = (typeof raw === 'string' ? parseJson(raw) : raw);
38
+ if (!value || typeof value !== 'object')
39
+ return null;
40
+ const wide = setInfo(value.wide);
41
+ if (!wide || typeof value.base !== 'string' || !SAFE_BASE.test(value.base) || !ALL_EXTENSIONS.has(String(value.ext)))
42
+ return null;
43
+ const phone = setInfo(value.phone);
44
+ return {
45
+ base: value.base.replace(/\/+$/, ''),
46
+ ext: value.ext,
47
+ wide,
48
+ ...(phone ? { phone } : {}),
49
+ ...(Number.isFinite(value.bytes) && value.bytes > 0 ? { bytes: value.bytes } : {}),
50
+ };
51
+ }
52
+ function parseJson(raw) {
53
+ try {
54
+ return JSON.parse(raw);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /** The name a picture is stored under: its place in the set, from 1. */
61
+ function frameFileName(index, ext) {
62
+ return `${String(index + 1).padStart(PAD, '0')}.${ext}`;
63
+ }
64
+ /** Picture `index` (from 0) of a set. */
65
+ function frameUrl(sequence, set, index) {
66
+ return `${sequence.base}/${set}/${frameFileName(index, sequence.ext)}`;
67
+ }
68
+ /** The first picture of each set, for a poster's `srcset`; '' when there is only one set. */
69
+ function phoneFrameSrcset(sequence) {
70
+ if (!sequence.phone)
71
+ return '';
72
+ return `${frameUrl(sequence, FrameSet.Phone, 0)} ${sequence.phone.width}w, ${frameUrl(sequence, FrameSet.Wide, 0)} ${sequence.wide.width}w`;
73
+ }
74
+ /** Which set plays: the phone's on a phone, when there is one. */
75
+ function frameSetFor(sequence, phone) {
76
+ return phone && sequence.phone ? { set: FrameSet.Phone, info: sequence.phone } : { set: FrameSet.Wide, info: sequence.wide };
77
+ }
78
+ /** The picture at `played` % of a set of `count`. */
79
+ function frameIndexAt(played, count) {
80
+ if (count <= 1)
81
+ return 0;
82
+ return Math.round((Math.min(100, Math.max(0, played)) / 100) * (count - 1));
83
+ }
84
+ /**
85
+ * 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
86
+ * then has a near picture almost at once, and the movement sharpens as the rest arrive. Fetching in sequence would leave a
87
+ * visitor who scrolls fast looking at the first frames.
88
+ */
89
+ function frameLoadOrder(count) {
90
+ const order = [];
91
+ const seen = new Set();
92
+ const add = (i) => {
93
+ if (i >= 0 && i < count && !seen.has(i)) {
94
+ seen.add(i);
95
+ order.push(i);
96
+ }
97
+ };
98
+ add(0);
99
+ add(count - 1);
100
+ for (let stride = 16; stride >= 1; stride /= 2) {
101
+ for (let i = 0; i < count; i += stride)
102
+ add(i);
103
+ }
104
+ return order;
105
+ }
106
+ /** The loaded picture nearest `index`, or -1 when none has arrived. Ties go backward: the picture already passed. */
107
+ function nearestLoadedFrame(loaded, index) {
108
+ for (let distance = 0; distance < loaded.length; distance++) {
109
+ if (loaded[index - distance])
110
+ return index - distance;
111
+ if (loaded[index + distance])
112
+ return index + distance;
113
+ }
114
+ return -1;
115
+ }
116
+ /** The longest side of a wide picture and of a phone picture. A phone's is not its width: a landscape video covers a
117
+ * tall stage by its height, so a picture sized to the phone's width would be enlarged five times. */
118
+ const WIDE_LONG_SIDE = 1600;
119
+ const PHONE_LONG_SIDE = 1100;
120
+ /** How many pictures a second of video becomes, before the cap. */
121
+ const VIDEO_FRAMES_PER_SECOND = 24;
122
+ /** 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. */
123
+ const VIDEO_MAX_FRAMES = 180;
124
+ /** `width`×`height` fitted inside `longSide`, never enlarged. */
125
+ function fittedFrameSize(width, height, longSide) {
126
+ const scale = Math.min(1, longSide / Math.max(width, height));
127
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
128
+ }
129
+ /** How many pictures a video of `seconds` becomes. */
130
+ function videoFrameCount(seconds) {
131
+ return Math.min(VIDEO_MAX_FRAMES, Math.max(2, Math.ceil(seconds * VIDEO_FRAMES_PER_SECOND)));
132
+ }
133
+ /** Which of `sourceCount` pictures make a set of `count`, spread evenly, first and last always in. */
134
+ function sampledFrameIndexes(sourceCount, count) {
135
+ if (count >= sourceCount)
136
+ return Array.from({ length: sourceCount }, (_, i) => i);
137
+ if (count <= 1)
138
+ return [0];
139
+ return Array.from({ length: count }, (_, i) => Math.round((i * (sourceCount - 1)) / (count - 1)));
140
+ }
141
+ /** A phone plays half the pictures: it has less memory, and half is plenty on a small screen. */
142
+ function phoneFrameCount(wideCount) {
143
+ return Math.max(2, Math.ceil(wideCount / 2));
144
+ }
145
+ const NATURAL = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
146
+ /** Rendered frames in their real order: `frame2` before `frame10`. */
147
+ function sortFrameFiles(files) {
148
+ return [...files].sort((a, b) => NATURAL.compare(a.name, b.name));
149
+ }
150
+ /** A sequence's weight, for the author. */
151
+ function frameSummary(sequence) {
152
+ return { frames: sequence.wide.count, megabytes: sequence.bytes ? Math.round((sequence.bytes / (1024 * 1024)) * 10) / 10 : null };
153
+ }
154
+ var FramesClass;
155
+ (function (FramesClass) {
156
+ FramesClass["Canvas"] = "iox-video__frames";
157
+ })(FramesClass || (FramesClass = {}));
158
+ const LOAD_CONCURRENCY = 6;
159
+ const NEAR_MARGIN = '100% 0px';
160
+ /** Under these the visitor keeps the poster: nobody on a metered 2G connection asked to download a film. */
161
+ const SLOW_CONNECTIONS = new Set(['slow-2g', '2g']);
162
+ /**
163
+ * The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
164
+ * first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
165
+ * Browser only. It writes and never reads, so the driver can call it in its write phase.
166
+ */
167
+ class FramePlayer {
168
+ constructor(el, sequence, options = {}) {
169
+ this.el = el;
170
+ this.sequence = sequence;
171
+ this.observer = null;
172
+ this.canvas = null;
173
+ this.context = null;
174
+ this.wanted = 0;
175
+ this.drawn = -1;
176
+ this.nextToLoad = 0;
177
+ this.loading = 0;
178
+ this.started = false;
179
+ this.destroyed = false;
180
+ this.pending = new Set();
181
+ this.host = options.host ?? window;
182
+ const { set, info } = frameSetFor(sequence, !!options.phone);
183
+ this.set = set;
184
+ this.info = info;
185
+ this.images = new Array(info.count).fill(null);
186
+ this.order = frameLoadOrder(info.count);
187
+ if (!this.allowed())
188
+ return;
189
+ const Observer = this.host.IntersectionObserver;
190
+ if (!Observer) {
191
+ this.start();
192
+ return;
193
+ }
194
+ this.observer = new Observer(entries => {
195
+ if (entries.some(entry => entry.isIntersecting))
196
+ this.start();
197
+ }, { rootMargin: NEAR_MARGIN });
198
+ this.observer.observe(options.near ?? el);
199
+ }
200
+ /** Stand at `played` %. */
201
+ seek(played) {
202
+ this.wanted = frameIndexAt(played, this.info.count);
203
+ this.draw();
204
+ }
205
+ /** Whether a re-render took the canvas away with the markup it sat in. */
206
+ get detached() {
207
+ return !!this.canvas && !this.canvas.isConnected;
208
+ }
209
+ destroy() {
210
+ this.destroyed = true;
211
+ this.observer?.disconnect();
212
+ this.observer = null;
213
+ for (const image of this.pending) {
214
+ image.onload = null;
215
+ image.onerror = null;
216
+ image.src = '';
217
+ }
218
+ this.pending.clear();
219
+ this.canvas?.remove();
220
+ this.canvas = null;
221
+ this.context = null;
222
+ }
223
+ allowed() {
224
+ const connection = this.host.navigator?.connection;
225
+ return !connection?.saveData && !SLOW_CONNECTIONS.has(connection?.effectiveType ?? '');
226
+ }
227
+ start() {
228
+ if (this.started)
229
+ return;
230
+ this.started = true;
231
+ this.observer?.disconnect();
232
+ this.observer = null;
233
+ this.pump();
234
+ }
235
+ pump() {
236
+ while (!this.destroyed && this.loading < LOAD_CONCURRENCY && this.nextToLoad < this.order.length) {
237
+ this.load(this.order[this.nextToLoad++]);
238
+ }
239
+ }
240
+ load(index) {
241
+ const image = this.host.document.createElement('img');
242
+ image.decoding = 'async';
243
+ this.loading++;
244
+ this.pending.add(image);
245
+ const done = (arrived) => {
246
+ this.pending.delete(image);
247
+ this.loading--;
248
+ if (this.destroyed)
249
+ return;
250
+ if (arrived) {
251
+ this.images[index] = image;
252
+ this.draw();
253
+ }
254
+ this.pump();
255
+ };
256
+ // Decoded before it counts, or the first draw of each picture decodes on the driver's frame and the scroll stutters.
257
+ image.onload = () => {
258
+ const decoded = image.decode?.();
259
+ if (decoded)
260
+ void decoded.then(() => done(true), () => done(true));
261
+ else
262
+ done(true);
263
+ };
264
+ // A picture that failed is a gap: its neighbours stand in.
265
+ image.onerror = () => done(false);
266
+ image.src = frameUrl(this.sequence, this.set, index);
267
+ }
268
+ draw() {
269
+ const index = nearestLoadedFrame(this.images, this.wanted);
270
+ if (index < 0 || index === this.drawn)
271
+ return;
272
+ const image = this.images[index];
273
+ if (!this.ensureCanvas(image))
274
+ return;
275
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
276
+ this.context.drawImage(image, 0, 0, this.canvas.width, this.canvas.height);
277
+ this.drawn = index;
278
+ }
279
+ ensureCanvas(image) {
280
+ if (this.canvas)
281
+ return !!this.context;
282
+ const canvas = this.host.document.createElement('canvas');
283
+ canvas.className = FramesClass.Canvas;
284
+ canvas.setAttribute('aria-hidden', 'true');
285
+ canvas.width = image.naturalWidth || this.info.width;
286
+ canvas.height = image.naturalHeight || this.info.height;
287
+ this.context = canvas.getContext('2d');
288
+ if (!this.context)
289
+ return false;
290
+ this.canvas = canvas;
291
+ this.el.appendChild(canvas);
292
+ return true;
293
+ }
294
+ }
295
+
3
296
  /**
4
297
  * VIDEO (CC3 — complex-components plan §7b, board 5). A LEAF type with two sources:
5
298
  * - an uploaded FILE renders as a real `<video>` (poster, `preload="metadata"`, captions, controls);
@@ -38,6 +331,8 @@ var VideoTrait;
38
331
  VideoTrait["Captions"] = "captions";
39
332
  VideoTrait["CaptionsLang"] = "captionsLang";
40
333
  VideoTrait["Consent"] = "consentCategory";
334
+ /** Pictures the scroll plays (SC4): a `FrameSequence`. Written by the Scene panel, never typed. */
335
+ VideoTrait["Frames"] = "frames";
41
336
  })(VideoTrait || (VideoTrait = {}));
42
337
  /** What a plan renders. */
43
338
  var VideoKind;
@@ -48,6 +343,8 @@ var VideoKind;
48
343
  VideoKind["Facade"] = "facade";
49
344
  /** No address yet: the poster blank (DS3) and nothing to play. */
50
345
  VideoKind["Empty"] = "empty";
346
+ /** Pictures the scroll plays: the poster, and the driver's canvas over it. No player, no controls. */
347
+ VideoKind["Frames"] = "frames";
51
348
  })(VideoKind || (VideoKind = {}));
52
349
  const VIDEO_PROVIDER_LABEL = {
53
350
  [VideoSource.YouTube]: 'YouTube',
@@ -160,6 +457,9 @@ function videoConsentCategory(node, source = videoSourceOf(videoProp(node, Video
160
457
  * studio's own image (or the thumbnail the server fetched and stored for it), never a request to a third party.
161
458
  */
162
459
  function planVideo(node, consent) {
460
+ const frames = normalizeFrameSequence(videoProp(node, VideoTrait.Frames));
461
+ if (frames)
462
+ return planFrames(node, frames);
163
463
  const rawSrc = str(videoProp(node, VideoTrait.Src));
164
464
  const source = videoSourceOf(rawSrc, videoProp(node, VideoTrait.Source));
165
465
  const ref = source === VideoSource.File ? null : parseVideoUrl(rawSrc);
@@ -187,6 +487,32 @@ function planVideo(node, consent) {
187
487
  granted,
188
488
  embedUrl: kind === VideoKind.Facade && granted && ref ? videoEmbedUrl(ref) : null,
189
489
  providerLabel: ref ? VIDEO_PROVIDER_LABEL[ref.source] : '',
490
+ frames: null,
491
+ };
492
+ }
493
+ /** Pictures win over any address the node also holds: it was turned into them. No player, no consent, nothing to press. */
494
+ function planFrames(node, frames) {
495
+ const poster = str(videoProp(node, VideoTrait.Poster));
496
+ return {
497
+ kind: VideoKind.Frames,
498
+ source: VideoSource.File,
499
+ ref: null,
500
+ src: '',
501
+ poster: poster && !isThirdPartyPoster(poster) ? poster : frameUrl(frames, FrameSet.Wide, 0),
502
+ title: str(videoProp(node, VideoTrait.Title)),
503
+ mode: VideoMode.Inline,
504
+ background: false,
505
+ autoplay: false,
506
+ muted: false,
507
+ loop: false,
508
+ controls: false,
509
+ captions: '',
510
+ captionsLang: 'en',
511
+ consentCategory: null,
512
+ granted: true,
513
+ embedUrl: null,
514
+ providerLabel: '',
515
+ frames,
190
516
  };
191
517
  }
192
518
  const THIRD_PARTY_POSTER_HOSTS = ['ytimg.com', 'youtube.com', 'vimeocdn.com', 'vimeo.com', 'googleusercontent.com'];
@@ -270,6 +596,8 @@ var VideoAttribute;
270
596
  VideoAttribute["Playing"] = "data-iox-video-playing";
271
597
  /** The consent prompt is showing. */
272
598
  VideoAttribute["Asking"] = "data-iox-video-asking";
599
+ /** The pictures the scroll plays, as JSON: what the driver reads, so it needs no node tree. */
600
+ VideoAttribute["Frames"] = "data-iox-video-frames";
273
601
  })(VideoAttribute || (VideoAttribute = {}));
274
602
  var VideoIcon;
275
603
  (function (VideoIcon) {
@@ -294,6 +622,8 @@ function videoRootAttributes(plan) {
294
622
  attrs[VideoAttribute.Title] = plan.title;
295
623
  if (!plan.poster)
296
624
  attrs[VideoAttribute.PosterEmpty] = '';
625
+ if (plan.frames)
626
+ attrs[VideoAttribute.Frames] = JSON.stringify(plan.frames);
297
627
  return attrs;
298
628
  }
299
629
  const iconHtml = (cls) => `<i class="${cls}" aria-hidden="true"></i>`;
@@ -302,6 +632,15 @@ const iconHtml = (cls) => `<i class="${cls}" aria-hidden="true"></i>`;
302
632
  function videoInnerHtml(plan, options) {
303
633
  const { esc } = options;
304
634
  const words = options.words ?? VIDEO_WORDS.en;
635
+ // The same on the canvas and on the site: the first picture is real content, so it is fetched at once, named, and the
636
+ // size a phone needs. A driver's canvas covers it when pictures arrive; with no script it is what there is.
637
+ if (plan.kind === VideoKind.Frames) {
638
+ const own = !!plan.frames && !isAuthoredPoster(plan);
639
+ const srcset = own ? phoneFrameSrcset(plan.frames) : '';
640
+ // Its real size, so where a Scene flows (reduced motion, no script) the picture holds its height before it loads.
641
+ const size = own ? ` width="${plan.frames.wide.width}" height="${plan.frames.wide.height}"` : '';
642
+ return `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}"${srcset ? ` srcset="${esc(srcset)}" sizes="100vw"` : ''}${size} alt="${esc(plan.title)}" decoding="async">`;
643
+ }
305
644
  const poster = plan.poster ? `<img class="${VideoClass.Poster}" src="${esc(plan.poster)}" alt="" loading="lazy" decoding="async">` : '';
306
645
  const label = esc(videoPlayLabel(plan, words));
307
646
  const playSpan = `<span class="${VideoClass.Play}" aria-hidden="true">${iconHtml(VideoIcon.Play)}</span>`;
@@ -341,6 +680,7 @@ function videoInnerHtml(plan, options) {
341
680
  `</div></div>`;
342
681
  return `${poster}<button type="button" class="${VideoClass.Play}" aria-label="${label}">${iconHtml(VideoIcon.Play)}</button>${badge}${prompt}`;
343
682
  }
683
+ const isAuthoredPoster = (plan) => !!plan.frames && plan.poster !== frameUrl(plan.frames, FrameSet.Wide, 0);
344
684
  /** Serialise attributes for a string renderer (a present boolean attribute is written bare). */
345
685
  function serializeVideoAttributes(attrs, esc) {
346
686
  return Object.entries(attrs).map(([name, value]) => (value === '' ? ` ${name}` : ` ${name}="${esc(value)}"`)).join('');
@@ -567,5 +907,5 @@ function videoRuntimeOf(el) {
567
907
  * Generated bundle index. Do not edit.
568
908
  */
569
909
 
570
- 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 };
910
+ 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 };
571
911
  //# sourceMappingURL=vectoriox-iox-ui-media-core.mjs.map