pixi-effects 0.44.0 → 0.46.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 (70) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +117 -2
  5. package/ai/tools/lookup.mjs +1 -0
  6. package/dist/Composition-NSY7A32Y.js +7 -0
  7. package/dist/{Composition-YGBQX3HZ.js.map → Composition-NSY7A32Y.js.map} +1 -1
  8. package/dist/Composition-PME3P5LP.cjs +16 -0
  9. package/dist/{Composition-N6BOROJ2.cjs.map → Composition-PME3P5LP.cjs.map} +1 -1
  10. package/dist/Controller.d.cts +2 -2
  11. package/dist/Controller.d.ts +2 -2
  12. package/dist/{Movie-B3VOAgwc.d.cts → Movie-BqrAWN0g.d.cts} +13 -1
  13. package/dist/{Movie-CQCVUqRs.d.ts → Movie-sV8nJr_f.d.ts} +13 -1
  14. package/dist/{Spans-XTIWYSNO.cjs → Spans-2TZCNREQ.cjs} +9 -9
  15. package/dist/{Spans-XTIWYSNO.cjs.map → Spans-2TZCNREQ.cjs.map} +1 -1
  16. package/dist/{Spans-6L2ZVECX.js → Spans-TESJPQKX.js} +4 -4
  17. package/dist/{Spans-6L2ZVECX.js.map → Spans-TESJPQKX.js.map} +1 -1
  18. package/dist/{chunk-4MBTYREC.js → chunk-2E74SP63.js} +72 -17
  19. package/dist/chunk-2E74SP63.js.map +1 -0
  20. package/dist/{chunk-MAAM45ZX.cjs → chunk-7M63Y6MO.cjs} +114 -59
  21. package/dist/chunk-7M63Y6MO.cjs.map +1 -0
  22. package/dist/{chunk-3HZV3UHU.cjs → chunk-DKCMJILW.cjs} +67 -65
  23. package/dist/chunk-DKCMJILW.cjs.map +1 -0
  24. package/dist/{chunk-4QVQ5UJJ.js → chunk-DM2OLQOJ.js} +5 -6
  25. package/dist/chunk-DM2OLQOJ.js.map +1 -0
  26. package/dist/{chunk-ICI6ACHY.js → chunk-FPV3NPYC.js} +8 -6
  27. package/dist/chunk-FPV3NPYC.js.map +1 -0
  28. package/dist/{chunk-UHNSL2KJ.js → chunk-K24FF4BT.js} +10 -5
  29. package/dist/chunk-K24FF4BT.js.map +1 -0
  30. package/dist/{chunk-X62H3RBG.cjs → chunk-LALEBV63.cjs} +5 -6
  31. package/dist/chunk-LALEBV63.cjs.map +1 -0
  32. package/dist/{chunk-LQDDDIC4.cjs → chunk-O66JGPVV.cjs} +10 -4
  33. package/dist/chunk-O66JGPVV.cjs.map +1 -0
  34. package/dist/index.cjs +35 -35
  35. package/dist/index.d.cts +3 -3
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +4 -4
  38. package/dist/loader.css +1 -1
  39. package/dist/present.cjs +4 -4
  40. package/dist/present.d.cts +2 -2
  41. package/dist/present.d.ts +2 -2
  42. package/dist/present.js +4 -4
  43. package/dist/sound.cjs +2 -2
  44. package/dist/sound.js +1 -1
  45. package/dist/three.cjs +194 -15
  46. package/dist/three.cjs.map +1 -1
  47. package/dist/three.d.cts +75 -4
  48. package/dist/three.d.ts +75 -4
  49. package/dist/three.js +191 -13
  50. package/dist/three.js.map +1 -1
  51. package/dist/{types-C5xo0fzb.d.cts → types-CTSrPvY3.d.cts} +64 -58
  52. package/dist/{types-C5xo0fzb.d.ts → types-CTSrPvY3.d.ts} +64 -58
  53. package/docs/api.md +2 -0
  54. package/docs/dsl.md +26 -0
  55. package/llms-full.txt +66 -4
  56. package/package.json +1 -1
  57. package/skills/pixi-effects/SKILL.md +2 -2
  58. package/skills/pixi-effects/reference/cheatsheet.md +2 -2
  59. package/skills/pixi-effects/reference/recipes.md +34 -0
  60. package/skills/pixi-effects/template.html +6 -6
  61. package/dist/Composition-N6BOROJ2.cjs +0 -16
  62. package/dist/Composition-YGBQX3HZ.js +0 -7
  63. package/dist/chunk-3HZV3UHU.cjs.map +0 -1
  64. package/dist/chunk-4MBTYREC.js.map +0 -1
  65. package/dist/chunk-4QVQ5UJJ.js.map +0 -1
  66. package/dist/chunk-ICI6ACHY.js.map +0 -1
  67. package/dist/chunk-LQDDDIC4.cjs.map +0 -1
  68. package/dist/chunk-MAAM45ZX.cjs.map +0 -1
  69. package/dist/chunk-UHNSL2KJ.js.map +0 -1
  70. package/dist/chunk-X62H3RBG.cjs.map +0 -1
@@ -1,4 +1,61 @@
1
- import { TextureSource, Container } from 'pixi.js';
1
+ import * as pixi_js from 'pixi.js';
2
+ import { Container, TextureSource } from 'pixi.js';
3
+
4
+ /**
5
+ * Resolves a routed keyframe path to a concrete tween target.
6
+ * `path` is everything after the registered prefix + dot (for
7
+ * `three.cube.rotation.y` under prefix `three`, path = `cube.rotation.y`).
8
+ * Return null to skip the key (the router is expected to have warned).
9
+ */
10
+ type PathRouter = (path: string) => {
11
+ target: object;
12
+ prop: string;
13
+ } | null;
14
+ type PathRouters = Record<string, PathRouter>;
15
+
16
+ /** Variables available to expression evaluation. */
17
+ interface Scope {
18
+ w: number;
19
+ h: number;
20
+ W: number;
21
+ H: number;
22
+ GW: number;
23
+ GH: number;
24
+ contain: number;
25
+ cover: number;
26
+ t: number;
27
+ d: number;
28
+ T: number;
29
+ }
30
+
31
+ declare abstract class Sequence {
32
+ spec: SequenceSpec;
33
+ parent: CompositionShape | null;
34
+ root: CompositionShape;
35
+ target: Container | null;
36
+ intrinsicWidth: number;
37
+ intrinsicHeight: number;
38
+ at: number;
39
+ duration: number | undefined;
40
+ constructor(spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape);
41
+ abstract build(): Promise<void>;
42
+ buildFilters(): void;
43
+ scope(): Scope;
44
+ /**
45
+ * Prefix routers contributed to the keyframe pipeline (e.g. `three.` in
46
+ * pixi-effects/three). Base sequences route nothing extra.
47
+ */
48
+ protected pathRouters(): PathRouters;
49
+ /**
50
+ * The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
51
+ * out of them: it is not a property of the display object.
52
+ */
53
+ protected displayProps(): {
54
+ initial: SequenceSpec['initial'];
55
+ keyframes: SequenceSpec['keyframes'];
56
+ };
57
+ destroy(): void;
58
+ }
2
59
 
3
60
  /** What an author writes in `composition.stops`: a time in seconds, or an object. */
4
61
  interface StopSpec {
@@ -120,62 +177,6 @@ interface SharedGpu {
120
177
  adopt(source: TextureSource, texture: unknown): void;
121
178
  }
122
179
 
123
- /**
124
- * Resolves a routed keyframe path to a concrete tween target.
125
- * `path` is everything after the registered prefix + dot (for
126
- * `three.cube.rotation.y` under prefix `three`, path = `cube.rotation.y`).
127
- * Return null to skip the key (the router is expected to have warned).
128
- */
129
- type PathRouter = (path: string) => {
130
- target: object;
131
- prop: string;
132
- } | null;
133
- type PathRouters = Record<string, PathRouter>;
134
-
135
- /** Variables available to expression evaluation. */
136
- interface Scope {
137
- w: number;
138
- h: number;
139
- W: number;
140
- H: number;
141
- GW: number;
142
- GH: number;
143
- contain: number;
144
- cover: number;
145
- t: number;
146
- d: number;
147
- T: number;
148
- }
149
-
150
- declare abstract class Sequence {
151
- spec: SequenceSpec;
152
- parent: CompositionShape | null;
153
- root: CompositionShape;
154
- target: Container | null;
155
- intrinsicWidth: number;
156
- intrinsicHeight: number;
157
- at: number;
158
- duration: number | undefined;
159
- constructor(spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape);
160
- abstract build(): Promise<void>;
161
- buildFilters(): void;
162
- scope(): Scope;
163
- /**
164
- * Prefix routers contributed to the keyframe pipeline (e.g. `three.` in
165
- * pixi-effects/three). Base sequences route nothing extra.
166
- */
167
- protected pathRouters(): PathRouters;
168
- /**
169
- * The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
170
- * out of them: it is not a property of the display object.
171
- */
172
- protected displayProps(): {
173
- initial: SequenceSpec['initial'];
174
- keyframes: SequenceSpec['keyframes'];
175
- };
176
- destroy(): void;
177
- }
178
-
179
180
  /**
180
181
  * Keeping a layer's expensive state across a rebuild (`setVars`, `resize`, `rebuild`): the new tree is built while the old one still plays, and a layer that cannot be told apart from
181
182
  * one of the old tree (same name, same setup, same size and options) may take over that layer's scene and renderer instead of building its own. Today only the `three` layer does:
@@ -949,6 +950,11 @@ interface CompositionShape {
949
950
  gpu?: SharedGpu;
950
951
  /** The layers of the tree that a rebuild replaces, that a new layer of the same kind may take the state of instead of building its own (`three` layers: see core/reuse.ts). Root shape only. */
951
952
  reuse?: ReusePool;
953
+ /** A layer of this composition by its `name`, among those already built (the ones listed before the asker): a `three` layer shows one of them on a mesh (`threeLayerTexture`). */
954
+ findLayer?: (name: string) => Sequence | null;
955
+ layerNames?: () => string[];
956
+ /** PixiJS's renderer, on the root shape only: what draws a layer into a texture for `threeLayerTexture`. */
957
+ pixiRenderer?: pixi_js.Renderer;
952
958
  }
953
959
  /** The composition's camera and the lights alive at one moment (what its `camera` and `light` layers say, after their keyframes). */
954
960
  interface SpaceNow {
@@ -957,4 +963,4 @@ interface SpaceNow {
957
963
  fog: FogState | null;
958
964
  }
959
965
 
960
- export { type SfxPreset as $, type AssetSpec as A, type BlendModeName as B, type CompositionSpec as C, type DipTransition as D, type EllipseShapeSpec as E, type FilterSpec as F, type GradientSpec as G, type MaskRefSpec as H, type ImageSequenceSpec as I, type MatteRefSpec as J, type Keyframe as K, type LightSequenceSpec as L, type MusicOptions as M, type MusicDrum as N, type MusicDrums as O, type Props as P, type MusicInstrument as Q, type MusicLevel as R, type SequenceSpec as S, type TransitionSpec as T, type MusicTrack as U, type NullSequenceSpec as V, type PathShapeSpec as W, type PolygonShapeSpec as X, type RectShapeSpec as Y, type SfxKnobs as Z, type SfxOptions as _, type Stop as a, type SfxVoice as a0, type ShaderSequenceSpec as a1, type ShapeSequenceSpec as a2, type SlideTransition as a3, type StopSpec as a4, type TextSpan as a5, type TransitionCommon as a6, type VideoSequenceSpec as a7, type WipeTransition as a8, type ZoomTransition as a9, type SpaceNow as b, type SequenceCommon as c, type PropValue as d, Sequence as e, type PathRouters as f, type CompositionShape as g, type CameraSequenceSpec as h, type TextSequenceSpec as i, type ArcShapeSpec as j, type AudioAssetSpec as k, type AudioMusicSpec as l, type AudioSequenceSpec as m, type AudioSfxSpec as n, type CanvasInfo as o, type CanvasSequenceSpec as p, type ChromaKeyFilterSpec as q, type CircleShapeSpec as r, type CompositionSequenceSpec as s, type CrossfadeTransition as t, type CustomFilterSpec as u, type DissolveTransition as v, type Expr as w, type IrisTransition as x, type LineShapeSpec as y, type LumaTransition as z };
966
+ export { type SfxPreset as $, type AssetSpec as A, type BlendModeName as B, type CompositionSpec as C, type DipTransition as D, type EllipseShapeSpec as E, type FilterSpec as F, type GradientSpec as G, type MaskRefSpec as H, type ImageSequenceSpec as I, type MatteRefSpec as J, type Keyframe as K, type LightSequenceSpec as L, type MusicOptions as M, type MusicDrum as N, type MusicDrums as O, type Props as P, type MusicInstrument as Q, type MusicLevel as R, type SequenceSpec as S, type TransitionSpec as T, type MusicTrack as U, type NullSequenceSpec as V, type PathShapeSpec as W, type PolygonShapeSpec as X, type RectShapeSpec as Y, type SfxKnobs as Z, type SfxOptions as _, type Stop as a, type SfxVoice as a0, type ShaderSequenceSpec as a1, type ShapeSequenceSpec as a2, type SlideTransition as a3, type StopSpec as a4, type TextSpan as a5, type TransitionCommon as a6, type VideoSequenceSpec as a7, type WipeTransition as a8, type ZoomTransition as a9, Sequence as b, type SpaceNow as c, type SequenceCommon as d, type PropValue as e, type PathRouters as f, type CompositionShape as g, type CameraSequenceSpec as h, type TextSequenceSpec as i, type ArcShapeSpec as j, type AudioAssetSpec as k, type AudioMusicSpec as l, type AudioSequenceSpec as m, type AudioSfxSpec as n, type CanvasInfo as o, type CanvasSequenceSpec as p, type ChromaKeyFilterSpec as q, type CircleShapeSpec as r, type CompositionSequenceSpec as s, type CrossfadeTransition as t, type CustomFilterSpec as u, type DissolveTransition as v, type Expr as w, type IrisTransition as x, type LineShapeSpec as y, type LumaTransition as z };
@@ -1,4 +1,61 @@
1
- import { TextureSource, Container } from 'pixi.js';
1
+ import * as pixi_js from 'pixi.js';
2
+ import { Container, TextureSource } from 'pixi.js';
3
+
4
+ /**
5
+ * Resolves a routed keyframe path to a concrete tween target.
6
+ * `path` is everything after the registered prefix + dot (for
7
+ * `three.cube.rotation.y` under prefix `three`, path = `cube.rotation.y`).
8
+ * Return null to skip the key (the router is expected to have warned).
9
+ */
10
+ type PathRouter = (path: string) => {
11
+ target: object;
12
+ prop: string;
13
+ } | null;
14
+ type PathRouters = Record<string, PathRouter>;
15
+
16
+ /** Variables available to expression evaluation. */
17
+ interface Scope {
18
+ w: number;
19
+ h: number;
20
+ W: number;
21
+ H: number;
22
+ GW: number;
23
+ GH: number;
24
+ contain: number;
25
+ cover: number;
26
+ t: number;
27
+ d: number;
28
+ T: number;
29
+ }
30
+
31
+ declare abstract class Sequence {
32
+ spec: SequenceSpec;
33
+ parent: CompositionShape | null;
34
+ root: CompositionShape;
35
+ target: Container | null;
36
+ intrinsicWidth: number;
37
+ intrinsicHeight: number;
38
+ at: number;
39
+ duration: number | undefined;
40
+ constructor(spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape);
41
+ abstract build(): Promise<void>;
42
+ buildFilters(): void;
43
+ scope(): Scope;
44
+ /**
45
+ * Prefix routers contributed to the keyframe pipeline (e.g. `three.` in
46
+ * pixi-effects/three). Base sequences route nothing extra.
47
+ */
48
+ protected pathRouters(): PathRouters;
49
+ /**
50
+ * The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
51
+ * out of them: it is not a property of the display object.
52
+ */
53
+ protected displayProps(): {
54
+ initial: SequenceSpec['initial'];
55
+ keyframes: SequenceSpec['keyframes'];
56
+ };
57
+ destroy(): void;
58
+ }
2
59
 
3
60
  /** What an author writes in `composition.stops`: a time in seconds, or an object. */
4
61
  interface StopSpec {
@@ -120,62 +177,6 @@ interface SharedGpu {
120
177
  adopt(source: TextureSource, texture: unknown): void;
121
178
  }
122
179
 
123
- /**
124
- * Resolves a routed keyframe path to a concrete tween target.
125
- * `path` is everything after the registered prefix + dot (for
126
- * `three.cube.rotation.y` under prefix `three`, path = `cube.rotation.y`).
127
- * Return null to skip the key (the router is expected to have warned).
128
- */
129
- type PathRouter = (path: string) => {
130
- target: object;
131
- prop: string;
132
- } | null;
133
- type PathRouters = Record<string, PathRouter>;
134
-
135
- /** Variables available to expression evaluation. */
136
- interface Scope {
137
- w: number;
138
- h: number;
139
- W: number;
140
- H: number;
141
- GW: number;
142
- GH: number;
143
- contain: number;
144
- cover: number;
145
- t: number;
146
- d: number;
147
- T: number;
148
- }
149
-
150
- declare abstract class Sequence {
151
- spec: SequenceSpec;
152
- parent: CompositionShape | null;
153
- root: CompositionShape;
154
- target: Container | null;
155
- intrinsicWidth: number;
156
- intrinsicHeight: number;
157
- at: number;
158
- duration: number | undefined;
159
- constructor(spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape);
160
- abstract build(): Promise<void>;
161
- buildFilters(): void;
162
- scope(): Scope;
163
- /**
164
- * Prefix routers contributed to the keyframe pipeline (e.g. `three.` in
165
- * pixi-effects/three). Base sequences route nothing extra.
166
- */
167
- protected pathRouters(): PathRouters;
168
- /**
169
- * The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
170
- * out of them: it is not a property of the display object.
171
- */
172
- protected displayProps(): {
173
- initial: SequenceSpec['initial'];
174
- keyframes: SequenceSpec['keyframes'];
175
- };
176
- destroy(): void;
177
- }
178
-
179
180
  /**
180
181
  * Keeping a layer's expensive state across a rebuild (`setVars`, `resize`, `rebuild`): the new tree is built while the old one still plays, and a layer that cannot be told apart from
181
182
  * one of the old tree (same name, same setup, same size and options) may take over that layer's scene and renderer instead of building its own. Today only the `three` layer does:
@@ -949,6 +950,11 @@ interface CompositionShape {
949
950
  gpu?: SharedGpu;
950
951
  /** The layers of the tree that a rebuild replaces, that a new layer of the same kind may take the state of instead of building its own (`three` layers: see core/reuse.ts). Root shape only. */
951
952
  reuse?: ReusePool;
953
+ /** A layer of this composition by its `name`, among those already built (the ones listed before the asker): a `three` layer shows one of them on a mesh (`threeLayerTexture`). */
954
+ findLayer?: (name: string) => Sequence | null;
955
+ layerNames?: () => string[];
956
+ /** PixiJS's renderer, on the root shape only: what draws a layer into a texture for `threeLayerTexture`. */
957
+ pixiRenderer?: pixi_js.Renderer;
952
958
  }
953
959
  /** The composition's camera and the lights alive at one moment (what its `camera` and `light` layers say, after their keyframes). */
954
960
  interface SpaceNow {
@@ -957,4 +963,4 @@ interface SpaceNow {
957
963
  fog: FogState | null;
958
964
  }
959
965
 
960
- export { type SfxPreset as $, type AssetSpec as A, type BlendModeName as B, type CompositionSpec as C, type DipTransition as D, type EllipseShapeSpec as E, type FilterSpec as F, type GradientSpec as G, type MaskRefSpec as H, type ImageSequenceSpec as I, type MatteRefSpec as J, type Keyframe as K, type LightSequenceSpec as L, type MusicOptions as M, type MusicDrum as N, type MusicDrums as O, type Props as P, type MusicInstrument as Q, type MusicLevel as R, type SequenceSpec as S, type TransitionSpec as T, type MusicTrack as U, type NullSequenceSpec as V, type PathShapeSpec as W, type PolygonShapeSpec as X, type RectShapeSpec as Y, type SfxKnobs as Z, type SfxOptions as _, type Stop as a, type SfxVoice as a0, type ShaderSequenceSpec as a1, type ShapeSequenceSpec as a2, type SlideTransition as a3, type StopSpec as a4, type TextSpan as a5, type TransitionCommon as a6, type VideoSequenceSpec as a7, type WipeTransition as a8, type ZoomTransition as a9, type SpaceNow as b, type SequenceCommon as c, type PropValue as d, Sequence as e, type PathRouters as f, type CompositionShape as g, type CameraSequenceSpec as h, type TextSequenceSpec as i, type ArcShapeSpec as j, type AudioAssetSpec as k, type AudioMusicSpec as l, type AudioSequenceSpec as m, type AudioSfxSpec as n, type CanvasInfo as o, type CanvasSequenceSpec as p, type ChromaKeyFilterSpec as q, type CircleShapeSpec as r, type CompositionSequenceSpec as s, type CrossfadeTransition as t, type CustomFilterSpec as u, type DissolveTransition as v, type Expr as w, type IrisTransition as x, type LineShapeSpec as y, type LumaTransition as z };
966
+ export { type SfxPreset as $, type AssetSpec as A, type BlendModeName as B, type CompositionSpec as C, type DipTransition as D, type EllipseShapeSpec as E, type FilterSpec as F, type GradientSpec as G, type MaskRefSpec as H, type ImageSequenceSpec as I, type MatteRefSpec as J, type Keyframe as K, type LightSequenceSpec as L, type MusicOptions as M, type MusicDrum as N, type MusicDrums as O, type Props as P, type MusicInstrument as Q, type MusicLevel as R, type SequenceSpec as S, type TransitionSpec as T, type MusicTrack as U, type NullSequenceSpec as V, type PathShapeSpec as W, type PolygonShapeSpec as X, type RectShapeSpec as Y, type SfxKnobs as Z, type SfxOptions as _, type Stop as a, type SfxVoice as a0, type ShaderSequenceSpec as a1, type ShapeSequenceSpec as a2, type SlideTransition as a3, type StopSpec as a4, type TextSpan as a5, type TransitionCommon as a6, type VideoSequenceSpec as a7, type WipeTransition as a8, type ZoomTransition as a9, Sequence as b, type SpaceNow as c, type SequenceCommon as d, type PropValue as e, type PathRouters as f, type CompositionShape as g, type CameraSequenceSpec as h, type TextSequenceSpec as i, type ArcShapeSpec as j, type AudioAssetSpec as k, type AudioMusicSpec as l, type AudioSequenceSpec as m, type AudioSfxSpec as n, type CanvasInfo as o, type CanvasSequenceSpec as p, type ChromaKeyFilterSpec as q, type CircleShapeSpec as r, type CompositionSequenceSpec as s, type CrossfadeTransition as t, type CustomFilterSpec as u, type DissolveTransition as v, type Expr as w, type IrisTransition as x, type LineShapeSpec as y, type LumaTransition as z };
package/docs/api.md CHANGED
@@ -44,6 +44,7 @@ interface MovieOptions {
44
44
  composition?: CompositionSpec; // root composition (see DSL reference)
45
45
  vars?: Record<string, number | string>; // named values the composition reads: a number as a name in an expression, any as {name} in a string (see Variables)
46
46
  loop?: boolean; // start with movie.loop on: playing goes back to the start at the end (default false, like a <video>)
47
+ loudness?: number | false; // the whole mix at this many LUFS (-16 is usual for web video): one gain, up or down, the loudest sample kept at -1 dBFS or under; false / left out: the mix as the layers make it (check says when it is quiet)
47
48
  poster?: number; // seconds: the frame shown before play (negative: from the end), see movie.poster
48
49
  motionBlur?: boolean | number | { samples?: number; shutter?: number }; // blur for render() / snapshot() / contactSheet(), not live playback; see Motion blur
49
50
  loader?: HTMLElement | string | false; // the page's loader (see pixi-effects/loader.css): shows how far init is (a bar, "BUILDING LAYERS 62%"), faded out when ready, stopped if init fails; default: a .pe-loader next to the canvas
@@ -176,6 +177,7 @@ interface PickHit {
176
177
  kind: 'flat' | 'card' | 'three'; // a 2D layer, a threeD layer (its projected card), a three layer
177
178
  point: { x: number; y: number }; // the point asked about
178
179
  local?: { x: number; y: number }; // a flat layer: the point in the layer's own pixels
180
+ via?: { path: string; object: string }; // the layer is shown on a mesh of a `three` layer (threeLayerTexture) and the point is on that mesh: the three layer and the object it came through
179
181
  three?: { object: string; point: [number, number, number]; distance: number; object3d: unknown; // a three layer: what the ray met first
180
182
  grab?: (plane?) => { move: (x, y) => { point, delta } | null } }; // drag it: see below
181
183
  }
package/docs/dsl.md CHANGED
@@ -224,6 +224,8 @@ Audio-only sequence. No visual. Volume is animatable via keyframes.
224
224
 
225
225
  Audio is mixed during `Movie.init()` and during `Movie.render()`. Volume keyframes interpolate linearly.
226
226
 
227
+ **The loudness of the whole mix: `movie.init({ loudness: -16 })`.** Layers make whatever level their `volume` gives (a few `sfx` at volume 0.15 mix to about -40 LUFS, far quieter than web video's -14 to -16). `loudness` is a target for the finished mix in LUFS (ITU-R BS.1770): one gain, up or down, applied before the limiter and the same for playback and export. The loudest sample may not pass -1 dBFS, so a mix with one loud click in a quiet bed stops short of the target, and `check` / `inspectAudio()` say so (`reached -19.4 LUFS`) with the fix (lower that sound, or ask for less). `false` or no option leaves the mix as the layers make it; `check` then says when it is quiet (found in a trial of nine films with sound: eight were -22 to -26 LUFS and only one was raised).
228
+
227
229
  #### Sound effects without files: `sfx`
228
230
 
229
231
  ```ts
@@ -848,6 +850,30 @@ three({ type: 'three', width: 'GW', height: 'GH',
848
850
  - **`threeFrameCamera(camera, object, { direction, margin })`** puts a `PerspectiveCamera` where the whole of `object` (its box now, with its scale and place) fits the frame, looking at its centre, from a three-quarter view (`direction` default `[0.45, 0.3, 1]`) with 10 % room (`margin` 1.1): the smallest distance at which the 8 corners of the box fit both the vertical and the horizontal field of view (a bounding sphere would leave a long model small and head-on). It uses the camera's own `fov` and `aspect` and sets `near` / `far`. The box is the bind pose: a clip that travels far needs a camera of your own.
849
851
  - With `followCamera` the composition's camera is the layer's camera (do not call `threeFrameCamera`); with `followLights` its lights light the model. See the recipes `gltf-in-space` and `gltf-shadow-room`. `orbit()` and `cameraPath()` drive the composition's `camera` layer, so they carry a following scene too.
850
852
 
853
+ ### A 2D layer on a mesh: `threeLayerTexture`
854
+
855
+ `threeLayerTexture(ctx, 'screen')` makes a live 2D layer (a `composition`) the picture on a mesh of a `three` layer: a phone or laptop screen with an app running, a television, a billboard, a label on a bottle, a card with moving type. The 2D layer stays an ordinary layer (the timeline drives it, its own `at` / `duration` / keyframes / filters apply) but is no longer drawn on the stage: just before the three layer draws, PixiJS draws it into a texture and the mesh shows that texture.
856
+
857
+ ```js
858
+ import { threeLayerTexture } from 'pixi-effects/three';
859
+ // in the composition, the 2D layer FIRST, then the three layer
860
+ { type: 'composition', name: 'screen', width: 480, height: 270, duration: 8, sequences: [ /* a background rect, your UI */ ] },
861
+ three({ type: 'three', width: 'GW', height: 'GH',
862
+ setup: async (ctx) => {
863
+ const screen = await threeLayerTexture(ctx, 'screen', { resolution: 2 }); // { material, texture, width, height, dispose }
864
+ ctx.scene.add(new THREE.Mesh(new THREE.PlaneGeometry(1.6, 0.9), screen.material));
865
+ } })
866
+ ```
867
+
868
+ - **The rules.** The layer must be a `composition` with a `width` and `height`, in the same composition as the three layer and **listed before it** (a layer built later cannot be found: the error lists the names that are there). `await` the call in `setup`. `resolution` (default 1) is the pixels of the texture per pixel of the layer: 2 for a screen that is seen large. `transparent: true` lets what is behind the mesh show through the empty and half-transparent parts of the composition (a sticker, a label with rounded corners; the material blends and does not write depth); default `false`, an opaque screen. The layer's own place, scale and rotation do not matter: its content is drawn from (0, 0) to (width, height); its alpha and filters do.
869
+ - **The material.** `screen.material` is unlit (a screen shines by itself; scene lights do not shade it) and carries the colour decoding; put it on a mesh, or on one face of a box (`[dark, dark, dark, dark, screen.material, dark]`). Use `screen.texture` yourself and its colours may be wrong. Without `transparent`, give the composition a background rect: what it leaves empty is black on the mesh.
870
+ - **Two routes, the same colours** (measured: the background, a moving dot and a growing bar equal the 2D layer, channel for channel, on both). When the three layer draws on PixiJS's own GPU device (`renderer: 'webgpu'` in a WebGPU movie, the default) the texture is PixiJS's own GPU texture used through three's `ExternalTexture`: no copy, 0.3 ms a frame, and the page's import map needs `"three/webgpu"` and `"three/tsl"` next to `"three"`. Otherwise (the layer has a canvas of its own with `shareGpu: false`, a `renderer: 'webgl'` layer, or PixiJS on WebGL) the pixels are read back into a canvas: 1.6 ms a frame at 480 x 270, 4 ms at 1280 x 720.
871
+ - **A function of the time.** The 2D layer is drawn when the three layer draws, after the timeline has put every layer at the frame: a seek, an export and a repeat give the same picture (`check` draws the layer twice, in two orders, as for any three layer). A layer that has not started yet, or has ended, shows nothing on the mesh.
872
+ - **A rebuild** (`setVars`, `resize`) keeps the three layer and shows the layer of the new tree (the text that changed shows on the mesh); a layer that changed size is drawn stretched to the texture.
873
+ - **Seen small or at a slant** the texture is filtered, not aliased (1-pixel stripes on a plane a quarter of the picture come out as one even grey, not black and white stripes; four taps along a slant). The mip levels are PixiJS's own on the shared GPU, averaged in the stored sRGB values, so very fine bright detail seen very small is a little darker there than on a read-back canvas, whose mipmaps three makes in linear light (stripes average to 128 and 188).
874
+ - **A click reaches the screen.** `movie.pick(x, y)` on a point of the mesh answers for the layers INSIDE the 2D layer, at the point of its picture under the pointer: the top hit is `screen/button` (a flat hit with `local` in the layer's own pixels), with `via: { path: 'phone', object: 'face' }` naming the three layer and the mesh it came through; under it come the screen's other layers, then the mesh itself. `only` / `ignore` see through the mesh (`only: ['screen']` is the screen's layers wherever they are shown), `movie.hover` and the click recipes work on a phone's screen as they do on a flat layer, and a layer that is not alive does not answer. A page asks the layer by its own name, so the button is one `pick` away: `if (movie.pick(...movie.canvasPoint(event), { only: ['button'] })) …`.
875
+ - **Not in this version:** several meshes showing the same layer are answered through the first one the ray meets (that is the right one), but a layer shown on a mesh seen through another transparent mesh is not looked behind.
876
+
851
877
  ### Keyframe paths: `three.<name>.<path>`
852
878
 
853
879
  Objects returned from `setup`'s `{ objects }` are addressable from keyframes as `three.<name>.<path>`, using the same `from`/`to`/`set` vocabulary as every other prop:
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.44.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.46.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
 
@@ -35,7 +35,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
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
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; **test the page with `check --do "hover Lime; click Lime; drag card > 770,240; wait 1"`**: real mouse and key events on the page (a point is `x,y` in canvas pixels or a layer name, the middle of its box), run after everything else, and the report says per step what was under the pointer, which variables changed (`score 0→1`), whether the page warned, and keeps one picture each in `steps/` (look at them). A step on a layer that is not there, or a point outside the picture, is a problem. To do the same by hand (a browser tool, Chrome's protocol; serve the folder over http: a model, an image or a module beside the page does not load from file://): `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"`.
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
+ - **Review:** `check` prints a `motion` line (how much of the movie moves, when the last movement ends, the longest still stretch) and lists, for review, a plain-colour last frame, a first second with nothing on screen, and a hold of 3 s or more: `OK` does not mean the film is rich, only that nothing is broken, so read those lines and the contact sheet. `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.
39
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`.
40
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.
41
41
 
@@ -180,7 +180,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
180
180
  **video** — `asset`, `loop`, `audio`, `volume`, **`speed`**; `initial: { scale: 'cover' }`. `speed: 0.5` slow motion, `speed: -1` backward (it starts at |speed| × `duration`, so a 3 s file with no `duration` plays whole, backward); with no `duration` the layer lasts the file's length ÷ |speed|. For anything that is not one straight speed — a freeze, a ramp, a loop — animate **`time`** (seconds in the file) like any property: `keyframes: [{ at: 0, from: { time: 0 }, to: { time: 2 }, duration: 1 }, { at: 1, to: { time: 2 }, duration: 0.5 }, { at: 1.5, to: { time: 6 }, duration: 3, ease: 'power2.inOut' }]` (play 2 s, hold, then accelerate). `repeat` loops it, `yoyo` ping-pongs. `speed` itself is not animatable (it warns): animate `time`. The sound follows (pitch too, like a tape).
181
181
 
182
182
  **audio** — a file: `asset`, `loop`, `volume`, `speed` (the pitch follows; `time` keyframes work as for video; not for `sfx` / `music`: change an sfx's `pitch` instead, or put it in a composition and remap that); `volume` (top level, or in `initial` like every other layer's starting values); fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). Omit `duration` for a one-shot clip: it lasts exactly as long as the clip. With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
183
- **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser; at most 10 s); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset; above 1 is allowed: a sparse track of clicks measured −32 LUFS at 0.5 and needed about 2 to reach −24 LUFS). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
183
+ **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. **The mix's loudness in one line:** `movie.init({ loudness: -16 })` brings the whole mix to -16 LUFS (web video), up or down; `check` says when a mix is quiet. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser; at most 10 s); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset; above 1 is allowed: a sparse track of clicks measured −32 LUFS at 0.5 and needed about 2 to reach −24 LUFS). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
184
184
  **Or music with no file — `music`:** `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4 Dm7:4 G7:4' }, { inst: 'bass', notes: 'c2:2 g1:2 …' }], drums: { kick: 'x...x...', snare: '....x...', hat: 'x.x.x.x.' } } }` — a tune written as text (notes in LOWERCASE `c4:2` in beats, chords start with a CAPITAL `Am7` `G7` `C@4` `Am7/e` (`g7` is a very high note, not G7), `[c4 e4 g4]`, rest `_`, hold `~`, `!` accent; `|` ignored). Instruments `keys pluck pad bass sub lead bell musicbox`; drums `kick snare hat openhat clap rim tom crash shaker sleigh` as 16th-note step strings (`x` hit, `o` soft, `.` none; a list of `{ from, to, … }` for sections). Score options `bpm` (or `[[beat, bpm], …]` for a ritardando) `meter` (beats in a bar, default 4: names the bars in `check`'s read-back, no effect on the sound) `swing reverb (0–0.6) humanize drumVol grid transpose tail seed`; track options `vol` (or `[[beat, level], …]` for a fade) `pan step strum legato tone reverb attack release ring transpose`. A plain capital letter is a major chord (`G`, `Bb`, `F#`; `Ebmaj7`, `F#m7`), `E5` is root and fifth only (e3 b3), every chord token is struck again (`~` holds), a track can start late with a rest (`_:32 e4:1 …`), `f#3` / `bb2` are sharps and flats, c4 is middle C, chord names are voiced around octave 3 (good ranges for notes: bass c1–c3, keys / pluck c3–c6, pad c2–c5, lead c4–c6, bell c5–c7). **Drums repeat by themselves** (one bar is enough) and `drums: { from: 32, to: 64, … }` counts BEATS, not bars (`drums` is one object, or a list of them for sections). `check` reads the score back (bars per track, what starts in each bar and on which beat: `Am(2)`; drum span): trust that over counting by hand; `await musicEvents(music)` gives the same as data. Lasts its notes + tail, ends with the movie, `loop: true` repeats; peaks −6 dBFS at `volume: 1`. `await musicEnvelope(music, { frameRate })` → an envelope for `react()`; `await musicBuffer(music)` → a Web Audio `AudioBuffer` to play yourself.
185
185
 
186
186
  **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`:
@@ -209,7 +209,7 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
209
209
 
210
210
  **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`.
211
211
 
212
- **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`.
212
+ **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`. **A 2D layer on a mesh** (a phone screen, a television): `const screen = await threeLayerTexture(ctx, 'screen')` in `setup`, `screen.material` on a mesh; `screen` is a `composition` listed BEFORE the three layer (recipe `three-screen`).
213
213
 
214
214
  ## Camera flight and a camera shake
215
215
 
@@ -1856,6 +1856,40 @@ return [
1856
1856
 
1857
1857
  A moving, shadow-casting model is held to "a function of the time" by `check`: place it from `t` in `update` (as above) and let `threeGltf` load it (it draws a skinned mesh always: three culls it by a bounding sphere that does not follow it).
1858
1858
 
1859
+ ## An app or video on a phone, a television, a billboard (`threeLayerTexture`)
1860
+
1861
+ A 2D composition is the screen; a `three` layer holds the object it is on. The composition keeps animating (its own keyframes, text, charts, a counter) and the mesh shows it live, turning with the object. Put the composition BEFORE the three layer, give it a background rect, and `await threeLayerTexture(ctx, 'screen')` in `setup`: `screen.material` goes on the face of the mesh. The page's import map needs `"three/webgpu"` and `"three/tsl"` next to `"three"` (the template's has them when it has `three`).
1862
+
1863
+ ```js
1864
+ // @docs-only three-screen
1865
+ // import { registerThree, threeLayerTexture } from 'pixi-effects/three'; registerThree(); (before movie.init)
1866
+ // composition 1280 x 720, 8 s
1867
+ let phone;
1868
+ return [
1869
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#e9e4dc' } },
1870
+ { type: 'composition', name: 'screen', width: 390, height: 844, duration: 8, sequences: [ // the app: any 2D layers; this is what the phone shows
1871
+ { type: 'shape', shape: 'rect', width: 390, height: 844, initial: { x: 195, y: 422, fillColor: '#101a33' } },
1872
+ { type: 'text', text: 'Hello', style: { fontSize: 72, fill: '#ffffff', fontWeight: '800' }, initial: { x: 195, y: 160, anchorX: 0.5, anchorY: 0.5 },
1873
+ keyframes: [{ at: 0.4, from: { alpha: 0, y: 200 }, to: { alpha: 1, y: 160 }, duration: 0.8, ease: 'power3.out' }] },
1874
+ ] },
1875
+ { type: 'three', name: 'phone', width: 'GW', height: 'GH',
1876
+ setup: async (ctx) => {
1877
+ const screen = await threeLayerTexture(ctx, 'screen', { resolution: 2 }); // a screen that is seen large: 2 x the pixels
1878
+ const body = new THREE.Mesh(new THREE.BoxGeometry(1.0, 2.1, 0.09), new THREE.MeshStandardMaterial({ color: 0x15171c, metalness: 0.6, roughness: 0.35 }));
1879
+ const face = new THREE.Mesh(new THREE.PlaneGeometry(0.92, 2.0), screen.material); // the layer's colours, as they are in 2D
1880
+ face.position.z = 0.046; // just in front of the body
1881
+ phone = new THREE.Group(); phone.add(body, face);
1882
+ ctx.scene.add(phone, new THREE.AmbientLight(0xffffff, 0.8), new THREE.DirectionalLight(0xffffff, 2.2));
1883
+ ctx.camera.position.set(0, 0, 2.9);
1884
+ },
1885
+ update: (t) => { phone.rotation.y = 0.6 * Math.sin(t * 0.7); } }, // a function of t, like any three layer
1886
+ ];
1887
+ ```
1888
+
1889
+ A tap on the phone's screen reaches the app: `movie.pick(...movie.canvasPoint(event))` answers with the layer inside the screen (`screen/button`, with `via` naming the phone), so the click and hover recipes work on a mesh as on a flat layer.
1890
+
1891
+ Rules: the layer must be a `composition` with a `width` and `height`; a mistake in the name lists the names that are there; the material is unlit (a screen shines by itself) so keep the scene's lights for the body; empty parts of the composition are black on the mesh unless you pass `{ transparent: true }` (a sticker or a rounded label). The full rules, the two routes (no copy on the shared GPU, a read-back otherwise) and what is not in this version: `pixi-effects help threeLayerTexture`.
1892
+
1859
1893
  ## Click a layer: what is under the pointer (`movie.pick`)
1860
1894
 
1861
1895
  `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).
@@ -2499,6 +2533,8 @@ Audio-only sequence. No visual. Volume is animatable via keyframes.
2499
2533
 
2500
2534
  Audio is mixed during `Movie.init()` and during `Movie.render()`. Volume keyframes interpolate linearly.
2501
2535
 
2536
+ **The loudness of the whole mix: `movie.init({ loudness: -16 })`.** Layers make whatever level their `volume` gives (a few `sfx` at volume 0.15 mix to about -40 LUFS, far quieter than web video's -14 to -16). `loudness` is a target for the finished mix in LUFS (ITU-R BS.1770): one gain, up or down, applied before the limiter and the same for playback and export. The loudest sample may not pass -1 dBFS, so a mix with one loud click in a quiet bed stops short of the target, and `check` / `inspectAudio()` say so (`reached -19.4 LUFS`) with the fix (lower that sound, or ask for less). `false` or no option leaves the mix as the layers make it; `check` then says when it is quiet (found in a trial of nine films with sound: eight were -22 to -26 LUFS and only one was raised).
2537
+
2502
2538
  #### Sound effects without files: `sfx`
2503
2539
 
2504
2540
  ```ts
@@ -3123,6 +3159,30 @@ three({ type: 'three', width: 'GW', height: 'GH',
3123
3159
  - **`threeFrameCamera(camera, object, { direction, margin })`** puts a `PerspectiveCamera` where the whole of `object` (its box now, with its scale and place) fits the frame, looking at its centre, from a three-quarter view (`direction` default `[0.45, 0.3, 1]`) with 10 % room (`margin` 1.1): the smallest distance at which the 8 corners of the box fit both the vertical and the horizontal field of view (a bounding sphere would leave a long model small and head-on). It uses the camera's own `fov` and `aspect` and sets `near` / `far`. The box is the bind pose: a clip that travels far needs a camera of your own.
3124
3160
  - With `followCamera` the composition's camera is the layer's camera (do not call `threeFrameCamera`); with `followLights` its lights light the model. See the recipes `gltf-in-space` and `gltf-shadow-room`. `orbit()` and `cameraPath()` drive the composition's `camera` layer, so they carry a following scene too.
3125
3161
 
3162
+ ### A 2D layer on a mesh: `threeLayerTexture`
3163
+
3164
+ `threeLayerTexture(ctx, 'screen')` makes a live 2D layer (a `composition`) the picture on a mesh of a `three` layer: a phone or laptop screen with an app running, a television, a billboard, a label on a bottle, a card with moving type. The 2D layer stays an ordinary layer (the timeline drives it, its own `at` / `duration` / keyframes / filters apply) but is no longer drawn on the stage: just before the three layer draws, PixiJS draws it into a texture and the mesh shows that texture.
3165
+
3166
+ ```js
3167
+ import { threeLayerTexture } from 'pixi-effects/three';
3168
+ // in the composition, the 2D layer FIRST, then the three layer
3169
+ { type: 'composition', name: 'screen', width: 480, height: 270, duration: 8, sequences: [ /* a background rect, your UI */ ] },
3170
+ three({ type: 'three', width: 'GW', height: 'GH',
3171
+ setup: async (ctx) => {
3172
+ const screen = await threeLayerTexture(ctx, 'screen', { resolution: 2 }); // { material, texture, width, height, dispose }
3173
+ ctx.scene.add(new THREE.Mesh(new THREE.PlaneGeometry(1.6, 0.9), screen.material));
3174
+ } })
3175
+ ```
3176
+
3177
+ - **The rules.** The layer must be a `composition` with a `width` and `height`, in the same composition as the three layer and **listed before it** (a layer built later cannot be found: the error lists the names that are there). `await` the call in `setup`. `resolution` (default 1) is the pixels of the texture per pixel of the layer: 2 for a screen that is seen large. `transparent: true` lets what is behind the mesh show through the empty and half-transparent parts of the composition (a sticker, a label with rounded corners; the material blends and does not write depth); default `false`, an opaque screen. The layer's own place, scale and rotation do not matter: its content is drawn from (0, 0) to (width, height); its alpha and filters do.
3178
+ - **The material.** `screen.material` is unlit (a screen shines by itself; scene lights do not shade it) and carries the colour decoding; put it on a mesh, or on one face of a box (`[dark, dark, dark, dark, screen.material, dark]`). Use `screen.texture` yourself and its colours may be wrong. Without `transparent`, give the composition a background rect: what it leaves empty is black on the mesh.
3179
+ - **Two routes, the same colours** (measured: the background, a moving dot and a growing bar equal the 2D layer, channel for channel, on both). When the three layer draws on PixiJS's own GPU device (`renderer: 'webgpu'` in a WebGPU movie, the default) the texture is PixiJS's own GPU texture used through three's `ExternalTexture`: no copy, 0.3 ms a frame, and the page's import map needs `"three/webgpu"` and `"three/tsl"` next to `"three"`. Otherwise (the layer has a canvas of its own with `shareGpu: false`, a `renderer: 'webgl'` layer, or PixiJS on WebGL) the pixels are read back into a canvas: 1.6 ms a frame at 480 x 270, 4 ms at 1280 x 720.
3180
+ - **A function of the time.** The 2D layer is drawn when the three layer draws, after the timeline has put every layer at the frame: a seek, an export and a repeat give the same picture (`check` draws the layer twice, in two orders, as for any three layer). A layer that has not started yet, or has ended, shows nothing on the mesh.
3181
+ - **A rebuild** (`setVars`, `resize`) keeps the three layer and shows the layer of the new tree (the text that changed shows on the mesh); a layer that changed size is drawn stretched to the texture.
3182
+ - **Seen small or at a slant** the texture is filtered, not aliased (1-pixel stripes on a plane a quarter of the picture come out as one even grey, not black and white stripes; four taps along a slant). The mip levels are PixiJS's own on the shared GPU, averaged in the stored sRGB values, so very fine bright detail seen very small is a little darker there than on a read-back canvas, whose mipmaps three makes in linear light (stripes average to 128 and 188).
3183
+ - **A click reaches the screen.** `movie.pick(x, y)` on a point of the mesh answers for the layers INSIDE the 2D layer, at the point of its picture under the pointer: the top hit is `screen/button` (a flat hit with `local` in the layer's own pixels), with `via: { path: 'phone', object: 'face' }` naming the three layer and the mesh it came through; under it come the screen's other layers, then the mesh itself. `only` / `ignore` see through the mesh (`only: ['screen']` is the screen's layers wherever they are shown), `movie.hover` and the click recipes work on a phone's screen as they do on a flat layer, and a layer that is not alive does not answer. A page asks the layer by its own name, so the button is one `pick` away: `if (movie.pick(...movie.canvasPoint(event), { only: ['button'] })) …`.
3184
+ - **Not in this version:** several meshes showing the same layer are answered through the first one the ray meets (that is the right one), but a layer shown on a mesh seen through another transparent mesh is not looked behind.
3185
+
3126
3186
  ### Keyframe paths: `three.<name>.<path>`
3127
3187
 
3128
3188
  Objects returned from `setup`'s `{ objects }` are addressable from keyframes as `three.<name>.<path>`, using the same `from`/`to`/`set` vocabulary as every other prop:
@@ -4151,6 +4211,7 @@ interface MovieOptions {
4151
4211
  composition?: CompositionSpec; // root composition (see DSL reference)
4152
4212
  vars?: Record<string, number | string>; // named values the composition reads: a number as a name in an expression, any as {name} in a string (see Variables)
4153
4213
  loop?: boolean; // start with movie.loop on: playing goes back to the start at the end (default false, like a <video>)
4214
+ loudness?: number | false; // the whole mix at this many LUFS (-16 is usual for web video): one gain, up or down, the loudest sample kept at -1 dBFS or under; false / left out: the mix as the layers make it (check says when it is quiet)
4154
4215
  poster?: number; // seconds: the frame shown before play (negative: from the end), see movie.poster
4155
4216
  motionBlur?: boolean | number | { samples?: number; shutter?: number }; // blur for render() / snapshot() / contactSheet(), not live playback; see Motion blur
4156
4217
  loader?: HTMLElement | string | false; // the page's loader (see pixi-effects/loader.css): shows how far init is (a bar, "BUILDING LAYERS 62%"), faded out when ready, stopped if init fails; default: a .pe-loader next to the canvas
@@ -4283,6 +4344,7 @@ interface PickHit {
4283
4344
  kind: 'flat' | 'card' | 'three'; // a 2D layer, a threeD layer (its projected card), a three layer
4284
4345
  point: { x: number; y: number }; // the point asked about
4285
4346
  local?: { x: number; y: number }; // a flat layer: the point in the layer's own pixels
4347
+ via?: { path: string; object: string }; // the layer is shown on a mesh of a `three` layer (threeLayerTexture) and the point is on that mesh: the three layer and the object it came through
4286
4348
  three?: { object: string; point: [number, number, number]; distance: number; object3d: unknown; // a three layer: what the ray met first
4287
4349
  grab?: (plane?) => { move: (x, y) => { point, delta } | null } }; // drag it: see below
4288
4350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.44.0",
3
+ "version": "0.46.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.44.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.46.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
 
@@ -32,7 +32,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
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
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; **test the page with `check --do "hover Lime; click Lime; drag card > 770,240; wait 1"`**: real mouse and key events on the page (a point is `x,y` in canvas pixels or a layer name, the middle of its box), run after everything else, and the report says per step what was under the pointer, which variables changed (`score 0→1`), whether the page warned, and keeps one picture each in `steps/` (look at them). A step on a layer that is not there, or a point outside the picture, is a problem. To do the same by hand (a browser tool, Chrome's protocol; serve the folder over http: a model, an image or a module beside the page does not load from file://): `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"`.
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
+ - **Review:** `check` prints a `motion` line (how much of the movie moves, when the last movement ends, the longest still stretch) and lists, for review, a plain-colour last frame, a first second with nothing on screen, and a hold of 3 s or more: `OK` does not mean the film is rich, only that nothing is broken, so read those lines and the contact sheet. `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.
36
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`.
37
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.
38
38
 
@@ -58,7 +58,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
58
58
  **video** — `asset`, `loop`, `audio`, `volume`, **`speed`**; `initial: { scale: 'cover' }`. `speed: 0.5` slow motion, `speed: -1` backward (it starts at |speed| × `duration`, so a 3 s file with no `duration` plays whole, backward); with no `duration` the layer lasts the file's length ÷ |speed|. For anything that is not one straight speed — a freeze, a ramp, a loop — animate **`time`** (seconds in the file) like any property: `keyframes: [{ at: 0, from: { time: 0 }, to: { time: 2 }, duration: 1 }, { at: 1, to: { time: 2 }, duration: 0.5 }, { at: 1.5, to: { time: 6 }, duration: 3, ease: 'power2.inOut' }]` (play 2 s, hold, then accelerate). `repeat` loops it, `yoyo` ping-pongs. `speed` itself is not animatable (it warns): animate `time`. The sound follows (pitch too, like a tape).
59
59
 
60
60
  **audio** — a file: `asset`, `loop`, `volume`, `speed` (the pitch follows; `time` keyframes work as for video; not for `sfx` / `music`: change an sfx's `pitch` instead, or put it in a composition and remap that); `volume` (top level, or in `initial` like every other layer's starting values); fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). Omit `duration` for a one-shot clip: it lasts exactly as long as the clip. With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
61
- **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser; at most 10 s); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset; above 1 is allowed: a sparse track of clicks measured −32 LUFS at 0.5 and needed about 2 to reach −24 LUFS). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
61
+ **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. **The mix's loudness in one line:** `movie.init({ loudness: -16 })` brings the whole mix to -16 LUFS (web video), up or down; `check` says when a mix is quiet. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser; at most 10 s); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset; above 1 is allowed: a sparse track of clicks measured −32 LUFS at 0.5 and needed about 2 to reach −24 LUFS). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
62
62
  **Or music with no file — `music`:** `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4 Dm7:4 G7:4' }, { inst: 'bass', notes: 'c2:2 g1:2 …' }], drums: { kick: 'x...x...', snare: '....x...', hat: 'x.x.x.x.' } } }` — a tune written as text (notes in LOWERCASE `c4:2` in beats, chords start with a CAPITAL `Am7` `G7` `C@4` `Am7/e` (`g7` is a very high note, not G7), `[c4 e4 g4]`, rest `_`, hold `~`, `!` accent; `|` ignored). Instruments `keys pluck pad bass sub lead bell musicbox`; drums `kick snare hat openhat clap rim tom crash shaker sleigh` as 16th-note step strings (`x` hit, `o` soft, `.` none; a list of `{ from, to, … }` for sections). Score options `bpm` (or `[[beat, bpm], …]` for a ritardando) `meter` (beats in a bar, default 4: names the bars in `check`'s read-back, no effect on the sound) `swing reverb (0–0.6) humanize drumVol grid transpose tail seed`; track options `vol` (or `[[beat, level], …]` for a fade) `pan step strum legato tone reverb attack release ring transpose`. A plain capital letter is a major chord (`G`, `Bb`, `F#`; `Ebmaj7`, `F#m7`), `E5` is root and fifth only (e3 b3), every chord token is struck again (`~` holds), a track can start late with a rest (`_:32 e4:1 …`), `f#3` / `bb2` are sharps and flats, c4 is middle C, chord names are voiced around octave 3 (good ranges for notes: bass c1–c3, keys / pluck c3–c6, pad c2–c5, lead c4–c6, bell c5–c7). **Drums repeat by themselves** (one bar is enough) and `drums: { from: 32, to: 64, … }` counts BEATS, not bars (`drums` is one object, or a list of them for sections). `check` reads the score back (bars per track, what starts in each bar and on which beat: `Am(2)`; drum span): trust that over counting by hand; `await musicEvents(music)` gives the same as data. Lasts its notes + tail, ends with the movie, `loop: true` repeats; peaks −6 dBFS at `volume: 1`. `await musicEnvelope(music, { frameRate })` → an envelope for `react()`; `await musicBuffer(music)` → a Web Audio `AudioBuffer` to play yourself.
63
63
 
64
64
  **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`:
@@ -87,7 +87,7 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
87
87
 
88
88
  **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`.
89
89
 
90
- **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`.
90
+ **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`. **A 2D layer on a mesh** (a phone screen, a television): `const screen = await threeLayerTexture(ctx, 'screen')` in `setup`, `screen.material` on a mesh; `screen` is a `composition` listed BEFORE the three layer (recipe `three-screen`).
91
91
 
92
92
  ## Camera flight and a camera shake
93
93