pixi-effects 0.41.0 → 0.43.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 +5 -5
- package/ai/chat-template.html +3 -3
- package/dist/{Base-Jgkud1Aw.d.ts → Base-DKFQtqzW.d.ts} +1 -1
- package/dist/{Base-DQWIJ6bJ.d.cts → Base-lb5Uhaqu.d.cts} +1 -1
- package/dist/Controller.d.cts +2 -2
- package/dist/Controller.d.ts +2 -2
- package/dist/{Movie-CwqRLOzE.d.ts → Movie-B_qQIYV6.d.ts} +47 -4
- package/dist/{Movie-DWtxRiJ2.d.cts → Movie-BdUm2Mc9.d.cts} +47 -4
- package/dist/{chunk-Y2N6U3X5.js → chunk-6N5ZBUOU.js} +169 -8
- package/dist/chunk-6N5ZBUOU.js.map +1 -0
- package/dist/{chunk-RN5FWGGM.cjs → chunk-BEGOKE7Q.cjs} +169 -8
- package/dist/chunk-BEGOKE7Q.cjs.map +1 -0
- package/dist/index.cjs +25 -25
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/loader.css +1 -1
- package/dist/present.cjs +1 -1
- package/dist/present.d.cts +2 -2
- package/dist/present.d.ts +2 -2
- package/dist/present.js +1 -1
- package/dist/three.cjs +115 -24
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +28 -2
- package/dist/three.d.ts +28 -2
- package/dist/three.js +117 -26
- package/dist/three.js.map +1 -1
- package/dist/{types-DIh93lFb.d.cts → types-DiNHtdrS.d.cts} +19 -0
- package/dist/{types-DIh93lFb.d.ts → types-DiNHtdrS.d.ts} +19 -0
- package/docs/api.md +23 -3
- package/docs/dsl.md +3 -2
- package/llms-full.txt +147 -17
- package/package.json +2 -1
- package/skills/pixi-effects/SKILL.md +2 -1
- package/skills/pixi-effects/reference/cheatsheet.md +2 -0
- package/skills/pixi-effects/reference/recipes.md +117 -11
- package/skills/pixi-effects/template.html +6 -6
- package/dist/chunk-RN5FWGGM.cjs.map +0 -1
- package/dist/chunk-Y2N6U3X5.js.map +0 -1
package/llms-full.txt
CHANGED
|
@@ -6,7 +6,7 @@ Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API / me
|
|
|
6
6
|
|
|
7
7
|
<!-- ===== skills/pixi-effects/SKILL.md ===== -->
|
|
8
8
|
|
|
9
|
-
> Written for pixi-effects 0.
|
|
9
|
+
> Written for pixi-effects 0.43.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
|
|
10
10
|
|
|
11
11
|
# pixi-effects — write a video as data
|
|
12
12
|
|
|
@@ -34,6 +34,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
34
34
|
**Read `reference/direction.md` before writing a composition for a product film, a company introduction, a launch or a data story.** It is renderer-neutral: write `brief.md` (the one sentence to remember, the REAL assets with paths, what must never be invented; if a needed asset is missing, stop and ask), `style-guide.md` (palette and type taken from the real source, a motion rule per class of object) and `shotlist.md` (a row per beat with entry and exit state) next to the page; then build, then review the rendered frames cold. What is specific to this library:
|
|
35
35
|
|
|
36
36
|
- **Time and sound:** pick a tempo whose bar is a round number of seconds (96 bpm: 2.5 s; 120 bpm: 2 s), put every cut on a bar line and every sfx on its beat. `npx pixi-effects check` prints a `music` read-back (bars per track, what starts on which beat, the drum span): verify the sound with it.
|
|
37
|
+
- **Interactive pages (a person clicks, hovers, drags):** `movie.pick(x, y)` says what is under a point, `movie.hover({ onEnter, onLeave })` and the recipes `pick-click`, `drag-drop`, `drag-3d` build on it; `check` does not click, so **test the page by sending pointer events and reading the state** (serve the folder over http: a model, an image or a module beside the page does not load from file://). In a browser tool or over Chrome's protocol: `const r = canvas.getBoundingClientRect(); canvas.dispatchEvent(new PointerEvent('pointerdown', { clientX: r.left + x * r.width / W, clientY: r.top + y * r.height / H, bubbles: true, pointerId: 1 }))` (then `pointermove`, `pointerup`, with a `settle` of 300 ms after a `setVars` rebuild), and read `movie.vars`, `movie.currentFrame`, `movie.isPlaying`, `movie.pick(x, y)` and the canvas's `style.cursor`. A canvas for touch has `style="touch-action: none"`.
|
|
37
38
|
- **Review:** `check --at 3.5,title@end,50% --pick 640,360` also says what is under that canvas point at each moment (the layers, top first; a three layer names the object) — a layout question you can answer without looking; `check --at 3.5,title@end,50%` writes the contact sheet and single frames; look at mid-transition frames of the exported file too. Frames are seekable and deterministic here: render the same range twice and compare the frames if you doubt it.
|
|
38
39
|
- **Formats:** make the formats the human asked for, one if they did not say. When a vertical cut is wanted it is its own composition: one source, a layout function, `?f=9x16` (recipe `two-formats`); `check` and `render` take `--query f=9x16`.
|
|
39
40
|
- Fit a picture into a rectangle with the `photo()` helper of that recipe (`scale: 'cover'` fits the PARENT only); designed pictures (posters, flyers, screenshots) are shown whole, never cropped.
|
|
@@ -301,7 +302,9 @@ await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several
|
|
|
301
302
|
await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
|
|
302
303
|
await movie.inspect(60, { layers: 'none' }) // issues only (default lists the visible layers); text off-canvas / cut off / empty / overlapping text. Name your layers so paths are readable
|
|
303
304
|
await movie.onionSkin({ from: 1, to: 3, count: 8, as: 'dataURL' }) // frames overlaid into ONE picture of a movement (later = stronger): path and easing at a glance
|
|
305
|
+
movie.pick(x, y, { alpha: 0.1 }) // alpha: look at the picture, not the box (image / video / canvas layers: a pixel at or below it is not a hit). A three hit can be dragged: `hit.three.grab('floor' | 'wall' | 'screen' | [nx, ny, nz]).move(x, y)` → { point, delta } on that plane; `await movie.redraw()` draws the paused frame again. Recipe `drag-3d`
|
|
304
306
|
movie.pick(x, y) // what is under a canvas point at the frame on screen: { name, path, type, kind: 'flat' | 'card' | 'three', local?, three?: { object, point, distance } } or null (pickAll: the whole stack, top first; { only, ignore }); in a click handler: `movie.pick(...movie.canvasPoint(event))`; to ask about another frame: `await movie.snapshot(f)` first
|
|
307
|
+
movie.hover({ onEnter: (hit) => …, onLeave: (hit) => …, ignore: ['background'], cursor: true }) // which layer the pointer is over (built on pick; follows a picture that moves under a still pointer); returns the stop function. Drag and drop: recipe `drag-drop`
|
|
305
308
|
movie.review({ at: 'title@end' }) // what `pixi-effects check` looks at, as data: { frames, problems (text cut off, off-canvas, no size), review (text overlaps: look at them), fonts, audio, at }; leaves the movie as it was. `strict: true` makes every issue a problem
|
|
306
309
|
movie.resolveAt('3.5, 50%, f120, title@end') // moments → [{ label, frame }]; a mistake says what could not be read
|
|
307
310
|
movie.inspectFonts() // { missing: [{ layer, family }] (no font of the list is installed), failed: [web fonts whose file did not load] }
|
|
@@ -1855,26 +1858,132 @@ A moving, shadow-casting model is held to "a function of the time" by `check`: p
|
|
|
1855
1858
|
|
|
1856
1859
|
## Click a layer: what is under the pointer (`movie.pick`)
|
|
1857
1860
|
|
|
1858
|
-
`movie.pick(x, y)` answers "what is drawn under this point, at the frame on screen" with no hit-testing code of yours: a flat layer by its own shape, a `threeD` card by its projected quad, a `three` layer by a ray (the hit says which object). `movie.canvasPoint(event)` turns a pointer event into canvas pixels, whatever size the canvas is shown at. Name the layers you want to react to.
|
|
1861
|
+
`movie.pick(x, y)` answers "what is drawn under this point, at the frame on screen" with no hit-testing code of yours: a flat layer by its own shape, a `threeD` card by its projected quad, a `three` layer by a ray (the hit says which object). `movie.canvasPoint(event)` turns a pointer event into canvas pixels, whatever size the canvas is shown at. Name the layers you want to react to: `only` / `ignore` take layer names (or paths; a group's name stands for everything in it). A `three` layer covers its whole picture but only what the ray meets is a hit: elsewhere the layers below are hit (the title, the background).
|
|
1862
|
+
|
|
1863
|
+
An interactive page is the template with three changes: no `Controller` (it adds a player bar), `movie.loop = true` or `movie.pause()` as the page needs, and a handler on the canvas. A text that changes on a click is a **variable** (`{title}` in the text, `vars: { title }`, then `movie.setVars({ title })`: it rebuilds at the same frame, and a paused movie stays paused); `at` and `duration` are not variables. A **reaction** is a short animation: put its keyframes on the layer, seek to its start and play (`await movie.gotoFrame(0, true); movie.play()`); a movie that is not looping stops at its end by itself.
|
|
1859
1864
|
|
|
1860
1865
|
```js
|
|
1861
1866
|
// @docs-only pick-click
|
|
1862
|
-
//
|
|
1867
|
+
// the page: a title, and a fox (a three layer, name 'fox', followCamera) running in front of it
|
|
1868
|
+
// await movie.init({ canvas, width: 960, height: 540, duration: 6, frameRate: 30, loop: true, vars: { title: 'CATCH THE FOX', hint: 'click it' },
|
|
1869
|
+
// composition: { sequences: [
|
|
1870
|
+
// { type: 'text', name: 'title', text: '{title}', style: { fontSize: 110, fill: '#ffffff' }, initial: { x: 'GW/2', y: 150, anchorX: 0.5, anchorY: 0.5 } },
|
|
1871
|
+
// { type: 'text', name: 'hint', text: '{hint}', style: { fontSize: 32, fill: '#8fa0c8' }, initial: { x: 'GW/2', y: 500, anchorX: 0.5, anchorY: 0.5 } },
|
|
1872
|
+
// { type: 'three', name: 'fox', followCamera: true, setup: ..., update: (t) => fox.pose(t) }, // recipe gltf-in-space
|
|
1873
|
+
// ] } });
|
|
1874
|
+
// movie.play();
|
|
1863
1875
|
const canvas = document.getElementById('stage');
|
|
1864
|
-
|
|
1865
|
-
|
|
1876
|
+
movie.hover({ only: ['fox'], cursor: true, // 'pointer' over the fox, the normal arrow elsewhere (the title and the background are not listed)
|
|
1877
|
+
onEnter: (hit) => console.log('over', hit.name),
|
|
1878
|
+
onLeave: (hit) => console.log('left', hit.name) });
|
|
1879
|
+
canvas.addEventListener('click', async (event) => {
|
|
1880
|
+
const hit = movie.pick(...movie.canvasPoint(event), { only: ['fox'] }); // null for a click anywhere else: nothing happens
|
|
1866
1881
|
if (!hit) return;
|
|
1867
|
-
|
|
1868
|
-
|
|
1882
|
+
movie.pause(); // the fox stops running
|
|
1883
|
+
await movie.setVars({ title: 'GOTCHA!', hint: 'the fox stopped running' }); // the text is a variable: the movie is built again at this frame, still paused
|
|
1869
1884
|
});
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1885
|
+
// to ask about another frame, or from a test: await movie.snapshot(90); movie.pick(480, 270)
|
|
1886
|
+
// after something outside the movie that a layer reads changes while the movie is paused: await movie.redraw();
|
|
1887
|
+
```
|
|
1888
|
+
|
|
1889
|
+
`pick` reads the frame as the last draw left it (it takes no time and has no clock). `movie.pick(x, y, { alpha: 0.1 })` looks at the picture and not its box (a transparent corner of a logo, the clear part of a drawn circle: not a hit). Dragging is built on `pick` too: recipes `drag-drop` (a flat layer) and `drag-3d` (an object of a three layer). **Test an interactive page** by sending pointer events and reading the state (`SKILL.md`, "Interactive pages"): `check` does not click.
|
|
1890
|
+
|
|
1891
|
+
## Drag a layer and drop it on a target (`pick` and variables)
|
|
1892
|
+
|
|
1893
|
+
A layer whose place is two variables moves when the page sets them: `movie.setVars({ cx, cy })` builds the composition again at the same frame (tens of milliseconds for a light composition; calls that fall behind are joined, so it is safe from `pointermove`). A glow whose `alpha` is a variable is a light that is on or off (`alpha: 'doneLit'`: any number a layer takes can be a variable name; write 0 and 1, not booleans). `pick` says what is taken (`only`) and what lies under it when it is let go (`ignore` the dragged layer). Listen on the window while it is held, so the pointer may leave the canvas. A heavy composition (a model to load again, a long sound) rebuilds slowly: keep what moves a light layer, and for a three scene read the place in `update` and call `await movie.redraw()` (recipe `drag-3d`).
|
|
1894
|
+
|
|
1895
|
+
A card with a label is two layers: name the card's group (a `composition` layer, whose children are one target) or pass both names (`only: ['card', 'card-label']`). A zone with a label is two layers as well: ask with `only: ['done', 'trash']` and the labels are not in the way. Which zone is the card over: the zone under the card's middle (`pick(cx, cy)`), not under the pointer.
|
|
1896
|
+
|
|
1897
|
+
```js
|
|
1898
|
+
// @docs-only drag-drop
|
|
1899
|
+
// the page: a canvas with style="touch-action: none" (a finger dragging must not scroll the page)
|
|
1900
|
+
// await movie.init({ canvas, width: 960, height: 540, vars: { cx: 200, cy: 270, doneLit: 0 }, composition: { sequences: [
|
|
1901
|
+
// { type: 'shape', shape: 'rect', name: 'done', width: 260, height: 200, initial: { x: 770, y: 270, anchorX: 0.5, anchorY: 0.5, fillColor: '#182038' } },
|
|
1902
|
+
// { type: 'shape', shape: 'rect', name: 'done-glow', width: 260, height: 200, initial: { x: 770, y: 270, anchorX: 0.5, anchorY: 0.5, fillColor: '#3ddc97', alpha: 'doneLit' } }, // the light: a variable
|
|
1903
|
+
// { type: 'composition', name: 'card', width: 200, height: 120, duration: 1, initial: { x: 'cx', y: 'cy', pivotX: 100, pivotY: 60 }, sequences: [ // the card and its label: ONE target (the group's name); a composition turns and sits about its pivot
|
|
1904
|
+
// { type: 'shape', shape: 'rect', name: 'face', width: 200, height: 120, cornerRadius: 14, initial: { x: 100, y: 60, anchorX: 0.5, anchorY: 0.5, fillColor: '#f2c14e' } },
|
|
1905
|
+
// { type: 'text', name: 'label', text: 'Task', style: { fontSize: 34, fill: '#1b1b1b' }, initial: { x: 100, y: 60, anchorX: 0.5, anchorY: 0.5 } },
|
|
1906
|
+
// ] },
|
|
1907
|
+
// ] } });
|
|
1908
|
+
// movie.pause(); // a still picture: nothing plays while it is dragged
|
|
1909
|
+
movie.hover({ only: ['card'], cursor: 'grab' }); // the cursor says it can be taken
|
|
1910
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
1911
|
+
const [x, y] = movie.canvasPoint(e);
|
|
1912
|
+
if (!movie.pick(x, y, { only: ['card'] })) return; // only the card (and its label) can be picked up
|
|
1913
|
+
const grip = { dx: movie.vars.cx - x, dy: movie.vars.cy - y }; // where in the card it was taken, so it does not jump
|
|
1914
|
+
canvas.style.cursor = 'grabbing';
|
|
1915
|
+
const place = (ev) => {
|
|
1916
|
+
const [px, py] = movie.canvasPoint(ev);
|
|
1917
|
+
const cx = px + grip.dx, cy = py + grip.dy;
|
|
1918
|
+
const over = movie.pick(cx, cy, { only: ['done'] }); // the zone under the card's middle: it lights while the card is over it
|
|
1919
|
+
return movie.setVars({ cx, cy, doneLit: over ? 0.6 : 0 });
|
|
1920
|
+
};
|
|
1921
|
+
const move = (ev) => { place(ev); };
|
|
1922
|
+
const up = async (ev) => {
|
|
1923
|
+
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
|
|
1924
|
+
canvas.style.cursor = '';
|
|
1925
|
+
await place(ev); // the card is where it was let go: now ask what is under it
|
|
1926
|
+
const [px, py] = movie.canvasPoint(ev);
|
|
1927
|
+
const under = movie.pick(px + grip.dx, py + grip.dy, { only: ['done'] });
|
|
1928
|
+
if (under) await movie.setVars({ cx: 770, cy: 270, doneLit: 0 }); // snap into the middle of the zone
|
|
1929
|
+
else await movie.setVars({ cx: 200, cy: 270, doneLit: 0 }); // anywhere else: back where it started
|
|
1930
|
+
};
|
|
1931
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
1932
|
+
});
|
|
1933
|
+
```
|
|
1934
|
+
|
|
1935
|
+
## Drag an object of a three layer (`hit.three.grab`)
|
|
1936
|
+
|
|
1937
|
+
The scene is the `three` layer's own, so what is taken is a three object: `movie.pick` says which (`hit.three.object`), `hit.three.grab(plane)` fixes a plane through the point that was taken, and `grab.move(x, y)` says where the pointer's ray meets that plane and how far that is from the point taken (`delta`). Planes: `'wall'` (z constant: slides across the picture), `'floor'` (horizontal: slides along the ground), `'screen'` (facing the camera), or a normal. The layer's `update` reads the object's place from a variable of the page, and `await movie.redraw()` draws the paused frame again after each move (a playing movie draws it by itself).
|
|
1938
|
+
|
|
1939
|
+
```js
|
|
1940
|
+
// @docs-only drag-3d
|
|
1941
|
+
// the three layer (followCamera: true, so the composition's camera is the layer's): update: () => { cube.position.set(pos.x, pos.y, pos.z); }
|
|
1942
|
+
// the page's state is the cube's place in three's units (the composition's (x, y, z) is (x, -y, z)): const pos = { x: 480, y: -270, z: 0 };
|
|
1943
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
1944
|
+
const hit = movie.pick(...movie.canvasPoint(e), { only: ['world'] });
|
|
1945
|
+
if (hit?.three?.object !== 'cube') return; // only the cube can be taken
|
|
1946
|
+
const grab = hit.three.grab('wall'); // it slides on the wall plane through the point it was taken at
|
|
1947
|
+
const start = { ...pos }; // where it was
|
|
1948
|
+
const move = (ev) => {
|
|
1949
|
+
const m = grab.move(...movie.canvasPoint(ev)); // the ray of the pointer meets the plane here, this far from the point taken
|
|
1950
|
+
if (!m) return; // a ray that never meets the plane (a floor seen edge-on): leave it
|
|
1951
|
+
pos.x = start.x + m.delta[0]; pos.y = start.y + m.delta[1]; pos.z = start.z + m.delta[2];
|
|
1952
|
+
movie.redraw(); // the layer reads `pos`: draw the frame again
|
|
1953
|
+
};
|
|
1954
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
1955
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
1873
1956
|
});
|
|
1874
|
-
// a test, or an AI checking its own page, asks about a chosen frame: await movie.snapshot(90); movie.pick(640, 360)
|
|
1875
1957
|
```
|
|
1876
1958
|
|
|
1877
|
-
|
|
1959
|
+
The grabbed point stays under the pointer exactly, whatever the camera does (the maths is the camera's own ray, not a guess of pixels per unit).
|
|
1960
|
+
|
|
1961
|
+
**On the ground:** use `'floor'`. Then `m.delta[1]` is 0: write only `pos.x` and `pos.z`, never `y`, and the object stays on the floor. A pointer ray that points above the horizon never meets the floor and `move` answers null (the `if (!m) return` keeps the object where it was). To see a floor in perspective: a camera above it, looking down at it (the composition's `y` runs DOWN, so a camera `y` smaller than the floor's is above it), and a floor big enough that its far edge stays above the horizon of the view.
|
|
1962
|
+
|
|
1963
|
+
```js
|
|
1964
|
+
// @docs-only drag-3d-floor
|
|
1965
|
+
// in the composition: { type: 'camera', initial: { x: 480, y: 40, z: 900, lookAtX: 480, lookAtY: 340, lookAtZ: 0 } },
|
|
1966
|
+
// { type: 'three', name: 'world', followCamera: true, setup, update: () => cube.position.set(pos.x, pos.y, pos.z) }
|
|
1967
|
+
// in setup: the floor, 9000 px square, turned flat, its top at the composition's y = 440 (three's -440), and the cube standing on it (a 100 px cube: its middle 50 px above)
|
|
1968
|
+
// const floor = new THREE.Mesh(new THREE.PlaneGeometry(9000, 9000), new THREE.MeshLambertMaterial({ color: 0x3b4f7a }));
|
|
1969
|
+
// floor.rotation.x = -Math.PI / 2; floor.position.set(480, -440, -400); ctx.scene.add(floor);
|
|
1970
|
+
const pos = { x: 600, y: -390, z: -40 }; // y is the cube's middle: it never changes
|
|
1971
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
1972
|
+
const hit = movie.pick(...movie.canvasPoint(e), { only: ['world'] });
|
|
1973
|
+
if (hit?.three?.object !== 'cube') return; // the floor is a hit of the same layer, with another object name
|
|
1974
|
+
const grab = hit.three.grab('floor');
|
|
1975
|
+
const start = { ...pos };
|
|
1976
|
+
const move = (ev) => {
|
|
1977
|
+
const m = grab.move(...movie.canvasPoint(ev));
|
|
1978
|
+
if (!m) return; // above the horizon: leave it
|
|
1979
|
+
pos.x = Math.max(60, Math.min(900, start.x + m.delta[0])); // along the floor, kept inside it
|
|
1980
|
+
pos.z = Math.max(-600, Math.min(300, start.z + m.delta[2])); // nearer or farther; y is never written
|
|
1981
|
+
movie.redraw();
|
|
1982
|
+
};
|
|
1983
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
1984
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
1985
|
+
});
|
|
1986
|
+
```
|
|
1878
1987
|
|
|
1879
1988
|
## Subtitles from an SRT file
|
|
1880
1989
|
|
|
@@ -2952,6 +3061,7 @@ three({
|
|
|
2952
3061
|
|
|
2953
3062
|
The layer's own three.js renderer is `WebGLRenderer` unless you ask for `renderer: 'webgpu'`, which makes it three's `WebGPURenderer` (the picture is still copied into the layer's texture, so filters, masks, blend modes and mattes work as on any layer). It needs three 0.17x or later and `three/webgpu` in the import map next to `three` (both files import the same `three.core.js`, so they share one copy of three). Where the browser has no WebGPU, three itself falls back to its WebGL2 backend.
|
|
2954
3063
|
|
|
3064
|
+
- **In a movie that PixiJS draws on WebGPU, a `'webgpu'` layer draws on PixiJS's own GPU device** (`shareGpu`, default `true`): it renders into a texture that PixiJS samples directly, with no canvas of its own and no copy. The picture is the same (measured 2026-10-17: 0 of 255 different in every channel of nine pictures, with antialias, fog and tone mapping), it is faster (spike, one Apple machine: 0.7 against 1.1 ms for one layer, 1.6 against 5.7 ms for ten), and the limit of about 32 WebGPU canvases does not apply (60 layers drew in the test film `three-budget`; a layer on the shared device is not counted in the budget warning). `shareGpu: false` gives the layer a canvas of its own as before, and so does a movie PixiJS draws on WebGL (the layer then counts against the 32). If the shared route fails, the layer says so once and is made again on its own canvas before `setup` runs. `movie.inspect()` lists `sharedGpu` for such a layer. This reaches into one field of PixiJS (`source._gpuData`); a PixiJS that moved it makes every layer fall back, with that one warning.
|
|
2955
3065
|
- `ctx.renderer` is a `WebGLRenderer` or a `WebGPURenderer`; `setClearColor`, `toneMapping` and `toneMappingExposure` exist on both.
|
|
2956
3066
|
- **The default stays `'webgl'`.** A scene built the way AI usually writes three code (`ShaderMaterial` / `RawShaderMaterial` with GLSL, a `WebGLRenderTarget` post chain with fullscreen quads, `onBeforeCompile`) cannot run on three's WebGPU renderer; it needs a rewrite with node materials (TSL). Tried on four large stock clips (custom bloom, 4K): all four stop with `Material "ShaderMaterial" is not compatible`. Use `'webgpu'` for scenes made of the built-in `Mesh*Material`s, `InstancedMesh`, `Points` and lights. At 1280×720 and 3840×2160 an MP4 export took the same time with either renderer.
|
|
2957
3067
|
- **One word for a whole page:** `registerThree({ renderer: 'webgpu' })` is the default of every three layer that names no `renderer` of its own (the layer's own value wins; `registerThree()` means `'webgl'`).
|
|
@@ -3034,7 +3144,7 @@ keyframes: [
|
|
|
3034
3144
|
|
|
3035
3145
|
1. Call `registerThree()` before `Movie.init` builds a composition containing a `type: 'three'` sequence — the sequence type is looked up by name at build time.
|
|
3036
3146
|
2. `update(t)` must derive all state purely from `t` — no wall clock, no unseeded randomness. Playback and export both seek arbitrarily; anything else in `update` means identical timestamps can render different pixels. **`check` holds the layer to it, as it does a `canvas` layer:** it draws the scene at four moments in two orders (one draw thrown away first) and reports a layer whose same frame is not the same picture (a clock, `Math.random`, a mixer advanced with `mixer.update(dt)`, a speed that builds up from call to call), naming the moments and the fix; a `Math.random` / `Date.now` / `performance.now` / `new Date` in `update` also warns at build. It prints a `three` line when every layer passed, on WebGL and WebGPU alike.
|
|
3037
|
-
3. Each three sequence owns one renderer and so one graphics context (an offscreen canvas). A browser keeps about **16 live WebGL contexts for the whole page** (PixiJS and a `shader` layer use some too) and about **32 WebGPU ones** (measured on Chrome, 2026-10-15); layers beyond that draw nothing, without an error. The layer therefore says so: once, when more than 12 WebGL (or 28 WebGPU) three layers are alive (with the count and the way out: put several objects in one layer, destroy layers that are over, or use the other renderer for some), and when the browser takes a layer's context away (`webglcontextlost`: the layer is named). Prefer one three layer with many objects to many layers.
|
|
3147
|
+
3. Each three sequence owns one renderer and so one graphics context (an offscreen canvas). A browser keeps about **16 live WebGL contexts for the whole page** (PixiJS and a `shader` layer use some too) and about **32 WebGPU ones** (measured on Chrome, 2026-10-15; a `'webgpu'` layer that shares PixiJS's GPU device has no context of its own and is not counted); layers beyond that draw nothing, without an error. The layer therefore says so: once, when more than 12 WebGL (or 28 WebGPU) three layers are alive (with the count and the way out: put several objects in one layer, destroy layers that are over, or use the other renderer for some), and when the browser takes a layer's context away (`webglcontextlost`: the layer is named). Prefer one three layer with many objects to many layers.
|
|
3038
3148
|
4. Like `custom` filters, the spec carries functions (`setup`, optionally `update` / `dispose`) and is **not** JSON-serializable.
|
|
3039
3149
|
5. The scene renders into the layer rectangle: anything that projects outside it is clipped at the layer's edges. Frame the camera with margin for the whole animation — rotating objects often project taller/wider than their resting pose (a `TorusKnotGeometry(radius, tube)`, for instance, extends to `1.5 * radius + tube`, not `radius`).
|
|
3040
3150
|
|
|
@@ -3614,7 +3724,7 @@ A filter by name, as plain data. `type` is the filter's class name without `Filt
|
|
|
3614
3724
|
keyframes: [{ at: 0, to: { 'filters.halo.outerStrength': 6 }, duration: 1 }] } // animate any scalar option by name
|
|
3615
3725
|
```
|
|
3616
3726
|
|
|
3617
|
-
Name a filter (`name: 'halo'`) to animate it with `'filters.halo.<option>'`. `blur`, `noise`, `alpha`, `colorMatrix` and `grain` need no extra package (`grain` is this library's own). All the others come from the **`pixi-filters`** package, which is loaded the first time a layer uses one (add `"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js"` to the import map, or `npm i pixi-filters`; `skills/pixi-effects/template.html` already has it). A misspelt type says what you probably meant. Rendering and exporting were verified in a real browser for 40 of them (`
|
|
3727
|
+
Name a filter (`name: 'halo'`) to animate it with `'filters.halo.<option>'`. `blur`, `noise`, `alpha`, `colorMatrix` and `grain` need no extra package (`grain` is this library's own). All the others come from the **`pixi-filters`** package, which is loaded the first time a layer uses one (add `"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js"` to the import map, or `npm i pixi-filters`; `skills/pixi-effects/template.html` already has it). A misspelt type says what you probably meant. Rendering and exporting were verified in a real browser for 40 of them (`tests/films/named-filters.html` and `named-filters-2.html`); `colorMap` and `simpleLightmap` need a texture, which JSON cannot hold, so use `custom` for those two:
|
|
3618
3728
|
|
|
3619
3729
|
| `type` | Options (the useful ones) | What it does |
|
|
3620
3730
|
|---|---|---|
|
|
@@ -4092,6 +4202,7 @@ interface RebuildReport { reason: 'vars' | 'resize' | 'rebuild'; ms: number; aud
|
|
|
4092
4202
|
- The new composition is built while the old one goes on; at the swap the movie keeps doing what it is doing at that moment: the same frame, playing or paused (a `pause()`, a seek or a `play()` you made during the build is kept), the poster if it was shown. A playing movie whose sound is unchanged (`audio: 'reused'`) is not stopped at all: the frame clock and the sound go on and only the picture is swapped, so rebuilds in a row (a pointer) do not hold the playhead back. When the sound changed, the movie stops and plays on from the same frame with the new sound. A `playUntil()` in flight ends. The canvas changes size at the swap, not before. A `render()` or `peek()` in flight is not interrupted: the swap waits for it.
|
|
4093
4203
|
- The sound is mixed only when it changed: `audio: 'reused'` means the same mix as before (the mix is the slow part, 0.6 to 0.8 s for a film; the rest takes about 20 ms). A time remap (`warp` / `sourceMap`) turns the cache off.
|
|
4094
4204
|
- Calls that arrive during a rebuild are joined: at most one rebuild runs and one waits, with the latest values. Safe to call from `pointermove` or `scroll`: a rebuild that leaves the sound alone takes tens of milliseconds (11 to 42 ms on four gallery pieces), one that mixes the sound again 0.6 to 0.8 s, so a slider that changes the sound will lag behind the hand.
|
|
4205
|
+
- A rebuild builds every layer again, a `three` layer included (its `setup` runs again: a model is loaded again, a renderer made again): fine for a click, slow for what changes while the pointer moves in a three scene. For that, keep the object's place in a variable of the page, read it in the layer's `update`, and call `await movie.redraw()` (recipe `drag-3d`).
|
|
4095
4206
|
- A rebuild that fails (a bad expression, a missing asset, a size that is not pixels) rejects, emits `error` with `where: 'rebuild'`, and the movie goes on with what it had. A `destroy()` during a rebuild ends it the same way.
|
|
4096
4207
|
- To follow a window: observe a CSS-sized box around the canvas, not the canvas itself (the movie sets the canvas's pixel size): `new ResizeObserver(() => movie.resize(Math.round(box.clientWidth * devicePixelRatio), Math.round(box.clientHeight * devicePixelRatio))).observe(box)` (the `liquid-layout` recipe).
|
|
4097
4208
|
|
|
@@ -4171,12 +4282,31 @@ interface PickHit {
|
|
|
4171
4282
|
kind: 'flat' | 'card' | 'three'; // a 2D layer, a threeD layer (its projected card), a three layer
|
|
4172
4283
|
point: { x: number; y: number }; // the point asked about
|
|
4173
4284
|
local?: { x: number; y: number }; // a flat layer: the point in the layer's own pixels
|
|
4174
|
-
three?: { object: string; point: [number, number, number]; distance: number; object3d: unknown
|
|
4285
|
+
three?: { object: string; point: [number, number, number]; distance: number; object3d: unknown; // a three layer: what the ray met first
|
|
4286
|
+
grab?: (plane?) => { move: (x, y) => { point, delta } | null } }; // drag it: see below
|
|
4175
4287
|
}
|
|
4176
|
-
// options: { only?: string[], ignore?: string[] } layer names or paths
|
|
4288
|
+
// options: { only?: string[], ignore?: string[], alpha?: number } layer names or paths; alpha: see below
|
|
4177
4289
|
```
|
|
4178
4290
|
|
|
4179
|
-
|
|
4291
|
+
A `three` layer covers its whole picture, but only what its ray meets is a hit: elsewhere the layers below are hit (a title, the background). `only` / `ignore` entries are layer names or paths, and a group's name (a nested composition) stands for everything in it: a card and its label are one target as `only: ['card']`.
|
|
4292
|
+
|
|
4293
|
+
How a layer is asked: a flat layer by its own shape (a rotated one by its rotated rectangle, a shape by its outline: a circle is hit inside the circle, not at the corners of its box), text, images and video by their box; a `threeD` layer by its projected quad (the card seen from behind is hit too; a `threeD` composition is one card: what is inside it is not asked); a `three` layer by a ray through its camera (with `followCamera` the composition's camera), skipping invisible objects: `hit.three.object` is the name of the object it met (its own, or its nearest ancestor's that has one) and `object3d` the live three object. A layer not alive at the frame, at alpha 0, hidden by a parent, or outside its mask is not hit; a group (a nested composition without `threeD`) is not a target, the layers in it are. **`{ alpha }` looks at the picture, not the box**: `movie.pick(x, y, { alpha: 0.1 })` leaves out a pixel of an image, a video or a `canvas` layer whose alpha is at or below 0.1 (a transparent corner of a logo, the clear part of a circle drawn on a canvas); without it the box is the picture and nothing is read. Shapes are always hit by their outline, text and `shader` layers by their box (a shader's WebGL picture cannot be read back safely). A picture that cannot be read (an image from another origin: load it with `crossorigin="anonymous"` or from the same site) stays a hit, with one warning. What is not here: touch with several fingers.
|
|
4294
|
+
|
|
4295
|
+
**Dragging an object of a `three` layer**: `hit.three.grab(plane)` fixes a plane through the point that was taken (`'screen'` facing the camera, the default; `'floor'` horizontal; `'wall'` with z constant; or a normal `[x, y, z]`), and `grab.move(x, y)` takes the pointer in canvas pixels and gives where its ray meets that plane (`point`) and how far that is from the point taken (`delta`; `null` where the ray never meets the plane: a floor seen edge-on). Move the object by `delta`, from where it was when it was taken, and it follows the pointer exactly. The ray is the layer's own camera's (with `followCamera`, the composition's). The layer's `update` should read where the object is from the page's own state, and the page calls `await movie.redraw()` to draw the frame again while the movie is paused (recipe `drag-3d`).
|
|
4296
|
+
|
|
4297
|
+
### `movie.redraw(): Promise<void>`
|
|
4298
|
+
|
|
4299
|
+
Draws the frame on screen again, quietly (no `seeking` / `seeked`). For a paused movie when something outside it that a layer reads has changed, such as a `three` layer whose `update` reads the page's own state. A playing movie needs no call: the next frame draws it.
|
|
4300
|
+
|
|
4301
|
+
Hover is `movie.hover` (below); drag and drop of a flat layer is the recipe `drag-drop`.
|
|
4302
|
+
|
|
4303
|
+
### `movie.hover(options?): () => void`
|
|
4304
|
+
|
|
4305
|
+
Which layer the pointer is over, as two callbacks, built on `pick`: `onEnter(hit)` when the layer under the pointer (or, in a `three` layer, the object: `hit.three.object`) changes to another, `onLeave(hit)` for the one it left, also when the pointer leaves the picture or the returned function is called. While the pointer rests inside the picture it is asked again on every frame, so a picture that moves under a still pointer is followed (the runner leaves, the cursor changes) with no pointer event in between. Options: `only` / `ignore` as for `pick`, `cursor` (`true` is `'pointer'`, or any CSS cursor: shown while something is hovered, put back otherwise). Returns the function that stops it. Pointer events cover a mouse, a pen and a finger (a touch is "entered" when it lands and "left" when it lifts). It is a page-side tool: an exported video has no pointer. A handler that throws is said once and does not stop the next hover.
|
|
4306
|
+
|
|
4307
|
+
```ts
|
|
4308
|
+
const stop = movie.hover({ ignore: ['background'], cursor: true, onEnter: (hit) => glow(hit.name), onLeave: (hit) => unglow(hit.name) });
|
|
4309
|
+
```
|
|
4180
4310
|
|
|
4181
4311
|
### `movie.inspectAudio(options?): AudioReport`
|
|
4182
4312
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pixi-effects",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.0",
|
|
4
4
|
"description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -104,6 +104,7 @@
|
|
|
104
104
|
"test": "vitest run",
|
|
105
105
|
"test:fast": "SKIP_BROWSER_TESTS=1 vitest run",
|
|
106
106
|
"test:films": "vitest run tests/films",
|
|
107
|
+
"test:site": "vitest run tests/site",
|
|
107
108
|
"release:check": "node scripts/release-check.mjs",
|
|
108
109
|
"post-publish:check": "node scripts/post-publish-check.mjs",
|
|
109
110
|
"test:changed": "node scripts/test-changed.mjs",
|
|
@@ -3,7 +3,7 @@ name: pixi-effects
|
|
|
3
3
|
description: Use when asked to make, animate, preview or export a video, motion graphics, an animated title, a lower third, kinetic type, a promo, a slideshow with transitions, an animated chart or data story, a social clip, a 2.5D / depth scene, or music and sound for a video, written as code in JavaScript or HTML. pixi-effects is a JS library where a video is a plain-object composition (text, shape, image, video, audio and file-free sfx / music, camera, light and shader layers with keyframes) that plays in the browser, is checked by a tool the agent can run, and exports MP4 / WebM / MOV with no server. Not for a project that already uses another video tool (Remotion, Motion Canvas, ffmpeg); follow that tool instead.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
> Written for pixi-effects 0.
|
|
6
|
+
> Written for pixi-effects 0.43.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
|
|
7
7
|
|
|
8
8
|
# pixi-effects — write a video as data
|
|
9
9
|
|
|
@@ -31,6 +31,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
31
31
|
**Read `reference/direction.md` before writing a composition for a product film, a company introduction, a launch or a data story.** It is renderer-neutral: write `brief.md` (the one sentence to remember, the REAL assets with paths, what must never be invented; if a needed asset is missing, stop and ask), `style-guide.md` (palette and type taken from the real source, a motion rule per class of object) and `shotlist.md` (a row per beat with entry and exit state) next to the page; then build, then review the rendered frames cold. What is specific to this library:
|
|
32
32
|
|
|
33
33
|
- **Time and sound:** pick a tempo whose bar is a round number of seconds (96 bpm: 2.5 s; 120 bpm: 2 s), put every cut on a bar line and every sfx on its beat. `npx pixi-effects check` prints a `music` read-back (bars per track, what starts on which beat, the drum span): verify the sound with it.
|
|
34
|
+
- **Interactive pages (a person clicks, hovers, drags):** `movie.pick(x, y)` says what is under a point, `movie.hover({ onEnter, onLeave })` and the recipes `pick-click`, `drag-drop`, `drag-3d` build on it; `check` does not click, so **test the page by sending pointer events and reading the state** (serve the folder over http: a model, an image or a module beside the page does not load from file://). In a browser tool or over Chrome's protocol: `const r = canvas.getBoundingClientRect(); canvas.dispatchEvent(new PointerEvent('pointerdown', { clientX: r.left + x * r.width / W, clientY: r.top + y * r.height / H, bubbles: true, pointerId: 1 }))` (then `pointermove`, `pointerup`, with a `settle` of 300 ms after a `setVars` rebuild), and read `movie.vars`, `movie.currentFrame`, `movie.isPlaying`, `movie.pick(x, y)` and the canvas's `style.cursor`. A canvas for touch has `style="touch-action: none"`.
|
|
34
35
|
- **Review:** `check --at 3.5,title@end,50% --pick 640,360` also says what is under that canvas point at each moment (the layers, top first; a three layer names the object) — a layout question you can answer without looking; `check --at 3.5,title@end,50%` writes the contact sheet and single frames; look at mid-transition frames of the exported file too. Frames are seekable and deterministic here: render the same range twice and compare the frames if you doubt it.
|
|
35
36
|
- **Formats:** make the formats the human asked for, one if they did not say. When a vertical cut is wanted it is its own composition: one source, a layout function, `?f=9x16` (recipe `two-formats`); `check` and `render` take `--query f=9x16`.
|
|
36
37
|
- Fit a picture into a rectangle with the `photo()` helper of that recipe (`scale: 'cover'` fits the PARENT only); designed pictures (posters, flyers, screenshots) are shown whole, never cropped.
|
|
@@ -180,7 +180,9 @@ await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several
|
|
|
180
180
|
await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
|
|
181
181
|
await movie.inspect(60, { layers: 'none' }) // issues only (default lists the visible layers); text off-canvas / cut off / empty / overlapping text. Name your layers so paths are readable
|
|
182
182
|
await movie.onionSkin({ from: 1, to: 3, count: 8, as: 'dataURL' }) // frames overlaid into ONE picture of a movement (later = stronger): path and easing at a glance
|
|
183
|
+
movie.pick(x, y, { alpha: 0.1 }) // alpha: look at the picture, not the box (image / video / canvas layers: a pixel at or below it is not a hit). A three hit can be dragged: `hit.three.grab('floor' | 'wall' | 'screen' | [nx, ny, nz]).move(x, y)` → { point, delta } on that plane; `await movie.redraw()` draws the paused frame again. Recipe `drag-3d`
|
|
183
184
|
movie.pick(x, y) // what is under a canvas point at the frame on screen: { name, path, type, kind: 'flat' | 'card' | 'three', local?, three?: { object, point, distance } } or null (pickAll: the whole stack, top first; { only, ignore }); in a click handler: `movie.pick(...movie.canvasPoint(event))`; to ask about another frame: `await movie.snapshot(f)` first
|
|
185
|
+
movie.hover({ onEnter: (hit) => …, onLeave: (hit) => …, ignore: ['background'], cursor: true }) // which layer the pointer is over (built on pick; follows a picture that moves under a still pointer); returns the stop function. Drag and drop: recipe `drag-drop`
|
|
184
186
|
movie.review({ at: 'title@end' }) // what `pixi-effects check` looks at, as data: { frames, problems (text cut off, off-canvas, no size), review (text overlaps: look at them), fonts, audio, at }; leaves the movie as it was. `strict: true` makes every issue a problem
|
|
185
187
|
movie.resolveAt('3.5, 50%, f120, title@end') // moments → [{ label, frame }]; a mistake says what could not be read
|
|
186
188
|
movie.inspectFonts() // { missing: [{ layer, family }] (no font of the list is installed), failed: [web fonts whose file did not load] }
|
|
@@ -1388,26 +1388,132 @@ A moving, shadow-casting model is held to "a function of the time" by `check`: p
|
|
|
1388
1388
|
|
|
1389
1389
|
## Click a layer: what is under the pointer (`movie.pick`)
|
|
1390
1390
|
|
|
1391
|
-
`movie.pick(x, y)` answers "what is drawn under this point, at the frame on screen" with no hit-testing code of yours: a flat layer by its own shape, a `threeD` card by its projected quad, a `three` layer by a ray (the hit says which object). `movie.canvasPoint(event)` turns a pointer event into canvas pixels, whatever size the canvas is shown at. Name the layers you want to react to.
|
|
1391
|
+
`movie.pick(x, y)` answers "what is drawn under this point, at the frame on screen" with no hit-testing code of yours: a flat layer by its own shape, a `threeD` card by its projected quad, a `three` layer by a ray (the hit says which object). `movie.canvasPoint(event)` turns a pointer event into canvas pixels, whatever size the canvas is shown at. Name the layers you want to react to: `only` / `ignore` take layer names (or paths; a group's name stands for everything in it). A `three` layer covers its whole picture but only what the ray meets is a hit: elsewhere the layers below are hit (the title, the background).
|
|
1392
|
+
|
|
1393
|
+
An interactive page is the template with three changes: no `Controller` (it adds a player bar), `movie.loop = true` or `movie.pause()` as the page needs, and a handler on the canvas. A text that changes on a click is a **variable** (`{title}` in the text, `vars: { title }`, then `movie.setVars({ title })`: it rebuilds at the same frame, and a paused movie stays paused); `at` and `duration` are not variables. A **reaction** is a short animation: put its keyframes on the layer, seek to its start and play (`await movie.gotoFrame(0, true); movie.play()`); a movie that is not looping stops at its end by itself.
|
|
1392
1394
|
|
|
1393
1395
|
```js
|
|
1394
1396
|
// @docs-only pick-click
|
|
1395
|
-
//
|
|
1397
|
+
// the page: a title, and a fox (a three layer, name 'fox', followCamera) running in front of it
|
|
1398
|
+
// await movie.init({ canvas, width: 960, height: 540, duration: 6, frameRate: 30, loop: true, vars: { title: 'CATCH THE FOX', hint: 'click it' },
|
|
1399
|
+
// composition: { sequences: [
|
|
1400
|
+
// { type: 'text', name: 'title', text: '{title}', style: { fontSize: 110, fill: '#ffffff' }, initial: { x: 'GW/2', y: 150, anchorX: 0.5, anchorY: 0.5 } },
|
|
1401
|
+
// { type: 'text', name: 'hint', text: '{hint}', style: { fontSize: 32, fill: '#8fa0c8' }, initial: { x: 'GW/2', y: 500, anchorX: 0.5, anchorY: 0.5 } },
|
|
1402
|
+
// { type: 'three', name: 'fox', followCamera: true, setup: ..., update: (t) => fox.pose(t) }, // recipe gltf-in-space
|
|
1403
|
+
// ] } });
|
|
1404
|
+
// movie.play();
|
|
1396
1405
|
const canvas = document.getElementById('stage');
|
|
1397
|
-
|
|
1398
|
-
|
|
1406
|
+
movie.hover({ only: ['fox'], cursor: true, // 'pointer' over the fox, the normal arrow elsewhere (the title and the background are not listed)
|
|
1407
|
+
onEnter: (hit) => console.log('over', hit.name),
|
|
1408
|
+
onLeave: (hit) => console.log('left', hit.name) });
|
|
1409
|
+
canvas.addEventListener('click', async (event) => {
|
|
1410
|
+
const hit = movie.pick(...movie.canvasPoint(event), { only: ['fox'] }); // null for a click anywhere else: nothing happens
|
|
1399
1411
|
if (!hit) return;
|
|
1400
|
-
|
|
1401
|
-
|
|
1412
|
+
movie.pause(); // the fox stops running
|
|
1413
|
+
await movie.setVars({ title: 'GOTCHA!', hint: 'the fox stopped running' }); // the text is a variable: the movie is built again at this frame, still paused
|
|
1402
1414
|
});
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1415
|
+
// to ask about another frame, or from a test: await movie.snapshot(90); movie.pick(480, 270)
|
|
1416
|
+
// after something outside the movie that a layer reads changes while the movie is paused: await movie.redraw();
|
|
1417
|
+
```
|
|
1418
|
+
|
|
1419
|
+
`pick` reads the frame as the last draw left it (it takes no time and has no clock). `movie.pick(x, y, { alpha: 0.1 })` looks at the picture and not its box (a transparent corner of a logo, the clear part of a drawn circle: not a hit). Dragging is built on `pick` too: recipes `drag-drop` (a flat layer) and `drag-3d` (an object of a three layer). **Test an interactive page** by sending pointer events and reading the state (`SKILL.md`, "Interactive pages"): `check` does not click.
|
|
1420
|
+
|
|
1421
|
+
## Drag a layer and drop it on a target (`pick` and variables)
|
|
1422
|
+
|
|
1423
|
+
A layer whose place is two variables moves when the page sets them: `movie.setVars({ cx, cy })` builds the composition again at the same frame (tens of milliseconds for a light composition; calls that fall behind are joined, so it is safe from `pointermove`). A glow whose `alpha` is a variable is a light that is on or off (`alpha: 'doneLit'`: any number a layer takes can be a variable name; write 0 and 1, not booleans). `pick` says what is taken (`only`) and what lies under it when it is let go (`ignore` the dragged layer). Listen on the window while it is held, so the pointer may leave the canvas. A heavy composition (a model to load again, a long sound) rebuilds slowly: keep what moves a light layer, and for a three scene read the place in `update` and call `await movie.redraw()` (recipe `drag-3d`).
|
|
1424
|
+
|
|
1425
|
+
A card with a label is two layers: name the card's group (a `composition` layer, whose children are one target) or pass both names (`only: ['card', 'card-label']`). A zone with a label is two layers as well: ask with `only: ['done', 'trash']` and the labels are not in the way. Which zone is the card over: the zone under the card's middle (`pick(cx, cy)`), not under the pointer.
|
|
1426
|
+
|
|
1427
|
+
```js
|
|
1428
|
+
// @docs-only drag-drop
|
|
1429
|
+
// the page: a canvas with style="touch-action: none" (a finger dragging must not scroll the page)
|
|
1430
|
+
// await movie.init({ canvas, width: 960, height: 540, vars: { cx: 200, cy: 270, doneLit: 0 }, composition: { sequences: [
|
|
1431
|
+
// { type: 'shape', shape: 'rect', name: 'done', width: 260, height: 200, initial: { x: 770, y: 270, anchorX: 0.5, anchorY: 0.5, fillColor: '#182038' } },
|
|
1432
|
+
// { type: 'shape', shape: 'rect', name: 'done-glow', width: 260, height: 200, initial: { x: 770, y: 270, anchorX: 0.5, anchorY: 0.5, fillColor: '#3ddc97', alpha: 'doneLit' } }, // the light: a variable
|
|
1433
|
+
// { type: 'composition', name: 'card', width: 200, height: 120, duration: 1, initial: { x: 'cx', y: 'cy', pivotX: 100, pivotY: 60 }, sequences: [ // the card and its label: ONE target (the group's name); a composition turns and sits about its pivot
|
|
1434
|
+
// { type: 'shape', shape: 'rect', name: 'face', width: 200, height: 120, cornerRadius: 14, initial: { x: 100, y: 60, anchorX: 0.5, anchorY: 0.5, fillColor: '#f2c14e' } },
|
|
1435
|
+
// { type: 'text', name: 'label', text: 'Task', style: { fontSize: 34, fill: '#1b1b1b' }, initial: { x: 100, y: 60, anchorX: 0.5, anchorY: 0.5 } },
|
|
1436
|
+
// ] },
|
|
1437
|
+
// ] } });
|
|
1438
|
+
// movie.pause(); // a still picture: nothing plays while it is dragged
|
|
1439
|
+
movie.hover({ only: ['card'], cursor: 'grab' }); // the cursor says it can be taken
|
|
1440
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
1441
|
+
const [x, y] = movie.canvasPoint(e);
|
|
1442
|
+
if (!movie.pick(x, y, { only: ['card'] })) return; // only the card (and its label) can be picked up
|
|
1443
|
+
const grip = { dx: movie.vars.cx - x, dy: movie.vars.cy - y }; // where in the card it was taken, so it does not jump
|
|
1444
|
+
canvas.style.cursor = 'grabbing';
|
|
1445
|
+
const place = (ev) => {
|
|
1446
|
+
const [px, py] = movie.canvasPoint(ev);
|
|
1447
|
+
const cx = px + grip.dx, cy = py + grip.dy;
|
|
1448
|
+
const over = movie.pick(cx, cy, { only: ['done'] }); // the zone under the card's middle: it lights while the card is over it
|
|
1449
|
+
return movie.setVars({ cx, cy, doneLit: over ? 0.6 : 0 });
|
|
1450
|
+
};
|
|
1451
|
+
const move = (ev) => { place(ev); };
|
|
1452
|
+
const up = async (ev) => {
|
|
1453
|
+
window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up);
|
|
1454
|
+
canvas.style.cursor = '';
|
|
1455
|
+
await place(ev); // the card is where it was let go: now ask what is under it
|
|
1456
|
+
const [px, py] = movie.canvasPoint(ev);
|
|
1457
|
+
const under = movie.pick(px + grip.dx, py + grip.dy, { only: ['done'] });
|
|
1458
|
+
if (under) await movie.setVars({ cx: 770, cy: 270, doneLit: 0 }); // snap into the middle of the zone
|
|
1459
|
+
else await movie.setVars({ cx: 200, cy: 270, doneLit: 0 }); // anywhere else: back where it started
|
|
1460
|
+
};
|
|
1461
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
1406
1462
|
});
|
|
1407
|
-
// a test, or an AI checking its own page, asks about a chosen frame: await movie.snapshot(90); movie.pick(640, 360)
|
|
1408
1463
|
```
|
|
1409
1464
|
|
|
1410
|
-
|
|
1465
|
+
## Drag an object of a three layer (`hit.three.grab`)
|
|
1466
|
+
|
|
1467
|
+
The scene is the `three` layer's own, so what is taken is a three object: `movie.pick` says which (`hit.three.object`), `hit.three.grab(plane)` fixes a plane through the point that was taken, and `grab.move(x, y)` says where the pointer's ray meets that plane and how far that is from the point taken (`delta`). Planes: `'wall'` (z constant: slides across the picture), `'floor'` (horizontal: slides along the ground), `'screen'` (facing the camera), or a normal. The layer's `update` reads the object's place from a variable of the page, and `await movie.redraw()` draws the paused frame again after each move (a playing movie draws it by itself).
|
|
1468
|
+
|
|
1469
|
+
```js
|
|
1470
|
+
// @docs-only drag-3d
|
|
1471
|
+
// the three layer (followCamera: true, so the composition's camera is the layer's): update: () => { cube.position.set(pos.x, pos.y, pos.z); }
|
|
1472
|
+
// the page's state is the cube's place in three's units (the composition's (x, y, z) is (x, -y, z)): const pos = { x: 480, y: -270, z: 0 };
|
|
1473
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
1474
|
+
const hit = movie.pick(...movie.canvasPoint(e), { only: ['world'] });
|
|
1475
|
+
if (hit?.three?.object !== 'cube') return; // only the cube can be taken
|
|
1476
|
+
const grab = hit.three.grab('wall'); // it slides on the wall plane through the point it was taken at
|
|
1477
|
+
const start = { ...pos }; // where it was
|
|
1478
|
+
const move = (ev) => {
|
|
1479
|
+
const m = grab.move(...movie.canvasPoint(ev)); // the ray of the pointer meets the plane here, this far from the point taken
|
|
1480
|
+
if (!m) return; // a ray that never meets the plane (a floor seen edge-on): leave it
|
|
1481
|
+
pos.x = start.x + m.delta[0]; pos.y = start.y + m.delta[1]; pos.z = start.z + m.delta[2];
|
|
1482
|
+
movie.redraw(); // the layer reads `pos`: draw the frame again
|
|
1483
|
+
};
|
|
1484
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
1485
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
1486
|
+
});
|
|
1487
|
+
```
|
|
1488
|
+
|
|
1489
|
+
The grabbed point stays under the pointer exactly, whatever the camera does (the maths is the camera's own ray, not a guess of pixels per unit).
|
|
1490
|
+
|
|
1491
|
+
**On the ground:** use `'floor'`. Then `m.delta[1]` is 0: write only `pos.x` and `pos.z`, never `y`, and the object stays on the floor. A pointer ray that points above the horizon never meets the floor and `move` answers null (the `if (!m) return` keeps the object where it was). To see a floor in perspective: a camera above it, looking down at it (the composition's `y` runs DOWN, so a camera `y` smaller than the floor's is above it), and a floor big enough that its far edge stays above the horizon of the view.
|
|
1492
|
+
|
|
1493
|
+
```js
|
|
1494
|
+
// @docs-only drag-3d-floor
|
|
1495
|
+
// in the composition: { type: 'camera', initial: { x: 480, y: 40, z: 900, lookAtX: 480, lookAtY: 340, lookAtZ: 0 } },
|
|
1496
|
+
// { type: 'three', name: 'world', followCamera: true, setup, update: () => cube.position.set(pos.x, pos.y, pos.z) }
|
|
1497
|
+
// in setup: the floor, 9000 px square, turned flat, its top at the composition's y = 440 (three's -440), and the cube standing on it (a 100 px cube: its middle 50 px above)
|
|
1498
|
+
// const floor = new THREE.Mesh(new THREE.PlaneGeometry(9000, 9000), new THREE.MeshLambertMaterial({ color: 0x3b4f7a }));
|
|
1499
|
+
// floor.rotation.x = -Math.PI / 2; floor.position.set(480, -440, -400); ctx.scene.add(floor);
|
|
1500
|
+
const pos = { x: 600, y: -390, z: -40 }; // y is the cube's middle: it never changes
|
|
1501
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
1502
|
+
const hit = movie.pick(...movie.canvasPoint(e), { only: ['world'] });
|
|
1503
|
+
if (hit?.three?.object !== 'cube') return; // the floor is a hit of the same layer, with another object name
|
|
1504
|
+
const grab = hit.three.grab('floor');
|
|
1505
|
+
const start = { ...pos };
|
|
1506
|
+
const move = (ev) => {
|
|
1507
|
+
const m = grab.move(...movie.canvasPoint(ev));
|
|
1508
|
+
if (!m) return; // above the horizon: leave it
|
|
1509
|
+
pos.x = Math.max(60, Math.min(900, start.x + m.delta[0])); // along the floor, kept inside it
|
|
1510
|
+
pos.z = Math.max(-600, Math.min(300, start.z + m.delta[2])); // nearer or farther; y is never written
|
|
1511
|
+
movie.redraw();
|
|
1512
|
+
};
|
|
1513
|
+
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
1514
|
+
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
|
|
1515
|
+
});
|
|
1516
|
+
```
|
|
1411
1517
|
|
|
1412
1518
|
## Subtitles from an SRT file
|
|
1413
1519
|
|
|
@@ -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.43.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 */
|
|
@@ -37,17 +37,17 @@
|
|
|
37
37
|
"imports": {
|
|
38
38
|
"pixi.js": "https://esm.sh/pixi.js@8.22.0?bundle-deps",
|
|
39
39
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
40
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
41
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
42
|
-
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
43
|
-
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
40
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.43.0/dist/index.js",
|
|
41
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.43.0/dist/Controller.js",
|
|
42
|
+
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.43.0/dist/present.js",
|
|
43
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.43.0/dist/three.js",
|
|
44
44
|
"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
|
|
45
45
|
"three": "https://esm.sh/three@0.178.0",
|
|
46
46
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.178.0/examples/jsm/"
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
</script>
|
|
50
|
-
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
50
|
+
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.43.0/dist/index.js" /> <!-- after the import map: the library downloads while the page is parsed -->
|
|
51
51
|
|
|
52
52
|
<script type="module">
|
|
53
53
|
import { Movie, kenBurns, withFade } from 'pixi-effects';
|