pixi-effects 0.41.0 → 0.42.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 +9 -0
- package/README.md +5 -5
- package/ai/chat-template.html +3 -3
- package/dist/Controller.d.cts +1 -1
- package/dist/Controller.d.ts +1 -1
- package/dist/{Movie-DWtxRiJ2.d.cts → Movie-BHGhg-fJ.d.cts} +44 -3
- package/dist/{Movie-CwqRLOzE.d.ts → Movie-CBVFZ1kg.d.ts} +44 -3
- package/dist/{chunk-Y2N6U3X5.js → chunk-NFJKBWYO.js} +150 -8
- package/dist/chunk-NFJKBWYO.js.map +1 -0
- package/dist/{chunk-RN5FWGGM.cjs → chunk-WVZLAIKK.cjs} +150 -8
- package/dist/chunk-WVZLAIKK.cjs.map +1 -0
- package/dist/index.cjs +25 -25
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/loader.css +1 -1
- package/dist/present.cjs +1 -1
- package/dist/present.d.cts +1 -1
- package/dist/present.d.ts +1 -1
- package/dist/present.js +1 -1
- package/dist/three.cjs +34 -1
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +11 -0
- package/dist/three.d.ts +11 -0
- package/dist/three.js +35 -2
- package/dist/three.js.map +1 -1
- package/docs/api.md +23 -3
- package/llms-full.txt +144 -15
- package/package.json +1 -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.42.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
|
|
|
@@ -4092,6 +4201,7 @@ interface RebuildReport { reason: 'vars' | 'resize' | 'rebuild'; ms: number; aud
|
|
|
4092
4201
|
- 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
4202
|
- 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
4203
|
- 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.
|
|
4204
|
+
- 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
4205
|
- 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
4206
|
- 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
4207
|
|
|
@@ -4171,12 +4281,31 @@ interface PickHit {
|
|
|
4171
4281
|
kind: 'flat' | 'card' | 'three'; // a 2D layer, a threeD layer (its projected card), a three layer
|
|
4172
4282
|
point: { x: number; y: number }; // the point asked about
|
|
4173
4283
|
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
|
|
4284
|
+
three?: { object: string; point: [number, number, number]; distance: number; object3d: unknown; // a three layer: what the ray met first
|
|
4285
|
+
grab?: (plane?) => { move: (x, y) => { point, delta } | null } }; // drag it: see below
|
|
4175
4286
|
}
|
|
4176
|
-
// options: { only?: string[], ignore?: string[] } layer names or paths
|
|
4287
|
+
// options: { only?: string[], ignore?: string[], alpha?: number } layer names or paths; alpha: see below
|
|
4177
4288
|
```
|
|
4178
4289
|
|
|
4179
|
-
|
|
4290
|
+
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']`.
|
|
4291
|
+
|
|
4292
|
+
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.
|
|
4293
|
+
|
|
4294
|
+
**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`).
|
|
4295
|
+
|
|
4296
|
+
### `movie.redraw(): Promise<void>`
|
|
4297
|
+
|
|
4298
|
+
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.
|
|
4299
|
+
|
|
4300
|
+
Hover is `movie.hover` (below); drag and drop of a flat layer is the recipe `drag-drop`.
|
|
4301
|
+
|
|
4302
|
+
### `movie.hover(options?): () => void`
|
|
4303
|
+
|
|
4304
|
+
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.
|
|
4305
|
+
|
|
4306
|
+
```ts
|
|
4307
|
+
const stop = movie.hover({ ignore: ['background'], cursor: true, onEnter: (hit) => glow(hit.name), onLeave: (hit) => unglow(hit.name) });
|
|
4308
|
+
```
|
|
4180
4309
|
|
|
4181
4310
|
### `movie.inspectAudio(options?): AudioReport`
|
|
4182
4311
|
|
package/package.json
CHANGED
|
@@ -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.42.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.42.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.42.0/dist/index.js",
|
|
41
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.42.0/dist/Controller.js",
|
|
42
|
+
"pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.42.0/dist/present.js",
|
|
43
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.42.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.42.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';
|