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.
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/bin/novac +6 -3
- package/bin/nvc +0 -0
- package/bin/nvml +0 -0
- package/demo.nv +0 -0
- package/demo_builtins.nv +0 -0
- package/demo_http.nv +0 -0
- package/examples/bf.nv +5 -13
- package/examples/math.nv +2 -2
- package/kits/kitffmpeg/kitdef.js +1174 -0
- package/kits/libos/kitdef.js +3135 -0
- package/kits/libtasker/kitdef.js +125 -0
- package/package.json +1 -1
- package/scripts/update-bin.js +0 -0
- package/src/core/executor.js +7 -4
- package/src/core/lexer.js +2 -2
- package/src/index.js +0 -0
- package/novac/LICENSE +0 -21
- package/novac/README.md +0 -1823
- package/novac/bin/novac +0 -950
- package/novac/bin/nvc +0 -522
- package/novac/bin/nvml +0 -542
- package/novac/demo.nv +0 -245
- package/novac/demo_builtins.nv +0 -209
- package/novac/demo_http.nv +0 -62
- package/novac/examples/bf.nv +0 -69
- package/novac/examples/math.nv +0 -21
- package/novac/kits/kitai/kitdef.js +0 -2185
- package/novac/kits/kitansi/kitdef.js +0 -1402
- package/novac/kits/kitformat/kitdef.js +0 -1485
- package/novac/kits/kitgps/kitdef.js +0 -1862
- package/novac/kits/kitlibfs/kitdef.js +0 -231
- package/novac/kits/kitlibproc/kitdef.js +0 -78
- package/novac/kits/kitmatrix/ex.js +0 -19
- package/novac/kits/kitmatrix/kitdef.js +0 -960
- package/novac/kits/kitmpatch/kitdef.js +0 -906
- package/novac/kits/kitnovacweb/README.md +0 -1572
- package/novac/kits/kitnovacweb/demo.nv +0 -12
- package/novac/kits/kitnovacweb/demo.nvml +0 -71
- package/novac/kits/kitnovacweb/index.nova +0 -12
- package/novac/kits/kitnovacweb/kitdef.js +0 -692
- package/novac/kits/kitnovacweb/nova.kit.json +0 -8
- package/novac/kits/kitnovacweb/nvml/executor.js +0 -739
- package/novac/kits/kitnovacweb/nvml/index.js +0 -67
- package/novac/kits/kitnovacweb/nvml/lexer.js +0 -263
- package/novac/kits/kitnovacweb/nvml/parser.js +0 -508
- package/novac/kits/kitnovacweb/nvml/renderer.js +0 -924
- package/novac/kits/kitparse/kitdef.js +0 -1688
- package/novac/kits/kitregex++/kitdef.js +0 -1353
- package/novac/kits/kitrequire/kitdef.js +0 -1599
- package/novac/kits/kitx11/kitdef.js +0 -1
- package/novac/kits/kitx11/kitx11.js +0 -2472
- package/novac/kits/kitx11/kitx11_conn.js +0 -948
- package/novac/kits/kitx11/kitx11_worker.js +0 -121
- package/novac/kits/libtea/tf.js +0 -2691
- package/novac/kits/libterm/ex.js +0 -285
- package/novac/kits/libterm/kitdef.js +0 -1927
- package/novac/node_modules/chalk/license +0 -9
- package/novac/node_modules/chalk/package.json +0 -83
- package/novac/node_modules/chalk/readme.md +0 -297
- package/novac/node_modules/chalk/source/index.d.ts +0 -325
- package/novac/node_modules/chalk/source/index.js +0 -225
- package/novac/node_modules/chalk/source/utilities.js +0 -33
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +0 -236
- package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +0 -223
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +0 -1
- package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +0 -34
- package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +0 -55
- package/novac/node_modules/chalk/source/vendor/supports-color/index.js +0 -190
- package/novac/node_modules/commander/LICENSE +0 -22
- package/novac/node_modules/commander/Readme.md +0 -1176
- package/novac/node_modules/commander/esm.mjs +0 -16
- package/novac/node_modules/commander/index.js +0 -24
- package/novac/node_modules/commander/lib/argument.js +0 -150
- package/novac/node_modules/commander/lib/command.js +0 -2777
- package/novac/node_modules/commander/lib/error.js +0 -39
- package/novac/node_modules/commander/lib/help.js +0 -747
- package/novac/node_modules/commander/lib/option.js +0 -380
- package/novac/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/novac/node_modules/commander/package-support.json +0 -19
- package/novac/node_modules/commander/package.json +0 -82
- package/novac/node_modules/commander/typings/esm.d.mts +0 -3
- package/novac/node_modules/commander/typings/index.d.ts +0 -1113
- package/novac/node_modules/node-addon-api/LICENSE.md +0 -9
- package/novac/node_modules/node-addon-api/README.md +0 -95
- package/novac/node_modules/node-addon-api/common.gypi +0 -21
- package/novac/node_modules/node-addon-api/except.gypi +0 -25
- package/novac/node_modules/node-addon-api/index.js +0 -14
- package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +0 -186
- package/novac/node_modules/node-addon-api/napi-inl.h +0 -7165
- package/novac/node_modules/node-addon-api/napi.h +0 -3364
- package/novac/node_modules/node-addon-api/node_addon_api.gyp +0 -42
- package/novac/node_modules/node-addon-api/node_api.gyp +0 -9
- package/novac/node_modules/node-addon-api/noexcept.gypi +0 -26
- package/novac/node_modules/node-addon-api/nothing.c +0 -0
- package/novac/node_modules/node-addon-api/package-support.json +0 -21
- package/novac/node_modules/node-addon-api/package.json +0 -480
- package/novac/node_modules/node-addon-api/tools/README.md +0 -73
- package/novac/node_modules/node-addon-api/tools/check-napi.js +0 -99
- package/novac/node_modules/node-addon-api/tools/clang-format.js +0 -71
- package/novac/node_modules/node-addon-api/tools/conversion.js +0 -301
- package/novac/node_modules/serialize-javascript/LICENSE +0 -27
- package/novac/node_modules/serialize-javascript/README.md +0 -149
- package/novac/node_modules/serialize-javascript/index.js +0 -297
- package/novac/node_modules/serialize-javascript/package.json +0 -33
- package/novac/package.json +0 -27
- package/novac/scripts/update-bin.js +0 -24
- package/novac/src/core/bstd.js +0 -1035
- package/novac/src/core/config.js +0 -155
- package/novac/src/core/describe.js +0 -187
- package/novac/src/core/emitter.js +0 -499
- package/novac/src/core/error.js +0 -86
- package/novac/src/core/executor.js +0 -5606
- package/novac/src/core/formatter.js +0 -686
- package/novac/src/core/lexer.js +0 -1026
- package/novac/src/core/nova_builtins.js +0 -717
- package/novac/src/core/nova_thread_worker.js +0 -166
- package/novac/src/core/parser.js +0 -2181
- package/novac/src/core/types.js +0 -112
- package/novac/src/index.js +0 -28
- 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
|
+
};
|