165 lines
4.9 KiB
TypeScript
165 lines
4.9 KiB
TypeScript
// Utilities for spawning ffmpeg and consuming its output as a `Progress`
|
|
// A headless parser is available with `Parse`
|
|
|
|
export type Line =
|
|
| { kind: "ignore" }
|
|
| { kind: "log"; level: "info" | "warn" | "error"; message: string }
|
|
| {
|
|
kind: "progress";
|
|
frame: number;
|
|
totalFrames: number;
|
|
speed: string | null;
|
|
fps: number | null;
|
|
rest: Record<string, string>;
|
|
};
|
|
|
|
export const defaultExtraOptions = [
|
|
"-hide_banner",
|
|
"-stats",
|
|
];
|
|
|
|
export interface SpawnOptions {
|
|
args: string[];
|
|
title: string;
|
|
ffmpeg?: string;
|
|
progress?: Progress;
|
|
cwd: string;
|
|
}
|
|
|
|
export async function spawn(options: SpawnOptions) {
|
|
const { ffmpeg = "ffmpeg", args, title, cwd } = options;
|
|
const proc = child_process.spawn(ffmpeg, [...defaultExtraOptions, ...args], {
|
|
stdio: ["ignore", "inherit", "pipe"],
|
|
env: { ...process.env, SVT_LOG: "2" },
|
|
cwd,
|
|
});
|
|
const parser = new Parse();
|
|
const bar = options.progress ?? new Progress({ text: title });
|
|
let running = true;
|
|
const splitter = readline.createInterface({ input: proc.stderr });
|
|
splitter.on("line", (line) => {
|
|
const result = parser.onLine(line);
|
|
if (result.kind === "ignore") {
|
|
return;
|
|
} else if (result.kind === "log") {
|
|
console[result.level](result.message);
|
|
} else if (result.kind === "progress") {
|
|
if (!running) return;
|
|
const { frame, totalFrames, fps, speed } = result;
|
|
bar.value = frame;
|
|
bar.total = totalFrames;
|
|
const extras = [
|
|
`${fps} fps`,
|
|
speed,
|
|
parser.hlsFile,
|
|
].filter(Boolean).join(", ");
|
|
bar.text = `${title} ${frame}/${totalFrames} ${
|
|
extras.length > 0 ? `(${extras})` : ""
|
|
}`;
|
|
} else result satisfies never;
|
|
});
|
|
const [code, signal] = await events.once(proc, "close");
|
|
running = false;
|
|
if (code !== 0) {
|
|
const fmt = code ? `code ${code}` : `signal ${signal}`;
|
|
const e: any = new Error(`ffmpeg failed with ${fmt}`);
|
|
e.args = [ffmpeg, ...args].join(" ");
|
|
e.code = code;
|
|
e.signal = signal;
|
|
bar.error(e.message);
|
|
return e;
|
|
}
|
|
bar.success(title);
|
|
}
|
|
|
|
export class Parse {
|
|
parsingStart = true;
|
|
inIndentedIgnore: null | "out" | "inp" | "other" = null;
|
|
durationTime = 0;
|
|
targetFps: number | null = null;
|
|
|
|
hlsFile: string | null = null;
|
|
durationFrames = 0;
|
|
|
|
onLine(line: string): Line {
|
|
line = line.trimEnd();
|
|
if (/^frame=/.test(line)) {
|
|
if (this.parsingStart) {
|
|
this.parsingStart = false;
|
|
this.durationFrames = Math.ceil(
|
|
(this.targetFps ?? 25) * this.durationTime,
|
|
);
|
|
}
|
|
const parts = Object.fromEntries(
|
|
[...line.matchAll(/\b([a-z0-9]+)=\s*([^ ]+)(?= |$)/ig)].map((
|
|
[, k, v],
|
|
) => [k, v]),
|
|
);
|
|
const { frame, fps, speed, ...rest } = parts;
|
|
return {
|
|
kind: "progress",
|
|
frame: Number(frame),
|
|
totalFrames: this.durationFrames,
|
|
fps: Number(fps),
|
|
speed,
|
|
rest,
|
|
};
|
|
}
|
|
|
|
if (this.parsingStart) {
|
|
if (this.inIndentedIgnore) {
|
|
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
line = line.trimStart();
|
|
if (this.inIndentedIgnore === "inp") {
|
|
const match = line.match(/^Duration: (\d+):(\d+):(\d+\.\d+)/);
|
|
if (match) {
|
|
const [h, m, s] = match.slice(1).map((x) => Number(x));
|
|
this.durationTime = Math.max(
|
|
this.durationTime,
|
|
h * 60 * 60 + m * 60 + s,
|
|
);
|
|
}
|
|
if (!this.targetFps) {
|
|
const match = line.match(/^Stream.*, (\d+) fps/);
|
|
if (match) this.targetFps = Number(match[1]);
|
|
}
|
|
}
|
|
return { kind: "ignore" };
|
|
}
|
|
this.inIndentedIgnore = null;
|
|
}
|
|
if (line === "Press [q] to stop, [?] for help") {
|
|
return { kind: "ignore" };
|
|
}
|
|
if (line === "Stream mapping:") {
|
|
this.inIndentedIgnore = "other";
|
|
return { kind: "ignore" };
|
|
}
|
|
if (line.startsWith("Output #") || line.startsWith("Input #")) {
|
|
this.inIndentedIgnore = line.slice(0, 3).toLowerCase() as "inp" | "out";
|
|
return { kind: "ignore" };
|
|
}
|
|
}
|
|
|
|
const hlsMatch = line.match(/^\[hls @ .*Opening '(.+)' for writing/);
|
|
if (hlsMatch) {
|
|
if (!hlsMatch[1].endsWith(".tmp")) {
|
|
this.hlsFile = path.basename(hlsMatch[1]);
|
|
}
|
|
return { kind: "ignore" };
|
|
}
|
|
|
|
let level: Extract<Line, { kind: "log" }>["level"] = "info";
|
|
if (line.toLowerCase().includes("err")) level = "error";
|
|
else if (line.toLowerCase().includes("warn")) level = "warn";
|
|
|
|
return { kind: "log", level, message: line };
|
|
}
|
|
}
|
|
|
|
import * as child_process from "node:child_process";
|
|
import * as readline from "node:readline";
|
|
import * as process from "node:process";
|
|
import events from "node:events";
|
|
import * as path from "node:path";
|
|
import { Progress } from "@paperclover/console/Progress";
|