pixi-effects 0.4.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 +30 -1
  2. package/README.md +9 -5
  3. package/ai/SKILL.md +7 -4
  4. package/ai/reference/cheatsheet.md +16 -5
  5. package/ai/reference/pitfalls.md +15 -11
  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-5R7C5pHR.d.cts → Base-CPSFBkPm.d.ts} +6 -1
  10. package/dist/{Base-DhY9oMxr.d.ts → Base-DnRAxroQ.d.cts} +6 -1
  11. package/dist/Composition-63EXHNJJ.cjs +13 -0
  12. package/dist/{Composition-D7RCAGEA.cjs.map → Composition-63EXHNJJ.cjs.map} +1 -1
  13. package/dist/Composition-TUCRSMJU.js +4 -0
  14. package/dist/{Composition-RGTRZ2JX.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-DRZtIGU7.d.ts → Movie-CuU_aaCm.d.cts} +56 -2
  22. package/dist/{Movie-CghFXprP.d.cts → Movie-VyDiUYOj.d.ts} +56 -2
  23. package/dist/{chunk-QGBOC5YL.cjs → chunk-23M5QTWW.cjs} +33 -21
  24. package/dist/chunk-23M5QTWW.cjs.map +1 -0
  25. package/dist/{chunk-6BQ6IEW2.cjs → chunk-2RR43OIN.cjs} +143 -30
  26. package/dist/chunk-2RR43OIN.cjs.map +1 -0
  27. package/dist/{chunk-A574IA4F.js → chunk-5Q5XZG7Z.js} +18 -6
  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-ZQX7WIYL.js → chunk-UBPZLGLU.js} +143 -31
  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 +291 -86
  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 +275 -72
  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-CRE9WKd4.d.ts → types-7VtTEKUW.d.cts} +96 -6
  50. package/dist/{types-CRE9WKd4.d.cts → types-7VtTEKUW.d.ts} +96 -6
  51. package/docs/api.md +36 -0
  52. package/docs/dsl.md +44 -3
  53. package/llms-full.txt +234 -22
  54. package/llms.txt +1 -1
  55. package/package.json +2 -1
  56. package/dist/Composition-D7RCAGEA.cjs +0 -13
  57. package/dist/Composition-RGTRZ2JX.js +0 -4
  58. package/dist/chunk-6BQ6IEW2.cjs.map +0 -1
  59. package/dist/chunk-A574IA4F.js.map +0 -1
  60. package/dist/chunk-QGBOC5YL.cjs.map +0 -1
  61. package/dist/chunk-ZQX7WIYL.js.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-CghFXprP.cjs';
2
- import { S as Sequence } from './Base-5R7C5pHR.cjs';
3
- import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, b as SequenceCommon } from './types-CRE9WKd4.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-CRE9WKd4.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-DRZtIGU7.js';
2
- import { S as Sequence } from './Base-DhY9oMxr.js';
3
- import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, b as SequenceCommon } from './types-CRE9WKd4.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-CRE9WKd4.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-A574IA4F.js';
2
- import { homeDistance, DEFAULT_FOV, DEG, resolveAt, collectPropKeys } from './chunk-ZQX7WIYL.js';
3
- export { registerSequenceType } from './chunk-ZQX7WIYL.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);
@@ -926,7 +1119,8 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
926
1119
  const view = { x: 0, y: 0, width: canvas.width, height: canvas.height };
927
1120
  const seen = /* @__PURE__ */ new Map();
928
1121
  const scene = /* @__PURE__ */ new Map();
929
- const walk = (comp, prefix, insideThreeD, parentVisible, parentScene) => {
1122
+ const scaledToNothing = /* @__PURE__ */ new Set();
1123
+ const walk = (comp, prefix, insideThreeD, parentVisible, parentScene, parentMoving = false, parentZero = false) => {
930
1124
  const blending = /* @__PURE__ */ new Set();
931
1125
  for (const w of transitionWindowsOf(comp.spec)) {
932
1126
  const start = (comp.absoluteStart ?? 0) + w.start, end = (comp.absoluteStart ?? 0) + w.end;
@@ -955,7 +1149,9 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
955
1149
  if (area === 0) onCanvas = "none";
956
1150
  }
957
1151
  const keys = collectPropKeys(seq.spec);
958
- const moving = (seq.spec.keyframes ?? []).length > 0 && (keys.has("x") || keys.has("y"));
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);
959
1155
  const info = { path: prefix + label, name, type: seq.spec.type, threeD, visible, alpha, moving, bounds, onCanvas };
960
1156
  layers.push(info);
961
1157
  let shown = bounds ? clip(bounds, view) : null;
@@ -967,7 +1163,8 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
967
1163
  seen.set(info, shown);
968
1164
  const group = parentScene ?? (name !== void 0 && blending.has(name) ? name : void 0);
969
1165
  if (group) scene.set(info, group);
970
- if (seq instanceof CompositionSequence) walk(seq, prefix + label + "/", insideThreeD || threeD, visible, group);
1166
+ if (zero) scaledToNothing.add(info);
1167
+ if (seq instanceof CompositionSequence) walk(seq, prefix + label + "/", insideThreeD || threeD, visible, group, moving, zero);
971
1168
  });
972
1169
  };
973
1170
  walk(root, "", false, true);
@@ -977,6 +1174,7 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
977
1174
  const b = l.bounds;
978
1175
  const who = `text layer "${l.path}"`;
979
1176
  if (b.width < 1 || b.height < 1) {
1177
+ if (scaledToNothing.has(l)) continue;
980
1178
  issues.push(`${who} has no size (empty text, or not drawn yet)`);
981
1179
  } else if (l.moving || scene.has(l)) ; else if (l.onCanvas === "none") {
982
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)})`);
@@ -1026,64 +1224,6 @@ function sheetLayout(n, columns, cellW, srcW, srcH, labelH) {
1026
1224
  return { cols, rows, cellW, cellH, width: cols * cellW, height: rows * (cellH + labelH), positions };
1027
1225
  }
1028
1226
 
1029
- // src/core/options.ts
1030
- function distance(a, b) {
1031
- const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
1032
- for (let j = 1; j <= b.length; j++) dp[0][j] = j;
1033
- for (let i = 1; i <= a.length; i++) {
1034
- for (let j = 1; j <= b.length; j++) {
1035
- 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));
1036
- 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);
1037
- }
1038
- }
1039
- return dp[a.length][b.length];
1040
- }
1041
- var isSubsequence = (small, big) => {
1042
- let i = 0;
1043
- for (const ch of big) if (i < small.length && ch === small[i]) i++;
1044
- return i === small.length;
1045
- };
1046
- var COMMON_ALIASES = {
1047
- fps: "frameRate",
1048
- framerate: "frameRate",
1049
- frame_rate: "frameRate",
1050
- bg: "background",
1051
- backgroundcolor: "background",
1052
- bgcolor: "background",
1053
- seconds: "duration",
1054
- length: "duration",
1055
- time: "duration",
1056
- w: "width",
1057
- h: "height",
1058
- cols: "columns",
1059
- sweep: "degrees",
1060
- angle: "degrees"
1061
- };
1062
- function suggestName(name, valid) {
1063
- const n = name.toLowerCase();
1064
- const alias = COMMON_ALIASES[n];
1065
- if (alias && valid.includes(alias)) return alias;
1066
- let best = null;
1067
- for (const v of valid) {
1068
- const lv = v.toLowerCase();
1069
- const d = distance(n, lv);
1070
- const close = d <= Math.max(1, Math.floor(Math.min(n.length, lv.length) / 3));
1071
- const abbreviation = n.length >= 3 && isSubsequence(n, lv) || lv.length >= 2 && n.length > lv.length && n.includes(lv);
1072
- if (!close && !abbreviation) continue;
1073
- const score = close ? d : 3 + (lv.length - n.length) / 100;
1074
- if (!best || score < best.score) best = { v, score };
1075
- }
1076
- return best ? best.v : null;
1077
- }
1078
- function warnUnknownOptions(where, opts, valid) {
1079
- if (!opts || typeof opts !== "object") return;
1080
- for (const key of Object.keys(opts)) {
1081
- if (valid.includes(key)) continue;
1082
- const guess = suggestName(key, valid);
1083
- console.warn(`pixi-effects: ${where}: unknown option "${key}"${guess ? ` \u2014 did you mean "${guess}"?` : ""} (valid: ${valid.join(", ")})`);
1084
- }
1085
- }
1086
-
1087
1227
  // src/core/Movie.ts
1088
1228
  extensions.add(CullerPlugin);
1089
1229
  gsap.registerPlugin(PixiPlugin);
@@ -1110,6 +1250,9 @@ var Movie = class {
1110
1250
  _rootSequence = null;
1111
1251
  _rootContainer = null;
1112
1252
  _raf = null;
1253
+ /** What went into the mix, and its peak before limiting — kept for inspectAudio(). */
1254
+ _audioSources = [];
1255
+ _mixStats = null;
1113
1256
  on(event, fn) {
1114
1257
  (this._events[event] ??= []).push(fn);
1115
1258
  if (event === "ready" && this._initState === "ready") {
@@ -1204,7 +1347,9 @@ var Movie = class {
1204
1347
  composition.collectAudio(audios, 0);
1205
1348
  if (audios.length > 0) {
1206
1349
  this.audioBuffer = await mixdown(audios, this.duration, audioContext.sampleRate);
1350
+ if (this.audioBuffer) this._mixStats = limitMix(this.audioBuffer, audios);
1207
1351
  }
1352
+ this._audioSources = audios;
1208
1353
  this.timeline.progress(1).progress(0);
1209
1354
  await this._awaitVideoFrames();
1210
1355
  this._updateSpace();
@@ -1292,6 +1437,15 @@ var Movie = class {
1292
1437
  await this.gotoFrame(f, true);
1293
1438
  return inspectScene(this._rootSequence, f, f / this.frameRate, { width: this.width, height: this.height }, opts);
1294
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
+ }
1295
1449
  /**
1296
1450
  * Draw the stage now. PixiJS's CullerPlugin only culls inside `app.render()` (the ticker), and we render
1297
1451
  * by hand — while seeking and especially during `render()`, which stops the ticker — so without this the
@@ -1408,6 +1562,8 @@ var Movie = class {
1408
1562
  safeRun(() => this.app?.destroy(true, { children: true, texture: true }));
1409
1563
  this.app = null;
1410
1564
  this.audioBuffer = null;
1565
+ this._audioSources = [];
1566
+ this._mixStats = null;
1411
1567
  safeRun(() => this._audioContext?.close().catch(() => {
1412
1568
  }));
1413
1569
  this._audioContext = null;
@@ -1435,6 +1591,53 @@ function collectVideoSequences(seq, out) {
1435
1591
  collectVideoSequences(child, out);
1436
1592
  }
1437
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
+ }
1438
1641
 
1439
1642
  // src/presets/kenBurns.ts
1440
1643
  var KEN_BURNS_KEYS = ["asset", "name", "at", "duration", "fit", "ease", "motion", "origin", "zoom", "direction", "angle", "from", "to"];
@@ -1628,6 +1831,6 @@ function withFade(spec, opts) {
1628
1831
  return spec;
1629
1832
  }
1630
1833
 
1631
- export { Movie, kenBurns, orbit, withFade };
1834
+ export { Movie, kenBurns, measureText, orbit, splitText, withFade };
1632
1835
  //# sourceMappingURL=index.js.map
1633
1836
  //# sourceMappingURL=index.js.map