pixi-effects 0.30.1 → 0.32.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 +33 -0
- package/README.md +6 -5
- package/ai/chat-template.html +3 -3
- package/ai/tools/check.mjs +42 -2
- package/ai/tools/render.mjs +34 -4
- package/ai/tools/varArgs.mjs +75 -0
- package/dist/{Base-Ne74ztNl.d.ts → Base-CXsMdmJB.d.ts} +1 -1
- package/dist/{Base-CB1_FPZD.d.cts → Base-pNq6sVGl.d.cts} +1 -1
- package/dist/Composition-LNMKWXSL.cjs +16 -0
- package/dist/{Composition-MUKR3FYD.cjs.map → Composition-LNMKWXSL.cjs.map} +1 -1
- package/dist/Composition-RF7QOEXT.js +7 -0
- package/dist/{Composition-RJGA7KDU.js.map → Composition-RF7QOEXT.js.map} +1 -1
- package/dist/Controller.cjs +42 -1
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.d.cts +10 -2
- package/dist/Controller.d.ts +10 -2
- package/dist/Controller.js +42 -1
- package/dist/Controller.js.map +1 -1
- package/dist/{Movie-DdnpLpw1.d.cts → Movie-CL6SsAWe.d.cts} +90 -3
- package/dist/{Movie-C2rZFkrO.d.ts → Movie-f0tQG9y5.d.ts} +90 -3
- package/dist/Spans-NQGKVMR5.cjs +55 -0
- package/dist/Spans-NQGKVMR5.cjs.map +1 -0
- package/dist/Spans-SDXABOV6.js +53 -0
- package/dist/Spans-SDXABOV6.js.map +1 -0
- package/dist/{blendModes-RIVZXL5A.js → blendModes-MVRLG3T7.js} +4 -4
- package/dist/{blendModes-RIVZXL5A.js.map → blendModes-MVRLG3T7.js.map} +1 -1
- package/dist/{blendModes-ETMYWATF.cjs → blendModes-VTKDXWEN.cjs} +5 -5
- package/dist/{blendModes-ETMYWATF.cjs.map → blendModes-VTKDXWEN.cjs.map} +1 -1
- package/dist/{chunk-ZAXYPXKW.js → chunk-AW5NPDE4.js} +1367 -1150
- package/dist/chunk-AW5NPDE4.js.map +1 -0
- package/dist/{chunk-RCVZX2JP.js → chunk-C5BNHVKK.js} +36 -3
- package/dist/chunk-C5BNHVKK.js.map +1 -0
- package/dist/{chunk-WHNADXXG.cjs → chunk-CRUDOKSD.cjs} +91 -91
- package/dist/chunk-CRUDOKSD.cjs.map +1 -0
- package/dist/{chunk-A3Q3SDSH.js → chunk-D5BFQVKL.js} +6 -6
- package/dist/chunk-D5BFQVKL.js.map +1 -0
- package/dist/{chunk-LYOKH7KE.cjs → chunk-FCGYJCVO.cjs} +36 -3
- package/dist/chunk-FCGYJCVO.cjs.map +1 -0
- package/dist/{chunk-CRQFDY7E.js → chunk-FCMOREIS.js} +467 -58
- package/dist/chunk-FCMOREIS.js.map +1 -0
- package/dist/{chunk-HREBQAY4.cjs → chunk-K3MAEKFM.cjs} +493 -84
- package/dist/chunk-K3MAEKFM.cjs.map +1 -0
- package/dist/{chunk-4KB3GUEK.js → chunk-QFNS6BFL.js} +4 -4
- package/dist/{chunk-4KB3GUEK.js.map → chunk-QFNS6BFL.js.map} +1 -1
- package/dist/{chunk-ZVUO325O.cjs → chunk-QHDN6TF5.cjs} +1382 -1160
- package/dist/chunk-QHDN6TF5.cjs.map +1 -0
- package/dist/{chunk-2DXPTKQZ.cjs → chunk-Z437UWI4.cjs} +5 -5
- package/dist/{chunk-2DXPTKQZ.cjs.map → chunk-Z437UWI4.cjs.map} +1 -1
- package/dist/index.cjs +33 -33
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +5 -5
- package/dist/loader.css +1 -1
- package/dist/present.cjs +5 -5
- package/dist/present.d.cts +2 -2
- package/dist/present.d.ts +2 -2
- package/dist/present.js +5 -5
- package/dist/three.cjs +6 -6
- package/dist/three.d.cts +2 -2
- package/dist/three.d.ts +2 -2
- package/dist/three.js +2 -2
- package/dist/{types-BzIB65Vk.d.ts → types-CavnUvd5.d.cts} +28 -1
- package/dist/{types-BzIB65Vk.d.cts → types-CavnUvd5.d.ts} +28 -1
- package/docs/api.md +28 -1
- package/docs/dsl.md +46 -5
- package/llms-full.txt +136 -10
- package/package.json +83 -20
- package/skills/pixi-effects/SKILL.md +1 -1
- package/skills/pixi-effects/reference/cheatsheet.md +5 -2
- package/skills/pixi-effects/reference/pitfalls.md +5 -1
- package/skills/pixi-effects/reference/recipes.md +52 -0
- package/skills/pixi-effects/template.html +6 -6
- package/dist/Composition-MUKR3FYD.cjs +0 -16
- package/dist/Composition-RJGA7KDU.js +0 -7
- package/dist/chunk-A3Q3SDSH.js.map +0 -1
- package/dist/chunk-CRQFDY7E.js.map +0 -1
- package/dist/chunk-HREBQAY4.cjs.map +0 -1
- package/dist/chunk-LYOKH7KE.cjs.map +0 -1
- package/dist/chunk-RCVZX2JP.js.map +0 -1
- package/dist/chunk-WHNADXXG.cjs.map +0 -1
- package/dist/chunk-ZAXYPXKW.js.map +0 -1
- package/dist/chunk-ZVUO325O.cjs.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
**`spans`: colour and style inside one text layer, and the papercuts of five films (planned as 0.32)**
|
|
6
|
+
|
|
7
|
+
- **`spans`** (design: `docs/superpowers/specs/2026-10-12-spans-design.md`): a text layer takes `spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'a'", fill: '#ffd166', fontWeight: '700' }, ',\n next']` instead of `text`. A span changes `fill`, `fontWeight`, `fontStyle`, `fontVariant`, `letterSpacing` or `dropShadow` (a size, a font, `lineHeight`, `align` or a `stroke` is warned about and ignored: they belong to the layer's `style`; the style's expressions and variables are resolved before measuring, and a missing `fontSize` is measured as the text layer's default); `\n` starts a line; `style.align` places the lines. At build time the layer becomes a group of ordinary text layers `<name>.0 …` (so every keyframe, filter and mask works, `anchorX` / `anchorY` centre the whole block, `inspect`, `check` and the timeline view see the pieces); a `visibleChars` typewriter counts over all the spans, the newline included; `{name}` variables inside spans are read before the layout, so `setVars` lays the line out again. Needs the font loaded, like `splitText`. The films needed one text layer per token to stream coloured code.
|
|
8
|
+
- **A dotted keyframe key on a filter now moves the leaf of a point option** (`'filters.s.red.x'`): it used to be written as a property literally named `red.x` and nothing moved, silently. A path that leads nowhere is warned about once. The docs said both that it works and that it does not; they say one true sentence now (the whole point `red: { x, y }` as a keyframe value still does not tween).
|
|
9
|
+
- **Filter options are copied** when the filter is made: a keyframe on `'filters.s.red.x'` writes the leaf of the point in place, which wrote into the author's spec object (after a `rebuild` a `to` keyframe then started from the last frame that had been drawn). A dotted key in `initial` / a `set` is a path too.
|
|
10
|
+
- **`pixi-effects render` prints the length of the FILE** (it printed the length of the whole movie for a `--range` or `--scene`).
|
|
11
|
+
- **A property written on a layer instead of in `initial`** (`z`, `x`, `alpha`, `rotationX` …) now says "put it in `initial`" instead of guessing another key ("did you mean d?" on a path).
|
|
12
|
+
- **The depth-of-field warning says where it starts** ("it warns from 20") and has a test.
|
|
13
|
+
- Docs: `animateText` without `out` (the pieces stay), what the default `dissolve` looks like (blotches; `scale: 200` is grain), a `path` in canvas coordinates takes no `x` / `y`. The roadmap's 0.32 list was checked against the 0.31.0 build before work began: of 11 gaps reported by AI agents, 6 were already documented or warned about.
|
|
14
|
+
|
|
15
|
+
**Landing: two 45-second films next to the reel**
|
|
16
|
+
|
|
17
|
+
- `examples/films/ink-and-paper.html` and `examples/films/light-as-material.html`: two films, each one HTML page of data, played live from the landing page on a click (the same way as the reel). They open in a terminal where an AI is asked for a video and then combine Rough.js, D3 and three.js with the library's own features (springs, particles, shaders, warp, light and shadows, depth of field, camera paths, time remap, music and sfx written as text). Written by an AI model from the docs and a short brief. Their web fonts load inside the film pages only; the landing page itself still loads nothing from third parties.
|
|
18
|
+
- They are not gallery pieces (the gallery's rules are 8–14 s and no third-party fonts), so the gallery's 52 is unchanged.
|
|
19
|
+
- A browser test: each of the two cards loads its page on a click, the page is ready and logs nothing.
|
|
20
|
+
|
|
21
|
+
## 0.31.0
|
|
22
|
+
|
|
23
|
+
**Added: variables, and a composition that is built again (design: `docs/superpowers/specs/2026-10-11-variables-0.31-design.md`)**
|
|
24
|
+
|
|
25
|
+
- **`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.
|
|
26
|
+
- **`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.
|
|
27
|
+
- **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.
|
|
28
|
+
- **`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.
|
|
29
|
+
- **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.
|
|
30
|
+
- **`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).
|
|
31
|
+
- **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).
|
|
32
|
+
- 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".
|
|
33
|
+
|
|
34
|
+
**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).
|
|
35
|
+
|
|
3
36
|
## 0.30.1
|
|
4
37
|
|
|
5
38
|
**Docs**
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pixi-effects
|
|
2
2
|
|
|
3
|
-
> **Status**: experimental — current release `0.
|
|
3
|
+
> **Status**: experimental — current release `0.32.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.
|
|
53
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
52
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/index.js",
|
|
53
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.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.
|
|
64
|
+
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.32.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.
|
|
80
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.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
|
package/ai/chat-template.html
CHANGED
|
@@ -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.
|
|
63
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
64
|
-
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
62
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/index.js",
|
|
63
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/Controller.js",
|
|
64
|
+
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/present.js"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
</script>
|
package/ai/tools/check.mjs
CHANGED
|
@@ -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)`);
|
package/ai/tools/render.mjs
CHANGED
|
@@ -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)');
|
|
@@ -117,12 +124,28 @@ const renderScript = (format, quality, videoCodec, audioCodec, motionBlur, shutt
|
|
|
117
124
|
: await movie.render({ format: ${JSON.stringify(format)}, ${SOUND_ONLY.includes(format) ? '' : 'video, '}audio${part.range ? `, range: [${part.range[0] ?? 0}, ${part.range[1] ?? 'movie.duration'}]` : ''}${part.scene ? `, range: ${JSON.stringify(part.scene)}` : ''}${part.scale ? `, scale: ${part.scale}` : ''}${part.draft ? ', draft: true' : ''}${motionBlur ? `, motionBlur: { samples: ${motionBlur}${shutter ? `, shutter: ${shutter}` : ''} }` : ''} });
|
|
118
125
|
const res = await fetch(${JSON.stringify(UPLOAD)}, { method: 'PUT', body: blob });
|
|
119
126
|
if (!res.ok) throw new Error('could not hand the file to the command (' + res.status + ')');
|
|
120
|
-
|
|
127
|
+
// how long the FILE is (a range or a scene is shorter than the movie): ask a media element, which reads the metadata of what was written; null when it cannot
|
|
128
|
+
let length = null;
|
|
129
|
+
try {
|
|
130
|
+
if (${JSON.stringify(format)} !== 'pdf') {
|
|
131
|
+
const el = document.createElement(${SOUND_ONLY.includes(format) ? "'audio'" : "'video'"}); el.preload = 'metadata'; el.muted = true; el.src = URL.createObjectURL(blob);
|
|
132
|
+
length = await new Promise(res => { el.onloadedmetadata = () => res(Number.isFinite(el.duration) ? Math.round(el.duration * 10) / 10 : null); el.onerror = () => res(null); setTimeout(() => res(null), 5000); });
|
|
133
|
+
URL.revokeObjectURL(el.src);
|
|
134
|
+
}
|
|
135
|
+
} catch { /* the length stays unknown: the command falls back to the movie's */ }
|
|
136
|
+
window.__r = { state: 'done', bytes: blob.size, type: blob.type, length };
|
|
121
137
|
} catch (e) { window.__r = { state: 'error', message: String((e && e.message) || e) }; }
|
|
122
138
|
})();
|
|
123
139
|
return 0;
|
|
124
140
|
})()`;
|
|
125
141
|
|
|
142
|
+
/** The length to print: the file's own (read in the page); when that could not be read, the range asked for, and only then the whole movie's. */
|
|
143
|
+
export function fileSeconds(r, opts) {
|
|
144
|
+
if (r.length != null) return r.length;
|
|
145
|
+
if (opts.range && r.duration != null) return Math.round(((opts.range[1] ?? r.duration) - (opts.range[0] ?? 0)) * 10) / 10;
|
|
146
|
+
return r.duration;
|
|
147
|
+
}
|
|
148
|
+
|
|
126
149
|
export async function runRender(opts, log = console.log, progress = () => {}) {
|
|
127
150
|
const pagePath = path.resolve(opts.page);
|
|
128
151
|
if (!fs.existsSync(pagePath)) throw new Error(`page not found: ${opts.page}`);
|
|
@@ -178,6 +201,12 @@ export async function runRender(opts, log = console.log, progress = () => {}) {
|
|
|
178
201
|
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
202
|
return result;
|
|
180
203
|
}
|
|
204
|
+
// --var / --size: the page is built again with them before anything is drawn
|
|
205
|
+
if (opts.vars.length || opts.size) {
|
|
206
|
+
await applyToPage(cdp, opts.vars, opts.size);
|
|
207
|
+
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 }; })()`);
|
|
208
|
+
result.logs = [...new Set([...info.logs, ...consoleLines])];
|
|
209
|
+
}
|
|
181
210
|
Object.assign(result, { duration: info.duration, frames: info.totalFrames, width: info.width, height: info.height, frameRate: info.frameRate });
|
|
182
211
|
|
|
183
212
|
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));
|
|
@@ -196,6 +225,7 @@ export async function runRender(opts, log = console.log, progress = () => {}) {
|
|
|
196
225
|
fs.renameSync(part, out);
|
|
197
226
|
result.file = out;
|
|
198
227
|
result.bytes = written;
|
|
228
|
+
result.length = result.render?.length ?? null; // the file's own length; a range or a scene is not the whole movie
|
|
199
229
|
result.seconds = Math.round((Date.now() - t0) / 100) / 10;
|
|
200
230
|
return result;
|
|
201
231
|
} finally {
|
|
@@ -231,7 +261,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
|
|
|
231
261
|
console.error(`pixi-effects render failed: ${r.problems.join('; ') || 'no file written'}`);
|
|
232
262
|
process.exit(1);
|
|
233
263
|
}
|
|
234
|
-
console.log(`${shown(r.file)} ${kb(r.bytes)} · ${r
|
|
264
|
+
console.log(`${shown(r.file)} ${kb(r.bytes)} · ${fileSeconds(r, opts)} s · ${SOUND_ONLY.includes(opts.format) ? 'sound only' : `${r.width}×${r.height} @ ${r.frameRate} fps`} · ${opts.format} · rendered in ${r.seconds} s`);
|
|
235
265
|
process.exit(opts.strict && r.logs.length ? 1 : 0);
|
|
236
266
|
} catch (e) {
|
|
237
267
|
console.error(`pixi-effects render failed: ${e.message}`);
|
|
@@ -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-
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape } from './types-CavnUvd5.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-
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape } from './types-CavnUvd5.cjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolves a routed keyframe path to a concrete tween target.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var chunkCRUDOKSD_cjs = require('./chunk-CRUDOKSD.cjs');
|
|
4
|
+
require('./chunk-QHDN6TF5.cjs');
|
|
5
|
+
require('./chunk-Z437UWI4.cjs');
|
|
6
|
+
require('./chunk-FCGYJCVO.cjs');
|
|
7
|
+
require('./chunk-TWZNOUM7.cjs');
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Object.defineProperty(exports, "CompositionSequence", {
|
|
12
|
+
enumerable: true,
|
|
13
|
+
get: function () { return chunkCRUDOKSD_cjs.CompositionSequence; }
|
|
14
|
+
});
|
|
15
|
+
//# sourceMappingURL=Composition-LNMKWXSL.cjs.map
|
|
16
|
+
//# sourceMappingURL=Composition-LNMKWXSL.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-LNMKWXSL.cjs"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { CompositionSequence } from './chunk-D5BFQVKL.js';
|
|
2
|
+
import './chunk-AW5NPDE4.js';
|
|
3
|
+
import './chunk-QFNS6BFL.js';
|
|
4
|
+
import './chunk-C5BNHVKK.js';
|
|
5
|
+
import './chunk-BXOCEZ4U.js';
|
|
6
|
+
//# sourceMappingURL=Composition-RF7QOEXT.js.map
|
|
7
|
+
//# sourceMappingURL=Composition-RF7QOEXT.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RF7QOEXT.js"}
|
package/dist/Controller.cjs
CHANGED
|
@@ -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;
|