pixi-effects 0.11.0 → 0.12.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 +16 -0
- package/README.md +11 -8
- package/ai/SKILL.md +2 -1
- package/ai/reference/cheatsheet.md +12 -1
- package/ai/reference/pitfalls.md +4 -0
- package/ai/reference/recipes.md +57 -0
- package/ai/template.html +8 -4
- package/ai/tools/check.mjs +17 -1
- package/ai/tools/render.mjs +13 -9
- package/dist/{Base-CyvSLoCP.d.ts → Base-CYH9vPCH.d.ts} +1 -1
- package/dist/{Base-CwUxTfja.d.cts → Base-W3sQzGPO.d.cts} +1 -1
- package/dist/Controller.cjs +58 -0
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.d.cts +13 -3
- package/dist/Controller.d.ts +13 -3
- package/dist/Controller.js +58 -0
- package/dist/Controller.js.map +1 -1
- package/dist/{Movie-nzmWsxWX.d.ts → Movie-CY2QPVVM.d.cts} +90 -2
- package/dist/{Movie-DSSNOF3T.d.cts → Movie-EBF6_RfX.d.ts} +90 -2
- package/dist/Presenter.cjs +671 -0
- package/dist/Presenter.cjs.map +1 -0
- package/dist/Presenter.d.cts +123 -0
- package/dist/Presenter.d.ts +123 -0
- package/dist/Presenter.js +669 -0
- package/dist/Presenter.js.map +1 -0
- package/dist/index.cjs +645 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +162 -5
- package/dist/index.d.ts +162 -5
- package/dist/index.js +640 -37
- package/dist/index.js.map +1 -1
- package/dist/loader.css +1 -1
- package/dist/three.d.cts +2 -2
- package/dist/three.d.ts +2 -2
- package/dist/{types-BXu28nJj.d.cts → types-mpqQO8MJ.d.cts} +39 -1
- package/dist/{types-BXu28nJj.d.ts → types-mpqQO8MJ.d.ts} +39 -1
- package/docs/api.md +79 -0
- package/docs/dsl.md +48 -0
- package/llms-full.txt +202 -2
- package/llms.txt +3 -3
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
**Added**
|
|
6
|
+
|
|
7
|
+
- **Audio-reactive visuals.** `audioEnvelope(source, { frameRate, bands, beatBand, beatSensitivity })` analyses a sound (a URL, Blob / File, ArrayBuffer or AudioBuffer) into one value per video frame for its loudness (`level`) and the energy in frequency bands (`bass` / `mid` / `treble` by default, or bands you name in Hz), 0–1 on a perceptual scale measured against the loudest, plus the times of its beats and its tempo. `react(env, { duration, props })` bakes that into keyframes (`base + amount × level`; `band`, `beats` + `decay`, `attack` / `release` smoothing, `invert`, `curve`, `audioOffset`, `loop`), so playback, seeking and export agree. `bpmEnvelope(120, { duration })` is a stand-in with no audio file (kick, snare and hats exactly on a tempo). The gallery piece Night Drive now listens to its music (24 bands mirrored into 48 bars, rings on the real kicks) instead of faking a spectrum on an invented tempo.
|
|
8
|
+
- **Presentations.** A composition can say where a presentation pauses: `composition: { stops: [2, { at: 5, page: 'The problem', notes, advance }] }` (a stop with `page` begins a page, the others are steps of it). `movie.next()` plays to the next stop and pauses exactly on its frame (pressed again while playing it skips to that stop), `prev()` jumps back to the previous one, `goToStop(i)` / `goToPage(n)` jump, `movie.on('stop')` says where it landed, and `movie.stops` / `stopIndex` / `pageIndex` / `pageCount` / `currentStop` tell where the playhead is. An ordinary `play()` ignores the stops.
|
|
9
|
+
- **`Presenter`** (`pixi-effects/presenter`): the player for it. Arrows, Space, Enter, PageDown, a click, a tap or a swipe move on; back, Home / End, a page number then Enter, B / W for a black or white screen, F for fullscreen, `?` for the list of keys; a page counter, the page name and a progress line that fade out when idle; `advance` for kiosks and `loop`. A movie with stops shown through the **`Controller`** gets a mark per stop on its seek bar and a Present button that hands the page to a Presenter (Esc gives the bar back).
|
|
10
|
+
- **The page overview and the presenter view.** In a `Presenter`, **G** shows every page as a picture (arrows and Enter, or a click, to jump) and **P** opens a second window for the speaker: the picture on screen (live), the next picture, the notes of the page, a timer, and Back / Next buttons. The pictures are made once, before the audience sees anything (`start()`, behind a "Preparing…" cover), with the new `movie.stopImages()` (a picture per page, or per stop, leaving the playhead where it was).
|
|
11
|
+
- **A deck as a PDF.** `movie.exportPDF()` (one PDF page per page of the deck, JPEG at the canvas size, no dependencies) and `npx pixi-effects-render talk.html -o talk.pdf` (`--all-stops` for a page per stop).
|
|
12
|
+
- **`deck({ pages, transition })`**: a talk as one movie. Each page is a nested composition laid out after the one before (its layers use the page's own time), the stops come from the pages, and the transition joins them with the existing crossfade / slide / wipe / … transitions. A gallery piece, `The Quiet Hours`, is a five-page deck (37 pieces now); a new guide page explains presenting.
|
|
13
|
+
- **`pixi-effects-check` knows decks.** For a page with `stops` it writes `stops.png` (one labelled picture of every stop, in order), prints the number of stops and pages, and puts them in `report.json` (`stops`).
|
|
14
|
+
|
|
15
|
+
**Fixed**
|
|
16
|
+
|
|
17
|
+
- A transition that ends exactly where its layer ends could fail with "not covered by `from`" because of floating-point sums (2.4 + 4.3 is 6.699999999999999): the coverage check now allows 1e-6 s.
|
|
18
|
+
|
|
3
19
|
## 0.11.0
|
|
4
20
|
|
|
5
21
|
**Performance**
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pixi-effects
|
|
2
2
|
|
|
3
|
-
> **Status**: experimental — current release `0.
|
|
3
|
+
> **Status**: experimental — current release `0.12.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.12.0` adds presentations (`stops`, `Presenter`, `deck()`, page overview, presenter view, PDF export) and audio-reactive visuals (`audioEnvelope()`, `react()`), `0.11.0` adds particles, motion blur, a shared loader and a much faster build for pieces with many layers, `0.10.0` adds seeded randomness, `wiggle` and `stagger`, null layers with `parent`, `animateText`, `followPath` and path morphing, and click-to-play in the player, `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
|
|
4
4
|
|
|
5
|
-
**[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** ·
|
|
5
|
+
**[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 37 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
|
|
6
6
|
|
|
7
7
|
Declarative composition and video rendering for the web. After Effects-style timelines on top of [PixiJS v8](https://pixijs.com/) and [GSAP](https://gsap.com/), with strict TypeScript types. Render to MP4 / WebM / MOV via [mediabunny](https://mediabunny.dev/).
|
|
8
8
|
|
|
@@ -12,7 +12,9 @@ Declarative composition and video rendering for the web. After Effects-style tim
|
|
|
12
12
|
- **Filters by name** — `filters: [{ type: 'glow', outerStrength: 3 }]`, no import: `blur noise alpha colorMatrix` (pixi.js) and the 38 filters of [`pixi-filters`](https://github.com/pixijs/filters) (`dropShadow crt rgbSplit oldFilm glitch pixelate twist …`), animated with `'filters.<name>.<option>'`. Any other PixiJS `Filter` goes in as `{ type: 'custom' }`.
|
|
13
13
|
- **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.
|
|
14
14
|
- **Masks, transitions, chroma key** — inline masks; chroma key; seven scene transitions (`crossfade`, `wipe`, `iris`, `slide`, `dip`, `zoom`, `dissolve`).
|
|
15
|
-
- **Presets** — `kenBurns` for stills, `withFade` for fade-in/out.
|
|
15
|
+
- **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).
|
|
16
|
+
- **Presentations** — `stops` in the composition pause the movie where you say; `Presenter` (`pixi-effects/presenter`) 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 (`movie.exportPDF()`, `pixi-effects-render talk.html -o talk.pdf`). See the [Presenting guide](https://yjmtmtk.github.io/pixi-effects/guide/presenting.html).
|
|
17
|
+
- **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.
|
|
16
18
|
- **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.
|
|
17
19
|
- **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`).
|
|
18
20
|
- **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`).
|
|
@@ -40,8 +42,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
|
|
|
40
42
|
"gsap": "https://esm.sh/gsap@3.12.5",
|
|
41
43
|
"gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
|
|
42
44
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
43
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
44
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
45
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/index.js",
|
|
46
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/Controller.js"
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
</script>
|
|
@@ -52,7 +54,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
|
|
|
52
54
|
</script>
|
|
53
55
|
```
|
|
54
56
|
|
|
55
|
-
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.
|
|
57
|
+
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.12.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.
|
|
56
58
|
|
|
57
59
|
> **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
|
|
58
60
|
>
|
|
@@ -68,7 +70,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
|
|
|
68
70
|
"imports": {
|
|
69
71
|
"...": "(everything from the importmap above)",
|
|
70
72
|
"three": "https://esm.sh/three@0.178.0",
|
|
71
|
-
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
73
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/three.js"
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
</script>
|
|
@@ -167,6 +169,7 @@ This library is designed to be written by AI: a video is plain data, and every m
|
|
|
167
169
|
|
|
168
170
|
- **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.
|
|
169
171
|
- **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), `--fail-on-warn` (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.
|
|
172
|
+
- **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.
|
|
170
173
|
- **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.
|
|
171
174
|
|
|
172
175
|
They ship in the npm package (`node_modules/pixi-effects/ai/`).
|
|
@@ -174,7 +177,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
|
|
|
174
177
|
## Documentation
|
|
175
178
|
|
|
176
179
|
- [**DSL reference**](./docs/dsl.md) — composition, sequences, 3D layers & camera, three.js layer, keyframes, expressions, filters
|
|
177
|
-
- [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/three`, events, render options
|
|
180
|
+
- [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/presenter`, `pixi-effects/three`, events, render options
|
|
178
181
|
- [**Examples**](./examples/) — runnable HTML files (read in order):
|
|
179
182
|
- `01-hello.html` — minimum viable composition
|
|
180
183
|
- `02-keyframes.html` — keyframes, easings, expressions
|
package/ai/SKILL.md
CHANGED
|
@@ -19,6 +19,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
19
19
|
- `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, or overlapping other text). Run it at several frames; fix every issue it names.
|
|
20
20
|
- `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
|
|
21
21
|
- `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix.
|
|
22
|
+
- **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await movie.stopImages({ which: 'pages' })` / `movie.exportPDF()` give the pages themselves.
|
|
22
23
|
From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
|
|
23
24
|
6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100).
|
|
24
25
|
|
|
@@ -32,7 +33,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
32
33
|
- **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
|
|
33
34
|
- **2.5D needs `threeD: true`** on each layer plus a `{ type: 'camera' }` layer; camera props go in `initial`/keyframes; keep every `z` below the camera distance (≈ 989 at 720p, fov 40); under a dolly zoom only `z = 0` stays fixed.
|
|
34
35
|
- **Group / parent layers = `{ type: 'null', name: 'rig' }`** (draws nothing, only moves) + `parent: 'rig'` on the layers that should follow it: their `x`/`y` are then measured from the null's origin, and the null's position / rotation / scale / alpha carry them (orbits, clock hands, grouped fades). Nulls chain. Only `null` layers can be parents; not for `threeD` layers.
|
|
35
|
-
- **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes) `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes), shared masks. (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
|
|
36
|
+
- **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes) `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects-render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes), shared masks. (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
|
|
36
37
|
- **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
|
|
37
38
|
- **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump.
|
|
38
39
|
|
|
@@ -110,7 +110,7 @@ transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' |
|
|
|
110
110
|
## Presets
|
|
111
111
|
|
|
112
112
|
```js
|
|
113
|
-
import { kenBurns, withFade, wiggle, stagger, followPath, particles, random } from 'pixi-effects';
|
|
113
|
+
import { kenBurns, withFade, wiggle, stagger, followPath, particles, deck, audioEnvelope, bpmEnvelope, react, random } from 'pixi-effects';
|
|
114
114
|
kenBurns({ asset, name, at, duration, motion: 'still'|'scale'|'rotation'|'position',
|
|
115
115
|
fit: 'cover'|'contain', ease, /* scale */ origin, zoom, direction, /* rotation */ angle, /* position */ from, to })
|
|
116
116
|
withFade(spec, { in: 0.5, out: 0.5 }) // alpha fade; `out` needs spec.duration
|
|
@@ -123,6 +123,17 @@ keyframes: followPath({ d: 'M 120 560 C 360 120 920 120 1160 560', duration: 4,
|
|
|
123
123
|
...particles({ count: 90, at: 2, emit: 0 /* 0 = burst; or seconds to spread births */, life: [1, 1.8], area: { x: 640, y: 330 /*, width, height */ }, angle: [0, 360] /* 0 right, 90 down, −90 up */,
|
|
124
124
|
speed: [120, 420], gravity: 240, wind: 0, drag: 1.2, sway: { amp: 30, freq: 0.6 }, size: [2, 4.5], scale: [1, 0], fade: { in: 0.2, out: 0.6 },
|
|
125
125
|
colors: ['#ffd166', '#ff6b9d'], shape: 'circle'|'rect'|'star', spin: [-200, 200], blendMode: 'add', seed: 1, name: 'burst', template: { type: 'image', asset } }) // seeded layers, paths baked into keyframes
|
|
126
|
+
// a presentation: stops pause playback (`composition.stops: [2, { at: 5, page: 'Name', notes, advance }]`; a `page` stop begins a page, the others are steps); deck() lays pages out
|
|
127
|
+
await movie.init({ canvas, width, height, frameRate, ...deck({ transition: { kind: 'slide', direction: 'left', duration: 0.6 },
|
|
128
|
+
pages: [{ name: 'Hello', duration: 3.2 /* last stop + transition */, stops: [2.6] /* page-local */, notes, sequences: [ /* page-local time; a step = a layer starting at the previous stop */ ] }] }) });
|
|
129
|
+
new Presenter(movie, { canvas }); // import { Presenter } from 'pixi-effects/presenter': → ↓ Space Enter click = next, ← = back, Home / End, number+Enter, B / W, F, ?
|
|
130
|
+
movie.next() / prev() / goToStop(i) / goToPage(n) / movie.stops / movie.on('stop', …) // Controller shows the stops on the seek bar + a Present button
|
|
131
|
+
// Presenter extras: G = page overview (pictures), P = presenter view (a second window: live picture, next picture, notes, timer); start() makes the pictures before the audience sees anything
|
|
132
|
+
await movie.stopImages({ as: 'dataURL', scale: 0.25 }) // [{ stop, page, image }] one per page (its last stop); { which: 'stops' } one per stop
|
|
133
|
+
await movie.exportPDF({ title }) // Blob: a PDF page per page of the deck; CLI: pixi-effects-render talk.html -o talk.pdf (--all-stops)
|
|
134
|
+
const env = await audioEnvelope('music.mp3', { frameRate: 30 }) // BEFORE movie.init: .series.level|bass|mid|treble (0–1 per frame), .beats [s], .bpm, .at(t, band); bands: { name: [Hz, Hz] }
|
|
135
|
+
const env = bpmEnvelope(120, { duration: 12 }) // no audio file: kick / snare / hats exactly on a tempo
|
|
136
|
+
keyframes: react(env, { duration: 12, props: { scale: { base: 1, amount: 0.4, band: 'bass', attack: 0.02, release: 0.2 }, alpha: { base: 0.4, amount: 0.6, beats: true, decay: 0.15 } }, audioOffset: 0, loop: false })
|
|
126
137
|
const r = random(7); r(); r(); // a repeatable stream in 0…1 for JS loops (never Math.random())
|
|
127
138
|
```
|
|
128
139
|
`kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
|
package/ai/reference/pitfalls.md
CHANGED
|
@@ -78,3 +78,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
78
78
|
51. **Arcs and rings are `shape: 'arc'`** (`radius`, `innerRadius`, `startAngle`, `endAngle`, degrees; 0° = 3 o'clock, clockwise). A progress ring is an arc with `startAngle: -90` and an animated `endAngle` (−90 → 270); `strokeCap: 'round'` rounds BOTH ends. Fill it for a pie slice, add `innerRadius` for a donut slice (a 120-polygon donut is no longer needed). Style (`strokeColor`, `strokeWidth`, `fillColor`) goes in `initial`; `strokeCap` / `strokeJoin` may be top level. *(fixed)*
|
|
79
79
|
52. **Measure and split text with `measureText` / `splitText`, not by hand.** `splitText(text, style, { by, x, y, align })` returns each piece's x / y / width in the renderer's own font metrics (kerning kept; verified: a whole line vs the same line in per-letter layers differs only by anti-aliasing). The pieces are for text layers with the default top-left anchor and the same `style`. Load a web font before measuring (`await document.fonts.load('24px "Name"')`) or the fallback font's widths are used. Size a pill from `measureText(...).width + padding` instead of `0.58 × fontSize × length`. *(fixed)*
|
|
80
80
|
53. **Draw-on and typewriter need no masks or per-letter layers.** `trimStart` / `trimEnd` (0–1 of the outline, animatable, every shape but `arc`) draw a stroke on: `trimEnd: 0` plus a keyframe `to: { trimEnd: 1 }`. The fill is not trimmed, so fade `fillAlpha` in afterwards; use `strokeCap: 'round'`. Text changes over time with `set: { text }` (swap at a time) and `visibleChars` (typewriter; left-align it so it grows rightward). A `to: { text }` cannot tween a string and warns. *(fixed)*
|
|
81
|
+
54. **A deck page lasts until its last stop plus the transition.** `deck()` lays pages out one after another in page-local time and a `transition` overlaps them: a page shorter than its last stop (or shorter than the transition) loses its final step or flashes. A step is a layer that starts at the previous stop (`at: <previous stop>`), so the thing that appears on a press is fully visible when the movie stops. The transition option is `kind`, not `type`.
|
|
82
|
+
55. **Presenter and Controller both listen to click and keys.** Use the `Presenter` for a talk (or let the Controller's Present button start one); do not run both on the same canvas yourself. Ordinary `play()` ignores stops; `movie.next()` plays to the next stop (a second press skips to it), `prev()` jumps back (nothing plays in reverse).
|
|
83
|
+
56. **Analyse audio before `init`, and give it the movie's frame rate.** `const env = await audioEnvelope('music.mp3', { frameRate })` is async; `react()` is pure and bakes the keyframes, so there is no per-frame callback. A custom `bands` list replaces the defaults (include `bass` if you want it; beats are picked there). No file yet: `bpmEnvelope(120, { duration })` is a stand-in with a kick on every beat. Offset the music with `audioOffset` when the audio layer does not start at 0.
|
|
84
|
+
57. **Review a deck by its stops, not by its timeline.** `pixi-effects-check` writes `stops.png` (one picture per stop, in order) for a page with `stops`; the picture at a stop is what the audience reads while it waits.
|
package/ai/reference/recipes.md
CHANGED
|
@@ -507,6 +507,63 @@ Both outlines are in canvas coordinates. Closed outlines are lined up so they do
|
|
|
507
507
|
|
|
508
508
|
---
|
|
509
509
|
|
|
510
|
+
## Visuals that follow the music (`audioEnvelope`, `react`)
|
|
511
|
+
|
|
512
|
+
Analyse the sound **before** `movie.init` (`const env = await audioEnvelope('music.mp3', { frameRate: 30 })`: levels per band per frame and the beats), then `react()` bakes it into keyframes, so playback and export agree. With no audio file at hand, `bpmEnvelope(120, { duration })` is a drum pattern on a tempo (kick = `bass`, snare = `mid`, hats = `treble`), exactly on the beat; it is also how to match sound effects you place on that tempo.
|
|
513
|
+
|
|
514
|
+
```js
|
|
515
|
+
// @recipe audio-react
|
|
516
|
+
const env = bpmEnvelope(120, { duration: 8, frameRate: 30 }); // with a file: `const env = await audioEnvelope(url, { frameRate: 30 })`
|
|
517
|
+
const bands = ['bass', 'bass', 'bass', 'mid', 'mid', 'mid', 'treble', 'treble'];
|
|
518
|
+
const bars = bands.map((band, i) => ({
|
|
519
|
+
type: 'shape', shape: 'rect', name: 'bar-' + i, width: 60, height: 10, cornerRadius: 6, anchorY: 1, duration: 8,
|
|
520
|
+
initial: { x: 330 + i * 86, y: 600, fillColor: ['#ff2d95', '#8a4dff', '#22e0ff'][['bass', 'mid', 'treble'].indexOf(band)] },
|
|
521
|
+
keyframes: react(env, { duration: 8, props: { height: { base: 10, amount: 260, band, release: 0.12 } } }), // an equaliser bar: up fast, down slowly
|
|
522
|
+
}));
|
|
523
|
+
return [
|
|
524
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#07060f' } },
|
|
525
|
+
{ type: 'shape', shape: 'circle', name: 'orb', radius: 90, duration: 8, initial: { x: 640, y: 260, fillColor: '#ffffff' },
|
|
526
|
+
keyframes: react(env, { duration: 8, props: { scale: { base: 1, amount: 0.5, beats: true, decay: 0.16 }, alpha: { base: 0.55, amount: 0.45, band: 'bass', attack: 0.02, release: 0.15 } } }) }, // a flash on every kick
|
|
527
|
+
...bars,
|
|
528
|
+
];
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
---
|
|
532
|
+
|
|
533
|
+
## A deck: pages and steps for a presenter (`deck`, `Presenter`)
|
|
534
|
+
|
|
535
|
+
`deck({ pages, transition })` lays pages out one after the other (each page's layers are written in the page's own time) and turns their `stops` into where a `Presenter` pauses. A **step** is a layer that starts at the previous stop and ends at this one: "press next" plays exactly that animation and stops. A page should last its last stop plus the transition.
|
|
536
|
+
|
|
537
|
+
```js
|
|
538
|
+
// @recipe deck
|
|
539
|
+
const paper = { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#f4efe6' } };
|
|
540
|
+
const style = { fontFamily: 'Georgia, serif', fontSize: 96, fill: '#1c1a17' };
|
|
541
|
+
const line = (text, y, at, dur) => animateText(text, style, { by: 'words', x: 96, y, align: 'left', name: 'l' + y, at, duration: dur - at, in: { from: { y: 40, alpha: 0 }, duration: 0.8, ease: 'power3.out' }, stagger: { each: 0.1 } });
|
|
542
|
+
const T = 0.6;
|
|
543
|
+
const d = deck({
|
|
544
|
+
transition: { kind: 'slide', direction: 'left', duration: T, ease: 'power3.inOut' },
|
|
545
|
+
pages: [
|
|
546
|
+
{ name: 'Hello', duration: 2 + T, stops: [2], sequences: [paper, ...line('The Quiet Hours', 260, 0.4, 2 + T)] },
|
|
547
|
+
{ name: 'Two steps', duration: 3.4 + T, stops: [1.6, 3.4], notes: 'pause after the first line',
|
|
548
|
+
sequences: [paper, ...line('We measure speed.', 200, 0.4, 3.4 + T), ...line('We rarely measure attention.', 340, 1.6, 3.4 + T)] },
|
|
549
|
+
{ name: 'Thanks', duration: 3, stops: [2], sequences: [paper, ...line('Go slowly.', 260, 0.4, 3)] },
|
|
550
|
+
],
|
|
551
|
+
});
|
|
552
|
+
return { duration: d.duration, sequences: d.composition.sequences, transitions: d.composition.transitions };
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
```js
|
|
556
|
+
// @docs-only
|
|
557
|
+
// in the page: the deck is spread into init; the Presenter (or the Controller, which then shows the stops and a Present button) plays it
|
|
558
|
+
import { Movie, deck } from 'pixi-effects';
|
|
559
|
+
import { Presenter } from 'pixi-effects/presenter';
|
|
560
|
+
const movie = new Movie();
|
|
561
|
+
await movie.init({ canvas, width: 1280, height: 720, frameRate: 30, ...deck({ /* as above */ }) });
|
|
562
|
+
new Presenter(movie, { canvas }); // arrows / Space / Enter / click / swipe = next, ← = back, B = black screen, F = fullscreen, ? = keys
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
510
567
|
## Looping motion
|
|
511
568
|
|
|
512
569
|
`repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
|
package/ai/template.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>pixi-effects video</title>
|
|
7
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
|
|
8
8
|
<style>
|
|
9
9
|
body { background: #0d1220; margin: 0; padding: 16px; }
|
|
10
10
|
.stage { position: relative; width: min(960px, 100%); } /* the player and the loader both sit inside this box */
|
|
@@ -39,9 +39,10 @@
|
|
|
39
39
|
"gsap": "https://esm.sh/gsap@3.12.5",
|
|
40
40
|
"gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
|
|
41
41
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
42
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
43
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
44
|
-
"pixi-effects/
|
|
42
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/index.js",
|
|
43
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/Controller.js",
|
|
44
|
+
"pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/Presenter.js",
|
|
45
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/three.js",
|
|
45
46
|
"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
|
|
46
47
|
"three": "https://esm.sh/three@0.178.0"
|
|
47
48
|
}
|
|
@@ -53,6 +54,9 @@
|
|
|
53
54
|
import { Controller } from 'pixi-effects/controller';
|
|
54
55
|
// import { registerThree, three } from 'pixi-effects/three'; // only for a three.js layer; call registerThree() before init
|
|
55
56
|
// import * as THREE from 'three';
|
|
57
|
+
// A talk instead of a video: import { deck } from 'pixi-effects'; import { Presenter } from 'pixi-effects/presenter'; (add "pixi-effects/presenter" to the import map)
|
|
58
|
+
// const d = deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops: [at, ...], notes, sequences }] });
|
|
59
|
+
// composition: d.composition, duration: d.duration; then new Presenter(movie, { canvas }) instead of the Controller. Docs: ai/reference/cheatsheet.md → Presets (the deck lines).
|
|
56
60
|
|
|
57
61
|
const W = 1280, H = 720, FPS = 30, DURATION = 6;
|
|
58
62
|
|
package/ai/tools/check.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* waits for `window.__ready === true`, then checks: console warnings / errors, `movie.inspect` over the whole timeline
|
|
9
9
|
* (grouped), `movie.inspectAudio()`, a contact sheet (a PNG you can look at), and a real export (`movie.render`) that it
|
|
10
10
|
* decodes again: video size / length, audio length, loudness per second, silence. Output goes to `check-out/<name>/`
|
|
11
|
-
* (`sheet.png`, `<name>.<format>`, `report.json`) and a short summary is printed. Exit code 0 = nothing to fix, 1 = problems.
|
|
11
|
+
* (`sheet.png`, `<name>.<format>`, `report.json`; a presentation with `stops` also gets `stops.png`, one picture per stop) and a short summary is printed. Exit code 0 = nothing to fix, 1 = problems.
|
|
12
12
|
*
|
|
13
13
|
* Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or --chrome PATH / CHROME=PATH). No npm dependencies.
|
|
14
14
|
* The page must follow ai/template.html: it exposes `window.movie` and sets `window.__ready = true` (and `window.__logs`).
|
|
@@ -279,6 +279,18 @@ export async function runCheck(opts, log = console.log) {
|
|
|
279
279
|
fs.writeFileSync(sheetFile, Buffer.from(sheet.slice(sheet.indexOf(',') + 1), 'base64'));
|
|
280
280
|
report.files.sheet = shown(sheetFile);
|
|
281
281
|
|
|
282
|
+
// a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
|
|
283
|
+
const stops = await cdp.eval(`(() => { const s = movie.stops || []; return s.length ? { count: s.length, pages: movie.pageCount, items: s.map(x => ({ at: Math.round(x.at * 100) / 100, frame: x.frame, page: x.page, pageStart: x.pageStart, notes: !!x.notes })) } : null; })()`).catch(() => null);
|
|
284
|
+
if (stops) {
|
|
285
|
+
report.stops = stops;
|
|
286
|
+
const stopsSheet = await cdp.eval(`movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })`).catch(() => null);
|
|
287
|
+
if (stopsSheet) {
|
|
288
|
+
const stopsFile = path.join(outDir, 'stops.png');
|
|
289
|
+
fs.writeFileSync(stopsFile, Buffer.from(stopsSheet.slice(stopsSheet.indexOf(',') + 1), 'base64'));
|
|
290
|
+
report.files.stops = shown(stopsFile);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
282
294
|
// the poster: the picture that stands for the movie (its poster time, or the first frame)
|
|
283
295
|
const poster = await cdp.eval(`movie.posterImage({ as: 'dataURL', type: 'image/jpeg', scale: 0.5 })`).catch(() => null);
|
|
284
296
|
if (poster) {
|
|
@@ -367,6 +379,10 @@ function finish(report, outDir, log) {
|
|
|
367
379
|
if (rv.length > 4) L.push(` … ${rv.length - 4} more (report.json)`);
|
|
368
380
|
}
|
|
369
381
|
}
|
|
382
|
+
if (report.stops) {
|
|
383
|
+
const s = report.stops;
|
|
384
|
+
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`);
|
|
385
|
+
}
|
|
370
386
|
if (report.audio !== undefined) {
|
|
371
387
|
const a = report.audio;
|
|
372
388
|
L.push(a ? ` audio ${a.sources.length} source(s) · mix peak ${a.peakDb} dBFS · ${a.issues.length ? a.issues.join(' | ') : 'no issues (movie.inspectAudio)'}` : ' audio none');
|
package/ai/tools/render.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Exit code: 0 = the file was written, 1 = the page or the render failed (no file), 2 = bad usage.
|
|
13
13
|
*
|
|
14
14
|
* Options: -o, --out FILE (default ./<page name>.<format>; the container follows the extension: mp4 webm mov mkv) ·
|
|
15
|
-
* --format mp4|webm|mov|mkv · --quality very-low|low|medium|high|very-high (video and audio bitrate, default high) ·
|
|
15
|
+
* --format mp4|webm|mov|mkv|pdf (a .pdf is the deck as pages: one picture per page of a movie with `stops`; --all-stops makes a page of every stop) · --quality very-low|low|medium|high|very-high (video and audio bitrate, default high) ·
|
|
16
16
|
* --motion-blur SAMPLES (2-64: each frame is drawn that many times over the shutter and averaged; overrides the page's own motionBlur) ·
|
|
17
17
|
* --shutter FRACTION (with --motion-blur: how long the shutter is open, 0-1 of a frame, default 0.5) ·
|
|
18
18
|
* --video-codec C · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --fail-on-warn (exit 1 when the page
|
|
@@ -25,14 +25,14 @@ import path from 'node:path';
|
|
|
25
25
|
import { pathToFileURL } from 'node:url';
|
|
26
26
|
import { findChrome, findRoot, serve, launchChrome, shown, sleep } from './check.mjs';
|
|
27
27
|
|
|
28
|
-
const FORMATS = ['mp4', 'webm', 'mov', 'mkv'];
|
|
28
|
+
const FORMATS = ['mp4', 'webm', 'mov', 'mkv', 'pdf'];
|
|
29
29
|
const QUALITIES = ['very-low', 'low', 'medium', 'high', 'very-high'];
|
|
30
30
|
|
|
31
31
|
/** 'clip.WEBM' → 'webm'; no extension → null; any other extension is an error. */
|
|
32
32
|
export function formatFromPath(file) {
|
|
33
33
|
const ext = path.extname(file).slice(1).toLowerCase();
|
|
34
34
|
if (!ext) return null;
|
|
35
|
-
if (!FORMATS.includes(ext)) throw new Error(`cannot tell the container from ".${ext}": use an output ending in mp4, webm, mov or
|
|
35
|
+
if (!FORMATS.includes(ext)) throw new Error(`cannot tell the container from ".${ext}": use an output ending in mp4, webm, mov, mkv or pdf (or --format)`);
|
|
36
36
|
return ext;
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -41,14 +41,14 @@ export function defaultOutput(page, format) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
export function parseRenderArgs(argv) {
|
|
44
|
-
const o = { page: null, out: null, format: null, quality: 'high', videoCodec: null, audioCodec: null, motionBlur: null, shutter: null, query: null, failOnWarn: false, quiet: false, timeout: 900, root: null, chrome: null };
|
|
44
|
+
const o = { page: null, out: null, format: null, quality: 'high', videoCodec: null, audioCodec: null, allStops: false, motionBlur: null, shutter: null, query: null, failOnWarn: false, quiet: false, timeout: 900, root: null, chrome: null };
|
|
45
45
|
const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
|
|
46
46
|
for (let i = 0; i < argv.length; i++) {
|
|
47
47
|
const a = argv[i];
|
|
48
48
|
if (a === '-o' || a === '--out') o.out = need(i++, a);
|
|
49
49
|
else if (a === '--format') {
|
|
50
50
|
o.format = need(i++, a).toLowerCase();
|
|
51
|
-
if (!FORMATS.includes(o.format)) throw new Error(`--format must be mp4, webm, mov or
|
|
51
|
+
if (!FORMATS.includes(o.format)) throw new Error(`--format must be mp4, webm, mov, mkv or pdf (got "${o.format}")`);
|
|
52
52
|
} else if (a === '--quality') {
|
|
53
53
|
o.quality = need(i++, a);
|
|
54
54
|
if (!QUALITIES.includes(o.quality)) throw new Error(`--quality must be very-low, low, medium, high or very-high (got "${o.quality}")`);
|
|
@@ -61,6 +61,7 @@ export function parseRenderArgs(argv) {
|
|
|
61
61
|
o.shutter = Number(need(i++, a));
|
|
62
62
|
if (!(o.shutter > 0 && o.shutter <= 1)) throw new Error(`--shutter must be above 0 and at most 1 (0.5 is a 180° shutter), got "${argv[i]}"`);
|
|
63
63
|
} else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
|
|
64
|
+
else if (a === '--all-stops') o.allStops = true;
|
|
64
65
|
else if (a === '--fail-on-warn') o.failOnWarn = true;
|
|
65
66
|
else if (a === '--quiet') o.quiet = true;
|
|
66
67
|
else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 900);
|
|
@@ -71,6 +72,7 @@ export function parseRenderArgs(argv) {
|
|
|
71
72
|
else throw new Error(`unexpected argument ${a}`);
|
|
72
73
|
}
|
|
73
74
|
if (o.shutter !== null && o.motionBlur === null) throw new Error('--shutter goes with --motion-blur SAMPLES');
|
|
75
|
+
if (o.allStops && o.format !== 'pdf' && !(o.out && formatFromPath(o.out) === 'pdf')) throw new Error('--all-stops goes with a PDF (-o deck.pdf)');
|
|
74
76
|
o.format ??= (o.out ? formatFromPath(o.out) : null) ?? 'mp4';
|
|
75
77
|
return o;
|
|
76
78
|
}
|
|
@@ -79,14 +81,16 @@ const UPLOAD = '/__pixi_effects_out';
|
|
|
79
81
|
const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
|
|
80
82
|
|
|
81
83
|
/** The page's side: render, and PUT the Blob to our server (so a long video never goes through base64). */
|
|
82
|
-
const renderScript = (format, quality, videoCodec, audioCodec, motionBlur, shutter) => `(() => {
|
|
84
|
+
const renderScript = (format, quality, videoCodec, audioCodec, motionBlur, shutter, allStops) => `(() => {
|
|
83
85
|
window.__r = { state: 'running', progress: 0 };
|
|
84
86
|
movie.on('progress', e => { window.__r.progress = e.progress; });
|
|
85
87
|
(async () => {
|
|
86
88
|
try {
|
|
87
89
|
const video = { bitrate: ${JSON.stringify(quality)}${videoCodec ? `, codec: ${JSON.stringify(videoCodec)}` : ''} };
|
|
88
90
|
const audio = { bitrate: ${JSON.stringify(quality)}${audioCodec ? `, codec: ${JSON.stringify(audioCodec)}` : ''} };
|
|
89
|
-
const blob =
|
|
91
|
+
const blob = ${JSON.stringify(format)} === 'pdf'
|
|
92
|
+
? await movie.exportPDF({ which: ${allStops ? "'stops'" : "'pages'"}, title: document.title })
|
|
93
|
+
: await movie.render({ format: ${JSON.stringify(format)}, video, audio${motionBlur ? `, motionBlur: { samples: ${motionBlur}${shutter ? `, shutter: ${shutter}` : ''} }` : ''} });
|
|
90
94
|
const res = await fetch(${JSON.stringify(UPLOAD)}, { method: 'PUT', body: blob });
|
|
91
95
|
if (!res.ok) throw new Error('could not hand the file to the command (' + res.status + ')');
|
|
92
96
|
window.__r = { state: 'done', bytes: blob.size, type: blob.type };
|
|
@@ -152,7 +156,7 @@ export async function runRender(opts, log = console.log, progress = () => {}) {
|
|
|
152
156
|
}
|
|
153
157
|
Object.assign(result, { duration: info.duration, frames: info.totalFrames, width: info.width, height: info.height, frameRate: info.frameRate });
|
|
154
158
|
|
|
155
|
-
await cdp.eval(renderScript(opts.format, opts.quality, opts.videoCodec, opts.audioCodec, opts.motionBlur, opts.shutter));
|
|
159
|
+
await cdp.eval(renderScript(opts.format, opts.quality, opts.videoCodec, opts.audioCodec, opts.motionBlur, opts.shutter, opts.allStops));
|
|
156
160
|
let state;
|
|
157
161
|
for (;;) {
|
|
158
162
|
left();
|
|
@@ -186,7 +190,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
186
190
|
if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
|
|
187
191
|
const opts = parseRenderArgs(process.argv.slice(2));
|
|
188
192
|
if (!opts.page) {
|
|
189
|
-
console.error('usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv] [--quality very-low|low|medium|high|very-high] [--query a=1] [--fail-on-warn] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]');
|
|
193
|
+
console.error('usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv|pdf] [--quality very-low|low|medium|high|very-high] [--query a=1] [--fail-on-warn] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]');
|
|
190
194
|
process.exit(2);
|
|
191
195
|
}
|
|
192
196
|
let last = -1;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
|
|
2
|
-
import { S as SequenceSpec, C as CompositionShape,
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape, M as AudioDescriptor } from './types-mpqQO8MJ.js';
|
|
3
3
|
import { gsap } from 'gsap';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
|
|
2
|
-
import { S as SequenceSpec, C as CompositionShape,
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape, M as AudioDescriptor } from './types-mpqQO8MJ.cjs';
|
|
3
3
|
import { gsap } from 'gsap';
|
|
4
4
|
|
|
5
5
|
/**
|
package/dist/Controller.cjs
CHANGED
|
@@ -10,6 +10,7 @@ var ICONS = {
|
|
|
10
10
|
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>',
|
|
11
11
|
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>',
|
|
12
12
|
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>',
|
|
13
|
+
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>',
|
|
13
14
|
fullscreenExit: '<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="M6 2 V6 H2 M10 2 V6 H14 M6 14 V10 H2 M10 14 V10 H14"/></svg>'
|
|
14
15
|
};
|
|
15
16
|
var STYLE_ATTR = "data-movie-controller";
|
|
@@ -47,6 +48,8 @@ var CONTROLLER_CSS = `
|
|
|
47
48
|
transition: height 120ms ease;
|
|
48
49
|
}
|
|
49
50
|
.mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }
|
|
51
|
+
.mc-stops { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; pointer-events: none; }
|
|
52
|
+
.mc-stop-tick { position: absolute; top: 50%; width: 2px; height: calc(var(--mc-track-height, 3px) + 6px); transform: translate(-50%, -50%); background: var(--mc-fg, #fff); opacity: 0.6; border-radius: 1px; }
|
|
50
53
|
.mc-progress-fill {
|
|
51
54
|
position: absolute; left: 12px; top: 50%;
|
|
52
55
|
height: var(--mc-track-height, 3px);
|
|
@@ -323,6 +326,8 @@ var Controller = class _Controller {
|
|
|
323
326
|
settingsKeyHandler = null;
|
|
324
327
|
settingsOutsideHandler = null;
|
|
325
328
|
onReady = null;
|
|
329
|
+
presenter = null;
|
|
330
|
+
presentBtn = null;
|
|
326
331
|
onFrame = null;
|
|
327
332
|
onPlay = null;
|
|
328
333
|
onPause = null;
|
|
@@ -353,6 +358,7 @@ var Controller = class _Controller {
|
|
|
353
358
|
showExportButton: options.showExportButton ?? true,
|
|
354
359
|
enableKeyboardShortcuts: options.enableKeyboardShortcuts ?? true,
|
|
355
360
|
clickToPlay: options.clickToPlay ?? true,
|
|
361
|
+
present: options.present ?? true,
|
|
356
362
|
className: options.className ?? "movie-controller"
|
|
357
363
|
};
|
|
358
364
|
installStyles();
|
|
@@ -371,6 +377,7 @@ var Controller = class _Controller {
|
|
|
371
377
|
}
|
|
372
378
|
this.root.setAttribute("data-state", "visible");
|
|
373
379
|
this.bindMovieEvents();
|
|
380
|
+
this.refreshStops();
|
|
374
381
|
this.bindPlayButton();
|
|
375
382
|
if (this.options.clickToPlay) this.bindCanvasClick();
|
|
376
383
|
this.bindScrubbing();
|
|
@@ -528,6 +535,7 @@ var Controller = class _Controller {
|
|
|
528
535
|
this.refreshTime(0);
|
|
529
536
|
this.refreshProgress(0);
|
|
530
537
|
this.refreshVolumeUI();
|
|
538
|
+
this.refreshStops();
|
|
531
539
|
};
|
|
532
540
|
this.onFrame = ({ frame, totalFrames }) => {
|
|
533
541
|
if (!this.isScrubbing) {
|
|
@@ -554,6 +562,53 @@ var Controller = class _Controller {
|
|
|
554
562
|
this.movie.on("pause", this.onPause);
|
|
555
563
|
this.movie.on("progress", this.onProgress);
|
|
556
564
|
}
|
|
565
|
+
/** The stops of a presentation as marks on the seek bar, and the Present button. */
|
|
566
|
+
refreshStops() {
|
|
567
|
+
this.progressEl.querySelector(".mc-stops")?.remove();
|
|
568
|
+
const stops = this.movie.stops ?? [];
|
|
569
|
+
if (stops.length === 0) {
|
|
570
|
+
this.presentBtn?.remove();
|
|
571
|
+
this.presentBtn = null;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const total = this.movie.totalFrames || 1;
|
|
575
|
+
const holder = document.createElement("div");
|
|
576
|
+
holder.className = "mc-stops";
|
|
577
|
+
for (const s of stops) {
|
|
578
|
+
const tick = document.createElement("div");
|
|
579
|
+
tick.className = "mc-stop-tick";
|
|
580
|
+
tick.style.left = `${s.frame / total * 100}%`;
|
|
581
|
+
if (s.page) tick.title = s.page;
|
|
582
|
+
holder.appendChild(tick);
|
|
583
|
+
}
|
|
584
|
+
this.progressEl.appendChild(holder);
|
|
585
|
+
if (this.options.present && !this.presentBtn && this.fullscreenBtn) {
|
|
586
|
+
const btn = document.createElement("button");
|
|
587
|
+
btn.className = "mc-btn mc-present";
|
|
588
|
+
btn.setAttribute("aria-label", "Present");
|
|
589
|
+
btn.title = "Present";
|
|
590
|
+
btn.innerHTML = ICONS.present;
|
|
591
|
+
btn.addEventListener("click", () => {
|
|
592
|
+
void this.startPresenting();
|
|
593
|
+
});
|
|
594
|
+
this.fullscreenBtn.parentElement?.insertBefore(btn, this.fullscreenBtn);
|
|
595
|
+
this.presentBtn = btn;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** Hand the page over to a Presenter (fullscreen, keys, click and swipe move through the stops); Escape gives it back. */
|
|
599
|
+
async startPresenting() {
|
|
600
|
+
if (this.presenter || this.destroyed) return;
|
|
601
|
+
const { Presenter } = await import('./Presenter.cjs');
|
|
602
|
+
if (this.presenter || this.destroyed) return;
|
|
603
|
+
const p = new Presenter(this.movie, { canvas: this.options.canvas, onExit: () => this.stopPresenting() });
|
|
604
|
+
this.presenter = p;
|
|
605
|
+
await p.start();
|
|
606
|
+
}
|
|
607
|
+
stopPresenting() {
|
|
608
|
+
const p = this.presenter;
|
|
609
|
+
this.presenter = null;
|
|
610
|
+
p?.destroy();
|
|
611
|
+
}
|
|
557
612
|
bindPlayButton() {
|
|
558
613
|
this.playBtn.addEventListener("click", () => {
|
|
559
614
|
if (this.movie.isPlaying) {
|
|
@@ -572,6 +627,7 @@ var Controller = class _Controller {
|
|
|
572
627
|
this.swallowCanvasClick = false;
|
|
573
628
|
};
|
|
574
629
|
this.canvasClickHandler = () => {
|
|
630
|
+
if (this.presenter) return;
|
|
575
631
|
if (this.swallowCanvasClick) {
|
|
576
632
|
this.swallowCanvasClick = false;
|
|
577
633
|
return;
|
|
@@ -937,6 +993,7 @@ var Controller = class _Controller {
|
|
|
937
993
|
}
|
|
938
994
|
bindKeyboard() {
|
|
939
995
|
this.keyHandler = (e) => {
|
|
996
|
+
if (this.presenter) return;
|
|
940
997
|
const target = e.target;
|
|
941
998
|
if (target) {
|
|
942
999
|
const tag = target.tagName;
|
|
@@ -998,6 +1055,7 @@ var Controller = class _Controller {
|
|
|
998
1055
|
destroy() {
|
|
999
1056
|
if (this.destroyed) return;
|
|
1000
1057
|
this.destroyed = true;
|
|
1058
|
+
this.stopPresenting();
|
|
1001
1059
|
if (this.onReady) {
|
|
1002
1060
|
this.movie.off("ready", this.onReady);
|
|
1003
1061
|
this.onReady = null;
|