pixi-effects 0.38.1 → 0.40.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 +28 -0
- package/README.md +16 -30
- package/ai/chat-template.html +3 -3
- package/ai/tools/check.mjs +109 -13
- package/ai/tools/gltfProbe.mjs +122 -0
- package/ai/tools/media.mjs +129 -10
- package/ai/tools/pixi-effects.mjs +1 -1
- package/ai/tools/render.mjs +34 -8
- package/dist/{Base-BKKwxavW.d.cts → Base-DQWIJ6bJ.d.cts} +1 -1
- package/dist/{Base-ioh_AjLB.d.ts → Base-Jgkud1Aw.d.ts} +1 -1
- package/dist/Composition-C3UEVHQG.cjs +16 -0
- package/dist/{Composition-23KGIJX4.cjs.map → Composition-C3UEVHQG.cjs.map} +1 -1
- package/dist/Composition-KRWH72MA.js +7 -0
- package/dist/{Composition-5SQXSZNA.js.map → Composition-KRWH72MA.js.map} +1 -1
- package/dist/Controller.d.cts +2 -2
- package/dist/Controller.d.ts +2 -2
- package/dist/{Movie-BtwFI2ne.d.ts → Movie-CG6Jy1tx.d.ts} +6 -1
- package/dist/{Movie-CSWUTnMd.d.cts → Movie-CMq4KFmc.d.cts} +6 -1
- package/dist/{Spans-WJFPHMSQ.cjs → Spans-APNJHCYO.cjs} +9 -9
- package/dist/{Spans-WJFPHMSQ.cjs.map → Spans-APNJHCYO.cjs.map} +1 -1
- package/dist/{Spans-4IBQASGK.js → Spans-TWO4VN5H.js} +4 -4
- package/dist/{Spans-4IBQASGK.js.map → Spans-TWO4VN5H.js.map} +1 -1
- package/dist/{chunk-4CSL5QSL.js → chunk-26Z544SH.js} +24 -13
- package/dist/chunk-26Z544SH.js.map +1 -0
- package/dist/{chunk-FE42PKHP.cjs → chunk-27PYJ4JY.cjs} +83 -72
- package/dist/chunk-27PYJ4JY.cjs.map +1 -0
- package/dist/{chunk-7NXKBRT7.cjs → chunk-7EI2N7WO.cjs} +27 -7
- package/dist/{chunk-7NXKBRT7.cjs.map → chunk-7EI2N7WO.cjs.map} +1 -1
- package/dist/{chunk-XOCRS5MW.js → chunk-CT5CZVT5.js} +15 -8
- package/dist/chunk-CT5CZVT5.js.map +1 -0
- package/dist/{chunk-KNMDDUA3.cjs → chunk-LEL7UNYQ.cjs} +50 -43
- package/dist/chunk-LEL7UNYQ.cjs.map +1 -0
- package/dist/{chunk-UDTLYYGY.js → chunk-LMWXR5OC.js} +27 -7
- package/dist/{chunk-UDTLYYGY.js.map → chunk-LMWXR5OC.js.map} +1 -1
- package/dist/index.cjs +33 -33
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/loader.css +1 -1
- package/dist/present.cjs +3 -3
- package/dist/present.d.cts +2 -2
- package/dist/present.d.ts +2 -2
- package/dist/present.js +3 -3
- package/dist/three.cjs +505 -12
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +139 -7
- package/dist/three.d.ts +139 -7
- package/dist/three.js +501 -10
- package/dist/three.js.map +1 -1
- package/dist/{types-BXi-0iZS.d.cts → types-DIh93lFb.d.cts} +82 -1
- package/dist/{types-BXi-0iZS.d.ts → types-DIh93lFb.d.ts} +82 -1
- package/docs/api.md +21 -11
- package/docs/dsl.md +73 -5
- package/docs/media.md +17 -2
- package/llms-full.txt +188 -23
- package/llms.txt +1 -1
- package/package.json +11 -12
- package/skills/pixi-effects/SKILL.md +5 -3
- package/skills/pixi-effects/reference/cheatsheet.md +2 -2
- package/skills/pixi-effects/reference/pitfalls.md +1 -0
- package/skills/pixi-effects/reference/recipes.md +69 -0
- package/skills/pixi-effects/template.html +9 -8
- package/dist/Composition-23KGIJX4.cjs +0 -16
- package/dist/Composition-5SQXSZNA.js +0 -7
- package/dist/chunk-4CSL5QSL.js.map +0 -1
- package/dist/chunk-FE42PKHP.cjs.map +0 -1
- package/dist/chunk-KNMDDUA3.cjs.map +0 -1
- package/dist/chunk-XOCRS5MW.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.40.0
|
|
4
|
+
|
|
5
|
+
The `three` layer grows up: it can live in the composition's space, it loads glTF models, `check` holds it to the library's contract, and it can use three's WebGPU renderer. Nothing else changed; one type-only break (the last entry).
|
|
6
|
+
|
|
7
|
+
- **New: a `three` layer can live in the composition's space (`followCamera`, `followLights`, `followFog`).** `followCamera: true` puts the layer's `PerspectiveCamera` where the composition's `camera` layer is, every frame (position, look-at point, vertical fov, the handheld offsets), so one `cameraPath()` or `orbit()` flies through the flat layers and the three scene together. The scene is in composition pixels with y flipped (the composition's `(x, y, z)` is `(x, -y, z)` in three); a point lands on the same pixel as the composition's own projection (to 1e-6 px; against the composition's cards in the real picture: mean 0.14–0.19 of 255, edges only). `followLights: true` makes the composition's `light` layers three lights (ambient, point, spot, parallel; position, colour, intensity, cone, falloff; the intensity times π, because three divides a diffuse surface by π), moved every frame; **a light with `castsShadows` casts in three** (the layer's own meshes choose who casts and receives: a card in front of a wall under a spot throws the same shadow as the composition's cards, mean 0.2–0.35 of 255, WebGL and WebGPU, at 480 × 270 and at 1280 × 720). With `MeshLambertMaterial` and three's colour management off the planes shade like the cards (mean 0.02–0.2 of 255); with three's defaults close (0.8–1.4). `followFog: true` makes the camera's fog the scene's `Fog`: close, not equal (three fades by `smoothstep`, the composition linearly, and has no `fogAmount` below 1, which warns once; mean 0.5–1.8 of 255 where the fog's own effect is 2–7). `ctx.space` gives the raw camera, lights and fog. A composition whose only consumer is such a layer no longer warns that its camera or light has no effect, or that no layer casts a shadow. Pinned by `tests/films/three-space` against the composition's own cards. See `docs/dsl.md` → three.
|
|
8
|
+
- **New: glTF models in the free `three` layer: `threeGltf`, `threeFrameCamera`, `pixi-effects probe model.glb`.** `threeGltf(url, { animation, speed, offset, loop })` returns `{ object, clips, size, pose(t), fit(px), dispose() }`: the group's origin is the centre of the model, `pose(t)` is `AnimationMixer.setTime` (a function of the time: a seek, an export and a repeat give the same frame), `fit(px)` scales the longest side to pixels, and skinned meshes are drawn always (three culls them by a bounding sphere that does not follow them: a moving, shadow-casting Fox drew its shadow depending on the frames drawn before it). `threeFrameCamera(camera, object, { direction, margin })` puts a camera where the whole model fits (a three-quarter view, from the corners of the bounding box rather than a sphere). `probe model.glb` (or `.gltf`) reads in Node, with no browser and no three.js, the clips with their lengths, the size (equal to what `threeGltf(...).size` measures; Khronos Fox: 25.185 × 79.029 × 154.72) and notes (Draco / KTX2 / Meshopt need a decoder that `threeGltf` does not set up; no animation; files to keep beside the model). The template's import map has `three/addons/`. Recipes `gltf-in-space` and `gltf-shadow-room` (a model running through a sweeping spotlight with its shadow on a wall and a lit flat caption), written and corrected after three fresh-AI trials on the real Khronos Fox. Tests: `tests/three/gltf.test.ts`, `tests/films/gltf-model`, `tests/tools/probeGltf.test.ts`.
|
|
9
|
+
- **New: `check` holds a `three` layer to "a function of the time", as it does a `canvas` layer.** It draws the scene at four moments in two orders (one draw thrown away first), reads the picture from the renderer's canvas and compares: a clock, `Math.random`, a mixer advanced with `mixer.update(dt)` or a speed that builds up from call to call is reported with the moments and the fix (`mixer.setTime(t)` / `threeGltf(url).pose(t)`); `Math.random` / `Date.now` / `performance.now` / `new Date` in `update` also warns at build. `check` prints a `three` line when every layer passed (WebGL and WebGPU), the canvas heading says "layer(s) (canvas / three)", and `movie.canvasReport()` rows have a `kind`. `tests/tools/threeDeterminism.test.ts`.
|
|
10
|
+
- **New: a page with too many `three` layers says so instead of going blank.** A browser keeps about 16 live WebGL contexts for a page (about 32 WebGPU: measured on Chrome); layers beyond that draw nothing and raise no error. The 13th live WebGL layer (the 29th WebGPU one) now warns once with the count and the way out, and a layer whose WebGL context the browser took away names itself. `docs/dsl.md` had said "roughly 8–16". `tests/films/three-budget` (20 layers in real Chrome).
|
|
11
|
+
- **New: a `three` layer can draw with three's WebGPU renderer (`renderer: 'webgpu'`), `antialias`, and a page-level default `registerThree({ renderer })`.** The layer used `WebGLRenderer` always, in its own offscreen context. `renderer: 'webgpu'` uses `WebGPURenderer` from `three/webgpu` (three 0.17x or later); the default stays `'webgl'` (a design review by two independent reviewers, with measurements on four large GLSL clips, rejected a silent fallback that runs `setup` twice: a second `setup` can draw a different video). The same lit scene draws within 0.06 of 255 per channel of the WebGL picture and each renderer is a function of the time (`tests/films/three-renderers`); the render alone is about the same for a box and about twice as fast on WebGPU for 2,880 instanced rods; an MP4 export is limited by the encode and took the same time. `antialias` (default `true`): WebGPURenderer blends fog and resolves multisampling in linear light before tone mapping, WebGLRenderer after it, so fog and thin hot edges differ by design; with `antialias: false` and no fog the two agree to 0.04 of 255. On `'webgpu'`, a `ShaderMaterial` / `RawShaderMaterial` / `onBeforeCompile` material stops the build with the fix (it used to draw a broken picture) and a `PMREMGenerator` taken from `three` is explained; where the browser has no WebGPU three's WebGL2 backend draws the same picture (`ThreeSequence.renderer` / `.backend` say what was used, and `movie.inspect()` lists them). Three's WebGPU renderer updates light uniforms once per animation-loop frame, so a seek or an export showed stale lights: the layer now advances three's node frame before each render and gives `time` the layer's own time. See `docs/dsl.md` → three.
|
|
12
|
+
- **Changed (types only): `ThreeContext.renderer` is `WebGLRenderer | WebGPURenderer`.** TypeScript code that used WebGL-only members of `ctx.renderer` has to narrow it (`ctx.renderer instanceof WebGLRenderer`); `setClearColor`, `toneMapping` and `toneMappingExposure` exist on both. `docs/api.md` → `pixi-effects/three` lists the whole entry.
|
|
13
|
+
|
|
14
|
+
## 0.39.0
|
|
15
|
+
|
|
16
|
+
**The tools tell the truth, and a file can be checked against what it must be**
|
|
17
|
+
|
|
18
|
+
- **`probe` names the colour tags, and `--expect` can ask for them (`matrix=bt709`, `transfer=…`).** The plain report says `colour bt709 (transfer iec61966-2-1, limited range)` and `--json` has `video[].color`. The files `render()` writes carry the Rec.709 primaries and matrix, limited range and the **sRGB transfer** (`iec61966-2-1`); it was listed as a defect to change to `bt709`, and it is left: the pixels are drawn as sRGB, so that is the true tag (rewriting it would mean patching the container and the H.264 bitstream to say something less exact; editors and players read it as Rec.709 in practice). A platform that asks for Rec.709 is answered by `probe file --expect matrix=bt709`.
|
|
19
|
+
- **New: `grain` has `loop: seconds`, so a clip made to loop has the same grain at its seam.** `{ type: 'grain', loop: 10 }` repeats the pattern every 10 s (the grain frame is taken modulo `loop × fps`): the frame after the last is the first again. `loop × fps` must be a whole number of grain frames (10 s at 24 fps is 240); anything else, and a `loop` that is not above 0, warns and says what to change. The library's grain had no period, so two stock-clip agents wrote their own grain inside a shader to close a loop. Tests: the grain frame at the seam, the warnings, and a living filter's `loop`.
|
|
20
|
+
- **New: `check --seam` tells whether a movie made to loop closes without a jump.** In the page it takes the last frame against the first (`seam`) and nine ordinary neighbouring pairs (`steps`, the two next to the seam among them) on pictures a quarter of the size, as the mean absolute difference of the colour channels. A seam up to 1.5 times the largest ordinary step (or under 0.001) prints `seam the loop closes: …`; more prints `seam JUMP: … 8.1× the largest ordinary step …`, is a problem and exits 1. `report.json` has `seam: { seam, median, max, ratio, ok, frames }`. The movie is its own measure (no stored picture). Found by stock-clip agents, who measured the seam by hand with ImageMagick (`compare -metric MAE`, the last frame against the first). New export: `seamVerdict` (check.mjs); test pages `examples/_checks/seam-loop.html` and `seam-jump.html`.
|
|
21
|
+
- **New: `probe file --expect "size=3840x2160 fps=30 frames=300 bitrate>=35M audio=none"` checks a file against what it must be.** One line each (`ok` / `FAIL`, the number the file has and the one wanted), `RESULT: …`, exit 1 when one fails, `--json` adds an `expect` list. The numbers are measured over every packet of the file (the new `frames`, and the bitrate of the whole file), not over the first 300 like the plain report. Words: `size width height fps frames duration bitrate video audio container`; operators `= != >= <= > <`; a misspelt word gets `did you mean`; a bad expectation is a usage error before the file is read. `docs/media.md`, the skill and `help media` carry it; the docs test now runs example commands with quoted arguments.
|
|
22
|
+
- **Docs: `bitrate` is a ceiling, not a target (pitfall 119), measured.** A constant bitrate mode (`bitrateMode: 'constant'`) reaches the encoder and still writes 0.1 Mbps for a black frame with a circle (H.264, HEVC and VP9: no encoder pads), so there is no option to add: read the real rate (`render` prints it, `probe --expect` checks it) and, when a minimum is needed, raise it with what the picture really has (seeded film grain).
|
|
23
|
+
- **Changed (breaking, small): an export is exactly `duration × frameRate` frames, not one more.** `render()` wrote the frames 0 … `totalFrames` inclusive, so a 10 s movie at 30 fps was 301 frames and 10.033 s while `check` said 300; the moment `duration` is now not a frame (it belongs to the next one), so the file is 300 frames and 10.000 s, a clip made to loop has no repeated frame at its seam, and the picture and the sound have the same length. A `range` is `[from, to)`: `1:2` is 30 frames at 30 fps (it was 31). The progress events count the frames, then one `100` when the file is finished; the stage is left on the last exported frame. What you may have done to get 300 frames (`duration: 10 - 1/30`) now gives 299: write the plain `duration: 10`. Seeking to the end moment (`gotoFrame(totalFrames)`) and the last-frame check are unchanged. Found by agents making stock clips, which all had to know the trick. Tests: `renderRange.test.ts` counts the packets of the real file; the Renderer unit tests and two browser tests that assumed the extra frame were rewritten (the stage's last frame, the progress events, a reversed loop's first instant).
|
|
24
|
+
- **Fixed: the guide's demo pages scrolled a little inside their frame.** Every generated demo (`examples/_guide/*.html`) had `body { padding: 16px }` and a picture that sits on a text line, so each was 20 px taller than the 16:9 frame the guide gives it and could be scrolled (measured: 20 px at 880 px and at 360 px wide, in all 17 pages). The pages now fill the frame exactly (no padding, `line-height: 0`, no scrolling), and `first-video.html` (which the reader also copies) has the same CSS. Test: `tests/tools/guideDemoFit.test.ts` loads every demo at both widths and fails if one scrolls or does not fill its frame.
|
|
25
|
+
- **`render` and `check` say more about the file they wrote.** `render --help` now lists every option (`--video-bitrate` and `--video-codec` were missing, so an agent could not find them). A command whose stderr is not a terminal (an agent, a script, CI) used to print no progress at all, so a render of many minutes looked hung: it now prints a line at once and one every 15 s (`rendering 40% (47 s)`; `--quiet` still prints none). The result line gives the real bitrate (`· 17.8 Mbps ·`), and when `--video-bitrate` asked for more than twice what the encoder spent (55M gave 17.8 Mbps on a mostly black picture) a note says so and to read the real rate with `probe`. `check`'s own export line gives its bitrate and says it is for looking at, not the file to deliver (`render` is). New exports: `makeProgress`, `bitrateLine` (render.mjs), `exportLine` (check.mjs).
|
|
26
|
+
- **Fixed: `frames --at f1` gave frame 0 (and `f4` gave 3, `f7` gave 6 at 30 fps).** A frame number was turned into seconds and rounded DOWN to four decimals (1/30 s became 0.0333, which is before frame 1), so a third of the frames of a 30 fps file came back as the one before. A time asked for a frame (`fN`, `start`, `end`) now rounds up, so it lands on that frame (`end` was also a frame early for some lengths, such as 4.2 s at 29.97 fps); a time asked in seconds is still the instant asked for. Found by an agent measuring a loop's seam with `f0` and `f1`, which were the same picture. Tests: every frame number of 8 frame rates, and the real command on `colors.mp4`.
|
|
27
|
+
- **A start of `check`, `render` and the media commands sweeps the headless Chromes that an earlier run left behind.** On 2026-10-14 264 of them (5.7 GB, up to 7 hours old) piled up in one working day from runs that were cut off (a timeout, a harness that ended) whose watchman never got to stop them; the cause was not proven, so the fix does not depend on it. Every private profile folder (`pixi-effects-*` in the temp folder) now holds `pixi-owner.pid`, the process that started the browser; the next `launchChrome` (new export `reapStaleChrome(tmp?)`) stops, by process id and without `pkill`, everything started with a profile whose owner is gone and removes the folder. A profile whose owner is alive (another tool running now), one with no owner file and any other folder are left alone; it never throws. Tests: `tests/tools/chromeReap.test.ts` (stand-in processes, mutation-checked, and a real-Chrome case).
|
|
28
|
+
- **The release gate is shorter when only documents changed.** `npm run release:check` compares the tree with the last `v*` tag (`scripts/release-scope.mjs`): when every changed file is a document (the changelog, README, AGENTS, SKILL.md, `docs/*.md`, the generated llms files) or a bump of this package's own version (the old number turned into the new one, and in the manifests only the `"version"` key), it runs the unit and document tests and leaves out the real-browser ones (about 4 of the 5 minutes) and says so; anything else, a file added or deleted outside the documents, no tag, or any doubt is a full run. `--scope` says the decision and why without running anything, `--full` forces everything; the stamp that `npm publish` checks records the mode. Tested on real git repositories and with the mutations that must fail (a site file counted as a document, a dependency bump taken for a version bump, a pure reordering taken for a bump, an empty change taken for docs-only).
|
|
29
|
+
- **CI had been red since 0.38.0, and it is fixed (tests only, nothing in the package).** A test of the media commands ran `frames` with a `PATH` of only node's folder: on Linux `google-chrome` is a shell script that needs `dirname`, so Chrome exited with code 127, and the test also ignored `SKIP_BROWSER_TESTS`, which CI sets. The browser parts of that test, and of the docs example runner, now run only where browser tests run, and the PATH keeps the basic system folders (the test first proves ffmpeg is not in them). The two pushes (0.38.0, 0.38.1) were made without looking at CI: the red mail came from the maintainer.
|
|
30
|
+
|
|
3
31
|
## 0.38.1
|
|
4
32
|
|
|
5
33
|
**Docs, and 21 pieces leave the gallery (64 → 43)**
|
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.40.0`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
|
|
4
4
|
|
|
5
|
-
**[
|
|
5
|
+
**[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 43 pieces written as plain data by AI models · **[For AI →](https://yjmtmtk.github.io/pixi-effects/for-ai.html)** · what an AI reads and runs · **[Playground →](https://yjmtmtk.github.io/pixi-effects/examples/playground.html)** in the browser.
|
|
6
6
|
|
|
7
7
|
## Make a video by asking an AI
|
|
8
8
|
|
|
@@ -21,11 +21,11 @@ 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/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`).
|
|
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`).
|
|
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
|
-
- **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`).
|
|
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`). It can live in the composition's space: the composition's camera, lights, shadows and fog drive the scene, so one `cameraPath()` flies through the flat layers and a glTF model together (`threeGltf`).
|
|
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
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
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.
|
|
@@ -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.40.0/dist/index.js",
|
|
53
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.40.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.40.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
|
>
|
|
@@ -69,7 +69,7 @@ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg
|
|
|
69
69
|
|
|
70
70
|
#### Adding three.js (optional)
|
|
71
71
|
|
|
72
|
-
Add two more entries to the importmap above: three.js itself, and the `pixi-effects/three` entry.
|
|
72
|
+
Add two more entries to the importmap above: three.js itself, and the `pixi-effects/three` entry (and `three/addons/` if you load a glTF model with `threeGltf`).
|
|
73
73
|
|
|
74
74
|
```html
|
|
75
75
|
<script type="importmap">
|
|
@@ -77,7 +77,8 @@ 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
|
-
"
|
|
80
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.178.0/examples/jsm/",
|
|
81
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.40.0/dist/three.js"
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
</script>
|
|
@@ -112,7 +113,7 @@ Always route `three` through the importmap — both your code and `pixi-effects/
|
|
|
112
113
|
</script>
|
|
113
114
|
```
|
|
114
115
|
|
|
115
|
-
The layer is a normal sprite once built, so 2D keyframes, masks and filters work on it. Full spec, rules and caveats (one WebGL context per layer, `update(t)` must be a pure function of `t`): [DSL reference § three](./docs/dsl.md#three) and [API reference](./docs/api.md#pixi-effectsthree).
|
|
116
|
+
The layer is a normal sprite once built, so 2D keyframes, masks and filters work on it. Full spec, rules and caveats (one WebGL context per layer, `update(t)` must be a pure function of `t`): [DSL reference § three](./docs/dsl.md#three) and [API reference](./docs/api.md#pixi-effectsthree).
|
|
116
117
|
|
|
117
118
|
With a bundler, `import { registerThree, three } from 'pixi-effects/three'` works the same once you've installed `three`.
|
|
118
119
|
|
|
@@ -164,7 +165,7 @@ sequences: [
|
|
|
164
165
|
]
|
|
165
166
|
```
|
|
166
167
|
|
|
167
|
-
`+z` is toward the viewer, rotations are degrees (CSS signs), and with `z = 0` and the default camera a `threeD` layer looks identical to a 2D one. Add `{ type: 'light', kind: 'spot', … }` layers for shading and shadows, and `fogNear` / `fogFar` on the camera for depth fog (nothing changes in a composition with neither). See [DSL reference § 3D layers & camera](./docs/dsl.md#3d-layers--camera)
|
|
168
|
+
`+z` is toward the viewer, rotations are degrees (CSS signs), and with `z = 0` and the default camera a `threeD` layer looks identical to a 2D one. Add `{ type: 'light', kind: 'spot', … }` layers for shading and shadows, and `fogNear` / `fogFar` on the camera for depth fog (nothing changes in a composition with neither). See [DSL reference § 3D layers & camera](./docs/dsl.md#3d-layers--camera).
|
|
168
169
|
|
|
169
170
|
## For AI agents
|
|
170
171
|
|
|
@@ -187,25 +188,10 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
|
|
|
187
188
|
|
|
188
189
|
- [**DSL reference**](./docs/dsl.md) — composition, sequences, 3D layers & camera, three.js layer, keyframes, expressions, filters
|
|
189
190
|
- [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/present`, `pixi-effects/three`, events, render options
|
|
190
|
-
- [**Interactive movies**](./examples/interactive/) — nine live samples of a movie you change while it plays (variables, `setVars`, `rebuild`, loop): rock paper scissors, a scrolling city, a tune from four chords, a card for anyone, choose your ending … ([
|
|
191
|
-
- [**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
- `03-shapes.html` — every shape primitive
|
|
195
|
-
- `04-media.html` — image / video / audio
|
|
196
|
-
- `05-composition-mask.html` — nested compositions and masks
|
|
197
|
-
- `06-filters.html` — built-in and pixi-filters (as `{ type: 'custom' }`; most can now be written by name, see `docs/dsl.md` → Named filters)
|
|
198
|
-
- `07-transitions.html` — all seven transition kinds in one timeline
|
|
199
|
-
- `08-presets-export.html` — `kenBurns` preset and `movie.render()` from code
|
|
200
|
-
- `09-audio.html` — multi-track audio mixing with BGM ducking and SFX cues
|
|
201
|
-
- `10-three.html` — a three.js scene as a layer (`pixi-effects/three`)
|
|
202
|
-
- `11-depth.html` — 2.5D layers and camera: parallax, spin, dolly zoom
|
|
203
|
-
- `12-title-motion.html` — a staggered 2.5D title: letters fly in from depth, particles, orbiting camera
|
|
204
|
-
- `13-sfx.html` — synthesised sound effects (no audio files), a riser into a hit, a custom voice
|
|
205
|
-
- `15-custom-player.html` — a complete player of your own in about 40 lines (play, seek, volume, replay) using the movie's events
|
|
206
|
-
- `14-draw-on.html` — draw-on strokes (`trimStart` / `trimEnd`) and text that changes over time (`visibleChars`, `set: { text }`)
|
|
207
|
-
- `music-lab.html` — eight tunes written as text (no audio files): press play, read each score; `musicBuffer()` plays music with no movie
|
|
208
|
-
- `playground.html` — in-browser editor on the chat template's edit block: twelve examples, a problems panel from `movie.review()`, share links, save as HTML, and WebMCP tools for an AI agent
|
|
191
|
+
- [**Interactive movies**](./examples/interactive/) — nine live samples of a movie you change while it plays (variables, `setVars`, `rebuild`, loop): rock paper scissors, a scrolling city, a tune from four chords, a card for anyone, choose your ending … ([docs/api.md](./docs/api.md))
|
|
192
|
+
- [**Gallery**](./examples/gallery/) — 43 complete pieces, each one HTML file of data
|
|
193
|
+
- [**Recipes**](./skills/pixi-effects/reference/recipes.md) — tested code you can copy (`npx pixi-effects recipe`)
|
|
194
|
+
- [**Playground**](./examples/playground.html) — in-browser editor on the chat template's edit block: fourteen examples (the tested recipes), a problems panel from `movie.review()`, share links, save as HTML, and WebMCP tools for an AI agent
|
|
209
195
|
|
|
210
196
|
## Browser support
|
|
211
197
|
|
package/ai/chat-template.html
CHANGED
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"imports": {
|
|
60
60
|
"pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8.22.0/dist/pixi.min.mjs",
|
|
61
61
|
"mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
|
|
62
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
63
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
64
|
-
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
62
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.40.0/dist/index.js",
|
|
63
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.40.0/dist/Controller.js",
|
|
64
|
+
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.40.0/dist/present.js"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
</script>
|
package/ai/tools/check.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* --query "a=1&b=2" (added to the page address: for a page that reads it, such as one video of a batch) ·
|
|
18
18
|
* --crop X,Y,W,H (with --at: each picture is that part of the canvas, in canvas pixels, enlarged up to 4x so a small figure can be judged) ·
|
|
19
19
|
* --onion A:B (one picture of the movement between A and B seconds, frames overlaid: onion.png) ·
|
|
20
|
+
* --seam (a movie made to loop: the last frame to the first is compared with the steps between ordinary neighbouring frames; a jump is a problem) ·
|
|
20
21
|
* --strict (text overlaps and stops where the picture is still changing fail the check; by default they are only listed for review) · --out DIR · --frames N (contact sheet tiles, default 12) · --formats mp4,webm (default mp4) · --no-export ·
|
|
21
22
|
* --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
|
|
22
23
|
*/
|
|
@@ -80,10 +81,10 @@ export function findChrome(env = process.env, exists = fs.existsSync, platform =
|
|
|
80
81
|
return candidates.find(c => c && exists(c)) ?? null;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--crop X,Y,W,H] [--onion 1:3] [--query a=1&b=2] [--var name=value] [--size 1920x1080,1080x1920] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
|
|
84
|
+
export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--crop X,Y,W,H] [--onion 1:3] [--seam] [--query a=1&b=2] [--var name=value] [--size 1920x1080,1080x1920] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
|
|
84
85
|
|
|
85
86
|
export function parseArgs(argv) {
|
|
86
|
-
const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, crop: null, draft: false, onion: null, query: null, vars: [], sizes: [], help: false };
|
|
87
|
+
const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, crop: null, draft: false, onion: null, seam: false, query: null, vars: [], sizes: [], help: false };
|
|
87
88
|
const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
|
|
88
89
|
for (let i = 0; i < argv.length; i++) {
|
|
89
90
|
const a = argv[i];
|
|
@@ -103,6 +104,7 @@ export function parseArgs(argv) {
|
|
|
103
104
|
}
|
|
104
105
|
else if (a === '--var') o.vars.push(parseVar(need(i++, a)));
|
|
105
106
|
else if (a === '--size') o.sizes.push(...parseSizes(need(i++, a)));
|
|
107
|
+
else if (a === '--seam') o.seam = true;
|
|
106
108
|
else if (a === '--onion') {
|
|
107
109
|
const m = need(i++, a).match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
|
108
110
|
if (!m || !(Number(m[1]) < Number(m[2]))) throw new Error(`--onion must look like 1:3 (seconds, from before to), got "${argv[i]}"`);
|
|
@@ -186,8 +188,50 @@ export class Cdp {
|
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
/** The file in a private profile folder that names the process that started the browser: the next tool start can tell a profile that is still in use from one whose owner is gone. */
|
|
192
|
+
export const OWNER_FILE = 'pixi-owner.pid';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Sweep what a dead owner left: every `pixi-effects-*` folder in `tmp` whose owner file names a process that no longer exists has everything that was
|
|
196
|
+
* started with it stopped (found with `ps`, stopped by process id: no pkill, which a sandbox may refuse) and is removed. A profile whose owner is alive
|
|
197
|
+
* (another tool running right now), one with no owner file (a tool that is just starting) and any folder that is not ours are left alone.
|
|
198
|
+
* Never throws: a launch must not fail because of a cleanup. Returns `{ removed: [folders], stopped: number }`. (POSIX only; nothing to do elsewhere.)
|
|
199
|
+
*/
|
|
200
|
+
export function reapStaleChrome(tmp = os.tmpdir()) {
|
|
201
|
+
const result = { removed: [], stopped: 0 };
|
|
202
|
+
if (process.platform === 'win32') return result;
|
|
203
|
+
try {
|
|
204
|
+
const alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
|
|
205
|
+
let stale = [];
|
|
206
|
+
for (const name of fs.readdirSync(tmp)) {
|
|
207
|
+
if (!name.startsWith('pixi-effects-')) continue;
|
|
208
|
+
const dir = path.join(tmp, name);
|
|
209
|
+
let owner;
|
|
210
|
+
try { owner = Number(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8').trim()); } catch { continue; } // no owner file: not ours to take yet
|
|
211
|
+
if (Number.isInteger(owner) && owner > 0 && !alive(owner)) stale.push(dir);
|
|
212
|
+
}
|
|
213
|
+
if (!stale.length) return result;
|
|
214
|
+
const ps = spawnSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
215
|
+
const lines = ps.status === 0 ? ps.stdout.split('\n') : [];
|
|
216
|
+
for (const dir of stale) {
|
|
217
|
+
const flag = `--user-data-dir=${dir}`;
|
|
218
|
+
for (const line of lines) {
|
|
219
|
+
const at = line.indexOf(flag);
|
|
220
|
+
if (at < 0 || (line[at + flag.length] !== undefined && line[at + flag.length] !== ' ')) continue; // the whole folder name, not one that only starts like it
|
|
221
|
+
const pid = Number(line.trim().split(/\s+/, 1)[0]);
|
|
222
|
+
if (!Number.isInteger(pid) || pid === process.pid) continue;
|
|
223
|
+
try { process.kill(pid, 'SIGKILL'); result.stopped++; } catch { /* already gone */ }
|
|
224
|
+
}
|
|
225
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); result.removed.push(dir); } catch { /* held a moment longer: the next start takes it */ }
|
|
226
|
+
}
|
|
227
|
+
} catch { /* a cleanup never stops a launch */ }
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
189
231
|
/** Start a private headless Chrome. `extraArgs`: more command-line flags (the tests use it for `--enable-features=WebMCP`). */
|
|
190
232
|
export async function launchChrome(chrome, userDataDir, extraArgs = []) {
|
|
233
|
+
reapStaleChrome(); // what an earlier run left behind (its owner is gone) goes first
|
|
234
|
+
try { fs.writeFileSync(path.join(userDataDir, OWNER_FILE), String(process.pid)); } catch { /* a profile that cannot be marked is simply never swept */ }
|
|
191
235
|
const args = [
|
|
192
236
|
'--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
|
|
193
237
|
'--autoplay-policy=no-user-gesture-required', '--mute-audio', '--disable-audio-output', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', ...extraArgs, 'about:blank',
|
|
@@ -391,7 +435,7 @@ export async function runCheck(opts, log = console.log) {
|
|
|
391
435
|
const layoutProblems = rv.problems.filter(g => g.kind !== 'canvas'), canvasProblems = rv.problems.filter(g => g.kind === 'canvas');
|
|
392
436
|
report.inspect = { checkedFrames: rv.frames, issues: layoutProblems, review: rv.review, canvas: canvasProblems.map(g => g.message), canvasLayers: rv.canvas ?? [] };
|
|
393
437
|
if (layoutProblems.length) report.problems.push(`${layoutProblems.length} kind(s) of layout issue from movie.inspect`);
|
|
394
|
-
if (canvasProblems.length) report.problems.push(`${canvasProblems.length}
|
|
438
|
+
if (canvasProblems.length) report.problems.push(`${canvasProblems.length} layer(s) (canvas / three) that do not draw the same picture for the same time (movie.canvasProblems)`);
|
|
395
439
|
// fonts: a web font whose file did not load is a problem (the library lists a layer with no available font for review)
|
|
396
440
|
report.fonts = rv.fonts;
|
|
397
441
|
if (rv.fonts.failed.length) report.problems.push(`${rv.fonts.failed.length} web font(s) failed to load: ${rv.fonts.failed.join(', ')} (the layers that use them are drawn in a fallback)`);
|
|
@@ -446,6 +490,16 @@ export async function runCheck(opts, log = console.log) {
|
|
|
446
490
|
report.files.onion = shown(onionFile);
|
|
447
491
|
}
|
|
448
492
|
|
|
493
|
+
// --seam: a movie made to loop must not jump where it starts again
|
|
494
|
+
if (opts.seam) {
|
|
495
|
+
const m = await cdp.eval(SEAM_SCRIPT);
|
|
496
|
+
if (m.error) report.problems.push(`--seam: ${m.error}`);
|
|
497
|
+
else {
|
|
498
|
+
report.seam = { ...seamVerdict(m), frames: m.frames };
|
|
499
|
+
if (!report.seam.ok) report.problems.push(`the loop jumps at its seam: the last frame to the first differs by ${report.seam.seam}, ${report.seam.ratio}× the largest ordinary step (${report.seam.max})`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
449
503
|
// a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
|
|
450
504
|
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);
|
|
451
505
|
if (stops) {
|
|
@@ -564,6 +618,50 @@ export async function runCheck(opts, log = console.log) {
|
|
|
564
618
|
}
|
|
565
619
|
}
|
|
566
620
|
|
|
621
|
+
/**
|
|
622
|
+
* `check --seam`: does a movie made to loop close without a jump? `seam` is how much the last frame differs from the first (mean absolute difference of the colour
|
|
623
|
+
* channels, 0 to 1), `steps` are the same number for ordinary neighbouring frames of the movie, so the movie is its own measure. A seam up to 1.5 times the largest
|
|
624
|
+
* ordinary step (or under the noise floor of 0.001) closes; more is a jump.
|
|
625
|
+
*/
|
|
626
|
+
export function seamVerdict({ seam, steps }) {
|
|
627
|
+
const sorted = [...steps].sort((a, b) => a - b);
|
|
628
|
+
const median = sorted.length ? sorted[sorted.length >> 1] : 0, max = sorted.length ? sorted[sorted.length - 1] : 0;
|
|
629
|
+
const ok = seam <= Math.max(max * 1.5, 0.001);
|
|
630
|
+
const ratio = max > 0 ? seam / max : (seam > 0 ? Infinity : 0);
|
|
631
|
+
const n = x => (x < 0.1 ? x.toFixed(4) : x.toFixed(3));
|
|
632
|
+
const line = ok
|
|
633
|
+
? ` seam the loop closes: the last frame to the first differs by ${n(seam)}, an ordinary step by ${n(median)} (largest ${n(max)})`
|
|
634
|
+
: ` seam JUMP: the last frame to the first differs by ${n(seam)}, ${Number.isFinite(ratio) ? `${(Math.round(ratio * 10) / 10).toFixed(1)}×` : 'against nothing'} the largest ordinary step (${n(max)}): the picture jumps when the movie loops (make every motion periodic over the duration)`;
|
|
635
|
+
return { seam: Math.round(seam * 1e6) / 1e6, median: Math.round(median * 1e6) / 1e6, max: Math.round(max * 1e6) / 1e6, ratio: Number.isFinite(ratio) ? Math.round(ratio * 100) / 100 : null, ok, line };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** In the page: the seam (last frame against the first) and nine ordinary steps (the first and the last of them next to the seam), on pictures a quarter of the size. */
|
|
639
|
+
const SEAM_SCRIPT = `(async () => {
|
|
640
|
+
const total = movie.totalFrames;
|
|
641
|
+
if (total < 4) return { error: 'a movie of fewer than 4 frames has no loop to measure' };
|
|
642
|
+
const cache = new Map();
|
|
643
|
+
const grab = async f => { if (!cache.has(f)) cache.set(f, (await movie.snapshot(f, { as: 'imageData', scale: 0.25, motionBlur: false })).data); return cache.get(f); };
|
|
644
|
+
const mae = (a, b) => { let s = 0; for (let i = 0; i < a.length; i += 4) s += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]); return s / (a.length / 4 * 3) / 255; };
|
|
645
|
+
const picks = new Set([0, total - 2]);
|
|
646
|
+
for (let k = 1; k <= 7; k++) picks.add(Math.round((total - 2) * k / 8));
|
|
647
|
+
const steps = [];
|
|
648
|
+
for (const k of [...picks].sort((a, b) => a - b)) steps.push(mae(await grab(k), await grab(k + 1)));
|
|
649
|
+
return { seam: mae(await grab(total - 1), await grab(0)), steps, frames: total };
|
|
650
|
+
})()`;
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The line `check` prints for its own export. That export is made to see that the movie can be written and read back, at a low bitrate and with no way to
|
|
654
|
+
* ask for another: it is not the file to deliver (`render` is: `--video-bitrate`), and the line says so, with the real bitrate so nobody mistakes it for one.
|
|
655
|
+
*/
|
|
656
|
+
export function exportLine(ex) {
|
|
657
|
+
if (ex.error) return ` export ${ex.format}: FAILED — ${ex.error}`;
|
|
658
|
+
const v = ex.video?.error ? `video: ${ex.video.error}` : `video ${ex.video.duration.toFixed(2)} s ${ex.video.width}×${ex.video.height}`;
|
|
659
|
+
const au = ex.audio ? (ex.audio.error ? `audio: ${ex.audio.error}` : `audio ${ex.audio.duration} s, peak ${ex.audio.peakDb} dBFS, rms/s [${ex.audio.rmsDbPerSecond.join(' ')}]`) : 'no audio track';
|
|
660
|
+
const seconds = ex.video && !ex.video.error ? ex.video.duration : 0;
|
|
661
|
+
const rate = seconds > 0 ? ` · ${Math.round(ex.bytes * 8 / seconds / 1e5) / 10} Mbps (for looking at; \`render\` writes the file to deliver)` : '';
|
|
662
|
+
return ` export ${ex.format}${ex.draft ? ' (draft)' : ''} ok · ${kb(ex.bytes)}${rate} · ${v} · ${au}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
567
665
|
function finish(report, outDir, log) {
|
|
568
666
|
const reportFile = path.join(outDir, 'report.json');
|
|
569
667
|
report.files.report = shown(reportFile);
|
|
@@ -584,11 +682,13 @@ function finish(report, outDir, log) {
|
|
|
584
682
|
if (g.length > 8) L.push(` … ${g.length - 8} more (report.json)`);
|
|
585
683
|
const cv = report.inspect.canvas ?? [];
|
|
586
684
|
const rows = report.inspect.canvasLayers ?? [];
|
|
587
|
-
if (cv.length) { L.push(` canvas ${cv.length}
|
|
588
|
-
|
|
589
|
-
const
|
|
685
|
+
if (cv.length) { L.push(` canvas ${cv.length} layer(s) (canvas / three) that do not draw the same picture for the same time:`); for (const m of cv.slice(0, 4)) L.push(` - ${m}`); }
|
|
686
|
+
for (const kind of ['canvas', 'three']) { // silence must not be the only sign that the check ran
|
|
687
|
+
const mine = rows.filter(r => (r.kind ?? 'canvas') === kind);
|
|
688
|
+
if (!mine.length) continue;
|
|
689
|
+
const good = mine.filter(r => r.ok), slow = [...mine].sort((a, b) => b.drawMs - a.drawMs)[0];
|
|
590
690
|
const ms = x => x < 10 ? x.toFixed(1) : String(Math.round(x));
|
|
591
|
-
if (good.length) L.push(`
|
|
691
|
+
if (good.length) L.push(` ${kind.padEnd(9)} ${cv.length ? 'the other ' : ''}${good.length} ${kind} layer(s) drew the same picture twice at ${good[0].moments.length} moments (two orders): deterministic · slowest draw ${ms(slow.drawMs)} ms${kind === 'canvas' ? ' on the CPU' : ''} (${slow.name})`);
|
|
592
692
|
}
|
|
593
693
|
const rv = report.inspect.review;
|
|
594
694
|
if (rv.length) {
|
|
@@ -626,12 +726,8 @@ function finish(report, outDir, log) {
|
|
|
626
726
|
for (const l of formatMusic(a?.sources)) L.push(l);
|
|
627
727
|
for (const n of a?.notes ?? []) L.push(` note ${n}`);
|
|
628
728
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const v = ex.video?.error ? `video: ${ex.video.error}` : `video ${ex.video.duration.toFixed(2)} s ${ex.video.width}×${ex.video.height}`;
|
|
632
|
-
const au = ex.audio ? (ex.audio.error ? `audio: ${ex.audio.error}` : `audio ${ex.audio.duration} s, peak ${ex.audio.peakDb} dBFS, rms/s [${ex.audio.rmsDbPerSecond.join(' ')}]`) : 'no audio track';
|
|
633
|
-
L.push(` export ${ex.format}${ex.draft ? ' (draft)' : ''} ok · ${kb(ex.bytes)} · ${v} · ${au}`);
|
|
634
|
-
}
|
|
729
|
+
if (report.seam) L.push(report.seam.line);
|
|
730
|
+
for (const ex of report.exports ?? []) L.push(exportLine(ex));
|
|
635
731
|
L.push(` files ${Object.values(report.files).join(' ')}`);
|
|
636
732
|
L.push(report.problems.length ? `RESULT: ${report.problems.length} PROBLEM(S)\n - ${report.problems.join('\n - ')}` : 'RESULT: OK — nothing to fix. Look at the contact sheet before you call it done.');
|
|
637
733
|
log(L.join('\n'));
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pixi-effects probe model.glb` — what is in a glTF model, read in Node with no browser and no three.js: the clips (name and length), the size of the model in the file's own units
|
|
3
|
+
* (the bounding box of the meshes through the transforms of the nodes: the pose the file is stored in, which is what `threeGltf(...).size` measures), what it holds, and the notes an
|
|
4
|
+
* author trips on (a file that needs a decoder, a model with no clip).
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const GLB_MAGIC = 0x46546c67, JSON_CHUNK = 0x4e4f534a;
|
|
10
|
+
const round = (v, d = 3) => Math.round(v * 10 ** d) / 10 ** d;
|
|
11
|
+
export const isGltfFile = (file) => /\.(glb|gltf)(\?.*)?$/i.test(file);
|
|
12
|
+
const isUrl = (f) => /^https?:\/\//i.test(f);
|
|
13
|
+
|
|
14
|
+
/** The JSON of a .glb (its first chunk) or a .gltf. */
|
|
15
|
+
function parseJson(file, buf) {
|
|
16
|
+
const name = isUrl(file) ? file : path.basename(file);
|
|
17
|
+
if (buf.length >= 12 && buf.readUInt32LE(0) === GLB_MAGIC) {
|
|
18
|
+
const version = buf.readUInt32LE(4);
|
|
19
|
+
if (version !== 2) throw new Error(`${name}: glTF version ${version} (only 2.0 is read)`);
|
|
20
|
+
let at = 12;
|
|
21
|
+
while (at + 8 <= buf.length) {
|
|
22
|
+
const len = buf.readUInt32LE(at), type = buf.readUInt32LE(at + 4);
|
|
23
|
+
if (type === JSON_CHUNK) return { json: JSON.parse(buf.subarray(at + 8, at + 8 + len).toString('utf8')), container: 'glb' };
|
|
24
|
+
at += 8 + len + ((4 - (len % 4)) % 4);
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`${name}: a .glb with no JSON chunk`);
|
|
27
|
+
}
|
|
28
|
+
try { return { json: JSON.parse(buf.toString('utf8')), container: 'gltf' }; }
|
|
29
|
+
catch { throw new Error(`${name} is not a glTF model (it is neither a .glb, which starts with "glTF", nor glTF JSON)`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 4x4 matrices, column-major as glTF stores them.
|
|
33
|
+
const identity = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
34
|
+
const multiply = (a, b) => { const o = new Array(16).fill(0); for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; return o; };
|
|
35
|
+
function local(node) {
|
|
36
|
+
if (node.matrix?.length === 16) return node.matrix;
|
|
37
|
+
const [tx, ty, tz] = node.translation ?? [0, 0, 0], [x, y, z, w] = node.rotation ?? [0, 0, 0, 1], [sx, sy, sz] = node.scale ?? [1, 1, 1];
|
|
38
|
+
return [
|
|
39
|
+
(1 - 2 * (y * y + z * z)) * sx, 2 * (x * y + z * w) * sx, 2 * (x * z - y * w) * sx, 0,
|
|
40
|
+
2 * (x * y - z * w) * sy, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z + x * w) * sy, 0,
|
|
41
|
+
2 * (x * z + y * w) * sz, 2 * (y * z - x * w) * sz, (1 - 2 * (x * x + y * y)) * sz, 0,
|
|
42
|
+
tx, ty, tz, 1,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
const apply = (m, [x, y, z]) => [m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14]];
|
|
46
|
+
|
|
47
|
+
/** The report of a glTF JSON. */
|
|
48
|
+
export function describeGltf(json, { file, bytes, container }) {
|
|
49
|
+
const nodes = json.nodes ?? [], meshes = json.meshes ?? [], accessors = json.accessors ?? [];
|
|
50
|
+
const notes = [];
|
|
51
|
+
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
52
|
+
let measured = 0, unmeasured = 0;
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const visit = (i, parent) => {
|
|
55
|
+
if (seen.has(i) || !nodes[i]) return; // a node reached twice is a broken file, not a reason to loop
|
|
56
|
+
seen.add(i);
|
|
57
|
+
const world = multiply(parent, local(nodes[i]));
|
|
58
|
+
const mesh = meshes[nodes[i].mesh];
|
|
59
|
+
for (const prim of mesh?.primitives ?? []) {
|
|
60
|
+
const acc = accessors[prim.attributes?.POSITION];
|
|
61
|
+
if (!acc?.min || !acc?.max) { unmeasured++; continue; }
|
|
62
|
+
measured++;
|
|
63
|
+
for (const x of [acc.min[0], acc.max[0]]) for (const y of [acc.min[1], acc.max[1]]) for (const z of [acc.min[2], acc.max[2]]) {
|
|
64
|
+
const p = apply(world, [x, y, z]);
|
|
65
|
+
for (let k = 0; k < 3; k++) { if (p[k] < lo[k]) lo[k] = p[k]; if (p[k] > hi[k]) hi[k] = p[k]; }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const c of nodes[i].children ?? []) visit(c, world);
|
|
69
|
+
};
|
|
70
|
+
const scene = json.scenes?.[json.scene ?? 0];
|
|
71
|
+
const roots = scene?.nodes ?? nodes.map((_, i) => i).filter((i) => !nodes.some((n) => n.children?.includes(i)));
|
|
72
|
+
for (const r of roots) visit(r, identity());
|
|
73
|
+
const size = measured ? [0, 1, 2].map((k) => hi[k] - lo[k]) : null;
|
|
74
|
+
if (!size) notes.push('the size of the model could not be read (its position accessors have no min / max)');
|
|
75
|
+
else if (unmeasured) notes.push(`${unmeasured} mesh part(s) have no position min / max: the size leaves them out`);
|
|
76
|
+
|
|
77
|
+
const animations = (json.animations ?? []).map((a, index) => {
|
|
78
|
+
let duration = 0;
|
|
79
|
+
for (const s of a.samplers ?? []) { const t = accessors[s.input]; if (t?.max?.[0] > duration) duration = t.max[0]; }
|
|
80
|
+
return { index, name: a.name ?? `(unnamed ${index})`, duration: round(duration) };
|
|
81
|
+
});
|
|
82
|
+
if (!animations.length) notes.push('no animation: threeGltf poses nothing; the model stays in the pose it is stored in');
|
|
83
|
+
const required = json.extensionsRequired ?? [];
|
|
84
|
+
const decoders = required.filter((e) => /draco|meshopt|basisu|ktx2|webp/i.test(e));
|
|
85
|
+
if (decoders.length) notes.push(`needs ${decoders.join(', ')}: three's GLTFLoader needs a decoder for it (DRACOLoader / KTX2Loader / MeshoptDecoder), and threeGltf does not set one up: re-export the model without that compression, or load it yourself`);
|
|
86
|
+
const external = [...(json.buffers ?? []), ...(json.images ?? [])].filter((x) => x.uri && !/^data:/i.test(x.uri));
|
|
87
|
+
if (external.length) notes.push(`${external.length} file(s) are loaded next to the model (${external.slice(0, 3).map((x) => x.uri).join(', ')}${external.length > 3 ? ', …' : ''}): keep them beside it`);
|
|
88
|
+
return {
|
|
89
|
+
file, container: `glTF ${json.asset?.version ?? '2.0'} (${container})`, bytes,
|
|
90
|
+
counts: { nodes: nodes.length, meshes: meshes.length, skins: (json.skins ?? []).length, materials: (json.materials ?? []).length, textures: (json.textures ?? []).length },
|
|
91
|
+
size: size ? size.map((v) => round(v, 6)) : null, longest: size ? round(Math.max(...size), 6) : null,
|
|
92
|
+
animations, extensions: json.extensionsUsed ?? [], notes,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read a local file or a URL and describe it. */
|
|
97
|
+
export async function probeGltf(file) {
|
|
98
|
+
let buf;
|
|
99
|
+
if (isUrl(file)) {
|
|
100
|
+
const res = await fetch(file);
|
|
101
|
+
if (!res.ok) throw new Error(`could not read ${file}: HTTP ${res.status}`);
|
|
102
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
103
|
+
} else {
|
|
104
|
+
if (!fs.existsSync(file)) throw new Error(`${file}: no such file`);
|
|
105
|
+
buf = fs.readFileSync(file);
|
|
106
|
+
}
|
|
107
|
+
const { json, container } = parseJson(file, buf);
|
|
108
|
+
return describeGltf(json, { file, bytes: isUrl(file) ? null : buf.length, container });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const num = (v) => String(round(v, 3));
|
|
112
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
113
|
+
export function gltfText(r) {
|
|
114
|
+
const kb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
|
|
115
|
+
const L = [r.file, ` ${r.container}${r.bytes != null ? ` · ${kb(r.bytes)}` : ''}`];
|
|
116
|
+
const c = r.counts;
|
|
117
|
+
L.push(` model ${plural(c.nodes, 'node')} · ${plural(c.meshes, 'mesh').replace('meshs', 'meshes')} · ${plural(c.skins, 'skin')} · ${plural(c.materials, 'material')} · ${plural(c.textures, 'texture')}`);
|
|
118
|
+
if (r.size) L.push(` size ${r.size.map(num).join(' × ')} (x × y × z in the file's units, as stored: the longest side is ${num(r.longest)}; threeGltf's fit(px) scales that side to px)`);
|
|
119
|
+
for (const a of r.animations) L.push(` clip ${a.index} "${a.name}" ${num(a.duration)} s`);
|
|
120
|
+
for (const n of r.notes) L.push(` note ${n}`);
|
|
121
|
+
return L.join('\n');
|
|
122
|
+
}
|