novac 2.2.0 → 2.2.2

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 (122) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +0 -0
  3. package/bin/novac +6 -3
  4. package/bin/nvc +0 -0
  5. package/bin/nvml +0 -0
  6. package/demo.nv +0 -0
  7. package/demo_builtins.nv +0 -0
  8. package/demo_http.nv +0 -0
  9. package/examples/bf.nv +5 -13
  10. package/examples/math.nv +2 -2
  11. package/kits/kitffmpeg/kitdef.js +1174 -0
  12. package/kits/libos/kitdef.js +3135 -0
  13. package/kits/libtasker/kitdef.js +125 -0
  14. package/package.json +1 -1
  15. package/scripts/update-bin.js +0 -0
  16. package/src/core/executor.js +7 -4
  17. package/src/core/lexer.js +2 -2
  18. package/src/index.js +0 -0
  19. package/novac/LICENSE +0 -21
  20. package/novac/README.md +0 -1823
  21. package/novac/bin/novac +0 -950
  22. package/novac/bin/nvc +0 -522
  23. package/novac/bin/nvml +0 -542
  24. package/novac/demo.nv +0 -245
  25. package/novac/demo_builtins.nv +0 -209
  26. package/novac/demo_http.nv +0 -62
  27. package/novac/examples/bf.nv +0 -69
  28. package/novac/examples/math.nv +0 -21
  29. package/novac/kits/kitai/kitdef.js +0 -2185
  30. package/novac/kits/kitansi/kitdef.js +0 -1402
  31. package/novac/kits/kitformat/kitdef.js +0 -1485
  32. package/novac/kits/kitgps/kitdef.js +0 -1862
  33. package/novac/kits/kitlibfs/kitdef.js +0 -231
  34. package/novac/kits/kitlibproc/kitdef.js +0 -78
  35. package/novac/kits/kitmatrix/ex.js +0 -19
  36. package/novac/kits/kitmatrix/kitdef.js +0 -960
  37. package/novac/kits/kitmpatch/kitdef.js +0 -906
  38. package/novac/kits/kitnovacweb/README.md +0 -1572
  39. package/novac/kits/kitnovacweb/demo.nv +0 -12
  40. package/novac/kits/kitnovacweb/demo.nvml +0 -71
  41. package/novac/kits/kitnovacweb/index.nova +0 -12
  42. package/novac/kits/kitnovacweb/kitdef.js +0 -692
  43. package/novac/kits/kitnovacweb/nova.kit.json +0 -8
  44. package/novac/kits/kitnovacweb/nvml/executor.js +0 -739
  45. package/novac/kits/kitnovacweb/nvml/index.js +0 -67
  46. package/novac/kits/kitnovacweb/nvml/lexer.js +0 -263
  47. package/novac/kits/kitnovacweb/nvml/parser.js +0 -508
  48. package/novac/kits/kitnovacweb/nvml/renderer.js +0 -924
  49. package/novac/kits/kitparse/kitdef.js +0 -1688
  50. package/novac/kits/kitregex++/kitdef.js +0 -1353
  51. package/novac/kits/kitrequire/kitdef.js +0 -1599
  52. package/novac/kits/kitx11/kitdef.js +0 -1
  53. package/novac/kits/kitx11/kitx11.js +0 -2472
  54. package/novac/kits/kitx11/kitx11_conn.js +0 -948
  55. package/novac/kits/kitx11/kitx11_worker.js +0 -121
  56. package/novac/kits/libtea/tf.js +0 -2691
  57. package/novac/kits/libterm/ex.js +0 -285
  58. package/novac/kits/libterm/kitdef.js +0 -1927
  59. package/novac/node_modules/chalk/license +0 -9
  60. package/novac/node_modules/chalk/package.json +0 -83
  61. package/novac/node_modules/chalk/readme.md +0 -297
  62. package/novac/node_modules/chalk/source/index.d.ts +0 -325
  63. package/novac/node_modules/chalk/source/index.js +0 -225
  64. package/novac/node_modules/chalk/source/utilities.js +0 -33
  65. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +0 -236
  66. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +0 -223
  67. package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +0 -1
  68. package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +0 -34
  69. package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +0 -55
  70. package/novac/node_modules/chalk/source/vendor/supports-color/index.js +0 -190
  71. package/novac/node_modules/commander/LICENSE +0 -22
  72. package/novac/node_modules/commander/Readme.md +0 -1176
  73. package/novac/node_modules/commander/esm.mjs +0 -16
  74. package/novac/node_modules/commander/index.js +0 -24
  75. package/novac/node_modules/commander/lib/argument.js +0 -150
  76. package/novac/node_modules/commander/lib/command.js +0 -2777
  77. package/novac/node_modules/commander/lib/error.js +0 -39
  78. package/novac/node_modules/commander/lib/help.js +0 -747
  79. package/novac/node_modules/commander/lib/option.js +0 -380
  80. package/novac/node_modules/commander/lib/suggestSimilar.js +0 -101
  81. package/novac/node_modules/commander/package-support.json +0 -19
  82. package/novac/node_modules/commander/package.json +0 -82
  83. package/novac/node_modules/commander/typings/esm.d.mts +0 -3
  84. package/novac/node_modules/commander/typings/index.d.ts +0 -1113
  85. package/novac/node_modules/node-addon-api/LICENSE.md +0 -9
  86. package/novac/node_modules/node-addon-api/README.md +0 -95
  87. package/novac/node_modules/node-addon-api/common.gypi +0 -21
  88. package/novac/node_modules/node-addon-api/except.gypi +0 -25
  89. package/novac/node_modules/node-addon-api/index.js +0 -14
  90. package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +0 -186
  91. package/novac/node_modules/node-addon-api/napi-inl.h +0 -7165
  92. package/novac/node_modules/node-addon-api/napi.h +0 -3364
  93. package/novac/node_modules/node-addon-api/node_addon_api.gyp +0 -42
  94. package/novac/node_modules/node-addon-api/node_api.gyp +0 -9
  95. package/novac/node_modules/node-addon-api/noexcept.gypi +0 -26
  96. package/novac/node_modules/node-addon-api/nothing.c +0 -0
  97. package/novac/node_modules/node-addon-api/package-support.json +0 -21
  98. package/novac/node_modules/node-addon-api/package.json +0 -480
  99. package/novac/node_modules/node-addon-api/tools/README.md +0 -73
  100. package/novac/node_modules/node-addon-api/tools/check-napi.js +0 -99
  101. package/novac/node_modules/node-addon-api/tools/clang-format.js +0 -71
  102. package/novac/node_modules/node-addon-api/tools/conversion.js +0 -301
  103. package/novac/node_modules/serialize-javascript/LICENSE +0 -27
  104. package/novac/node_modules/serialize-javascript/README.md +0 -149
  105. package/novac/node_modules/serialize-javascript/index.js +0 -297
  106. package/novac/node_modules/serialize-javascript/package.json +0 -33
  107. package/novac/package.json +0 -27
  108. package/novac/scripts/update-bin.js +0 -24
  109. package/novac/src/core/bstd.js +0 -1035
  110. package/novac/src/core/config.js +0 -155
  111. package/novac/src/core/describe.js +0 -187
  112. package/novac/src/core/emitter.js +0 -499
  113. package/novac/src/core/error.js +0 -86
  114. package/novac/src/core/executor.js +0 -5606
  115. package/novac/src/core/formatter.js +0 -686
  116. package/novac/src/core/lexer.js +0 -1026
  117. package/novac/src/core/nova_builtins.js +0 -717
  118. package/novac/src/core/nova_thread_worker.js +0 -166
  119. package/novac/src/core/parser.js +0 -2181
  120. package/novac/src/core/types.js +0 -112
  121. package/novac/src/index.js +0 -28
  122. package/novac/src/runtime/stdlib.js +0 -244
@@ -0,0 +1,1174 @@
1
+ // kitdef.js — kitFFmpeg
2
+ // Comprehensive FFmpeg wrapper: transcoding, trimming, merging, streaming,
3
+ // filters, thumbnails, metadata, probing, and more.
4
+ //
5
+ // Requires: ffmpeg and ffprobe installed and available in PATH.
6
+ // Install: https://ffmpeg.org/download.html
7
+ //
8
+ // const { kitFFmpeg } = require('./kitdef').kitdef;
9
+
10
+ const { execSync, spawn } = require("child_process");
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const os = require("os");
14
+
15
+ // ── Helpers ──────────────────────────────────────────────────────────────────
16
+
17
+ function _hasFFmpeg() {
18
+ try { execSync("ffmpeg -version", { stdio: "pipe" }); return true; }
19
+ catch { return false; }
20
+ }
21
+
22
+ function _hasFFprobe() {
23
+ try { execSync("ffprobe -version", { stdio: "pipe" }); return true; }
24
+ catch { return false; }
25
+ }
26
+
27
+ function _require() {
28
+ if (!_hasFFmpeg()) throw new Error("ffmpeg not found in PATH. Install from https://ffmpeg.org/download.html");
29
+ }
30
+
31
+ function _requireProbe() {
32
+ if (!_hasFFprobe()) throw new Error("ffprobe not found in PATH.");
33
+ }
34
+
35
+ function _tmpFile(ext) {
36
+ return path.join(os.tmpdir(), `kitffmpeg_${Date.now()}_${Math.random().toString(36).slice(2)}${ext}`);
37
+ }
38
+
39
+ function _ext(filePath) {
40
+ return path.extname(filePath).toLowerCase();
41
+ }
42
+
43
+ function _run(args, options = {}) {
44
+ _require();
45
+ const cmd = ["ffmpeg", "-y", ...args].join(" ");
46
+ return execSync(cmd, { stdio: options.silent ? "pipe" : "inherit", ...options });
47
+ }
48
+
49
+ function _runSpawn(args, { onProgress, onLog } = {}) {
50
+ _require();
51
+ return new Promise((resolve, reject) => {
52
+ const proc = spawn("ffmpeg", ["-y", ...args]);
53
+ let stderr = "";
54
+ let duration = null;
55
+
56
+ proc.stderr.on("data", chunk => {
57
+ const text = chunk.toString();
58
+ stderr += text;
59
+ if (onLog) onLog(text);
60
+
61
+ // Parse duration for progress calculation
62
+ if (!duration) {
63
+ const dm = text.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
64
+ if (dm) duration = parseInt(dm[1]) * 3600 + parseInt(dm[2]) * 60 + parseFloat(dm[3]);
65
+ }
66
+
67
+ // Parse progress
68
+ if (onProgress && duration) {
69
+ const tm = text.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/);
70
+ if (tm) {
71
+ const elapsed = parseInt(tm[1]) * 3600 + parseInt(tm[2]) * 60 + parseFloat(tm[3]);
72
+ onProgress({ percent: parseFloat(((elapsed / duration) * 100).toFixed(1)), elapsed, duration });
73
+ }
74
+ }
75
+ });
76
+
77
+ proc.on("close", code => {
78
+ if (code === 0) resolve({ code, stderr });
79
+ else reject(new Error(`ffmpeg exited with code ${code}\n${stderr}`));
80
+ });
81
+ });
82
+ }
83
+
84
+ function _probeRaw(filePath) {
85
+ _requireProbe();
86
+ const result = execSync(
87
+ `ffprobe -v quiet -print_format json -show_format -show_streams "${filePath}"`,
88
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
89
+ );
90
+ return JSON.parse(result);
91
+ }
92
+
93
+ function _timeStr(seconds) {
94
+ const h = Math.floor(seconds / 3600);
95
+ const m = Math.floor((seconds % 3600) / 60);
96
+ const s = (seconds % 60).toFixed(3);
97
+ return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(s).padStart(6,"0")}`;
98
+ }
99
+
100
+ // ── Export ───────────────────────────────────────────────────────────────────
101
+
102
+ module.exports = {
103
+ kitdef: {
104
+
105
+ name: "kitFFmpeg",
106
+ version: "1.0.0",
107
+ description: "Comprehensive FFmpeg wrapper: transcoding, trimming, merging, filters, thumbnails, metadata, probing, streaming, and more.",
108
+
109
+ // ──────────────────────────────────────────────────────────────────────
110
+ // SYSTEM
111
+ // ──────────────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Check if ffmpeg and ffprobe are available.
115
+ * @returns {{ ffmpeg: boolean, ffprobe: boolean, version?: string }}
116
+ */
117
+ isAvailable() {
118
+ const ffmpeg = _hasFFmpeg();
119
+ const ffprobe = _hasFFprobe();
120
+ let version = null;
121
+ if (ffmpeg) {
122
+ try {
123
+ const out = execSync("ffmpeg -version", { encoding: "utf8", stdio: ["ignore","pipe","pipe"] });
124
+ version = out.split("\n")[0].replace("ffmpeg version ", "").split(" ")[0];
125
+ } catch {}
126
+ }
127
+ return { ffmpeg, ffprobe, version };
128
+ },
129
+
130
+ /**
131
+ * List all available codecs.
132
+ * @returns {string[]}
133
+ */
134
+ listCodecs() {
135
+ _require();
136
+ const out = execSync("ffmpeg -codecs", { encoding: "utf8", stdio: ["ignore","pipe","pipe"] });
137
+ return out.split("\n").filter(l => /^\s[D.]/.test(l)).map(l => l.trim());
138
+ },
139
+
140
+ /**
141
+ * List all available formats.
142
+ * @returns {string[]}
143
+ */
144
+ listFormats() {
145
+ _require();
146
+ const out = execSync("ffmpeg -formats", { encoding: "utf8", stdio: ["ignore","pipe","pipe"] });
147
+ return out.split("\n").filter(l => /^\s[DE]/.test(l)).map(l => l.trim());
148
+ },
149
+
150
+ /**
151
+ * List all available filters.
152
+ * @returns {string[]}
153
+ */
154
+ listFilters() {
155
+ _require();
156
+ const out = execSync("ffmpeg -filters", { encoding: "utf8", stdio: ["ignore","pipe","pipe"] });
157
+ return out.split("\n").filter(l => /^\s[A-Z]/.test(l)).map(l => l.trim());
158
+ },
159
+
160
+ // ──────────────────────────────────────────────────────────────────────
161
+ // PROBING & METADATA
162
+ // ──────────────────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * Probe a media file and return full metadata.
166
+ * @param {string} filePath
167
+ * @returns {object} Full ffprobe JSON output.
168
+ *
169
+ * @example
170
+ * const info = kitFFmpeg.probe("video.mp4");
171
+ * console.log(info.format.duration);
172
+ */
173
+ probe(filePath) {
174
+ return _probeRaw(filePath);
175
+ },
176
+
177
+ /**
178
+ * Get a simplified summary of a media file.
179
+ * @param {string} filePath
180
+ * @returns {object}
181
+ */
182
+ info(filePath) {
183
+ const raw = _probeRaw(filePath);
184
+ const fmt = raw.format;
185
+ const videoStream = raw.streams.find(s => s.codec_type === "video");
186
+ const audioStream = raw.streams.find(s => s.codec_type === "audio");
187
+
188
+ return {
189
+ path: filePath,
190
+ format: fmt.format_name,
191
+ duration: parseFloat(fmt.duration || 0),
192
+ size: parseInt(fmt.size || 0),
193
+ bitrate: parseInt(fmt.bit_rate || 0),
194
+ tags: fmt.tags || {},
195
+ video: videoStream ? {
196
+ codec: videoStream.codec_name,
197
+ width: videoStream.width,
198
+ height: videoStream.height,
199
+ fps: eval(videoStream.r_frame_rate) || null,
200
+ bitrate: parseInt(videoStream.bit_rate || 0),
201
+ pixelFmt: videoStream.pix_fmt,
202
+ profile: videoStream.profile,
203
+ } : null,
204
+ audio: audioStream ? {
205
+ codec: audioStream.codec_name,
206
+ channels: audioStream.channels,
207
+ sampleRate: parseInt(audioStream.sample_rate || 0),
208
+ bitrate: parseInt(audioStream.bit_rate || 0),
209
+ language: audioStream.tags?.language || null,
210
+ } : null,
211
+ streams: raw.streams.length,
212
+ };
213
+ },
214
+
215
+ /**
216
+ * Get the duration of a media file in seconds.
217
+ * @param {string} filePath
218
+ * @returns {number}
219
+ */
220
+ duration(filePath) {
221
+ return parseFloat(_probeRaw(filePath).format.duration || 0);
222
+ },
223
+
224
+ /**
225
+ * Get the resolution of a video file.
226
+ * @param {string} filePath
227
+ * @returns {{ width: number, height: number }}
228
+ */
229
+ resolution(filePath) {
230
+ const raw = _probeRaw(filePath);
231
+ const v = raw.streams.find(s => s.codec_type === "video");
232
+ if (!v) throw new Error("No video stream found.");
233
+ return { width: v.width, height: v.height };
234
+ },
235
+
236
+ /**
237
+ * Get all metadata tags from a file.
238
+ * @param {string} filePath
239
+ * @returns {object}
240
+ */
241
+ getTags(filePath) {
242
+ return _probeRaw(filePath).format.tags || {};
243
+ },
244
+
245
+ /**
246
+ * Check if a file has a video stream.
247
+ * @param {string} filePath
248
+ * @returns {boolean}
249
+ */
250
+ hasVideo(filePath) {
251
+ return _probeRaw(filePath).streams.some(s => s.codec_type === "video");
252
+ },
253
+
254
+ /**
255
+ * Check if a file has an audio stream.
256
+ * @param {string} filePath
257
+ * @returns {boolean}
258
+ */
259
+ hasAudio(filePath) {
260
+ return _probeRaw(filePath).streams.some(s => s.codec_type === "audio");
261
+ },
262
+
263
+ // ──────────────────────────────────────────────────────────────────────
264
+ // TRANSCODING
265
+ // ──────────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Convert a media file to a different format/codec.
269
+ * @param {string} input
270
+ * @param {string} output
271
+ * @param {object} [options]
272
+ * @param {string} [options.videoCodec] - e.g. "libx264", "libvpx", "copy"
273
+ * @param {string} [options.audioCodec] - e.g. "aac", "libmp3lame", "copy"
274
+ * @param {string} [options.videoBitrate] - e.g. "2M"
275
+ * @param {string} [options.audioBitrate] - e.g. "192k"
276
+ * @param {number} [options.crf] - Constant rate factor (0-51, lower = better)
277
+ * @param {string} [options.preset] - e.g. "fast", "slow", "veryslow"
278
+ * @param {string} [options.pixelFormat] - e.g. "yuv420p"
279
+ * @param {function}[options.onProgress] - Progress callback
280
+ * @returns {Promise<{ input, output, duration }>}
281
+ *
282
+ * @example
283
+ * await kitFFmpeg.convert("input.mov", "output.mp4", { videoCodec: "libx264", crf: 23 });
284
+ */
285
+ async convert(input, output, options = {}) {
286
+ const args = ["-i", `"${input}"`];
287
+ if (options.videoCodec) args.push("-c:v", options.videoCodec);
288
+ if (options.audioCodec) args.push("-c:a", options.audioCodec);
289
+ if (options.videoBitrate) args.push("-b:v", options.videoBitrate);
290
+ if (options.audioBitrate) args.push("-b:a", options.audioBitrate);
291
+ if (options.crf !== undefined) args.push("-crf", options.crf);
292
+ if (options.preset) args.push("-preset", options.preset);
293
+ if (options.pixelFormat) args.push("-pix_fmt", options.pixelFormat);
294
+ args.push(`"${output}"`);
295
+
296
+ await _runSpawn(args, { onProgress: options.onProgress });
297
+ return { input, output };
298
+ },
299
+
300
+ /**
301
+ * Re-encode a video with H.264 (most compatible format).
302
+ * @param {string} input
303
+ * @param {string} output
304
+ * @param {{ crf?: number, preset?: string, onProgress?: function }} [options]
305
+ * @returns {Promise<object>}
306
+ */
307
+ toH264(input, output, options = {}) {
308
+ return this.convert(input, output, {
309
+ videoCodec: "libx264",
310
+ audioCodec: "aac",
311
+ pixelFormat: "yuv420p",
312
+ crf: options.crf || 23,
313
+ preset: options.preset || "medium",
314
+ onProgress: options.onProgress,
315
+ });
316
+ },
317
+
318
+ /**
319
+ * Re-encode a video with H.265/HEVC (better compression than H.264).
320
+ * @param {string} input
321
+ * @param {string} output
322
+ * @param {{ crf?: number, preset?: string, onProgress?: function }} [options]
323
+ */
324
+ toH265(input, output, options = {}) {
325
+ return this.convert(input, output, {
326
+ videoCodec: "libx265",
327
+ audioCodec: "aac",
328
+ crf: options.crf || 28,
329
+ preset: options.preset || "medium",
330
+ onProgress: options.onProgress,
331
+ });
332
+ },
333
+
334
+ /**
335
+ * Convert to WebM (VP9 + Opus, ideal for web).
336
+ * @param {string} input
337
+ * @param {string} output
338
+ * @param {{ crf?: number, onProgress?: function }} [options]
339
+ */
340
+ toWebM(input, output, options = {}) {
341
+ return this.convert(input, output, {
342
+ videoCodec: "libvpx-vp9",
343
+ audioCodec: "libopus",
344
+ crf: options.crf || 30,
345
+ onProgress: options.onProgress,
346
+ });
347
+ },
348
+
349
+ /**
350
+ * Convert video to GIF.
351
+ * @param {string} input
352
+ * @param {string} output
353
+ * @param {{ fps?: number, width?: number, start?: number, duration?: number }} [options]
354
+ * @returns {Promise<object>}
355
+ */
356
+ async toGIF(input, output, options = {}) {
357
+ const fps = options.fps || 15;
358
+ const width = options.width || 480;
359
+ const palette = _tmpFile(".png");
360
+
361
+ const baseArgs = [];
362
+ if (options.start) baseArgs.push("-ss", options.start);
363
+ if (options.duration) baseArgs.push("-t", options.duration);
364
+ baseArgs.push("-i", `"${input}"`);
365
+
366
+ // Generate palette for better quality
367
+ await _runSpawn([...baseArgs, "-vf", `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen`, `"${palette}"`]);
368
+ await _runSpawn([...baseArgs, "-i", `"${palette}"`, "-filter_complex", `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse`, `"${output}"`]);
369
+
370
+ try { fs.unlinkSync(palette); } catch {}
371
+ return { input, output, fps, width };
372
+ },
373
+
374
+ // ──────────────────────────────────────────────────────────────────────
375
+ // TRIMMING & CUTTING
376
+ // ──────────────────────────────────────────────────────────────────────
377
+
378
+ /**
379
+ * Trim a media file to a start and end time.
380
+ * @param {string} input
381
+ * @param {string} output
382
+ * @param {object} options
383
+ * @param {number|string} options.start - Start time in seconds or "HH:MM:SS".
384
+ * @param {number|string} [options.end] - End time in seconds or "HH:MM:SS".
385
+ * @param {number|string} [options.duration] - Duration instead of end time.
386
+ * @param {boolean} [options.accurate=false] - Slow but frame-accurate trim.
387
+ * @returns {Promise<object>}
388
+ *
389
+ * @example
390
+ * await kitFFmpeg.trim("video.mp4", "clip.mp4", { start: 10, end: 30 });
391
+ * await kitFFmpeg.trim("video.mp4", "clip.mp4", { start: "00:01:30", duration: 60 });
392
+ */
393
+ async trim(input, output, options = {}) {
394
+ const args = [];
395
+
396
+ if (!options.accurate) {
397
+ // Fast seek (before input)
398
+ if (options.start !== undefined) args.push("-ss", options.start);
399
+ args.push("-i", `"${input}"`);
400
+ if (options.end !== undefined) args.push("-to", options.end);
401
+ if (options.duration !== undefined) args.push("-t", options.duration);
402
+ args.push("-c", "copy");
403
+ } else {
404
+ // Accurate seek (slower, re-encodes)
405
+ args.push("-i", `"${input}"`);
406
+ if (options.start !== undefined) args.push("-ss", options.start);
407
+ if (options.end !== undefined) args.push("-to", options.end);
408
+ if (options.duration !== undefined) args.push("-t", options.duration);
409
+ }
410
+
411
+ args.push(`"${output}"`);
412
+ await _runSpawn(args, { onProgress: options.onProgress });
413
+ return { input, output, start: options.start, end: options.end };
414
+ },
415
+
416
+ /**
417
+ * Split a file into segments of a fixed duration.
418
+ * @param {string} input
419
+ * @param {string} outputPattern - e.g. "segment_%03d.mp4"
420
+ * @param {number} segmentSeconds
421
+ * @returns {Promise<{ segments: string[] }>}
422
+ *
423
+ * @example
424
+ * await kitFFmpeg.split("long.mp4", "part_%03d.mp4", 600); // 10-min segments
425
+ */
426
+ async split(input, outputPattern, segmentSeconds) {
427
+ const args = [
428
+ "-i", `"${input}"`,
429
+ "-c", "copy",
430
+ "-f", "segment",
431
+ "-segment_time", segmentSeconds,
432
+ "-reset_timestamps", "1",
433
+ `"${outputPattern}"`,
434
+ ];
435
+ await _runSpawn(args);
436
+ return { input, outputPattern, segmentSeconds };
437
+ },
438
+
439
+ /**
440
+ * Cut out a section from a file (remove a range, keep the rest).
441
+ * @param {string} input
442
+ * @param {string} output
443
+ * @param {number} cutStart - Start of section to remove (seconds).
444
+ * @param {number} cutEnd - End of section to remove (seconds).
445
+ * @returns {Promise<object>}
446
+ */
447
+ async cutOut(input, output, cutStart, cutEnd) {
448
+ const part1 = _tmpFile(_ext(input));
449
+ const part2 = _tmpFile(_ext(input));
450
+ await this.trim(input, part1, { end: cutStart });
451
+ await this.trim(input, part2, { start: cutEnd });
452
+ await this.concat([part1, part2], output);
453
+ try { fs.unlinkSync(part1); fs.unlinkSync(part2); } catch {}
454
+ return { input, output, cutStart, cutEnd };
455
+ },
456
+
457
+ // ──────────────────────────────────────────────────────────────────────
458
+ // MERGING & CONCATENATION
459
+ // ──────────────────────────────────────────────────────────────────────
460
+
461
+ /**
462
+ * Concatenate multiple media files into one.
463
+ * All files must have the same codec and resolution.
464
+ * @param {string[]} inputs
465
+ * @param {string} output
466
+ * @returns {Promise<object>}
467
+ *
468
+ * @example
469
+ * await kitFFmpeg.concat(["part1.mp4", "part2.mp4", "part3.mp4"], "full.mp4");
470
+ */
471
+ async concat(inputs, output) {
472
+ const listFile = _tmpFile(".txt");
473
+ const content = inputs.map(f => `file '${path.resolve(f)}'`).join("\n");
474
+ fs.writeFileSync(listFile, content, "utf8");
475
+
476
+ await _runSpawn(["-f", "concat", "-safe", "0", "-i", `"${listFile}"`, "-c", "copy", `"${output}"`]);
477
+ try { fs.unlinkSync(listFile); } catch {}
478
+ return { inputs, output };
479
+ },
480
+
481
+ /**
482
+ * Merge video and audio from separate files.
483
+ * @param {string} videoInput
484
+ * @param {string} audioInput
485
+ * @param {string} output
486
+ * @param {{ shortest?: boolean }} [options]
487
+ * @returns {Promise<object>}
488
+ *
489
+ * @example
490
+ * await kitFFmpeg.mergeAV("video.mp4", "audio.m4a", "combined.mp4");
491
+ */
492
+ async mergeAV(videoInput, audioInput, output, options = {}) {
493
+ const args = [
494
+ "-i", `"${videoInput}"`,
495
+ "-i", `"${audioInput}"`,
496
+ "-c:v", "copy",
497
+ "-c:a", "aac",
498
+ ];
499
+ if (options.shortest) args.push("-shortest");
500
+ args.push(`"${output}"`);
501
+ await _runSpawn(args);
502
+ return { videoInput, audioInput, output };
503
+ },
504
+
505
+ /**
506
+ * Stack two videos side by side (horizontally).
507
+ * @param {string} left
508
+ * @param {string} right
509
+ * @param {string} output
510
+ * @returns {Promise<object>}
511
+ */
512
+ async stackHorizontal(left, right, output) {
513
+ await _runSpawn([
514
+ "-i", `"${left}"`, "-i", `"${right}"`,
515
+ "-filter_complex", "hstack=inputs=2",
516
+ `"${output}"`,
517
+ ]);
518
+ return { left, right, output };
519
+ },
520
+
521
+ /**
522
+ * Stack two videos on top of each other (vertically).
523
+ * @param {string} top
524
+ * @param {string} bottom
525
+ * @param {string} output
526
+ * @returns {Promise<object>}
527
+ */
528
+ async stackVertical(top, bottom, output) {
529
+ await _runSpawn([
530
+ "-i", `"${top}"`, "-i", `"${bottom}"`,
531
+ "-filter_complex", "vstack=inputs=2",
532
+ `"${output}"`,
533
+ ]);
534
+ return { top, bottom, output };
535
+ },
536
+
537
+ /**
538
+ * Create a 2x2 grid from four video files.
539
+ * @param {string[]} inputs - Exactly 4 files.
540
+ * @param {string} output
541
+ * @returns {Promise<object>}
542
+ */
543
+ async grid2x2(inputs, output) {
544
+ if (inputs.length !== 4) throw new Error("grid2x2 requires exactly 4 input files.");
545
+ const [a, b, c, d] = inputs;
546
+ await _runSpawn([
547
+ "-i", `"${a}"`, "-i", `"${b}"`, "-i", `"${c}"`, "-i", `"${d}"`,
548
+ "-filter_complex",
549
+ "[0:v][1:v]hstack[top];[2:v][3:v]hstack[bottom];[top][bottom]vstack",
550
+ `"${output}"`,
551
+ ]);
552
+ return { inputs, output };
553
+ },
554
+
555
+ // ──────────────────────────────────────────────────────────────────────
556
+ // AUDIO
557
+ // ──────────────────────────────────────────────────────────────────────
558
+
559
+ /**
560
+ * Extract audio from a video file.
561
+ * @param {string} input
562
+ * @param {string} output - e.g. "audio.mp3", "audio.aac", "audio.wav"
563
+ * @param {{ bitrate?: string, codec?: string }} [options]
564
+ * @returns {Promise<object>}
565
+ *
566
+ * @example
567
+ * await kitFFmpeg.extractAudio("video.mp4", "audio.mp3", { bitrate: "320k" });
568
+ */
569
+ async extractAudio(input, output, options = {}) {
570
+ const args = ["-i", `"${input}"`, "-vn"];
571
+ if (options.codec) args.push("-c:a", options.codec);
572
+ if (options.bitrate) args.push("-b:a", options.bitrate);
573
+ args.push(`"${output}"`);
574
+ await _runSpawn(args);
575
+ return { input, output };
576
+ },
577
+
578
+ /**
579
+ * Strip all audio from a video file.
580
+ * @param {string} input
581
+ * @param {string} output
582
+ * @returns {Promise<object>}
583
+ */
584
+ async stripAudio(input, output) {
585
+ await _runSpawn(["-i", `"${input}"`, "-an", "-c:v", "copy", `"${output}"`]);
586
+ return { input, output };
587
+ },
588
+
589
+ /**
590
+ * Replace the audio track of a video with a different audio file.
591
+ * @param {string} videoInput
592
+ * @param {string} audioInput
593
+ * @param {string} output
594
+ * @param {{ shortest?: boolean }} [options]
595
+ */
596
+ async replaceAudio(videoInput, audioInput, output, options = {}) {
597
+ return this.mergeAV(videoInput, audioInput, output, options);
598
+ },
599
+
600
+ /**
601
+ * Adjust the volume of a media file.
602
+ * @param {string} input
603
+ * @param {string} output
604
+ * @param {number|string} volume - e.g. 0.5 (half), 2.0 (double), "6dB"
605
+ * @returns {Promise<object>}
606
+ */
607
+ async adjustVolume(input, output, volume) {
608
+ await _runSpawn(["-i", `"${input}"`, "-filter:a", `volume=${volume}`, `"${output}"`]);
609
+ return { input, output, volume };
610
+ },
611
+
612
+ /**
613
+ * Normalize audio loudness to a target LUFS level.
614
+ * @param {string} input
615
+ * @param {string} output
616
+ * @param {number} [targetLUFS=-16]
617
+ * @returns {Promise<object>}
618
+ */
619
+ async normalizeAudio(input, output, targetLUFS = -16) {
620
+ await _runSpawn([
621
+ "-i", `"${input}"`,
622
+ "-filter:a", `loudnorm=I=${targetLUFS}:TP=-1.5:LRA=11`,
623
+ `"${output}"`,
624
+ ]);
625
+ return { input, output, targetLUFS };
626
+ },
627
+
628
+ /**
629
+ * Mix multiple audio files into one.
630
+ * @param {string[]} inputs
631
+ * @param {string} output
632
+ * @returns {Promise<object>}
633
+ */
634
+ async mixAudio(inputs, output) {
635
+ const args = inputs.flatMap(f => ["-i", `"${f}"`]);
636
+ args.push("-filter_complex", `amix=inputs=${inputs.length}:duration=longest`, `"${output}"`);
637
+ await _runSpawn(args);
638
+ return { inputs, output };
639
+ },
640
+
641
+ /**
642
+ * Convert audio to a different format.
643
+ * @param {string} input
644
+ * @param {string} output
645
+ * @param {{ codec?: string, bitrate?: string, sampleRate?: number, channels?: number }} [options]
646
+ */
647
+ async convertAudio(input, output, options = {}) {
648
+ const args = ["-i", `"${input}"`];
649
+ if (options.codec) args.push("-c:a", options.codec);
650
+ if (options.bitrate) args.push("-b:a", options.bitrate);
651
+ if (options.sampleRate) args.push("-ar", options.sampleRate);
652
+ if (options.channels) args.push("-ac", options.channels);
653
+ args.push(`"${output}"`);
654
+ await _runSpawn(args);
655
+ return { input, output };
656
+ },
657
+
658
+ /**
659
+ * Generate a silent audio file of a given duration.
660
+ * @param {string} output
661
+ * @param {number} durationSeconds
662
+ * @param {{ sampleRate?: number, channels?: number }} [options]
663
+ */
664
+ async generateSilence(output, durationSeconds, options = {}) {
665
+ const sr = options.sampleRate || 44100;
666
+ const ch = options.channels || 2;
667
+ await _runSpawn([
668
+ "-f", "lavfi", "-i", `anullsrc=r=${sr}:cl=${ch === 2 ? "stereo" : "mono"}`,
669
+ "-t", durationSeconds, `"${output}"`,
670
+ ]);
671
+ return { output, durationSeconds };
672
+ },
673
+
674
+ /**
675
+ * Generate a tone audio file.
676
+ * @param {string} output
677
+ * @param {{ frequency?: number, duration?: number, amplitude?: number }} [options]
678
+ */
679
+ async generateTone(output, options = {}) {
680
+ const freq = options.frequency || 440;
681
+ const dur = options.duration || 5;
682
+ const amp = options.amplitude || 0.5;
683
+ await _runSpawn([
684
+ "-f", "lavfi", "-i", `sine=frequency=${freq}:amplitude=${amp}`,
685
+ "-t", dur, `"${output}"`,
686
+ ]);
687
+ return { output, frequency: freq, duration: dur };
688
+ },
689
+
690
+ // ──────────────────────────────────────────────────────────────────────
691
+ // VIDEO FILTERS & EFFECTS
692
+ // ──────────────────────────────────────────────────────────────────────
693
+
694
+ /**
695
+ * Scale (resize) a video.
696
+ * @param {string} input
697
+ * @param {string} output
698
+ * @param {number|string} width - Width in pixels, or -1 to maintain aspect ratio.
699
+ * @param {number|string} height - Height in pixels, or -1 to maintain aspect ratio.
700
+ * @returns {Promise<object>}
701
+ *
702
+ * @example
703
+ * await kitFFmpeg.scale("input.mp4", "output.mp4", 1280, 720);
704
+ * await kitFFmpeg.scale("input.mp4", "output.mp4", 1280, -1); // maintain aspect
705
+ */
706
+ async scale(input, output, width, height) {
707
+ await _runSpawn(["-i", `"${input}"`, "-vf", `scale=${width}:${height}`, `"${output}"`]);
708
+ return { input, output, width, height };
709
+ },
710
+
711
+ /**
712
+ * Crop a video to a rectangle.
713
+ * @param {string} input
714
+ * @param {string} output
715
+ * @param {{ w: number, h: number, x?: number, y?: number }} rect
716
+ * @returns {Promise<object>}
717
+ */
718
+ async crop(input, output, { w, h, x = 0, y = 0 }) {
719
+ await _runSpawn(["-i", `"${input}"`, "-vf", `crop=${w}:${h}:${x}:${y}`, `"${output}"`]);
720
+ return { input, output, w, h, x, y };
721
+ },
722
+
723
+ /**
724
+ * Rotate a video.
725
+ * @param {string} input
726
+ * @param {string} output
727
+ * @param {90|180|270|-90} degrees
728
+ * @returns {Promise<object>}
729
+ */
730
+ async rotate(input, output, degrees) {
731
+ const transposeMap = { 90: "1", [-90]: "2", 270: "2", 180: "transpose=1,transpose=1" };
732
+ const filter = transposeMap[degrees];
733
+ if (!filter) throw new Error("Rotation must be 90, -90, 270, or 180.");
734
+ const vf = degrees === 180 ? filter : `transpose=${filter}`;
735
+ await _runSpawn(["-i", `"${input}"`, "-vf", vf, `"${output}"`]);
736
+ return { input, output, degrees };
737
+ },
738
+
739
+ /**
740
+ * Flip a video horizontally (mirror).
741
+ * @param {string} input
742
+ * @param {string} output
743
+ */
744
+ async flipHorizontal(input, output) {
745
+ await _runSpawn(["-i", `"${input}"`, "-vf", "hflip", `"${output}"`]);
746
+ return { input, output };
747
+ },
748
+
749
+ /**
750
+ * Flip a video vertically.
751
+ * @param {string} input
752
+ * @param {string} output
753
+ */
754
+ async flipVertical(input, output) {
755
+ await _runSpawn(["-i", `"${input}"`, "-vf", "vflip", `"${output}"`]);
756
+ return { input, output };
757
+ },
758
+
759
+ /**
760
+ * Change the playback speed of a video.
761
+ * @param {string} input
762
+ * @param {string} output
763
+ * @param {number} speed - e.g. 2.0 = 2x speed, 0.5 = half speed
764
+ * @returns {Promise<object>}
765
+ */
766
+ async setSpeed(input, output, speed) {
767
+ const vPts = 1 / speed;
768
+ const aTempo = Math.min(2.0, Math.max(0.5, speed));
769
+ await _runSpawn([
770
+ "-i", `"${input}"`,
771
+ "-filter_complex", `[0:v]setpts=${vPts}*PTS[v];[0:a]atempo=${aTempo}[a]`,
772
+ "-map", "[v]", "-map", "[a]",
773
+ `"${output}"`,
774
+ ]);
775
+ return { input, output, speed };
776
+ },
777
+
778
+ /**
779
+ * Reverse a video (plays backwards).
780
+ * @param {string} input
781
+ * @param {string} output
782
+ * @returns {Promise<object>}
783
+ */
784
+ async reverse(input, output) {
785
+ await _runSpawn(["-i", `"${input}"`, "-vf", "reverse", "-af", "areverse", `"${output}"`]);
786
+ return { input, output };
787
+ },
788
+
789
+ /**
790
+ * Apply a blur effect to a video.
791
+ * @param {string} input
792
+ * @param {string} output
793
+ * @param {number} [strength=5]
794
+ */
795
+ async blur(input, output, strength = 5) {
796
+ await _runSpawn(["-i", `"${input}"`, "-vf", `boxblur=${strength}:${strength}`, `"${output}"`]);
797
+ return { input, output, strength };
798
+ },
799
+
800
+ /**
801
+ * Sharpen a video.
802
+ * @param {string} input
803
+ * @param {string} output
804
+ * @param {number} [strength=1.5]
805
+ */
806
+ async sharpen(input, output, strength = 1.5) {
807
+ await _runSpawn(["-i", `"${input}"`, "-vf", `unsharp=5:5:${strength}:5:5:0`, `"${output}"`]);
808
+ return { input, output, strength };
809
+ },
810
+
811
+ /**
812
+ * Convert video to grayscale.
813
+ * @param {string} input
814
+ * @param {string} output
815
+ */
816
+ async grayscale(input, output) {
817
+ await _runSpawn(["-i", `"${input}"`, "-vf", "format=gray", `"${output}"`]);
818
+ return { input, output };
819
+ },
820
+
821
+ /**
822
+ * Adjust brightness, contrast, and saturation.
823
+ * @param {string} input
824
+ * @param {string} output
825
+ * @param {{ brightness?: number, contrast?: number, saturation?: number }} options
826
+ * Values: brightness [-1,1], contrast [0,2], saturation [0,3]
827
+ */
828
+ async adjustColor(input, output, { brightness = 0, contrast = 1, saturation = 1 } = {}) {
829
+ await _runSpawn([
830
+ "-i", `"${input}"`,
831
+ "-vf", `eq=brightness=${brightness}:contrast=${contrast}:saturation=${saturation}`,
832
+ `"${output}"`,
833
+ ]);
834
+ return { input, output, brightness, contrast, saturation };
835
+ },
836
+
837
+ /**
838
+ * Add a fade in/out effect to a video.
839
+ * @param {string} input
840
+ * @param {string} output
841
+ * @param {{ fadeIn?: number, fadeOut?: number }} [options] - Duration in seconds.
842
+ * @returns {Promise<object>}
843
+ */
844
+ async fade(input, output, { fadeIn = 1, fadeOut = 1 } = {}) {
845
+ const dur = this.duration(input);
846
+ const filters = [];
847
+ if (fadeIn) filters.push(`fade=in:st=0:d=${fadeIn}`);
848
+ if (fadeOut) filters.push(`fade=out:st=${dur - fadeOut}:d=${fadeOut}`);
849
+ const audioFilters = [];
850
+ if (fadeIn) audioFilters.push(`afade=in:st=0:d=${fadeIn}`);
851
+ if (fadeOut) audioFilters.push(`afade=out:st=${dur - fadeOut}:d=${fadeOut}`);
852
+
853
+ await _runSpawn([
854
+ "-i", `"${input}"`,
855
+ "-vf", filters.join(","),
856
+ "-af", audioFilters.join(","),
857
+ `"${output}"`,
858
+ ]);
859
+ return { input, output, fadeIn, fadeOut };
860
+ },
861
+
862
+ /**
863
+ * Add a watermark/overlay image to a video.
864
+ * @param {string} input
865
+ * @param {string} watermark - Path to watermark image.
866
+ * @param {string} output
867
+ * @param {{ position?: 'topleft'|'topright'|'bottomleft'|'bottomright'|'center', opacity?: number }} [options]
868
+ */
869
+ async watermark(input, watermark, output, options = {}) {
870
+ const pos = options.position || "bottomright";
871
+ const opacity = options.opacity || 0.8;
872
+ const posMap = {
873
+ topleft: "10:10",
874
+ topright: "main_w-overlay_w-10:10",
875
+ bottomleft: "10:main_h-overlay_h-10",
876
+ bottomright: "main_w-overlay_w-10:main_h-overlay_h-10",
877
+ center: "(main_w-overlay_w)/2:(main_h-overlay_h)/2",
878
+ };
879
+ const overlay = posMap[pos] || posMap.bottomright;
880
+ await _runSpawn([
881
+ "-i", `"${input}"`,
882
+ "-i", `"${watermark}"`,
883
+ "-filter_complex", `[1:v]format=rgba,colorchannelmixer=aa=${opacity}[wm];[0:v][wm]overlay=${overlay}`,
884
+ "-c:a", "copy",
885
+ `"${output}"`,
886
+ ]);
887
+ return { input, watermark, output, position: pos };
888
+ },
889
+
890
+ /**
891
+ * Add a text overlay to a video.
892
+ * @param {string} input
893
+ * @param {string} output
894
+ * @param {object} options
895
+ * @param {string} options.text
896
+ * @param {string} [options.font="Arial"]
897
+ * @param {number} [options.fontSize=36]
898
+ * @param {string} [options.color="white"]
899
+ * @param {string} [options.position="bottom"] - "top", "bottom", "center"
900
+ * @param {number} [options.startTime]
901
+ * @param {number} [options.endTime]
902
+ */
903
+ async addText(input, output, options = {}) {
904
+ const font = options.font || "Arial";
905
+ const size = options.fontSize || 36;
906
+ const color = options.color || "white";
907
+ const pos = options.position || "bottom";
908
+ const yMap = { top: "50", center: "(h-text_h)/2", bottom: "h-text_h-50" };
909
+ const y = yMap[pos] || yMap.bottom;
910
+ let drawtext = `text='${options.text}':fontfile=${font}:fontsize=${size}:fontcolor=${color}:x=(w-text_w)/2:y=${y}:shadowcolor=black:shadowx=2:shadowy=2`;
911
+ if (options.startTime !== undefined) drawtext += `:enable='between(t,${options.startTime},${options.endTime || 9999})'`;
912
+
913
+ await _runSpawn(["-i", `"${input}"`, "-vf", `drawtext=${drawtext}`, "-c:a", "copy", `"${output}"`]);
914
+ return { input, output, text: options.text };
915
+ },
916
+
917
+ /**
918
+ * Apply a custom ffmpeg filter string.
919
+ * @param {string} input
920
+ * @param {string} output
921
+ * @param {string} videoFilter - -vf filter string
922
+ * @param {string} [audioFilter] - -af filter string
923
+ */
924
+ async applyFilter(input, output, videoFilter, audioFilter) {
925
+ const args = ["-i", `"${input}"`, "-vf", videoFilter];
926
+ if (audioFilter) args.push("-af", audioFilter);
927
+ args.push(`"${output}"`);
928
+ await _runSpawn(args);
929
+ return { input, output };
930
+ },
931
+
932
+ // ──────────────────────────────────────────────────────────────────────
933
+ // THUMBNAILS & IMAGES
934
+ // ──────────────────────────────────────────────────────────────────────
935
+
936
+ /**
937
+ * Extract a single thumbnail frame from a video.
938
+ * @param {string} input
939
+ * @param {string} output - Should be a .jpg or .png file.
940
+ * @param {number|string} [time=1] - Time in seconds or "HH:MM:SS".
941
+ * @param {{ width?: number }} [options]
942
+ * @returns {Promise<object>}
943
+ *
944
+ * @example
945
+ * await kitFFmpeg.thumbnail("video.mp4", "thumb.jpg", 30);
946
+ */
947
+ async thumbnail(input, output, time = 1, options = {}) {
948
+ const args = ["-ss", time, "-i", `"${input}"`, "-frames:v", "1"];
949
+ if (options.width) args.push("-vf", `scale=${options.width}:-1`);
950
+ args.push(`"${output}"`);
951
+ await _runSpawn(args);
952
+ return { input, output, time };
953
+ },
954
+
955
+ /**
956
+ * Extract multiple thumbnail frames at regular intervals.
957
+ * @param {string} input
958
+ * @param {string} outputPattern - e.g. "thumb_%03d.jpg"
959
+ * @param {number} [intervalSeconds=10]
960
+ * @param {{ width?: number }} [options]
961
+ * @returns {Promise<object>}
962
+ */
963
+ async thumbnails(input, outputPattern, intervalSeconds = 10, options = {}) {
964
+ const args = ["-i", `"${input}"`];
965
+ const filters = [`fps=1/${intervalSeconds}`];
966
+ if (options.width) filters.push(`scale=${options.width}:-1`);
967
+ args.push("-vf", filters.join(","), `"${outputPattern}"`);
968
+ await _runSpawn(args);
969
+ return { input, outputPattern, intervalSeconds };
970
+ },
971
+
972
+ /**
973
+ * Extract all frames from a video as images.
974
+ * @param {string} input
975
+ * @param {string} outputPattern - e.g. "frame_%06d.png"
976
+ * @param {{ fps?: number }} [options]
977
+ * @returns {Promise<object>}
978
+ */
979
+ async extractFrames(input, outputPattern, options = {}) {
980
+ const args = ["-i", `"${input}"`];
981
+ if (options.fps) args.push("-vf", `fps=${options.fps}`);
982
+ args.push(`"${outputPattern}"`);
983
+ await _runSpawn(args);
984
+ return { input, outputPattern };
985
+ },
986
+
987
+ /**
988
+ * Create a video from a sequence of images.
989
+ * @param {string} inputPattern - e.g. "frame_%06d.png"
990
+ * @param {string} output
991
+ * @param {{ fps?: number, codec?: string }} [options]
992
+ * @returns {Promise<object>}
993
+ */
994
+ async fromImages(inputPattern, output, options = {}) {
995
+ const fps = options.fps || 24;
996
+ const codec = options.codec || "libx264";
997
+ await _runSpawn([
998
+ "-r", fps,
999
+ "-i", `"${inputPattern}"`,
1000
+ "-c:v", codec,
1001
+ "-pix_fmt", "yuv420p",
1002
+ `"${output}"`,
1003
+ ]);
1004
+ return { inputPattern, output, fps };
1005
+ },
1006
+
1007
+ /**
1008
+ * Create a video slideshow from a list of images.
1009
+ * @param {string[]} images
1010
+ * @param {string} output
1011
+ * @param {{ duration?: number, fps?: number, transition?: boolean }} [options]
1012
+ * @returns {Promise<object>}
1013
+ */
1014
+ async slideshow(images, output, options = {}) {
1015
+ const duration = options.duration || 3;
1016
+ const fps = options.fps || 25;
1017
+ const listFile = _tmpFile(".txt");
1018
+ const content = images.map(f => `file '${path.resolve(f)}'\nduration ${duration}`).join("\n");
1019
+ fs.writeFileSync(listFile, content, "utf8");
1020
+
1021
+ await _runSpawn([
1022
+ "-f", "concat", "-safe", "0", "-i", `"${listFile}"`,
1023
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", fps,
1024
+ `"${output}"`,
1025
+ ]);
1026
+ try { fs.unlinkSync(listFile); } catch {}
1027
+ return { images, output, duration, fps };
1028
+ },
1029
+
1030
+ /**
1031
+ * Create a contact sheet (grid of thumbnails) from a video.
1032
+ * @param {string} input
1033
+ * @param {string} output
1034
+ * @param {{ cols?: number, rows?: number, width?: number }} [options]
1035
+ * @returns {Promise<object>}
1036
+ */
1037
+ async contactSheet(input, output, options = {}) {
1038
+ const cols = options.cols || 4;
1039
+ const rows = options.rows || 4;
1040
+ const width = options.width || 320;
1041
+ const total = cols * rows;
1042
+ const dur = this.duration(input);
1043
+ const step = dur / total;
1044
+
1045
+ await _runSpawn([
1046
+ "-i", `"${input}"`,
1047
+ "-vf", `select='not(mod(n,${Math.floor(step * 25)}))':n=${total},scale=${width}:-1,tile=${cols}x${rows}`,
1048
+ "-frames:v", "1",
1049
+ `"${output}"`,
1050
+ ]);
1051
+ return { input, output, cols, rows };
1052
+ },
1053
+
1054
+ // ──────────────────────────────────────────────────────────────────────
1055
+ // STREAMING & PIPING
1056
+ // ──────────────────────────────────────────────────────────────────────
1057
+
1058
+ /**
1059
+ * Stream a file to an RTMP endpoint (e.g. YouTube Live, Twitch).
1060
+ * @param {string} input
1061
+ * @param {string} rtmpUrl
1062
+ * @param {{ videoBitrate?: string, audioBitrate?: string, onLog?: function }} [options]
1063
+ * @returns {object} - { stop: function } to end the stream.
1064
+ *
1065
+ * @example
1066
+ * const stream = kitFFmpeg.streamRTMP("video.mp4", "rtmp://live.twitch.tv/live/YOUR_KEY");
1067
+ * // later: stream.stop();
1068
+ */
1069
+ streamRTMP(input, rtmpUrl, options = {}) {
1070
+ _require();
1071
+ const args = [
1072
+ "-re", "-i", input,
1073
+ "-c:v", "libx264",
1074
+ "-b:v", options.videoBitrate || "2500k",
1075
+ "-preset", "veryfast",
1076
+ "-c:a", "aac",
1077
+ "-b:a", options.audioBitrate || "128k",
1078
+ "-f", "flv",
1079
+ rtmpUrl,
1080
+ ];
1081
+ const proc = spawn("ffmpeg", ["-y", ...args]);
1082
+ if (options.onLog) proc.stderr.on("data", d => options.onLog(d.toString()));
1083
+ return {
1084
+ stop: () => proc.kill("SIGTERM"),
1085
+ pid: proc.pid,
1086
+ rtmpUrl,
1087
+ };
1088
+ },
1089
+
1090
+ /**
1091
+ * Capture from a webcam/device and save to file.
1092
+ * @param {string} output
1093
+ * @param {{ device?: string, duration?: number, format?: string }} [options]
1094
+ * @returns {object} - { stop: function }
1095
+ */
1096
+ captureDevice(output, options = {}) {
1097
+ _require();
1098
+ const platform = process.platform;
1099
+ let inputArgs;
1100
+ if (platform === "darwin") {
1101
+ inputArgs = ["-f", "avfoundation", "-i", options.device || "0:0"];
1102
+ } else if (platform === "win32") {
1103
+ inputArgs = ["-f", "dshow", "-i", `video=${options.device || "0"}`];
1104
+ } else {
1105
+ inputArgs = ["-f", "v4l2", "-i", options.device || "/dev/video0"];
1106
+ }
1107
+
1108
+ const args = [...inputArgs];
1109
+ if (options.duration) args.push("-t", options.duration);
1110
+ args.push(output);
1111
+
1112
+ const proc = spawn("ffmpeg", ["-y", ...args]);
1113
+ return { stop: () => proc.kill("SIGTERM"), pid: proc.pid, output };
1114
+ },
1115
+
1116
+ /**
1117
+ * Run a raw ffmpeg command with full control.
1118
+ * @param {string[]} args - ffmpeg arguments (without "ffmpeg" itself).
1119
+ * @param {{ onProgress?: function, onLog?: function }} [options]
1120
+ * @returns {Promise<{ code, stderr }>}
1121
+ *
1122
+ * @example
1123
+ * await kitFFmpeg.raw(["-i", "input.mp4", "-vf", "negate", "output.mp4"]);
1124
+ */
1125
+ raw(args, options = {}) {
1126
+ return _runSpawn(args, options);
1127
+ },
1128
+
1129
+ // ──────────────────────────────────────────────────────────────────────
1130
+ // COMPRESSION & OPTIMIZATION
1131
+ // ──────────────────────────────────────────────────────────────────────
1132
+
1133
+ /**
1134
+ * Compress a video to reduce file size.
1135
+ * @param {string} input
1136
+ * @param {string} output
1137
+ * @param {{ quality?: 'low'|'medium'|'high'|'veryhigh', onProgress?: function }} [options]
1138
+ * @returns {Promise<{ input, output, savedBytes: number }>}
1139
+ */
1140
+ async compress(input, output, options = {}) {
1141
+ const crfMap = { low: 32, medium: 28, high: 23, veryhigh: 18 };
1142
+ const crf = crfMap[options.quality || "medium"];
1143
+ await this.toH264(input, output, { crf, preset: "slow", onProgress: options.onProgress });
1144
+ const inSize = fs.statSync(input).size;
1145
+ const outSize = fs.statSync(output).size;
1146
+ return { input, output, inputBytes: inSize, outputBytes: outSize, savedBytes: inSize - outSize };
1147
+ },
1148
+
1149
+ /**
1150
+ * Target a specific output file size by calculating the required bitrate.
1151
+ * @param {string} input
1152
+ * @param {string} output
1153
+ * @param {number} targetMB - Target file size in megabytes.
1154
+ * @param {{ audioBitrate?: string }} [options]
1155
+ * @returns {Promise<object>}
1156
+ */
1157
+ async targetSize(input, output, targetMB, options = {}) {
1158
+ const dur = this.duration(input);
1159
+ const audioBitrateK = parseInt((options.audioBitrate || "128k")) || 128;
1160
+ const targetBitsK = (targetMB * 8 * 1024) / dur;
1161
+ const videoBitrateK = Math.floor(targetBitsK - audioBitrateK);
1162
+ if (videoBitrateK < 100) throw new Error("Target size too small for this duration.");
1163
+ await this.convert(input, output, {
1164
+ videoCodec: "libx264",
1165
+ audioCodec: "aac",
1166
+ videoBitrate: `${videoBitrateK}k`,
1167
+ audioBitrate: `${audioBitrateK}k`,
1168
+ onProgress: options.onProgress,
1169
+ });
1170
+ return { input, output, targetMB, videoBitrate: `${videoBitrateK}k` };
1171
+ },
1172
+
1173
+ }
1174
+ };