pixi-effects 0.28.1 → 0.29.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 +67 -0
- package/README.md +11 -11
- package/ai/chat-template.html +5 -5
- package/ai/tools/check.mjs +18 -3
- package/ai/tools/pixi-effects.mjs +1 -1
- package/ai/tools/render.mjs +14 -9
- package/ai/tools/view.mjs +108 -16
- package/dist/Base-CB1_FPZD.d.cts +60 -0
- package/dist/Base-Ne74ztNl.d.ts +60 -0
- package/dist/Composition-MUKR3FYD.cjs +16 -0
- package/dist/{Composition-NI74UJT3.cjs.map → Composition-MUKR3FYD.cjs.map} +1 -1
- package/dist/Composition-RJGA7KDU.js +7 -0
- package/dist/{Composition-HFEVIYHN.js.map → Composition-RJGA7KDU.js.map} +1 -1
- package/dist/Controller.cjs +73 -45
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.d.cts +16 -22
- package/dist/Controller.d.ts +16 -22
- package/dist/Controller.js +74 -40
- package/dist/Controller.js.map +1 -1
- package/dist/{Movie-CHcM8HNB.d.ts → Movie-C2rZFkrO.d.ts} +227 -119
- package/dist/{Movie-Cx5ozUGb.d.cts → Movie-DdnpLpw1.d.cts} +227 -119
- package/dist/{blendModes-HDOP6R7T.cjs → blendModes-ETMYWATF.cjs} +5 -5
- package/dist/{blendModes-HDOP6R7T.cjs.map → blendModes-ETMYWATF.cjs.map} +1 -1
- package/dist/{blendModes-PAVTW3BO.js → blendModes-RIVZXL5A.js} +4 -4
- package/dist/{blendModes-PAVTW3BO.js.map → blendModes-RIVZXL5A.js.map} +1 -1
- package/dist/{chunk-P4GY3Z4G.cjs → chunk-2DXPTKQZ.cjs} +6 -6
- package/dist/chunk-2DXPTKQZ.cjs.map +1 -0
- package/dist/{chunk-TYWFKCPY.js → chunk-4KB3GUEK.js} +5 -5
- package/dist/chunk-4KB3GUEK.js.map +1 -0
- package/dist/{chunk-WEJAQAFX.js → chunk-A3Q3SDSH.js} +35 -28
- package/dist/chunk-A3Q3SDSH.js.map +1 -0
- package/dist/chunk-CRQFDY7E.js +4182 -0
- package/dist/chunk-CRQFDY7E.js.map +1 -0
- package/dist/chunk-HREBQAY4.cjs +4206 -0
- package/dist/chunk-HREBQAY4.cjs.map +1 -0
- package/dist/{chunk-ZMAD3KJU.cjs → chunk-LYOKH7KE.cjs} +22 -3
- package/dist/chunk-LYOKH7KE.cjs.map +1 -0
- package/dist/{chunk-45QKLD6M.js → chunk-RCVZX2JP.js} +22 -3
- package/dist/chunk-RCVZX2JP.js.map +1 -0
- package/dist/{chunk-SBOVUZZY.cjs → chunk-WHNADXXG.cjs} +121 -114
- package/dist/chunk-WHNADXXG.cjs.map +1 -0
- package/dist/{chunk-AK54FIWM.js → chunk-ZAXYPXKW.js} +17 -6
- package/dist/chunk-ZAXYPXKW.js.map +1 -0
- package/dist/{chunk-MZCDBRTY.cjs → chunk-ZVUO325O.cjs} +34 -23
- package/dist/chunk-ZVUO325O.cjs.map +1 -0
- package/dist/index.cjs +106 -4480
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -60
- package/dist/index.d.ts +15 -60
- package/dist/index.js +6 -4455
- package/dist/index.js.map +1 -1
- package/dist/loader.css +1 -1
- package/dist/{Presenter.cjs → present.cjs} +370 -31
- package/dist/present.cjs.map +1 -0
- package/dist/present.d.cts +278 -0
- package/dist/present.d.ts +278 -0
- package/dist/{Presenter.js → present.js} +358 -32
- package/dist/present.js.map +1 -0
- 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-DpK1vwcl.d.ts → types-BzIB65Vk.d.cts} +11 -265
- package/dist/{types-DpK1vwcl.d.cts → types-BzIB65Vk.d.ts} +11 -265
- package/docs/api.md +44 -77
- package/docs/dsl.md +16 -14
- package/llms-full.txt +90 -118
- package/llms.txt +1 -1
- package/package.json +5 -5
- package/skills/pixi-effects/SKILL.md +6 -6
- package/skills/pixi-effects/reference/cheatsheet.md +9 -9
- package/skills/pixi-effects/reference/direction.md +4 -2
- package/skills/pixi-effects/reference/pitfalls.md +7 -6
- package/skills/pixi-effects/reference/recipes.md +4 -4
- package/skills/pixi-effects/template.html +7 -7
- package/dist/Base-C6f0fE8j.d.ts +0 -109
- package/dist/Base-DI1FCGcH.d.cts +0 -109
- package/dist/Composition-HFEVIYHN.js +0 -7
- package/dist/Composition-NI74UJT3.cjs +0 -16
- package/dist/Presenter.cjs.map +0 -1
- package/dist/Presenter.d.cts +0 -122
- package/dist/Presenter.d.ts +0 -122
- package/dist/Presenter.js.map +0 -1
- package/dist/chunk-45QKLD6M.js.map +0 -1
- package/dist/chunk-AK54FIWM.js.map +0 -1
- package/dist/chunk-MZCDBRTY.cjs.map +0 -1
- package/dist/chunk-P4GY3Z4G.cjs.map +0 -1
- package/dist/chunk-SBOVUZZY.cjs.map +0 -1
- package/dist/chunk-TYWFKCPY.js.map +0 -1
- package/dist/chunk-WEJAQAFX.js.map +0 -1
- package/dist/chunk-ZMAD3KJU.cjs.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.29.0
|
|
4
|
+
|
|
5
|
+
**Changed (breaking): presentation leaves the core**
|
|
6
|
+
|
|
7
|
+
The talk side of the library is now its own entry, `pixi-effects/present`, built on the core's public API only (a test enforces the import rule). The core keeps `composition.stops` (named times, `movie.stops`) and gains two general moves; everything else that was about a talk moved out.
|
|
8
|
+
|
|
9
|
+
| before | now |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `movie.next()` `prev()` `goToStop(i)` `goToPage(n)` | `next(movie)` `prev(movie)` `goToStop(movie, i)` `goToPage(movie, n)` from `pixi-effects/present` |
|
|
12
|
+
| `movie.stopIndex` `pageIndex` `pageCount` `currentStop` | `stopIndex(movie)` `pageIndex(movie)` `pageCount(movie)` `currentStop(movie)` |
|
|
13
|
+
| `movie.on('stop', fn)` | `const off = onStop(movie, fn)` |
|
|
14
|
+
| `movie.stopImages(o)` `movie.inspectStops(o)` `movie.exportPDF(o)` | `stopImages(movie, o)` `inspectStops(movie, o)` `exportPDF(movie, o)` |
|
|
15
|
+
| `import { deck } from 'pixi-effects'` | `import { deck } from 'pixi-effects/present'` |
|
|
16
|
+
| `import { Presenter } from 'pixi-effects/presenter'` (`dist/Presenter.js`) | `import { Presenter } from 'pixi-effects/present'` (`dist/present.js`; the import map line is `"pixi-effects/present"`) |
|
|
17
|
+
| `normalizeStops`, `pictureStops` exported from `pixi-effects` | not exported (the core normalises `composition.stops` itself; `pictureStops` is inside `pixi-effects/present`) |
|
|
18
|
+
| the types `StopEvent`, `StopImage`, `StopImagesOptions` from `pixi-effects` | the same names from `pixi-effects/present` (`Stop` and `StopSpec` stay in `pixi-effects`) |
|
|
19
|
+
|
|
20
|
+
A page that only maps `"pixi-effects"` still gets its stops reviewed and its PDF exported by `check` / `render`: they load `present.js` from beside the library.
|
|
21
|
+
|
|
22
|
+
**Changed (breaking): the API list for 0.29** (`docs/api-surface.txt` is the whole public surface, read from the sources and frozen by a test; the decisions are in `docs/superpowers/specs/2026-10-11-api-list-0.29.md`)
|
|
23
|
+
|
|
24
|
+
| before | now |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `maskInverted: true` next to `mask: { …a layer… }` | `mask: { layer: { …the mask layer… }, invert: true }` (the one inverted-mask form, the same as `{ layer: 'name', invert: true }` for a named matte; `channel: 'luma'` works on a layer written in place too). The old key warns and says this. Inside a list of masks only names are allowed |
|
|
27
|
+
| `Controller` options `enableKeyboardShortcuts`, `showExportButton`, `present` | `keyboard`, `exportButton`, `presentButton` (the Presenter's names). An unknown `Controller` option now warns, with did-you-mean |
|
|
28
|
+
| `pixi-effects/controller` also exported `CONTROLLER_CSS`, `formatTime`, `frameToPercent`, `pxToFrame`, `pxToFraction`, `extensionForMimeType` | `Controller`, `ControllerOptions`, `ControllerTheme` only |
|
|
29
|
+
| the bins `pixi-effects-check`, `pixi-effects-render`, `pixi-effects-view` | `pixi-effects check|render|view` (the one bin; `npx pixi-effects-check` never worked) |
|
|
30
|
+
| `pixi-effects render --fail-on-warn` | `--strict`, the word `check` already used (the old flag says so) |
|
|
31
|
+
| `Movie.emit`, `audioSource`, `gainNode`; `Sequence.bindTimeline`, `collectAudio`, `absoluteStart`, `keepHidden`, `maskSequence`, `filters`, `updateSpace`, `syncFrame` in the type declarations | marked `@internal` and stripped from the declarations (the library still uses them) |
|
|
32
|
+
|
|
33
|
+
**Added**
|
|
34
|
+
|
|
35
|
+
- `movie.playUntil(seconds?)`: play and pause exactly at a time (`true` when it landed; `false` when a pause, a seek or a newer `playUntil` ended it first; no time = to the end). It is what `next(movie)` is made of, and the way to "play the intro, then wait".
|
|
36
|
+
- `movie.peek(fn)`: run a function that looks around (seeks, pictures) and put the playhead or the poster back; the seeks inside are not reported as `seeking` / `seeked`.
|
|
37
|
+
- `movie.snapshot(frame, { quality })` (JPEG quality) and `{ as: 'imageData' }` (the pixels, to measure or compare).
|
|
38
|
+
- `warnUnknownOptions(where, options, validNames)` is exported: the did-you-mean warning for an unknown option, for presets and layer types written outside the library.
|
|
39
|
+
- `animateText({ out: { preset, at: T } })`: the exit starts at composition second T (the pieces leave in the same wave and stay gone until the layer ends). Without `at` it is the end of the layer run backwards, as before; a warning says `out.at ≤ …` when the exit does not fit, or when T is before the entrance has finished.
|
|
40
|
+
- `pixi-effects render --help` and `pixi-effects view --help` (usage, exit 0), like `check`.
|
|
41
|
+
- A warning when a picture key (`filters`, `filterArea`, `mask`, `blendMode`, `threeD`, `hideBehindCamera`, `lit`, `castsShadows`) is put on a layer that is never drawn: an audio layer, a camera, a light (a light keeps `castsShadows`, its own switch). The key did nothing there, silently.
|
|
42
|
+
|
|
43
|
+
**Fixed**
|
|
44
|
+
|
|
45
|
+
- `NamedFilterType` lacked `'warp'`: `{ type: 'warp' }` was a TypeScript error though it worked at run time. The compiler now checks the type and the runtime filter tables against each other, both ways.
|
|
46
|
+
|
|
47
|
+
**Checked, not reproduced**
|
|
48
|
+
|
|
49
|
+
- The two bugs the old roadmap listed (an inline shape mask under a transition; a 4000 x 2400 `threeD` layer black on WebGPU) do not reproduce on this code. Real-browser tests pin both, on both backends.
|
|
50
|
+
|
|
51
|
+
**Docs**
|
|
52
|
+
|
|
53
|
+
- **The film guide says who it is for.** `direction.md` opens with a note for an AI in a browser chat (no shell, no files): this guide is not yours, follow `ai/CHAT.md`; you can still put a three-line plan in your reply and ask. The `llms.txt` line for it says "for an AI with a shell and files; a browser chat skips it".
|
|
54
|
+
|
|
55
|
+
## 0.28.2
|
|
56
|
+
|
|
57
|
+
**Added**
|
|
58
|
+
|
|
59
|
+
- **`pixi-effects view` tells you the work changed, and never switches by itself.** It watches the page's folder (not `node_modules`, `out`, `reviews`, `check-out`, hidden files); when something changes it shows "Updated hh:mm:ss — reload (R)" and nothing else. Reload (the button or R) loads the page again and returns to the frame you were on; the switch "auto" (off by default, remembered) reloads only while the movie is paused. The skill now says to start the viewer right after copying the template (before the brief is written) and to give the human the address in the first message, so the first address goes out within two minutes and they watch the film grow.
|
|
60
|
+
|
|
61
|
+
**Fixed**
|
|
62
|
+
|
|
63
|
+
- **`pixi-effects view` centres the page, and the timeline legend no longer gets cut off.** A page that keeps its picture at the left edge (the template does) sat against the left of a wide viewer; the iframe is now as wide as the page's own content and centred. The legend under the timeline (text, shape, image, …) is wrapped onto more lines when the chart is narrow instead of running past the right edge (`timelineSvg()` and the `timeline.html` that `check` writes).
|
|
64
|
+
- **A text layer whose `style` has an undefined value is no longer blanked.** `style: { fontSize: undefined }` (a variable that was never set) erased the default size and the text was not drawn; the check said only "has no size". The undefined keys are now skipped (Pixi keeps its default) and one warning names them: `style.fontSize is undefined … omit the key instead`.
|
|
65
|
+
|
|
66
|
+
**Docs**
|
|
67
|
+
|
|
68
|
+
- Pitfall 103: load a web font before `measureText` / `splitText` / `animateText` (measured: a Latin headline is 21 % wider in the fallback font). `direction.md` asks the reviewer to write the three defects into `reviews/notes.md`, to measure the smallest text, and to look at both ends of every cut. Both came from a fresh agent making a company film from the published skill alone.
|
|
69
|
+
|
|
3
70
|
## 0.28.1
|
|
4
71
|
|
|
5
72
|
**Changed**
|
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.29.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
|
|
|
@@ -21,14 +21,14 @@ Declarative composition and video rendering for the web. After Effects-style tim
|
|
|
21
21
|
- **Draw-on strokes and changing text** — `trimEnd` 0 → 1 draws a line, border or SVG path on; a keyframe `set: { text }` swaps a string and `visibleChars` is a typewriter.
|
|
22
22
|
- **Masks, transitions, chroma key** — inline masks; chroma key; eight scene transitions (`crossfade`, `wipe`, `iris`, `slide`, `dip`, `zoom`, `dissolve`, `luma`).
|
|
23
23
|
- **Presets** — `kenBurns` for stills, `withFade` for fade-in/out; `wiggle`, `stagger`, `animateText`, `followPath`, `particles` and `react` return keyframes or layers (seeded, so every render is the same).
|
|
24
|
-
- **Presentations** — `stops` in the composition pause the movie where you say; `Presenter` (`pixi-effects/
|
|
24
|
+
- **Presentations** — `stops` in the composition pause the movie where you say; `Presenter` (`pixi-effects/present`) moves on with arrow keys, a click or a swipe, with a page overview (G), a presenter view with notes and a timer (P), and `deck()` to lay out pages and steps. Export a deck as PDF (`exportPDF(movie)`, `pixi-effects render talk.html -o talk.pdf`). See the [Presenting guide](https://yjmtmtk.github.io/pixi-effects/guide/presenting.html).
|
|
25
25
|
- **Music written as text** — `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }], drums: { kick: 'x...x...' } } }`: a tune with no audio file, played by a small built-in synthesiser (keys, pluck, pad, bass, lead, bell, music box, drums; chords by name, swing, fades, a ritardando). Deterministic, in the exported file at the same moment; an AI writes it as easily as a title.
|
|
26
26
|
- **Audio-reactive visuals** — `audioEnvelope('music.mp3')` analyses loudness, bass / mid / treble and the beats per frame; `react()` turns them into keyframes, so the picture follows the music and still renders identically.
|
|
27
27
|
- **2.5D layers & camera** — add `threeD: true`, `z`, `rotationX/Y` and a `{ type: 'camera' }` layer for parallax, flips and dolly zooms; no three.js needed.
|
|
28
28
|
- **three.js layer** *(optional)* — drop a real three.js scene in as a layer via `pixi-effects/three`; keyframes drive its objects (`three.cube.rotation.y`).
|
|
29
29
|
- **Built-in player UI — or your own** — a drop-in HTML5-`<video>`-style overlay controller (play, scrub, mute, volume, fullscreen, export-to-file) whose colours are one option (`theme: { accent: '#ff4d6d' }`); or build a player yourself on the movie's `<video>`-named events (`play pause ended seeking seeked volumechange error`).
|
|
30
|
-
- **MP4 / WebM / MOV export** — pick container and quality from the controller, call `movie.render()` from code, or render headless from the command line (`pixi-effects
|
|
31
|
-
- **A timeline you can read and scrub** — `movie.timelineChart()` draws every layer as a bar on a time axis; `pixi-effects
|
|
30
|
+
- **MP4 / WebM / MOV export** — pick container and quality from the controller, call `movie.render()` from code, or render headless from the command line (`pixi-effects render`).
|
|
31
|
+
- **A timeline you can read and scrub** — `movie.timelineChart()` draws every layer as a bar on a time axis; `pixi-effects view` opens the page with that timeline under it, zoomable, with a playhead you can drag.
|
|
32
32
|
- **Tiny dependency surface** — only `mediabunny` (runtime) plus PixiJS (a peer dep). three.js is an *optional* peer, needed only if you import `pixi-effects/three`.
|
|
33
33
|
|
|
34
34
|
## Install
|
|
@@ -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.29.0/dist/index.js",
|
|
53
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.29.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.29.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.29.0/dist/three.js"
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
</script>
|
|
@@ -174,8 +174,8 @@ This library is designed to be written by AI: a video is plain data, and every m
|
|
|
174
174
|
- [`skills/pixi-effects/SKILL.md`](./skills/pixi-effects/SKILL.md) — a [skill](https://docs.claude.com/en/docs/claude-code/skills) (workflow, rules, verification loop). Install it with `npx skills add yjmtmtk/pixi-effects` (or copy the `skills/pixi-effects/` folder to `~/.claude/skills/pixi-effects/` or your project's `.claude/skills/`) and your agent loads it by itself when you ask for a video.
|
|
175
175
|
- [`skills/pixi-effects/reference/cheatsheet.md`](./skills/pixi-effects/reference/cheatsheet.md), [`recipes.md`](./skills/pixi-effects/reference/recipes.md) (tested), [`pitfalls.md`](./skills/pixi-effects/reference/pitfalls.md), and a starter [`skills/pixi-effects/template.html`](./skills/pixi-effects/template.html).
|
|
176
176
|
|
|
177
|
-
- **Look at a page with its timeline, and scrub one with the other:** `npx pixi-effects view my-video.html` (or `node ai/tools/view.mjs …`) opens your browser on a viewer: the page on top, the timeline of every layer under it with a playhead that follows the movie. Click or drag the timeline to seek, click a layer's name to jump to where it starts, Space plays, ← / → step a frame, + / − (or Ctrl/⌘ + wheel) zoom, the names stay in place. For the human who wants to see what the AI made.
|
|
178
|
-
- **Render a page to a video file, headless:** `npx pixi-effects render my-video.html -o my-video.mp4` (or `node ai/tools/render.mjs …`; Node ≥ 22 and Chrome installed, no dependencies). The container follows the extension (`mp4 webm mov mkv`); `--quality very-low…very-high`, `--query lang=ja` (added to the page URL), `--
|
|
177
|
+
- **Look at a page with its timeline, and scrub one with the other:** `npx pixi-effects view my-video.html` (it also watches the folder: when the work changes it shows "Updated hh:mm:ss — reload (R)" and never switches by itself; reload comes back to the same frame) (or `node ai/tools/view.mjs …`) opens your browser on a viewer: the page on top, the timeline of every layer under it with a playhead that follows the movie. Click or drag the timeline to seek, click a layer's name to jump to where it starts, Space plays, ← / → step a frame, + / − (or Ctrl/⌘ + wheel) zoom, the names stay in place. For the human who wants to see what the AI made.
|
|
178
|
+
- **Render a page to a video file, headless:** `npx pixi-effects render my-video.html -o my-video.mp4` (or `node ai/tools/render.mjs …`; Node ≥ 22 and Chrome installed, no dependencies). The container follows the extension (`mp4 webm mov mkv`); `--quality very-low…very-high`, `--query lang=ja` (added to the page URL), `--strict` (exit 1 when the page logged a warning), `--quiet`. It waits for `window.__ready`, runs `movie.render()` and streams the file to disk; exit 0 = written, 1 = the page or the render failed (no file). For scripts, CI and batches; `pixi-effects check` below is the review.
|
|
179
179
|
- **A talk as a PDF:** `npx pixi-effects render my-talk.html -o my-talk.pdf` (one page per page of a `deck()` / `stops` movie; `--all-stops` makes a page of every step). The review tool writes `stops.png` for such a page, one picture per stop.
|
|
180
180
|
- **One-command review for the AI that wrote the page:** `npx pixi-effects check my-video.html` (or `node ai/tools/check.mjs my-video.html`; Node ≥ 22 and Chrome installed, no dependencies). It opens the page in its own headless Chrome and reports warnings, layout (`movie.inspect` over the whole timeline), the soundtrack (`movie.inspectAudio`) and a real export decoded again, and writes a contact sheet PNG and a `timeline.html` (every layer as a bar on a time axis) to look at. Exit code 0 / 1.
|
|
181
181
|
|
|
@@ -184,7 +184,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
|
|
|
184
184
|
## Documentation
|
|
185
185
|
|
|
186
186
|
- [**DSL reference**](./docs/dsl.md) — composition, sequences, 3D layers & camera, three.js layer, keyframes, expressions, filters
|
|
187
|
-
- [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/
|
|
187
|
+
- [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/present`, `pixi-effects/three`, events, render options
|
|
188
188
|
- [**Examples**](./examples/) — runnable HTML files (read in order):
|
|
189
189
|
- `01-hello.html` — minimum viable composition
|
|
190
190
|
- `02-keyframes.html` — keyframes, easings, expressions
|
package/ai/chat-template.html
CHANGED
|
@@ -59,17 +59,17 @@
|
|
|
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/
|
|
62
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.29.0/dist/index.js",
|
|
63
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.29.0/dist/Controller.js",
|
|
64
|
+
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.29.0/dist/present.js"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
</script>
|
|
68
68
|
|
|
69
69
|
<script type="module">
|
|
70
|
-
import { Movie, kenBurns, withFade, wiggle, stagger, animateText, followPath, particles
|
|
70
|
+
import { Movie, kenBurns, withFade, wiggle, stagger, animateText, followPath, particles } from 'pixi-effects';
|
|
71
71
|
import { Controller } from 'pixi-effects/controller';
|
|
72
|
-
// A talk instead of a video: import { Presenter } from 'pixi-effects/
|
|
72
|
+
// A talk instead of a video: import { deck, Presenter } from 'pixi-effects/present'; use deck({ pages: [...] }) and new Presenter(movie, { canvas }) instead of the Controller.
|
|
73
73
|
|
|
74
74
|
// ===================== EDIT FROM HERE =====================
|
|
75
75
|
const W = 1280, H = 720, FPS = 30, DURATION = 6;
|
package/ai/tools/check.mjs
CHANGED
|
@@ -286,6 +286,18 @@ const waveformScript = (audio, duration) => `(() => {
|
|
|
286
286
|
// ───────────────────────────── the check ─────────────────────────────
|
|
287
287
|
|
|
288
288
|
export const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* JavaScript, as text, for the page: it resolves to the page's own `pixi-effects/present` module (stops, pages, PDF). The page's import map names it
|
|
292
|
+
* (`pixi-effects/present`); a page that only maps `pixi-effects` gets the `present.js` that sits beside the library.
|
|
293
|
+
*/
|
|
294
|
+
export const LOAD_PRESENT = `(async () => {
|
|
295
|
+
try { return await import('pixi-effects/present'); } catch (e) { /* not in the import map: look beside the library */ }
|
|
296
|
+
let lib = null;
|
|
297
|
+
try { lib = JSON.parse(document.querySelector('script[type="importmap"]').textContent).imports['pixi-effects']; } catch (e) { /* none */ }
|
|
298
|
+
if (!lib) throw new Error('the page has no "pixi-effects" in its import map');
|
|
299
|
+
return import(new URL('present.js', new URL(lib, document.baseURI)).href);
|
|
300
|
+
})()`;
|
|
289
301
|
/** A path for display: relative to the cwd when it is inside it, absolute otherwise. */
|
|
290
302
|
export const shown = f => { const r = path.relative(process.cwd(), f); return r.startsWith('..') || path.isAbsolute(r) ? f : r; };
|
|
291
303
|
const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
|
|
@@ -404,11 +416,13 @@ export async function runCheck(opts, log = console.log) {
|
|
|
404
416
|
}
|
|
405
417
|
|
|
406
418
|
// a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
|
|
407
|
-
const stops = await cdp.eval(`(() => { const s = movie.stops || []; return s.length ? { count: s.length, pages:
|
|
419
|
+
const stops = await cdp.eval(`(() => { const s = movie.stops || []; return s.length ? { count: s.length, pages: new Set(s.map(x => x.pageIndex)).size, items: s.map(x => ({ at: Math.round(x.at * 100) / 100, frame: x.frame, page: x.page, pageStart: x.pageStart, notes: !!x.notes, pdf: x.pdf })) } : null; })()`).catch(() => null);
|
|
408
420
|
if (stops) {
|
|
409
421
|
report.stops = stops;
|
|
410
422
|
// is the picture at each stop still changing? (the audience, the page overview and a PDF would see a half-finished animation)
|
|
411
|
-
|
|
423
|
+
let skipped = '';
|
|
424
|
+
const settled = await cdp.eval(`${LOAD_PRESENT}.then(p => p.inspectStops(movie))`).catch(e => { skipped = String((e && e.message) || e); return null; });
|
|
425
|
+
if (!settled) report.stops.reviewSkipped = `${skipped || 'no answer'} (the page needs "pixi-effects/present": ".../dist/present.js" in its import map, from the same version as the library)`;
|
|
412
426
|
if (settled) {
|
|
413
427
|
report.stops.review = settled.issues;
|
|
414
428
|
report.stops.moving = settled.stops.map(s => Math.round(s.moving * 10000) / 10000);
|
|
@@ -529,7 +543,8 @@ function finish(report, outDir, log) {
|
|
|
529
543
|
}
|
|
530
544
|
if (report.stops) {
|
|
531
545
|
const s = report.stops;
|
|
532
|
-
L.push(` stops ${s.count} stop(s) on ${s.pages} page(s) — stops.png shows each one in order; the picture at a stop is what the audience sees while it waits`)
|
|
546
|
+
L.push(` stops ${s.count} stop(s) on ${s.pages} page(s) — stops.png shows each one in order; the picture at a stop is what the audience sees while it waits`)
|
|
547
|
+
if (s.reviewSkipped) L.push(` not reviewed for stops that are still moving: ${s.reviewSkipped}`);
|
|
533
548
|
if (s.review?.length) {
|
|
534
549
|
L.push(` review ${s.review.length} stop(s) where the picture is still changing (may be on purpose, e.g. something always moving; use --strict to fail on them):`);
|
|
535
550
|
for (const r of s.review.slice(0, 4)) L.push(` - ${r}`);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* npx pixi-effects view my-video.html open it in a browser with a timeline
|
|
8
8
|
*
|
|
9
9
|
* Everything after the command goes to the tool unchanged (`check.mjs`, `render.mjs`, `view.mjs`; their own headers list every option), and so do the
|
|
10
|
-
* exit code and the output.
|
|
10
|
+
* exit code and the output. It is the package's only bin (the older `pixi-effects-check|render|view` are gone).
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { realpathSync } from 'node:fs';
|
package/ai/tools/render.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* It starts a private static server and a private headless Chrome, opens the page, waits for `window.__ready === true`
|
|
8
8
|
* (the contract of skills/pixi-effects/template.html), runs `movie.render()` in the page and streams the file to disk. Nothing is decoded or
|
|
9
|
-
* reviewed: that is `pixi-effects
|
|
9
|
+
* reviewed: that is `pixi-effects check`'s job. Use this one to produce the file (a script, CI, a batch).
|
|
10
10
|
*
|
|
11
11
|
* Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or CHROME=PATH / --chrome PATH). No npm dependencies.
|
|
12
12
|
* Exit code: 0 = the file was written, 1 = the page or the render failed (no file), 2 = bad usage.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* --draft (for looking: half size, low quality, no motion blur; a --quality you give still wins) ·
|
|
18
18
|
* --motion-blur SAMPLES (2-64: each frame is drawn that many times over the shutter and averaged; overrides the page's own motionBlur) ·
|
|
19
19
|
* --shutter FRACTION (with --motion-blur: how long the shutter is open, 0-1 of a frame, default 0.5) ·
|
|
20
|
-
* --video-codec C (avc hevc vp9 av1 vp8) · --video-bitrate B (a quality or bits a second: 8M, 800k) · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --
|
|
20
|
+
* --video-codec C (avc hevc vp9 av1 vp8) · --video-bitrate B (a quality or bits a second: 8M, 800k) · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --strict (exit 1 when the page
|
|
21
21
|
* logged a warning; the file is still written) · --quiet (no progress line) · --timeout SECONDS (default 900) ·
|
|
22
22
|
* --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
|
|
23
23
|
*/
|
|
@@ -25,7 +25,7 @@ import fs from 'node:fs';
|
|
|
25
25
|
import os from 'node:os';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { pathToFileURL } from 'node:url';
|
|
28
|
-
import { findChrome, findRoot, serve, launchChrome, shown, sleep } from './check.mjs';
|
|
28
|
+
import { findChrome, findRoot, serve, launchChrome, shown, sleep, LOAD_PRESENT } from './check.mjs';
|
|
29
29
|
|
|
30
30
|
const FORMATS = ['mp4', 'webm', 'mov', 'mkv', 'wav', 'ogg', 'pdf'];
|
|
31
31
|
const SOUND_ONLY = ['wav', 'ogg'];
|
|
@@ -43,12 +43,15 @@ export function defaultOutput(page, format) {
|
|
|
43
43
|
return path.resolve(path.basename(page).replace(/\.[^.]+$/, '') + '.' + format);
|
|
44
44
|
}
|
|
45
45
|
|
|
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
|
+
|
|
46
48
|
export function parseRenderArgs(argv) {
|
|
47
|
-
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,
|
|
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 };
|
|
48
50
|
const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
|
|
49
51
|
for (let i = 0; i < argv.length; i++) {
|
|
50
52
|
const a = argv[i];
|
|
51
|
-
if (a === '
|
|
53
|
+
if (a === '--help' || a === '-h') o.help = true;
|
|
54
|
+
else if (a === '-o' || a === '--out') o.out = need(i++, a);
|
|
52
55
|
else if (a === '--format') {
|
|
53
56
|
o.format = need(i++, a).toLowerCase();
|
|
54
57
|
if (!FORMATS.includes(o.format)) throw new Error(`--format must be mp4, webm, mov, mkv, wav, ogg or pdf (got "${o.format}")`);
|
|
@@ -79,7 +82,8 @@ export function parseRenderArgs(argv) {
|
|
|
79
82
|
} else if (a === '--draft') o.draft = true;
|
|
80
83
|
else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
|
|
81
84
|
else if (a === '--all-stops') o.allStops = true;
|
|
82
|
-
else if (a === '--
|
|
85
|
+
else if (a === '--strict') o.strict = true;
|
|
86
|
+
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)');
|
|
83
87
|
else if (a === '--quiet') o.quiet = true;
|
|
84
88
|
else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 900);
|
|
85
89
|
else if (a === '--root') o.root = need(i++, a);
|
|
@@ -109,7 +113,7 @@ const renderScript = (format, quality, videoCodec, audioCodec, motionBlur, shutt
|
|
|
109
113
|
const video = { ${[videoBitrate ? `bitrate: ${JSON.stringify(/^\d+(\.\d+)?$/.test(videoBitrate) ? Number(videoBitrate) : videoBitrate)}` : part.draft && !part.qualityGiven ? null : `bitrate: ${JSON.stringify(quality)}`, videoCodec ? `codec: ${JSON.stringify(videoCodec)}` : null].filter(Boolean).join(', ')} };
|
|
110
114
|
const audio = { bitrate: ${JSON.stringify(quality)}${audioCodec ? `, codec: ${JSON.stringify(audioCodec)}` : ''} };
|
|
111
115
|
const blob = ${JSON.stringify(format)} === 'pdf'
|
|
112
|
-
? await
|
|
116
|
+
? await (${LOAD_PRESENT}).catch(e => { throw new Error('a PDF needs pixi-effects/present in the page (add "pixi-effects/present": ".../dist/present.js" to its import map, from the same version as the library): ' + ((e && e.message) || e)); }).then(p => p.exportPDF(movie, { which: ${allStops ? "'stops'" : "'pages'"}, title: document.title }))
|
|
113
117
|
: 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}` : ''} }` : ''} });
|
|
114
118
|
const res = await fetch(${JSON.stringify(UPLOAD)}, { method: 'PUT', body: blob });
|
|
115
119
|
if (!res.ok) throw new Error('could not hand the file to the command (' + res.status + ')');
|
|
@@ -209,8 +213,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
|
|
|
209
213
|
try {
|
|
210
214
|
if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
|
|
211
215
|
const opts = parseRenderArgs(process.argv.slice(2));
|
|
216
|
+
if (opts.help) { console.log(USAGE); process.exit(0); }
|
|
212
217
|
if (!opts.page) {
|
|
213
|
-
console.error(
|
|
218
|
+
console.error(USAGE);
|
|
214
219
|
process.exit(2);
|
|
215
220
|
}
|
|
216
221
|
let last = -1;
|
|
@@ -227,7 +232,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
|
|
|
227
232
|
process.exit(1);
|
|
228
233
|
}
|
|
229
234
|
console.log(`${shown(r.file)} ${kb(r.bytes)} · ${r.duration} s · ${SOUND_ONLY.includes(opts.format) ? 'sound only' : `${r.width}×${r.height} @ ${r.frameRate} fps`} · ${opts.format} · rendered in ${r.seconds} s`);
|
|
230
|
-
process.exit(opts.
|
|
235
|
+
process.exit(opts.strict && r.logs.length ? 1 : 0);
|
|
231
236
|
} catch (e) {
|
|
232
237
|
console.error(`pixi-effects render failed: ${e.message}`);
|
|
233
238
|
process.exit(2);
|
package/ai/tools/view.mjs
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* Click or drag on the timeline to seek the movie; click a layer's name to jump to where it starts; Space plays / pauses,
|
|
10
10
|
* ← / → step a frame (Shift: a second), Home / End jump to the ends. Stop it with Ctrl-C.
|
|
11
11
|
*
|
|
12
|
+
* It watches the page's folder (not `node_modules`, `out`, `reviews`, `check-out`, hidden files). When the work changes it shows "Updated hh:mm:ss — reload (R)"
|
|
13
|
+
* and NOTHING else: the picture you are looking at never switches by itself. Reload (the button, or R) loads the page again and goes back to the frame you were on.
|
|
14
|
+
* The switch "auto" (off by default, remembered) reloads for you, but only while the movie is paused and you are not dragging the timeline.
|
|
15
|
+
*
|
|
12
16
|
* Needs: Node >= 18. A browser (the default one is opened; --no-open prints the address instead). The page must follow
|
|
13
17
|
* skills/pixi-effects/template.html (`window.movie`, `window.__ready = true`) and use a pixi-effects that has `movie.timelineSvg()` (0.7+).
|
|
14
18
|
* Options: --port N (default: a free one) · --no-open · --query "a=1" (added to the page URL) · --root DIR (default: the nearest folder above the page with dist/)
|
|
@@ -22,12 +26,15 @@ import { findRoot, serve } from './check.mjs';
|
|
|
22
26
|
|
|
23
27
|
export const VIEWER = '/__viewer';
|
|
24
28
|
|
|
29
|
+
export const USAGE = 'usage: node ai/tools/view.mjs <page.html> [--port N] [--no-open] [--query a=1] [--root DIR]';
|
|
30
|
+
|
|
25
31
|
export function parseViewArgs(argv) {
|
|
26
32
|
const o = { page: null, port: 0, open: true, query: null, root: null };
|
|
27
33
|
const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
|
|
28
34
|
for (let i = 0; i < argv.length; i++) {
|
|
29
35
|
const a = argv[i];
|
|
30
|
-
if (a === '--
|
|
36
|
+
if (a === '--help' || a === '-h') o.help = true;
|
|
37
|
+
else if (a === '--port') o.port = Math.max(0, Math.round(Number(need(i++, a))) || 0);
|
|
31
38
|
else if (a === '--no-open') o.open = false;
|
|
32
39
|
else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
|
|
33
40
|
else if (a === '--root') o.root = need(i++, a);
|
|
@@ -38,6 +45,26 @@ export function parseViewArgs(argv) {
|
|
|
38
45
|
return o;
|
|
39
46
|
}
|
|
40
47
|
|
|
48
|
+
const IGNORED = new Set(['node_modules', 'out', 'reviews', 'check-out', '.git', 'dist']);
|
|
49
|
+
|
|
50
|
+
/** A short fingerprint of the work in a folder (file count, newest edit, total size): it changes when something is edited or added. Outputs, installs and hidden files are left out. */
|
|
51
|
+
export function folderSignature(dir, { ignore = IGNORED, maxFiles = 4000, maxDepth = 5 } = {}) {
|
|
52
|
+
let count = 0, newest = 0, bytes = 0;
|
|
53
|
+
const walk = (d, depth) => {
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
if (count >= maxFiles) return;
|
|
58
|
+
if (e.name.startsWith('.')) continue;
|
|
59
|
+
const full = path.join(d, e.name);
|
|
60
|
+
if (e.isDirectory()) { if (!ignore.has(e.name) && depth < maxDepth) walk(full, depth + 1); continue; }
|
|
61
|
+
try { const st = fs.statSync(full); count++; newest = Math.max(newest, st.mtimeMs); bytes += st.size; } catch { /* removed while we looked */ }
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
walk(dir, 0);
|
|
65
|
+
return count + ':' + Math.round(newest) + ':' + bytes;
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
const escHtml = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
42
69
|
|
|
43
70
|
/** The viewer page: the composition in an iframe, its timeline under it (zoomable), a playhead, click / drag / keys to seek. */
|
|
@@ -53,6 +80,8 @@ export function viewerHtml({ pageUrl, title }) {
|
|
|
53
80
|
header b { font-size: 14px; font-weight: 600; }
|
|
54
81
|
#time { font-family: ui-monospace, Menlo, monospace; color: var(--dim); }
|
|
55
82
|
#status { color: var(--dim); }
|
|
83
|
+
#update button { background: var(--head); color: #fff; border-color: var(--head); font-weight: 600; }
|
|
84
|
+
#autolabel { color: var(--dim); user-select: none; }
|
|
56
85
|
.zoom { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; }
|
|
57
86
|
#zlabel { min-width: 44px; text-align: center; font-family: ui-monospace, Menlo, monospace; color: var(--dim); }
|
|
58
87
|
button { font: inherit; background: transparent; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 3px 12px; cursor: pointer; }
|
|
@@ -72,7 +101,7 @@ export function viewerHtml({ pageUrl, title }) {
|
|
|
72
101
|
#head line { stroke: var(--head); stroke-width: 2; } #head path { fill: var(--head); }
|
|
73
102
|
</style></head>
|
|
74
103
|
<body>
|
|
75
|
-
<header><b>${escHtml(title)}</b><button id="play" type="button" disabled>▶</button><span id="time">0.00 / 0.00 s · frame 0</span><span id="status">loading the page…</span>
|
|
104
|
+
<header><b>${escHtml(title)}</b><button id="play" type="button" disabled>▶</button><span id="time">0.00 / 0.00 s · frame 0</span><span id="status">loading the page…</span><span id="update" hidden><button id="reload" type="button" title="load the page again, on the frame you are on (R)">Updated — reload (R)</button></span><label id="autolabel" title="reload by itself, but only while the movie is paused"><input type="checkbox" id="auto"> auto</label>
|
|
76
105
|
<span class="zoom"><button id="zout" type="button" title="zoom out (-)" disabled>−</button><span id="zlabel">fit</span><button id="zin" type="button" title="zoom in (+, or Ctrl/⌘ + wheel)" disabled>+</button><button id="zfit" type="button" title="fit the whole movie (0)" disabled>fit</button></span></header>
|
|
77
106
|
<div id="stage"><iframe id="page" src="${escHtml(pageUrl)}" allow="autoplay; fullscreen"></iframe></div>
|
|
78
107
|
<div id="chart"><div id="names"></div><div id="plot"></div></div>
|
|
@@ -87,31 +116,75 @@ export function viewerHtml({ pageUrl, title }) {
|
|
|
87
116
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
88
117
|
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
89
118
|
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
90
|
-
let movie = null, svg = null, head = null, geom = null, zoom = 1, scrubbing = false;
|
|
119
|
+
let movie = null, svg = null, head = null, geom = null, zoom = 1, scrubbing = false, bound = false;
|
|
120
|
+
const $update = document.getElementById('update'), $reload = document.getElementById('reload'), $auto = document.getElementById('auto');
|
|
121
|
+
let seen = null, pending = false;
|
|
122
|
+
try { $auto.checked = localStorage.getItem('pe-view-auto') === '1'; } catch { /* storage may be blocked */ }
|
|
91
123
|
|
|
92
|
-
|
|
124
|
+
/** Wait for the page inside to be a ready movie, then (re)build the timeline. \`restore\` = { frame, playing }: go back where the viewer was. */
|
|
125
|
+
async function attach(restore) {
|
|
126
|
+
movie = null; svg = null; head = null; geom = null;
|
|
93
127
|
for (let i = 0; i < 600 && !movie; i++) {
|
|
94
128
|
try { const w = frame.contentWindow; if (w && w.__ready === true && w.movie) movie = w.movie; } catch { /* still loading */ }
|
|
95
129
|
if (!movie) await sleep(200);
|
|
96
130
|
}
|
|
97
|
-
if (!movie) {
|
|
131
|
+
if (!movie) {
|
|
132
|
+
let logs = '';
|
|
133
|
+
try { logs = (frame.contentWindow.__logs || []).slice(-3).join(' | '); } catch { /* unreadable */ }
|
|
134
|
+
$status.textContent = 'the page did not become ready (window.__ready === true and window.movie are needed)' + (logs ? ' — ' + logs : '');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
98
137
|
if (typeof movie.timelineSvg !== 'function') { $status.textContent = 'this page loads a pixi-effects without movie.timelineSvg() (0.7+ needed)'; return; }
|
|
99
138
|
layout();
|
|
100
139
|
mount();
|
|
101
|
-
|
|
140
|
+
if (restore) { await seekFrame(restore.frame); if (restore.playing) movie.play(); }
|
|
141
|
+
}
|
|
142
|
+
attach(null);
|
|
143
|
+
|
|
144
|
+
/** Load the page again and come back to the same frame (and playing, if it was). Only ever on a request. */
|
|
145
|
+
async function reload() {
|
|
146
|
+
const restore = movie ? { frame: movie.currentFrame, playing: movie.isPlaying } : null;
|
|
147
|
+
pending = false; $update.hidden = true;
|
|
148
|
+
movie = null; svg = null; head = null; geom = null; plot.innerHTML = ''; names.innerHTML = '';
|
|
149
|
+
const loaded = new Promise(r => frame.addEventListener('load', r, { once: true }));
|
|
150
|
+
try { frame.contentWindow.location.reload(); } catch { frame.src = frame.src; }
|
|
151
|
+
await loaded;
|
|
152
|
+
await attach(restore);
|
|
153
|
+
}
|
|
154
|
+
$reload.addEventListener('click', reload);
|
|
155
|
+
$auto.addEventListener('change', () => { try { localStorage.setItem('pe-view-auto', $auto.checked ? '1' : '0'); } catch { /* storage may be blocked */ } });
|
|
156
|
+
// auto only while the movie is paused and nobody is dragging: otherwise the notice waits
|
|
157
|
+
const maybeAuto = () => { if (pending && $auto.checked && movie && !movie.isPlaying && !scrubbing) reload(); };
|
|
102
158
|
|
|
103
|
-
//
|
|
159
|
+
// does the work still look the same? (the server's number goes up when a file in the folder changes)
|
|
160
|
+
async function poll() {
|
|
161
|
+
try {
|
|
162
|
+
const v = await (await fetch('/__viewer/version', { cache: 'no-store' })).json();
|
|
163
|
+
if (seen === null) seen = v.version;
|
|
164
|
+
else if (v.version !== seen) {
|
|
165
|
+
seen = v.version; pending = true;
|
|
166
|
+
const d = new Date(v.at), p2 = n => String(n).padStart(2, '0');
|
|
167
|
+
$reload.textContent = 'Updated ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()) + ' — reload (R)';
|
|
168
|
+
$update.hidden = false;
|
|
169
|
+
}
|
|
170
|
+
} catch { /* the viewer is stopped */ }
|
|
171
|
+
}
|
|
172
|
+
poll(); setInterval(poll, 800);
|
|
173
|
+
|
|
174
|
+
// The page is shown whole and in the MIDDLE: as tall as its picture, scaled down (never up) so the timeline stays in view under it. A page that
|
|
175
|
+
// keeps its picture at the left edge (the template does) is still centred: the iframe is as wide as the page's own content, then centred.
|
|
104
176
|
function layout() {
|
|
105
177
|
const stageW = stage.clientWidth || innerWidth, headH = document.querySelector('header').offsetHeight;
|
|
106
|
-
let h = 560;
|
|
107
|
-
|
|
108
|
-
fit(1);
|
|
178
|
+
let h = 560, cw = stageW;
|
|
179
|
+
frame.style.width = stageW + 'px'; frame.style.transform = 'none'; frame.style.marginLeft = '0px';
|
|
109
180
|
try {
|
|
110
181
|
const c = frame.contentDocument.querySelector('canvas');
|
|
111
|
-
if (c) h = Math.round(
|
|
182
|
+
if (c) { const r = c.getBoundingClientRect(); h = Math.round(r.height + 40); cw = Math.min(stageW, Math.round(r.right + r.left)); }
|
|
112
183
|
} catch { /* a page we cannot read */ }
|
|
113
184
|
const k = Math.min(1, Math.max(200, innerHeight - headH - 240) / h);
|
|
114
|
-
|
|
185
|
+
frame.style.width = cw + 'px';
|
|
186
|
+
frame.style.transform = 'scale(' + k + ')';
|
|
187
|
+
frame.style.marginLeft = Math.max(0, Math.round((stageW - cw * k) / 2)) + 'px';
|
|
115
188
|
frame.style.height = h + 'px';
|
|
116
189
|
stage.style.height = Math.round(h * k) + 'px';
|
|
117
190
|
}
|
|
@@ -180,10 +253,12 @@ export function viewerHtml({ pageUrl, title }) {
|
|
|
180
253
|
const data = movie.timelineData();
|
|
181
254
|
names.innerHTML = '<div class="ruler" style="height:34px"></div>' + data.rows.map(r =>
|
|
182
255
|
'<div class="name" data-start="' + r.start + '" style="padding-left:' + (8 + r.depth * 14) + 'px" title="' + esc(r.path + ' · ' + r.type) + '">' + esc(r.name) + '</div>').join('');
|
|
183
|
-
names.addEventListener('click', e => { const n = e.target.closest('.name'); if (n) seekTime(parseFloat(n.dataset.start) || 0); });
|
|
184
256
|
render(null);
|
|
185
257
|
$status.textContent = 'click or drag the timeline · Space plays · ← → step a frame (Shift: a second) · + / − zoom (Ctrl/⌘ + wheel) · click a layer name to jump to its start';
|
|
186
258
|
$play.disabled = false; $zfit.disabled = true;
|
|
259
|
+
if (bound) return; // the controls below are bound once; a reload only rebuilds the timeline
|
|
260
|
+
bound = true;
|
|
261
|
+
names.addEventListener('click', e => { const n = e.target.closest('.name'); if (n && movie) seekTime(parseFloat(n.dataset.start) || 0); });
|
|
187
262
|
|
|
188
263
|
$play.addEventListener('click', toggle);
|
|
189
264
|
$zin.addEventListener('click', () => setZoom(zoom * 1.6, playheadAnchor()));
|
|
@@ -198,6 +273,8 @@ export function viewerHtml({ pageUrl, title }) {
|
|
|
198
273
|
}, { passive: false });
|
|
199
274
|
addEventListener('keydown', e => {
|
|
200
275
|
if (e.target && /input|textarea|select/i.test(e.target.tagName)) return;
|
|
276
|
+
if ((e.key === 'r' || e.key === 'R') && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); reload(); return; }
|
|
277
|
+
if (!movie) return;
|
|
201
278
|
const fps = movie.frameRate, f = movie.currentFrame;
|
|
202
279
|
if (e.key === ' ') { e.preventDefault(); toggle(); }
|
|
203
280
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); seekFrame(f - (e.shiftKey ? fps : 1)); }
|
|
@@ -210,9 +287,11 @@ export function viewerHtml({ pageUrl, title }) {
|
|
|
210
287
|
});
|
|
211
288
|
requestAnimationFrame(tick);
|
|
212
289
|
}
|
|
213
|
-
const toggle = () => { if (movie.isPlaying) movie.pause(); else movie.play(); };
|
|
290
|
+
const toggle = () => { if (!movie) return; if (movie.isPlaying) movie.pause(); else movie.play(); };
|
|
214
291
|
|
|
215
292
|
function tick() {
|
|
293
|
+
if (!movie || !head || !geom) { maybeAuto(); requestAnimationFrame(tick); return; }
|
|
294
|
+
maybeAuto();
|
|
216
295
|
const t = nowT(), x = tToX(t);
|
|
217
296
|
head.setAttribute('transform', 'translate(' + x.toFixed(2) + ',0)');
|
|
218
297
|
$time.textContent = t.toFixed(2) + ' / ' + geom.dur.toFixed(2) + ' s · frame ' + movie.currentFrame;
|
|
@@ -244,19 +323,32 @@ export async function startViewer(opts) {
|
|
|
244
323
|
const pageUrl = '/' + rel + (opts.query ? '?' + opts.query : '');
|
|
245
324
|
const title = path.basename(pagePath).replace(/\.[^.]+$/, '');
|
|
246
325
|
const html = viewerHtml({ pageUrl, title });
|
|
326
|
+
// The work's version: a number that goes up when a file in the page's folder changes. The viewer only SHOWS it; it never reloads by itself.
|
|
327
|
+
const dir = path.dirname(pagePath);
|
|
328
|
+
let version = 1, changedAt = Date.now(), signature = folderSignature(dir);
|
|
329
|
+
const timer = setInterval(() => { const now = folderSignature(dir); if (now !== signature) { signature = now; version++; changedAt = Date.now(); } }, opts.watchEvery ?? 400);
|
|
330
|
+
timer.unref();
|
|
247
331
|
const { server, port } = await serve(root, (req, res) => {
|
|
248
|
-
if (req.method !== 'GET'
|
|
332
|
+
if (req.method !== 'GET') return false;
|
|
333
|
+
const pathname = req.url.split('?')[0];
|
|
334
|
+
if (pathname === VIEWER + '/version') {
|
|
335
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ version, at: changedAt }));
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
if (pathname !== VIEWER) return false;
|
|
249
339
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }).end(html);
|
|
250
340
|
return true;
|
|
251
341
|
}, opts.port);
|
|
342
|
+
server.on('close', () => clearInterval(timer));
|
|
252
343
|
return { server, url: `http://127.0.0.1:${port}${VIEWER}` };
|
|
253
344
|
}
|
|
254
345
|
|
|
255
346
|
if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { // the real path: npm starts a bin through a symlink
|
|
256
347
|
try {
|
|
257
348
|
const opts = parseViewArgs(process.argv.slice(2));
|
|
349
|
+
if (opts.help) { console.log(USAGE); process.exit(0); }
|
|
258
350
|
if (!opts.page) {
|
|
259
|
-
console.error(
|
|
351
|
+
console.error(USAGE);
|
|
260
352
|
process.exit(2);
|
|
261
353
|
}
|
|
262
354
|
const { url } = await startViewer(opts);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Container } from 'pixi.js';
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape } from './types-BzIB65Vk.cjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves a routed keyframe path to a concrete tween target.
|
|
6
|
+
* `path` is everything after the registered prefix + dot (for
|
|
7
|
+
* `three.cube.rotation.y` under prefix `three`, path = `cube.rotation.y`).
|
|
8
|
+
* Return null to skip the key (the router is expected to have warned).
|
|
9
|
+
*/
|
|
10
|
+
type PathRouter = (path: string) => {
|
|
11
|
+
target: object;
|
|
12
|
+
prop: string;
|
|
13
|
+
} | null;
|
|
14
|
+
type PathRouters = Record<string, PathRouter>;
|
|
15
|
+
|
|
16
|
+
/** Variables available to expression evaluation. */
|
|
17
|
+
interface Scope {
|
|
18
|
+
w: number;
|
|
19
|
+
h: number;
|
|
20
|
+
W: number;
|
|
21
|
+
H: number;
|
|
22
|
+
GW: number;
|
|
23
|
+
GH: number;
|
|
24
|
+
contain: number;
|
|
25
|
+
cover: number;
|
|
26
|
+
t: number;
|
|
27
|
+
d: number;
|
|
28
|
+
T: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare abstract class Sequence {
|
|
32
|
+
spec: SequenceSpec;
|
|
33
|
+
parent: CompositionShape | null;
|
|
34
|
+
root: CompositionShape;
|
|
35
|
+
target: Container | null;
|
|
36
|
+
intrinsicWidth: number;
|
|
37
|
+
intrinsicHeight: number;
|
|
38
|
+
at: number;
|
|
39
|
+
duration: number | undefined;
|
|
40
|
+
constructor(spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape);
|
|
41
|
+
abstract build(): Promise<void>;
|
|
42
|
+
buildFilters(): void;
|
|
43
|
+
scope(): Scope;
|
|
44
|
+
/**
|
|
45
|
+
* Prefix routers contributed to the keyframe pipeline (e.g. `three.` in
|
|
46
|
+
* pixi-effects/three). Base sequences route nothing extra.
|
|
47
|
+
*/
|
|
48
|
+
protected pathRouters(): PathRouters;
|
|
49
|
+
/**
|
|
50
|
+
* The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
|
|
51
|
+
* out of them: it is not a property of the display object.
|
|
52
|
+
*/
|
|
53
|
+
protected displayProps(): {
|
|
54
|
+
initial: SequenceSpec['initial'];
|
|
55
|
+
keyframes: SequenceSpec['keyframes'];
|
|
56
|
+
};
|
|
57
|
+
destroy(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { type PathRouters as P, Sequence as S };
|