pixi-effects 0.16.3 → 0.17.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 +29 -0
  2. package/README.md +6 -6
  3. package/ai/CHAT.md +4 -0
  4. package/ai/SKILL.md +2 -1
  5. package/ai/chat-template.html +3 -3
  6. package/ai/reference/cheatsheet.md +4 -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 +162 -18
  11. package/ai/tools/render.mjs +21 -7
  12. package/dist/Composition-HYAOYPAX.js +5 -0
  13. package/dist/{Composition-VJWY2NDL.js.map → Composition-HYAOYPAX.js.map} +1 -1
  14. package/dist/Composition-LMVEASHF.cjs +14 -0
  15. package/dist/{Composition-3WWNVRR5.cjs.map → Composition-LMVEASHF.cjs.map} +1 -1
  16. package/dist/Controller.d.cts +1 -1
  17. package/dist/Controller.d.ts +1 -1
  18. package/dist/{Movie-oA0SUAyb.d.cts → Movie-CoxF2Z7t.d.cts} +82 -2
  19. package/dist/{Movie-DilBC348.d.ts → Movie-DHJRrUfI.d.ts} +82 -2
  20. package/dist/Presenter.d.cts +1 -1
  21. package/dist/Presenter.d.ts +1 -1
  22. package/dist/{chunk-2ROVCPSL.cjs → chunk-ETAIVOHP.cjs} +32 -27
  23. package/dist/chunk-ETAIVOHP.cjs.map +1 -0
  24. package/dist/{chunk-FJA5VUOF.cjs → chunk-LBJRXU7N.cjs} +15 -3
  25. package/dist/chunk-LBJRXU7N.cjs.map +1 -0
  26. package/dist/{chunk-ZNKQH356.js → chunk-ORTYBA42.js} +15 -4
  27. package/dist/chunk-ORTYBA42.js.map +1 -0
  28. package/dist/{chunk-XTIHKODH.js → chunk-Q3IVBYT7.js} +10 -5
  29. package/dist/chunk-Q3IVBYT7.js.map +1 -0
  30. package/dist/index.cjs +351 -41
  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 +329 -19
  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 +78 -4
  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.cjs CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var chunk2ROVCPSL_cjs = require('./chunk-2ROVCPSL.cjs');
4
- var chunkFJA5VUOF_cjs = require('./chunk-FJA5VUOF.cjs');
3
+ var chunkETAIVOHP_cjs = require('./chunk-ETAIVOHP.cjs');
4
+ var chunkLBJRXU7N_cjs = require('./chunk-LBJRXU7N.cjs');
5
5
  var chunkUQBGW5DO_cjs = require('./chunk-UQBGW5DO.cjs');
6
6
  var PIXI = require('pixi.js');
7
7
  var gsap = require('gsap');
@@ -203,6 +203,118 @@ function spectralCentroid(samples, sampleRate, from = 0, to = samples.length) {
203
203
  return den > 1e-9 ? Math.round(num3 / den / 10) * 10 : 0;
204
204
  }
205
205
 
206
+ // src/audio/loudness.ts
207
+ var LOUDNESS_NOTES = { quietBelow: -24, loudAbove: -9, headroomDb: -1 };
208
+ function kWeighting(fs) {
209
+ const f0 = 1681.974450955533, G = 3.999843853973347, Q = 0.7071752369554196;
210
+ const K = Math.tan(Math.PI * f0 / fs), Vh = 10 ** (G / 20), Vb = Vh ** 0.4996667741545416;
211
+ const a0 = 1 + K / Q + K * K;
212
+ const shelf = {
213
+ b: [(Vh + Vb * K / Q + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q + K * K) / a0],
214
+ a: [1, 2 * (K * K - 1) / a0, (1 - K / Q + K * K) / a0]
215
+ };
216
+ const f1 = 38.13547087602444, Q1 = 0.5003270373238773, K1 = Math.tan(Math.PI * f1 / fs), a1 = 1 + K1 / Q1 + K1 * K1;
217
+ const high = { b: [1, -2, 1], a: [1, 2 * (K1 * K1 - 1) / a1, (1 - K1 / Q1 + K1 * K1) / a1] };
218
+ return [shelf, high];
219
+ }
220
+ function filter(x, stages) {
221
+ let cur = x;
222
+ for (const { b, a } of stages) {
223
+ const out = new Float32Array(cur.length);
224
+ let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
225
+ for (let i = 0; i < cur.length; i++) {
226
+ const xi = cur[i], y = b[0] * xi + b[1] * x1 + b[2] * x2 - a[1] * y1 - a[2] * y2;
227
+ x2 = x1;
228
+ x1 = xi;
229
+ y2 = y1;
230
+ y1 = y;
231
+ out[i] = y;
232
+ }
233
+ cur = out;
234
+ }
235
+ return cur;
236
+ }
237
+ var toDb = (x) => Math.round(200 * Math.log10(Math.max(x, 1e-6))) / 10;
238
+ var TP_HALF = 12;
239
+ var TP_BLOCK = 32;
240
+ var TP_KERNELS = [0.25, 0.5, 0.75].map((p) => Float64Array.from({ length: 2 * TP_HALF }, (_, j) => {
241
+ const k = j - TP_HALF + 1, x = k - p, s = x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
242
+ return s * (0.5 + 0.5 * Math.cos(Math.PI * x / (TP_HALF + 1)));
243
+ }));
244
+ var TP_GAIN = Math.max(...TP_KERNELS.map((k) => k.reduce((a, v) => a + Math.abs(v), 0)));
245
+ function truePeak(chans) {
246
+ let peak = 0;
247
+ for (const c of chans) {
248
+ const n = c.length;
249
+ const blocks = Math.ceil(n / TP_BLOCK), blockMax = new Float32Array(blocks + 2);
250
+ for (let i = 0; i < n; i++) {
251
+ const a = Math.abs(c[i]), b = (i / TP_BLOCK | 0) + 1;
252
+ if (a > blockMax[b]) blockMax[b] = a;
253
+ if (a > peak) peak = a;
254
+ }
255
+ const padded = new Float32Array(n + 2 * TP_HALF);
256
+ padded.set(c, TP_HALF);
257
+ for (let i = 0; i < n; i++) {
258
+ const b = (i / TP_BLOCK | 0) + 1;
259
+ const local = Math.max(blockMax[b - 1], blockMax[b], blockMax[b + 1]);
260
+ if (local * TP_GAIN <= peak) continue;
261
+ for (let pi = 0; pi < 3; pi++) {
262
+ const k = TP_KERNELS[pi];
263
+ let v = 0;
264
+ for (let j = 0; j < 2 * TP_HALF; j++) v += padded[i + 1 + j] * k[j];
265
+ const a = Math.abs(v);
266
+ if (a > peak) peak = a;
267
+ }
268
+ }
269
+ }
270
+ return peak;
271
+ }
272
+ function measureLoudness(channels, sampleRate, opts = {}) {
273
+ let samplePeak = 0, clipped = 0;
274
+ for (const c of channels) for (let i = 0; i < c.length; i++) {
275
+ const a = Math.abs(c[i]);
276
+ if (a > samplePeak) samplePeak = a;
277
+ if (a >= 1) clipped++;
278
+ }
279
+ const stages = kWeighting(sampleRate);
280
+ const weighted = channels.map((c) => filter(c, stages));
281
+ const block = Math.round(0.4 * sampleRate), hop = Math.round(0.1 * sampleRate);
282
+ const energies = [];
283
+ for (let s = 0; s + block <= (weighted[0]?.length ?? 0); s += hop) {
284
+ let sum = 0;
285
+ for (const w of weighted) {
286
+ let z = 0;
287
+ for (let i = s; i < s + block; i++) z += w[i] * w[i];
288
+ sum += z / block;
289
+ }
290
+ energies.push(sum);
291
+ }
292
+ const lufs = (e) => -0.691 + 10 * Math.log10(e);
293
+ const absolute = energies.filter((e) => e > 0 && lufs(e) > -70);
294
+ let integrated = null;
295
+ if (absolute.length) {
296
+ const relative = lufs(absolute.reduce((a, b) => a + b, 0) / absolute.length) - 10;
297
+ const gated = absolute.filter((e) => lufs(e) > relative);
298
+ if (gated.length) integrated = Math.round(10 * lufs(gated.reduce((a, b) => a + b, 0) / gated.length)) / 10;
299
+ }
300
+ return { integratedLufs: integrated, truePeakDb: opts.truePeak === false ? null : toDb(truePeak(channels)), samplePeakDb: toDb(samplePeak), clippedSamples: clipped };
301
+ }
302
+ function findSilences(channels, sampleRate, opts = {}) {
303
+ const min = Math.round((opts.minLength ?? 1) * sampleRate), limit = 10 ** ((opts.thresholdDb ?? -60) / 20);
304
+ const n = channels[0]?.length ?? 0, out = [];
305
+ let start = -1;
306
+ const quiet = (i) => channels.every((c) => Math.abs(c[i]) < limit);
307
+ for (let i = 0; i <= n; i++) {
308
+ const q = i < n && quiet(i);
309
+ if (q && start < 0) start = i;
310
+ if (!q && start >= 0) {
311
+ if (i - start >= min) out.push({ from: Math.round(start / sampleRate * 1e3) / 1e3, to: Math.round(i / sampleRate * 1e3) / 1e3 });
312
+ start = -1;
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
206
318
  // src/core/inspectAudio.ts
207
319
  var INAUDIBLE_DB = -50;
208
320
  var db = (x) => Math.round(10 * Math.max(-120, 20 * Math.log10(x))) / 10;
@@ -228,7 +340,19 @@ function measure(chans, sampleRate) {
228
340
  }
229
341
  function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
230
342
  if (!mix) {
231
- 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 })'] };
343
+ return {
344
+ duration: movieDuration,
345
+ sampleRate: 0,
346
+ peakDb: -120,
347
+ peakAt: 0,
348
+ sources: [],
349
+ windows: [],
350
+ loudness: { integratedLufs: null, truePeakDb: -120, clippedSamples: 0, silences: [] },
351
+ scenes: [],
352
+ cues: [],
353
+ notes: [],
354
+ issues: ['no audio: the movie has no audio layers (sound effects need no files: { type: "audio", sfx: "pop", at: 1 })']
355
+ };
232
356
  }
233
357
  const sr = mix.sampleRate;
234
358
  const chans = Array.from({ length: mix.numberOfChannels }, (_, c) => mix.getChannelData(c));
@@ -286,6 +410,24 @@ function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
286
410
  }
287
411
  return { layer, source, start: s.start, end: s.end, peakDb, sound };
288
412
  });
413
+ const whole = measureLoudness(chans, sr);
414
+ const silences = findSilences(chans, sr, { minLength: 1 });
415
+ 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`);
416
+ 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`);
417
+ const notes = [];
418
+ if (whole.integratedLufs !== null) {
419
+ 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`);
420
+ else if (whole.integratedLufs > LOUDNESS_NOTES.loudAbove) notes.push(`the mix is loud: ${whole.integratedLufs} LUFS (typical web video \u221214 to \u221216)`);
421
+ }
422
+ 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)`);
423
+ 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)`);
424
+ if (silences.length > 3) notes.push(`and ${silences.length - 3} more silent stretch(es) (loudness.silences lists them all)`);
425
+ const sceneList = (opts.scenes && opts.scenes.length ? opts.scenes : [{ name: "movie", start: 0, end: movieDuration }]).map((sc) => {
426
+ const a = Math.max(0, Math.round(sc.start * sr)), b = Math.min(mix.length, Math.round(sc.end * sr));
427
+ const part = chans.map((c) => c.subarray(a, Math.max(a, b)));
428
+ const m = measureLoudness(part, sr, { truePeak: false });
429
+ return { name: sc.name, from: sc.start, to: sc.end, lufs: m.integratedLufs, peakDb: m.samplePeakDb, silent: m.samplePeakDb < -60 };
430
+ });
289
431
  return {
290
432
  duration: movieDuration,
291
433
  sampleRate: sr,
@@ -293,8 +435,69 @@ function analyzeAudio(mix, sources, stats, movieDuration, opts = {}) {
293
435
  peakAt: stats?.peakAt ?? 0,
294
436
  sources: list,
295
437
  windows: windows2,
296
- issues
438
+ issues,
439
+ loudness: { integratedLufs: whole.integratedLufs, truePeakDb: whole.truePeakDb ?? -120, clippedSamples: whole.clippedSamples, silences },
440
+ scenes: sceneList,
441
+ cues: list.map((s) => ({ t: s.start, name: `${s.layer} (${s.source})` })),
442
+ notes
443
+ };
444
+ }
445
+
446
+ // src/text/fonts.ts
447
+ 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"]);
448
+ function fontAvailable(family2) {
449
+ const f = family2.trim().replace(/^["']|["']$/g, "");
450
+ if (!f || GENERIC.has(f.toLowerCase())) return true;
451
+ const g = document.createElement("canvas").getContext("2d");
452
+ if (!g) return true;
453
+ const sample = "mmmmmmmmmmlliWWWW 0123 \u3042\u3044\u3046";
454
+ const width = (stack) => {
455
+ g.font = `72px ${stack}`;
456
+ return g.measureText(sample).width;
457
+ };
458
+ const q = JSON.stringify(f);
459
+ return width(`${q}, monospace`) !== width("monospace") || width(`${q}, serif`) !== width("serif");
460
+ }
461
+
462
+ // src/core/inspectFonts.ts
463
+ function inspectFonts(root) {
464
+ const failed = /* @__PURE__ */ new Set();
465
+ if (typeof document !== "undefined" && document.fonts) document.fonts.forEach((face) => {
466
+ if (face.status === "error") failed.add(face.family.replace(/^["']|["']$/g, ""));
467
+ });
468
+ const missing = [];
469
+ const walk2 = (seq, index) => {
470
+ const spec = seq.spec;
471
+ if (spec.type === "text") {
472
+ const raw = spec.style?.fontFamily;
473
+ const family2 = Array.isArray(raw) ? raw.join(", ") : typeof raw === "string" ? raw : "";
474
+ const list = family2.split(",").map((f) => f.trim()).filter(Boolean);
475
+ if (list.length && !list.some(fontAvailable) && !list.some((f) => failed.has(f.replace(/^["']|["']$/g, "")))) missing.push({ layer: spec.name ?? `text#${index}`, family: family2 });
476
+ }
477
+ if (seq.maskSequence) walk2(seq.maskSequence, index);
478
+ (seq._children ?? []).forEach((child, i) => walk2(child, i));
297
479
  };
480
+ walk2(root, 0);
481
+ return { missing, failed: [...failed] };
482
+ }
483
+
484
+ // src/core/scenes.ts
485
+ function namedScenes(rows) {
486
+ 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 }));
487
+ }
488
+
489
+ // src/core/onion.ts
490
+ function onionTimes(from, to, count) {
491
+ if (count <= 1) return [from];
492
+ return Array.from({ length: count }, (_, i) => Math.round((from + (to - from) * i / (count - 1)) * 1e6) / 1e6);
493
+ }
494
+ function onionAlphas(count, ramp = 0.25) {
495
+ const weights = Array.from({ length: count }, (_, i) => count === 1 ? 1 : ramp + (1 - ramp) * (i / (count - 1)));
496
+ let sum = 0;
497
+ return weights.map((w) => {
498
+ sum += w;
499
+ return w / sum;
500
+ });
298
501
  }
299
502
 
300
503
  // src/core/motionBlur.ts
@@ -313,6 +516,57 @@ function blurTimes(frame, frameRate, mb, duration) {
313
516
  return Array.from({ length: mb.samples }, (_, k) => Math.min(Math.max(t + ((k + 0.5) / mb.samples - 0.5) * width, 0), duration));
314
517
  }
315
518
 
519
+ // src/core/renderRange.ts
520
+ function resolveRange(range2, ctx) {
521
+ const { frameRate: fps, totalFrames, duration } = ctx;
522
+ if (range2 === void 0) return { fromFrame: 0, toFrame: totalFrames, fromSec: 0, toSec: duration, partial: false };
523
+ let from, to;
524
+ if (typeof range2 === "string") {
525
+ const rows = (ctx.rows ?? []).filter((r) => r.depth === 0);
526
+ const row = rows.find((r) => r.name === range2);
527
+ if (!row) throw new Error(`pixi-effects: render({ range: "${range2}" }): no layer named "${range2}" at the top level (names: ${rows.map((r) => r.name).join(", ") || "none"})`);
528
+ from = row.start;
529
+ to = row.end;
530
+ } else {
531
+ if (!Array.isArray(range2) || range2.length !== 2 || !range2.every((n) => typeof n === "number" && Number.isFinite(n))) {
532
+ throw new Error("pixi-effects: render({ range }) must be [from, to] in seconds (numbers) or the name of a top-level layer");
533
+ }
534
+ [from, to] = range2;
535
+ }
536
+ 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)`);
537
+ 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`);
538
+ const fromFrame = Math.round(from * fps);
539
+ const toFrame = Math.min(Math.round(to * fps), totalFrames);
540
+ 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)`);
541
+ const partial = fromFrame > 0 || toFrame < totalFrames;
542
+ return { fromFrame, toFrame, fromSec: fromFrame / fps, toSec: toFrame / fps, partial };
543
+ }
544
+ function sliceChannels(channels, sampleRate, fromSec, toSec, opts = {}) {
545
+ const fade = Math.max(0, Math.round((opts.fade ?? 0.01) * sampleRate));
546
+ const from = Math.round(fromSec * sampleRate), len = Math.max(1, Math.round(toSec * sampleRate) - from);
547
+ return channels.map((src) => {
548
+ const out = new Float32Array(len);
549
+ for (let i = 0; i < len; i++) {
550
+ const j = from + i;
551
+ if (j >= 0 && j < src.length) out[i] = src[j];
552
+ }
553
+ const f = Math.min(fade, len >> 1);
554
+ if (opts.fadeIn !== false) for (let i = 0; i < f; i++) out[i] = out[i] * (i / f);
555
+ if (opts.fadeOut !== false) for (let i = 0; i < f; i++) out[len - 1 - i] = out[len - 1 - i] * (i / f);
556
+ return out;
557
+ });
558
+ }
559
+ function resolveRenderOptions(o) {
560
+ const draft = !!o.draft;
561
+ const scale = o.scale ?? (draft ? 0.5 : 1);
562
+ 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})`);
563
+ return {
564
+ scale,
565
+ bitrate: o.video?.bitrate ?? (draft ? "low" : void 0),
566
+ motionBlur: o.motionBlur !== void 0 ? o.motionBlur : draft ? false : void 0
567
+ };
568
+ }
569
+
316
570
  // src/core/Renderer.ts
317
571
  var VIDEO_CODEC_BY_FORMAT = {
318
572
  mp4: "avc",
@@ -367,13 +621,22 @@ function makeOutputFormat(name) {
367
621
  return new mediabunny.MkvOutputFormat();
368
622
  }
369
623
  }
624
+ function sliceAudio(buffer, fromSec, toSec, fadeIn, fadeOut) {
625
+ const channels = Array.from({ length: buffer.numberOfChannels }, (_, c) => buffer.getChannelData(c));
626
+ const cut = sliceChannels(channels, buffer.sampleRate, fromSec, toSec, { fadeIn, fadeOut });
627
+ const out = new AudioBuffer({ length: cut[0].length, numberOfChannels: cut.length, sampleRate: buffer.sampleRate });
628
+ cut.forEach((data, c) => out.copyToChannel(data, c));
629
+ return out;
630
+ }
370
631
  async function exportFrames(movie, options = {}) {
371
632
  const fmt = options.format ?? "mp4";
633
+ const eff = resolveRenderOptions(options);
634
+ const range2 = resolveRange(options.range, { frameRate: movie.frameRate, totalFrames: movie.totalFrames, duration: movie.duration, rows: typeof options.range === "string" ? movie.timelineData().rows : void 0 });
372
635
  const opts = {
373
636
  format: fmt,
374
637
  video: {
375
638
  codec: options.video?.codec ?? VIDEO_CODEC_BY_FORMAT[fmt],
376
- bitrate: qualityMap[options.video?.bitrate ?? "high"] ?? mediabunny.QUALITY_HIGH
639
+ bitrate: qualityMap[eff.bitrate ?? "high"] ?? mediabunny.QUALITY_HIGH
377
640
  },
378
641
  audio: {
379
642
  codec: options.audio?.codec ?? AUDIO_CODEC_BY_FORMAT[fmt],
@@ -381,27 +644,30 @@ async function exportFrames(movie, options = {}) {
381
644
  }
382
645
  };
383
646
  const container = makeOutputFormat(opts.format);
384
- if (movie.audioBuffer) opts.audio.codec = await chooseAudioCodec(fmt, options.audio?.codec, container, movie.audioBuffer, opts.audio.bitrate);
647
+ const audio = movie.audioBuffer && range2.partial ? sliceAudio(movie.audioBuffer, range2.fromSec, range2.toSec, range2.fromFrame > 0, range2.toFrame < movie.totalFrames) : movie.audioBuffer;
648
+ if (audio) opts.audio.codec = await chooseAudioCodec(fmt, options.audio?.codec, container, audio, opts.audio.bitrate);
385
649
  const output = new mediabunny.Output({
386
650
  format: container,
387
651
  target: new mediabunny.BufferTarget()
388
652
  });
389
- const mb = options.motionBlur !== void 0 ? resolveMotionBlur(options.motionBlur, "movie.render()") : movie.motionBlur ?? null;
653
+ const mb = eff.motionBlur !== void 0 ? resolveMotionBlur(eff.motionBlur, "movie.render()") : movie.motionBlur ?? null;
390
654
  const stage = movie.app.canvas;
391
655
  const blurCanvas = mb ? Object.assign(document.createElement("canvas"), { width: stage.width, height: stage.height }) : null;
392
- const canvasSource = new mediabunny.CanvasSource(blurCanvas ?? stage, {
656
+ 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;
657
+ const smallCtx = small?.getContext("2d") ?? null;
658
+ const canvasSource = new mediabunny.CanvasSource(small ?? blurCanvas ?? stage, {
393
659
  codec: opts.video.codec,
394
660
  bitrate: opts.video.bitrate
395
661
  });
396
662
  output.addVideoTrack(canvasSource, { frameRate: movie.frameRate });
397
- if (movie.audioBuffer) {
663
+ if (audio) {
398
664
  const audioSource = new mediabunny.AudioBufferSource({
399
665
  codec: opts.audio.codec,
400
666
  bitrate: opts.audio.bitrate
401
- }, { startTimestamp: opts.audio.codec === "aac" ? -2112 / movie.audioBuffer.sampleRate : 0 });
667
+ }, { startTimestamp: opts.audio.codec === "aac" ? -2112 / audio.sampleRate : 0 });
402
668
  output.addAudioTrack(audioSource);
403
669
  await output.start();
404
- await audioSource.add(movie.audioBuffer);
670
+ await audioSource.add(audio);
405
671
  await audioSource.close();
406
672
  } else {
407
673
  await output.start();
@@ -409,13 +675,16 @@ async function exportFrames(movie, options = {}) {
409
675
  movie.app.ticker.stop();
410
676
  const keyframeIntervalFrames = Math.max(1, Math.round(2 * movie.frameRate));
411
677
  try {
412
- for (let frame = 0; frame <= movie.totalFrames; frame++) {
678
+ const count = Math.max(1, range2.toFrame - range2.fromFrame);
679
+ for (let frame = range2.fromFrame; frame <= range2.toFrame; frame++) {
413
680
  if (mb && blurCanvas) await movie._exposeFrame(frame, mb, blurCanvas, false);
414
681
  else await movie.gotoFrame(frame, true);
415
- const isKey = frame === 0 || frame % keyframeIntervalFrames === 0;
682
+ if (small && smallCtx) smallCtx.drawImage(blurCanvas ?? stage, 0, 0, small.width, small.height);
683
+ const rel = frame - range2.fromFrame;
684
+ const isKey = rel === 0 || rel % keyframeIntervalFrames === 0;
416
685
  const addOpts = isKey ? { keyFrame: true } : void 0;
417
- await canvasSource.add(frame / movie.frameRate, 1 / movie.frameRate, addOpts);
418
- const progress = Math.floor(frame / movie.totalFrames * 100);
686
+ await canvasSource.add(rel / movie.frameRate, 1 / movie.frameRate, addOpts);
687
+ const progress = Math.floor(rel / count * 100);
419
688
  movie.emit("progress", { progress, frame, totalFrames: movie.totalFrames });
420
689
  }
421
690
  await canvasSource.close();
@@ -867,16 +1136,16 @@ function expandTransitions(spec) {
867
1136
  if (toUses > 1) {
868
1137
  throw new Error(`pixi-effects: ${tag} sequence "${t.to}" is used as \`to\` in more than one transition`);
869
1138
  }
870
- const tStart = chunkFJA5VUOF_cjs.resolveAt(t.at, parentDuration);
1139
+ const tStart = chunkLBJRXU7N_cjs.resolveAt(t.at, parentDuration);
871
1140
  const tEnd = tStart + t.duration;
872
1141
  const fromSeq = fromEntry.seq;
873
- const fromStart = chunkFJA5VUOF_cjs.resolveAt(fromSeq.at, parentDuration);
1142
+ const fromStart = chunkLBJRXU7N_cjs.resolveAt(fromSeq.at, parentDuration);
874
1143
  const fromEnd = fromStart + (fromSeq.duration ?? parentDuration);
875
1144
  if (tStart < fromStart - EPS || tEnd > fromEnd + EPS) {
876
1145
  throw new Error(`pixi-effects: ${tag} window [${tStart}, ${tEnd}] is not covered by \`from\` "${t.from}" (lives [${fromStart}, ${fromEnd}], so it ends at ${fromEnd})`);
877
1146
  }
878
1147
  const toSeq = toEntry.seq;
879
- const toStart = chunkFJA5VUOF_cjs.resolveAt(toSeq.at, parentDuration);
1148
+ const toStart = chunkLBJRXU7N_cjs.resolveAt(toSeq.at, parentDuration);
880
1149
  const toEnd = toStart + (toSeq.duration ?? parentDuration);
881
1150
  if (tStart < toStart - EPS || tEnd > toEnd + EPS) {
882
1151
  throw new Error(`pixi-effects: ${tag} window [${tStart}, ${tEnd}] is not covered by \`to\` "${t.to}" (lives [${toStart}, ${toEnd}], so it starts at ${toStart})`);
@@ -1216,9 +1485,9 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
1216
1485
  onCanvas = inter === 0 && area > 0 ? "none" : inter >= area - 1e-6 ? "full" : "partial";
1217
1486
  if (area === 0) onCanvas = "none";
1218
1487
  }
1219
- const keys = chunkFJA5VUOF_cjs.collectPropKeys(seq.spec);
1488
+ const keys = chunkLBJRXU7N_cjs.collectPropKeys(seq.spec);
1220
1489
  const carried = carriers.some((c) => {
1221
- const k = chunkFJA5VUOF_cjs.collectPropKeys(c.spec);
1490
+ const k = chunkLBJRXU7N_cjs.collectPropKeys(c.spec);
1222
1491
  return (c.spec.keyframes ?? []).length > 0 && (k.has("x") || k.has("y") || k.has("rotation") || k.has("scale"));
1223
1492
  });
1224
1493
  const moving = parentMoving || carried || (seq.spec.keyframes ?? []).length > 0 && (keys.has("x") || keys.has("y"));
@@ -1239,7 +1508,7 @@ function inspectScene(root, frame, time, canvas, opts = {}) {
1239
1508
  const group = parentScene ?? (name !== void 0 && blending.has(name) ? name : void 0);
1240
1509
  if (group) scene.set(info, group);
1241
1510
  if (zero || seq.showsNothingYet) scaledToNothing.add(info);
1242
- if (seq instanceof chunk2ROVCPSL_cjs.CompositionSequence) walk2(seq, prefix + label + "/", insideThreeD || threeD, visible, group, moving, zero);
1511
+ if (seq instanceof chunkETAIVOHP_cjs.CompositionSequence) walk2(seq, prefix + label + "/", insideThreeD || threeD, visible, group, moving, zero);
1243
1512
  });
1244
1513
  };
1245
1514
  walk2(root, "", false, true);
@@ -1300,10 +1569,10 @@ function walk(comp, parentStart, parentEnd, depth, prefix, rows, transitions) {
1300
1569
  const start = parentStart + (seq.at ?? 0);
1301
1570
  const end = Math.min(parentEnd, seq.duration !== void 0 ? start + seq.duration : parentEnd);
1302
1571
  const span = end - start;
1303
- const keys = (seq.spec.keyframes ?? []).map((kf) => round(start + chunkFJA5VUOF_cjs.resolveAt(kf.at, span))).sort((a, b) => a - b).slice(0, MAX_KEYS);
1572
+ const keys = (seq.spec.keyframes ?? []).map((kf) => round(start + chunkLBJRXU7N_cjs.resolveAt(kf.at, span))).sort((a, b) => a - b).slice(0, MAX_KEYS);
1304
1573
  const row = { path: prefix + name, name, type: seq.spec.type, depth, start: round(start), end: round(end), keys, detail: detailOf(seq) };
1305
1574
  const inner = [];
1306
- const isComp = seq instanceof chunk2ROVCPSL_cjs.CompositionSequence;
1575
+ const isComp = seq instanceof chunkETAIVOHP_cjs.CompositionSequence;
1307
1576
  if (isComp) walk(seq, start, end, depth + 1, prefix + name + "/", inner, transitions);
1308
1577
  entries.push({ row, inner, groupable: !isComp });
1309
1578
  });
@@ -1851,7 +2120,7 @@ var Movie = class {
1851
2120
  height: options.composition.height ?? this.height,
1852
2121
  duration: options.composition.duration ?? this.duration
1853
2122
  } : void 0;
1854
- await chunkFJA5VUOF_cjs.ensureFilterLibrary(seededComposition);
2123
+ await chunkLBJRXU7N_cjs.ensureFilterLibrary(seededComposition);
1855
2124
  const userComposition = seededComposition ? expandTransitions(seededComposition) : void 0;
1856
2125
  const rootSeqSpec = {
1857
2126
  type: "composition",
@@ -1861,7 +2130,7 @@ var Movie = class {
1861
2130
  ...userComposition
1862
2131
  };
1863
2132
  if (userComposition) carryTransitionWindows(userComposition, rootSeqSpec);
1864
- const composition = new chunk2ROVCPSL_cjs.CompositionSequence(rootSeqSpec, null, rootShape);
2133
+ const composition = new chunkETAIVOHP_cjs.CompositionSequence(rootSeqSpec, null, rootShape);
1865
2134
  await composition.build();
1866
2135
  this._rootSequence = composition;
1867
2136
  if (composition.target) root.addChild(composition.target);
@@ -2059,6 +2328,37 @@ var Movie = class {
2059
2328
  })));
2060
2329
  return new Blob([buildPdf(pages, { title: opts.title })], { type: "application/pdf" });
2061
2330
  }
2331
+ async onionSkin(opts = {}) {
2332
+ chunkUQBGW5DO_cjs.warnUnknownOptions("movie.onionSkin()", opts, ["from", "to", "count", "scale", "as", "motionBlur"]);
2333
+ this._requireReady("onionSkin");
2334
+ const from = opts.from ?? 0, to = opts.to ?? this.duration, count = opts.count ?? 8, scale = opts.scale ?? 1;
2335
+ 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}`);
2336
+ 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}`);
2337
+ if (!(scale > 0 && scale <= 4)) throw new Error(`pixi-effects: movie.onionSkin(): scale must be above 0 and at most 4, got ${scale}`);
2338
+ const mb = this._blurFor(opts.motionBlur, "movie.onionSkin()");
2339
+ const blurred = mb ? this._blurCanvas() : null;
2340
+ const out = document.createElement("canvas");
2341
+ out.width = Math.max(1, Math.round(this.width * scale));
2342
+ out.height = Math.max(1, Math.round(this.height * scale));
2343
+ const g = out.getContext("2d");
2344
+ const alphas = onionAlphas(count);
2345
+ const frames = onionTimes(from, to, count).map((t) => clampFrame(t * this.frameRate, this.totalFrames));
2346
+ const back = this.currentFrame, wasAtPoster = this._atPoster;
2347
+ try {
2348
+ for (let i = 0; i < frames.length; i++) {
2349
+ const f = frames[i];
2350
+ if (mb && blurred) await this._quietly(() => this._exposeFrame(f, mb, blurred, true));
2351
+ else await this._quietly(() => this.gotoFrame(f, true));
2352
+ g.globalAlpha = alphas[i];
2353
+ g.drawImage(blurred ?? this.app.canvas, 0, 0, out.width, out.height);
2354
+ }
2355
+ } finally {
2356
+ g.globalAlpha = 1;
2357
+ if (wasAtPoster) await this._showPoster();
2358
+ else await this._quietly(() => this.gotoFrame(back, true));
2359
+ }
2360
+ return encodeCanvas(out, "image/png", opts.as ?? "blob");
2361
+ }
2062
2362
  async contactSheet(opts = {}) {
2063
2363
  chunkUQBGW5DO_cjs.warnUnknownOptions("movie.contactSheet()", opts, ["frames", "times", "count", "columns", "cellWidth", "as", "motionBlur"]);
2064
2364
  this._requireReady("contactSheet");
@@ -2125,10 +2425,19 @@ var Movie = class {
2125
2425
  * The soundtrack, measured: when every audio layer plays, how loud the mix is over time, and `issues`
2126
2426
  * (limiting, inaudible layers, sounds cut off by the end). Use it to CHECK sound you cannot hear.
2127
2427
  */
2428
+ /**
2429
+ * The fonts of the text layers: `missing` lists layers none of whose `fontFamily` entries is available in this browser (they are
2430
+ * drawn in a fallback font), `failed` lists web fonts (`@font-face`) whose file could not be loaded.
2431
+ */
2432
+ inspectFonts() {
2433
+ this._requireReady("inspectFonts");
2434
+ return inspectFonts(this._rootSequence);
2435
+ }
2128
2436
  inspectAudio(opts = {}) {
2129
- chunkUQBGW5DO_cjs.warnUnknownOptions("movie.inspectAudio()", opts, ["window"]);
2437
+ chunkUQBGW5DO_cjs.warnUnknownOptions("movie.inspectAudio()", opts, ["window", "scenes"]);
2130
2438
  this._requireReady("inspectAudio");
2131
- return analyzeAudio(this.audioBuffer, this._audioSources, this._mixStats, this.duration, opts);
2439
+ const scenes = opts.scenes ?? namedScenes(this.timelineData().rows);
2440
+ return analyzeAudio(this.audioBuffer, this._audioSources, this._mixStats, this.duration, { ...opts, scenes });
2132
2441
  }
2133
2442
  /**
2134
2443
  * Draw the stage now. PixiJS's CullerPlugin only culls inside `app.render()` (the ticker), and we render
@@ -2396,7 +2705,7 @@ var Movie = class {
2396
2705
  return this.muted;
2397
2706
  }
2398
2707
  async render(options) {
2399
- chunkUQBGW5DO_cjs.warnUnknownOptions("movie.render()", options, ["format", "video", "audio", "motionBlur"]);
2708
+ chunkUQBGW5DO_cjs.warnUnknownOptions("movie.render()", options, ["format", "video", "audio", "motionBlur", "range", "scale", "draft"]);
2400
2709
  try {
2401
2710
  return await this._quietly(() => exportFrames(this, options));
2402
2711
  } catch (err) {
@@ -2607,6 +2916,7 @@ function clamp(v, lo, hi) {
2607
2916
  return Math.max(lo, Math.min(hi, v));
2608
2917
  }
2609
2918
  function parseEase(name) {
2919
+ chunkLBJRXU7N_cjs.checkEase(name, "a preset");
2610
2920
  return gsap.gsap.parseEase(name);
2611
2921
  }
2612
2922
 
@@ -2629,17 +2939,17 @@ function orbit(opts) {
2629
2939
  throw new Error("pixi-effects: orbit() cannot combine `radius` with `dollyZoom` \u2014 the radius follows the animated fov");
2630
2940
  }
2631
2941
  const [cx, cy] = opts.center ?? [width / 2, height / 2];
2632
- const fixedR = opts.radius ?? chunkFJA5VUOF_cjs.homeDistance(height, fov ?? chunkFJA5VUOF_cjs.DEFAULT_FOV);
2942
+ const fixedR = opts.radius ?? chunkLBJRXU7N_cjs.homeDistance(height, fov ?? chunkLBJRXU7N_cjs.DEFAULT_FOV);
2633
2943
  const start = opts.start ?? -degrees / 2;
2634
2944
  const steps = Math.max(1, Math.round(duration * stepsPerSecond));
2635
2945
  const easeFn = parseEase(ease);
2636
2946
  const dollyEase = dolly ? parseEase(dolly.ease ?? ease) : null;
2637
2947
  const pos = (i) => {
2638
2948
  const p = i / steps;
2639
- const a = (start + degrees * easeFn(p)) * chunkFJA5VUOF_cjs.DEG;
2949
+ const a = (start + degrees * easeFn(p)) * chunkLBJRXU7N_cjs.DEG;
2640
2950
  if (!dolly) return { x: cx + fixedR * Math.sin(a), z: fixedR * Math.cos(a) };
2641
2951
  const f = dolly.from + (dolly.to - dolly.from) * dollyEase(p);
2642
- const R = chunkFJA5VUOF_cjs.homeDistance(height, f);
2952
+ const R = chunkLBJRXU7N_cjs.homeDistance(height, f);
2643
2953
  return { x: cx + R * Math.sin(a), z: R * Math.cos(a), fov: f };
2644
2954
  };
2645
2955
  const keyframes = [];
@@ -2694,7 +3004,7 @@ function particles(opts) {
2694
3004
  const round3 = (v) => Math.round(v * 1e6) / 1e6;
2695
3005
  const out = [];
2696
3006
  for (let i = 0; i < count; i++) {
2697
- const r = chunkFJA5VUOF_cjs.random(seed * 1000003 + i * 7919 + 17);
3007
+ const r = chunkLBJRXU7N_cjs.random(seed * 1000003 + i * 7919 + 17);
2698
3008
  const pick = ([a2, b]) => a2 + r() * (b - a2);
2699
3009
  const born = at + r() * emit;
2700
3010
  const lifetime = pick(life);
@@ -2707,7 +3017,7 @@ function particles(opts) {
2707
3017
  const turn = spin ? pick(spin) : 0;
2708
3018
  const startAngle = spin ? r() * 360 : 0;
2709
3019
  const swayAmp = sway?.amp ?? 0, swayFreq = sway?.freq ?? 1;
2710
- const n0 = sway ? chunkFJA5VUOF_cjs.noise(0, seed * 1000003 + i) : 0;
3020
+ const n0 = sway ? chunkLBJRXU7N_cjs.noise(0, seed * 1000003 + i) : 0;
2711
3021
  const at_ = (t) => {
2712
3022
  const axis = (p0, v0, acc) => {
2713
3023
  if (drag > 0) {
@@ -2716,7 +3026,7 @@ function particles(opts) {
2716
3026
  }
2717
3027
  return p0 + v0 * t + 0.5 * acc * t * t;
2718
3028
  };
2719
- const sw = sway ? swayAmp * (chunkFJA5VUOF_cjs.noise(t * swayFreq, seed * 1000003 + i) - n0) : 0;
3029
+ const sw = sway ? swayAmp * (chunkLBJRXU7N_cjs.noise(t * swayFreq, seed * 1000003 + i) - n0) : 0;
2720
3030
  return { x: axis(x0, vx, wind) + sw, y: axis(y0, vy, gravity) };
2721
3031
  };
2722
3032
  const kfs = [];
@@ -2778,7 +3088,7 @@ function followPath(opts) {
2778
3088
  if (steps > MAX_STEPS) throw new Error(`followPath(): ${steps} samples is too many (max ${MAX_STEPS}); lower frameRate or duration`);
2779
3089
  const segments = [];
2780
3090
  let total = 0;
2781
- for (const line of chunkFJA5VUOF_cjs.flattenSvgPath(d)) {
3091
+ for (const line of chunkLBJRXU7N_cjs.flattenSvgPath(d)) {
2782
3092
  const p = line.closed ? [...line.pts, line.pts[0], line.pts[1]] : line.pts;
2783
3093
  for (let i = 2; i < p.length; i += 2) {
2784
3094
  const len = Math.hypot(p[i] - p[i - 2], p[i + 1] - p[i - 1]);
@@ -2931,7 +3241,7 @@ function distances(n, from, grid, seed) {
2931
3241
  if (n === 0) return [];
2932
3242
  if (from === "random") {
2933
3243
  const order = Array.from({ length: n }, (_, i) => i);
2934
- const r = chunkFJA5VUOF_cjs.random(seed);
3244
+ const r = chunkLBJRXU7N_cjs.random(seed);
2935
3245
  for (let i = n - 1; i > 0; i--) {
2936
3246
  const j = Math.floor(r() * (i + 1));
2937
3247
  [order[i], order[j]] = [order[j], order[i]];
@@ -2993,7 +3303,7 @@ function wiggle(opts) {
2993
3303
  to[n] = p.around;
2994
3304
  return;
2995
3305
  }
2996
- const off = (chunkFJA5VUOF_cjs.rand(seed * 7919 + pi * 104729 + s * 31 + 1) * 2 - 1) * p.amp;
3306
+ const off = (chunkLBJRXU7N_cjs.rand(seed * 7919 + pi * 104729 + s * 31 + 1) * 2 - 1) * p.amp;
2997
3307
  to[n] = typeof p.around === "number" ? p.around + off : `(${p.around}) ${off < 0 ? "-" : "+"} ${Math.abs(off)}`;
2998
3308
  });
2999
3309
  out.push({ at: at + s * stepLen, to, duration: stepLen, ease });
@@ -3458,19 +3768,19 @@ function withFade(spec, opts) {
3458
3768
 
3459
3769
  Object.defineProperty(exports, "noise", {
3460
3770
  enumerable: true,
3461
- get: function () { return chunkFJA5VUOF_cjs.noise; }
3771
+ get: function () { return chunkLBJRXU7N_cjs.noise; }
3462
3772
  });
3463
3773
  Object.defineProperty(exports, "rand", {
3464
3774
  enumerable: true,
3465
- get: function () { return chunkFJA5VUOF_cjs.rand; }
3775
+ get: function () { return chunkLBJRXU7N_cjs.rand; }
3466
3776
  });
3467
3777
  Object.defineProperty(exports, "random", {
3468
3778
  enumerable: true,
3469
- get: function () { return chunkFJA5VUOF_cjs.random; }
3779
+ get: function () { return chunkLBJRXU7N_cjs.random; }
3470
3780
  });
3471
3781
  Object.defineProperty(exports, "registerSequenceType", {
3472
3782
  enumerable: true,
3473
- get: function () { return chunkFJA5VUOF_cjs.registerSequenceType; }
3783
+ get: function () { return chunkLBJRXU7N_cjs.registerSequenceType; }
3474
3784
  });
3475
3785
  exports.Movie = Movie;
3476
3786
  exports.animateText = animateText;