sitegen/src/file-viewer/ffmpeg.ts

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";