1/**
2 * subprocess bindings for `ffmpeg`, expecting the binary to be in `$PATH` or
3 * given as the `ffmpeg` option when relevant.
4 *
5 * @module ffmpeg
6 */
7
8/** options for {@linkcode spawn} */
9export interface SpawnOptions {
10 args: string[];
11 ffmpeg?: string;
12 progress: progress.Node;
13 cwd?: string; // TODO: Path
14 /** kills the process when aborted */
15 signal?: AbortSignal;
16}
17
18/**
19 * spawn an instance of `ffmpeg` and bind it's live status to the given
20 * `progress.Node`. Logs are routed through the progress node, and dismissed on
21 * exit. `lib/progress.ts` will buffer and colocate the logs when running
22 * parallel jobs.
23 *
24 * ```ts
25 * await ffmpeg.spawn({
26 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
27 * progress: progress.start("encode hello.mov"),
28 * });
29 * ```
30 */
31export async function spawn(options: SpawnOptions) {
32 const { ffmpeg = "ffmpeg", args, cwd } = options;
33 using node = options.progress;
34 const title = node.text;
35 const proc = child_process.spawn(ffmpeg, [...defaultExtraOptions, ...args], {
36 stdio: ["ignore", "inherit", "pipe"],
37 env: { ...process.env, SVT_LOG: "2" },
38 cwd: cwd?.toString(),
39 signal: options.signal,
40 });
41 const parser = new Parse();
42 let running = true;
43 const splitter = readline.createInterface({ input: proc.stderr });
44 splitter.on("line", (line) => {
45 const result = parser.onLine(line);
46 if (result.kind === "ignore") {
47 return;
48 } else if (result.kind === "log") {
49 node.log[result.level](result.message);
50 } else if (result.kind === "progress") {
51 if (!running) return;
52 const { frame, totalFrames, fps, speed } = result;
53 node.value = frame;
54 node.total = totalFrames;
55 const extras = [
56 `${fps} fps`,
57 speed,
58 parser.hlsFile,
59 ].filter(Boolean).join(", ");
60 node.text = `${title}${extras.length > 0 ? ` (${extras})` : ""}`;
61 } else result satisfies never;
62 });
63 const [code, signal] = await events.once(proc, "close");
64 running = false;
65 if (code !== 0) {
66 const fmt = code ? `code ${code}` : `signal ${signal}`;
67 const e: any = new Error(`ffmpeg failed with ${fmt}`);
68 e.args = [ffmpeg, ...args].map(string.escapeShellArgument).join(" ");
69 e.code = code;
70 e.signal = signal;
71 throw e;
72 }
73}
74
75export interface Streams {
76 audio: boolean;
77 video: boolean;
78}
79
80export async function probeStreams(path: string): Promise<Streams> {
81 const { stdout } = await subprocess.spawnAndWait({
82 cmd: [
83 "ffprobe",
84 "-v",
85 "error",
86 "-show_entries",
87 "stream=codec_type",
88 path.toString(),
89 "-of",
90 "default=noprint_wrappers=1:nokey=1",
91 ],
92 stdio: "pipe",
93 });
94
95 const lines = new TextDecoder().decode(stdout!).trim().split("\n");
96 return {
97 audio: lines.includes("audio"),
98 video: lines.includes("video"),
99 };
100}
101
102export type Line =
103 | { kind: "ignore" }
104 | { kind: "log"; level: "info" | "warn" | "error"; message: string }
105 | {
106 kind: "progress";
107 frame: number;
108 totalFrames: number;
109 speed: string | null;
110 fps: number | null;
111 rest: Record<string, string>;
112 };
113
114export const defaultExtraOptions = [
115 "-hide_banner",
116 "-stats",
117];
118
119export class Parse {
120 parsingStart = true;
121 inIndentedIgnore: null | "out" | "inp" | "other" = null;
122 durationTime = 0;
123 targetFps: number | null = null;
124
125 hlsFile: string | null = null;
126 durationFrames = 0;
127
128 onLine(line: string): Line {
129 line = line.trimEnd();
130 if (/^frame=/.test(line)) {
131 if (this.parsingStart) {
132 this.parsingStart = false;
133 this.durationFrames = Math.ceil(
134 (this.targetFps ?? 25) * this.durationTime,
135 );
136 }
137 const parts = Object.fromEntries(
138 [...line.matchAll(/\b([a-z0-9]+)=\s*([^ ]+)(?= |$)/ig)].map((
139 [, k, v],
140 ) => [k, v]),
141 );
142 const { frame, fps, speed, ...rest } = parts;
143 return {
144 kind: "progress",
145 frame: Number(frame),
146 totalFrames: this.durationFrames,
147 fps: Number(fps),
148 speed,
149 rest,
150 };
151 }
152
153 if (this.parsingStart) {
154 if (this.inIndentedIgnore) {
155 if (line.startsWith(" ") || line.startsWith("\t")) {
156 line = line.trimStart();
157 if (this.inIndentedIgnore === "inp") {
158 const match = line.match(/^Duration: (\d+):(\d+):(\d+\.\d+)/);
159 if (match) {
160 const [h, m, s] = match.slice(1).map((x) => Number(x));
161 this.durationTime = Math.max(
162 this.durationTime,
163 h! * 60 * 60 + m! * 60 + s!,
164 );
165 }
166 if (!this.targetFps) {
167 const match = line.match(/^Stream.*, (\d+(?:\.\d+)?) fps/);
168 if (match) this.targetFps = Number(match[1]);
169 }
170 }
171 return { kind: "ignore" };
172 }
173 this.inIndentedIgnore = null;
174 }
175 if (line === "Press [q] to stop, [?] for help") {
176 return { kind: "ignore" };
177 }
178 if (line === "Stream mapping:") {
179 this.inIndentedIgnore = "other";
180 return { kind: "ignore" };
181 }
182 if (line.startsWith("Output #") || line.startsWith("Input #")) {
183 this.inIndentedIgnore = line.slice(0, 3).toLowerCase() as "inp" | "out";
184 return { kind: "ignore" };
185 }
186 }
187
188 const hlsMatch = line.match(/.*Opening '(.+)' for writing/);
189 if (hlsMatch) {
190 if (!hlsMatch[1]!.endsWith(".tmp")) {
191 this.hlsFile = path.basename(hlsMatch[1]!);
192 }
193 return { kind: "ignore" };
194 }
195
196 let level: Extract<Line, { kind: "log" }>["level"] = "info";
197 if (line.toLowerCase().includes("err")) level = "error";
198 else if (line.toLowerCase().includes("warn")) level = "warn";
199
200 return { kind: "log", level, message: line };
201 }
202}
203
204import * as child_process from "node:child_process";
205import events from "node:events";
206import * as path from "node:path";
207import * as process from "node:process";
208import * as readline from "node:readline";
209import type * as progress from "../progress.ts";
210import * as string from "../string.ts";
211import * as subprocess from "../subprocess.ts";