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 await using proc = subprocess.spawn([ffmpeg, ...defaultExtraOptions, ...args], {
36 stderr: "pipe",
37 env: { SVT_LOG: "2" },
38 cwd,
39 signal: options.signal,
40 });
41
42 const parser = new Parse();
43 for await (const line of stream.lines(proc.stderr)) {
44 const result = parser.onLine(line);
45 if (result.kind === "ignore") {
46 continue;
47 } else if (result.kind === "log") {
48 node.log[result.level](result.message);
49 } else if (result.kind === "progress") {
50 const { frame, totalFrames, fps, speed } = result;
51 node.value = frame;
52 node.total = totalFrames;
53 const extras = [
54 `${fps} fps`,
55 speed,
56 parser.hlsFile,
57 ].filter(Boolean).join(", ");
58 node.text = `${title}${extras.length > 0 ? ` (${extras})` : ""}`;
59 } else result satisfies never;
60 }
61 await proc.wait();
62}
63
64export interface Streams {
65 audio: boolean;
66 video: boolean;
67}
68
69export async function probeStreams(path: string): Promise<Streams> {
70 const stdout = await subprocess.execute([
71 "ffprobe",
72 "-v",
73 "error",
74 "-show_entries",
75 "stream=codec_type",
76 path.toString(),
77 "-of",
78 "default=noprint_wrappers=1:nokey=1",
79 ]);
80
81 const lines = stdout.trim().split("\n");
82 return {
83 audio: lines.includes("audio"),
84 video: lines.includes("video"),
85 };
86}
87
88export type Line =
89 | { kind: "ignore" }
90 | { kind: "log"; level: "info" | "warn" | "error"; message: string }
91 | {
92 kind: "progress";
93 frame: number;
94 totalFrames: number;
95 speed: string | null;
96 fps: number | null;
97 rest: Record<string, string>;
98 };
99
100export const defaultExtraOptions = [
101 "-hide_banner",
102 "-stats",
103];
104
105export class Parse {
106 parsingStart = true;
107 inIndentedIgnore: null | "out" | "inp" | "other" = null;
108 durationTime = 0;
109 targetFps: number | null = null;
110
111 hlsFile: string | null = null;
112 durationFrames = 0;
113
114 onLine(line: string): Line {
115 line = line.trimEnd();
116 if (/^frame=/.test(line)) {
117 if (this.parsingStart) {
118 this.parsingStart = false;
119 this.durationFrames = Math.ceil(
120 (this.targetFps ?? 25) * this.durationTime,
121 );
122 }
123 const parts = Object.fromEntries(
124 [...line.matchAll(/\b([a-z0-9]+)=\s*([^ ]+)(?= |$)/ig)].map((
125 [, k, v],
126 ) => [k, v]),
127 );
128 const { frame, fps, speed, ...rest } = parts;
129 return {
130 kind: "progress",
131 frame: Number(frame),
132 totalFrames: this.durationFrames,
133 fps: Number(fps),
134 speed,
135 rest,
136 };
137 }
138
139 if (this.parsingStart) {
140 if (this.inIndentedIgnore) {
141 if (line.startsWith(" ") || line.startsWith("\t")) {
142 line = line.trimStart();
143 if (this.inIndentedIgnore === "inp") {
144 const match = line.match(/^Duration: (\d+):(\d+):(\d+\.\d+)/);
145 if (match) {
146 const [h, m, s] = match.slice(1).map((x) => Number(x));
147 this.durationTime = Math.max(
148 this.durationTime,
149 h! * 60 * 60 + m! * 60 + s!,
150 );
151 }
152 if (!this.targetFps) {
153 const match = line.match(/^Stream.*, (\d+(?:\.\d+)?) fps/);
154 if (match) this.targetFps = Number(match[1]);
155 }
156 }
157 return { kind: "ignore" };
158 }
159 this.inIndentedIgnore = null;
160 }
161 if (line === "Press [q] to stop, [?] for help") {
162 return { kind: "ignore" };
163 }
164 if (line === "Stream mapping:") {
165 this.inIndentedIgnore = "other";
166 return { kind: "ignore" };
167 }
168 if (line.startsWith("Output #") || line.startsWith("Input #")) {
169 this.inIndentedIgnore = line.slice(0, 3).toLowerCase() as "inp" | "out";
170 return { kind: "ignore" };
171 }
172 }
173
174 const hlsMatch = line.match(/.*Opening '(.+)' for writing/);
175 if (hlsMatch) {
176 if (!hlsMatch[1]!.endsWith(".tmp")) {
177 this.hlsFile = path.basename(hlsMatch[1]!);
178 }
179 return { kind: "ignore" };
180 }
181
182 let level: Extract<Line, { kind: "log" }>["level"] = "info";
183 if (line.toLowerCase().includes("err")) level = "error";
184 else if (line.toLowerCase().includes("warn")) level = "warn";
185
186 return { kind: "log", level, message: line };
187 }
188}
189
190import * as path from "node:path";
191import type * as progress from "../progress.ts";
192import * as stream from "../stream.ts";
193import * as subprocess from "../subprocess.ts";