pixi-effects 0.30.1 → 0.31.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 (62) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +6 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +42 -2
  5. package/ai/tools/render.mjs +15 -2
  6. package/ai/tools/varArgs.mjs +75 -0
  7. package/dist/{Base-CB1_FPZD.d.cts → Base-BKagtWc0.d.cts} +1 -1
  8. package/dist/{Base-Ne74ztNl.d.ts → Base-jRiMHcWT.d.ts} +1 -1
  9. package/dist/Composition-DQCPJPFE.js +7 -0
  10. package/dist/{Composition-RJGA7KDU.js.map → Composition-DQCPJPFE.js.map} +1 -1
  11. package/dist/Composition-V2XYSW6P.cjs +16 -0
  12. package/dist/{Composition-MUKR3FYD.cjs.map → Composition-V2XYSW6P.cjs.map} +1 -1
  13. package/dist/Controller.cjs +42 -1
  14. package/dist/Controller.cjs.map +1 -1
  15. package/dist/Controller.d.cts +10 -2
  16. package/dist/Controller.d.ts +10 -2
  17. package/dist/Controller.js +42 -1
  18. package/dist/Controller.js.map +1 -1
  19. package/dist/{Movie-DdnpLpw1.d.cts → Movie-BH81stkb.d.cts} +90 -3
  20. package/dist/{Movie-C2rZFkrO.d.ts → Movie-CfFPItoL.d.ts} +90 -3
  21. package/dist/{chunk-A3Q3SDSH.js → chunk-BGMCZA36.js} +3 -3
  22. package/dist/{chunk-A3Q3SDSH.js.map → chunk-BGMCZA36.js.map} +1 -1
  23. package/dist/{chunk-ZVUO325O.cjs → chunk-DJ5B7IKT.cjs} +9 -3
  24. package/dist/chunk-DJ5B7IKT.cjs.map +1 -0
  25. package/dist/{chunk-WHNADXXG.cjs → chunk-L73KRFXI.cjs} +61 -61
  26. package/dist/{chunk-WHNADXXG.cjs.map → chunk-L73KRFXI.cjs.map} +1 -1
  27. package/dist/{chunk-ZAXYPXKW.js → chunk-MMTESMJ5.js} +9 -4
  28. package/dist/chunk-MMTESMJ5.js.map +1 -0
  29. package/dist/{chunk-CRQFDY7E.js → chunk-UTIBYMIO.js} +461 -56
  30. package/dist/chunk-UTIBYMIO.js.map +1 -0
  31. package/dist/{chunk-HREBQAY4.cjs → chunk-X3WHETQ3.cjs} +485 -80
  32. package/dist/chunk-X3WHETQ3.cjs.map +1 -0
  33. package/dist/index.cjs +31 -31
  34. package/dist/index.d.cts +5 -5
  35. package/dist/index.d.ts +5 -5
  36. package/dist/index.js +3 -3
  37. package/dist/loader.css +1 -1
  38. package/dist/present.cjs +3 -3
  39. package/dist/present.d.cts +2 -2
  40. package/dist/present.d.ts +2 -2
  41. package/dist/present.js +3 -3
  42. package/dist/three.cjs +5 -5
  43. package/dist/three.d.cts +2 -2
  44. package/dist/three.d.ts +2 -2
  45. package/dist/three.js +1 -1
  46. package/dist/{types-BzIB65Vk.d.cts → types-CK_crNAI.d.cts} +2 -0
  47. package/dist/{types-BzIB65Vk.d.ts → types-CK_crNAI.d.ts} +2 -0
  48. package/docs/api.md +28 -1
  49. package/docs/dsl.md +32 -1
  50. package/llms-full.txt +118 -3
  51. package/package.json +83 -20
  52. package/skills/pixi-effects/SKILL.md +1 -1
  53. package/skills/pixi-effects/reference/cheatsheet.md +3 -0
  54. package/skills/pixi-effects/reference/pitfalls.md +4 -0
  55. package/skills/pixi-effects/reference/recipes.md +52 -0
  56. package/skills/pixi-effects/template.html +6 -6
  57. package/dist/Composition-MUKR3FYD.cjs +0 -16
  58. package/dist/Composition-RJGA7KDU.js +0 -7
  59. package/dist/chunk-CRQFDY7E.js.map +0 -1
  60. package/dist/chunk-HREBQAY4.cjs.map +0 -1
  61. package/dist/chunk-ZAXYPXKW.js.map +0 -1
  62. package/dist/chunk-ZVUO325O.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0
4
+
5
+ **Added: variables, and a composition that is built again (design: `docs/superpowers/specs/2026-10-11-variables-0.31-design.md`)**
6
+
7
+ - **`movie.init({ vars: { title: 'NORD', accent: '#e5399a', shift: 0 } })`.** A number is a plain name in an expression (`x: 'W/2 + shift'`); any value is `{title}` inside a string anywhere in the composition (`text`, a colour, `asset`, a layer name). Not `$`: that is a price. Reserved (refused, said once): `w h W H GW GH t d T contain cover value`, the expression functions and `PI`. `at` and `duration` stay plain numbers: a variable moves values, not timing.
8
+ - **`movie.setVars()`, `movie.resize(w, h)`, `movie.rebuild({ composition, vars, width, height })`, `movie.vars`, the `'rebuilt'` event.** The composition is built off the stage and swapped in whole, at the same frame; a playing movie keeps playing, a poster stays; a failure keeps the old composition (`error` with `where: 'rebuild'`, and the promise rejects); calls that overlap are joined (the latest values are built once more). Each returns `{ reason, ms, audio: 'reused' | 'mixed' | 'none', frame, playing }`. The sound is mixed again **only if it changed** (a mix of a film takes 0.6 to 0.8 s, the rest about 20 ms). After `resize()` the movie's size beats a `width` / `height` the composition names. The `Controller` redraws its stop marks.
9
+ - **Warnings at `init` (and when `rebuild({ composition })` replaces the spec):** a variable nothing reads, and a `{name}` nobody declared (with the declared name it looks like). `setVars` of an undeclared name is ignored with a did-you-mean.
10
+ - **`pixi-effects check` / `render` take `--var name=value` (repeatable) and `--size WxH`.** The tool builds the page again once it is ready, so a page needs nothing for it; a number variable is given a number, a name the page did not declare stops the run with the nearest one. `check --size 1920x1080,1080x1920` writes a contact sheet and a layout review per size (`sheet.png`, `sheet-1080x1920.png`) and a `vars` and a `sizes` line; `render --var lang=ja --size 1080x1920` writes the variant.
11
+ - **Fixed with it: `play()`'s first frame could land one frame BEHIND where it was started** (the animation frame's time is a little earlier than the start time), so a pause and play in a row walked the playhead back; and rebuilds that came faster than a frame held a playing movie still. A rebuild that leaves the sound alone no longer stops anything.
12
+ - **`movie.loop` (and `init({ loop })`, the `loopchange` event) and the Controller's loop button.** At the end of an ordinary `play()` the movie goes back to the start and plays on: no `ended`, no `pause` / `play` events at the wrap, the sound starts again. Default `false`, like a `<video>`; `playUntil()` and `render()` ignore it. The bar has a loop button next to the volume and the `L` key (`Controller` option `loopButton`, default true).
13
+ - **Fixed: a text outline no longer spikes.** `style.stroke: { color, width }` was drawn with PixiJS's default `miter` join, which grows spikes on the sharp corners of W, V, A, k (an AI wrote this often). An outline that names no `join` is round-joined now; `join: 'miter'` / `'bevel'` still say what they say. Pitfall 106; `docs/dsl.md` (Text).
14
+ - Recipes `variants` (a language and a colour as variables) and `liquid-layout` (`ResizeObserver` + `rebuild`); pitfalls 104 and 105; `docs/dsl.md` "Variables", `docs/api.md` "Movie variables and rebuilding".
15
+
16
+ **Known limits.** While a movie plays, the new composition is built beside the old one (it keeps playing); when the sound is unchanged nothing stops, when it changed the sound starts again from the same frame (a short gap); a rebuild is the whole composition, not one property (measured in the spike: evaluating the expressions is about 40 µs, so a finer recompute buys little).
17
+
3
18
  ## 0.30.1
4
19
 
5
20
  **Docs**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.30.1`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
3
+ > **Status**: experimental — current release `0.31.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.30.1/dist/index.js",
53
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/Controller.js"
52
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.31.0/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.31.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.30.1/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.31.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.30.1/dist/three.js"
80
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.31.0/dist/three.js"
81
81
  }
82
82
  }
83
83
  </script>
@@ -186,6 +186,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
186
186
 
187
187
  - [**DSL reference**](./docs/dsl.md) — composition, sequences, 3D layers & camera, three.js layer, keyframes, expressions, filters
188
188
  - [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/present`, `pixi-effects/three`, events, render options
189
+ - [**Interactive movies**](./examples/interactive/) — nine live samples of a movie you change while it plays (variables, `setVars`, `rebuild`, loop): rock paper scissors, a scrolling city, a tune from four chords, a card for anyone, choose your ending … ([the guide chapter](./site/guide/interactive.md))
189
190
  - [**Examples**](./examples/) — runnable HTML files (read in order):
190
191
  - `01-hello.html` — minimum viable composition
191
192
  - `02-keyframes.html` — keyframes, easings, expressions
@@ -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.30.1/dist/index.js",
63
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/Controller.js",
64
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/present.js"
62
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.31.0/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.31.0/dist/Controller.js",
64
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.31.0/dist/present.js"
65
65
  }
66
66
  }
67
67
  </script>
@@ -25,6 +25,7 @@ import fs from 'node:fs';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import { fileURLToPath, pathToFileURL } from 'node:url';
28
+ import { parseVar, parseSizes, applyToPage } from './varArgs.mjs';
28
29
 
29
30
  // ───────────────────────────── pure helpers (unit-tested) ─────────────────────────────
30
31
 
@@ -78,10 +79,10 @@ export function findChrome(env = process.env, exists = fs.existsSync, platform =
78
79
  return candidates.find(c => c && exists(c)) ?? null;
79
80
  }
80
81
 
81
- 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] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
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)';
82
83
 
83
84
  export function parseArgs(argv) {
84
- 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, help: false };
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 };
85
86
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
86
87
  for (let i = 0; i < argv.length; i++) {
87
88
  const a = argv[i];
@@ -94,6 +95,8 @@ export function parseArgs(argv) {
94
95
  else if (a === '--draft') o.draft = true;
95
96
  else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
96
97
  else if (a === '--at') o.at = need(i++, a);
98
+ else if (a === '--var') o.vars.push(parseVar(need(i++, a)));
99
+ else if (a === '--size') o.sizes.push(...parseSizes(need(i++, a)));
97
100
  else if (a === '--onion') {
98
101
  const m = need(i++, a).match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
99
102
  if (!m || !(Number(m[1]) < Number(m[2]))) throw new Error(`--onion must look like 1:3 (seconds, from before to), got "${argv[i]}"`);
@@ -353,6 +356,13 @@ export async function runCheck(opts, log = console.log) {
353
356
  report.problems.push(`the page did not become ready (window.__ready !== true within 60 s${info?.hasMovie ? '' : '; window.movie is missing'})`);
354
357
  return finish(report, outDir, log);
355
358
  }
359
+ // --var / --size: the page is rebuilt once with what the command line gave (the first size; the others are looked at after the export)
360
+ if (opts.vars.length || opts.sizes.length) {
361
+ report.rebuilt = await applyToPage(cdp, opts.vars, opts.sizes[0] ?? null);
362
+ info = await cdp.eval(INFO).catch(() => info) ?? info;
363
+ report.logs = [...new Set([...(info?.logs ?? []), ...consoleLines])];
364
+ }
365
+ report.vars = await cdp.eval('JSON.parse(JSON.stringify((window.movie && window.movie.vars) || {}))').catch(() => ({}));
356
366
  Object.assign(report, { width: info.width, height: info.height, frameRate: info.frameRate, totalFrames: info.totalFrames, duration: info.duration, hasAudio: info.hasAudio });
357
367
  if (report.logs.length) report.problems.push(`${report.logs.length} console warning(s) / error(s) — each says what to change`);
358
368
 
@@ -500,6 +510,30 @@ export async function runCheck(opts, log = console.log) {
500
510
  report.exports.push(ex);
501
511
  }
502
512
  }
513
+
514
+ // --size A,B,…: the same movie at the other sizes, a layout review and a contact sheet each (the export above was the first size)
515
+ if (opts.sizes.length) {
516
+ report.sizes = [{ size: `${info.width}x${info.height}`, sheet: report.files.sheet, issues: report.inspect.issues, review: report.inspect.review }];
517
+ for (const size of opts.sizes.slice(1)) {
518
+ left();
519
+ const label = `${size.width}x${size.height}`;
520
+ await applyToPage(cdp, [], size);
521
+ const r = await cdp.eval(`movie.review(${JSON.stringify({ strict: !!opts.strict })})`);
522
+ const sheetAt = await cdp.eval(`movie.contactSheet({ count: ${opts.frames}, as: 'dataURL' })`);
523
+ const file = path.join(outDir, `sheet-${label}.png`);
524
+ fs.writeFileSync(file, Buffer.from(sheetAt.slice(sheetAt.indexOf(',') + 1), 'base64'));
525
+ report.files[`sheet ${label}`] = shown(file);
526
+ report.sizes.push({ size: label, sheet: shown(file), issues: r.problems, review: r.review });
527
+ if (r.problems.length) report.problems.push(`${r.problems.length} kind(s) of layout issue from movie.inspect at ${label}`);
528
+ }
529
+ }
530
+ // what the page said while the other sizes were built (the first list was taken before)
531
+ const later = await cdp.eval('window.__logs || []').catch(() => []);
532
+ const fresh = [...new Set([...later, ...consoleLines])].filter(l => !report.logs.includes(l));
533
+ if (fresh.length) {
534
+ report.logs.push(...fresh);
535
+ report.problems.push(`${fresh.length} more console warning(s) / error(s) while the page was built at the other size(s) — each says what to change`);
536
+ }
503
537
  return finish(report, outDir, log);
504
538
  } finally {
505
539
  try { chromeProc?.kill(); } catch { /* already gone */ }
@@ -519,6 +553,7 @@ function finish(report, outDir, log) {
519
553
  if (report.ready) L.push(` page ready in ${report.readySeconds} s · ${report.width}×${report.height} · ${report.frameRate} fps · ${report.totalFrames} frames (${report.duration} s)${report.hasAudio ? ' · audio' : ''}`);
520
554
  else L.push(' page NOT READY');
521
555
  if (report.loadStages) L.push(` loading ${formatLoadStages(report.loadStages)}`);
556
+ if (report.vars && Object.keys(report.vars).length) L.push(` vars ${Object.entries(report.vars).map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 40 ? v.slice(0, 37) + '…' : v}`).join(', ')}${report.rebuilt ? ' (after --var / --size: the page was built again)' : ''}`);
522
557
  L.push(` warnings ${report.logs?.length ? report.logs.length + ' — ' + report.logs.slice(0, 5).map(l => l.length > 220 ? l.slice(0, 217) + '…' : l).join('\n ') + (report.logs.length > 5 ? `\n … ${report.logs.length - 5} more (report.json)` : '') : 'none'}`);
523
558
  if (report.inspect) {
524
559
  const g = report.inspect.issues;
@@ -533,6 +568,11 @@ function finish(report, outDir, log) {
533
568
  if (rv.length > 4) L.push(` … ${rv.length - 4} more (report.json)`);
534
569
  }
535
570
  }
571
+ if (report.sizes) {
572
+ const g = report.sizes;
573
+ L.push(` sizes ${g.map(x => `${x.size} ${x.issues.length ? x.issues.length + ' layout issue kind(s)' : 'no layout issue'}`).join(' · ')}${g.length > 1 ? ' (one contact sheet each: sheet.png, sheet-<size>.png)' : ''}`);
574
+ for (const x of g) for (const i of x.issues.slice(0, 3)) L.push(` - ${x.size}: ${i.message}`);
575
+ }
536
576
  for (const w of (report.at ?? []).filter(x => x.depthBlur)) {
537
577
  const shown = Object.entries(w.depthBlur).slice(0, 8).map(([k, px]) => `${k} ${px} px`).join(', ');
538
578
  L.push(` depth ${w.label} — ${shown}${Object.keys(w.depthBlur).length > 8 ? ', …' : ''} (blur of each threeD layer at that moment; 0 = sharp)`);
@@ -26,6 +26,7 @@ import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import { pathToFileURL } from 'node:url';
28
28
  import { findChrome, findRoot, serve, launchChrome, shown, sleep, LOAD_PRESENT } from './check.mjs';
29
+ import { parseVar, parseSizes, applyToPage } from './varArgs.mjs';
29
30
 
30
31
  const FORMATS = ['mp4', 'webm', 'mov', 'mkv', 'wav', 'ogg', 'pdf'];
31
32
  const SOUND_ONLY = ['wav', 'ogg'];
@@ -43,10 +44,10 @@ export function defaultOutput(page, format) {
43
44
  return path.resolve(path.basename(page).replace(/\.[^.]+$/, '') + '.' + format);
44
45
  }
45
46
 
46
- export const USAGE = 'usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv|pdf] [--range A:B | --scene NAME] [--scale S] [--draft] [--quality very-low|low|medium|high|very-high] [--query a=1] [--strict] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]';
47
+ export const USAGE = 'usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv|pdf] [--range A:B | --scene NAME] [--scale S] [--draft] [--quality very-low|low|medium|high|very-high] [--query a=1] [--var name=value] [--size 1080x1920] [--strict] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]';
47
48
 
48
49
  export function parseRenderArgs(argv) {
49
- const o = { range: null, scene: null, scale: null, draft: false, qualityGiven: false, page: null, out: null, format: null, quality: 'high', videoCodec: null, videoBitrate: null, audioCodec: null, allStops: false, motionBlur: null, shutter: null, query: null, strict: false, quiet: false, timeout: 900, root: null, chrome: null };
50
+ const o = { range: null, scene: null, scale: null, draft: false, qualityGiven: false, page: null, out: null, format: null, quality: 'high', videoCodec: null, videoBitrate: null, audioCodec: null, allStops: false, motionBlur: null, shutter: null, query: null, vars: [], size: null, strict: false, quiet: false, timeout: 900, root: null, chrome: null };
50
51
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
51
52
  for (let i = 0; i < argv.length; i++) {
52
53
  const a = argv[i];
@@ -81,6 +82,12 @@ export function parseRenderArgs(argv) {
81
82
  if (!(o.scale > 0 && o.scale <= 1)) throw new Error(`--scale must be above 0 and at most 1 (0.5 is half size), got "${argv[i]}"`);
82
83
  } else if (a === '--draft') o.draft = true;
83
84
  else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
85
+ else if (a === '--var') o.vars.push(parseVar(need(i++, a)));
86
+ else if (a === '--size') {
87
+ const sizes = parseSizes(need(i++, a));
88
+ if (sizes.length > 1) throw new Error('render writes one file: one --size (1080x1920); check takes several');
89
+ o.size = sizes[0];
90
+ }
84
91
  else if (a === '--all-stops') o.allStops = true;
85
92
  else if (a === '--strict') o.strict = true;
86
93
  else if (a === '--fail-on-warn') throw new Error('--fail-on-warn is now --strict (the same word as in check: a warning, or anything it would only list, fails the run)');
@@ -178,6 +185,12 @@ export async function runRender(opts, log = console.log, progress = () => {}) {
178
185
  result.problems.push(`the page did not become ready (window.__ready !== true${info?.hasMovie ? '' : '; window.movie is missing'})${result.logs.length ? ': ' + result.logs[0] : ''}`);
179
186
  return result;
180
187
  }
188
+ // --var / --size: the page is built again with them before anything is drawn
189
+ if (opts.vars.length || opts.size) {
190
+ await applyToPage(cdp, opts.vars, opts.size);
191
+ info = await cdp.eval(`(() => { const m = window.movie; return { logs: window.__logs || [], duration: m.duration, totalFrames: m.totalFrames, width: m.width, height: m.height, frameRate: m.frameRate }; })()`);
192
+ result.logs = [...new Set([...info.logs, ...consoleLines])];
193
+ }
181
194
  Object.assign(result, { duration: info.duration, frames: info.totalFrames, width: info.width, height: info.height, frameRate: info.frameRate });
182
195
 
183
196
  await cdp.eval(renderScript(opts.format, opts.quality, opts.videoCodec, opts.audioCodec, opts.motionBlur, opts.shutter, opts.allStops, { range: opts.range, scene: opts.scene, scale: opts.scale, draft: opts.draft, qualityGiven: opts.qualityGiven }, opts.videoBitrate));
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `--var name=value` and `--size WxH` for `pixi-effects check` and `render`: read here, matched against what the page declares
3
+ * (`movie.vars`), then the tool calls `movie.rebuild({ vars, width, height })` once the page is ready. The page needs nothing for this.
4
+ */
5
+
6
+ /** 'lang=en' → ['lang', 'en'] (the value is the rest of the text; the page decides whether it is a number). */
7
+ export function parseVar(text) {
8
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/.exec(text);
9
+ if (!m) throw new Error(`--var must look like name=value (lang=en, shift=40, accent=#e5399a), got "${text}"`);
10
+ return [m[1], m[2]];
11
+ }
12
+
13
+ /** '1920x1080,1080x1920' → [{ width, height }, …]. */
14
+ export function parseSizes(text) {
15
+ const bad = () => new Error(`--size must look like 1920x1080 (whole pixels; several with a comma: 1920x1080,1080x1920), got "${text}"`);
16
+ const out = String(text).split(',').map(part => {
17
+ const m = /^\s*(\d+)\s*[xX×]\s*(\d+)\s*$/.exec(part);
18
+ if (!m || Number(m[1]) < 1 || Number(m[2]) < 1) throw bad();
19
+ return { width: Number(m[1]), height: Number(m[2]) };
20
+ });
21
+ if (!out.length) throw bad();
22
+ return out;
23
+ }
24
+
25
+ function distance(a, b) {
26
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
27
+ for (let i = 1; i <= a.length; i++) {
28
+ let prev = row[0]; row[0] = i;
29
+ for (let j = 1; j <= b.length; j++) { const keep = row[j]; row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = keep; }
30
+ }
31
+ return row[b.length];
32
+ }
33
+ const nearestOf = (word, list) => {
34
+ let best = null;
35
+ for (const c of list) { const d = distance(word.toLowerCase(), c.toLowerCase()); if (d <= Math.max(2, Math.floor(c.length / 3)) && (!best || d < best.d)) best = { c, d }; }
36
+ return best?.c ?? null;
37
+ };
38
+
39
+ /** The pairs from `--var`, as the values the page's variables take: a number variable gets a number, a string variable the text. `declared` is `movie.vars`. */
40
+ export function coerceVars(pairs, declared) {
41
+ const names = Object.keys(declared);
42
+ const out = {};
43
+ for (const [name, raw] of pairs) {
44
+ if (!names.length) throw new Error(`--var ${name}: the page declares no variables (declare them in movie.init({ vars: { ${name}: … }, … }))`);
45
+ if (!Object.prototype.hasOwnProperty.call(declared, name)) {
46
+ const guess = nearestOf(name, names);
47
+ throw new Error(`--var ${name}: the page declares no variable "${name}"${guess ? ` — did you mean "${guess}"?` : ''} (declared: ${names.join(', ')})`);
48
+ }
49
+ if (typeof declared[name] === 'number') {
50
+ const n = raw.trim() === '' ? NaN : Number(raw);
51
+ if (!Number.isFinite(n)) throw new Error(`--var ${name}=${raw}: "${name}" is a number in the page (it declares ${declared[name]}), got "${raw}"`);
52
+ out[name] = n;
53
+ } else out[name] = raw;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Once the page is ready: set the variables and the size the command line gave, with one `movie.rebuild`. Returns the rebuild report, or null when
60
+ * nothing was asked. `cdp.eval` is the command's page connection. A refusal (an unknown name, a rebuild that failed) is thrown as a usage error.
61
+ */
62
+ export async function applyToPage(cdp, pairs, size) {
63
+ if (!pairs.length && !size) return null;
64
+ if (await cdp.eval("typeof (window.movie && window.movie.rebuild)") !== 'function') throw new Error("this page's pixi-effects is older than --var / --size: use 0.31 or newer (the version in the page's import map)");
65
+ const declared = await cdp.eval('JSON.parse(JSON.stringify((window.movie && window.movie.vars) || {}))');
66
+ const given = coerceVars(pairs, declared);
67
+ const change = { ...(Object.keys(given).length ? { vars: given } : {}), ...(size ?? {}) };
68
+ const answer = JSON.parse(await cdp.eval(`(async () => {
69
+ try {
70
+ return JSON.stringify({ report: await movie.rebuild(${JSON.stringify(change)}) });
71
+ } catch (e) { return JSON.stringify({ error: String((e && e.message) || e) }); }
72
+ })()`));
73
+ if (answer.error) throw new Error(`${answer.error.replace(/^pixi-effects: /, '')} (while applying ${[...pairs.map(([n, v]) => `--var ${n}=${v}`), size ? `--size ${size.width}x${size.height}` : ''].filter(Boolean).join(' ')})`);
74
+ return answer.report;
75
+ }
@@ -1,5 +1,5 @@
1
1
  import { Container } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape } from './types-BzIB65Vk.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape } from './types-CK_crNAI.cjs';
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-BzIB65Vk.js';
2
+ import { S as SequenceSpec, C as CompositionShape } from './types-CK_crNAI.js';
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-BGMCZA36.js';
2
+ import './chunk-MMTESMJ5.js';
3
+ import './chunk-4KB3GUEK.js';
4
+ import './chunk-RCVZX2JP.js';
5
+ import './chunk-BXOCEZ4U.js';
6
+ //# sourceMappingURL=Composition-DQCPJPFE.js.map
7
+ //# sourceMappingURL=Composition-DQCPJPFE.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RJGA7KDU.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-DQCPJPFE.js"}
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ var chunkL73KRFXI_cjs = require('./chunk-L73KRFXI.cjs');
4
+ require('./chunk-DJ5B7IKT.cjs');
5
+ require('./chunk-2DXPTKQZ.cjs');
6
+ require('./chunk-LYOKH7KE.cjs');
7
+ require('./chunk-TWZNOUM7.cjs');
8
+
9
+
10
+
11
+ Object.defineProperty(exports, "CompositionSequence", {
12
+ enumerable: true,
13
+ get: function () { return chunkL73KRFXI_cjs.CompositionSequence; }
14
+ });
15
+ //# sourceMappingURL=Composition-V2XYSW6P.cjs.map
16
+ //# sourceMappingURL=Composition-V2XYSW6P.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-MUKR3FYD.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-V2XYSW6P.cjs"}
@@ -81,6 +81,7 @@ var CONTROLLER_CSS = `
81
81
  transition: opacity 120ms ease;
82
82
  }
83
83
  .mc-btn:hover { opacity: 1; }
84
+ .mc-loop[aria-pressed="true"] { opacity: 1; color: var(--mc-accent); }
84
85
  .mc-btn:disabled { opacity: 0.4; cursor: not-allowed; }
85
86
  .mc-time {
86
87
  font-size: 12px; font-family: Menlo, Monaco, monospace;
@@ -262,6 +263,7 @@ var ICONS = {
262
263
  pause: '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><rect x="3" y="2" width="3.5" height="12"/><rect x="9.5" y="2" width="3.5" height="12"/></svg>',
263
264
  volumeOn: '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M2 6 H5 L9 2 V14 L5 10 H2 Z"/><path d="M11 5 Q13 8 11 11" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M12.5 3.5 Q15.5 8 12.5 12.5" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>',
264
265
  volumeOff: '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M2 6 H5 L9 2 V14 L5 10 H2 Z"/><path d="M11 5 L15 11 M15 5 L11 11" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>',
266
+ loop: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7 V6 Q3 4 5 4 H12"/><path d="M10 2 L12.5 4 L10 6"/><path d="M13 9 V10 Q13 12 11 12 H4"/><path d="M6 14 L3.5 12 L6 10"/></svg>',
265
267
  download: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2 V11 M4 7 L8 11 L12 7 M3 13 H13"/></svg>',
266
268
  fullscreenEnter: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 6 V2 H6 M14 6 V2 H10 M2 10 V14 H6 M14 10 V14 H10"/></svg>',
267
269
  present: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2.5 H14 V10.5 H2 Z M8 10.5 V13.5 M5 13.5 H11"/></svg>',
@@ -293,7 +295,7 @@ var THEME_PROPERTIES = {
293
295
  trackHeight: "--mc-track-height",
294
296
  font: "--mc-font"
295
297
  };
296
- var CONTROLLER_OPTION_KEYS = ["canvas", "theme", "exportButton", "keyboard", "clickToPlay", "presentButton", "pauseAtStops", "className"];
298
+ var CONTROLLER_OPTION_KEYS = ["canvas", "theme", "exportButton", "keyboard", "clickToPlay", "presentButton", "pauseAtStops", "loopButton", "className"];
297
299
  var Controller = class _Controller {
298
300
  movie;
299
301
  options;
@@ -311,6 +313,8 @@ var Controller = class _Controller {
311
313
  progressThumbEl;
312
314
  playBtn;
313
315
  muteBtn;
316
+ loopBtn = null;
317
+ onLoopChange = null;
314
318
  timeEl;
315
319
  exportBtn = null;
316
320
  settingsPopoverEl = null;
@@ -335,6 +339,8 @@ var Controller = class _Controller {
335
339
  settingsKeyHandler = null;
336
340
  settingsOutsideHandler = null;
337
341
  onReady = null;
342
+ /** A rebuild (setVars, resize, rebuild) may have moved the stops: draw their marks again. */
343
+ onRebuilt = null;
338
344
  presenter = null;
339
345
  /** `pixi-effects/present`, loaded as soon as the movie has stops (a press of play must not wait for it). */
340
346
  present = null;
@@ -380,6 +386,7 @@ var Controller = class _Controller {
380
386
  clickToPlay: options.clickToPlay ?? true,
381
387
  presentButton: options.presentButton ?? true,
382
388
  pauseAtStops: options.pauseAtStops ?? true,
389
+ loopButton: options.loopButton ?? true,
383
390
  className: options.className ?? "movie-controller"
384
391
  };
385
392
  installStyles();
@@ -403,6 +410,7 @@ var Controller = class _Controller {
403
410
  if (this.options.clickToPlay) this.bindCanvasClick();
404
411
  this.bindScrubbing();
405
412
  this.bindMuteButton();
413
+ this.bindLoopButton();
406
414
  this.bindVolumeSlider();
407
415
  this.bindExportPopover();
408
416
  this.bindFullscreen();
@@ -447,6 +455,7 @@ var Controller = class _Controller {
447
455
  <div class="mc-vol-thumb"></div>
448
456
  </div>
449
457
  </div>
458
+ ${this.options.loopButton ? `<button class="mc-btn mc-loop" aria-label="Loop" aria-pressed="false" title="Loop (L)">${ICONS.loop}</button>` : ""}
450
459
  <span class="mc-time">0:00 / 0:00</span>
451
460
  <div class="mc-spacer"></div>
452
461
  ${this.options.exportButton ? `<button class="mc-btn mc-export" aria-label="Export" aria-expanded="false">${ICONS.download}</button>` : ""}
@@ -577,7 +586,11 @@ var Controller = class _Controller {
577
586
  this.exportFillEl.style.width = `${progress}%`;
578
587
  this.exportTextEl.textContent = `${progress}% (${frame} / ${totalFrames} frames)`;
579
588
  };
589
+ this.onRebuilt = () => {
590
+ this.refreshStops();
591
+ };
580
592
  this.movie.on("ready", this.onReady);
593
+ this.movie.on("rebuilt", this.onRebuilt);
581
594
  this.movie.on("frame", this.onFrame);
582
595
  this.movie.on("play", this.onPlay);
583
596
  this.movie.on("pause", this.onPause);
@@ -712,6 +725,20 @@ var Controller = class _Controller {
712
725
  this.playBtn.innerHTML = this.movie.isPlaying ? ICONS.pause : ICONS.play;
713
726
  this.playBtn.setAttribute("aria-label", this.movie.isPlaying ? "Pause" : "Play");
714
727
  }
728
+ bindLoopButton() {
729
+ this.loopBtn = this.root.querySelector(".mc-loop");
730
+ if (!this.loopBtn) return;
731
+ const refresh = () => {
732
+ this.loopBtn.setAttribute("aria-pressed", String(this.movie.loop));
733
+ };
734
+ this.loopBtn.addEventListener("click", () => {
735
+ this.movie.loop = !this.movie.loop;
736
+ refresh();
737
+ });
738
+ this.onLoopChange = refresh;
739
+ this.movie.on("loopchange", this.onLoopChange);
740
+ refresh();
741
+ }
715
742
  bindMuteButton() {
716
743
  this.muteBtn.addEventListener("click", () => {
717
744
  this.movie.toggleMute();
@@ -1124,6 +1151,12 @@ var Controller = class _Controller {
1124
1151
  e.preventDefault();
1125
1152
  this.muteBtn.click();
1126
1153
  break;
1154
+ case "KeyL":
1155
+ if (this.loopBtn) {
1156
+ e.preventDefault();
1157
+ this.loopBtn.click();
1158
+ }
1159
+ break;
1127
1160
  case "KeyE":
1128
1161
  if (e.shiftKey && this.exportBtn) {
1129
1162
  e.preventDefault();
@@ -1158,6 +1191,14 @@ var Controller = class _Controller {
1158
1191
  this.movie.off("ready", this.onReady);
1159
1192
  this.onReady = null;
1160
1193
  }
1194
+ if (this.onRebuilt) {
1195
+ this.movie.off("rebuilt", this.onRebuilt);
1196
+ this.onRebuilt = null;
1197
+ }
1198
+ if (this.onLoopChange) {
1199
+ this.movie.off("loopchange", this.onLoopChange);
1200
+ this.onLoopChange = null;
1201
+ }
1161
1202
  if (this.onFrame) {
1162
1203
  this.movie.off("frame", this.onFrame);
1163
1204
  this.onFrame = null;