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/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.41.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
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
- // in the page, after movie.init(...): a click on the fox, on the title, or on the wall behind them
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
- canvas.addEventListener('click', (event) => {
1865
- const hit = movie.pick(...movie.canvasPoint(event)); // the layer on top of that point, or null
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
- if (hit.kind === 'three' && hit.three.object === 'fox') console.log('the fox, at distance', hit.three.distance); // a three layer: the object the ray met
1868
- else if (hit.name === 'title') movie.play(); // a flat layer: by its name
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
- canvas.addEventListener('pointermove', (event) => {
1871
- const hit = movie.pick(...movie.canvasPoint(event), { ignore: ['background'] }); // { only } / { ignore }: layer names or paths
1872
- canvas.style.cursor = hit ? 'pointer' : 'default';
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
- `pick` reads the frame as the last draw left it (it takes no time and has no clock), so a paused movie answers for the frame it shows. A transparent corner of a picture still counts as the picture; hover effects and dragging are yours to build on `pick`.
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 }; // a three layer: what the ray met first
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
- 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. A transparent corner of a picture still counts as the picture (the box is what is asked). What is not here: hover and cursor, dragging, touch with several fingers.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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.41.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
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
- // in the page, after movie.init(...): a click on the fox, on the title, or on the wall behind them
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
- canvas.addEventListener('click', (event) => {
1398
- const hit = movie.pick(...movie.canvasPoint(event)); // the layer on top of that point, or null
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
- if (hit.kind === 'three' && hit.three.object === 'fox') console.log('the fox, at distance', hit.three.distance); // a three layer: the object the ray met
1401
- else if (hit.name === 'title') movie.play(); // a flat layer: by its name
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
- canvas.addEventListener('pointermove', (event) => {
1404
- const hit = movie.pick(...movie.canvasPoint(event), { ignore: ['background'] }); // { only } / { ignore }: layer names or paths
1405
- canvas.style.cursor = hit ? 'pointer' : 'default';
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
- `pick` reads the frame as the last draw left it (it takes no time and has no clock), so a paused movie answers for the frame it shows. A transparent corner of a picture still counts as the picture; hover effects and dragging are yours to build on `pick`.
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.41.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 -->
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.0/dist/index.js",
41
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.41.0/dist/Controller.js",
42
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.41.0/dist/present.js",
43
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.41.0/dist/three.js",
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.41.0/dist/index.js" /> <!-- after the import map: the library downloads while the page is parsed -->
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';