bytecut 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,536 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ffmpegDirOf,
4
+ probeFfmpeg,
5
+ resolveBinary
6
+ } from "./chunk-YBEC5QGV.js";
7
+ import {
8
+ BytecutError
9
+ } from "./chunk-TWI2OZJZ.js";
10
+
11
+ // src/core/posix.ts
12
+ import { sep } from "path";
13
+ function toPosix(path) {
14
+ return sep === "/" ? path : path.split(sep).join("/");
15
+ }
16
+
17
+ // src/core/video/probe.ts
18
+ import { open } from "fs/promises";
19
+ function rate(text) {
20
+ const m = /^(\d+)\/(\d+)$/.exec(text ?? "");
21
+ if (!m || Number(m[2]) === 0) return 0;
22
+ return Number(m[1]) / Number(m[2]);
23
+ }
24
+ function parseProbe(json) {
25
+ const data = json;
26
+ const streams = data.streams ?? [];
27
+ const video = streams.find((s) => s.codec_type === "video" && !s.disposition?.attached_pic);
28
+ if (!video) return null;
29
+ const audio = streams.find((s) => s.codec_type === "audio");
30
+ const duration = Number(data.format?.duration ?? video.duration ?? 0);
31
+ const bitrate = Number(data.format?.bit_rate);
32
+ return {
33
+ durationSec: Number.isFinite(duration) ? duration : 0,
34
+ width: video.width ?? 0,
35
+ height: video.height ?? 0,
36
+ // avg is the real rate for variable-rate sources; r_frame_rate is the container's base.
37
+ fps: rate(video.avg_frame_rate) || rate(video.r_frame_rate),
38
+ videoCodec: video.codec_name ?? "unknown",
39
+ pixelFormat: video.pix_fmt ?? null,
40
+ bitrate: Number.isFinite(bitrate) && bitrate > 0 ? bitrate : null,
41
+ hasAudio: audio !== void 0,
42
+ audioCodec: audio?.codec_name ?? null,
43
+ container: data.format?.format_name ?? "unknown"
44
+ };
45
+ }
46
+ async function isFastStart(path) {
47
+ const file = await open(path, "r");
48
+ try {
49
+ const { size } = await file.stat();
50
+ const header = Buffer.alloc(16);
51
+ let offset = 0;
52
+ let first = true;
53
+ for (let guard = 0; guard < 64 && offset + 8 <= size; guard++) {
54
+ const { bytesRead } = await file.read(header, 0, 16, offset);
55
+ if (bytesRead < 8) return null;
56
+ let boxSize = header.readUInt32BE(0);
57
+ const type = header.toString("latin1", 4, 8);
58
+ if (first) {
59
+ if (type !== "ftyp") return null;
60
+ first = false;
61
+ }
62
+ if (type === "moov") return true;
63
+ if (type === "mdat") return false;
64
+ if (boxSize === 1) {
65
+ if (bytesRead < 16) return null;
66
+ boxSize = Number(header.readBigUInt64BE(8));
67
+ } else if (boxSize === 0) {
68
+ return null;
69
+ }
70
+ if (boxSize < 8) return null;
71
+ offset += boxSize;
72
+ }
73
+ return null;
74
+ } finally {
75
+ await file.close();
76
+ }
77
+ }
78
+
79
+ // src/core/video/ffmpeg-engine.ts
80
+ import { spawn } from "child_process";
81
+
82
+ // src/core/video/args.ts
83
+ var evenDown = (n) => Math.max(2, Math.floor(n / 2) * 2);
84
+ function outputWidth(sourceWidth, maxWidth) {
85
+ const capped = maxWidth !== null ? Math.min(sourceWidth, maxWidth) : sourceWidth;
86
+ return evenDown(capped);
87
+ }
88
+ function needsFpsCap(probe, maxFps) {
89
+ return maxFps !== null && probe.fps > maxFps + 0.01;
90
+ }
91
+ function videoFilter(probe, maxWidth, maxFps) {
92
+ const filters = [];
93
+ if (needsFpsCap(probe, maxFps)) filters.push(`fps=${maxFps}`);
94
+ filters.push(`scale=${outputWidth(probe.width, maxWidth)}:-2:flags=lanczos`);
95
+ filters.push("format=yuv420p");
96
+ return filters.join(",");
97
+ }
98
+ function encoderArgs(encoder, crf, speed) {
99
+ switch (encoder) {
100
+ case "libsvtav1":
101
+ return ["-c:v", "libsvtav1", "-crf", String(crf), "-preset", speed];
102
+ case "libaom-av1":
103
+ return [
104
+ "-c:v",
105
+ "libaom-av1",
106
+ "-crf",
107
+ String(crf),
108
+ "-b:v",
109
+ "0",
110
+ "-cpu-used",
111
+ speed,
112
+ "-row-mt",
113
+ "1"
114
+ ];
115
+ case "libx264":
116
+ return ["-c:v", "libx264", "-crf", String(crf), "-preset", speed];
117
+ }
118
+ }
119
+ function buildEncodeArgs(request) {
120
+ const args = [
121
+ "-hide_banner",
122
+ "-nostdin",
123
+ "-loglevel",
124
+ "error",
125
+ "-y",
126
+ "-i",
127
+ request.source,
128
+ "-map",
129
+ "0:v:0"
130
+ ];
131
+ if (request.audio === "keep" && request.probe.hasAudio) {
132
+ args.push("-map", "0:a:0", "-c:a", "aac", "-b:a", request.audioBitrate);
133
+ } else {
134
+ args.push("-an");
135
+ }
136
+ args.push(
137
+ "-vf",
138
+ videoFilter(request.probe, request.maxWidth, request.maxFps),
139
+ ...encoderArgs(request.encoder, request.crf, request.speed),
140
+ "-movflags",
141
+ "+faststart",
142
+ "-map_metadata",
143
+ "-1",
144
+ "-fflags",
145
+ "+bitexact",
146
+ "-flags:v",
147
+ "+bitexact",
148
+ "-flags:a",
149
+ "+bitexact",
150
+ // The reserved temp path ends in .tmp, so the muxer cannot be inferred from it.
151
+ "-f",
152
+ "mp4",
153
+ "-progress",
154
+ "pipe:1",
155
+ "-nostats",
156
+ request.output
157
+ );
158
+ return args;
159
+ }
160
+ function buildSampleArgs(request) {
161
+ const raw = request.encoder === "libx264" ? "h264" : "ivf";
162
+ return [
163
+ "-hide_banner",
164
+ "-nostdin",
165
+ "-loglevel",
166
+ "error",
167
+ "-ss",
168
+ String(request.startSec),
169
+ "-t",
170
+ String(request.durationSec),
171
+ "-i",
172
+ request.source,
173
+ "-map",
174
+ "0:v:0",
175
+ "-an",
176
+ "-vf",
177
+ videoFilter(request.probe, request.maxWidth, request.maxFps),
178
+ ...encoderArgs(request.encoder, request.crf, request.speed),
179
+ "-f",
180
+ raw,
181
+ "pipe:1"
182
+ ];
183
+ }
184
+ function buildClipEncodeArgs(request) {
185
+ const args = buildSampleArgs(request);
186
+ const at = args.lastIndexOf("-f");
187
+ return [...args.slice(0, at), "-f", "matroska", "pipe:1"];
188
+ }
189
+ var COMMON = ["-hide_banner", "-nostdin", "-loglevel", "error"];
190
+ function frameSelection(request) {
191
+ const rate2 = Math.max(0.1, request.count / Math.max(0.1, request.durationSec));
192
+ return [`fps=${Math.round(rate2 * 1e3) / 1e3}`];
193
+ }
194
+ function rawOut(count) {
195
+ return ["-frames:v", String(count), "-an", "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"];
196
+ }
197
+ function buildFramesArgs(request) {
198
+ const filters = [];
199
+ if (request.pipeline) {
200
+ const { probe, maxWidth, maxFps } = request.pipeline;
201
+ filters.push(videoFilter(probe, maxWidth, maxFps));
202
+ }
203
+ filters.push(...frameSelection(request), `scale=${request.width}:${request.height}:flags=area`);
204
+ return [
205
+ ...COMMON,
206
+ "-ss",
207
+ String(request.startSec),
208
+ "-t",
209
+ String(request.durationSec),
210
+ "-i",
211
+ request.source,
212
+ "-map",
213
+ "0:v:0",
214
+ "-vf",
215
+ filters.join(","),
216
+ ...rawOut(request.count)
217
+ ];
218
+ }
219
+ function buildPipedFramesArgs(request) {
220
+ return [
221
+ ...COMMON,
222
+ "-f",
223
+ "matroska",
224
+ "-i",
225
+ "pipe:0",
226
+ "-map",
227
+ "0:v:0",
228
+ "-vf",
229
+ [...frameSelection(request), `scale=${request.width}:${request.height}:flags=area`].join(","),
230
+ ...rawOut(request.count)
231
+ ];
232
+ }
233
+ function buildVmafArgs(request, width, height) {
234
+ const size = `scale=${width}:${height}:flags=bicubic,setpts=PTS-STARTPTS`;
235
+ return [
236
+ "-hide_banner",
237
+ "-nostdin",
238
+ "-loglevel",
239
+ "info",
240
+ "-ss",
241
+ String(request.startSec),
242
+ "-t",
243
+ String(request.durationSec),
244
+ "-i",
245
+ request.source,
246
+ ...request.candidate ? [
247
+ "-ss",
248
+ String(request.startSec),
249
+ "-t",
250
+ String(request.durationSec),
251
+ "-i",
252
+ request.candidate
253
+ ] : ["-f", "matroska", "-i", "pipe:0"],
254
+ "-lavfi",
255
+ `[0:v]${videoFilter(request.probe, request.maxWidth, request.maxFps)},${size}[ref];[1:v]${size}[dist];[dist][ref]libvmaf=n_subsample=3`,
256
+ "-f",
257
+ "null",
258
+ "-"
259
+ ];
260
+ }
261
+ function parseVmafScore(stderr) {
262
+ const matches = [...stderr.matchAll(/VMAF score:\s*(-?\d+(?:\.\d+)?)/g)];
263
+ const last = matches[matches.length - 1];
264
+ return last ? Number(last[1]) : null;
265
+ }
266
+ function buildFrameArgs(source, atSec, maxWidth, sourceWidth) {
267
+ return [
268
+ "-hide_banner",
269
+ "-nostdin",
270
+ "-loglevel",
271
+ "error",
272
+ "-ss",
273
+ String(atSec),
274
+ "-i",
275
+ source,
276
+ "-frames:v",
277
+ "1",
278
+ "-vf",
279
+ `scale=${outputWidth(sourceWidth, maxWidth)}:-2:flags=lanczos`,
280
+ "-f",
281
+ "image2pipe",
282
+ "-vcodec",
283
+ "png",
284
+ "pipe:1"
285
+ ];
286
+ }
287
+ function progressFraction(chunk, durationSec) {
288
+ if (durationSec <= 0) return null;
289
+ let latest = null;
290
+ for (const line of chunk.split("\n")) {
291
+ const m = /^out_time_us=(\d+)/.exec(line.trim());
292
+ if (m) latest = Number(m[1]);
293
+ }
294
+ if (latest === null) return null;
295
+ return Math.max(0, Math.min(1, latest / (durationSec * 1e6)));
296
+ }
297
+
298
+ // src/core/video/ffmpeg-engine.ts
299
+ function splitFrames(data, width, height) {
300
+ const size = width * height * 3;
301
+ if (size === 0 || data.byteLength === 0 || data.byteLength % size !== 0) {
302
+ throw new Error("ffmpeg returned no frames, or frames of an unexpected size");
303
+ }
304
+ const frames = [];
305
+ for (let at = 0; at < data.byteLength; at += size) {
306
+ frames.push({ data: new Uint8Array(data.subarray(at, at + size)), width, height });
307
+ }
308
+ return frames;
309
+ }
310
+ function stderrTail(text) {
311
+ return text.split("\n").filter((l) => l.trim() && !l.startsWith("Svt[info]")).slice(-6).join("\n");
312
+ }
313
+ function runProcess(file, args, options = {}) {
314
+ return new Promise((resolve, reject) => {
315
+ const child = spawn(file, args, { stdio: ["pipe", "pipe", "pipe"] });
316
+ child.stdin.on("error", () => void 0);
317
+ child.stdin.end(options.input);
318
+ const out = [];
319
+ let counted = 0;
320
+ let stderr = "";
321
+ let aborted = false;
322
+ let killTimer;
323
+ const abort = () => {
324
+ aborted = true;
325
+ child.kill("SIGTERM");
326
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 3e3);
327
+ };
328
+ if (options.signal?.aborted) abort();
329
+ else options.signal?.addEventListener("abort", abort, { once: true });
330
+ child.stdout.on("data", (chunk) => {
331
+ if (options.collectStdout) out.push(chunk);
332
+ if (options.countOnly) counted += chunk.length;
333
+ options.onStdout?.(chunk.toString("utf8"));
334
+ });
335
+ child.stderr.on("data", (chunk) => {
336
+ stderr = (stderr + chunk.toString("utf8")).slice(-16384);
337
+ });
338
+ child.on("error", (error) => {
339
+ if (killTimer) clearTimeout(killTimer);
340
+ reject(error);
341
+ });
342
+ child.on("close", (code) => {
343
+ if (killTimer) clearTimeout(killTimer);
344
+ options.signal?.removeEventListener("abort", abort);
345
+ resolve({
346
+ stdout: options.countOnly ? Buffer.alloc(counted) : Buffer.concat(out),
347
+ code,
348
+ stderr,
349
+ aborted
350
+ });
351
+ });
352
+ });
353
+ }
354
+ var AbortedError = class extends Error {
355
+ constructor() {
356
+ super("Cancelled");
357
+ this.name = "AbortedError";
358
+ }
359
+ };
360
+ function createFfmpegVideoEngine(options) {
361
+ let resolved;
362
+ function resolve() {
363
+ resolved ??= (async () => {
364
+ const base = { env: options.env, platform: options.platform };
365
+ const ffmpeg = resolveBinary(
366
+ "ffmpeg",
367
+ { ...base, flag: options.ffmpegFlag, config: options.ffmpegConfig },
368
+ options.deps
369
+ );
370
+ if (!ffmpeg.found) return null;
371
+ let info;
372
+ try {
373
+ info = await probeFfmpeg(ffmpeg.path, options.run);
374
+ } catch {
375
+ return null;
376
+ }
377
+ const probe = resolveBinary(
378
+ "ffprobe",
379
+ {
380
+ ...base,
381
+ flag: options.ffprobeFlag,
382
+ config: options.ffprobeConfig,
383
+ ffmpegDir: ffmpegDirOf(ffmpeg)
384
+ },
385
+ options.deps
386
+ );
387
+ return {
388
+ ffmpeg: ffmpeg.path,
389
+ ffprobe: probe.found ? probe.path : null,
390
+ caps: info.capabilities,
391
+ version: info.version
392
+ };
393
+ })();
394
+ return resolved;
395
+ }
396
+ async function need() {
397
+ const r = await resolve();
398
+ if (!r) throw new BytecutError("FFMPEG_NOT_FOUND", "ffmpeg was not found.");
399
+ return r;
400
+ }
401
+ return {
402
+ async capabilities() {
403
+ return (await resolve())?.caps ?? null;
404
+ },
405
+ async versions() {
406
+ const r = await resolve();
407
+ const versions = {};
408
+ if (r) versions.ffmpeg = r.version;
409
+ return versions;
410
+ },
411
+ async probe(source) {
412
+ const r = await need();
413
+ if (!r.ffprobe) {
414
+ throw new BytecutError("FFMPEG_NOT_FOUND", "ffprobe was not found.");
415
+ }
416
+ const { stdout } = await options.run(r.ffprobe, [
417
+ "-v",
418
+ "error",
419
+ "-show_format",
420
+ "-show_streams",
421
+ "-of",
422
+ "json",
423
+ source
424
+ ]);
425
+ return parseProbe(JSON.parse(stdout));
426
+ },
427
+ async encode(request, onProgress, signal) {
428
+ const r = await need();
429
+ const result = await runProcess(r.ffmpeg, buildEncodeArgs(request), {
430
+ signal,
431
+ onStdout: (chunk) => {
432
+ const f = progressFraction(chunk, request.probe.durationSec);
433
+ if (f !== null) onProgress(f);
434
+ }
435
+ });
436
+ if (result.aborted) throw new AbortedError();
437
+ if (result.code !== 0) {
438
+ throw new Error(`ffmpeg exited with code ${result.code}: ${stderrTail(result.stderr)}`);
439
+ }
440
+ onProgress(1);
441
+ },
442
+ async sampleBytes(request, signal) {
443
+ const r = await need();
444
+ const result = await runProcess(r.ffmpeg, buildSampleArgs(request), {
445
+ signal,
446
+ countOnly: true
447
+ });
448
+ if (result.aborted) throw new AbortedError();
449
+ if (result.code !== 0) {
450
+ throw new Error(`ffmpeg exited with code ${result.code}: ${stderrTail(result.stderr)}`);
451
+ }
452
+ return result.stdout.byteLength;
453
+ },
454
+ async frames(request, signal) {
455
+ const r = await need();
456
+ const result = await runProcess(r.ffmpeg, buildFramesArgs(request), {
457
+ signal,
458
+ collectStdout: true
459
+ });
460
+ if (result.aborted) throw new AbortedError();
461
+ if (result.code !== 0) {
462
+ throw new Error(`ffmpeg exited with code ${result.code}: ${stderrTail(result.stderr)}`);
463
+ }
464
+ return splitFrames(result.stdout, request.width, request.height);
465
+ },
466
+ async encodedFrames(request, signal) {
467
+ const r = await need();
468
+ const encoded = await runProcess(r.ffmpeg, buildClipEncodeArgs(request), {
469
+ signal,
470
+ collectStdout: true
471
+ });
472
+ if (encoded.aborted) throw new AbortedError();
473
+ if (encoded.code !== 0) {
474
+ throw new Error(`ffmpeg exited with code ${encoded.code}: ${stderrTail(encoded.stderr)}`);
475
+ }
476
+ const decoded = await runProcess(r.ffmpeg, buildPipedFramesArgs(request), {
477
+ signal,
478
+ collectStdout: true,
479
+ input: encoded.stdout
480
+ });
481
+ if (decoded.aborted) throw new AbortedError();
482
+ if (decoded.code !== 0) {
483
+ throw new Error(`ffmpeg exited with code ${decoded.code}: ${stderrTail(decoded.stderr)}`);
484
+ }
485
+ return splitFrames(decoded.stdout, request.width, request.height);
486
+ },
487
+ async vmaf(request, signal) {
488
+ const r = await need();
489
+ let candidate;
490
+ if (!request.candidate) {
491
+ const encoded = await runProcess(r.ffmpeg, buildClipEncodeArgs(request), {
492
+ signal,
493
+ collectStdout: true
494
+ });
495
+ if (encoded.aborted) throw new AbortedError();
496
+ if (encoded.code !== 0) {
497
+ throw new Error(`ffmpeg exited with code ${encoded.code}: ${stderrTail(encoded.stderr)}`);
498
+ }
499
+ candidate = encoded.stdout;
500
+ }
501
+ const width = outputWidth(request.probe.width, request.maxWidth);
502
+ const height = Math.max(
503
+ 2,
504
+ Math.round(request.probe.height * width / Math.max(1, request.probe.width) / 2) * 2
505
+ );
506
+ const scored = await runProcess(r.ffmpeg, buildVmafArgs(request, width, height), {
507
+ signal,
508
+ ...candidate ? { input: candidate } : {}
509
+ });
510
+ if (scored.aborted) throw new AbortedError();
511
+ const score = parseVmafScore(scored.stderr);
512
+ if (scored.code !== 0 || score === null) {
513
+ throw new Error(`libvmaf did not report a score: ${stderrTail(scored.stderr)}`);
514
+ }
515
+ return score;
516
+ },
517
+ async frame(source, atSec, maxWidth) {
518
+ const r = await need();
519
+ const probe = await this.probe(source);
520
+ const args = buildFrameArgs(source, atSec, maxWidth, probe?.width ?? 0);
521
+ const result = await runProcess(r.ffmpeg, args, { collectStdout: true });
522
+ if (result.code !== 0 || result.stdout.byteLength === 0) {
523
+ throw new Error(`could not extract a frame: ${stderrTail(result.stderr)}`);
524
+ }
525
+ return result.stdout;
526
+ }
527
+ };
528
+ }
529
+
530
+ export {
531
+ toPosix,
532
+ outputWidth,
533
+ isFastStart,
534
+ AbortedError,
535
+ createFfmpegVideoEngine
536
+ };