// Aufrufe von ffmpeg und ffprobe als Kindprozesse. // // Zwei Dinge sind hier wichtiger als alles andere (ADR-0006): // 1. Der Prozess laeuft nachrangig (os.setPriority), damit das Encodieren // dem laufenden Stream keine Frames kostet. // 2. Er darf scheitern, ohne den Daemon mitzureissen - deshalb Zeitlimit, // sauberes Toeten und die letzten Zeilen von stderr in der Fehlermeldung. import { spawn } from 'node:child_process'; import os from 'node:os'; import { logger } from '#core/logger.ts'; import { binaries } from '#core/platform.ts'; const log = logger('ffmpeg'); export type RunOptions = { niceness?: number; timeoutMs?: number; onProgress?: (seconds: number) => void; signal?: AbortSignal; /** Arbeitsverzeichnis. Erlaubt relative Dateinamen im Filtergraphen. */ cwd?: string; }; /** * Blanke Programmnamen auf die mitgelieferten Dateien abbilden. * * Im Container lagen ffmpeg und whisper-cli im PATH. Nativ liegen sie neben * der Anwendung. Die Abbildung steht an dieser einen Stelle, damit die * Aufrufer weiter schlicht `run('ffmpeg', ...)` schreiben koennen. */ function resolveBinary(bin: string): string { if (bin === 'ffmpeg') return binaries.ffmpeg; if (bin === 'ffprobe') return binaries.ffprobe; if (bin === 'whisper-cli') return binaries.whisper; return bin; } /** * Einen Dateipfad so verpacken, dass ffmpeg ihn im Filtergraphen als *einen* * Wert liest. * * Das ist unter Windows die Stelle, an der es sonst reihenweise knallt: * C:\Users\... enthaelt einen Doppelpunkt, und der trennt in ffmpeg die * Filteroptionen voneinander. Rueckwaertsschraegstriche sind ausserdem * ffmpeg-Escapes und gehen unterwegs verloren. * * Es braucht **alle drei** Schritte. An echtem ffmpeg 8.1 durchprobiert: * * ass=filename=C:/x/s.ass -> "No option name near '/x/s.ass'" * ass=filename=C\:/x/s.ass -> ebenso * ass=filename='C:/x/s.ass' -> ebenso * ass=filename='C\:/x/s.ass' -> laeuft * * Anfuehrungszeichen allein schuetzen den Doppelpunkt also nicht, und das * Escape allein auch nicht. */ export function filterPath(file: string): string { const forward = file.replace(/\\/g, '/').replace(/:/g, '\\:'); // Ein Anfuehrungszeichen laesst sich innerhalb der Gruppe nicht escapen: // Gruppe schliessen, Zeichen einzeln escapen, Gruppe wieder oeffnen. Bei // Benutzernamen wie O'Brien passiert das wirklich. return `'${forward.replace(/'/g, "'\\''")}'`; } /** * Filterausdruck zum Einbrennen einer ASS-Datei. * * `fontsdir` ist nicht optional im Sinne von "nice to have": im Container kam * DejaVu Sans aus dem Betriebssystem, Windows hat sie nicht. Fehlt der Ordner, * setzt libass wortlos eine Ersatzschrift ein und der Untertitel sieht anders * aus, als er soll - ohne jede Fehlermeldung. */ export function assFilter(assFile: string, fonts?: string): string { const parts = [`ass=filename=${filterPath(assFile)}`]; if (fonts) parts.push(`fontsdir=${filterPath(fonts)}`); return parts.join(':'); } export class FfmpegError extends Error { code: number | null; tail: string; constructor(message: string, code: number | null, tail: string) { super(message); this.name = 'FfmpegError'; this.code = code; this.tail = tail; } } /** Liest `out_time_ms=` aus der Fortschrittsausgabe von ffmpeg. */ export function parseProgress(chunk: string): number | null { const m = /out_time_ms=(\d+)/.exec(chunk); return m?.[1] ? Number(m[1]) / 1_000_000 : null; } export function run(bin: string, args: string[], opts: RunOptions = {}): Promise { const cmd = resolveBinary(bin); return new Promise((resolve, reject) => { log.debug(`${cmd} ${args.slice(0, 6).join(' ')} ...`); const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...(opts.cwd ? { cwd: opts.cwd } : {}), }); // Frueher lief das ueber `nice`, das es unter Windows nicht gibt. // os.setPriority kann beides und braucht keinen Hilfsprozess davor; // das Zurueckstufen darf jeder, nur das Hochstufen braucht Rechte. if (opts.niceness && child.pid !== undefined) { try { os.setPriority(child.pid, opts.niceness); } catch (err) { log.debug(`Prioritaet nicht setzbar: ${(err as Error).message}`); } } let stdout = ''; const errLines: string[] = []; let done = false; const finish = (fn: () => void) => { if (!done) { done = true; clearTimeout(timer); fn(); } }; const timer = setTimeout(() => { finish(() => { child.kill('SIGKILL'); reject(new FfmpegError(`${bin} hat das Zeitlimit ueberschritten`, null, errLines.join('\n'))); }); }, opts.timeoutMs ?? 30 * 60_000); opts.signal?.addEventListener('abort', () => { finish(() => { child.kill('SIGKILL'); reject(new Error('abgebrochen')); }); }, { once: true }); child.stdout.on('data', (d: Buffer) => { const text = d.toString(); stdout += text; if (opts.onProgress) { const s = parseProgress(text); if (s !== null) opts.onProgress(s); } }); child.stderr.on('data', (d: Buffer) => { // Nur die letzten Zeilen behalten - ffmpeg ist gespraechig, und im // Fehlerfall steht das Wesentliche am Ende. for (const line of d.toString().split('\n')) { if (line.trim()) errLines.push(line.trim()); } if (errLines.length > 40) errLines.splice(0, errLines.length - 40); }); child.on('error', (err) => finish(() => reject( new FfmpegError(`${bin} nicht startbar: ${err.message}`, null, errLines.join('\n'))))); child.on('close', (code) => finish(() => { if (code === 0) resolve(stdout); else reject(new FfmpegError(`${bin} endete mit Code ${code}`, code, errLines.join('\n'))); })); }); } export type AudioStream = { index: number; codec: string; channels: number; language?: string; title?: string }; export type ProbeResult = { durationSeconds: number; width: number; height: number; audio: AudioStream[] }; export async function probe(file: string): Promise { const out = await run('ffprobe', [ '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file, ], { timeoutMs: 60_000 }); return parseProbe(out); } export function parseProbe(json: string): ProbeResult { const data = JSON.parse(json) as { format?: { duration?: string }; streams?: Array<{ index: number; codec_type: string; codec_name?: string; width?: number; height?: number; channels?: number; tags?: Record; }>; }; const streams = data.streams ?? []; const video = streams.find((s) => s.codec_type === 'video'); const audio = streams .filter((s) => s.codec_type === 'audio') .map((s, i) => { const tags = s.tags ?? {}; const out: AudioStream = { index: i, // Position unter den Audiospuren, fuer -map 0:a:N codec: s.codec_name ?? '?', channels: s.channels ?? 0, }; if (tags['language']) out.language = tags['language']; if (tags['title']) out.title = tags['title']; return out; }); return { durationSeconds: Number(data.format?.duration ?? 0), width: video?.width ?? 0, height: video?.height ?? 0, audio, }; }