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
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 17:39:26 +02:00
1 parent 71dd660d01
commit 5f6301268a
41 files changed
+2254 -312

No files matched your search

+75 -7
View File
@@ -1,13 +1,15 @@
// Aufrufe von ffmpeg und ffprobe als Kindprozesse.
//
// Zwei Dinge sind hier wichtiger als alles andere (ADR-0006):
// 1. Der Prozess laeuft nachrangig (`nice`), damit das Encodieren dem
// laufenden Stream keine Frames kostet.
// 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');
@@ -16,8 +18,65 @@ export type RunOptions = {
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;
@@ -36,13 +95,22 @@ export function parseProgress(chunk: string): number | null {
}
export function run(bin: string, args: string[], opts: RunOptions = {}): Promise<string> {
const niceness = opts.niceness ?? 0;
const cmd = niceness > 0 ? 'nice' : bin;
const argv = niceness > 0 ? ['-n', String(niceness), bin, ...args] : args;
const cmd = resolveBinary(bin);
return new Promise((resolve, reject) => {
log.debug(`${cmd} ${argv.slice(0, 6).join(' ')} ...`);
const child = spawn(cmd, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
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[] = [];