// 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; }; 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["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";