pixi-effects 0.34.0 → 0.36.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +5 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +26 -7
  5. package/ai/tools/lookup.mjs +14 -2
  6. package/dist/{Base-CXsMdmJB.d.ts → Base-CdF7AJop.d.ts} +1 -1
  7. package/dist/{Base-pNq6sVGl.d.cts → Base-CmiFiwQM.d.cts} +1 -1
  8. package/dist/Composition-BMRCE5FH.js +7 -0
  9. package/dist/{Composition-NDXKQHB4.js.map → Composition-BMRCE5FH.js.map} +1 -1
  10. package/dist/Composition-XRLYR6GL.cjs +16 -0
  11. package/dist/{Composition-OLH7RC5Z.cjs.map → Composition-XRLYR6GL.cjs.map} +1 -1
  12. package/dist/Controller.cjs +130 -1
  13. package/dist/Controller.cjs.map +1 -1
  14. package/dist/Controller.d.cts +24 -2
  15. package/dist/Controller.d.ts +24 -2
  16. package/dist/Controller.js +130 -1
  17. package/dist/Controller.js.map +1 -1
  18. package/dist/{Movie-f0tQG9y5.d.ts → Movie-Bh1HPdKw.d.cts} +30 -2
  19. package/dist/{Movie-CL6SsAWe.d.cts → Movie-D5WFfZdZ.d.ts} +30 -2
  20. package/dist/{Spans-QRMN2EZO.cjs → Spans-4IRBSOLI.cjs} +11 -11
  21. package/dist/{Spans-QRMN2EZO.cjs.map → Spans-4IRBSOLI.cjs.map} +1 -1
  22. package/dist/{Spans-QHHWC2PB.js → Spans-MWP2FX3T.js} +6 -6
  23. package/dist/{Spans-QHHWC2PB.js.map → Spans-MWP2FX3T.js.map} +1 -1
  24. package/dist/{blendModes-WIUCYIMC.cjs → blendModes-KU7XLV7U.cjs} +5 -5
  25. package/dist/{blendModes-WIUCYIMC.cjs.map → blendModes-KU7XLV7U.cjs.map} +1 -1
  26. package/dist/{blendModes-PP5NPBXY.js → blendModes-OOI3T6BE.js} +4 -4
  27. package/dist/{blendModes-PP5NPBXY.js.map → blendModes-OOI3T6BE.js.map} +1 -1
  28. package/dist/{chunk-ITDYFBB6.js → chunk-2JFFKHXO.js} +5 -5
  29. package/dist/chunk-2JFFKHXO.js.map +1 -0
  30. package/dist/{chunk-GL2AQFLQ.cjs → chunk-42ML5AKN.cjs} +5 -5
  31. package/dist/chunk-42ML5AKN.cjs.map +1 -0
  32. package/dist/{chunk-XV4L72FW.js → chunk-AZ6AINSW.js} +5 -5
  33. package/dist/{chunk-XV4L72FW.js.map → chunk-AZ6AINSW.js.map} +1 -1
  34. package/dist/{chunk-FVZEBYLW.cjs → chunk-CC7SUR7Q.cjs} +237 -54
  35. package/dist/chunk-CC7SUR7Q.cjs.map +1 -0
  36. package/dist/{chunk-Y253JQOH.js → chunk-G3DX65HO.js} +110 -10
  37. package/dist/chunk-G3DX65HO.js.map +1 -0
  38. package/dist/{chunk-LEECUCTG.cjs → chunk-KJSEQVBV.cjs} +129 -29
  39. package/dist/chunk-KJSEQVBV.cjs.map +1 -0
  40. package/dist/{chunk-IMQJVRMT.cjs → chunk-NEUPEENI.cjs} +5 -5
  41. package/dist/{chunk-IMQJVRMT.cjs.map → chunk-NEUPEENI.cjs.map} +1 -1
  42. package/dist/{chunk-67LG7MN5.js → chunk-VF56PRAA.js} +205 -22
  43. package/dist/chunk-VF56PRAA.js.map +1 -0
  44. package/dist/{chunk-RTJD3ZSW.js → chunk-WIIU42FH.js} +4 -4
  45. package/dist/{chunk-RTJD3ZSW.js.map → chunk-WIIU42FH.js.map} +1 -1
  46. package/dist/{chunk-5IJENZOG.cjs → chunk-Y5UWFH6Y.cjs} +91 -91
  47. package/dist/{chunk-5IJENZOG.cjs.map → chunk-Y5UWFH6Y.cjs.map} +1 -1
  48. package/dist/index.cjs +33 -33
  49. package/dist/index.d.cts +20 -6
  50. package/dist/index.d.ts +20 -6
  51. package/dist/index.js +5 -5
  52. package/dist/loader.css +1 -1
  53. package/dist/present.cjs +5 -5
  54. package/dist/present.d.cts +2 -2
  55. package/dist/present.d.ts +2 -2
  56. package/dist/present.js +5 -5
  57. package/dist/three.cjs +6 -6
  58. package/dist/three.d.cts +2 -2
  59. package/dist/three.d.ts +2 -2
  60. package/dist/three.js +2 -2
  61. package/dist/{types-CavnUvd5.d.ts → types-B6zDTU2V.d.cts} +14 -1
  62. package/dist/{types-CavnUvd5.d.cts → types-B6zDTU2V.d.ts} +14 -1
  63. package/docs/api.md +15 -4
  64. package/docs/dsl.md +7 -5
  65. package/llms-full.txt +95 -18
  66. package/package.json +1 -1
  67. package/skills/pixi-effects/SKILL.md +3 -3
  68. package/skills/pixi-effects/reference/cheatsheet.md +7 -6
  69. package/skills/pixi-effects/reference/pitfalls.md +3 -0
  70. package/skills/pixi-effects/reference/recipes.md +60 -0
  71. package/skills/pixi-effects/template.html +6 -6
  72. package/dist/Composition-NDXKQHB4.js +0 -7
  73. package/dist/Composition-OLH7RC5Z.cjs +0 -16
  74. package/dist/chunk-67LG7MN5.js.map +0 -1
  75. package/dist/chunk-FVZEBYLW.cjs.map +0 -1
  76. package/dist/chunk-GL2AQFLQ.cjs.map +0 -1
  77. package/dist/chunk-ITDYFBB6.js.map +0 -1
  78. package/dist/chunk-LEECUCTG.cjs.map +0 -1
  79. package/dist/chunk-Y253JQOH.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.36.0
4
+
5
+ **Vertical writing, `check` that sees pictures over text, holes in paths, and the fixes found by 18 fresh authors (the claims that were verified on the real build)**
6
+
7
+ - **New: vertical writing (tategaki).** `animateText(..., { vertical: true })` and `splitText(..., { vertical: true })`: characters go down a column and a new line is a new column to the left (`x` is the right edge of the first column). 、。 sit at the upper right of their cell, small kana a little up and right, and the long vowel mark, dashes, waves and brackets are turned a quarter clockwise (`rotation: 90`); pieces carry `cx` / `cy` / `rotation`. No tate-chu-yoko and no automatic line breaking.
8
+ - **New: `check` lists an opaque shape or image drawn over part of a text, and a thin line drawn over its body** (review list, with the other overlaps; a shadow, glow, ring or backdrop is not reported), **`check --at … --crop X,Y,W,H`** writes each picture as that part of the canvas, enlarged up to 4x, and **`help`** finds a heading written with spaces (`motionBlur`), a table row (`path`) and a call (`steps`).
9
+ - **New: `format: { time: 'hh:mm' }`** (also `'hh:mm:ss'`, `'h:mm:ss'`, `'mm:ss'`, `'m:ss'`) prints a counter's value, in seconds, as a clock; **`resolution`** on a text layer (1–8) draws it at N× the pixels so text inside a group scaled up N× stays sharp; **image `scaleMode: 'nearest'`** for pixel art; **`inspectAudio`** gives each source `belowMixDb` (how far below the mix around its loudest moment it is) and `check` gives ONE note when short sfx sit more than 12 dB under the mix ("buried", naming the worst).
10
+ - **Guide demos load this repository's build** (`../../dist/…`), like the gallery pages, so a demo shows the code of the same commit.
11
+ - **Fixed: `initial: { fill }` on a text layer was dropped without a word** (only a `fill` keyframe worked). It is now the colour from the start (it wins over `style.fill`).
12
+ - **Fixed: a filter option set to `undefined` (a misspelt variable) overwrote the filter's own default**: `{ type: 'bloom', strength: undefined }` made `movie.init` fail with "Cannot read properties of undefined (reading 'x')". The key is now left out and a warning names the filter and the options.
13
+ - **`check --at`** no longer leaves the pictures of an earlier run in `frames/` (it cleared nothing, so an old frame could be taken for the current one). The music read-back no longer says "ends early" for a track that is a few millionths of a beat shorter (decimal note lengths add up with float noise).
14
+ - **`pixi-effects new --size`** sizes the shown page for a tall or square movie (it was 960 px wide, so a 9:16 page was 1700 px tall).
15
+ - **New: `fillRule: 'evenodd'` on a `path`** (a donut written as two circles in one `d` was a solid disc: sub-paths were never holes). The biggest sub-path is the shape, every other one is cut out of it, and the stroke goes round all of them.
16
+ - **Changed: a morph between two outlines with the same number of corners is paired corner to corner** (it resampled both to 128 points along the edges, so a morph between two quads whose edges are not in proportion rounded the corners half way). Outlines with different corner counts, and curves, behave as before.
17
+ - Docs: a keyframe's `from` holds from the start of the layer and beats `initial` (cheatsheet, pitfall 114: no warning, because `initial` = the resting value and `from` = where it starts is what the recipes do); a path's `x` / `y`, when given, place its middle.
18
+ - Docs: `--out DIR` writes straight into DIR (no `<name>/` inside); pitfalls 112 (text under a scaled-up parent is soft) and 113 (an `sfx` lasts at most 10 s, a `music` layer plays 16 tracks).
19
+ - Gallery and guide: 59 pieces (18 new, 17 swapped, 11 removed); the guide no longer shows gallery pieces (its demos are built from its own recipes, `scripts/build-guide-demos.mjs`); `scripts/gallery-swap.mjs` also adds (`--add`) and removes (`--remove`).
20
+
21
+ ## 0.35.0
22
+
23
+ **The player's seek-bar preview (`thumbnails: true`, off by default) and `movie.thumbnail()`**
24
+
25
+ - **`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.
26
+ - **`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).
27
+ - **`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.
28
+ - 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).
29
+
3
30
  ## 0.34.0
4
31
 
5
32
  **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.36.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.36.0/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.36.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.36.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.36.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.36.0/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.36.0/dist/Controller.js",
64
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.36.0/dist/present.js"
65
65
  }
66
66
  }
67
67
  </script>
@@ -7,7 +7,7 @@
7
7
  * It starts a private static server and a private headless Chrome (nothing shared with other sessions), opens the page,
8
8
  * waits for `window.__ready === true`, then checks: console warnings / errors, `movie.inspect` over the whole timeline
9
9
  * (grouped), `movie.inspectAudio()`, a contact sheet (a PNG you can look at), and a real export (`movie.render`) that it
10
- * decodes again: video size / length, audio length, loudness per second, silence. Output goes to `check-out/<name>/`
10
+ * decodes again: video size / length, audio length, loudness per second, silence. Output goes to `check-out/<name>/` (`--out DIR` writes straight into DIR, with no <name>/ inside it)
11
11
  * (`sheet.png`, `<name>.<format>`, `report.json`; a presentation with `stops` also gets `stops.png`, one picture per stop) and a short summary is printed. Exit code 0 = nothing to fix, 1 = problems.
12
12
  *
13
13
  * Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or --chrome PATH / CHROME=PATH). No npm dependencies.
@@ -15,6 +15,7 @@
15
15
  *
16
16
  * Options: --draft (the export is a draft: half size, low quality, no motion blur: a much smaller file; the drawing itself is not faster) · --at LIST (pictures at moments you name: 3.5, 50%, f120, title@end → frames/*.png and at.png) ·
17
17
  * --query "a=1&b=2" (added to the page address: for a page that reads it, such as one video of a batch) ·
18
+ * --crop X,Y,W,H (with --at: each picture is that part of the canvas, in canvas pixels, enlarged up to 4x so a small figure can be judged) ·
18
19
  * --onion A:B (one picture of the movement between A and B seconds, frames overlaid: onion.png) ·
19
20
  * --strict (text overlaps and stops where the picture is still changing fail the check; by default they are only listed for review) · --out DIR · --frames N (contact sheet tiles, default 12) · --formats mp4,webm (default mp4) · --no-export ·
20
21
  * --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
@@ -44,8 +45,8 @@ export function formatMusic(sources) {
44
45
  out.push(` music ${s.layer} · ${num(m.bpm)} bpm · ${num(m.beats)} beats = ${num(m.bars)} bars of ${m.meter} beats${assumed} · ${num(Math.round(m.seconds * 10) / 10)} s (+ ${num(m.tail)} s tail)`);
45
46
  for (const t of m.tracks) {
46
47
  const whole = Math.abs(t.bars - Math.round(t.bars)) < 1e-6;
47
- // a track shorter than the longest stops early (whatever its length in bars); a short BAR can only be the longest track's, and only the author's meter can say so
48
- const note = t.beats < m.beats - 1e-6 ? ` (ends early: silent from beat ${num(t.beats)})` : !whole && m.meterSet !== false ? ' (not a whole number of bars: a bar is short)' : '';
48
+ // a track shorter than the longest stops early (whatever its length in bars; decimal note lengths add up with float noise, so a gap under 1e-4 of a beat is none); a short BAR can only be the longest track's, and only the author's meter can say so
49
+ const note = t.beats < m.beats - 1e-4 ? ` (ends early: silent from beat ${num(t.beats)})` : !whole && m.meterSet !== false ? ' (not a whole number of bars: a bar is short)' : '';
49
50
  const cell = names => { const n = names.match(/\[[^\]]*\]\S*|\S+/g) ?? []; return n.length === 0 ? '–' : n.length > 4 ? `${n.slice(0, 4).join(' ')} …` : n.join(' '); };
50
51
  const shown = t.perBar.slice(0, 16).map(cell).join(' | ');
51
52
  out.push(`${pad}${t.inst.padEnd(6)} ${num(t.bars)} bar${t.bars === 1 ? '' : 's'}${note} | ${shown}${t.perBar.length > 16 ? ` | … (${t.perBar.length - 16} more)` : ''}`);
@@ -79,10 +80,10 @@ export function findChrome(env = process.env, exists = fs.existsSync, platform =
79
80
  return candidates.find(c => c && exists(c)) ?? null;
80
81
  }
81
82
 
82
- export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--onion 1:3] [--query a=1&b=2] [--var name=value] [--size 1920x1080,1080x1920] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
83
+ export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--crop X,Y,W,H] [--onion 1:3] [--query a=1&b=2] [--var name=value] [--size 1920x1080,1080x1920] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
83
84
 
84
85
  export function parseArgs(argv) {
85
- const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, draft: false, onion: null, query: null, vars: [], sizes: [], help: false };
86
+ const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, crop: null, draft: false, onion: null, query: null, vars: [], sizes: [], help: false };
86
87
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
87
88
  for (let i = 0; i < argv.length; i++) {
88
89
  const a = argv[i];
@@ -95,6 +96,11 @@ export function parseArgs(argv) {
95
96
  else if (a === '--draft') o.draft = true;
96
97
  else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
97
98
  else if (a === '--at') o.at = need(i++, a);
99
+ else if (a === '--crop') {
100
+ const m = need(i++, a).split(',').map(Number);
101
+ if (m.length !== 4 || !m.every(Number.isFinite) || m[0] < 0 || m[1] < 0 || m[2] < 1 || m[3] < 1) throw new Error(`--crop must look like 100,200,400,300 (x, y, width, height in canvas pixels, width and height at least 1), got "${argv[i]}"`);
102
+ o.crop = { x: Math.round(m[0]), y: Math.round(m[1]), width: Math.round(m[2]), height: Math.round(m[3]) };
103
+ }
98
104
  else if (a === '--var') o.vars.push(parseVar(need(i++, a)));
99
105
  else if (a === '--size') o.sizes.push(...parseSizes(need(i++, a)));
100
106
  else if (a === '--onion') {
@@ -109,6 +115,7 @@ export function parseArgs(argv) {
109
115
  else if (!o.page) o.page = a;
110
116
  else throw new Error(`unexpected argument ${a}`);
111
117
  }
118
+ if (o.crop && !o.at) throw new Error('--crop crops the pictures of --at: give --at too (for example --at 3.5 --crop 100,200,400,300)');
112
119
  return o;
113
120
  }
114
121
 
@@ -377,6 +384,7 @@ export async function runCheck(opts, log = console.log) {
377
384
  if (r.error) throw new Error(r.error.replace(/^pixi-effects: /, ''));
378
385
  atList = r.at;
379
386
  }
387
+ if (opts.crop && (opts.crop.x + opts.crop.width > info.width || opts.crop.y + opts.crop.height > info.height)) throw new Error(`--crop ${opts.crop.x},${opts.crop.y},${opts.crop.width},${opts.crop.height} goes past the canvas (${info.width}×${info.height})`);
380
388
  if (opts.onion && opts.onion[1] > info.duration + 1e-9) throw new Error(`--onion ${opts.onion[0]}:${opts.onion[1]} goes past the end: the movie is ${info.duration} s`);
381
389
 
382
390
  const rv = await cdp.eval(`movie.review(${JSON.stringify({ strict: !!opts.strict })})`);
@@ -398,8 +406,16 @@ export async function runCheck(opts, log = console.log) {
398
406
  const wanted = atList;
399
407
  const framesDir = path.join(outDir, 'frames');
400
408
  fs.mkdirSync(framesDir, { recursive: true });
409
+ for (const old of fs.readdirSync(framesDir)) if (old.endsWith('.png')) fs.rmSync(path.join(framesDir, old)); // the pictures of an earlier run: frames/ shows this run's moments only
410
+ // --crop: that part of the canvas, enlarged by a whole factor (up to 4x, the longer side to about 800 px) with no smoothing, so small figures and fine lines can be judged
411
+ const crop = opts.crop ? { ...opts.crop, scale: Math.max(1, Math.min(4, Math.floor(800 / Math.max(opts.crop.width, opts.crop.height)))) } : null;
412
+ const cropped = [];
401
413
  for (const w of wanted) {
402
- const png = await cdp.eval(`movie.snapshot(${w.frame}, { as: 'dataURL' })`);
414
+ let png = await cdp.eval(`movie.snapshot(${w.frame}, { as: 'dataURL' })`);
415
+ if (crop) {
416
+ png = await cdp.eval(`(async (url, c) => { const img = new Image(); img.src = url; await img.decode(); const cv = document.createElement('canvas'); cv.width = c.width * c.scale; cv.height = c.height * c.scale; const g = cv.getContext('2d'); g.imageSmoothingEnabled = false; g.drawImage(img, c.x, c.y, c.width, c.height, 0, 0, cv.width, cv.height); return cv.toDataURL('image/png'); })(${JSON.stringify(png)}, ${JSON.stringify(crop)})`);
417
+ cropped.push(png);
418
+ }
403
419
  fs.writeFileSync(path.join(framesDir, `${w.label}.png`), Buffer.from(png.slice(png.indexOf(',') + 1), 'base64'));
404
420
  // depth of field: the blur (px) each threeD layer has at this moment, so a focus pull can be read as numbers (0 = sharp)
405
421
  const rows = await cdp.eval(`movie.inspect(${w.frame}).then(r => r.layers.filter(l => l.depthBlur !== undefined).map(l => [l.path, l.depthBlur]))`).catch(() => []);
@@ -408,12 +424,15 @@ export async function runCheck(opts, log = console.log) {
408
424
  const lit = await cdp.eval(`movie.inspect(${w.frame}).then(r => r.layers.filter(l => l.light !== undefined).map(l => [l.path, l.light]))`).catch(() => []);
409
425
  if (lit.length) w.light = Object.fromEntries(lit);
410
426
  }
411
- const atSheet = await cdp.eval(`movie.contactSheet({ frames: ${JSON.stringify(wanted.map(w => w.frame))}, as: 'dataURL' })`);
427
+ const atSheet = crop
428
+ ? await cdp.eval(`(async (urls) => { const imgs = await Promise.all(urls.map(async u => { const i = new Image(); i.src = u; await i.decode(); return i; })); const cols = Math.min(imgs.length, 3), rows = Math.ceil(imgs.length / cols), w = imgs[0].width, h = imgs[0].height; const cv = document.createElement('canvas'); cv.width = cols * w; cv.height = rows * h; const g = cv.getContext('2d'); imgs.forEach((im, k) => g.drawImage(im, (k % cols) * w, Math.floor(k / cols) * h)); return cv.toDataURL('image/png'); })(${JSON.stringify(cropped)})`)
429
+ : await cdp.eval(`movie.contactSheet({ frames: ${JSON.stringify(wanted.map(w => w.frame))}, as: 'dataURL' })`);
412
430
  const atFile = path.join(outDir, 'at.png');
413
431
  fs.writeFileSync(atFile, Buffer.from(atSheet.slice(atSheet.indexOf(',') + 1), 'base64'));
414
432
  report.files.at = shown(atFile);
415
433
  report.files.frames = shown(framesDir);
416
434
  report.at = wanted;
435
+ if (crop) report.crop = crop;
417
436
  }
418
437
 
419
438
  // --onion A:B: one picture of the movement between A and B seconds (8 frames overlaid, the later the stronger)
@@ -105,9 +105,19 @@ export function searchDocs(root, word) {
105
105
  const heading = s.heading.toLowerCase();
106
106
  const body = s.lines.join('\n').toLowerCase();
107
107
  if (!tokens.every(t => body.includes(t))) continue;
108
- const inHeading = tokens.every(t => heading.includes(t));
108
+ const flat = (x) => x.replace(/[^a-z0-9]/g, ''); // "motionBlur" is the heading "Motion blur"
109
+ const inHeading = tokens.every(t => heading.includes(t) || (flat(t).length > 2 && flat(heading).includes(flat(t))));
109
110
  const count = tokens.reduce((n, t) => n + body.split(t).length - 1, 0);
110
- scored.push({ s, score: (inHeading ? 1000 : 0) + Math.min(count, 50) + bonus });
111
+ // the word as a table row (`| \`path\` | …`, the first cell) or as a call in code (`steps(n)`) says the section is ABOUT it, not that it only mentions it
112
+ const row = tokens.some(t => s.lines.some(l => new RegExp(`^\\|\\s*\\*{0,2}\\x60${t.replace(/[^a-z0-9]/g, '')}\\x60`).test(l.toLowerCase())));
113
+ const calls = (t) => { // the word followed by a bracket INSIDE code: a fenced block or an inline `span` (not prose that has a bracket after it)
114
+ const w = t.replace(/[^a-z0-9]/g, ''), re = new RegExp(`\\b${w}\\s*\\(`);
115
+ const fences = [...body.matchAll(/```[\s\S]*?```/g)].map(m => m[0]);
116
+ const inline = [...body.replace(/```[\s\S]*?```/g, ' ').matchAll(/`([^`\n]+)`/g)].map(m => m[1]);
117
+ return [...fences, ...inline].some(code => re.test(code));
118
+ };
119
+ const call = tokens.some(calls);
120
+ scored.push({ s, score: (inHeading ? 1000 : 0) + (row ? 1100 : 0) + (call ? 300 : 0) + Math.min(count, 50) + bonus });
111
121
  }
112
122
  }
113
123
  scored.sort((a, b) => b.score - a.score);
@@ -155,6 +165,8 @@ export function newPage(name, { cwd = process.cwd(), root = PACKAGE_ROOT, force
155
165
  .replace('<title>pixi-effects video</title>', `<title>${base.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</title>`)
156
166
  // the size sits in two places (the canvas attributes and W / H) and the length in one; the poster follows DURATION
157
167
  .replace('<canvas id="stage" width="1280" height="720">', size ? `<canvas id="stage" width="${size.width}" height="${size.height}">` : '<canvas id="stage" width="1280" height="720">')
168
+ // a tall or square page would be 960 px wide and far taller than a laptop screen: its width follows a height of about 760 px
169
+ .replace('.stage { position: relative; width: min(960px, 100%); }', size ? `.stage { position: relative; width: min(${Math.min(960, Math.round(760 * size.width / size.height))}px, 100%); }` : '.stage { position: relative; width: min(960px, 100%); }')
158
170
  .replace('const W = 1280, H = 720, FPS = 30, DURATION = 6;', `const W = ${size ? size.width : 1280}, H = ${size ? size.height : 720}, FPS = 30, DURATION = ${duration ?? 6};`);
159
171
  fs.writeFileSync(file, html);
160
172
  return { file };
@@ -1,5 +1,5 @@
1
1
  import { Container } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape } from './types-CavnUvd5.js';
2
+ import { S as SequenceSpec, C as CompositionShape } from './types-B6zDTU2V.js';
3
3
 
4
4
  /**
5
5
  * Resolves a routed keyframe path to a concrete tween target.
@@ -1,5 +1,5 @@
1
1
  import { Container } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape } from './types-CavnUvd5.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape } from './types-B6zDTU2V.cjs';
3
3
 
4
4
  /**
5
5
  * Resolves a routed keyframe path to a concrete tween target.
@@ -0,0 +1,7 @@
1
+ export { CompositionSequence } from './chunk-AZ6AINSW.js';
2
+ import './chunk-G3DX65HO.js';
3
+ import './chunk-WIIU42FH.js';
4
+ import './chunk-2JFFKHXO.js';
5
+ import './chunk-BXOCEZ4U.js';
6
+ //# sourceMappingURL=Composition-BMRCE5FH.js.map
7
+ //# sourceMappingURL=Composition-BMRCE5FH.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-NDXKQHB4.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-BMRCE5FH.js"}
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ var chunkY5UWFH6Y_cjs = require('./chunk-Y5UWFH6Y.cjs');
4
+ require('./chunk-KJSEQVBV.cjs');
5
+ require('./chunk-NEUPEENI.cjs');
6
+ require('./chunk-42ML5AKN.cjs');
7
+ require('./chunk-TWZNOUM7.cjs');
8
+
9
+
10
+
11
+ Object.defineProperty(exports, "CompositionSequence", {
12
+ enumerable: true,
13
+ get: function () { return chunkY5UWFH6Y_cjs.CompositionSequence; }
14
+ });
15
+ //# sourceMappingURL=Composition-XRLYR6GL.cjs.map
16
+ //# sourceMappingURL=Composition-XRLYR6GL.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-OLH7RC5Z.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-XRLYR6GL.cjs"}
@@ -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;