pixi-effects 0.3.0 → 0.5.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 (61) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +10 -6
  3. package/ai/SKILL.md +9 -4
  4. package/ai/reference/cheatsheet.md +19 -7
  5. package/ai/reference/pitfalls.md +32 -7
  6. package/ai/reference/recipes.md +117 -0
  7. package/ai/template.html +6 -3
  8. package/ai/tools/check.mjs +385 -0
  9. package/dist/{Base-BDw6dKRB.d.cts → Base-CPSFBkPm.d.ts} +12 -1
  10. package/dist/{Base-Ba4Ta4ap.d.ts → Base-DnRAxroQ.d.cts} +12 -1
  11. package/dist/Composition-63EXHNJJ.cjs +13 -0
  12. package/dist/{Composition-6FDH5OPM.cjs.map → Composition-63EXHNJJ.cjs.map} +1 -1
  13. package/dist/Composition-TUCRSMJU.js +4 -0
  14. package/dist/{Composition-FEYAUMFI.js.map → Composition-TUCRSMJU.js.map} +1 -1
  15. package/dist/Controller.cjs +37 -13
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +4 -2
  18. package/dist/Controller.d.ts +4 -2
  19. package/dist/Controller.js +37 -13
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-W8ISiEBB.d.cts → Movie-CuU_aaCm.d.cts} +56 -2
  22. package/dist/{Movie-DMpaT67V.d.ts → Movie-VyDiUYOj.d.ts} +56 -2
  23. package/dist/{chunk-ZL262ZRM.cjs → chunk-23M5QTWW.cjs} +70 -20
  24. package/dist/chunk-23M5QTWW.cjs.map +1 -0
  25. package/dist/{chunk-SIRVULFF.cjs → chunk-2RR43OIN.cjs} +256 -60
  26. package/dist/chunk-2RR43OIN.cjs.map +1 -0
  27. package/dist/{chunk-DIJG2RSF.js → chunk-5Q5XZG7Z.js} +59 -9
  28. package/dist/chunk-5Q5XZG7Z.js.map +1 -0
  29. package/dist/chunk-DKGG7WD4.js +61 -0
  30. package/dist/chunk-DKGG7WD4.js.map +1 -0
  31. package/dist/{chunk-PN5A6QA7.js → chunk-UBPZLGLU.js} +255 -61
  32. package/dist/chunk-UBPZLGLU.js.map +1 -0
  33. package/dist/chunk-UQBGW5DO.cjs +64 -0
  34. package/dist/chunk-UQBGW5DO.cjs.map +1 -0
  35. package/dist/index.cjs +336 -89
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +54 -5
  38. package/dist/index.d.ts +54 -5
  39. package/dist/index.js +320 -75
  40. package/dist/index.js.map +1 -1
  41. package/dist/sfx-MZJZXYY3.cjs +446 -0
  42. package/dist/sfx-MZJZXYY3.cjs.map +1 -0
  43. package/dist/sfx-S37DDC66.js +434 -0
  44. package/dist/sfx-S37DDC66.js.map +1 -0
  45. package/dist/three.cjs +5 -5
  46. package/dist/three.d.cts +2 -2
  47. package/dist/three.d.ts +2 -2
  48. package/dist/three.js +1 -1
  49. package/dist/{types-3c8Vymgw.d.ts → types-7VtTEKUW.d.cts} +102 -6
  50. package/dist/{types-3c8Vymgw.d.cts → types-7VtTEKUW.d.ts} +102 -6
  51. package/docs/api.md +36 -0
  52. package/docs/dsl.md +56 -4
  53. package/llms-full.txt +268 -21
  54. package/llms.txt +1 -1
  55. package/package.json +3 -1
  56. package/dist/Composition-6FDH5OPM.cjs +0 -13
  57. package/dist/Composition-FEYAUMFI.js +0 -4
  58. package/dist/chunk-DIJG2RSF.js.map +0 -1
  59. package/dist/chunk-PN5A6QA7.js.map +0 -1
  60. package/dist/chunk-SIRVULFF.cjs.map +0 -1
  61. package/dist/chunk-ZL262ZRM.cjs.map +0 -1
package/dist/index.d.cts CHANGED
@@ -1,10 +1,59 @@
1
- export { C as ContactSheetOptions, F as FrameEvent, I as InspectOptions, a as InspectReport, L as LayerInfo, M as Movie, b as MovieOptions, P as ProgressEvent, R as RenderOptions, S as SnapshotOptions } from './Movie-W8ISiEBB.cjs';
2
- import { S as Sequence } from './Base-BDw6dKRB.cjs';
3
- import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, b as SequenceCommon } from './types-3c8Vymgw.cjs';
4
- export { A as AssetSpec, c as AudioSequenceSpec, d as ChromaKeyFilterSpec, e as CircleShapeSpec, f as CompositionSequenceSpec, g as CompositionSpec, h as CrossfadeTransition, i as CustomFilterSpec, D as DipTransition, j as DissolveTransition, E as EllipseShapeSpec, k as Expr, F as FilterSpec, G as GradientSpec, l as IrisTransition, K as Keyframe, L as LineShapeSpec, P as PathShapeSpec, m as PolygonShapeSpec, n as PropValue, o as Props, R as RectShapeSpec, p as ShapeSequenceSpec, q as SlideTransition, T as TextSequenceSpec, r as TransitionCommon, s as TransitionSpec, V as VideoSequenceSpec, W as WipeTransition, Z as ZoomTransition } from './types-3c8Vymgw.cjs';
1
+ export { A as AudioInspectOptions, a as AudioReport, b as AudioSourceReport, C as ContactSheetOptions, F as FrameEvent, I as InspectOptions, c as InspectReport, L as LayerInfo, M as Movie, d as MovieOptions, P as ProgressEvent, R as RenderOptions, S as SnapshotOptions, e as SoundMeasure } from './Movie-CuU_aaCm.cjs';
2
+ import { S as Sequence } from './Base-DnRAxroQ.cjs';
3
+ import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, b as SequenceCommon } from './types-7VtTEKUW.cjs';
4
+ export { A as ArcShapeSpec, c as AssetSpec, d as AudioAssetSpec, e as AudioSequenceSpec, f as AudioSfxSpec, g as ChromaKeyFilterSpec, h as CircleShapeSpec, i as CompositionSequenceSpec, j as CompositionSpec, k as CrossfadeTransition, l as CustomFilterSpec, D as DipTransition, m as DissolveTransition, E as EllipseShapeSpec, n as Expr, F as FilterSpec, G as GradientSpec, o as IrisTransition, K as Keyframe, L as LineShapeSpec, P as PathShapeSpec, p as PolygonShapeSpec, q as PropValue, r as Props, R as RectShapeSpec, s as SfxKnobs, t as SfxOptions, u as SfxPreset, v as SfxVoice, w as ShapeSequenceSpec, x as SlideTransition, T as TextSequenceSpec, y as TransitionCommon, z as TransitionSpec, V as VideoSequenceSpec, W as WipeTransition, Z as ZoomTransition } from './types-7VtTEKUW.cjs';
5
5
  import 'pixi.js';
6
6
  import 'gsap';
7
7
 
8
+ /** A text style as written in a `text` layer's `style` (numbers, not expressions, for the fields that size the text). */
9
+ type MeasureStyle = Record<string, unknown>;
10
+ interface TextSize {
11
+ width: number;
12
+ height: number;
13
+ /** Number of lines. */
14
+ lines: number;
15
+ }
16
+ interface SplitOptions {
17
+ /** What a piece is: one visible character (default), one word, or one line. */
18
+ by?: 'chars' | 'words' | 'lines';
19
+ /** Where the text goes: `x` is its left edge, or its centre / right edge with `align`. Default 0. */
20
+ x?: number;
21
+ /** Top of the first line. Default 0. */
22
+ y?: number;
23
+ /** How each line sits on `x`. Default `'left'`. */
24
+ align?: 'left' | 'center' | 'right';
25
+ }
26
+ interface TextPiece {
27
+ text: string;
28
+ /** Left edge of the piece, for a text layer with `anchorX: 0` (the default for text). */
29
+ x: number;
30
+ /** Top of the piece's line, for a text layer with `anchorY: 0`. */
31
+ y: number;
32
+ width: number;
33
+ /** Position in the returned list, 0-based. */
34
+ index: number;
35
+ /** Which line (0-based) the piece is on. */
36
+ line: number;
37
+ }
38
+ /**
39
+ * How big `text` is drawn with `style`: the same numbers a text layer with that style has, measured with the font the
40
+ * renderer will use. A web font must be loaded first (`await document.fonts.load('24px "Name"')`), or the width is a
41
+ * fallback font's.
42
+ */
43
+ declare function measureText(text: string, style?: MeasureStyle): TextSize;
44
+ /**
45
+ * Cut `text` into characters, words or lines and give each piece the x / y it needs so that one text layer per piece
46
+ * (same `style`, `anchorX: 0`, `anchorY: 0`) reads exactly like the whole text: per-letter and per-word animation without
47
+ * measuring anything by hand. Kerning is kept (a piece's x is the measured width of everything before it). Whitespace is
48
+ * not returned as a piece, but it takes its room. Lines are split on `\n` and go down by the style's line height.
49
+ *
50
+ * ```js
51
+ * splitText('Hello world', style, { by: 'words', x: 640, y: 300, align: 'center' })
52
+ * .map(p => ({ type: 'text', text: p.text, style, initial: { x: p.x, y: p.y, alpha: 0 }, at: 0.2 * p.index, ... }))
53
+ * ```
54
+ */
55
+ declare function splitText(text: string, style?: MeasureStyle, opts?: SplitOptions): TextPiece[];
56
+
8
57
  type SequenceCtor = {
9
58
  new (spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape): Sequence;
10
59
  };
@@ -147,4 +196,4 @@ interface WithFadeOptions {
147
196
  */
148
197
  declare function withFade<T extends SequenceCommon>(spec: T, opts: WithFadeOptions): T;
149
198
 
150
- export { CameraSequenceSpec, ImageSequenceSpec, type KenBurnsOptions, type OrbitOptions, SequenceCommon, type SequenceCtor, SequenceSpec, type WithFadeOptions, kenBurns, orbit, registerSequenceType, withFade };
199
+ export { CameraSequenceSpec, ImageSequenceSpec, type KenBurnsOptions, type MeasureStyle, type OrbitOptions, SequenceCommon, type SequenceCtor, SequenceSpec, type SplitOptions, type TextPiece, type TextSize, type WithFadeOptions, kenBurns, measureText, orbit, registerSequenceType, splitText, withFade };
package/dist/index.d.ts CHANGED
@@ -1,10 +1,59 @@
1
- export { C as ContactSheetOptions, F as FrameEvent, I as InspectOptions, a as InspectReport, L as LayerInfo, M as Movie, b as MovieOptions, P as ProgressEvent, R as RenderOptions, S as SnapshotOptions } from './Movie-DMpaT67V.js';
2
- import { S as Sequence } from './Base-Ba4Ta4ap.js';
3
- import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, b as SequenceCommon } from './types-3c8Vymgw.js';
4
- export { A as AssetSpec, c as AudioSequenceSpec, d as ChromaKeyFilterSpec, e as CircleShapeSpec, f as CompositionSequenceSpec, g as CompositionSpec, h as CrossfadeTransition, i as CustomFilterSpec, D as DipTransition, j as DissolveTransition, E as EllipseShapeSpec, k as Expr, F as FilterSpec, G as GradientSpec, l as IrisTransition, K as Keyframe, L as LineShapeSpec, P as PathShapeSpec, m as PolygonShapeSpec, n as PropValue, o as Props, R as RectShapeSpec, p as ShapeSequenceSpec, q as SlideTransition, T as TextSequenceSpec, r as TransitionCommon, s as TransitionSpec, V as VideoSequenceSpec, W as WipeTransition, Z as ZoomTransition } from './types-3c8Vymgw.js';
1
+ export { A as AudioInspectOptions, a as AudioReport, b as AudioSourceReport, C as ContactSheetOptions, F as FrameEvent, I as InspectOptions, c as InspectReport, L as LayerInfo, M as Movie, d as MovieOptions, P as ProgressEvent, R as RenderOptions, S as SnapshotOptions, e as SoundMeasure } from './Movie-VyDiUYOj.js';
2
+ import { S as Sequence } from './Base-CPSFBkPm.js';
3
+ import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, b as SequenceCommon } from './types-7VtTEKUW.js';
4
+ export { A as ArcShapeSpec, c as AssetSpec, d as AudioAssetSpec, e as AudioSequenceSpec, f as AudioSfxSpec, g as ChromaKeyFilterSpec, h as CircleShapeSpec, i as CompositionSequenceSpec, j as CompositionSpec, k as CrossfadeTransition, l as CustomFilterSpec, D as DipTransition, m as DissolveTransition, E as EllipseShapeSpec, n as Expr, F as FilterSpec, G as GradientSpec, o as IrisTransition, K as Keyframe, L as LineShapeSpec, P as PathShapeSpec, p as PolygonShapeSpec, q as PropValue, r as Props, R as RectShapeSpec, s as SfxKnobs, t as SfxOptions, u as SfxPreset, v as SfxVoice, w as ShapeSequenceSpec, x as SlideTransition, T as TextSequenceSpec, y as TransitionCommon, z as TransitionSpec, V as VideoSequenceSpec, W as WipeTransition, Z as ZoomTransition } from './types-7VtTEKUW.js';
5
5
  import 'pixi.js';
6
6
  import 'gsap';
7
7
 
8
+ /** A text style as written in a `text` layer's `style` (numbers, not expressions, for the fields that size the text). */
9
+ type MeasureStyle = Record<string, unknown>;
10
+ interface TextSize {
11
+ width: number;
12
+ height: number;
13
+ /** Number of lines. */
14
+ lines: number;
15
+ }
16
+ interface SplitOptions {
17
+ /** What a piece is: one visible character (default), one word, or one line. */
18
+ by?: 'chars' | 'words' | 'lines';
19
+ /** Where the text goes: `x` is its left edge, or its centre / right edge with `align`. Default 0. */
20
+ x?: number;
21
+ /** Top of the first line. Default 0. */
22
+ y?: number;
23
+ /** How each line sits on `x`. Default `'left'`. */
24
+ align?: 'left' | 'center' | 'right';
25
+ }
26
+ interface TextPiece {
27
+ text: string;
28
+ /** Left edge of the piece, for a text layer with `anchorX: 0` (the default for text). */
29
+ x: number;
30
+ /** Top of the piece's line, for a text layer with `anchorY: 0`. */
31
+ y: number;
32
+ width: number;
33
+ /** Position in the returned list, 0-based. */
34
+ index: number;
35
+ /** Which line (0-based) the piece is on. */
36
+ line: number;
37
+ }
38
+ /**
39
+ * How big `text` is drawn with `style`: the same numbers a text layer with that style has, measured with the font the
40
+ * renderer will use. A web font must be loaded first (`await document.fonts.load('24px "Name"')`), or the width is a
41
+ * fallback font's.
42
+ */
43
+ declare function measureText(text: string, style?: MeasureStyle): TextSize;
44
+ /**
45
+ * Cut `text` into characters, words or lines and give each piece the x / y it needs so that one text layer per piece
46
+ * (same `style`, `anchorX: 0`, `anchorY: 0`) reads exactly like the whole text: per-letter and per-word animation without
47
+ * measuring anything by hand. Kerning is kept (a piece's x is the measured width of everything before it). Whitespace is
48
+ * not returned as a piece, but it takes its room. Lines are split on `\n` and go down by the style's line height.
49
+ *
50
+ * ```js
51
+ * splitText('Hello world', style, { by: 'words', x: 640, y: 300, align: 'center' })
52
+ * .map(p => ({ type: 'text', text: p.text, style, initial: { x: p.x, y: p.y, alpha: 0 }, at: 0.2 * p.index, ... }))
53
+ * ```
54
+ */
55
+ declare function splitText(text: string, style?: MeasureStyle, opts?: SplitOptions): TextPiece[];
56
+
8
57
  type SequenceCtor = {
9
58
  new (spec: SequenceSpec, parent: CompositionShape | null, root: CompositionShape): Sequence;
10
59
  };
@@ -147,4 +196,4 @@ interface WithFadeOptions {
147
196
  */
148
197
  declare function withFade<T extends SequenceCommon>(spec: T, opts: WithFadeOptions): T;
149
198
 
150
- export { CameraSequenceSpec, ImageSequenceSpec, type KenBurnsOptions, type OrbitOptions, SequenceCommon, type SequenceCtor, SequenceSpec, type WithFadeOptions, kenBurns, orbit, registerSequenceType, withFade };
199
+ export { CameraSequenceSpec, ImageSequenceSpec, type KenBurnsOptions, type MeasureStyle, type OrbitOptions, SequenceCommon, type SequenceCtor, SequenceSpec, type SplitOptions, type TextPiece, type TextSize, type WithFadeOptions, kenBurns, measureText, orbit, registerSequenceType, splitText, withFade };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- import { CompositionSequence } from './chunk-DIJG2RSF.js';
2
- import { homeDistance, DEFAULT_FOV, DEG, resolveAt, collectPropKeys } from './chunk-PN5A6QA7.js';
3
- export { registerSequenceType } from './chunk-PN5A6QA7.js';
1
+ import { warnUnknownOptions, suggestName } from './chunk-DKGG7WD4.js';
2
+ import { CompositionSequence } from './chunk-5Q5XZG7Z.js';
3
+ import { homeDistance, DEFAULT_FOV, DEG, resolveAt, collectPropKeys } from './chunk-UBPZLGLU.js';
4
+ export { registerSequenceType } from './chunk-UBPZLGLU.js';
4
5
  import * as PIXI from 'pixi.js';
5
- import { extensions, CullerPlugin, Application, Container, Rectangle, Culler, Assets, ExtensionType, Filter, UniformGroup, GpuProgram, GlProgram, defaultFilterVert } from 'pixi.js';
6
+ import { extensions, CullerPlugin, Application, Container, Rectangle, Culler, CanvasTextMetrics, TextStyle, Assets, ExtensionType, Filter, UniformGroup, GpuProgram, GlProgram, defaultFilterVert } from 'pixi.js';
6
7
  import { gsap } from 'gsap';
7
8
  import { PixiPlugin } from 'gsap/PixiPlugin';
8
9
  import { QUALITY_VERY_HIGH, QUALITY_HIGH, QUALITY_MEDIUM, QUALITY_LOW, QUALITY_VERY_LOW, Output, BufferTarget, CanvasSource, AudioBufferSource, Input, ALL_FORMATS, BlobSource, WavOutputFormat, Conversion, VideoSampleSink, MkvOutputFormat, WebMOutputFormat, MovOutputFormat, Mp4OutputFormat } from 'mediabunny';
@@ -68,21 +69,213 @@ async function loadAssetBundle(assets, audioContext) {
68
69
  async function mixdown(audios, totalDuration, sampleRate = 44100) {
69
70
  if (audios.length === 0) return null;
70
71
  const ctx = new OfflineAudioContext(2, Math.ceil(sampleRate * totalDuration), sampleRate);
72
+ const synthesised = /* @__PURE__ */ new Map();
73
+ const bufferOf = (a) => {
74
+ if (a.buffer) return a.buffer;
75
+ if (!a.synth) return null;
76
+ let buf = synthesised.get(a.synth.key);
77
+ if (!buf) {
78
+ const [left, right] = a.synth.render(sampleRate);
79
+ buf = ctx.createBuffer(2, left.length, sampleRate);
80
+ buf.getChannelData(0).set(left);
81
+ buf.getChannelData(1).set(right);
82
+ synthesised.set(a.synth.key, buf);
83
+ }
84
+ return buf;
85
+ };
71
86
  for (const a of audios) {
87
+ const buffer = bufferOf(a);
88
+ if (!buffer) continue;
89
+ const begin = Math.max(0, a.start);
90
+ const skipped = begin - a.start;
91
+ if (skipped > 0) {
92
+ console.warn(
93
+ `pixi-effects: ${a.layer ?? "an audio layer"} starts at ${a.start.toFixed(2)}s, before the movie starts, so its first ${skipped.toFixed(2)}s are cut off. Start it at 0 or later.`
94
+ );
95
+ }
72
96
  const src = ctx.createBufferSource();
73
- src.buffer = a.buffer;
97
+ src.buffer = buffer;
74
98
  src.loop = !!a.loop;
75
99
  const gain = ctx.createGain();
76
- gain.gain.setValueAtTime(a.initialVolume ?? 1, a.start);
100
+ gain.gain.setValueAtTime(a.initialVolume ?? 1, begin);
77
101
  for (const kf of a.volumeKeyframes ?? []) {
78
- gain.gain.linearRampToValueAtTime(kf.value, kf.time);
102
+ gain.gain.linearRampToValueAtTime(kf.value, Math.max(0, kf.time));
79
103
  }
80
104
  src.connect(gain).connect(ctx.destination);
81
- src.start(a.start);
82
- src.stop(a.end);
105
+ src.start(begin, skipped);
106
+ src.stop(Math.max(begin, a.end));
83
107
  }
84
108
  return await ctx.startRendering();
85
109
  }
110
+ var KNEE = 0.9;
111
+ function limitMix(buffer, audios) {
112
+ let peak = 0;
113
+ let peakIndex = 0;
114
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
115
+ const d = buffer.getChannelData(ch);
116
+ for (let i = 0; i < d.length; i++) {
117
+ const x = Math.abs(d[i]);
118
+ if (x > peak) {
119
+ peak = x;
120
+ peakIndex = i;
121
+ }
122
+ if (x > KNEE) d[i] = Math.sign(d[i]) * (KNEE + (1 - KNEE) * Math.tanh((x - KNEE) / (1 - KNEE)));
123
+ }
124
+ }
125
+ const peakAt = peakIndex / buffer.sampleRate;
126
+ if (peak > 1) {
127
+ const playing = audios.filter((a) => a.start <= peakAt && peakAt < a.end).map((a) => a.layer ?? "an audio layer");
128
+ console.warn(
129
+ `pixi-effects: the audio mix peaks at ${peak.toFixed(2)} (${(20 * Math.log10(peak)).toFixed(1)} dBFS) at ${peakAt.toFixed(2)}s, so it was limited (it would distort). Playing there: ${[...new Set(playing)].join(", ")}. Lower their volume.`
130
+ );
131
+ }
132
+ return { peak, peakAt };
133
+ }
134
+
135
+ // src/core/spectrum.ts
136
+ function fft(re, im) {
137
+ const n = re.length;
138
+ for (let i = 1, j = 0; i < n; i++) {
139
+ let bit = n >> 1;
140
+ for (; j & bit; bit >>= 1) j ^= bit;
141
+ j ^= bit;
142
+ if (i < j) {
143
+ [re[i], re[j]] = [re[j], re[i]];
144
+ [im[i], im[j]] = [im[j], im[i]];
145
+ }
146
+ }
147
+ for (let len = 2; len <= n; len <<= 1) {
148
+ const ang = -2 * Math.PI / len;
149
+ for (let i = 0; i < n; i += len) {
150
+ for (let k = 0; k < len / 2; k++) {
151
+ const wr = Math.cos(ang * k), wi = Math.sin(ang * k);
152
+ const a = i + k, b = a + len / 2;
153
+ const xr = re[b] * wr - im[b] * wi, xi = re[b] * wi + im[b] * wr;
154
+ re[b] = re[a] - xr;
155
+ im[b] = im[a] - xi;
156
+ re[a] = re[a] + xr;
157
+ im[a] = im[a] + xi;
158
+ }
159
+ }
160
+ }
161
+ }
162
+ var FRAME = 2048;
163
+ function spectralCentroid(samples, sampleRate, from = 0, to = samples.length) {
164
+ let num = 0;
165
+ let den = 0;
166
+ const re = new Float64Array(FRAME);
167
+ const im = new Float64Array(FRAME);
168
+ const end = Math.min(to, samples.length);
169
+ for (let start = Math.max(0, from); start < end; start += FRAME / 2) {
170
+ for (let i = 0; i < FRAME; i++) {
171
+ const k = start + i;
172
+ const w = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (FRAME - 1));
173
+ re[i] = k < end ? samples[k] * w : 0;
174
+ im[i] = 0;
175
+ }
176
+ fft(re, im);
177
+ for (let b = 1; b < FRAME / 2; b++) {
178
+ const power = re[b] * re[b] + im[b] * im[b];
179
+ num += power * (b * sampleRate / FRAME);
180
+ den += power;
181
+ }
182
+ }
183
+ return den > 1e-9 ? Math.round(num / den / 10) * 10 : 0;
184
+ }
185
+
186
+ // src/core/inspectAudio.ts
187
+ var INAUDIBLE_DB = -50;
188
+ var db = (x) => Math.round(10 * Math.max(-120, 20 * Math.log10(x))) / 10;
189
+ var mono = (chans) => {
190
+ if (chans.length === 1) return chans[0];
191
+ const out = new Float32Array(chans[0].length);
192
+ for (const d of chans) for (let i = 0; i < out.length; i++) out[i] = out[i] + d[i] / chans.length;
193
+ return out;
194
+ };
195
+ function measure(chans, sampleRate) {
196
+ let peak = 0;
197
+ let at = 0;
198
+ for (const d of chans) for (let i = 0; i < d.length; i++) if (Math.abs(d[i]) > peak) {
199
+ peak = Math.abs(d[i]);
200
+ at = i;
201
+ }
202
+ return {
203
+ length: Math.round(chans[0].length / sampleRate * 1e3) / 1e3,
204
+ peakDb: db(peak),
205
+ loudestAt: Math.round(at / sampleRate * 1e3) / 1e3,
206
+ brightnessHz: spectralCentroid(mono(chans), sampleRate)
207
+ };
208
+ }
209
+ function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
210
+ if (!mix) {
211
+ return { duration: movieDuration, sampleRate: 0, peakDb: -120, peakAt: 0, sources: [], windows: [], issues: ['no audio: the movie has no audio layers (sound effects need no files: { type: "audio", sfx: "pop", at: 1 })'] };
212
+ }
213
+ const sr = mix.sampleRate;
214
+ const chans = Array.from({ length: mix.numberOfChannels }, (_, c) => mix.getChannelData(c));
215
+ const mixMono = mono(chans);
216
+ const peakIn = (t0, t1) => {
217
+ let p = 0;
218
+ const i1 = Math.min(mix.length, Math.ceil(t1 * sr));
219
+ for (const d of chans) for (let i = Math.max(0, Math.floor(t0 * sr)); i < i1; i++) p = Math.max(p, Math.abs(d[i]));
220
+ return p;
221
+ };
222
+ const win = opts.window && opts.window > 0 ? Math.max(0.01, opts.window) : 0.1;
223
+ const windows2 = [];
224
+ for (let t = 0; t < movieDuration - 1e-9; t += win) {
225
+ const i0 = Math.floor(t * sr);
226
+ const i1 = Math.min(mix.length, Math.floor((t + win) * sr));
227
+ let sum = 0;
228
+ let peak = 0;
229
+ for (const d of chans) {
230
+ for (let i = i0; i < i1; i++) {
231
+ sum += d[i] * d[i];
232
+ peak = Math.max(peak, Math.abs(d[i]));
233
+ }
234
+ }
235
+ const n = Math.max(1, (i1 - i0) * chans.length);
236
+ windows2.push({ t: Math.round(t * 1e3) / 1e3, rmsDb: db(Math.sqrt(sum / n)), peakDb: db(peak), brightnessHz: spectralCentroid(mixMono, sr, i0, i1) });
237
+ }
238
+ const issues = [];
239
+ if (stats && stats.peak > 1) {
240
+ issues.push(`the mix peaks at ${db(stats.peak)} dBFS at ${stats.peakAt.toFixed(2)}s and was limited \u2014 lower the volume of the layers playing there`);
241
+ }
242
+ const rendered = /* @__PURE__ */ new Map();
243
+ const soundOf = (s) => {
244
+ if (s.synth) {
245
+ let m = rendered.get(s.synth.key);
246
+ if (!m) rendered.set(s.synth.key, m = measure(s.synth.render(sr), sr));
247
+ return m;
248
+ }
249
+ const b = s.buffer;
250
+ return measure(Array.from({ length: b.numberOfChannels }, (_, c) => b.getChannelData(c)), b.sampleRate);
251
+ };
252
+ const list = [...sources].sort((a, b) => a.start - b.start).map((s) => {
253
+ const layer = s.layer ?? "an audio layer";
254
+ const source = s.source ?? "audio";
255
+ const peakDb = db(peakIn(s.start, Math.min(s.end, movieDuration)));
256
+ const sound = soundOf(s);
257
+ const topVolume = Math.max(s.initialVolume ?? 1, ...(s.volumeKeyframes ?? []).map((k) => k.value));
258
+ const ownDb = db(Math.pow(10, sound.peakDb / 20) * Math.max(0, topVolume));
259
+ if (s.start >= movieDuration) {
260
+ issues.push(`${layer} (${source}) starts at ${s.start.toFixed(2)}s, after the movie ends (${movieDuration}s)`);
261
+ } else if (ownDb < INAUDIBLE_DB || peakDb < INAUDIBLE_DB) {
262
+ issues.push(`${layer} (${source}) is inaudible from ${s.start.toFixed(2)}s to ${s.end.toFixed(2)}s (its own peak ${ownDb} dBFS at volume ${topVolume}; mix peak there ${peakDb} dBFS) \u2014 check its volume`);
263
+ }
264
+ if (s.synth && s.end > movieDuration + 1e-6 && s.start < movieDuration) {
265
+ issues.push(`${layer} (${source}) is cut off by the end of the movie at ${movieDuration}s (it runs to ${s.end.toFixed(2)}s)`);
266
+ }
267
+ return { layer, source, start: s.start, end: s.end, peakDb, sound };
268
+ });
269
+ return {
270
+ duration: movieDuration,
271
+ sampleRate: sr,
272
+ peakDb: db(stats?.peak ?? peakIn(0, movieDuration)),
273
+ peakAt: stats?.peakAt ?? 0,
274
+ sources: list,
275
+ windows: windows2,
276
+ issues
277
+ };
278
+ }
86
279
  var VIDEO_CODEC_BY_FORMAT = {
87
280
  mp4: "avc",
88
281
  mov: "avc",
@@ -140,7 +333,7 @@ async function exportFrames(movie, options = {}) {
140
333
  const audioSource = new AudioBufferSource({
141
334
  codec: opts.audio.codec,
142
335
  bitrate: opts.audio.bitrate
143
- });
336
+ }, { startTimestamp: opts.audio.codec === "aac" ? -2112 / movie.audioBuffer.sampleRate : 0 });
144
337
  output.addAudioTrack(audioSource);
145
338
  await output.start();
146
339
  await audioSource.add(movie.audioBuffer);
@@ -501,6 +694,15 @@ var TransitionMaskFilter = class extends Filter {
501
694
  };
502
695
 
503
696
  // src/core/Transitions.ts
697
+ var windows = /* @__PURE__ */ new WeakMap();
698
+ function transitionWindowsOf(spec) {
699
+ return windows.get(spec) ?? [];
700
+ }
701
+ function carryTransitionWindows(from, to) {
702
+ const w = windows.get(from);
703
+ if (w) windows.set(to, w);
704
+ return to;
705
+ }
504
706
  function expandTransitions(spec) {
505
707
  const out = {
506
708
  ...spec,
@@ -611,6 +813,9 @@ function expandTransitions(spec) {
611
813
  if (tStart < toStart || tEnd > toEnd) {
612
814
  throw new Error(`pixi-effects: ${tag} window [${tStart}, ${tEnd}] is not covered by \`to\` "${t.to}" (lives [${toStart}, ${toEnd}], so it starts at ${toStart})`);
613
815
  }
816
+ const record = windows.get(out) ?? [];
817
+ record.push({ from: t.from, to: t.to, start: tStart, end: tEnd });
818
+ windows.set(out, record);
614
819
  const abs = { ...t, at: tStart };
615
820
  const nFrom = keyframeCount(fromEntry.seq);
616
821
  const nTo = keyframeCount(toEntry.seq);
@@ -904,10 +1109,26 @@ var intersection = (a, b) => {
904
1109
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
905
1110
  return w > 0 && h > 0 ? w * h : 0;
906
1111
  };
1112
+ var clip = (a, b) => {
1113
+ const x = Math.max(a.x, b.x), y = Math.max(a.y, b.y);
1114
+ const w = Math.min(a.x + a.width, b.x + b.width) - x, h = Math.min(a.y + a.height, b.y + b.height) - y;
1115
+ return w > 0 && h > 0 ? { x, y, width: w, height: h } : null;
1116
+ };
907
1117
  function inspectScene(root, frame, time, canvas, opts = {}) {
908
1118
  const layers = [];
909
1119
  const view = { x: 0, y: 0, width: canvas.width, height: canvas.height };
910
- const walk = (comp, prefix, insideThreeD, parentVisible) => {
1120
+ const seen = /* @__PURE__ */ new Map();
1121
+ const scene = /* @__PURE__ */ new Map();
1122
+ const scaledToNothing = /* @__PURE__ */ new Set();
1123
+ const walk = (comp, prefix, insideThreeD, parentVisible, parentScene, parentMoving = false, parentZero = false) => {
1124
+ const blending = /* @__PURE__ */ new Set();
1125
+ for (const w of transitionWindowsOf(comp.spec)) {
1126
+ const start = (comp.absoluteStart ?? 0) + w.start, end = (comp.absoluteStart ?? 0) + w.end;
1127
+ if (time >= start - 1e-9 && time <= end + 1e-9) {
1128
+ blending.add(w.from);
1129
+ blending.add(w.to);
1130
+ }
1131
+ }
911
1132
  comp.layers().forEach(({ seq, display, threeD }, i) => {
912
1133
  const t = seq.target;
913
1134
  const name = seq.spec.name;
@@ -928,9 +1149,22 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
928
1149
  if (area === 0) onCanvas = "none";
929
1150
  }
930
1151
  const keys = collectPropKeys(seq.spec);
931
- const moving = (seq.spec.keyframes ?? []).length > 0 && (keys.has("x") || keys.has("y"));
932
- layers.push({ path: prefix + label, name, type: seq.spec.type, threeD, visible, alpha, moving, bounds, onCanvas });
933
- if (seq instanceof CompositionSequence) walk(seq, prefix + label + "/", insideThreeD || threeD, visible);
1152
+ const moving = parentMoving || (seq.spec.keyframes ?? []).length > 0 && (keys.has("x") || keys.has("y"));
1153
+ const scale = t?.scale;
1154
+ const zero = parentZero || !!scale && (scale.x === 0 || scale.y === 0);
1155
+ const info = { path: prefix + label, name, type: seq.spec.type, threeD, visible, alpha, moving, bounds, onCanvas };
1156
+ layers.push(info);
1157
+ let shown = bounds ? clip(bounds, view) : null;
1158
+ const maskTarget = seq.maskSequence?.target;
1159
+ if (shown && maskTarget && !seq.spec.maskInverted) {
1160
+ const m = maskTarget.getBounds();
1161
+ shown = clip(shown, { x: m.x, y: m.y, width: m.width, height: m.height });
1162
+ }
1163
+ seen.set(info, shown);
1164
+ const group = parentScene ?? (name !== void 0 && blending.has(name) ? name : void 0);
1165
+ if (group) scene.set(info, group);
1166
+ if (zero) scaledToNothing.add(info);
1167
+ if (seq instanceof CompositionSequence) walk(seq, prefix + label + "/", insideThreeD || threeD, visible, group, moving, zero);
934
1168
  });
935
1169
  };
936
1170
  walk(root, "", false, true);
@@ -940,8 +1174,9 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
940
1174
  const b = l.bounds;
941
1175
  const who = `text layer "${l.path}"`;
942
1176
  if (b.width < 1 || b.height < 1) {
1177
+ if (scaledToNothing.has(l)) continue;
943
1178
  issues.push(`${who} has no size (empty text, or not drawn yet)`);
944
- } else if (l.moving) ; else if (l.onCanvas === "none") {
1179
+ } else if (l.moving || scene.has(l)) ; else if (l.onCanvas === "none") {
945
1180
  issues.push(`${who} is entirely outside the canvas (at ${Math.round(b.x)},${Math.round(b.y)}, ${Math.round(b.width)}\xD7${Math.round(b.height)})`);
946
1181
  } else if (l.onCanvas === "partial") {
947
1182
  const over = [];
@@ -954,7 +1189,10 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
954
1189
  }
955
1190
  for (let i = 0; i < texts.length; i++) {
956
1191
  for (let j = i + 1; j < texts.length; j++) {
957
- const a = texts[i].bounds, b = texts[j].bounds;
1192
+ const a = seen.get(texts[i]), b = seen.get(texts[j]);
1193
+ if (!a || !b) continue;
1194
+ const sa = scene.get(texts[i]), sb = scene.get(texts[j]);
1195
+ if (sa && sb && sa !== sb) continue;
958
1196
  const smaller = Math.min(a.width * a.height, b.width * b.height);
959
1197
  if (smaller <= 0) continue;
960
1198
  const share = intersection(a, b) / smaller;
@@ -986,64 +1224,6 @@ function sheetLayout(n, columns, cellW, srcW, srcH, labelH) {
986
1224
  return { cols, rows, cellW, cellH, width: cols * cellW, height: rows * (cellH + labelH), positions };
987
1225
  }
988
1226
 
989
- // src/core/options.ts
990
- function distance(a, b) {
991
- const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
992
- for (let j = 1; j <= b.length; j++) dp[0][j] = j;
993
- for (let i = 1; i <= a.length; i++) {
994
- for (let j = 1; j <= b.length; j++) {
995
- dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
996
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) dp[i][j] = Math.min(dp[i][j], dp[i - 2][j - 2] + 1);
997
- }
998
- }
999
- return dp[a.length][b.length];
1000
- }
1001
- var isSubsequence = (small, big) => {
1002
- let i = 0;
1003
- for (const ch of big) if (i < small.length && ch === small[i]) i++;
1004
- return i === small.length;
1005
- };
1006
- var COMMON_ALIASES = {
1007
- fps: "frameRate",
1008
- framerate: "frameRate",
1009
- frame_rate: "frameRate",
1010
- bg: "background",
1011
- backgroundcolor: "background",
1012
- bgcolor: "background",
1013
- seconds: "duration",
1014
- length: "duration",
1015
- time: "duration",
1016
- w: "width",
1017
- h: "height",
1018
- cols: "columns",
1019
- sweep: "degrees",
1020
- angle: "degrees"
1021
- };
1022
- function suggestName(name, valid) {
1023
- const n = name.toLowerCase();
1024
- const alias = COMMON_ALIASES[n];
1025
- if (alias && valid.includes(alias)) return alias;
1026
- let best = null;
1027
- for (const v of valid) {
1028
- const lv = v.toLowerCase();
1029
- const d = distance(n, lv);
1030
- const close = d <= Math.max(1, Math.floor(Math.min(n.length, lv.length) / 3));
1031
- const abbreviation = n.length >= 3 && isSubsequence(n, lv) || lv.length >= 2 && n.length > lv.length && n.includes(lv);
1032
- if (!close && !abbreviation) continue;
1033
- const score = close ? d : 3 + (lv.length - n.length) / 100;
1034
- if (!best || score < best.score) best = { v, score };
1035
- }
1036
- return best ? best.v : null;
1037
- }
1038
- function warnUnknownOptions(where, opts, valid) {
1039
- if (!opts || typeof opts !== "object") return;
1040
- for (const key of Object.keys(opts)) {
1041
- if (valid.includes(key)) continue;
1042
- const guess = suggestName(key, valid);
1043
- console.warn(`pixi-effects: ${where}: unknown option "${key}"${guess ? ` \u2014 did you mean "${guess}"?` : ""} (valid: ${valid.join(", ")})`);
1044
- }
1045
- }
1046
-
1047
1227
  // src/core/Movie.ts
1048
1228
  extensions.add(CullerPlugin);
1049
1229
  gsap.registerPlugin(PixiPlugin);
@@ -1070,6 +1250,9 @@ var Movie = class {
1070
1250
  _rootSequence = null;
1071
1251
  _rootContainer = null;
1072
1252
  _raf = null;
1253
+ /** What went into the mix, and its peak before limiting — kept for inspectAudio(). */
1254
+ _audioSources = [];
1255
+ _mixStats = null;
1073
1256
  on(event, fn) {
1074
1257
  (this._events[event] ??= []).push(fn);
1075
1258
  if (event === "ready" && this._initState === "ready") {
@@ -1154,6 +1337,7 @@ var Movie = class {
1154
1337
  duration: this.duration,
1155
1338
  ...userComposition
1156
1339
  };
1340
+ if (userComposition) carryTransitionWindows(userComposition, rootSeqSpec);
1157
1341
  const composition = new CompositionSequence(rootSeqSpec, null, rootShape);
1158
1342
  await composition.build();
1159
1343
  this._rootSequence = composition;
@@ -1163,7 +1347,9 @@ var Movie = class {
1163
1347
  composition.collectAudio(audios, 0);
1164
1348
  if (audios.length > 0) {
1165
1349
  this.audioBuffer = await mixdown(audios, this.duration, audioContext.sampleRate);
1350
+ if (this.audioBuffer) this._mixStats = limitMix(this.audioBuffer, audios);
1166
1351
  }
1352
+ this._audioSources = audios;
1167
1353
  this.timeline.progress(1).progress(0);
1168
1354
  await this._awaitVideoFrames();
1169
1355
  this._updateSpace();
@@ -1251,6 +1437,15 @@ var Movie = class {
1251
1437
  await this.gotoFrame(f, true);
1252
1438
  return inspectScene(this._rootSequence, f, f / this.frameRate, { width: this.width, height: this.height }, opts);
1253
1439
  }
1440
+ /**
1441
+ * The soundtrack, measured: when every audio layer plays, how loud the mix is over time, and `issues`
1442
+ * (limiting, inaudible layers, sounds cut off by the end). Use it to CHECK sound you cannot hear.
1443
+ */
1444
+ inspectAudio(opts = {}) {
1445
+ warnUnknownOptions("movie.inspectAudio()", opts, ["window"]);
1446
+ this._requireReady("inspectAudio");
1447
+ return analyzeAudio(this.audioBuffer, this._audioSources, this._mixStats, this.duration, opts);
1448
+ }
1254
1449
  /**
1255
1450
  * Draw the stage now. PixiJS's CullerPlugin only culls inside `app.render()` (the ticker), and we render
1256
1451
  * by hand — while seeking and especially during `render()`, which stops the ticker — so without this the
@@ -1261,6 +1456,7 @@ var Movie = class {
1261
1456
  _renderNow() {
1262
1457
  const app = this.app;
1263
1458
  if (!app?.renderer) return;
1459
+ this._rootSequence?.syncFrame();
1264
1460
  Culler.shared.cull(app.stage, app.renderer.screen, false);
1265
1461
  app.renderer.render({ container: app.stage });
1266
1462
  }
@@ -1366,6 +1562,8 @@ var Movie = class {
1366
1562
  safeRun(() => this.app?.destroy(true, { children: true, texture: true }));
1367
1563
  this.app = null;
1368
1564
  this.audioBuffer = null;
1565
+ this._audioSources = [];
1566
+ this._mixStats = null;
1369
1567
  safeRun(() => this._audioContext?.close().catch(() => {
1370
1568
  }));
1371
1569
  this._audioContext = null;
@@ -1393,6 +1591,53 @@ function collectVideoSequences(seq, out) {
1393
1591
  collectVideoSequences(child, out);
1394
1592
  }
1395
1593
  }
1594
+ var BY = ["chars", "words", "lines"];
1595
+ var ALIGN = ["left", "center", "right"];
1596
+ var widthOf = (text, style) => CanvasTextMetrics.measureText(text, style).width;
1597
+ function measureText(text, style = {}) {
1598
+ const m = CanvasTextMetrics.measureText(text, new TextStyle(style));
1599
+ return { width: m.width, height: m.height, lines: text.split("\n").length };
1600
+ }
1601
+ function splitText(text, style = {}, opts = {}) {
1602
+ warnUnknownOptions("splitText()", opts, ["by", "x", "y", "align"]);
1603
+ let by = opts.by ?? "chars";
1604
+ if (!BY.includes(by)) {
1605
+ const g = suggestName(String(by), BY);
1606
+ console.warn(`pixi-effects: splitText(): by "${by}" is not one of ${BY.map((b) => `"${b}"`).join(", ")}${g ? ` \u2014 did you mean "${g}"?` : ""}; using "chars"`);
1607
+ by = "chars";
1608
+ }
1609
+ let align = opts.align ?? "left";
1610
+ if (!ALIGN.includes(align)) {
1611
+ const g = suggestName(String(align), ALIGN);
1612
+ console.warn(`pixi-effects: splitText(): align "${align}" is not one of ${ALIGN.join(", ")}${g ? ` \u2014 did you mean "${g}"?` : ""}; using "left"`);
1613
+ align = "left";
1614
+ }
1615
+ const x0 = opts.x ?? 0, y0 = opts.y ?? 0;
1616
+ if (text === "") return [];
1617
+ const ts = new TextStyle(style);
1618
+ const lineHeight = CanvasTextMetrics.measureText("M", ts).lineHeight ?? CanvasTextMetrics.measureText("M", ts).height;
1619
+ const sentinel = widthOf("|", ts);
1620
+ const out = [];
1621
+ text.split("\n").forEach((line, lineNo) => {
1622
+ const lineWidth = widthOf(line, ts);
1623
+ const left = align === "center" ? x0 - lineWidth / 2 : align === "right" ? x0 - lineWidth : x0;
1624
+ const y = y0 + lineNo * lineHeight;
1625
+ const advance = (upTo) => upTo === 0 ? 0 : widthOf(line.slice(0, upTo) + "|", ts) - sentinel;
1626
+ const spans = [];
1627
+ if (by === "lines") {
1628
+ if (line.trim() !== "") spans.push([0, line.length]);
1629
+ } else if (by === "words") {
1630
+ for (const m of line.matchAll(/\S+/g)) spans.push([m.index, m.index + m[0].length]);
1631
+ } else {
1632
+ for (let i = 0; i < line.length; i++) if (!/\s/.test(line[i])) spans.push([i, i + 1]);
1633
+ }
1634
+ for (const [a, b] of spans) {
1635
+ const piece = line.slice(a, b);
1636
+ out.push({ text: piece, x: left + advance(a), y, width: widthOf(piece, ts), index: out.length, line: lineNo });
1637
+ }
1638
+ });
1639
+ return out;
1640
+ }
1396
1641
 
1397
1642
  // src/presets/kenBurns.ts
1398
1643
  var KEN_BURNS_KEYS = ["asset", "name", "at", "duration", "fit", "ease", "motion", "origin", "zoom", "direction", "angle", "from", "to"];
@@ -1586,6 +1831,6 @@ function withFade(spec, opts) {
1586
1831
  return spec;
1587
1832
  }
1588
1833
 
1589
- export { Movie, kenBurns, orbit, withFade };
1834
+ export { Movie, kenBurns, measureText, orbit, splitText, withFade };
1590
1835
  //# sourceMappingURL=index.js.map
1591
1836
  //# sourceMappingURL=index.js.map