pixi-effects 0.16.3 → 0.18.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 (48) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +7 -7
  3. package/ai/CHAT.md +7 -0
  4. package/ai/SKILL.md +3 -2
  5. package/ai/chat-template.html +8 -4
  6. package/ai/reference/cheatsheet.md +6 -2
  7. package/ai/reference/pitfalls.md +3 -0
  8. package/ai/reference/recipes.md +67 -0
  9. package/ai/template.html +5 -5
  10. package/ai/tools/check.mjs +119 -59
  11. package/ai/tools/render.mjs +22 -7
  12. package/dist/Composition-6JMXWNVV.cjs +14 -0
  13. package/dist/{Composition-3WWNVRR5.cjs.map → Composition-6JMXWNVV.cjs.map} +1 -1
  14. package/dist/Composition-MLBHYYFF.js +5 -0
  15. package/dist/{Composition-VJWY2NDL.js.map → Composition-MLBHYYFF.js.map} +1 -1
  16. package/dist/Controller.d.cts +1 -1
  17. package/dist/Controller.d.ts +1 -1
  18. package/dist/{Movie-DilBC348.d.ts → Movie-DlHQ9C02.d.ts} +126 -2
  19. package/dist/{Movie-oA0SUAyb.d.cts → Movie-K2YuVAdg.d.cts} +126 -2
  20. package/dist/Presenter.d.cts +1 -1
  21. package/dist/Presenter.d.ts +1 -1
  22. package/dist/{chunk-FJA5VUOF.cjs → chunk-ANO5R2E6.cjs} +25 -3
  23. package/dist/chunk-ANO5R2E6.cjs.map +1 -0
  24. package/dist/{chunk-2ROVCPSL.cjs → chunk-JW67MDHQ.cjs} +32 -27
  25. package/dist/chunk-JW67MDHQ.cjs.map +1 -0
  26. package/dist/{chunk-ZNKQH356.js → chunk-SG3Y66FN.js} +25 -4
  27. package/dist/chunk-SG3Y66FN.js.map +1 -0
  28. package/dist/{chunk-XTIHKODH.js → chunk-UXDR3ZDU.js} +10 -5
  29. package/dist/chunk-UXDR3ZDU.js.map +1 -0
  30. package/dist/index.cjs +464 -43
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +442 -21
  35. package/dist/index.js.map +1 -1
  36. package/dist/loader.css +1 -1
  37. package/dist/three.cjs +5 -5
  38. package/dist/three.js +1 -1
  39. package/docs/dsl.md +2 -1
  40. package/llms-full.txt +81 -5
  41. package/llms.txt +1 -1
  42. package/package.json +6 -3
  43. package/dist/Composition-3WWNVRR5.cjs +0 -14
  44. package/dist/Composition-VJWY2NDL.js +0 -5
  45. package/dist/chunk-2ROVCPSL.cjs.map +0 -1
  46. package/dist/chunk-FJA5VUOF.cjs.map +0 -1
  47. package/dist/chunk-XTIHKODH.js.map +0 -1
  48. package/dist/chunk-ZNKQH356.js.map +0 -1
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
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, d as LoaderOption, M as MotionBlurOptions, e as MotionBlurSpec, f as Movie, g as MovieErrorEvent, h as MovieOptions, P as ProgressEvent, R as RenderOptions, S as SnapshotOptions, i as SoundMeasure, j as StopEvent, k as StopImage, l as StopImagesOptions, T as TimelineData, m as TimelineHtmlOptions, n as TimelineRow, o as TimelineSvgOptions, p as TimelineTransition, V as VolumeEvent, q as dismissLoader, r as failLoader, s as resolveLoader } from './Movie-oA0SUAyb.cjs';
1
+ export { A as AudioInspectOptions, a as AudioReport, b as AudioSourceReport, C as ContactSheetOptions, F as FontReport, c as FrameEvent, I as InspectOptions, d as InspectReport, e as IssueGroup, L as LayerInfo, f as LoaderOption, M as MotionBlurOptions, g as MotionBlurSpec, h as Movie, i as MovieErrorEvent, j as MovieOptions, O as OnionSkinOptions, P as ProgressEvent, R as RenderOptions, k as ReviewOptions, l as ReviewReport, S as SnapshotOptions, m as SoundMeasure, n as StopEvent, o as StopImage, p as StopImagesOptions, T as TimelineData, q as TimelineHtmlOptions, r as TimelineRow, s as TimelineSvgOptions, t as TimelineTransition, V as VolumeEvent, u as dismissLoader, v as failLoader, w as resolveLoader } from './Movie-K2YuVAdg.cjs';
2
2
  import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, K as Keyframe, T as TextSequenceSpec, M as MusicOptions, P as Props, b as TransitionSpec, c as CompositionSpec, d as SequenceCommon } from './types-SS51lP6O.cjs';
3
3
  export { A as ArcShapeSpec, e as AssetSpec, f as AudioAssetSpec, g as AudioMusicSpec, h as AudioSequenceSpec, i as AudioSfxSpec, j as ChromaKeyFilterSpec, k as CircleShapeSpec, l as CompositionSequenceSpec, m as CrossfadeTransition, n as CustomFilterSpec, D as DipTransition, o as DissolveTransition, E as EllipseShapeSpec, p as Expr, F as FilterSpec, G as GradientSpec, q as IrisTransition, L as LineShapeSpec, r as MusicDrum, s as MusicDrums, t as MusicInstrument, u as MusicLevel, v as MusicTrack, N as NullSequenceSpec, w as PathShapeSpec, x as PolygonShapeSpec, y as PropValue, R as RectShapeSpec, z as SfxKnobs, B as SfxOptions, H as SfxPreset, J as SfxVoice, O as ShapeSequenceSpec, Q as SlideTransition, U as Stop, V as StopSpec, W as TransitionCommon, X as VideoSequenceSpec, Y as WipeTransition, Z as ZoomTransition, _ as normalizeStops, $ as pictureStops } from './types-SS51lP6O.cjs';
4
4
  import { S as Sequence } from './Base-OHqp8qvX.cjs';
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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, d as LoaderOption, M as MotionBlurOptions, e as MotionBlurSpec, f as Movie, g as MovieErrorEvent, h as MovieOptions, P as ProgressEvent, R as RenderOptions, S as SnapshotOptions, i as SoundMeasure, j as StopEvent, k as StopImage, l as StopImagesOptions, T as TimelineData, m as TimelineHtmlOptions, n as TimelineRow, o as TimelineSvgOptions, p as TimelineTransition, V as VolumeEvent, q as dismissLoader, r as failLoader, s as resolveLoader } from './Movie-DilBC348.js';
1
+ export { A as AudioInspectOptions, a as AudioReport, b as AudioSourceReport, C as ContactSheetOptions, F as FontReport, c as FrameEvent, I as InspectOptions, d as InspectReport, e as IssueGroup, L as LayerInfo, f as LoaderOption, M as MotionBlurOptions, g as MotionBlurSpec, h as Movie, i as MovieErrorEvent, j as MovieOptions, O as OnionSkinOptions, P as ProgressEvent, R as RenderOptions, k as ReviewOptions, l as ReviewReport, S as SnapshotOptions, m as SoundMeasure, n as StopEvent, o as StopImage, p as StopImagesOptions, T as TimelineData, q as TimelineHtmlOptions, r as TimelineRow, s as TimelineSvgOptions, t as TimelineTransition, V as VolumeEvent, u as dismissLoader, v as failLoader, w as resolveLoader } from './Movie-DlHQ9C02.js';
2
2
  import { S as SequenceSpec, C as CompositionShape, I as ImageSequenceSpec, a as CameraSequenceSpec, K as Keyframe, T as TextSequenceSpec, M as MusicOptions, P as Props, b as TransitionSpec, c as CompositionSpec, d as SequenceCommon } from './types-SS51lP6O.js';
3
3
  export { A as ArcShapeSpec, e as AssetSpec, f as AudioAssetSpec, g as AudioMusicSpec, h as AudioSequenceSpec, i as AudioSfxSpec, j as ChromaKeyFilterSpec, k as CircleShapeSpec, l as CompositionSequenceSpec, m as CrossfadeTransition, n as CustomFilterSpec, D as DipTransition, o as DissolveTransition, E as EllipseShapeSpec, p as Expr, F as FilterSpec, G as GradientSpec, q as IrisTransition, L as LineShapeSpec, r as MusicDrum, s as MusicDrums, t as MusicInstrument, u as MusicLevel, v as MusicTrack, N as NullSequenceSpec, w as PathShapeSpec, x as PolygonShapeSpec, y as PropValue, R as RectShapeSpec, z as SfxKnobs, B as SfxOptions, H as SfxPreset, J as SfxVoice, O as ShapeSequenceSpec, Q as SlideTransition, U as Stop, V as StopSpec, W as TransitionCommon, X as VideoSequenceSpec, Y as WipeTransition, Z as ZoomTransition, _ as normalizeStops, $ as pictureStops } from './types-SS51lP6O.js';
4
4
  import { S as Sequence } from './Base-BQUodYZK.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { CompositionSequence } from './chunk-XTIHKODH.js';
2
- import { ensureFilterLibrary, homeDistance, DEFAULT_FOV, random, noise, flattenSvgPath, rand, DEG, resolveAt, collectPropKeys } from './chunk-ZNKQH356.js';
3
- export { noise, rand, random, registerSequenceType } from './chunk-ZNKQH356.js';
1
+ import { CompositionSequence } from './chunk-UXDR3ZDU.js';
2
+ import { ensureFilterLibrary, homeDistance, DEFAULT_FOV, random, noise, flattenSvgPath, rand, DEG, resolveAt, checkEase, collectPropKeys } from './chunk-SG3Y66FN.js';
3
+ export { noise, rand, random, registerSequenceType } from './chunk-SG3Y66FN.js';
4
4
  import { warnUnknownOptions, suggestName } from './chunk-DKGG7WD4.js';
5
5
  import * as PIXI from 'pixi.js';
6
6
  import { extensions, CullerPlugin, Application, Container, Rectangle, Culler, CanvasTextMetrics, TextStyle, Assets, ExtensionType, Filter, UniformGroup, GpuProgram, GlProgram, defaultFilterVert } from 'pixi.js';
@@ -183,6 +183,118 @@ function spectralCentroid(samples, sampleRate, from = 0, to = samples.length) {
183
183
  return den > 1e-9 ? Math.round(num3 / den / 10) * 10 : 0;
184
184
  }
185
185
 
186
+ // src/audio/loudness.ts
187
+ var LOUDNESS_NOTES = { quietBelow: -24, loudAbove: -9, headroomDb: -1 };
188
+ function kWeighting(fs) {
189
+ const f0 = 1681.974450955533, G = 3.999843853973347, Q = 0.7071752369554196;
190
+ const K = Math.tan(Math.PI * f0 / fs), Vh = 10 ** (G / 20), Vb = Vh ** 0.4996667741545416;
191
+ const a0 = 1 + K / Q + K * K;
192
+ const shelf = {
193
+ b: [(Vh + Vb * K / Q + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q + K * K) / a0],
194
+ a: [1, 2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0]
195
+ };
196
+ const f1 = 38.13547087602444, Q1 = 0.5003270373238773, K1 = Math.tan(Math.PI * f1 / fs), a1 = 1 + K1 / Q1 + K1 * K1;
197
+ const high = { b: [1, -2, 1], a: [1, 2 * (K1 * K1 - 1) / a1, (1 - K1 / Q1 + K1 * K1) / a1] };
198
+ return [shelf, high];
199
+ }
200
+ function filter(x, stages) {
201
+ let cur = x;
202
+ for (const { b, a } of stages) {
203
+ const out = new Float32Array(cur.length);
204
+ let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
205
+ for (let i = 0; i < cur.length; i++) {
206
+ const xi = cur[i], y = b[0] * xi + b[1] * x1 + b[2] * x2 - a[1] * y1 - a[2] * y2;
207
+ x2 = x1;
208
+ x1 = xi;
209
+ y2 = y1;
210
+ y1 = y;
211
+ out[i] = y;
212
+ }
213
+ cur = out;
214
+ }
215
+ return cur;
216
+ }
217
+ var toDb = (x) => Math.round(200 * Math.log10(Math.max(x, 1e-6))) / 10;
218
+ var TP_HALF = 12;
219
+ var TP_BLOCK = 32;
220
+ var TP_KERNELS = [0.25, 0.5, 0.75].map((p) => Float64Array.from({ length: 2 * TP_HALF }, (_, j) => {
221
+ const k = j - TP_HALF + 1, x = k - p, s = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
222
+ return s * (0.5 + 0.5 * Math.cos(Math.PI * x / (TP_HALF + 1)));
223
+ }));
224
+ var TP_GAIN = Math.max(...TP_KERNELS.map((k) => k.reduce((a, v) => a + Math.abs(v), 0)));
225
+ function truePeak(chans) {
226
+ let peak = 0;
227
+ for (const c of chans) {
228
+ const n = c.length;
229
+ const blocks = Math.ceil(n / TP_BLOCK), blockMax = new Float32Array(blocks + 2);
230
+ for (let i = 0; i < n; i++) {
231
+ const a = Math.abs(c[i]), b = (i / TP_BLOCK | 0) + 1;
232
+ if (a > blockMax[b]) blockMax[b] = a;
233
+ if (a > peak) peak = a;
234
+ }
235
+ const padded = new Float32Array(n + 2 * TP_HALF);
236
+ padded.set(c, TP_HALF);
237
+ for (let i = 0; i < n; i++) {
238
+ const b = (i / TP_BLOCK | 0) + 1;
239
+ const local = Math.max(blockMax[b - 1], blockMax[b], blockMax[b + 1]);
240
+ if (local * TP_GAIN <= peak) continue;
241
+ for (let pi = 0; pi < 3; pi++) {
242
+ const k = TP_KERNELS[pi];
243
+ let v = 0;
244
+ for (let j = 0; j < 2 * TP_HALF; j++) v += padded[i + 1 + j] * k[j];
245
+ const a = Math.abs(v);
246
+ if (a > peak) peak = a;
247
+ }
248
+ }
249
+ }
250
+ return peak;
251
+ }
252
+ function measureLoudness(channels, sampleRate, opts = {}) {
253
+ let samplePeak = 0, clipped = 0;
254
+ for (const c of channels) for (let i = 0; i < c.length; i++) {
255
+ const a = Math.abs(c[i]);
256
+ if (a > samplePeak) samplePeak = a;
257
+ if (a >= 1) clipped++;
258
+ }
259
+ const stages = kWeighting(sampleRate);
260
+ const weighted = channels.map((c) => filter(c, stages));
261
+ const block = Math.round(0.4 * sampleRate), hop = Math.round(0.1 * sampleRate);
262
+ const energies = [];
263
+ for (let s = 0; s + block <= (weighted[0]?.length ?? 0); s += hop) {
264
+ let sum = 0;
265
+ for (const w of weighted) {
266
+ let z = 0;
267
+ for (let i = s; i < s + block; i++) z += w[i] * w[i];
268
+ sum += z / block;
269
+ }
270
+ energies.push(sum);
271
+ }
272
+ const lufs = (e) => -0.691 + 10 * Math.log10(e);
273
+ const absolute = energies.filter((e) => e > 0 && lufs(e) > -70);
274
+ let integrated = null;
275
+ if (absolute.length) {
276
+ const relative = lufs(absolute.reduce((a, b) => a + b, 0) / absolute.length) - 10;
277
+ const gated = absolute.filter((e) => lufs(e) > relative);
278
+ if (gated.length) integrated = Math.round(10 * lufs(gated.reduce((a, b) => a + b, 0) / gated.length)) / 10;
279
+ }
280
+ return { integratedLufs: integrated, truePeakDb: opts.truePeak === false ? null : toDb(truePeak(channels)), samplePeakDb: toDb(samplePeak), clippedSamples: clipped };
281
+ }
282
+ function findSilences(channels, sampleRate, opts = {}) {
283
+ const min = Math.round((opts.minLength ?? 1) * sampleRate), limit = 10 ** ((opts.thresholdDb ?? -60) / 20);
284
+ const n = channels[0]?.length ?? 0, out = [];
285
+ let start = -1;
286
+ const quiet = (i) => channels.every((c) => Math.abs(c[i]) < limit);
287
+ for (let i = 0; i <= n; i++) {
288
+ const q = i < n && quiet(i);
289
+ if (q && start < 0) start = i;
290
+ if (!q && start >= 0) {
291
+ if (i - start >= min) out.push({ from: Math.round(start / sampleRate * 1e3) / 1e3, to: Math.round(i / sampleRate * 1e3) / 1e3 });
292
+ start = -1;
293
+ }
294
+ }
295
+ return out;
296
+ }
297
+
186
298
  // src/core/inspectAudio.ts
187
299
  var INAUDIBLE_DB = -50;
188
300
  var db = (x) => Math.round(10 * Math.max(-120, 20 * Math.log10(x))) / 10;
@@ -208,7 +320,19 @@ function measure(chans, sampleRate) {
208
320
  }
209
321
  function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
210
322
  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 })'] };
323
+ return {
324
+ duration: movieDuration,
325
+ sampleRate: 0,
326
+ peakDb: -120,
327
+ peakAt: 0,
328
+ sources: [],
329
+ windows: [],
330
+ loudness: { integratedLufs: null, truePeakDb: -120, clippedSamples: 0, silences: [] },
331
+ scenes: [],
332
+ cues: [],
333
+ notes: [],
334
+ issues: ['no audio: the movie has no audio layers (sound effects need no files: { type: "audio", sfx: "pop", at: 1 })']
335
+ };
212
336
  }
213
337
  const sr = mix.sampleRate;
214
338
  const chans = Array.from({ length: mix.numberOfChannels }, (_, c) => mix.getChannelData(c));
@@ -266,6 +390,24 @@ function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
266
390
  }
267
391
  return { layer, source, start: s.start, end: s.end, peakDb, sound };
268
392
  });
393
+ const whole = measureLoudness(chans, sr);
394
+ const silences = findSilences(chans, sr, { minLength: 1 });
395
+ if (whole.clippedSamples > 0) issues.push(`${whole.clippedSamples} sample(s) of the mix are at full scale (clipped) \u2014 lower the volume of the layers that play loudest`);
396
+ if (whole.truePeakDb > 0) issues.push(`the mix's true peak is ${whole.truePeakDb} dBTP: it will distort when the video is encoded \u2014 lower the volume`);
397
+ const notes = [];
398
+ if (whole.integratedLufs !== null) {
399
+ if (whole.integratedLufs < LOUDNESS_NOTES.quietBelow) notes.push(`the mix is quiet for web video: ${whole.integratedLufs} LUFS (typical \u221214 to \u221216); raise the volume if that is not on purpose`);
400
+ else if (whole.integratedLufs > LOUDNESS_NOTES.loudAbove) notes.push(`the mix is loud: ${whole.integratedLufs} LUFS (typical web video \u221214 to \u221216)`);
401
+ }
402
+ if (whole.truePeakDb > LOUDNESS_NOTES.headroomDb && whole.truePeakDb <= 0) notes.push(`the true peak is ${whole.truePeakDb} dBTP: little headroom left for encoding (keep it below ${LOUDNESS_NOTES.headroomDb} dBTP)`);
403
+ for (const q of silences.slice(0, 3)) notes.push(`the mix is silent from ${q.from} s to ${q.to} s (${Math.round((q.to - q.from) * 10) / 10} s)`);
404
+ if (silences.length > 3) notes.push(`and ${silences.length - 3} more silent stretch(es) (loudness.silences lists them all)`);
405
+ const sceneList = (opts.scenes && opts.scenes.length ? opts.scenes : [{ name: "movie", start: 0, end: movieDuration }]).map((sc) => {
406
+ const a = Math.max(0, Math.round(sc.start * sr)), b = Math.min(mix.length, Math.round(sc.end * sr));
407
+ const part = chans.map((c) => c.subarray(a, Math.max(a, b)));
408
+ const m = measureLoudness(part, sr, { truePeak: false });
409
+ return { name: sc.name, from: sc.start, to: sc.end, lufs: m.integratedLufs, peakDb: m.samplePeakDb, silent: m.samplePeakDb < -60 };
410
+ });
269
411
  return {
270
412
  duration: movieDuration,
271
413
  sampleRate: sr,
@@ -273,8 +415,158 @@ function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
273
415
  peakAt: stats?.peakAt ?? 0,
274
416
  sources: list,
275
417
  windows: windows2,
276
- issues
418
+ issues,
419
+ loudness: { integratedLufs: whole.integratedLufs, truePeakDb: whole.truePeakDb ?? -120, clippedSamples: whole.clippedSamples, silences },
420
+ scenes: sceneList,
421
+ cues: list.map((s) => ({ t: s.start, name: `${s.layer} (${s.source})` })),
422
+ notes
423
+ };
424
+ }
425
+
426
+ // src/text/fonts.ts
427
+ var GENERIC = /* @__PURE__ */ new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "emoji", "math", "fangsong", "-apple-system", "blinkmacsystemfont"]);
428
+ function fontAvailable(family2) {
429
+ const f = family2.trim().replace(/^["']|["']$/g, "");
430
+ if (!f || GENERIC.has(f.toLowerCase())) return true;
431
+ const g = document.createElement("canvas").getContext("2d");
432
+ if (!g) return true;
433
+ const sample = "mmmmmmmmmmlliWWWW 0123 \u3042\u3044\u3046";
434
+ const width = (stack) => {
435
+ g.font = `72px ${stack}`;
436
+ return g.measureText(sample).width;
277
437
  };
438
+ const q = JSON.stringify(f);
439
+ return width(`${q}, monospace`) !== width("monospace") || width(`${q}, serif`) !== width("serif");
440
+ }
441
+
442
+ // src/core/inspectFonts.ts
443
+ function inspectFonts(root) {
444
+ const failed = /* @__PURE__ */ new Set();
445
+ if (typeof document !== "undefined" && document.fonts) document.fonts.forEach((face) => {
446
+ if (face.status === "error") failed.add(face.family.replace(/^["']|["']$/g, ""));
447
+ });
448
+ const missing = [];
449
+ const used = /* @__PURE__ */ new Set();
450
+ const walk2 = (seq, index) => {
451
+ const spec = seq.spec;
452
+ if (spec.type === "text") {
453
+ const raw = spec.style?.fontFamily;
454
+ const family2 = Array.isArray(raw) ? raw.join(", ") : typeof raw === "string" ? raw : "";
455
+ const list = family2.split(",").map((f) => f.trim()).filter(Boolean);
456
+ for (const f of list) used.add(f.replace(/^["']|["']$/g, "").toLowerCase());
457
+ if (list.length && !list.some(fontAvailable) && !list.some((f) => failed.has(f.replace(/^["']|["']$/g, "")))) missing.push({ layer: spec.name ?? `text#${index}`, family: family2 });
458
+ }
459
+ if (seq.maskSequence) walk2(seq.maskSequence, index);
460
+ (seq._children ?? []).forEach((child, i) => walk2(child, i));
461
+ };
462
+ walk2(root, 0);
463
+ const all = [...failed];
464
+ return { missing, failed: all.filter((f) => used.has(f.toLowerCase())), failedUnused: all.filter((f) => !used.has(f.toLowerCase())) };
465
+ }
466
+
467
+ // src/core/scenes.ts
468
+ function namedScenes(rows) {
469
+ return rows.filter((r) => r.depth === 0 && r.type === "composition" && !/[#×]/.test(r.name) && r.end - r.start >= 1).map((r) => ({ name: r.name, start: r.start, end: r.end }));
470
+ }
471
+
472
+ // src/core/review.ts
473
+ function sampleFrames({ totalFrames, frameRate, scenes = [], step = 0.25, cap = 240 }) {
474
+ const last = totalFrames;
475
+ const clamp2 = (f) => Math.max(0, Math.min(last, Math.round(f)));
476
+ const edges = /* @__PURE__ */ new Set([0, last]);
477
+ for (const s of scenes) {
478
+ edges.add(clamp2(s.start * frameRate));
479
+ edges.add(clamp2(s.end * frameRate - 1));
480
+ }
481
+ const room = Math.max(0, cap - edges.size);
482
+ const seconds = totalFrames / frameRate;
483
+ const wanted = Math.floor(seconds / step) + 1;
484
+ const stride = wanted <= room ? step : seconds / Math.max(1, room);
485
+ const frames = new Set(edges);
486
+ for (let t = stride; frames.size < cap && t < seconds; t += stride) frames.add(clamp2(t * frameRate));
487
+ return [...frames].sort((a, b) => a - b);
488
+ }
489
+ var AT_HELP = "use seconds (3.5), a percentage (50%), a frame (f120) or a layer's start / mid / end (name@end)";
490
+ function resolveAtList(list, ctx) {
491
+ const { frameRate: fps, totalFrames, duration, rows } = ctx;
492
+ const out = [], used = /* @__PURE__ */ new Set();
493
+ const named = rows.flatMap((r) => r.parts && r.partNames ? r.partNames.map((name, i) => ({ name, start: r.parts[i].start, end: r.parts[i].end })) : [r]);
494
+ for (const raw of String(list).split(",").map((s) => s.trim()).filter(Boolean)) {
495
+ let frame, label, m;
496
+ if (m = raw.match(/^(.+)@(start|mid|end)$/)) {
497
+ const row = named.find((r) => r.name === m[1]);
498
+ if (!row) throw new Error(`--at "${raw}": no layer named "${m[1]}" (names: ${named.slice(0, 40).map((r) => r.name).join(", ")}${named.length > 40 ? ", \u2026" : ""})`);
499
+ const t = m[2] === "start" ? row.start : m[2] === "mid" ? (row.start + row.end) / 2 : row.end - 1 / fps;
500
+ frame = Math.round(t * fps);
501
+ label = `${m[1]}-${m[2]}`;
502
+ } else if (m = raw.match(/^(\d+(?:\.\d+)?)%$/)) {
503
+ frame = Math.round(Number(m[1]) / 100 * totalFrames);
504
+ label = `${m[1]}pct`;
505
+ } else if (m = raw.match(/^f(\d+)$/i)) {
506
+ frame = Number(m[1]);
507
+ label = `f${m[1]}`;
508
+ } else if (/^\d+(\.\d+)?$/.test(raw)) {
509
+ frame = Math.round(Number(raw) * fps);
510
+ label = `${Number(raw).toFixed(2)}s`;
511
+ } else throw new Error(`--at: cannot read "${raw}": ${AT_HELP}`);
512
+ if (frame > totalFrames || frame < 0) throw new Error(`--at "${raw}" is past the end: the movie is ${duration} s (${totalFrames} frames)`);
513
+ label = label.replace(/[\\/:*?"<>|]+/g, "-");
514
+ let unique = label, n = 2;
515
+ while (used.has(unique)) unique = `${label}-${n++}`;
516
+ used.add(unique);
517
+ out.push({ label: unique, frame });
518
+ }
519
+ return out;
520
+ }
521
+ function groupIssues(perFrame) {
522
+ const groups = /* @__PURE__ */ new Map();
523
+ for (const { frame, issues } of perFrame) {
524
+ for (const message of issues) {
525
+ const key = message.replace(/-?\d+(\.\d+)?/g, "#");
526
+ const g = groups.get(key);
527
+ if (g) {
528
+ g.count++;
529
+ g.lastFrame = frame;
530
+ } else groups.set(key, { message, count: 1, firstFrame: frame, lastFrame: frame });
531
+ }
532
+ }
533
+ return [...groups.values()];
534
+ }
535
+ function splitIssues(groups, strict = false) {
536
+ if (strict) return { problems: groups, review: [] };
537
+ const overlap = (g) => /\boverlap\b/.test(g.message);
538
+ return { problems: groups.filter((g) => !overlap(g)), review: groups.filter(overlap) };
539
+ }
540
+ async function reviewMovie(movie, opts = {}) {
541
+ const rows = movie.timelineData().rows;
542
+ const at = opts.at ? resolveAtList(opts.at, { frameRate: movie.frameRate, totalFrames: movie.totalFrames, duration: movie.duration, rows }) : [];
543
+ const frames = sampleFrames({ totalFrames: movie.totalFrames, frameRate: movie.frameRate, scenes: namedScenes(rows) });
544
+ const perFrame = [];
545
+ for (const frame of frames) {
546
+ const r = await movie.inspect(frame, { layers: "none" });
547
+ if (r.issues.length) perFrame.push({ frame, issues: r.issues });
548
+ }
549
+ const grouped = splitIssues(groupIssues(perFrame), !!opts.strict);
550
+ const fonts = movie.inspectFonts();
551
+ for (const f of fonts.failedUnused) grouped.review.push({ message: `web font "${f}" failed to load, but no text layer uses it (a broken or unused @font-face)`, count: 1, firstFrame: 0, lastFrame: 0 });
552
+ for (const m of fonts.missing) {
553
+ (opts.strict ? grouped.problems : grouped.review).push({ message: `layer "${m.layer}": none of the fonts "${m.family}" is available here (it is drawn in a fallback font)`, count: 1, firstFrame: 0, lastFrame: 0 });
554
+ }
555
+ return { frames: frames.length, problems: grouped.problems, review: grouped.review, fonts, audio: movie.audioBuffer ? movie.inspectAudio() : null, at };
556
+ }
557
+
558
+ // src/core/onion.ts
559
+ function onionTimes(from, to, count) {
560
+ if (count <= 1) return [from];
561
+ return Array.from({ length: count }, (_, i) => Math.round((from + (to - from) * i / (count - 1)) * 1e6) / 1e6);
562
+ }
563
+ function onionAlphas(count, ramp = 0.25) {
564
+ const weights = Array.from({ length: count }, (_, i) => count === 1 ? 1 : ramp + (1 - ramp) * (i / (count - 1)));
565
+ let sum = 0;
566
+ return weights.map((w) => {
567
+ sum += w;
568
+ return w / sum;
569
+ });
278
570
  }
279
571
 
280
572
  // src/core/motionBlur.ts
@@ -293,6 +585,57 @@ function blurTimes(frame, frameRate, mb, duration) {
293
585
  return Array.from({ length: mb.samples }, (_, k) => Math.min(Math.max(t + ((k + 0.5) / mb.samples - 0.5) * width, 0), duration));
294
586
  }
295
587
 
588
+ // src/core/renderRange.ts
589
+ function resolveRange(range2, ctx) {
590
+ const { frameRate: fps, totalFrames, duration } = ctx;
591
+ if (range2 === void 0) return { fromFrame: 0, toFrame: totalFrames, fromSec: 0, toSec: duration, partial: false };
592
+ let from, to;
593
+ if (typeof range2 === "string") {
594
+ const rows = (ctx.rows ?? []).filter((r) => r.depth === 0).flatMap((r) => r.parts && r.partNames ? r.partNames.map((name, i) => ({ name, start: r.parts[i].start, end: r.parts[i].end })) : [r]);
595
+ const row = rows.find((r) => r.name === range2);
596
+ if (!row) throw new Error(`pixi-effects: render({ range: "${range2}" }): no layer named "${range2}" at the top level (names: ${rows.slice(0, 40).map((r) => r.name).join(", ") || "none"}${rows.length > 40 ? ", \u2026" : ""})`);
597
+ from = row.start;
598
+ to = row.end;
599
+ } else {
600
+ if (!Array.isArray(range2) || range2.length !== 2 || !range2.every((n) => typeof n === "number" && Number.isFinite(n))) {
601
+ throw new Error("pixi-effects: render({ range }) must be [from, to] in seconds (numbers) or the name of a top-level layer");
602
+ }
603
+ [from, to] = range2;
604
+ }
605
+ if (from < 0 || to <= from) throw new Error(`pixi-effects: render({ range: [${from}, ${to}] }): a range must start before it ends (from >= 0, from < to)`);
606
+ if (to > duration + 0.5 / fps) throw new Error(`pixi-effects: render({ range: [${from}, ${to}] }) goes past the end: the movie is ${duration} s long`);
607
+ const fromFrame = Math.round(from * fps);
608
+ const toFrame = Math.min(Math.round(to * fps), totalFrames);
609
+ if (toFrame <= fromFrame) throw new Error(`pixi-effects: render({ range: [${from}, ${to}] }) is shorter than one frame (${(1 / fps).toFixed(4)} s at ${fps} fps)`);
610
+ const partial = fromFrame > 0 || toFrame < totalFrames;
611
+ return { fromFrame, toFrame, fromSec: fromFrame / fps, toSec: toFrame / fps, partial };
612
+ }
613
+ function sliceChannels(channels, sampleRate, fromSec, toSec, opts = {}) {
614
+ const fade = Math.max(0, Math.round((opts.fade ?? 0.01) * sampleRate));
615
+ const from = Math.round(fromSec * sampleRate), len = Math.max(1, Math.round(toSec * sampleRate) - from);
616
+ return channels.map((src) => {
617
+ const out = new Float32Array(len);
618
+ for (let i = 0; i < len; i++) {
619
+ const j = from + i;
620
+ if (j >= 0 && j < src.length) out[i] = src[j];
621
+ }
622
+ const f = Math.min(fade, len >> 1);
623
+ if (opts.fadeIn !== false) for (let i = 0; i < f; i++) out[i] = out[i] * (i / f);
624
+ if (opts.fadeOut !== false) for (let i = 0; i < f; i++) out[len - 1 - i] = out[len - 1 - i] * (i / f);
625
+ return out;
626
+ });
627
+ }
628
+ function resolveRenderOptions(o) {
629
+ const draft = !!o.draft;
630
+ const scale = o.scale ?? (draft ? 0.5 : 1);
631
+ if (!(typeof scale === "number" && scale > 0 && scale <= 1)) throw new Error(`pixi-effects: render({ scale }): scale must be above 0 and at most 1 (got ${scale})`);
632
+ return {
633
+ scale,
634
+ bitrate: o.video?.bitrate ?? (draft ? "low" : void 0),
635
+ motionBlur: o.motionBlur !== void 0 ? o.motionBlur : draft ? false : void 0
636
+ };
637
+ }
638
+
296
639
  // src/core/Renderer.ts
297
640
  var VIDEO_CODEC_BY_FORMAT = {
298
641
  mp4: "avc",
@@ -347,13 +690,22 @@ function makeOutputFormat(name) {
347
690
  return new MkvOutputFormat();
348
691
  }
349
692
  }
693
+ function sliceAudio(buffer, fromSec, toSec, fadeIn, fadeOut) {
694
+ const channels = Array.from({ length: buffer.numberOfChannels }, (_, c) => buffer.getChannelData(c));
695
+ const cut = sliceChannels(channels, buffer.sampleRate, fromSec, toSec, { fadeIn, fadeOut });
696
+ const out = new AudioBuffer({ length: cut[0].length, numberOfChannels: cut.length, sampleRate: buffer.sampleRate });
697
+ cut.forEach((data, c) => out.copyToChannel(data, c));
698
+ return out;
699
+ }
350
700
  async function exportFrames(movie, options = {}) {
351
701
  const fmt = options.format ?? "mp4";
702
+ const eff = resolveRenderOptions(options);
703
+ const range2 = resolveRange(options.range, { frameRate: movie.frameRate, totalFrames: movie.totalFrames, duration: movie.duration, rows: typeof options.range === "string" ? movie.timelineData().rows : void 0 });
352
704
  const opts = {
353
705
  format: fmt,
354
706
  video: {
355
707
  codec: options.video?.codec ?? VIDEO_CODEC_BY_FORMAT[fmt],
356
- bitrate: qualityMap[options.video?.bitrate ?? "high"] ?? QUALITY_HIGH
708
+ bitrate: qualityMap[eff.bitrate ?? "high"] ?? QUALITY_HIGH
357
709
  },
358
710
  audio: {
359
711
  codec: options.audio?.codec ?? AUDIO_CODEC_BY_FORMAT[fmt],
@@ -361,27 +713,30 @@ async function exportFrames(movie, options = {}) {
361
713
  }
362
714
  };
363
715
  const container = makeOutputFormat(opts.format);
364
- if (movie.audioBuffer) opts.audio.codec = await chooseAudioCodec(fmt, options.audio?.codec, container, movie.audioBuffer, opts.audio.bitrate);
716
+ const audio = movie.audioBuffer && range2.partial ? sliceAudio(movie.audioBuffer, range2.fromSec, range2.toSec, range2.fromFrame > 0, range2.toFrame < movie.totalFrames) : movie.audioBuffer;
717
+ if (audio) opts.audio.codec = await chooseAudioCodec(fmt, options.audio?.codec, container, audio, opts.audio.bitrate);
365
718
  const output = new Output({
366
719
  format: container,
367
720
  target: new BufferTarget()
368
721
  });
369
- const mb = options.motionBlur !== void 0 ? resolveMotionBlur(options.motionBlur, "movie.render()") : movie.motionBlur ?? null;
722
+ const mb = eff.motionBlur !== void 0 ? resolveMotionBlur(eff.motionBlur, "movie.render()") : movie.motionBlur ?? null;
370
723
  const stage = movie.app.canvas;
371
724
  const blurCanvas = mb ? Object.assign(document.createElement("canvas"), { width: stage.width, height: stage.height }) : null;
372
- const canvasSource = new CanvasSource(blurCanvas ?? stage, {
725
+ const small = eff.scale < 1 ? Object.assign(document.createElement("canvas"), { width: Math.max(2, Math.round(stage.width * eff.scale / 2) * 2), height: Math.max(2, Math.round(stage.height * eff.scale / 2) * 2) }) : null;
726
+ const smallCtx = small?.getContext("2d") ?? null;
727
+ const canvasSource = new CanvasSource(small ?? blurCanvas ?? stage, {
373
728
  codec: opts.video.codec,
374
729
  bitrate: opts.video.bitrate
375
730
  });
376
731
  output.addVideoTrack(canvasSource, { frameRate: movie.frameRate });
377
- if (movie.audioBuffer) {
732
+ if (audio) {
378
733
  const audioSource = new AudioBufferSource({
379
734
  codec: opts.audio.codec,
380
735
  bitrate: opts.audio.bitrate
381
- }, { startTimestamp: opts.audio.codec === "aac" ? -2112 / movie.audioBuffer.sampleRate : 0 });
736
+ }, { startTimestamp: opts.audio.codec === "aac" ? -2112 / audio.sampleRate : 0 });
382
737
  output.addAudioTrack(audioSource);
383
738
  await output.start();
384
- await audioSource.add(movie.audioBuffer);
739
+ await audioSource.add(audio);
385
740
  await audioSource.close();
386
741
  } else {
387
742
  await output.start();
@@ -389,18 +744,21 @@ async function exportFrames(movie, options = {}) {
389
744
  movie.app.ticker.stop();
390
745
  const keyframeIntervalFrames = Math.max(1, Math.round(2 * movie.frameRate));
391
746
  try {
392
- for (let frame = 0; frame <= movie.totalFrames; frame++) {
747
+ const count = Math.max(1, range2.toFrame - range2.fromFrame);
748
+ for (let frame = range2.fromFrame; frame <= range2.toFrame; frame++) {
393
749
  if (mb && blurCanvas) await movie._exposeFrame(frame, mb, blurCanvas, false);
394
750
  else await movie.gotoFrame(frame, true);
395
- const isKey = frame === 0 || frame % keyframeIntervalFrames === 0;
751
+ if (small && smallCtx) smallCtx.drawImage(blurCanvas ?? stage, 0, 0, small.width, small.height);
752
+ const rel = frame - range2.fromFrame;
753
+ const isKey = rel === 0 || rel % keyframeIntervalFrames === 0;
396
754
  const addOpts = isKey ? { keyFrame: true } : void 0;
397
- await canvasSource.add(frame / movie.frameRate, 1 / movie.frameRate, addOpts);
398
- const progress = Math.floor(frame / movie.totalFrames * 100);
755
+ await canvasSource.add(rel / movie.frameRate, 1 / movie.frameRate, addOpts);
756
+ const progress = Math.floor(rel / count * 100);
399
757
  movie.emit("progress", { progress, frame, totalFrames: movie.totalFrames });
400
758
  }
401
759
  await canvasSource.close();
402
760
  await output.finalize();
403
- if (mb) await movie.gotoFrame(movie.totalFrames, true);
761
+ if (mb) await movie.gotoFrame(range2.toFrame, true);
404
762
  return new Blob([output.target.buffer], { type: output.format.mimeType });
405
763
  } finally {
406
764
  movie.app.ticker.start();
@@ -1316,7 +1674,8 @@ function groupFamilies(entries) {
1316
1674
  start: Math.min(...members.map((x) => x.start)),
1317
1675
  end: Math.max(...members.map((x) => x.end)),
1318
1676
  keys: members.flatMap((x) => x.keys).sort((a, b) => a - b).slice(0, MAX_KEYS),
1319
- parts: members.map((x) => ({ start: x.start, end: x.end }))
1677
+ parts: members.map((x) => ({ start: x.start, end: x.end })),
1678
+ partNames: members.map((x) => x.name)
1320
1679
  });
1321
1680
  } else {
1322
1681
  out.push(e.row, ...e.inner);
@@ -2039,6 +2398,37 @@ var Movie = class {
2039
2398
  })));
2040
2399
  return new Blob([buildPdf(pages, { title: opts.title })], { type: "application/pdf" });
2041
2400
  }
2401
+ async onionSkin(opts = {}) {
2402
+ warnUnknownOptions("movie.onionSkin()", opts, ["from", "to", "count", "scale", "as", "motionBlur"]);
2403
+ this._requireReady("onionSkin");
2404
+ const from = opts.from ?? 0, to = opts.to ?? this.duration, count = opts.count ?? 8, scale = opts.scale ?? 1;
2405
+ if (!(Number.isInteger(count) && count >= 1 && count <= 64)) throw new Error(`pixi-effects: movie.onionSkin(): count must be a whole number from 1 to 64, got ${count}`);
2406
+ if (!(from >= 0 && to <= this.duration + 1e-9 && (count === 1 || from < to))) throw new Error(`pixi-effects: movie.onionSkin(): from must be before to, inside the movie (0 to ${this.duration} s), got ${from} to ${to}`);
2407
+ if (!(scale > 0 && scale <= 4)) throw new Error(`pixi-effects: movie.onionSkin(): scale must be above 0 and at most 4, got ${scale}`);
2408
+ const mb = this._blurFor(opts.motionBlur, "movie.onionSkin()");
2409
+ const blurred = mb ? this._blurCanvas() : null;
2410
+ const out = document.createElement("canvas");
2411
+ out.width = Math.max(1, Math.round(this.width * scale));
2412
+ out.height = Math.max(1, Math.round(this.height * scale));
2413
+ const g = out.getContext("2d");
2414
+ const alphas = onionAlphas(count);
2415
+ const frames = onionTimes(from, to, count).map((t) => clampFrame(t * this.frameRate, this.totalFrames));
2416
+ const back = this.currentFrame, wasAtPoster = this._atPoster;
2417
+ try {
2418
+ for (let i = 0; i < frames.length; i++) {
2419
+ const f = frames[i];
2420
+ if (mb && blurred) await this._quietly(() => this._exposeFrame(f, mb, blurred, true));
2421
+ else await this._quietly(() => this.gotoFrame(f, true));
2422
+ g.globalAlpha = alphas[i];
2423
+ g.drawImage(blurred ?? this.app.canvas, 0, 0, out.width, out.height);
2424
+ }
2425
+ } finally {
2426
+ g.globalAlpha = 1;
2427
+ if (wasAtPoster) await this._showPoster();
2428
+ else await this._quietly(() => this.gotoFrame(back, true));
2429
+ }
2430
+ return encodeCanvas(out, "image/png", opts.as ?? "blob");
2431
+ }
2042
2432
  async contactSheet(opts = {}) {
2043
2433
  warnUnknownOptions("movie.contactSheet()", opts, ["frames", "times", "count", "columns", "cellWidth", "as", "motionBlur"]);
2044
2434
  this._requireReady("contactSheet");
@@ -2105,10 +2495,40 @@ var Movie = class {
2105
2495
  * The soundtrack, measured: when every audio layer plays, how loud the mix is over time, and `issues`
2106
2496
  * (limiting, inaudible layers, sounds cut off by the end). Use it to CHECK sound you cannot hear.
2107
2497
  */
2498
+ /** The pictures to look at for moments such as `3.5`, `50%`, `f120`, `title@end` (comma-separated). A mistake says what could not be read. */
2499
+ resolveAt(list) {
2500
+ this._requireReady("resolveAt");
2501
+ return resolveAtList(list, { frameRate: this.frameRate, totalFrames: this.totalFrames, duration: this.duration, rows: this.timelineData().rows });
2502
+ }
2503
+ /**
2504
+ * One call that looks at everything `pixi-effects-check` looks at inside the page: layout issues over the whole timeline (every
2505
+ * scene's first and last frame and a frame every 0.25 s), fonts and the sound. See `ReviewReport`. Page warnings are in `window.__logs`. The movie is left as it was.
2506
+ */
2507
+ async review(opts = {}) {
2508
+ warnUnknownOptions("movie.review()", opts, ["at", "strict"]);
2509
+ this._requireReady("review");
2510
+ const back = this.currentFrame, wasAtPoster = this._atPoster;
2511
+ try {
2512
+ return await reviewMovie(this, opts);
2513
+ } finally {
2514
+ this.currentFrame = back;
2515
+ if (wasAtPoster) await this._showPoster();
2516
+ else await this._quietly(() => this.gotoFrame(back, true));
2517
+ }
2518
+ }
2519
+ /**
2520
+ * The fonts of the text layers: `missing` lists layers none of whose `fontFamily` entries is available in this browser (they are
2521
+ * drawn in a fallback font), `failed` lists web fonts (`@font-face`) whose file could not be loaded.
2522
+ */
2523
+ inspectFonts() {
2524
+ this._requireReady("inspectFonts");
2525
+ return inspectFonts(this._rootSequence);
2526
+ }
2108
2527
  inspectAudio(opts = {}) {
2109
- warnUnknownOptions("movie.inspectAudio()", opts, ["window"]);
2528
+ warnUnknownOptions("movie.inspectAudio()", opts, ["window", "scenes"]);
2110
2529
  this._requireReady("inspectAudio");
2111
- return analyzeAudio(this.audioBuffer, this._audioSources, this._mixStats, this.duration, opts);
2530
+ const scenes = opts.scenes ?? namedScenes(this.timelineData().rows);
2531
+ return analyzeAudio(this.audioBuffer, this._audioSources, this._mixStats, this.duration, { ...opts, scenes });
2112
2532
  }
2113
2533
  /**
2114
2534
  * Draw the stage now. PixiJS's CullerPlugin only culls inside `app.render()` (the ticker), and we render
@@ -2376,7 +2796,7 @@ var Movie = class {
2376
2796
  return this.muted;
2377
2797
  }
2378
2798
  async render(options) {
2379
- warnUnknownOptions("movie.render()", options, ["format", "video", "audio", "motionBlur"]);
2799
+ warnUnknownOptions("movie.render()", options, ["format", "video", "audio", "motionBlur", "range", "scale", "draft"]);
2380
2800
  try {
2381
2801
  return await this._quietly(() => exportFrames(this, options));
2382
2802
  } catch (err) {
@@ -2587,6 +3007,7 @@ function clamp(v, lo, hi) {
2587
3007
  return Math.max(lo, Math.min(hi, v));
2588
3008
  }
2589
3009
  function parseEase(name) {
3010
+ checkEase(name, "a preset");
2590
3011
  return gsap.parseEase(name);
2591
3012
  }
2592
3013