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
202 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|