pixi-effects 0.34.0 → 0.35.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0
4
+
5
+ **The player's seek-bar preview (`thumbnails: true`, off by default) and `movie.thumbnail()`**
6
+
7
+ - **`movie.thumbnail(frame, { width? | scale? })`** gives a small canvas of any frame **without moving the playhead or changing the picture**, also while the movie plays. It draws the frame on the side inside one task (nominal time set, 3D / shader layers awaited, the playhead put back and awaited), so the browser never shows the in-between; it equals `snapshot({ motionBlur: false })` at that size. `null` when a seek, render or another thumbnail is running. Seeks, renders and peeks wait for a running thumbnail, so they never interleave with it.
8
+ - **`movie.thumbnailsOff`** is `'variables'` for a movie that declares variables and `'video'` for one with a video layer, else `null`; `thumbnail()` gives `null` then (a video would have to seek twice, a variable movie can be re-laid out under it).
9
+ - **`new Controller(movie, { canvas, thumbnails: true })`**: hover (or move along) the seek bar and a 160 px picture with the time appears above it, kept inside the picture, hidden while dragging; pictures are cached per tenth of a second until a rebuild. Default off.
10
+ - Tests: `tests/tools/thumbnail.test.ts` (exact against `snapshot`, undisturbed canvas and playhead, playback with a thumbnail per frame, the off reasons, options) and `tests/tools/thumbnailPreview.test.ts` (the bar, with real mouse moves).
11
+
3
12
  ## 0.34.0
4
13
 
5
14
  **From the capability probe of 2026-10-12 (15 kinds of video tried by fresh agents; nothing was impossible, 9 needed hand-written glue; these are the defects that were verified on the real build)**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.34.0`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
3
+ > **Status**: experimental — current release `0.35.0`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
4
4
 
5
5
  **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 52 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
6
6
 
@@ -49,8 +49,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
49
49
  "imports": {
50
50
  "pixi.js": "https://esm.sh/pixi.js@8.22.0?bundle-deps",
51
51
  "mediabunny": "https://esm.sh/mediabunny",
52
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.34.0/dist/index.js",
53
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.34.0/dist/Controller.js"
52
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.35.0/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.35.0/dist/Controller.js"
54
54
  }
55
55
  }
56
56
  </script>
@@ -61,7 +61,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
61
61
  </script>
62
62
  ```
63
63
 
64
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.34.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
64
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.35.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
65
65
 
66
66
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
67
67
  >
@@ -77,7 +77,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
77
77
  "imports": {
78
78
  "...": "(everything from the importmap above)",
79
79
  "three": "https://esm.sh/three@0.178.0",
80
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.34.0/dist/three.js"
80
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.35.0/dist/three.js"
81
81
  }
82
82
  }
83
83
  </script>
@@ -59,9 +59,9 @@
59
59
  "imports": {
60
60
  "pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8.22.0/dist/pixi.min.mjs",
61
61
  "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
62
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.34.0/dist/index.js",
63
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.34.0/dist/Controller.js",
64
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.34.0/dist/present.js"
62
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.35.0/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.35.0/dist/Controller.js",
64
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.35.0/dist/present.js"
65
65
  }
66
66
  }
67
67
  </script>
@@ -63,6 +63,19 @@ var CONTROLLER_CSS = `
63
63
  .mc-progress:hover .mc-progress-thumb,
64
64
  .mc-progress.mc-scrubbing .mc-progress-thumb { transform: translate(-50%, -50%) scale(1); }
65
65
 
66
+ .movie-controller > .mc-preview {
67
+ position: absolute; bottom: 74px; left: 0;
68
+ transform: translateX(-50%);
69
+ display: flex; flex-direction: column; align-items: center; gap: 4px;
70
+ padding: 3px; border-radius: 4px;
71
+ background: rgba(0,0,0,0.75);
72
+ pointer-events: none;
73
+ opacity: 0; transition: opacity 100ms ease;
74
+ }
75
+ .mc-preview[data-show="1"] { opacity: 1; }
76
+ .mc-preview canvas { display: block; width: 160px; height: auto; border-radius: 2px; }
77
+ .mc-preview-time { font-size: 12px; line-height: 1; color: var(--mc-fg, #fff); font-variant-numeric: tabular-nums; }
78
+
66
79
  .mc-bar {
67
80
  position: relative;
68
81
  display: flex;
@@ -295,7 +308,7 @@ var THEME_PROPERTIES = {
295
308
  trackHeight: "--mc-track-height",
296
309
  font: "--mc-font"
297
310
  };
298
- var CONTROLLER_OPTION_KEYS = ["canvas", "theme", "exportButton", "keyboard", "clickToPlay", "presentButton", "pauseAtStops", "loopButton", "className"];
311
+ var CONTROLLER_OPTION_KEYS = ["canvas", "theme", "exportButton", "keyboard", "clickToPlay", "presentButton", "pauseAtStops", "loopButton", "thumbnails", "className"];
299
312
  var Controller = class _Controller {
300
313
  movie;
301
314
  options;
@@ -341,6 +354,17 @@ var Controller = class _Controller {
341
354
  onReady = null;
342
355
  /** A rebuild (setVars, resize, rebuild) may have moved the stops: draw their marks again. */
343
356
  onRebuilt = null;
357
+ previewEl = null;
358
+ previewCanvas = null;
359
+ previewTimeEl = null;
360
+ /** Pictures already made, by frame bucket (a frame is a pure function of time; cleared on a rebuild). */
361
+ previewCache = /* @__PURE__ */ new Map();
362
+ previewWanted = null;
363
+ previewRunning = false;
364
+ previewRetry = null;
365
+ previewRetries = 0;
366
+ previewCleanup = null;
367
+ static PREVIEW_WIDTH_PX = 160;
344
368
  presenter = null;
345
369
  /** `pixi-effects/present`, loaded as soon as the movie has stops (a press of play must not wait for it). */
346
370
  present = null;
@@ -387,6 +411,7 @@ var Controller = class _Controller {
387
411
  presentButton: options.presentButton ?? true,
388
412
  pauseAtStops: options.pauseAtStops ?? true,
389
413
  loopButton: options.loopButton ?? true,
414
+ thumbnails: options.thumbnails ?? false,
390
415
  className: options.className ?? "movie-controller"
391
416
  };
392
417
  installStyles();
@@ -409,6 +434,7 @@ var Controller = class _Controller {
409
434
  this.bindPlayButton();
410
435
  if (this.options.clickToPlay) this.bindCanvasClick();
411
436
  this.bindScrubbing();
437
+ if (this.options.thumbnails) this.bindPreview();
412
438
  this.bindMuteButton();
413
439
  this.bindLoopButton();
414
440
  this.bindVolumeSlider();
@@ -588,6 +614,7 @@ var Controller = class _Controller {
588
614
  };
589
615
  this.onRebuilt = () => {
590
616
  this.refreshStops();
617
+ this.previewCache.clear();
591
618
  };
592
619
  this.movie.on("ready", this.onReady);
593
620
  this.movie.on("rebuilt", this.onRebuilt);
@@ -911,6 +938,104 @@ var Controller = class _Controller {
911
938
  };
912
939
  window.addEventListener("blur", this.windowBlurHandler);
913
940
  }
941
+ /** The hover preview above the seek bar (`thumbnails: true`). */
942
+ bindPreview() {
943
+ const el = document.createElement("div");
944
+ el.className = "mc-preview";
945
+ el.setAttribute("aria-hidden", "true");
946
+ const canvas = document.createElement("canvas");
947
+ const time = document.createElement("span");
948
+ time.className = "mc-preview-time";
949
+ el.append(canvas, time);
950
+ this.root.appendChild(el);
951
+ this.previewEl = el;
952
+ this.previewCanvas = canvas;
953
+ this.previewTimeEl = time;
954
+ const hide = () => {
955
+ this.previewWanted = null;
956
+ el.removeAttribute("data-show");
957
+ };
958
+ const onMove = (e) => {
959
+ if (this.isScrubbing || this.movie.thumbnailsOff || e.pointerType === "touch") {
960
+ hide();
961
+ return;
962
+ }
963
+ const rect = this.progressEl.getBoundingClientRect();
964
+ const inset = _Controller.PROGRESS_INSET_PX;
965
+ const frame = pxToFrame(e.clientX, { left: rect.left + inset, width: Math.max(0, rect.width - 2 * inset) }, this.movie.totalFrames);
966
+ const rootRect = this.root.getBoundingClientRect();
967
+ const half = el.getBoundingClientRect().width / 2 + 2;
968
+ const x = Math.min(Math.max(e.clientX - rootRect.left, half), Math.max(half, rootRect.width - half));
969
+ el.style.left = `${rootRect.width > 0 ? x / rootRect.width * 100 : 50}%`;
970
+ this.previewRetries = 0;
971
+ time.textContent = formatTime(frame / (this.movie.frameRate || 1));
972
+ this.previewWanted = frame;
973
+ void this.runPreview();
974
+ };
975
+ this.progressEl.addEventListener("pointermove", onMove);
976
+ this.progressEl.addEventListener("pointerleave", hide);
977
+ this.progressEl.addEventListener("pointerdown", hide);
978
+ this.previewCleanup = () => {
979
+ this.progressEl.removeEventListener("pointermove", onMove);
980
+ this.progressEl.removeEventListener("pointerleave", hide);
981
+ this.progressEl.removeEventListener("pointerdown", hide);
982
+ if (this.previewRetry) {
983
+ clearTimeout(this.previewRetry);
984
+ this.previewRetry = null;
985
+ }
986
+ el.remove();
987
+ };
988
+ }
989
+ /** Draws the picture of the wanted frame into the preview; one at a time, and the last wish wins. */
990
+ async runPreview() {
991
+ if (this.previewRunning) return;
992
+ this.previewRunning = true;
993
+ try {
994
+ while (this.previewWanted !== null && !this.destroyed) {
995
+ const wanted = this.previewWanted;
996
+ const fps = this.movie.frameRate || 30;
997
+ const step = Math.max(1, Math.round(fps / 10));
998
+ const key = Math.min(this.movie.totalFrames, Math.round(wanted / step) * step);
999
+ let pic = this.previewCache.get(key) ?? null;
1000
+ if (!pic) {
1001
+ try {
1002
+ pic = await this.movie.thumbnail(key, { width: _Controller.PREVIEW_WIDTH_PX });
1003
+ } catch {
1004
+ this.previewWanted = null;
1005
+ this.previewEl?.removeAttribute("data-show");
1006
+ break;
1007
+ }
1008
+ if (pic) {
1009
+ if (this.previewCache.size >= 120) this.previewCache.delete(this.previewCache.keys().next().value);
1010
+ this.previewCache.set(key, pic);
1011
+ }
1012
+ }
1013
+ if (this.previewWanted === null || this.destroyed) break;
1014
+ if (pic) {
1015
+ const c = this.previewCanvas;
1016
+ if (c.width !== pic.width || c.height !== pic.height) {
1017
+ c.width = pic.width;
1018
+ c.height = pic.height;
1019
+ }
1020
+ c.getContext("2d").drawImage(pic, 0, 0);
1021
+ this.previewEl.setAttribute("data-show", "1");
1022
+ if (this.previewWanted === wanted) this.previewWanted = null;
1023
+ } else if (this.previewWanted !== wanted) {
1024
+ } else if (!this.movie.thumbnailsOff && this.previewRetries++ < 10) {
1025
+ this.previewRetry = setTimeout(() => {
1026
+ this.previewRetry = null;
1027
+ void this.runPreview();
1028
+ }, 120);
1029
+ break;
1030
+ } else {
1031
+ this.previewWanted = null;
1032
+ this.previewEl?.removeAttribute("data-show");
1033
+ }
1034
+ }
1035
+ } finally {
1036
+ this.previewRunning = false;
1037
+ }
1038
+ }
914
1039
  seekFromPointer(clientX) {
915
1040
  const rect = this.progressEl.getBoundingClientRect();
916
1041
  const inset = _Controller.PROGRESS_INSET_PX;
@@ -1187,6 +1312,10 @@ var Controller = class _Controller {
1187
1312
  if (this.destroyed) return;
1188
1313
  this.destroyed = true;
1189
1314
  this.stopPresenting();
1315
+ if (this.previewCleanup) {
1316
+ this.previewCleanup();
1317
+ this.previewCleanup = null;
1318
+ }
1190
1319
  if (this.onReady) {
1191
1320
  this.movie.off("ready", this.onReady);
1192
1321
  this.onReady = null;