Files
abdulive/packages/media/src/ffmpeg.ts
T
ZeasyAlexandClaude Opus 5 5f6301268a Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt
TypeScript direkt aus - der Container schrieb nur noch drei Programme fest,
und dafuer braucht es keine Virtualisierung, sondern einen Ordner.

Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber
nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste
whisper.cpp aus dem Quelltext uebersetzen.

Plattformschicht (packages/core/src/platform.ts):
- Programme werden neben der Anwendung gesucht, nicht im PATH
- Daten unter %LOCALAPPDATA%, damit Installation und Update ohne
  Administratorrechte auskommen
- Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine
  Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite

Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert:
- Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche,
  escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht
- `nice` gibt es nicht, ersetzt durch os.setPriority

Auslieferung:
- scripts/build-package.ts baut das Paket, laeuft auch unter Linux
- install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner
- update legt neue Fassungen bereit; eingespielt wird beim naechsten Start,
  nie mitten im Stream
- ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts)

Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer
Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell
aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene
Vorgabeadresse mehr.

Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben.

ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
2026-09-09 17:39:26 +02:00

202 lines
7.1 KiB
TypeScript

// 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<string> {
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<ProbeResult> {
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<string, string>;
}>;
};
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,
};
}