pixi-effects 0.30.1 → 0.32.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +6 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +42 -2
  5. package/ai/tools/render.mjs +34 -4
  6. package/ai/tools/varArgs.mjs +75 -0
  7. package/dist/{Base-Ne74ztNl.d.ts → Base-CXsMdmJB.d.ts} +1 -1
  8. package/dist/{Base-CB1_FPZD.d.cts → Base-pNq6sVGl.d.cts} +1 -1
  9. package/dist/Composition-LNMKWXSL.cjs +16 -0
  10. package/dist/{Composition-MUKR3FYD.cjs.map → Composition-LNMKWXSL.cjs.map} +1 -1
  11. package/dist/Composition-RF7QOEXT.js +7 -0
  12. package/dist/{Composition-RJGA7KDU.js.map → Composition-RF7QOEXT.js.map} +1 -1
  13. package/dist/Controller.cjs +42 -1
  14. package/dist/Controller.cjs.map +1 -1
  15. package/dist/Controller.d.cts +10 -2
  16. package/dist/Controller.d.ts +10 -2
  17. package/dist/Controller.js +42 -1
  18. package/dist/Controller.js.map +1 -1
  19. package/dist/{Movie-DdnpLpw1.d.cts → Movie-CL6SsAWe.d.cts} +90 -3
  20. package/dist/{Movie-C2rZFkrO.d.ts → Movie-f0tQG9y5.d.ts} +90 -3
  21. package/dist/Spans-NQGKVMR5.cjs +55 -0
  22. package/dist/Spans-NQGKVMR5.cjs.map +1 -0
  23. package/dist/Spans-SDXABOV6.js +53 -0
  24. package/dist/Spans-SDXABOV6.js.map +1 -0
  25. package/dist/{blendModes-RIVZXL5A.js → blendModes-MVRLG3T7.js} +4 -4
  26. package/dist/{blendModes-RIVZXL5A.js.map → blendModes-MVRLG3T7.js.map} +1 -1
  27. package/dist/{blendModes-ETMYWATF.cjs → blendModes-VTKDXWEN.cjs} +5 -5
  28. package/dist/{blendModes-ETMYWATF.cjs.map → blendModes-VTKDXWEN.cjs.map} +1 -1
  29. package/dist/{chunk-ZAXYPXKW.js → chunk-AW5NPDE4.js} +1367 -1150
  30. package/dist/chunk-AW5NPDE4.js.map +1 -0
  31. package/dist/{chunk-RCVZX2JP.js → chunk-C5BNHVKK.js} +36 -3
  32. package/dist/chunk-C5BNHVKK.js.map +1 -0
  33. package/dist/{chunk-WHNADXXG.cjs → chunk-CRUDOKSD.cjs} +91 -91
  34. package/dist/chunk-CRUDOKSD.cjs.map +1 -0
  35. package/dist/{chunk-A3Q3SDSH.js → chunk-D5BFQVKL.js} +6 -6
  36. package/dist/chunk-D5BFQVKL.js.map +1 -0
  37. package/dist/{chunk-LYOKH7KE.cjs → chunk-FCGYJCVO.cjs} +36 -3
  38. package/dist/chunk-FCGYJCVO.cjs.map +1 -0
  39. package/dist/{chunk-CRQFDY7E.js → chunk-FCMOREIS.js} +467 -58
  40. package/dist/chunk-FCMOREIS.js.map +1 -0
  41. package/dist/{chunk-HREBQAY4.cjs → chunk-K3MAEKFM.cjs} +493 -84
  42. package/dist/chunk-K3MAEKFM.cjs.map +1 -0
  43. package/dist/{chunk-4KB3GUEK.js → chunk-QFNS6BFL.js} +4 -4
  44. package/dist/{chunk-4KB3GUEK.js.map → chunk-QFNS6BFL.js.map} +1 -1
  45. package/dist/{chunk-ZVUO325O.cjs → chunk-QHDN6TF5.cjs} +1382 -1160
  46. package/dist/chunk-QHDN6TF5.cjs.map +1 -0
  47. package/dist/{chunk-2DXPTKQZ.cjs → chunk-Z437UWI4.cjs} +5 -5
  48. package/dist/{chunk-2DXPTKQZ.cjs.map → chunk-Z437UWI4.cjs.map} +1 -1
  49. package/dist/index.cjs +33 -33
  50. package/dist/index.d.cts +5 -5
  51. package/dist/index.d.ts +5 -5
  52. package/dist/index.js +5 -5
  53. package/dist/loader.css +1 -1
  54. package/dist/present.cjs +5 -5
  55. package/dist/present.d.cts +2 -2
  56. package/dist/present.d.ts +2 -2
  57. package/dist/present.js +5 -5
  58. package/dist/three.cjs +6 -6
  59. package/dist/three.d.cts +2 -2
  60. package/dist/three.d.ts +2 -2
  61. package/dist/three.js +2 -2
  62. package/dist/{types-BzIB65Vk.d.ts → types-CavnUvd5.d.cts} +28 -1
  63. package/dist/{types-BzIB65Vk.d.cts → types-CavnUvd5.d.ts} +28 -1
  64. package/docs/api.md +28 -1
  65. package/docs/dsl.md +46 -5
  66. package/llms-full.txt +136 -10
  67. package/package.json +83 -20
  68. package/skills/pixi-effects/SKILL.md +1 -1
  69. package/skills/pixi-effects/reference/cheatsheet.md +5 -2
  70. package/skills/pixi-effects/reference/pitfalls.md +5 -1
  71. package/skills/pixi-effects/reference/recipes.md +52 -0
  72. package/skills/pixi-effects/template.html +6 -6
  73. package/dist/Composition-MUKR3FYD.cjs +0 -16
  74. package/dist/Composition-RJGA7KDU.js +0 -7
  75. package/dist/chunk-A3Q3SDSH.js.map +0 -1
  76. package/dist/chunk-CRQFDY7E.js.map +0 -1
  77. package/dist/chunk-HREBQAY4.cjs.map +0 -1
  78. package/dist/chunk-LYOKH7KE.cjs.map +0 -1
  79. package/dist/chunk-RCVZX2JP.js.map +0 -1
  80. package/dist/chunk-WHNADXXG.cjs.map +0 -1
  81. package/dist/chunk-ZAXYPXKW.js.map +0 -1
  82. package/dist/chunk-ZVUO325O.cjs.map +0 -1
package/package.json CHANGED
@@ -1,42 +1,99 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
- "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
9
- "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
8
+ "import": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "require": {
13
+ "types": "./dist/index.d.cts",
14
+ "default": "./dist/index.cjs"
15
+ }
10
16
  },
11
17
  "./controller": {
12
- "import": { "types": "./dist/Controller.d.ts", "default": "./dist/Controller.js" },
13
- "require": { "types": "./dist/Controller.d.cts", "default": "./dist/Controller.cjs" }
18
+ "import": {
19
+ "types": "./dist/Controller.d.ts",
20
+ "default": "./dist/Controller.js"
21
+ },
22
+ "require": {
23
+ "types": "./dist/Controller.d.cts",
24
+ "default": "./dist/Controller.cjs"
25
+ }
14
26
  },
15
27
  "./loader.css": "./dist/loader.css",
16
28
  "./present": {
17
- "import": { "types": "./dist/present.d.ts", "default": "./dist/present.js" },
18
- "require": { "types": "./dist/present.d.cts", "default": "./dist/present.cjs" }
29
+ "import": {
30
+ "types": "./dist/present.d.ts",
31
+ "default": "./dist/present.js"
32
+ },
33
+ "require": {
34
+ "types": "./dist/present.d.cts",
35
+ "default": "./dist/present.cjs"
36
+ }
19
37
  },
20
38
  "./three": {
21
- "import": { "types": "./dist/three.d.ts", "default": "./dist/three.js" },
22
- "require": { "types": "./dist/three.d.cts", "default": "./dist/three.cjs" }
39
+ "import": {
40
+ "types": "./dist/three.d.ts",
41
+ "default": "./dist/three.js"
42
+ },
43
+ "require": {
44
+ "types": "./dist/three.d.cts",
45
+ "default": "./dist/three.cjs"
46
+ }
23
47
  }
24
48
  },
25
49
  "main": "./dist/index.cjs",
26
50
  "module": "./dist/index.js",
27
51
  "types": "./dist/index.d.ts",
28
- "bin": { "pixi-effects": "ai/tools/pixi-effects.mjs" },
29
- "files": ["dist", "ai", "skills", "docs/dsl.md", "docs/api.md", "llms.txt", "llms-full.txt", "CHANGELOG.md", "README.md", "LICENSE"],
52
+ "bin": {
53
+ "pixi-effects": "ai/tools/pixi-effects.mjs"
54
+ },
55
+ "files": [
56
+ "dist",
57
+ "ai",
58
+ "skills",
59
+ "docs/dsl.md",
60
+ "docs/api.md",
61
+ "llms.txt",
62
+ "llms-full.txt",
63
+ "CHANGELOG.md",
64
+ "README.md",
65
+ "LICENSE"
66
+ ],
30
67
  "keywords": [
31
- "pixi", "pixijs", "video", "composition", "compositing", "rendering",
32
- "after-effects", "motion-graphics", "timeline", "webgl", "keyframe", "typescript",
33
- "three", "threejs"
68
+ "pixi",
69
+ "pixijs",
70
+ "video",
71
+ "composition",
72
+ "compositing",
73
+ "rendering",
74
+ "after-effects",
75
+ "motion-graphics",
76
+ "timeline",
77
+ "webgl",
78
+ "keyframe",
79
+ "typescript",
80
+ "three",
81
+ "threejs"
34
82
  ],
35
83
  "license": "MIT",
36
- "author": { "name": "tefista", "email": "tefista.dev@gmail.com", "url": "https://x.com/t_yjm" },
37
- "repository": { "type": "git", "url": "git+https://github.com/yjmtmtk/pixi-effects.git" },
84
+ "author": {
85
+ "name": "tefista",
86
+ "email": "tefista.dev@gmail.com",
87
+ "url": "https://x.com/t_yjm"
88
+ },
89
+ "repository": {
90
+ "type": "git",
91
+ "url": "git+https://github.com/yjmtmtk/pixi-effects.git"
92
+ },
38
93
  "homepage": "https://github.com/yjmtmtk/pixi-effects#readme",
39
- "bugs": { "url": "https://github.com/yjmtmtk/pixi-effects/issues" },
94
+ "bugs": {
95
+ "url": "https://github.com/yjmtmtk/pixi-effects/issues"
96
+ },
40
97
  "sideEffects": [
41
98
  "./dist/loader.css"
42
99
  ],
@@ -67,8 +124,12 @@
67
124
  "pixi-filters": "^6.1.0"
68
125
  },
69
126
  "peerDependenciesMeta": {
70
- "three": { "optional": true },
71
- "pixi-filters": { "optional": true }
127
+ "three": {
128
+ "optional": true
129
+ },
130
+ "pixi-filters": {
131
+ "optional": true
132
+ }
72
133
  },
73
134
  "devDependencies": {
74
135
  "tsup": "^8.0.0",
@@ -82,5 +143,7 @@
82
143
  "three": "^0.185.1",
83
144
  "@types/three": "^0.185.0"
84
145
  },
85
- "engines": { "node": ">=18" }
146
+ "engines": {
147
+ "node": ">=18"
148
+ }
86
149
  }
@@ -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.30.1. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
6
+ > Written for pixi-effects 0.32.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
 
@@ -29,6 +29,7 @@ Operators `+ - * /`, parentheses, unary `-`. Functions `min max abs floor ceil r
29
29
  | `w`, `h` | this layer's own size (image/video natural size, text size **after** its style is applied, shape bounds) |
30
30
  | `cover`, `contain` | scale that makes the layer cover / fit the parent (`scale: 'cover'`) |
31
31
  | `t`, `d`, `T` | layer start time, layer duration, parent duration |
32
+ | your own names | `movie.init({ vars: { shift: 0, title: 'NORD' } })`: a number is a name in an expression (`'W/2 + shift'`), any value is `{title}` inside a string (`text: '{title}'`, `fillColor: '{accent}'`). Reserved: the names above, `value`, the functions, `PI`. `await movie.setVars({ shift: 40 })` builds again at the same frame; `await movie.resize(w, h)` makes `W` / `H` follow a window. `render --var title=SÜD --size 1080x1920`, `check --size 1920x1080,1080x1920` |
32
33
 
33
34
  ## Common fields (every layer)
34
35
 
@@ -49,7 +50,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
49
50
 
50
51
  ## Layer types
51
52
 
52
- **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
53
+ **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change). **Colour inside one line:** `spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'a'", fill: '#ffd166', fontWeight: '700' }, ',\n next line']` instead of `text` (strings use the layer's `style`; a span changes only fill / fontWeight / fontStyle / fontVariant / letterSpacing / dropShadow, never a size, font or stroke (those are the layer's `style`); `\n` starts a line; `style.align: 'left'` for code; `visibleChars` types over all the spans; it becomes a group of text layers `<name>.0…`).
53
54
 
54
55
  **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
55
56
 
@@ -154,7 +155,7 @@ measureText('Hello', style) // → { width, height,
154
155
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
155
156
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
156
157
  ```
157
- Per-letter / per-word / per-line animation in one call: `...animateText(text, style, { x, y, align, by: 'chars'|'words'|'lines', at, duration /* required: until the last piece is gone */, in: 'rise'|'drop'|'fade'|'pop'|'zoom'|'slide'|'spin'|{ from, to, duration, ease }|false, out: <same, in the same wave; by default BACKWARDS FROM THE LAYER'S END (at + duration, so to be gone at time T set duration = T − at); `out: { preset: 'fade', at: 6 }` starts the exit at composition second 6 instead>, stagger: { each, from, … }, idle: { y: 4, rotation: 1 }, styleFor: p => ({ fill }) })` → text layers `<name>-0…` (anchor centre; `x`/`y` in `from` are offsets) to spread into `sequences`. **`x` / `y` are pixel NUMBERS (an expression like `'GW/2'` throws: compute `W / 2`), and `y` is the TOP of the line box**: the visible text is centred `lineHeight / 2` below it, so an underline goes at `y + lineHeight`, not at `y` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
158
+ Per-letter / per-word / per-line animation in one call: `...animateText(text, style, { x, y, align, by: 'chars'|'words'|'lines', at, duration /* required: until the last piece is gone */, in: 'rise'|'drop'|'fade'|'pop'|'zoom'|'slide'|'spin'|{ from, to, duration, ease }|false, out: <same, in the same wave (leave `out` out and the pieces stay until the layer ends); by default BACKWARDS FROM THE LAYER'S END (at + duration, so to be gone at time T set duration = T − at); `out: { preset: 'fade', at: 6 }` starts the exit at composition second 6 instead>, stagger: { each, from, … }, idle: { y: 4, rotation: 1 }, styleFor: p => ({ fill }) })` → text layers `<name>-0…` (anchor centre; `x`/`y` in `from` are offsets) to spread into `sequences`. **`x` / `y` are pixel NUMBERS (an expression like `'GW/2'` throws: compute `W / 2`), and `y` is the TOP of the line box**: the visible text is centred `lineHeight / 2` below it, so an underline goes at `y + lineHeight`, not at `y` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
158
159
 
159
160
  ## Assets
160
161
 
@@ -169,6 +170,8 @@ movie.play(); movie.pause();
169
170
  await movie.gotoFrame(n, true); // seek (frames; 30 fps => t = n/30)
170
171
  const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time. `format: 'wav'` / `'ogg'` writes only the movie's SOUND (a `music` score as an audio file). `video: { codec: 'hevc' | 'av1' | 'vp9' | 'avc', bitrate: '8M', hardware, keyFrameInterval }` (a codec this browser cannot encode names the one that works). Part of it: `range: [2, 5]` (seconds) or `range: 'title'` (a top-level layer's span; picture and sound); `scale: 0.5` / `draft: true` (half size, low quality, no motion blur: a smaller file, not a faster drawing). Fast motion: `movie.init({ motionBlur: true })` (8 samples, 180° shutter; or a number / { samples: 2–64, shutter: 0–1 }) blurs render / snapshot / contactSheet, not live playback; a render takes `samples`× as long
171
172
  movie.on('progress', e => e.progress /* 0–100 */);
173
+ await movie.setVars({ shift: 40 }); // declared variables → build again, same frame, sound mixed again only if it changed. `resize(w, h)`, `rebuild({ composition, vars, width, height })`; `movie.on('rebuilt', report => …)`; `movie.vars`
174
+ movie.loop = true // (or init({ loop: true })) playing goes back to the start at the end instead of stopping; the Controller bar has a loop button (L). playUntil() and render() ignore it
172
175
  movie.audioBuffer // mixed audio (after init), if any audio layers
173
176
  await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
174
177
  await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
@@ -123,7 +123,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
123
123
  93. **A `threeD` layer's shadow budget is four casters per receiver, counted in layer order, not geometry.** Five letters as five `castsShadows` layers over one floor: the fifth casts nothing (the warning names it). Make the word ONE text layer (the light is per pixel, each letter is still lit on its own) or give only the four that matter `castsShadows`. Splitting the floor does not help: each half takes the first four.
124
124
  94. **`resolution` below 1 softens crisp lines in a shader.** `fwidth` anti-aliased edges come out a little blurry after the upscale: keep `resolution: 1` for line art and use 0.5 only for soft fields (flowing colour, glow).
125
125
  95. **A `color-dodge` leak over a dark picture is a muddy red disc.** Dodge divides each channel of near-black separately, red first. Cut the dodge to the bright subject with a matte (`mask: 'letters'`, a matte-only copy of the word) and put the warm haze in a separate `screen` radial.
126
- 96. **A filter option that is a point (`rgbSplit.red`, `glitch` offsets) cannot be tweened as a whole**, but a scalar can: `set: { 'filters.tear.seed': n }` steps `glitch.seed` (a `set` keyframe works on filter options; no warning, visible tearing). To switch between looks on one subject, put the subject in time-windowed `composition` copies, each carrying the filters of its look.
126
+ 96. **A filter option that is a point (`rgbSplit.red`, `glitch` offsets) is tweened one leaf at a time** (`'filters.s.red.x'`; the whole point as a value switches, it does not tween), and a scalar too: `set: { 'filters.tear.seed': n }` steps `glitch.seed` (a `set` keyframe works on filter options; no warning, visible tearing). To switch between looks on one subject, put the subject in time-windowed `composition` copies, each carrying the filters of its look.
127
127
  97. **A deck inside your own composition loses its `transitions` and `stops`** unless you take them across: `const d = deck({ width: 1920, height: 1080, pages })`, then `{ type: 'composition', sequences: d.composition.sequences, transitions: d.composition.transitions }`. Pass `width` / `height` yourself for a page that is not the movie's own size.
128
128
  98. **A matte layer needs a fill.** A named matte (`mask: 'glass'`) that is a shape with no `fillColor` / `fillGradient` (or a text with no fill) cuts everything away and nothing warns: the layers it should show simply vanish. Give the matte a fill (white for `alpha`, a gradient for `luma`).
129
129
  99. **In a `music` score a capital letter is a chord and a lowercase one a note.** `G7` is G dominant seventh, `C5` a power chord, `C6` a sixth chord; `g7`, `c5`, `c6` are notes (octave 7 is a very high note: it used to be what `G7` meant, silently). A bare note in capitals (`Bb2`) is refused with the lowercase spelling (`bb2`); inside brackets (`[C4 E4 G4]`) any case is a note. An unknown chord kind suggests the nearest (`Cmin7` → `Cm7`, `Csus` → `Csus4`, `CM7` → `Cmaj7`).
@@ -131,3 +131,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
131
131
  101. **A film built from one adjective looks like every other one.** Read `reference/direction.md` and write `brief.md` / `style-guide.md` / `shotlist.md` first: real assets with paths, the one sentence to remember, a row per beat with entry and exit state. If a real asset is missing, ask: a drawn fake (a logo, a dashboard, a figure) is the failure that matters. Judge the contact sheet cold: three defects, each with a timestamp, evidence and a local fix.
132
132
  102. **A vertical cut is a second composition, not a crop, and only when someone asked for it.** One source, a layout function, `?f=9x16`; `check` and `render` take `--query f=9x16` (recipe `two-formats`). Look at a contact sheet of each format: type that fits wide is tiny tall. Ask which formats are wanted; make one if the answer is open. In a 1080p film keep every line of text at 34 px or more; if you cannot read it in a sheet tile, a phone cannot.
133
133
  103. **Load a web font BEFORE you measure with it.** `measureText` / `splitText` / `animateText` measure once, when the composition is built. Measured in Chrome: "Respect the Local, Loop the Value." at 80 px Lato 900 is 1487 px wide before the Google font has arrived (the fallback) and 1231 px after; Japanese text in a similar fallback was within 3 px. Every box, line break and centred position built from the wrong width stays wrong. `await document.fonts.load('900 80px "Lato"', theTextYouUse)` first (the text argument loads exactly the glyphs you need), then build the composition.
134
+ 104. **A variable moves values, not timing, and it is read when the composition is built.** `at` and `duration` are plain numbers; `vars: { shift }` works in `x: 'W/2 + shift'` and `'{title}'` inside any string, not in `at: 'shift'`. `setVars` builds everything again (about 20 ms, plus the sound only if it changed): for a continuous input call it as often as you like, calls that overlap are joined, but do not use it for something that should move every frame (that is a keyframe).
135
+ 105. **Layout that follows the screen is written in `W` / `H` / `GW` / `GH`, never in pixels.** After `movie.resize(w, h)` an expression sees the new size; a literal `x: 960` stays at 960. A string variable cannot be read in an expression (`x: 'title'` is a number context): use `{title}` in a string, or declare the number. A name that is declared but never read, and a `{name}` that was not declared, are both said at `init` (usually a typo). Load a web font BEFORE the first `setVars` that changes a text (see 103): the text is measured again each rebuild.
136
+ 106. **A text outline used to spike; it is round-joined now.** `style: { stroke: { color: '#000', width: 12 } }` was drawn with PixiJS's default `miter` join, so the sharp corners of W, V, A, k and 4 grew spikes ("the outline sticks out"). The library now adds `join: 'round'` to an outline that names no join; say `join: 'miter'` or `'bevel'` if you want one. The stroke is centred on the glyph edge, so half of it eats into the letter: keep `width` to about a tenth of `fontSize`, or for a thick outline put a second copy of the text with the stroke UNDER the plain one.
137
+ 107. **`spans` places pieces by measuring, so the font must be loaded first, and a span cannot change size.** A web font that is not loaded yet is measured as the fallback font and the pieces drift apart: `await document.fonts.load('30px "Name"')` before `movie.init`. `fontSize` / `fontFamily` / `lineHeight` / `align` on a span are ignored (warned): they belong to the layer's `style`. `fill`, `text` and `value` keyframes do not reach a spans layer (it is a group of text layers).
@@ -1174,3 +1174,55 @@ while IFS=, read -r name score; do
1174
1174
  npx pixi-effects render batch.html --query "name=$(printf %s "$name" | jq -sRr @uri)&score=$score" -o "out/$name.mp4" --quiet
1175
1175
  done < table.csv
1176
1176
  ```
1177
+
1178
+ ---
1179
+
1180
+ ## One film, several languages and colours (variables)
1181
+
1182
+ Declare what changes as `vars` and write `{name}` in the strings and the names as identifiers in the expressions. One page then makes every variant: `pixi-effects render film.html --var lang=ja --var accent=#3949ab -o film-ja.mp4`, and a page can switch live with `movie.setVars(...)`. Load the fonts of every language before the first build (pitfall 103); `check` says a variable nothing reads, and a `{name}` nobody declared.
1183
+
1184
+ ```js
1185
+ // @docs-only variants
1186
+ const COPY = { en: { head: 'Spring sale', sub: 'Up to 40% off' }, ja: { head: '春のセール', sub: '最大40%オフ' } };
1187
+ const lang = new URLSearchParams(location.search).get('lang') ?? 'en';
1188
+ await movie.init({
1189
+ canvas, width: 1920, height: 1080, duration: 5,
1190
+ vars: { head: COPY[lang].head, sub: COPY[lang].sub, accent: '#e5399a', lift: 0 }, // numbers can be read in expressions, any value in {name}
1191
+ composition: { sequences: [
1192
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#12151f' } },
1193
+ { type: 'text', text: '{head}', at: 0.3, duration: 4.7, style: { fontSize: 120, fontWeight: 'bold', fill: '{accent}' },
1194
+ initial: { x: 'GW/2', y: 'GH/2 - 40 - lift', anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.6 }] },
1195
+ { type: 'text', text: '{sub}', at: 0.9, duration: 4.1, style: { fontSize: 56, fill: '#ffffff' },
1196
+ initial: { x: 'GW/2', y: 'GH/2 + 80', anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.6 }] },
1197
+ ] },
1198
+ });
1199
+ // later, from a button: await movie.setVars({ head: COPY.ja.head, sub: COPY.ja.sub, accent: '#3949ab' }); // same frame, sound untouched
1200
+ ```
1201
+
1202
+ ---
1203
+
1204
+ ## A layout that follows the window (liquid)
1205
+
1206
+ `resize(w, h)` builds the composition again at that size, and every expression sees it as `W` / `H` / `GW` / `GH`: write the layout in those, never in pixels. For a different arrangement per shape (wide / tall) write the composition in a function of the size and hand it to `rebuild`. Watch a **box** that you size with CSS (`#box { width: 100vw; height: 100vh }`, the canvas inside it `width: 100%; height: 100%`), not the canvas: the movie sets the canvas's pixel size, and an observer on the canvas would see its own change and grow it again. `check --size 1920x1080,1080x1920` makes a contact sheet and a layout review for each size without a browser window.
1207
+
1208
+ ```js
1209
+ // @docs-only liquid-layout
1210
+ const layout = (w, h) => {
1211
+ const tall = h > w;
1212
+ return { sequences: [
1213
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#12151f' } },
1214
+ { type: 'text', text: 'Headline', style: { fontSize: tall ? 'GW * 0.12' : 'GH * 0.14', fontWeight: 'bold', fill: '#ffffff' },
1215
+ initial: { x: tall ? 'GW * 0.08' : 'GW/2', y: tall ? 'GH * 0.7' : 'GH/2', anchorX: tall ? 0 : 0.5, anchorY: 0.5 } },
1216
+ ] };
1217
+ };
1218
+ // <div id="box" style="width:100vw;height:100vh"><canvas id="stage" style="width:100%;height:100%;display:block"></canvas></div>
1219
+ const box = document.getElementById('box');
1220
+ const pixels = () => [Math.round(box.clientWidth * devicePixelRatio), Math.round(box.clientHeight * devicePixelRatio)];
1221
+ const [w0, h0] = pixels();
1222
+ await movie.init({ canvas, width: w0, height: h0, duration: 6, composition: layout(w0, h0) });
1223
+ new ResizeObserver(() => {
1224
+ const [w, h] = pixels();
1225
+ movie.rebuild({ composition: layout(w, h), width: w, height: h }).catch(console.error); // overlapping calls are joined: only the latest size is built
1226
+ }).observe(box);
1227
+ ```
1228
+
@@ -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.30.1/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.32.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,16 +37,16 @@
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.30.1/dist/index.js",
41
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/Controller.js",
42
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/present.js",
43
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/three.js",
40
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/index.js",
41
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/Controller.js",
42
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/present.js",
43
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.32.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
  }
47
47
  }
48
48
  </script>
49
- <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/index.js" /> <!-- after the import map: the library downloads while the page is parsed -->
49
+ <link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.32.0/dist/index.js" /> <!-- after the import map: the library downloads while the page is parsed -->
50
50
 
51
51
  <script type="module">
52
52
  import { Movie, kenBurns, withFade } from 'pixi-effects';
@@ -1,16 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkWHNADXXG_cjs = require('./chunk-WHNADXXG.cjs');
4
- require('./chunk-ZVUO325O.cjs');
5
- require('./chunk-2DXPTKQZ.cjs');
6
- require('./chunk-LYOKH7KE.cjs');
7
- require('./chunk-TWZNOUM7.cjs');
8
-
9
-
10
-
11
- Object.defineProperty(exports, "CompositionSequence", {
12
- enumerable: true,
13
- get: function () { return chunkWHNADXXG_cjs.CompositionSequence; }
14
- });
15
- //# sourceMappingURL=Composition-MUKR3FYD.cjs.map
16
- //# sourceMappingURL=Composition-MUKR3FYD.cjs.map
@@ -1,7 +0,0 @@
1
- export { CompositionSequence } from './chunk-A3Q3SDSH.js';
2
- import './chunk-ZAXYPXKW.js';
3
- import './chunk-4KB3GUEK.js';
4
- import './chunk-RCVZX2JP.js';
5
- import './chunk-BXOCEZ4U.js';
6
- //# sourceMappingURL=Composition-RJGA7KDU.js.map
7
- //# sourceMappingURL=Composition-RJGA7KDU.js.map