@vectoriox/iox-ui 5.76.0 → 5.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,8 +20,12 @@ var FrameExtension;
20
20
  FrameExtension["Webp"] = "webp";
21
21
  FrameExtension["Jpeg"] = "jpg";
22
22
  })(FrameExtension || (FrameExtension = {}));
23
- /** The most pictures one set may hold. Past this a phone runs out of memory before the visitor runs out of scroll. */
24
- const MAX_FRAMES = 300;
23
+ /**
24
+ * The most pictures one set may hold. A film's smoothness is its density, pictures per screen of scrolling: 210 over 18
25
+ * screens stepped on Doron's iPhone as a glide slowed, 1050 did not (2026-11-05). Memory is not what limits it: the player
26
+ * keeps the downloaded files (about 10 kB a phone picture) and only a window of them unpacked (`unpackedFrameCount`).
27
+ */
28
+ const MAX_FRAMES = 1200;
25
29
  /** The most one picture may weigh when uploaded. */
26
30
  const MAX_FRAME_BYTES = 3 * 1024 * 1024;
27
31
  /** A sequence's name in storage: what the browser generates and what the server accepts in a key. */
@@ -140,14 +144,38 @@ function nearestLoadedFrame(loaded, index) {
140
144
  }
141
145
  return -1;
142
146
  }
147
+ /**
148
+ * How much unpacked picture one player holds at once. A browser keeps few of 300 unpacked pictures (a wide set is 1.7 GB
149
+ * unpacked) and unpacked again on the page's own thread, at the moment of drawing, 22 ms a 1600×900 picture: longer than a
150
+ * whole frame, so the film and the page beside it skipped a beat for every new picture (Doron's recording, 2026-11-04).
151
+ */
152
+ const FRAMES_UNPACKED_BYTES = 96 * 1024 * 1024;
153
+ const MIN_UNPACKED = 6;
154
+ const MAX_UNPACKED = 24;
155
+ /** How many pictures of `width`×`height` a player holds unpacked. */
156
+ function unpackedFrameCount(width, height) {
157
+ return Math.min(MAX_UNPACKED, Math.max(MIN_UNPACKED, Math.floor(FRAMES_UNPACKED_BYTES / (width * height * 4))));
158
+ }
159
+ /** The `size` pictures to hold unpacked around `position`, nearest first, each side in turn starting ahead. */
160
+ function framesToHold(position, count, size) {
161
+ const from = Math.min(count - 1, Math.max(0, Math.floor(position)));
162
+ const held = [from];
163
+ for (let distance = 1; held.length < Math.min(size, count); distance++) {
164
+ if (from + distance < count)
165
+ held.push(from + distance);
166
+ if (from - distance >= 0 && held.length < size)
167
+ held.push(from - distance);
168
+ }
169
+ return held;
170
+ }
143
171
  /** The longest side of a wide picture and of a phone picture. A phone's is not its width: a landscape video covers a
144
172
  * tall stage by its height, so a picture sized to the phone's width would be enlarged five times. */
145
173
  const WIDE_LONG_SIDE = 1600;
146
174
  const PHONE_LONG_SIDE = 1100;
147
- /** How many pictures a second of video becomes, before the cap. */
148
- const VIDEO_FRAMES_PER_SECOND = 24;
149
- /** 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. */
150
- const VIDEO_MAX_FRAMES = 180;
175
+ /** How many pictures a second of video becomes, before the cap: what most recordings hold, so few are the same twice. */
176
+ const VIDEO_FRAMES_PER_SECOND = 30;
177
+ /** A video's cap: half a minute at that rate. A set of pictures is the author's own count, up to `MAX_FRAMES`. */
178
+ const VIDEO_MAX_FRAMES = 900;
151
179
  /** `width`×`height` fitted inside `longSide`, never enlarged. */
152
180
  function fittedFrameSize(width, height, longSide) {
153
181
  const scale = Math.min(1, longSide / Math.max(width, height));
@@ -165,10 +193,6 @@ function sampledFrameIndexes(sourceCount, count) {
165
193
  return [0];
166
194
  return Array.from({ length: count }, (_, i) => Math.round((i * (sourceCount - 1)) / (count - 1)));
167
195
  }
168
- /** A phone plays half the pictures: it has less memory, and half is plenty on a small screen. */
169
- function phoneFrameCount(wideCount) {
170
- return Math.max(2, Math.ceil(wideCount / 2));
171
- }
172
196
  const NATURAL = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
173
197
  /** Rendered frames in their real order: `frame2` before `frame10`. */
174
198
  function sortFrameFiles(files) {
@@ -183,6 +207,7 @@ var FramesClass;
183
207
  FramesClass["Canvas"] = "iox-video__frames";
184
208
  })(FramesClass || (FramesClass = {}));
185
209
  const LOAD_CONCURRENCY = 6;
210
+ const UNPACK_CONCURRENCY = 4;
186
211
  const NEAR_MARGIN = '100% 0px';
187
212
  /** Under these the visitor keeps the poster: nobody on a metered 2G connection asked to download a film. */
188
213
  const SLOW_CONNECTIONS = new Set(['slow-2g', '2g']);
@@ -190,6 +215,11 @@ const SLOW_CONNECTIONS = new Set(['slow-2g', '2g']);
190
215
  * The player: draws the picture nearest what the scroll asked for on a canvas laid over the poster, and nothing until a
191
216
  * first picture has arrived, so the poster is what a visitor sees until then, and for good if the pictures never come.
192
217
  * Browser only. It writes and never reads, so the driver can call it in its write phase.
218
+ *
219
+ * Each picture is downloaded as a file and unpacked off the page's thread (`createImageBitmap` of the file), a window of
220
+ * them around where the film stands, so drawing one never waits for it. A browser that cannot (no `fetch` or
221
+ * `createImageBitmap`, or storage that refuses the download) plays from `<img>` pictures, as before: it works, and pays the
222
+ * unpacking on the page's thread.
193
223
  */
194
224
  class FramePlayer {
195
225
  constructor(el, sequence, options = {}) {
@@ -204,13 +234,21 @@ class FramePlayer {
204
234
  this.loading = 0;
205
235
  this.started = false;
206
236
  this.destroyed = false;
237
+ this.held = new Set();
238
+ this.unpacked = new Map();
239
+ this.unpacking = new Set();
207
240
  this.pending = new Set();
208
241
  this.host = options.host ?? window;
209
242
  const { set, info } = frameSetFor(sequence, !!options.phone);
210
243
  this.set = set;
211
244
  this.info = info;
212
- this.images = new Array(info.count).fill(null);
245
+ this.ready = new Array(info.count).fill(null);
246
+ this.files = new Array(info.count).fill(null);
213
247
  this.order = frameLoadOrder(info.count);
248
+ this.holdCount = unpackedFrameCount(info.width, info.height);
249
+ this.fromFiles = typeof this.host.fetch === 'function' && typeof this.host.createImageBitmap === 'function';
250
+ const Controller = this.host.AbortController;
251
+ this.downloads = this.fromFiles && Controller ? new Controller() : null;
214
252
  if (!this.allowed())
215
253
  return;
216
254
  const Observer = this.host.IntersectionObserver;
@@ -232,6 +270,7 @@ class FramePlayer {
232
270
  if (played === this.played)
233
271
  return;
234
272
  this.played = played;
273
+ this.hold();
235
274
  this.draw();
236
275
  }
237
276
  /** Whether a re-render took the canvas away with the markup it sat in. */
@@ -243,12 +282,16 @@ class FramePlayer {
243
282
  this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
244
283
  this.observer?.disconnect();
245
284
  this.observer = null;
285
+ this.downloads?.abort();
246
286
  for (const image of this.pending) {
247
287
  image.onload = null;
248
288
  image.onerror = null;
249
289
  image.src = '';
250
290
  }
251
291
  this.pending.clear();
292
+ for (const bitmap of this.unpacked.values())
293
+ bitmap.close();
294
+ this.unpacked.clear();
252
295
  this.canvas?.remove();
253
296
  this.canvas = null;
254
297
  this.context = null;
@@ -267,26 +310,55 @@ class FramePlayer {
267
310
  }
268
311
  pump() {
269
312
  while (!this.destroyed && this.loading < LOAD_CONCURRENCY && this.nextToLoad < this.order.length) {
270
- this.load(this.order[this.nextToLoad++]);
313
+ const index = this.order[this.nextToLoad++];
314
+ if (this.fromFiles)
315
+ this.download(index);
316
+ else
317
+ this.loadImage(index);
271
318
  }
272
319
  }
273
- load(index) {
320
+ loaded(arrived) {
321
+ this.loading--;
322
+ if (this.destroyed)
323
+ return;
324
+ if (arrived)
325
+ this.draw();
326
+ this.pump();
327
+ }
328
+ download(index) {
329
+ this.loading++;
330
+ const url = frameUrl(this.sequence, this.set, index);
331
+ this.host.fetch(url, this.downloads ? { signal: this.downloads.signal } : undefined)
332
+ .then(response => (response.ok ? response.blob() : null))
333
+ .then(file => {
334
+ if (this.destroyed)
335
+ return;
336
+ // A picture that failed is a gap: its neighbours stand in.
337
+ this.files[index] = file;
338
+ this.loading--;
339
+ this.hold();
340
+ this.pump();
341
+ }, () => {
342
+ if (this.destroyed)
343
+ return;
344
+ // Storage that refuses a download from the page's address still serves an <img>: play from those.
345
+ this.fromFiles = false;
346
+ this.loading--;
347
+ this.loadImage(index);
348
+ });
349
+ }
350
+ loadImage(index) {
274
351
  const image = this.host.document.createElement('img');
275
352
  image.decoding = 'async';
276
353
  this.loading++;
277
354
  this.pending.add(image);
278
355
  const done = (arrived) => {
279
356
  this.pending.delete(image);
280
- this.loading--;
281
- if (this.destroyed)
282
- return;
283
- if (arrived) {
284
- this.images[index] = image;
285
- this.draw();
286
- }
287
- this.pump();
357
+ if (!this.destroyed && arrived)
358
+ this.ready[index] = image;
359
+ this.loaded(arrived);
288
360
  };
289
- // Decoded before it counts, or the first draw of each picture decodes on the driver's frame and the scroll stutters.
361
+ // Decoded before it counts, or its first draw decodes on the driver's frame.
290
362
  image.onload = () => {
291
363
  const decoded = image.decode?.();
292
364
  if (decoded)
@@ -294,19 +366,58 @@ class FramePlayer {
294
366
  else
295
367
  done(true);
296
368
  };
297
- // A picture that failed is a gap: its neighbours stand in.
298
369
  image.onerror = () => done(false);
299
370
  image.src = frameUrl(this.sequence, this.set, index);
300
371
  }
372
+ /** Unpack the pictures around where the film stands, nearest first, and let go of the ones it has left. */
373
+ hold() {
374
+ if (this.destroyed)
375
+ return;
376
+ const wanted = framesToHold(framePosition(this.played ?? 0, this.info.count), this.info.count, this.holdCount);
377
+ this.held = new Set(wanted);
378
+ for (const [index, bitmap] of this.unpacked) {
379
+ if (this.held.has(index))
380
+ continue;
381
+ bitmap.close();
382
+ this.unpacked.delete(index);
383
+ this.ready[index] = null;
384
+ }
385
+ for (const index of wanted) {
386
+ if (this.unpacking.size >= UNPACK_CONCURRENCY)
387
+ return;
388
+ if (this.files[index] && !this.ready[index] && !this.unpacking.has(index))
389
+ this.unpack(index);
390
+ }
391
+ }
392
+ unpack(index) {
393
+ this.unpacking.add(index);
394
+ this.host.createImageBitmap(this.files[index]).then(bitmap => {
395
+ this.unpacking.delete(index);
396
+ if (this.destroyed || !this.held.has(index)) {
397
+ bitmap.close();
398
+ }
399
+ else {
400
+ this.unpacked.set(index, bitmap);
401
+ this.ready[index] = bitmap;
402
+ this.draw();
403
+ }
404
+ this.hold();
405
+ }, () => {
406
+ // A file that will not unpack is a gap, like one that never came.
407
+ this.unpacking.delete(index);
408
+ this.files[index] = null;
409
+ this.hold();
410
+ });
411
+ }
301
412
  draw() {
302
413
  if (this.destroyed || this.played === null)
303
414
  return;
304
- const blend = frameBlendAt(this.images, framePosition(this.played, this.info.count));
415
+ const blend = frameBlendAt(this.ready, framePosition(this.played, this.info.count));
305
416
  const last = this.drawn;
306
417
  if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix))
307
418
  return;
308
- const image = this.images[blend.from];
309
- if (!this.ensureCanvas(image))
419
+ const picture = this.ready[blend.from];
420
+ if (!this.ensureCanvas(picture))
310
421
  return;
311
422
  const context = this.context;
312
423
  const { width, height } = this.canvas;
@@ -317,28 +428,29 @@ class FramePlayer {
317
428
  // a time instead of moving.
318
429
  context.globalCompositeOperation = 'lighter';
319
430
  context.globalAlpha = 1 - blend.mix;
320
- context.drawImage(image, 0, 0, width, height);
431
+ context.drawImage(picture, 0, 0, width, height);
321
432
  context.globalAlpha = blend.mix;
322
- context.drawImage(this.images[blend.to], 0, 0, width, height);
433
+ context.drawImage(this.ready[blend.to], 0, 0, width, height);
323
434
  context.globalAlpha = 1;
324
435
  context.globalCompositeOperation = 'source-over';
325
436
  }
326
437
  else {
327
- context.drawImage(image, 0, 0, width, height);
438
+ context.drawImage(picture, 0, 0, width, height);
328
439
  }
329
440
  // Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
330
441
  if (!this.drawn)
331
442
  this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
332
443
  this.drawn = blend;
333
444
  }
334
- ensureCanvas(image) {
445
+ ensureCanvas(picture) {
335
446
  if (this.canvas)
336
447
  return !!this.context;
448
+ const size = picture;
337
449
  const canvas = this.host.document.createElement('canvas');
338
450
  canvas.className = FramesClass.Canvas;
339
451
  canvas.setAttribute('aria-hidden', 'true');
340
- canvas.width = image.naturalWidth || this.info.width;
341
- canvas.height = image.naturalHeight || this.info.height;
452
+ canvas.width = size.naturalWidth || Number(size.width) || this.info.width;
453
+ canvas.height = size.naturalHeight || Number(size.height) || this.info.height;
342
454
  this.context = canvas.getContext('2d');
343
455
  if (!this.context)
344
456
  return false;
@@ -968,5 +1080,5 @@ function videoRuntimeOf(el) {
968
1080
  * Generated bundle index. Do not edit.
969
1081
  */
970
1082
 
971
- export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameCount, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
1083
+ export { CONSENT_GRANT_EVENT, CONSENT_PREFERENCES_EVENT, FRAMES_PHONE_MAX_WIDTH, FRAMES_SHOWING_ATTRIBUTE, FRAMES_UNPACKED_BYTES, FRAME_BLEND_STEPS, FRAME_SEQUENCE_ID, FrameExtension, FramePlayer, FrameSet, FramesClass, MAX_FRAMES, MAX_FRAME_BYTES, PHONE_LONG_SIDE, VIDEO_EMBED_ORIGINS, VIDEO_NODE_TYPE, VIDEO_PROVIDER_COMPANY, VIDEO_PROVIDER_LABEL, VIDEO_WORDS, VideoAttribute, VideoClass, VideoIcon, VideoKind, VideoMode, VideoRuntime, VideoSource, VideoTrait, WIDE_LONG_SIDE, attachVideoAt, attachVideos, documentConsentTransport, fittedFrameSize, frameBlendAt, frameFileName, frameIndexAt, frameLoadOrder, framePosition, frameSetFor, frameSummary, frameUrl, framesToHold, isThirdPartyPoster, nearestLoadedFrame, normalizeFrameSequence, parseVideoUrl, phoneFrameSrcset, planVideo, readConsentRecord, sampledFrameIndexes, serializeVideoAttributes, sortFrameFiles, unpackedFrameCount, videoConsentCategory, videoEmbedUrl, videoFrameCount, videoInnerHtml, videoPlayLabel, videoPosterFileName, videoProp, videoRefOf, videoRootAttributes, videoRuntimeOf, videoSourceOf, videoThumbnailRequest, videoWords };
972
1084
  //# sourceMappingURL=vectoriox-iox-ui-media-core.mjs.map