pixi-effects 0.37.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +6 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/lookup.mjs +6 -3
  5. package/ai/tools/media-page.html +122 -0
  6. package/ai/tools/media.mjs +748 -0
  7. package/ai/tools/mediaBrowser.mjs +47 -0
  8. package/ai/tools/pixi-effects.mjs +11 -2
  9. package/dist/{Base-BlA6SeqJ.d.cts → Base-BKKwxavW.d.cts} +1 -1
  10. package/dist/{Base-YqM0Ljvp.d.ts → Base-ioh_AjLB.d.ts} +1 -1
  11. package/dist/Composition-23KGIJX4.cjs +16 -0
  12. package/dist/{Composition-MHDI7UNZ.cjs.map → Composition-23KGIJX4.cjs.map} +1 -1
  13. package/dist/Composition-5SQXSZNA.js +7 -0
  14. package/dist/{Composition-5KZETM4J.js.map → Composition-5SQXSZNA.js.map} +1 -1
  15. package/dist/Controller.d.cts +3 -2
  16. package/dist/Controller.d.ts +3 -2
  17. package/dist/{Movie-CfHaGmUP.d.ts → Movie-BtwFI2ne.d.ts} +3 -161
  18. package/dist/{Movie-CJN_gvLB.d.cts → Movie-CSWUTnMd.d.cts} +3 -161
  19. package/dist/{Spans-FSSO2HGX.js → Spans-4IBQASGK.js} +4 -4
  20. package/dist/{Spans-FSSO2HGX.js.map → Spans-4IBQASGK.js.map} +1 -1
  21. package/dist/{Spans-WVJH77BW.cjs → Spans-WJFPHMSQ.cjs} +9 -9
  22. package/dist/{Spans-WVJH77BW.cjs.map → Spans-WJFPHMSQ.cjs.map} +1 -1
  23. package/dist/{chunk-IPQNT65V.js → chunk-4CSL5QSL.js} +3 -3
  24. package/dist/{chunk-IPQNT65V.js.map → chunk-4CSL5QSL.js.map} +1 -1
  25. package/dist/{chunk-QHIOOK5S.cjs → chunk-7NXKBRT7.cjs} +4 -4
  26. package/dist/{chunk-QHIOOK5S.cjs.map → chunk-7NXKBRT7.cjs.map} +1 -1
  27. package/dist/{chunk-MJMOPWMK.cjs → chunk-FE42PKHP.cjs} +62 -62
  28. package/dist/{chunk-MJMOPWMK.cjs.map → chunk-FE42PKHP.cjs.map} +1 -1
  29. package/dist/{chunk-E26M4NTL.cjs → chunk-KNMDDUA3.cjs} +48 -453
  30. package/dist/chunk-KNMDDUA3.cjs.map +1 -0
  31. package/dist/chunk-LQDDDIC4.cjs +431 -0
  32. package/dist/chunk-LQDDDIC4.cjs.map +1 -0
  33. package/dist/{chunk-SBR75G73.js → chunk-UDTLYYGY.js} +4 -4
  34. package/dist/{chunk-SBR75G73.js.map → chunk-UDTLYYGY.js.map} +1 -1
  35. package/dist/chunk-UHNSL2KJ.js +425 -0
  36. package/dist/chunk-UHNSL2KJ.js.map +1 -0
  37. package/dist/{chunk-KNDEKWQO.js → chunk-XOCRS5MW.js} +9 -414
  38. package/dist/chunk-XOCRS5MW.js.map +1 -0
  39. package/dist/index.cjs +38 -33
  40. package/dist/index.d.cts +6 -5
  41. package/dist/index.d.ts +6 -5
  42. package/dist/index.js +4 -3
  43. package/dist/loader.css +1 -1
  44. package/dist/present.cjs +4 -3
  45. package/dist/present.cjs.map +1 -1
  46. package/dist/present.d.cts +3 -2
  47. package/dist/present.d.ts +3 -2
  48. package/dist/present.js +4 -3
  49. package/dist/present.js.map +1 -1
  50. package/dist/sound.cjs +12 -0
  51. package/dist/sound.cjs.map +1 -0
  52. package/dist/sound.d.cts +1 -0
  53. package/dist/sound.d.ts +1 -0
  54. package/dist/sound.js +3 -0
  55. package/dist/sound.js.map +1 -0
  56. package/dist/soundEntry-DT13DNt8.d.cts +191 -0
  57. package/dist/soundEntry-DT13DNt8.d.ts +191 -0
  58. package/dist/three.cjs +5 -5
  59. package/dist/three.d.cts +2 -2
  60. package/dist/three.d.ts +2 -2
  61. package/dist/three.js +1 -1
  62. package/dist/{types-DvnXXm33.d.cts → types-BXi-0iZS.d.cts} +1 -1
  63. package/dist/{types-DvnXXm33.d.ts → types-BXi-0iZS.d.ts} +1 -1
  64. package/docs/api.md +19 -0
  65. package/docs/media.md +136 -0
  66. package/llms-full.txt +170 -3
  67. package/llms.txt +1 -0
  68. package/package.json +2 -1
  69. package/skills/pixi-effects/SKILL.md +10 -2
  70. package/skills/pixi-effects/template.html +6 -6
  71. package/dist/Composition-5KZETM4J.js +0 -7
  72. package/dist/Composition-MHDI7UNZ.cjs +0 -16
  73. package/dist/chunk-E26M4NTL.cjs.map +0 -1
  74. package/dist/chunk-KNDEKWQO.js.map +0 -1
@@ -0,0 +1,748 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pixi-effects probe | trim | frames | sound — look at, listen to and cut footage, with no ffmpeg installed.
4
+ *
5
+ * npx pixi-effects probe <file|url> [--keyframes] [--json] what is in a file: container, duration, tracks, codecs, size, notes
6
+ * npx pixi-effects trim <file|url> <start> <end> -o <out> [--exact] [--force] [--json]
7
+ * cut [start, end] (seconds, or 1:02.5): by COPY when it can (no re-encode, no loss),
8
+ * and it says the real times; --exact re-encodes so the start is the frame asked for
9
+ * npx pixi-effects audio <file|url> -o <out.m4a> [--force] [--json] the sound of a file, copied out (no re-encode): .m4a for AAC, .webm for Opus, .mka for any
10
+ * npx pixi-effects concat <a> <b> [<c> …] -o <out> [--force] [--json] join clips by copy (no re-encode): they must have the same codecs, shown size, rotation and sound; it says where each clip starts
11
+ * npx pixi-effects frames <file|url> [--at 1,3.5,50%,f120,end,"every 2s"] [--size 480] [--sheet] [-o dir] [--json]
12
+ * PNGs of the frames at those times (default: 0, 25%, 50%, 75%, end), and a contact sheet
13
+ * npx pixi-effects sound <file|url> [--waveform] [-o dir] [--json] loudness (LUFS), true peak, clipping, silences, level over time; a waveform picture
14
+ *
15
+ * Every command prints a short report, or the same facts as JSON with --json. Exit code: 0 done · 1 a problem (the file, the browser, the job as asked) · 2 usage.
16
+ * `probe` and `trim` (copy) read and write containers in Node and need no browser. `frames`, `sound` and `trim --exact` decode or re-encode, so they use the headless
17
+ * Chrome that `check` and `render` use (--chrome PATH or CHROME_PATH). mediabunny comes from this package, so it works offline. Where ffmpeg is installed and the job is
18
+ * a long transcode, use ffmpeg: this is for looking, listening and cutting, with structured answers.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { createRequire } from 'node:module';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
27
+ const PACKAGE_ROOT = path.resolve(HERE, '../..');
28
+
29
+ export class UsageError extends Error { constructor(message) { super(message); this.name = 'UsageError'; this.exitCode = 2; } }
30
+
31
+ export const USAGE = `usage:
32
+ pixi-effects probe <file|url> [--keyframes] [--json]
33
+ pixi-effects trim <file|url> <start> <end> -o <out> [--exact] [--force] [--json]
34
+ pixi-effects audio <file|url> -o <out.m4a> [--force] [--json]
35
+ pixi-effects concat <a> <b> [<c> …] -o <out> [--force] [--json]
36
+ pixi-effects frames <file|url> [--at 1,3.5,50%,f120,end] [--size 480] [--sheet] [-o dir] [--json]
37
+ pixi-effects sound <file|url> [--waveform] [-o dir] [--json]`;
38
+
39
+ /** How each command reads its command line: the positional words, and each option with the kind of value it takes. */
40
+ const COMMANDS = {
41
+ probe: { positional: ['file'], flags: { '--keyframes': 'bool', '--json': 'bool' } },
42
+ trim: { positional: ['file', 'start', 'end'], flags: { '-o': 'str', '--out': 'str', '--exact': 'bool', '--force': 'bool', '--json': 'bool', '--chrome': 'str' } },
43
+ audio: { positional: ['file'], flags: { '-o': 'str', '--out': 'str', '--force': 'bool', '--json': 'bool' } },
44
+ concat: { positional: ['file', 'file'], variadic: true, flags: { '-o': 'str', '--out': 'str', '--force': 'bool', '--json': 'bool' } },
45
+ frames: { positional: ['file'], flags: { '--at': 'str', '--size': 'num', '--sheet': 'bool', '-o': 'str', '--out': 'str', '--json': 'bool', '--chrome': 'str' } },
46
+ sound: { positional: ['file'], flags: { '--waveform': 'bool', '--window': 'num', '-o': 'str', '--out': 'str', '--json': 'bool', '--chrome': 'str' } },
47
+ };
48
+ export const MEDIA_COMMANDS = Object.keys(COMMANDS);
49
+
50
+ function distance(a, b) {
51
+ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
52
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
53
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
54
+ return m[a.length][b.length];
55
+ }
56
+ const nearest = (word, list) => { const best = list.map(k => [k, distance(word, k)]).sort((x, y) => x[1] - y[1])[0]; return best && best[1] <= 2 ? best[0] : null; };
57
+
58
+ /** `12.5`, `1:02.5` (m:ss) or `1:02:03` (h:mm:ss) → seconds. */
59
+ export function parseTime(text, what = 'a time') {
60
+ const s = String(text).trim();
61
+ if (/^\d+(\.\d+)?$/.test(s)) return Number(s);
62
+ const m = s.match(/^(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
63
+ if (m) return Number(m[1] ?? 0) * 3600 + Number(m[2]) * 60 + Number(m[3]);
64
+ throw new UsageError(`${what} must be seconds (12.5) or m:ss (1:02.5), got "${text}"`);
65
+ }
66
+
67
+ /** The command line → `{ command, file, opts }`. A mistake is a UsageError (exit code 2) that says what to write. */
68
+ export function parseMediaArgs(argv) {
69
+ const [command, ...rest] = argv;
70
+ const spec = COMMANDS[command];
71
+ if (!spec) throw new UsageError(`unknown command "${command ?? ''}"${command && nearest(command, MEDIA_COMMANDS) ? `; did you mean "${nearest(command, MEDIA_COMMANDS)}"?` : ''}`);
72
+ const words = [], opts = {};
73
+ for (let i = 0; i < rest.length; i++) {
74
+ const a = rest[i];
75
+ if (a.startsWith('-') && a.length > 1 && !/^-\d/.test(a)) {
76
+ const kind = spec.flags[a];
77
+ if (!kind) {
78
+ const guess = nearest(a, Object.keys(spec.flags));
79
+ throw new UsageError(`${command}: unknown option ${a}${guess ? `; did you mean ${guess}?` : ''} (options: ${Object.keys(spec.flags).join(' ')})`);
80
+ }
81
+ const key = a.replace(/^-+/, '');
82
+ if (kind === 'bool') opts[key] = true;
83
+ else {
84
+ const v = rest[++i];
85
+ if (v === undefined) throw new UsageError(`${command}: ${a} needs a value`);
86
+ opts[key] = kind === 'num' ? Number(v) : v;
87
+ if (kind === 'num' && !Number.isFinite(opts[key])) throw new UsageError(`${command}: ${a} must be a number, got "${v}"`);
88
+ }
89
+ } else words.push(a);
90
+ }
91
+ if (opts.out && opts.o === undefined) opts.o = opts.out;
92
+ delete opts.out;
93
+ if (command === 'frames' && /^every$/i.test(opts.at ?? '')) throw new UsageError('frames: --at every needs its step in the same argument: write --at every:2s (or quote it: --at "every 2s"), also inside a list: --at "every:2s,end"');
94
+ if (words.length < spec.positional.length) throw new UsageError(`${command}: needs ${spec.variadic ? `at least ${spec.positional.length} files` : spec.positional.map(p => `<${p}>`).join(' ')}${words.length ? ` (got ${words.length})` : ''}`);
95
+ if (words.length > spec.positional.length && !spec.variadic) throw new UsageError(`${command}: unexpected "${words[spec.positional.length]}" (a path with spaces needs quotes)`);
96
+ const file = words[0];
97
+ if (command === 'concat') { opts.inputs = words; if (!opts.o) throw new UsageError('concat: needs -o <output file> (for example -o joined.mp4)'); opts.out = opts.o; delete opts.o; }
98
+ if (command === 'audio') { if (!opts.o) throw new UsageError('audio: needs -o <output file> (for example -o sound.m4a)'); opts.out = opts.o; delete opts.o; }
99
+ if (command === 'sound' && opts.window !== undefined && !(opts.window >= 0.05 && opts.window <= 60)) throw new UsageError(`sound: --window is the width of a level row in seconds, from 0.05 to 60 (got ${opts.window})`);
100
+ if (command === 'trim') {
101
+ opts.start = parseTime(words[1], 'start');
102
+ opts.end = parseTime(words[2], 'end');
103
+ if (!opts.o) throw new UsageError('trim: needs -o <output file> (for example -o cut.mp4)');
104
+ opts.out = opts.o; delete opts.o;
105
+ }
106
+ return { command, file, opts };
107
+ }
108
+
109
+ /** Is this the very same file (not just the same spelling: `a.mp4` and `A.MP4` are one file on a disk that ignores case, a link is one too)? */
110
+ export function sameFile(a, b) {
111
+ try { const x = fs.statSync(a), y = fs.statSync(b); return x.ino === y.ino && x.dev === y.dev; } catch { return path.resolve(a) === path.resolve(b); }
112
+ }
113
+
114
+ /**
115
+ * The output file's folder is made; an existing file is only replaced with --force. Returns `{ undo() }`: it removes the folders THIS call made (when they are empty), so a command
116
+ * that fails after this leaves no empty `newdir/sub` behind.
117
+ */
118
+ export function prepareOutput(out, { force = false } = {}) {
119
+ if (fs.existsSync(out) && !force) throw new Error(`${out} already exists: pass --force to overwrite it`);
120
+ const dir = path.dirname(path.resolve(out));
121
+ const made = [];
122
+ for (let d = dir; !fs.existsSync(d); d = path.dirname(d)) made.push(d); // deepest first
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ return { undo() { for (const d of made) { try { fs.rmdirSync(d); } catch { break; } } } };
125
+ }
126
+
127
+ const defaultIo = { log: (s) => console.log(s), err: (s) => console.error(s) };
128
+
129
+ /** Filled in by the commands (probe, trim, frames, sound). Each is `(parsed, io) => exit code`. */
130
+ export const commands = {};
131
+
132
+ const isUrl = (s) => /^https?:\/\//i.test(s);
133
+ const round = (n, d = 2) => Math.round(n * 10 ** d) / 10 ** d;
134
+
135
+ /** A path or a URL as a mediabunny input; a sentence for a path that cannot be read. */
136
+ async function openInput(file) {
137
+ const mb = await import('mediabunny');
138
+ mb.Logging.level = mb.LogLevel.Silent; // its own console lines ("Retrying failed fetch" with a stack) are not for an agent's output
139
+ let source;
140
+ if (isUrl(file)) source = new mb.UrlSource(file, { getRetryDelay: (attempts) => (attempts < 3 ? 0.3 * attempts : null) }); // seconds; its default never gives up, and a dead URL must be a sentence, not a hang
141
+ else {
142
+ const full = path.resolve(file);
143
+ if (!fs.existsSync(full)) throw new Error(`no such file: ${file}`);
144
+ const stat = fs.statSync(full);
145
+ if (stat.isDirectory()) throw new Error(`${file} is a folder, not a media file`);
146
+ if (stat.size === 0) throw new Error(`${file} is empty`);
147
+ source = new mb.FilePathSource(full);
148
+ }
149
+ const input = new mb.Input({ source, formats: mb.ALL_FORMATS });
150
+ let format;
151
+ try { format = await input.getFormat(); }
152
+ catch (e) {
153
+ if (isUrl(file)) throw new Error(`could not read ${file}: ${e && e.message ? e.message : e}`);
154
+ throw new Error(`${file} is not a media file this tool can read (${String(e && e.message ? e.message : e).split('\n')[0]})`);
155
+ }
156
+ return { mb, input, format };
157
+ }
158
+
159
+ /** What is in a file: container, duration, size, one row per track, key packet times with `keyframes`, and notes about what an author would trip on. */
160
+ export async function probeFile(file, { keyframes = false } = {}) {
161
+ const { mb, input, format } = await openInput(file);
162
+ const notes = [];
163
+ let duration;
164
+ try { duration = await input.computeDuration(); }
165
+ catch (e) { throw new Error(isUrl(file) ? `could not read ${file}: ${e.message}` : `${file}: could not read its duration (${e.message})`); }
166
+ const bytes = isUrl(file) ? null : fs.statSync(path.resolve(file)).size;
167
+ const video = [], audio = [];
168
+ let keys;
169
+ for (const track of await input.getTracks()) {
170
+ if (track.isVideoTrack()) {
171
+ const sink = new mb.EncodedPacketSink(track);
172
+ const pts = [];
173
+ for await (const p of sink.packets(undefined, undefined, { metadataOnly: true })) { pts.push(p.timestamp); if (pts.length >= 300) break; } // the first 300 frames say how the rest are paced
174
+ pts.sort((a, b) => a - b);
175
+ const gaps = pts.slice(1).map((t, i) => t - pts[i]).filter(g => g > 0).sort((a, b) => a - b);
176
+ const median = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0;
177
+ const fpsVaries = gaps.length > 4 && median > 0 && (gaps[gaps.length - 1] - gaps[0]) / median > 0.35;
178
+ const stats = await track.computePacketStats(300);
179
+ const rotation = await track.getRotation();
180
+ const first = await track.getFirstTimestamp(), end = await track.computeDuration();
181
+ video.push({
182
+ codec: track.codec, width: await track.getDisplayWidth(), height: await track.getDisplayHeight(), rotation,
183
+ fps: round(stats.averagePacketRate, 2), fpsVaries, bitrate: Math.round(stats.averageBitrate),
184
+ first: round(first, 4), end: round(end, 6), frameGap: round(median, 6),
185
+ stored: { width: await track.getCodedWidth(), height: await track.getCodedHeight() },
186
+ });
187
+ if (first > 0.05) notes.push(`the video starts at ${round(first, 3)} s (its first frame is not at 0, so the sound can start before the picture; frames before ${round(first, 3)} s show the first frame)`);
188
+ if (rotation) notes.push(`rotated ${rotation}° clockwise: stored ${await track.getCodedWidth()}×${await track.getCodedHeight()}, shown ${await track.getDisplayWidth()}×${await track.getDisplayHeight()} (frames and anything that places the picture use the shown size; whether the picture then reads upright depends on how the file was made, so look at a frame)`);
189
+ if (fpsVaries) notes.push(`variable frame rate (the gaps between frames range from ${round(gaps[0] * 1000, 1)} to ${round(gaps[gaps.length - 1] * 1000, 1)} ms): a clip from a phone or a screen recording; cut and sample by time, not by frame number`);
190
+ if (keyframes && !keys) {
191
+ keys = [];
192
+ let packet = await sink.getFirstPacket({ metadataOnly: true });
193
+ if (packet && packet.type !== 'key') packet = await sink.getNextKeyPacket(packet, { metadataOnly: true });
194
+ while (packet && keys.length < 5000) { keys.push(round(packet.timestamp, 3)); packet = await sink.getNextKeyPacket(packet, { metadataOnly: true }); }
195
+ }
196
+ } else if (track.isAudioTrack()) {
197
+ const stats = await track.computePacketStats(300); // measured from the packets, like the video's: a declared figure and a measured one made a cut look re-encoded
198
+ audio.push({ codec: track.codec, sampleRate: track.sampleRate, channels: track.numberOfChannels, bitrate: Math.round(stats.averageBitrate) });
199
+ }
200
+ }
201
+ if (!video.length) notes.push('no video');
202
+ if (!audio.length) notes.push('no audio');
203
+ const report = { file, container: format.name, duration: round(duration, 3), bytes, video, audio, notes };
204
+ if (keyframes) report.keyframes = keys ?? [];
205
+ return report;
206
+ }
207
+
208
+ const kb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
209
+
210
+ function probeText(r, keyframes) {
211
+ const L = [`${r.file}`, ` ${r.container} · ${r.duration} s${r.bytes != null ? ` · ${kb(r.bytes)}` : ''}`];
212
+ for (const v of r.video) L.push(` video ${v.codec} ${v.width}×${v.height}${v.rotation ? ` shown (stored ${v.stored.width}×${v.stored.height}, turned ${v.rotation}° clockwise)` : ''} · ${v.fps}${v.fpsVaries ? ' (varies)' : ''} fps${v.bitrate ? ` · ${Math.round(v.bitrate / 1000)} kb/s` : ''}`);
213
+ for (const a of r.audio) L.push(` audio ${a.codec} ${a.sampleRate} Hz · ${a.channels} ch${a.bitrate ? ` · ${Math.round(a.bitrate / 1000)} kb/s` : ''}`);
214
+ if (keyframes && r.video.length) L.push(` key packets at: ${r.keyframes.slice(0, 12).join(', ')}${r.keyframes.length > 12 ? `, … (${r.keyframes.length})` : ''} s`);
215
+ for (const n of r.notes) L.push(` note ${n}`);
216
+ return L.join('\n');
217
+ }
218
+
219
+ commands.probe = async ({ file, opts }, io) => {
220
+ const r = await probeFile(file, { keyframes: !!opts.keyframes });
221
+ io.log(opts.json ? JSON.stringify(r, null, 2) : probeText(r, !!opts.keyframes));
222
+ return 0;
223
+ };
224
+
225
+ const OUTPUT_FORMATS = { '.mp4': 'Mp4OutputFormat', '.m4v': 'Mp4OutputFormat', '.m4a': 'Mp4OutputFormat', '.mov': 'MovOutputFormat', '.webm': 'WebMOutputFormat', '.mkv': 'MkvOutputFormat', '.mka': 'MkvOutputFormat' };
226
+
227
+ /**
228
+ * What a cut by copy of [start, stop) really contains: it starts on the first key frame at or after `start` (a copy cannot start between key frames) and ends with the
229
+ * last frame that is wholly before `stop`. `{ from, to }` in the file's own seconds, or `{ none: true, before, after }` (the key frames around the range) when no key frame
230
+ * is inside it. A sound has no key frames: any packet is one.
231
+ */
232
+ async function copyWindow(mb, input, start, stop) {
233
+ const track = (await input.getPrimaryVideoTrack()) ?? (await input.getPrimaryAudioTrack());
234
+ if (!track) return null;
235
+ const sink = new mb.EncodedPacketSink(track);
236
+ const meta = { metadataOnly: true };
237
+ let key = await sink.getKeyPacket(start, meta);
238
+ const before = key ? key.timestamp : null;
239
+ if (!key || key.timestamp < start - 1e-6) key = key ? await sink.getNextKeyPacket(key, meta) : await sink.getFirstPacket(meta);
240
+ if (!key || key.timestamp >= stop - 1e-6) return { none: true, before, after: key ? key.timestamp : null };
241
+ let end = key.timestamp;
242
+ for await (const p of sink.packets(key, undefined, meta)) {
243
+ if (p.timestamp > stop + 4) break; // the reorder window of a frame is far inside this
244
+ if (p.timestamp + p.duration <= stop + 1e-6) end = Math.max(end, p.timestamp + p.duration);
245
+ }
246
+ return { from: key.timestamp, to: end };
247
+ }
248
+
249
+ /**
250
+ * Cuts [start, end] out of a file. By copy when the codecs allow (no re-encode, no loss): it can only start on a frame and the container's rules decide how the start is
251
+ * made (`boundary`). With `exact` the cut is re-encoded in the browser so it starts on the frame asked for (see `trimExact`). The result says what was really done.
252
+ */
253
+ export async function trimFile({ input: file, start, end, out, exact = false, force = false, chrome = null, requestLog = null, copy = { boundaryPolicy: 'shrink', shiftTolerance: Infinity } }) {
254
+ const env = { chrome, requestLog };
255
+ if (!(end > start)) throw new UsageError(`start must be before end (got ${start} and ${end})`);
256
+ if (start < 0) throw new UsageError(`start must not be negative (got ${start})`);
257
+ const ext = path.extname(out).toLowerCase();
258
+ if (!OUTPUT_FORMATS[ext]) throw new UsageError(`cannot write "${ext || out}": name the output .mp4, .mov, .webm, .mkv or .m4a`);
259
+ if (!isUrl(file) && sameFile(file, out)) throw new Error(`the output ${out} would overwrite the input: choose another name`);
260
+ const { mb, input } = await openInput(file);
261
+ const duration = await input.computeDuration();
262
+ const notes = [];
263
+ if (start >= duration) throw new Error(`start ${start} s is past the end of the file (${round(duration, 3)} s)`);
264
+ let stop = end;
265
+ if (end > duration) { stop = duration; notes.push(`end is past the file (${round(duration, 3)} s): cut at ${round(duration, 3)}`); }
266
+ if (exact) return trimExact({ mb, input, file, start, end, stop, duration, out, ext, force, notes, env });
267
+ const { input: probe } = await openInput(file);
268
+ const window = await copyWindow(mb, probe, start, stop);
269
+ if (window && window.none) {
270
+ const around = [window.before, window.after].filter(t => t !== null).map(t => `${round(t, 3)} s`).join(' and ');
271
+ throw new Error(`no key frame between ${start} s and ${round(stop, 3)} s${around ? ` (the nearest are at ${around})` : ''}: a copy can only start on a key frame, so a cut this short cannot be copied; use --exact (re-encodes, needs Chrome)`);
272
+ }
273
+ // The cut is made aside and moved into place only when it worked: a refused or failed copy must leave nothing, and must never truncate a file that was already there.
274
+ const aside = path.join(path.dirname(path.resolve(out)), `.pixi-effects-${process.pid}-${Date.now()}${ext}`);
275
+ const copyOptions = { mode: 'forced', ...copy };
276
+ const trimRange = { start: window ? window.from : start, end: stop };
277
+ { // can it be copied into this type? asked of a throwaway output in memory, so a refusal creates no file
278
+ const trial = new mb.Output({ format: new mb[OUTPUT_FORMATS[ext]](), target: new mb.BufferTarget() });
279
+ const dry = await mb.Conversion.init({ input, output: trial, trim: trimRange, copy: copyOptions, showWarnings: false });
280
+ const refused = !dry.isValid || dry.discardedTracks.length;
281
+ const why = dry.discardedTracks.map(d => `${d.track.type}: ${d.reason}`).join(', ');
282
+ try { await trial.cancel(); } catch { /* it had not started */ }
283
+ if (refused) throw new Error(`${file} cannot be cut by copy into ${ext} (${why || 'no track can be copied'}): use a different output type, or --exact (re-encodes, needs Chrome)`);
284
+ }
285
+ const { input: again } = await openInput(file); // a Conversion consumes its input: the real one gets a fresh one
286
+ const prepared = prepareOutput(out, { force });
287
+ let output = null;
288
+ try {
289
+ output = new mb.Output({ format: new mb[OUTPUT_FORMATS[ext]](), target: new mb.FilePathTarget(aside) });
290
+ // The cut STARTS ON THE KEY FRAME (`window.from`), not on the time asked: a copy asked to start between key frames leaves the picture starting late (at key frame − start) while the sound
291
+ // starts at the time asked, so the file is longer than the cut and has sound over nothing. Starting on the key frame gives picture and sound that start together, at 0.
292
+ const conversion = await mb.Conversion.init({ input: again, output, trim: trimRange, copy: copyOptions, showWarnings: false });
293
+ await conversion.execute();
294
+ fs.renameSync(aside, path.resolve(out));
295
+ const tracks = conversion.utilizedTracks.map(t => `${t.type} ${t.codec ?? ''}`.trim());
296
+ // what the cut really is: read the FILE back (its first picture and how long it lasts), not a prediction
297
+ const { input: made } = await openInput(out);
298
+ const lead = (await made.getPrimaryVideoTrack()) ?? (await made.getPrimaryAudioTrack());
299
+ const from = window ? window.from : start;
300
+ const actual = lead ? [round(from + (await lead.getFirstTimestamp()), 3), round(from + (await lead.computeDuration()), 3)] : [start, stop];
301
+ if (actual[0] - start > 1e-3) notes.push(`a copy can only start on a key frame: it starts on the next key frame, ${actual[0]} s, not ${start} s (${round(actual[0] - start, 3)} s later); use --exact to start on ${start} s (re-encodes, needs Chrome), or probe --keyframes to pick a key frame`);
302
+ return { requested: [start, end], actual, mode: 'copy', reencoded: false, tracks, bytes: fs.statSync(path.resolve(out)).size, out, notes };
303
+ } catch (e) {
304
+ try { await output?.cancel(); } catch { /* it had not started */ } // close its file handle first, or the file comes back after it is removed
305
+ prepared.undo();
306
+ throw e;
307
+ } finally { fs.rmSync(aside, { force: true }); }
308
+ }
309
+
310
+ /** The cut re-encoded in the browser, so it starts on the frame asked for. The file is made in a temporary folder (the page may only write plain names) and moved to `out`. */
311
+ async function trimExact({ mb, input, file, start, end, stop, duration, out, ext, force, notes, env }) {
312
+ const prepared = prepareOutput(out, { force });
313
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pixi-effects-trim-'));
314
+ try {
315
+ const { withBrowser } = await import(pathToFileURL(path.join(HERE, 'mediaBrowser.mjs')).href);
316
+ const inputs = new Map([['t1', isUrl(file) ? file : path.resolve(file)]]);
317
+ const name = `cut${ext}`;
318
+ await withBrowser({ chrome: env.chrome, inputs, outDir: tmp, requestLog: env.requestLog }, ({ call }) => call('trimExact', { id: 't1', start, end: stop, ext, outName: name }));
319
+ fs.copyFileSync(path.join(tmp, name), path.resolve(out));
320
+ } catch (e) {
321
+ prepared.undo();
322
+ throw e;
323
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
324
+ const made = await probeFile(out);
325
+ // the frame the cut starts on: the first frame at or after the start (frames are on a grid, so a start between two is the next)
326
+ const track = await input.getPrimaryVideoTrack();
327
+ let from = start;
328
+ if (track) {
329
+ const sink = new mb.EncodedPacketSink(track);
330
+ const key = (await sink.getKeyPacket(start, { metadataOnly: true })) ?? (await sink.getFirstPacket({ metadataOnly: true }));
331
+ const times = [];
332
+ if (key) for await (const p of sink.packets(key, undefined, { metadataOnly: true })) { times.push(p.timestamp); if (p.timestamp > start + 4) break; }
333
+ const after = times.filter(t => t >= start - 1e-6).sort((a, b) => a - b)[0];
334
+ if (after !== undefined) from = after;
335
+ }
336
+ // the end is the FILE's own (where its picture stops), not the end asked for: a re-encoded cut lasts to the frame boundary, and the report says what it lasts
337
+ const lasts = made.video.length ? made.video[0].end - made.video[0].first : made.duration;
338
+ const actual = [round(from, 3), round(from + lasts, 3)];
339
+ const tracks = [...made.video.map(v => `video ${v.codec}`), ...made.audio.map(a => `audio ${a.codec}`)];
340
+ const sourceCodec = track ? track.codec : null;
341
+ if (made.video.length && sourceCodec && made.video[0].codec !== sourceCodec) notes.push(`the picture was re-encoded as ${made.video[0].codec} (the source is ${sourceCodec}): a re-encode uses the usual codec of the output type, so the file is a different codec and size from the source`);
342
+ return { requested: [start, end], actual, mode: 'reencode', reencoded: true, tracks, bytes: fs.statSync(path.resolve(out)).size, out, notes };
343
+ }
344
+
345
+ function trimText(r) {
346
+ const L = [`${r.out} ${kb(r.bytes)}`, ` asked ${r.requested[0]}–${r.requested[1]} s, got ${r.actual[0]}–${r.actual[1]} s`, r.reencoded ? ` re-encoded: ${r.tracks.join(', ')}` : ` copied: ${r.tracks.join(', ')} (no re-encode)`];
347
+ for (const n of r.notes) L.push(` note ${n}`);
348
+ return L.join('\n');
349
+ }
350
+
351
+ commands.trim = async ({ file, opts }, io, env = {}) => {
352
+ const r = await trimFile({ input: file, start: opts.start, end: opts.end, out: opts.out, exact: !!opts.exact, force: !!opts.force, chrome: opts.chrome ?? null, requestLog: env.requestLog ?? null });
353
+ io.log(opts.json ? JSON.stringify(r, null, 2) : trimText(r));
354
+ return 0;
355
+ };
356
+
357
+
358
+
359
+
360
+ /** Runs a media command line; resolves to the exit code (0 done, 1 a problem, 2 usage). */
361
+ export async function runMedia(argv, io = defaultIo, env = {}) {
362
+ let parsed;
363
+ try { parsed = parseMediaArgs(argv); }
364
+ catch (e) { if (e instanceof UsageError) { io.err(`pixi-effects: ${e.message}\n${USAGE}`); return 2; } throw e; }
365
+ const run = commands[parsed.command];
366
+ if (!run) { io.err(`pixi-effects ${parsed.command}: not available in this build`); return 1; }
367
+ try { return await run(parsed, io, env); }
368
+ catch (e) {
369
+ if (e instanceof UsageError) { io.err(`pixi-effects: ${e.message}\n${USAGE}`); return 2; }
370
+ io.err(`pixi-effects ${parsed.command}: ${e && e.message ? e.message : String(e)}`); // a sentence, never a stack
371
+ return 1;
372
+ }
373
+ }
374
+
375
+ const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
376
+
377
+ /** One file by path, with `Range` (what the page's UrlSource reads by). */
378
+ function sendFile(req, res, file) {
379
+ const stat = fs.statSync(file), size = stat.size;
380
+ if (!stat.isFile()) { res.writeHead(404).end('not a file'); return; }
381
+ const headers = { 'accept-ranges': 'bytes', 'cache-control': 'no-store', 'access-control-allow-origin': '*', 'content-type': 'application/octet-stream' };
382
+ const m = String(req.headers.range ?? '').match(/^bytes=(\d*)-(\d*)$/);
383
+ if (m && (m[1] !== '' || m[2] !== '')) {
384
+ const start = m[1] === '' ? Math.max(0, size - Number(m[2])) : Number(m[1]); // bytes=-n is the last n bytes
385
+ const end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
386
+ if (start >= size || start > end) { res.writeHead(416, { 'content-range': `bytes */${size}` }).end(); return; }
387
+ res.writeHead(206, { ...headers, 'content-range': `bytes ${start}-${end}/${size}`, 'content-length': end - start + 1 });
388
+ if (req.method === 'HEAD') { res.end(); return; }
389
+ fs.createReadStream(file, { start, end }).on('error', () => res.destroy()).pipe(res);
390
+ return;
391
+ }
392
+ res.writeHead(200, { ...headers, 'content-length': size });
393
+ if (req.method === 'HEAD') { res.end(); return; }
394
+ fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
395
+ }
396
+
397
+ /** A remote input, passed through with its Range (the page may not be allowed to read the other site, and this adds no CORS rules to learn). */
398
+ async function proxy(req, res, url) {
399
+ try {
400
+ const { Readable } = await import('node:stream');
401
+ const up = await fetch(url, { headers: req.headers.range ? { range: req.headers.range } : {} });
402
+ const headers = { 'cache-control': 'no-store', 'access-control-allow-origin': '*' };
403
+ for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (up.headers.get(h)) headers[h] = up.headers.get(h);
404
+ res.writeHead(up.status, headers);
405
+ if (req.method === 'HEAD' || !up.body) { res.end(); return; }
406
+ Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res);
407
+ } catch (e) { if (!res.headersSent) res.writeHead(502).end(`could not read ${url}: ${e.message}`); else res.destroy(); }
408
+ }
409
+
410
+ /** Where mediabunny's browser bundle is (this package's own copy, so the page works offline and with the version pixi-effects uses). */
411
+ export function mediabunnyBundle() {
412
+ const main = createRequire(import.meta.url).resolve('mediabunny'); // .../dist/bundles/mediabunny.cjs (its package.json is not exported)
413
+ return path.join(path.dirname(main), 'mediabunny.min.mjs');
414
+ }
415
+
416
+ /**
417
+ * The tool's local server: the package folder as static files (the page `ai/tools/media-page.html`), plus
418
+ * GET|HEAD /__in/<id> one registered input file, with Range (only the ids in `inputs`)
419
+ * PUT /__out/<name> a file the page made, into `outDir` (a plain file name only)
420
+ * GET /__mb/mediabunny.min.mjs mediabunny's bundle from this package
421
+ * GET /__lib/<name>.js a file of this package's dist/ by name (the sound measurement), nothing else
422
+ * Resolves `{ server, port, close() }`.
423
+ */
424
+ export async function serveMedia({ inputs, outDir, root = PACKAGE_ROOT, requestLog = null }) {
425
+ const { serve } = await import(pathToFileURL(path.join(HERE, 'check.mjs')).href);
426
+ const onRequest = (req, res) => {
427
+ const url = new URL(req.url, 'http://x');
428
+ const p = decodeURIComponent(url.pathname);
429
+ let m;
430
+ if ((m = p.match(/^\/__in\/([A-Za-z0-9_-]+)$/)) && (req.method === 'GET' || req.method === 'HEAD')) {
431
+ const file = inputs.get(m[1]);
432
+ requestLog?.push({ id: m[1], range: req.headers.range ?? null });
433
+ if (file && isUrl(file)) { proxy(req, res, file); return true; }
434
+ if (!file || !fs.existsSync(file)) { res.writeHead(404).end('not found'); return true; }
435
+ sendFile(req, res, file); return true;
436
+ }
437
+ if ((m = p.match(/^\/__out\/(.+)$/)) && req.method === 'PUT') {
438
+ const name = m[1];
439
+ if (!SAFE_NAME.test(name) || /^\.+$/.test(name)) { res.writeHead(400).end('bad file name'); return true; }
440
+ fs.mkdirSync(outDir, { recursive: true });
441
+ const ws = fs.createWriteStream(path.join(outDir, name));
442
+ req.pipe(ws);
443
+ ws.on('finish', () => res.writeHead(204).end());
444
+ ws.on('error', () => res.writeHead(500).end());
445
+ return true;
446
+ }
447
+ if (p === '/__mb/mediabunny.min.mjs') {
448
+ const file = mediabunnyBundle();
449
+ if (!fs.existsSync(file)) { res.writeHead(404).end('mediabunny is not installed next to pixi-effects'); return true; }
450
+ res.writeHead(200, { 'content-type': 'text/javascript', 'cache-control': 'no-store' }); fs.createReadStream(file).pipe(res); return true;
451
+ }
452
+ if ((m = p.match(/^\/__lib\/([A-Za-z0-9._-]+\.js)$/))) {
453
+ const file = path.join(root, 'dist', m[1]);
454
+ if (!fs.existsSync(file)) { res.writeHead(404).end('not found'); return true; }
455
+ res.writeHead(200, { 'content-type': 'text/javascript', 'cache-control': 'no-store' }); fs.createReadStream(file).pipe(res); return true;
456
+ }
457
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end('this server only reads, and takes files at /__out/<name>'); return true; }
458
+ return false;
459
+ };
460
+ const { server, port } = await serve(root, onRequest);
461
+ return { server, port, close: () => new Promise(r => { server.close(r); server.closeAllConnections?.(); }) };
462
+ }
463
+
464
+ const MAX_FRAMES = 60;
465
+ const AT_HELP = 'use seconds (3.5), a percentage (50%), a frame (f120), start, end or every:2s';
466
+
467
+ /**
468
+ * `--at` for a file with no layers: `3.5`, `50%`, `f120`, `start`, `end` (the last frame), `every 2s`, separated by commas → [{ t, label }] (a label safe in a file name).
469
+ * The same words as `check --at` where they overlap; a time outside the file is refused with the length of the file in the sentence.
470
+ */
471
+ export function parseTimes(list, { duration, fps, first = 0, last: lastFrame }) {
472
+ const out = [], seen = new Set(), last = Math.max(first, lastFrame ?? duration - 1 / fps);
473
+ const add = (t, label) => { if (!seen.has(label)) { seen.add(label); out.push({ t, label }); } };
474
+ const inside = (t, raw) => { if (t > duration + 1e-6) throw new Error(`${raw} s is past the end of the file (${round(duration, 3)} s)`); return Math.min(t, last > t ? t : Math.max(t, 0)); };
475
+ for (const raw of String(list).split(',').map(x => x.trim()).filter(Boolean)) {
476
+ let m;
477
+ if ((m = raw.match(/^every(?:\s+|:)(\d+(?:\.\d+)?)\s*s?$/i))) {
478
+ const step = Number(m[1]);
479
+ if (!(step > 0)) throw new Error(`--at "${raw}": every needs a step above 0 (every 2s)`);
480
+ const n = Math.ceil(duration / step);
481
+ if (n > MAX_FRAMES) throw new Error(`--at "${raw}": that is ${n} frames of a ${round(duration, 1)} s file, too many (at most ${MAX_FRAMES}): a larger step, or a range of times`);
482
+ for (let k = 0; k * step < duration - 1e-6; k++) add(round(k * step, 4), `${round(k * step, 2).toFixed(2)}s`);
483
+ } else if (/^start$/i.test(raw)) add(round(first, 4), 'start');
484
+ else if (/^end$/i.test(raw)) add(round(last, 4), 'end');
485
+ else if ((m = raw.match(/^(\d+(?:\.\d+)?)%$/))) {
486
+ if (Number(m[1]) > 100) throw new Error(`--at "${raw}": a percentage is 0–100`);
487
+ add(round(Math.min(last, duration * Number(m[1]) / 100), 4), `${m[1]}pct`);
488
+ } else if ((m = raw.match(/^f(\d+)$/i))) add(round(inside(Number(m[1]) / fps, `frame ${m[1]} is ${round(Number(m[1]) / fps, 3)}`), 4), `f${m[1]}`);
489
+ else if (/^\d+(\.\d+)?$/.test(raw)) add(round(inside(Number(raw), raw), 4), `${Number(raw).toFixed(2)}s`);
490
+ else throw new Error(`--at: cannot read "${raw}": ${AT_HELP}`);
491
+ }
492
+ if (!out.length) throw new Error(`--at is empty: ${AT_HELP}`);
493
+ if (out.length > MAX_FRAMES) throw new Error(`--at asks for ${out.length} frames, too many (at most ${MAX_FRAMES})`);
494
+ return out;
495
+ }
496
+
497
+ commands.frames = async ({ file, opts }, io, env = {}) => {
498
+ const probe = await probeFile(file);
499
+ if (!probe.video.length) throw new Error(`no video track in ${file} (${probe.notes.join('; ')}): frames takes a video`);
500
+ const v = probe.video[0];
501
+ const times = parseTimes(opts.at ?? '0,25%,50%,75%,end', { duration: v.end, fps: v.fps || 30, first: v.first, last: v.end - (v.frameGap || 1 / (v.fps || 30)) }); // the VIDEO's own length and last frame (a file's audio may be longer)
502
+ const outDir = path.resolve(opts.o ?? `${path.basename(file).replace(/\.[^.]*$/, '') || 'video'}-frames`);
503
+ if (fs.existsSync(outDir) && !fs.statSync(outDir).isDirectory()) throw new Error(`${outDir} is a file, not a folder: frames writes its pictures into a folder (-o <folder>)`);
504
+ const { withBrowser } = await import(pathToFileURL(path.join(HERE, 'mediaBrowser.mjs')).href);
505
+ const inputs = new Map([['f1', isUrl(file) ? file : path.resolve(file)]]);
506
+ const r = await withBrowser({ chrome: opts.chrome, inputs, outDir, requestLog: env.requestLog }, ({ call }) => call('frames', { id: 'f1', times, size: opts.size ?? 480, sheet: !!opts.sheet }));
507
+ const report = { file, duration: probe.duration, outDir, frames: r.frames.map(f => ({ ...f, path: path.join(outDir, f.file) })), sheet: r.sheet };
508
+ if (opts.json) io.log(JSON.stringify(report, null, 2));
509
+ else {
510
+ const L = [`${r.frames.length} frame(s) of ${file} → ${outDir}${path.sep}`];
511
+ for (const f of r.frames) L.push(` ${f.file} ${round(f.actual, 3)} s${Math.abs(f.actual - f.requested) > 1 / (probe.video[0].fps || 30) ? ` (asked ${f.requested})` : ''} ${f.width}×${f.height}`);
512
+ if (r.sheet) L.push(` ${r.sheet} all of them, with their times`);
513
+ io.log(L.join('\n'));
514
+ }
515
+ return 0;
516
+ };
517
+
518
+ /** The longest sound `sound` decodes into memory (a stereo hour would be gigabytes). */
519
+ export const SOUND_MAX_SECONDS = 600;
520
+
521
+ function soundText(r, file, waveform) {
522
+ const dbText = (n) => (n === null || n === undefined ? 'n/a' : String(n));
523
+ const span = (w) => `${round(w.from, 2)}–${round(w.to, 2)} s (${w.rmsDb.toFixed(1)} dBFS rms)`;
524
+ const L = [`${file}`, ` ${r.duration} s · ${r.sampleRate} Hz · ${r.channels} ch`,
525
+ ` loudness ${dbText(r.loudness.integratedLufs)} LUFS · true peak ${dbText(r.loudness.truePeakDb)} dBTP · peak ${dbText(r.peakDb)} dBFS · ${r.loudness.clippedSamples} sample(s) at full scale`];
526
+ L.push(` silences ${r.loudness.silences.length ? r.loudness.silences.slice(0, 6).map(q => `${round(q.from, 1)}–${round(q.to, 1)} s`).join(', ') + (r.loudness.silences.length > 6 ? `, … (${r.loudness.silences.length})` : '') : 'none (nothing quieter than −60 dBFS for a second or more)'}`);
527
+ if (r.loudest) L.push(` loudest ${span(r.loudest)}`, ` quietest ${span(r.quietest)} (rows of ${r.window} s)`);
528
+ const step = Math.max(1, Math.ceil(r.windows.length / 16));
529
+ L.push(` level ${r.windows.filter((_, i) => i % step === 0).map(w => w.rmsDb.toFixed(1)).join(' ')} dBFS rms, every ${round(step * r.window, 2)} s from 0`);
530
+ if (r.reference) {
531
+ const ref = r.reference, d = ref.luToTarget;
532
+ const where = d > 0 ? `${d} LU under −14` : d < -2 ? `${-d} LU over −14` : 'within the usual range';
533
+ L.push(` reference web video is typically −14 to −16 LUFS: this is ${where} · headroom ${ref.headroomDb} dB before the true peak reaches −1 dBTP${ref.raiseToTargetNeedsLimiter ? ' (raising it to −14 would need a limiter)' : ''}`);
534
+ }
535
+ for (const n of r.notes.slice(0, 3)) L.push(` note ${n}`);
536
+ if (r.notes.length > 3) L.push(` note … ${r.notes.length - 3} more (--json)`);
537
+ if (waveform) L.push(` ${waveform}`);
538
+ return L.join('\n');
539
+ }
540
+
541
+ commands.sound = async ({ file, opts }, io, env = {}) => {
542
+ const probe = await probeFile(file);
543
+ if (!probe.audio.length) throw new Error(`no audio track in ${file}${probe.video.length ? ' (it is a video with no sound)' : ''}: sound measures the sound of a file`);
544
+ if (probe.duration > SOUND_MAX_SECONDS) throw new Error(`${file} is ${round(probe.duration, 0)} s long: sound decodes the whole sound into memory, so it takes up to ${SOUND_MAX_SECONDS} s; for longer files use ffmpeg (ffmpeg -i file -af ebur128 -f null -) or measure a part (trim it first)`);
545
+ const outDir = path.resolve(opts.o ?? `${path.basename(file).replace(/\.[^.]*$/, '') || 'sound'}-sound`);
546
+ const { withBrowser } = await import(pathToFileURL(path.join(HERE, 'mediaBrowser.mjs')).href);
547
+ const inputs = new Map([['s1', isUrl(file) ? file : path.resolve(file)]]);
548
+ const window = opts.window ?? Math.max(1, probe.duration / 120); // a row per second for a short file, at most ~120 rows for a long one; --window sets it
549
+ const r = await withBrowser({ chrome: opts.chrome, inputs, outDir, requestLog: env.requestLog }, ({ call }) => call('sound', { id: 's1', waveform: !!opts.waveform, window }));
550
+ const rows = r.windows;
551
+ const span = (w) => w && { from: round(w.t, 3), to: round(Math.min(w.t + window, r.duration), 3), rmsDb: w.rmsDb };
552
+ const loudest = rows.length ? rows.reduce((best, w) => (w.rmsDb > best.rmsDb ? w : best), rows[0]) : null;
553
+ const quietest = rows.length ? rows.reduce((best, w) => (w.rmsDb < best.rmsDb ? w : best), rows[0]) : null;
554
+ const lufs = r.loudness.integratedLufs;
555
+ // the loudness the web usually wants, and how much room there is to get there: numbers an agent can act on (raising by the gap needs the headroom, or a limiter)
556
+ const reference = lufs === null ? null : { webTargetLufs: [-16, -14], luToTarget: round(-14 - lufs, 1), headroomDb: round(-1 - r.loudness.truePeakDb, 1), raiseToTargetNeedsLimiter: -14 - lufs > -1 - r.loudness.truePeakDb };
557
+ const report = { file, ...r, window: round(window, 3), loudest: span(loudest), quietest: span(quietest), reference, waveform: r.waveform ? path.join(outDir, r.waveform) : null };
558
+ io.log(opts.json ? JSON.stringify(report, null, 2) : soundText(report, file, report.waveform));
559
+ return 0;
560
+ };
561
+
562
+ /** A note when the sound began later than the picture in the source: a file of the sound alone starts at 0, so that offset is gone. */
563
+ async function soundLead(input, audioTrack) {
564
+ const a = await audioTrack.getFirstTimestamp();
565
+ const video = await input.getPrimaryVideoTrack();
566
+ const v = video ? await video.getFirstTimestamp() : 0;
567
+ const lead = Math.max(a, 0) - Math.max(v, 0);
568
+ return lead > 0.05 ? `the sound started ${round(lead, 3)} s after the picture in the source (it begins at 0 in this file, so that offset is gone)` : null;
569
+ }
570
+
571
+ const AUDIO_HOME = { aac: '.m4a', opus: '.webm', vorbis: '.webm' };
572
+
573
+ /**
574
+ * Takes the sound out of a file by copy: the audio packets are the source's own bytes in a file of their own. The output is made aside and moved into place when it worked
575
+ * (a refusal leaves nothing and never empties a file that was there).
576
+ */
577
+ export async function audioFile({ input: file, out, force = false }) {
578
+ const ext = path.extname(out).toLowerCase();
579
+ if (!OUTPUT_FORMATS[ext]) throw new UsageError(`cannot write "${ext || out}": name the output .m4a, .webm, .mka, .mp4, .mov or .mkv`);
580
+ if (!isUrl(file) && sameFile(file, out)) throw new Error(`the output ${out} would overwrite the input: choose another name`);
581
+ const { mb, input } = await openInput(file);
582
+ const track = await input.getPrimaryAudioTrack();
583
+ if (!track) throw new Error(`no audio track in ${file}: audio takes the sound out of a file that has some`);
584
+ const options = { video: { discard: true }, copy: { mode: 'forced' }, showWarnings: false };
585
+ const refusal = `${file} has ${track.codec} sound, which a ${ext} file cannot hold by copy: write it as ${AUDIO_HOME[track.codec] ?? '.mka'} (or .mka, which holds any codec)`;
586
+ { // can the output type hold it? asked of a throwaway output in memory: a refusal creates no file
587
+ const trial = new mb.Output({ format: new mb[OUTPUT_FORMATS[ext]](), target: new mb.BufferTarget() });
588
+ const dry = await mb.Conversion.init({ input, output: trial, ...options });
589
+ const refused = !dry.isValid || dry.discardedTracks.some(d => d.reason !== 'discarded_by_user');
590
+ try { await trial.cancel(); } catch { /* it had not started */ }
591
+ if (refused) throw new Error(refusal);
592
+ }
593
+ const { input: again } = await openInput(file);
594
+ const lead = await soundLead(input, track);
595
+ const prepared = prepareOutput(out, { force });
596
+ const aside = path.join(path.dirname(path.resolve(out)), `.pixi-effects-${process.pid}-${Date.now()}${ext}`);
597
+ let output = null;
598
+ try {
599
+ output = new mb.Output({ format: new mb[OUTPUT_FORMATS[ext]](), target: new mb.FilePathTarget(aside) });
600
+ const conversion = await mb.Conversion.init({ input: again, output, ...options });
601
+ await conversion.execute();
602
+ fs.renameSync(aside, path.resolve(out));
603
+ } catch (e) {
604
+ try { await output?.cancel(); } catch { /* it had not started */ }
605
+ prepared.undo();
606
+ throw e;
607
+ } finally { fs.rmSync(aside, { force: true }); }
608
+ const { input: made } = await openInput(out);
609
+ const a = await made.getPrimaryAudioTrack();
610
+ const notes = lead ? [lead] : [];
611
+ return { file, out, mode: 'copy', reencoded: false, codec: a.codec, sampleRate: a.sampleRate, channels: a.numberOfChannels, duration: round(await a.computeDuration(), 3), bytes: fs.statSync(path.resolve(out)).size, notes };
612
+ }
613
+
614
+ commands.audio = async ({ file, opts }, io) => {
615
+ const r = await audioFile({ input: file, out: opts.out, force: !!opts.force });
616
+ io.log(opts.json ? JSON.stringify(r, null, 2) : [`${r.out} ${kb(r.bytes)}`, ` ${r.codec} ${r.sampleRate} Hz · ${r.channels} ch · ${r.duration} s, copied (no re-encode)`, ...r.notes.map(n => ` note ${n}`)].join('\n'));
617
+ return 0;
618
+ };
619
+
620
+ /** What decides whether clips can be joined by copy: the codecs AND their decoder parameters, the SHOWN size and rotation of the picture, and the sound's codec, rate and channels. */
621
+ async function describeClip(input) {
622
+ const v = await input.getPrimaryVideoTrack(), a = await input.getPrimaryAudioTrack();
623
+ const vc = v ? await v.getDecoderConfig() : null, ac = a ? await a.getDecoderConfig() : null;
624
+ const hex = (d) => (d ? Buffer.from(d instanceof ArrayBuffer ? new Uint8Array(d) : new Uint8Array(d.buffer, d.byteOffset, d.byteLength)).toString('hex') : '');
625
+ return {
626
+ video: v ? { codec: v.codec, width: await v.getDisplayWidth(), height: await v.getDisplayHeight(), rotation: await v.getRotation(), config: `${vc?.codec ?? ''}|${hex(vc?.description)}` } : null,
627
+ audio: a ? { codec: a.codec, rate: a.sampleRate, channels: a.numberOfChannels, config: `${ac?.codec ?? ''}|${hex(ac?.description)}` } : null,
628
+ audioTracks: (await input.getAudioTracks()).length,
629
+ };
630
+ }
631
+
632
+ function differences(a, b) {
633
+ const d = [];
634
+ if (!!a.video !== !!b.video) d.push(a.video ? 'the first has a picture, the other has none' : 'the first has no picture, the other has one');
635
+ else if (a.video) {
636
+ if (a.video.codec !== b.video.codec) d.push(`video codec ${a.video.codec} against ${b.video.codec}`);
637
+ else if (a.video.config !== b.video.config) d.push(`the ${a.video.codec} decoder parameters differ (the same codec encoded with other settings: profile, level or parameter sets; a joined file can carry only the first clip's, so the others would decode as garbage)`);
638
+ if (a.video.width !== b.video.width || a.video.height !== b.video.height) d.push(`shown size ${a.video.width}×${a.video.height} against ${b.video.width}×${b.video.height}`);
639
+ if (a.video.rotation !== b.video.rotation) d.push(`rotation ${a.video.rotation}° against ${b.video.rotation}°`);
640
+ }
641
+ if (!!a.audio !== !!b.audio) d.push(a.audio ? 'the first has sound, the other has none' : 'the first has no sound, the other has some');
642
+ else if (a.audio) {
643
+ if (a.audio.codec !== b.audio.codec) d.push(`audio codec ${a.audio.codec} against ${b.audio.codec}`);
644
+ else if (a.audio.config !== b.audio.config && a.audio.rate === b.audio.rate && a.audio.channels === b.audio.channels) d.push(`the ${a.audio.codec} sound's decoder parameters differ`);
645
+ if (a.audio.rate !== b.audio.rate) d.push(`sample rate ${a.audio.rate} against ${b.audio.rate} Hz`);
646
+ if (a.audio.channels !== b.audio.channels) d.push(`${a.audio.channels} against ${b.audio.channels} audio channels`);
647
+ }
648
+ return d;
649
+ }
650
+
651
+ /**
652
+ * Joins clips one after another by copy (no re-encode): their packets are the sources' own bytes. Every clip starts where the longest track of the one before it ends, and its
653
+ * picture and sound keep their own offset to each other (one start per clip, not one per track), so they stay together however many are joined. The encoder-delay packet of AAC
654
+ * (before 0) is not copied: in the middle of a file it decodes to silence. Clips that differ in a way a copy cannot hide are refused with what differs.
655
+ */
656
+ export async function concatFiles({ inputs, out, force = false }) {
657
+ const ext = path.extname(out).toLowerCase();
658
+ if (!OUTPUT_FORMATS[ext]) throw new UsageError(`cannot write "${ext || out}": name the output .mp4, .mov, .webm, .mkv or .m4a`);
659
+ for (const f of inputs) if (!isUrl(f) && sameFile(f, out)) throw new Error(`the output ${out} would overwrite the input ${f}: choose another name`);
660
+ let mb = null;
661
+ const kinds = [];
662
+ for (const file of inputs) { // one clip open at a time: hundreds of inputs must not hold a file each
663
+ const o = await openInput(file);
664
+ mb = o.mb;
665
+ kinds.push(await describeClip(o.input));
666
+ o.input.dispose();
667
+ }
668
+ for (let i = 1; i < kinds.length; i++) {
669
+ const d = differences(kinds[0], kinds[i]);
670
+ if (d.length) throw new Error(`${inputs[i]} cannot be joined to ${inputs[0]} by copy: ${d.join('; ')}; a copy needs the same codecs, decoder parameters, shown size, rotation and sound in every clip, so make them the same first (ffmpeg's concat filter does that)`);
671
+ }
672
+ const first = kinds[0];
673
+ if (!first.video && !first.audio) throw new Error(`${inputs[0]} has no picture and no sound to join`);
674
+ if (first.video && (ext === '.m4a' || ext === '.mka')) throw new Error(`a ${ext} file is for sound: the clips have a picture, so name the output .mp4, .mov, .webm or .mkv`);
675
+ const notes = [];
676
+ kinds.forEach((k, i) => { if (k.audioTracks > 1) notes.push(`clip ${i + 1} (${path.basename(inputs[i])}) has ${k.audioTracks} sound tracks: only the first sound track is joined`); });
677
+ const prepared = prepareOutput(out, { force });
678
+ const aside = path.join(path.dirname(path.resolve(out)), `.pixi-effects-${process.pid}-${Date.now()}${ext}`);
679
+ const output = new mb.Output({ format: new mb[OUTPUT_FORMATS[ext]](), target: new mb.FilePathTarget(aside) });
680
+ try {
681
+ let vSrc = null, aSrc = null;
682
+ try {
683
+ if (first.video) { vSrc = new mb.EncodedVideoPacketSource(first.video.codec); output.addVideoTrack(vSrc, { rotation: first.video.rotation }); }
684
+ if (first.audio) { aSrc = new mb.EncodedAudioPacketSource(first.audio.codec); output.addAudioTrack(aSrc); }
685
+ } catch (e) { throw new Error(`a ${ext} file cannot hold these tracks (${[first.video && `${first.video.codec} video`, first.audio && `${first.audio.codec} sound`].filter(Boolean).join(', ')}): ${e.message}; name the output .mp4, .mov or .mkv`); }
686
+ await output.start();
687
+ const clips = [];
688
+ let offset = 0;
689
+ for (let n = 0; n < inputs.length; n++) {
690
+ const { input } = await openInput(inputs[n]);
691
+ const v = first.video ? await input.getPrimaryVideoTrack() : null, a = first.audio ? await input.getPrimaryAudioTrack() : null;
692
+ const vFirst = v ? await v.getFirstTimestamp() : Infinity;
693
+ const aFirst = a ? Math.max(await a.getFirstTimestamp(), 0) : Infinity;
694
+ const base = Math.min(vFirst, aFirst); // ONE start for both tracks: a sound that begins later than its picture still does
695
+ const vCfg = v ? await v.getDecoderConfig() : null, aCfg = a ? await a.getDecoderConfig() : null;
696
+ const vIt = v ? new mb.EncodedPacketSink(v).packets()[Symbol.asyncIterator]() : null, aIt = a ? new mb.EncodedPacketSink(a).packets()[Symbol.asyncIterator]() : null;
697
+ const nextSound = async () => { let r = aIt ? await aIt.next() : { done: true }; while (!r.done && r.value.timestamp < -1e-6) r = await aIt.next(); return r; }; // the encoder-delay packet before 0 is left out
698
+ let vn = vIt ? await vIt.next() : { done: true }, an = await nextSound();
699
+ let vFirstPacket = true, aFirstPacket = true, vEnd = offset, aEnd = offset;
700
+ // picture and sound are written in the order of their times, so the muxer never has to hold a whole track of one kind while waiting for the other
701
+ while (!vn.done || !an.done) {
702
+ const takeVideo = !vn.done && (an.done || vn.value.timestamp - base <= an.value.timestamp - base);
703
+ if (takeVideo) {
704
+ const p = vn.value, ts = p.timestamp - base + offset;
705
+ await vSrc.add(p.clone({ timestamp: ts }), vFirstPacket ? { decoderConfig: vCfg } : undefined); vFirstPacket = false;
706
+ vEnd = Math.max(vEnd, ts + p.duration);
707
+ vn = await vIt.next();
708
+ } else {
709
+ const p = an.value, ts = p.timestamp - base + offset;
710
+ await aSrc.add(p.clone({ timestamp: ts }), aFirstPacket ? { decoderConfig: aCfg } : undefined); aFirstPacket = false;
711
+ aEnd = Math.max(aEnd, ts + p.duration);
712
+ an = await nextSound();
713
+ }
714
+ }
715
+ input.dispose();
716
+ const end = Math.max(vEnd, aEnd);
717
+ if (v && a && Math.abs(vEnd - aEnd) > 0.25) notes.push(`clip ${n + 1} (${path.basename(inputs[n])}) has ${round(Math.abs(aEnd - vEnd), 2)} s ${aEnd > vEnd ? 'more sound than picture: the picture ends first and the last frame stays on screen while the sound goes on' : 'more picture than sound: the sound ends first and the next clip\'s sound starts only at the end of the picture'}`);
718
+ clips.push({ file: inputs[n], from: round(offset, 3), to: round(end, 3) });
719
+ offset = end;
720
+ }
721
+ await output.finalize();
722
+ fs.renameSync(aside, path.resolve(out));
723
+ const { input: made } = await openInput(out);
724
+ const tracks = [first.video && `video ${first.video.codec}`, first.audio && `audio ${first.audio.codec}`].filter(Boolean);
725
+ return { inputs, out, mode: 'copy', reencoded: false, clips, duration: round(await made.computeDuration(), 3), tracks, bytes: fs.statSync(path.resolve(out)).size, notes };
726
+ } catch (e) {
727
+ try { await output.cancel(); } catch { /* it had not started */ }
728
+ prepared.undo();
729
+ throw e;
730
+ } finally { fs.rmSync(aside, { force: true }); }
731
+ }
732
+
733
+ commands.concat = async ({ opts }, io) => {
734
+ const r = await concatFiles({ inputs: opts.inputs, out: opts.out, force: !!opts.force });
735
+ if (opts.json) { io.log(JSON.stringify(r, null, 2)); return 0; }
736
+ const L = [`${r.out} ${kb(r.bytes)}`, ` ${r.clips.length} clips, ${r.duration} s, joined by copy (no re-encode): ${r.tracks.join(', ')}`];
737
+ r.clips.slice(0, 8).forEach((c, i) => L.push(` ${i + 1} ${path.basename(c.file)} ${c.from}–${c.to} s`));
738
+ if (r.clips.length > 8) L.push(` … ${r.clips.length - 8} more (--json)`);
739
+ for (const n of r.notes.slice(0, 3)) L.push(` note ${n}`);
740
+ if (r.notes.length > 3) L.push(` note … ${r.notes.length - 3} more (--json)`);
741
+ io.log(L.join('\n'));
742
+ return 0;
743
+ };
744
+
745
+ const self = fileURLToPath(import.meta.url);
746
+ if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fs.realpathSync(self)) {
747
+ runMedia(process.argv.slice(2)).then(code => process.exit(code));
748
+ }