pixi-effects 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.1
4
+
5
+ **Docs**
6
+
7
+ - **Found by six fresh agents (three before 0.30.0's docs, three after):** a recipe for **a plain cut with generated images and music written as text** (`slideshow-cuts`; the slideshow recipe had only transitions and a music file); the **bar chart** recipe grows its bars one at a time with `EACH = DUR + 0.3` (the highlight and the callout wait for the last bar: `PEAK_AT`); `AGENTS.md` and the skill now agree that the check writes to `check-out/<name>/` (the example passed `--out check-out`).
8
+
9
+ **Fixed**
10
+
11
+ - `pixi-effects recipe` listed only the recipes whose code block had a name, so three (`deck-in-page`, `placeholder-images`, `metallic-three`) were invisible to it; every section is listed now (a section without a name gets one made from its title), and a test requires every section of `recipes.md` to have a name.
12
+
3
13
  ## 0.30.0
4
14
 
5
15
  **Added**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.30.0`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
3
+ > **Status**: experimental — current release `0.30.1`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
4
4
 
5
5
  **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 52 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
6
6
 
@@ -49,8 +49,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
49
49
  "imports": {
50
50
  "pixi.js": "https://esm.sh/pixi.js@8.22.0?bundle-deps",
51
51
  "mediabunny": "https://esm.sh/mediabunny",
52
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/index.js",
53
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/Controller.js"
52
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/Controller.js"
54
54
  }
55
55
  }
56
56
  </script>
@@ -61,7 +61,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
61
61
  </script>
62
62
  ```
63
63
 
64
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.30.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
64
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.30.1/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
65
65
 
66
66
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
67
67
  >
@@ -77,7 +77,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
77
77
  "imports": {
78
78
  "...": "(everything from the importmap above)",
79
79
  "three": "https://esm.sh/three@0.178.0",
80
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/three.js"
80
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/three.js"
81
81
  }
82
82
  }
83
83
  </script>
@@ -59,9 +59,9 @@
59
59
  "imports": {
60
60
  "pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8.22.0/dist/pixi.min.mjs",
61
61
  "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
62
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/index.js",
63
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/Controller.js",
64
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/present.js"
62
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/Controller.js",
64
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/present.js"
65
65
  }
66
66
  }
67
67
  </script>
@@ -53,15 +53,21 @@ export function parseRecipes(md) {
53
53
  });
54
54
  }
55
55
 
56
+ /** The name `recipe` lists a section by: its first id, or a slug of its title when it has none (so no recipe hides). */
57
+ export function recipeId(section) {
58
+ return section.ids[0] ?? section.title.toLowerCase().replace(/`/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
59
+ }
60
+
56
61
  /** { section, id } for an exact id or a unique part of one (or of a title); { many } when several match; { nearest } (maybe undefined) when none does. */
57
62
  export function findRecipe(sections, query) {
58
63
  const q = query.trim().toLowerCase();
59
- const exact = sections.find(s => s.ids.some(id => id.toLowerCase() === q));
60
- if (exact) return { section: exact, id: exact.ids.find(id => id.toLowerCase() === q) };
61
- const part = sections.filter(s => s.ids.some(id => id.toLowerCase().includes(q)) || s.title.toLowerCase().includes(q));
64
+ const names = (s) => [...s.ids, recipeId(s)];
65
+ const exact = sections.find(s => names(s).some(id => id.toLowerCase() === q));
66
+ if (exact) return { section: exact, id: names(exact).find(id => id.toLowerCase() === q) };
67
+ const part = sections.filter(s => names(s).some(id => id.toLowerCase().includes(q)) || s.title.toLowerCase().includes(q));
62
68
  if (part.length === 1) return { section: part[0] };
63
69
  if (part.length > 1) return { many: part };
64
- return { nearest: nearestOf(q, sections.flatMap(s => s.ids)) };
70
+ return { nearest: nearestOf(q, sections.flatMap(names)) };
65
71
  }
66
72
 
67
73
  const readIf = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
@@ -164,12 +170,12 @@ export function runLookup(cmd, args, { cwd = process.cwd(), root = PACKAGE_ROOT
164
170
  const query = args.join(' ').trim();
165
171
  if (!query) {
166
172
  const lines = ['recipes (pixi-effects recipe <id> prints one with its code):'];
167
- for (const s of sections) for (const id of s.ids) lines.push(` ${id.padEnd(28)} ${s.title}`);
173
+ for (const s of sections) (s.ids.length ? s.ids : [recipeId(s)]).forEach((id, k) => lines.push(` ${id.padEnd(28)} ${k === 0 ? s.title : `(the same section as ${recipeId(s)})`}`));
168
174
  return ok(lines.join('\n'));
169
175
  }
170
176
  const r = findRecipe(sections, query);
171
177
  if (r.section) return ok(r.section.text);
172
- if (r.many) return fail(`several recipes match "${query}": ${r.many.map(s => s.ids[0] ?? s.title).join(', ')}. Give one id (pixi-effects recipe lists them).`);
178
+ if (r.many) return fail(`several recipes match "${query}": ${r.many.map(s => recipeId(s)).join(', ')}. Give one id (pixi-effects recipe lists them).`);
173
179
  return fail(`no recipe "${query}"${r.nearest ? `; did you mean "${r.nearest}"?` : ''} (pixi-effects recipe lists them)`);
174
180
  }
175
181
  if (cmd === 'help') {
package/dist/loader.css CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * pixi-effects loader: one line of HTML on the page, a few lines of CSS, no JavaScript of your own.
3
3
  *
4
- * <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/loader.css">
4
+ * <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.30.1/dist/loader.css">
5
5
  * <div class="stage" style="position: relative"> <!-- any box the canvas sits in, with position: relative -->
6
6
  * <canvas id="stage" width="1280" height="720"></canvas>
7
7
  * <div class="pe-loader" data-label="LOADING"></div> <!-- or class="pe-loader pe-loader--bar" -->
package/llms-full.txt CHANGED
@@ -6,7 +6,7 @@ Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API refe
6
6
 
7
7
  <!-- ===== skills/pixi-effects/SKILL.md ===== -->
8
8
 
9
- > Written for pixi-effects 0.30.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.30.1. 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
 
@@ -622,6 +622,8 @@ const max = Math.max(...data.map(d => d[1]));
622
622
  const step = [1, 2, 2.5, 5, 10].map(m => m * 10 ** Math.floor(Math.log10(max / 4))).find(v => max / v <= 5) ?? 10;
623
623
  const NICE = Math.ceil(max / step) * step; // axis top, so the chart survives changed data
624
624
  const BASE = 600, CHART_H = 380, BAR_W = 90, GAP = 40, DUR = 0.9, EASE = 'power3.out';
625
+ const EACH = 0.12; // seconds between bars: 0.12 is a quick wave; EACH = DUR + 0.3 grows them one at a time
626
+ const PEAK_AT = Math.max(4, 0.4 + (data.length - 1) * EACH + DUR + 0.2); // the highlight and the callout come after the last bar has grown
625
627
  const X0 = (1280 - (data.length * BAR_W + (data.length - 1) * GAP)) / 2, X1 = X0 + data.length * BAR_W + (data.length - 1) * GAP;
626
628
  const sequences = [];
627
629
  for (let v = 0; v <= NICE; v += step) { // grid lines + tick labels
@@ -632,14 +634,14 @@ for (let v = 0; v <= NICE; v += step) { // grid lines + ti
632
634
  const best = data.findIndex(d => d[1] === max);
633
635
  data.forEach(([label, value], i) => {
634
636
  const x = X0 + i * (BAR_W + GAP) + BAR_W / 2;
635
- const at = 0.4 + i * 0.12;
637
+ const at = 0.4 + i * EACH;
636
638
  const h = CHART_H * value / NICE;
637
639
  sequences.push({
638
640
  type: 'shape', shape: 'rect', width: BAR_W, height: 0, cornerRadius: 8, anchorY: 1, at, colorSpace: 'oklab',
639
641
  initial: { x, y: BASE, fillColor: '#4f6df5' },
640
642
  keyframes: [
641
643
  { at: 0, to: { height: h }, duration: DUR, ease: EASE },
642
- ...(i === best ? [{ at: 4 - at, to: { fillColor: '#ffd166' }, duration: 0.5 }] : [{ at: 4 - at, to: { alpha: 0.45 }, duration: 0.5 }]), // highlight the maximum at t = 4 s
644
+ ...(i === best ? [{ at: PEAK_AT - at, to: { fillColor: '#ffd166' }, duration: 0.5 }] : [{ at: PEAK_AT - at, to: { alpha: 0.45 }, duration: 0.5 }]), // highlight the maximum at t = 4 s
643
645
  ],
644
646
  });
645
647
  sequences.push({ // value label: counts up while riding the bar's top
@@ -653,11 +655,11 @@ data.forEach(([label, value], i) => {
653
655
  // callout: a pill sized from its text (~0.58 x fontSize per glyph + padding), a pointer triangle, clamped inside the plot
654
656
  const text = 'Peak: ' + data[best][0] + ' ' + max, FS = 26;
655
657
  const PW = text.length * FS * 0.58 + 44, cx = Math.min(Math.max(X0 + best * (BAR_W + GAP) + BAR_W / 2, X0 + PW / 2), X1 - PW / 2), cy = BASE - CHART_H - 70;
656
- sequences.push({ type: 'shape', shape: 'rect', width: PW, height: 52, cornerRadius: 26, at: 4.2, initial: { x: cx, y: cy, fillColor: '#ffd166', alpha: 0 },
658
+ sequences.push({ type: 'shape', shape: 'rect', width: PW, height: 52, cornerRadius: 26, at: PEAK_AT + 0.2, initial: { x: cx, y: cy, fillColor: '#ffd166', alpha: 0 },
657
659
  keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
658
- sequences.push({ type: 'shape', shape: 'polygon', points: [[-10, 0], [10, 0], [0, 12]], at: 4.2, initial: { x: cx, y: cy + 32, fillColor: '#ffd166', alpha: 0 },
660
+ sequences.push({ type: 'shape', shape: 'polygon', points: [[-10, 0], [10, 0], [0, 12]], at: PEAK_AT + 0.2, initial: { x: cx, y: cy + 32, fillColor: '#ffd166', alpha: 0 },
659
661
  keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
660
- sequences.push({ type: 'text', text, at: 4.2, style: { fontSize: FS, fontWeight: 'bold', fill: '#1b1b2f' }, initial: { x: cx, y: cy, anchorX: 0.5, anchorY: 0.5, alpha: 0 },
662
+ sequences.push({ type: 'text', text, at: PEAK_AT + 0.2, style: { fontSize: FS, fontWeight: 'bold', fill: '#1b1b2f' }, initial: { x: cx, y: cy, anchorX: 0.5, anchorY: 0.5, alpha: 0 },
661
663
  keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
662
664
  return sequences;
663
665
  ```
@@ -788,6 +790,23 @@ return { sequences, transitions, duration: total };
788
790
 
789
791
  Source images should be at least canvas-sized (`kenBurns` zooms in): draw generated images at 1920×1080 for a 1280×720 movie.
790
792
 
793
+ **A plain cut instead of a transition, generated images, music with no file:** scenes are layers one after the other (`at` = `i × L`) with no `transitions`; the images are the assets `p1…p3` (draw them with `placeholderPhoto`, recipe `placeholder-images`). The tune is written as text: at 96 bpm a beat is 0.625 s, so 24 beats are 15 s and every track has to add up to 24 (the check's `music` read-back shows each track's length).
794
+
795
+ ```js
796
+ // @recipe slideshow-cuts
797
+ const N = 3, L = 5, total = N * L;
798
+ const sequences = Array.from({ length: N }, (_, i) =>
799
+ kenBurns({ asset: 'p' + (i + 1), name: 'scene' + i, at: i * L, duration: L, motion: i % 2 ? 'position' : 'scale' }));
800
+ sequences.push({ type: 'audio', at: 0, volume: 0.7, duration: total, music: {
801
+ bpm: 96,
802
+ tracks: [
803
+ { inst: 'pad', notes: 'Am:4 F:4 C:4 G:4 Am:4 F:4' }, // 6 chords x 4 beats = 24 beats = 15 s
804
+ { inst: 'bass', notes: 'a1:4 f1:4 c2:4 g1:4 a1:4 f1:4' },
805
+ ],
806
+ } });
807
+ return { sequences, duration: total };
808
+ ```
809
+
791
810
  ---
792
811
 
793
812
  ## Music with no file (`music`)
@@ -1088,7 +1107,7 @@ return { duration: d.duration, sequences: d.composition.sequences, transitions:
1088
1107
  ```
1089
1108
 
1090
1109
  ```js
1091
- // @docs-only
1110
+ // @docs-only deck-in-page
1092
1111
  // in the page: the deck is spread into init; the Presenter (or the Controller, which then shows the stops and a Present button) plays it
1093
1112
  import { Movie } from 'pixi-effects';
1094
1113
  import { deck, Presenter } from 'pixi-effects/present'; // (add "pixi-effects/present" to the import map)
@@ -1457,7 +1476,7 @@ return [{ type: 'composition', name: 'film', width: 'GW', height: 'GH', sequence
1457
1476
  Draw on a canvas and register `canvas.toDataURL()` as an asset (`data:` URLs work). Make the image at least canvas-sized (1920×1080 for a 1280×720 movie) so `kenBurns` zooms stay sharp.
1458
1477
 
1459
1478
  ```js
1460
- // @docs-only
1479
+ // @docs-only placeholder-images
1461
1480
  function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
1462
1481
  const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
1463
1482
  const g = c.getContext('2d');
@@ -1476,7 +1495,7 @@ function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
1476
1495
  `ctx.renderer` is a normal `WebGLRenderer`, so an environment map works; lights alone make `metalness: 1` look black. `three/addons` is not in the importmap: build the environment yourself. Size the layer to the area you want (it clips at its own rectangle) and pull the camera back (`z ≈ 5.4` for a radius-1 torus knot at fov 50).
1477
1496
 
1478
1497
  ```js
1479
- // @docs-only
1498
+ // @docs-only metallic-three
1480
1499
  // registerThree(); const THREE = await import('three');
1481
1500
  three({
1482
1501
  type: 'three', width: 'GW * 0.56', height: 'GH * 0.9', initial: { x: 'GW * 0.77', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
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.30.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.30.1. 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
 
@@ -190,6 +190,8 @@ const max = Math.max(...data.map(d => d[1]));
190
190
  const step = [1, 2, 2.5, 5, 10].map(m => m * 10 ** Math.floor(Math.log10(max / 4))).find(v => max / v <= 5) ?? 10;
191
191
  const NICE = Math.ceil(max / step) * step; // axis top, so the chart survives changed data
192
192
  const BASE = 600, CHART_H = 380, BAR_W = 90, GAP = 40, DUR = 0.9, EASE = 'power3.out';
193
+ const EACH = 0.12; // seconds between bars: 0.12 is a quick wave; EACH = DUR + 0.3 grows them one at a time
194
+ const PEAK_AT = Math.max(4, 0.4 + (data.length - 1) * EACH + DUR + 0.2); // the highlight and the callout come after the last bar has grown
193
195
  const X0 = (1280 - (data.length * BAR_W + (data.length - 1) * GAP)) / 2, X1 = X0 + data.length * BAR_W + (data.length - 1) * GAP;
194
196
  const sequences = [];
195
197
  for (let v = 0; v <= NICE; v += step) { // grid lines + tick labels
@@ -200,14 +202,14 @@ for (let v = 0; v <= NICE; v += step) { // grid lines + ti
200
202
  const best = data.findIndex(d => d[1] === max);
201
203
  data.forEach(([label, value], i) => {
202
204
  const x = X0 + i * (BAR_W + GAP) + BAR_W / 2;
203
- const at = 0.4 + i * 0.12;
205
+ const at = 0.4 + i * EACH;
204
206
  const h = CHART_H * value / NICE;
205
207
  sequences.push({
206
208
  type: 'shape', shape: 'rect', width: BAR_W, height: 0, cornerRadius: 8, anchorY: 1, at, colorSpace: 'oklab',
207
209
  initial: { x, y: BASE, fillColor: '#4f6df5' },
208
210
  keyframes: [
209
211
  { at: 0, to: { height: h }, duration: DUR, ease: EASE },
210
- ...(i === best ? [{ at: 4 - at, to: { fillColor: '#ffd166' }, duration: 0.5 }] : [{ at: 4 - at, to: { alpha: 0.45 }, duration: 0.5 }]), // highlight the maximum at t = 4 s
212
+ ...(i === best ? [{ at: PEAK_AT - at, to: { fillColor: '#ffd166' }, duration: 0.5 }] : [{ at: PEAK_AT - at, to: { alpha: 0.45 }, duration: 0.5 }]), // highlight the maximum at t = 4 s
211
213
  ],
212
214
  });
213
215
  sequences.push({ // value label: counts up while riding the bar's top
@@ -221,11 +223,11 @@ data.forEach(([label, value], i) => {
221
223
  // callout: a pill sized from its text (~0.58 x fontSize per glyph + padding), a pointer triangle, clamped inside the plot
222
224
  const text = 'Peak: ' + data[best][0] + ' ' + max, FS = 26;
223
225
  const PW = text.length * FS * 0.58 + 44, cx = Math.min(Math.max(X0 + best * (BAR_W + GAP) + BAR_W / 2, X0 + PW / 2), X1 - PW / 2), cy = BASE - CHART_H - 70;
224
- sequences.push({ type: 'shape', shape: 'rect', width: PW, height: 52, cornerRadius: 26, at: 4.2, initial: { x: cx, y: cy, fillColor: '#ffd166', alpha: 0 },
226
+ sequences.push({ type: 'shape', shape: 'rect', width: PW, height: 52, cornerRadius: 26, at: PEAK_AT + 0.2, initial: { x: cx, y: cy, fillColor: '#ffd166', alpha: 0 },
225
227
  keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
226
- sequences.push({ type: 'shape', shape: 'polygon', points: [[-10, 0], [10, 0], [0, 12]], at: 4.2, initial: { x: cx, y: cy + 32, fillColor: '#ffd166', alpha: 0 },
228
+ sequences.push({ type: 'shape', shape: 'polygon', points: [[-10, 0], [10, 0], [0, 12]], at: PEAK_AT + 0.2, initial: { x: cx, y: cy + 32, fillColor: '#ffd166', alpha: 0 },
227
229
  keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
228
- sequences.push({ type: 'text', text, at: 4.2, style: { fontSize: FS, fontWeight: 'bold', fill: '#1b1b2f' }, initial: { x: cx, y: cy, anchorX: 0.5, anchorY: 0.5, alpha: 0 },
230
+ sequences.push({ type: 'text', text, at: PEAK_AT + 0.2, style: { fontSize: FS, fontWeight: 'bold', fill: '#1b1b2f' }, initial: { x: cx, y: cy, anchorX: 0.5, anchorY: 0.5, alpha: 0 },
229
231
  keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
230
232
  return sequences;
231
233
  ```
@@ -356,6 +358,23 @@ return { sequences, transitions, duration: total };
356
358
 
357
359
  Source images should be at least canvas-sized (`kenBurns` zooms in): draw generated images at 1920×1080 for a 1280×720 movie.
358
360
 
361
+ **A plain cut instead of a transition, generated images, music with no file:** scenes are layers one after the other (`at` = `i × L`) with no `transitions`; the images are the assets `p1…p3` (draw them with `placeholderPhoto`, recipe `placeholder-images`). The tune is written as text: at 96 bpm a beat is 0.625 s, so 24 beats are 15 s and every track has to add up to 24 (the check's `music` read-back shows each track's length).
362
+
363
+ ```js
364
+ // @recipe slideshow-cuts
365
+ const N = 3, L = 5, total = N * L;
366
+ const sequences = Array.from({ length: N }, (_, i) =>
367
+ kenBurns({ asset: 'p' + (i + 1), name: 'scene' + i, at: i * L, duration: L, motion: i % 2 ? 'position' : 'scale' }));
368
+ sequences.push({ type: 'audio', at: 0, volume: 0.7, duration: total, music: {
369
+ bpm: 96,
370
+ tracks: [
371
+ { inst: 'pad', notes: 'Am:4 F:4 C:4 G:4 Am:4 F:4' }, // 6 chords x 4 beats = 24 beats = 15 s
372
+ { inst: 'bass', notes: 'a1:4 f1:4 c2:4 g1:4 a1:4 f1:4' },
373
+ ],
374
+ } });
375
+ return { sequences, duration: total };
376
+ ```
377
+
359
378
  ---
360
379
 
361
380
  ## Music with no file (`music`)
@@ -656,7 +675,7 @@ return { duration: d.duration, sequences: d.composition.sequences, transitions:
656
675
  ```
657
676
 
658
677
  ```js
659
- // @docs-only
678
+ // @docs-only deck-in-page
660
679
  // in the page: the deck is spread into init; the Presenter (or the Controller, which then shows the stops and a Present button) plays it
661
680
  import { Movie } from 'pixi-effects';
662
681
  import { deck, Presenter } from 'pixi-effects/present'; // (add "pixi-effects/present" to the import map)
@@ -1025,7 +1044,7 @@ return [{ type: 'composition', name: 'film', width: 'GW', height: 'GH', sequence
1025
1044
  Draw on a canvas and register `canvas.toDataURL()` as an asset (`data:` URLs work). Make the image at least canvas-sized (1920×1080 for a 1280×720 movie) so `kenBurns` zooms stay sharp.
1026
1045
 
1027
1046
  ```js
1028
- // @docs-only
1047
+ // @docs-only placeholder-images
1029
1048
  function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
1030
1049
  const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
1031
1050
  const g = c.getContext('2d');
@@ -1044,7 +1063,7 @@ function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
1044
1063
  `ctx.renderer` is a normal `WebGLRenderer`, so an environment map works; lights alone make `metalness: 1` look black. `three/addons` is not in the importmap: build the environment yourself. Size the layer to the area you want (it clips at its own rectangle) and pull the camera back (`z ≈ 5.4` for a radius-1 torus knot at fov 50).
1045
1064
 
1046
1065
  ```js
1047
- // @docs-only
1066
+ // @docs-only metallic-three
1048
1067
  // registerThree(); const THREE = await import('three');
1049
1068
  three({
1050
1069
  type: 'three', width: 'GW * 0.56', height: 'GH * 0.9', initial: { x: 'GW * 0.77', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
@@ -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.0/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.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 -->
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.0/dist/index.js",
41
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/Controller.js",
42
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/present.js",
43
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/three.js",
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",
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.0/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.30.1/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';