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[] = [];
+47
View File
@@ -0,0 +1,47 @@
// Diese Tests halten fest, was an echtem ffmpeg 8.1 unter Windows
// durchprobiert wurde. Ohne sie schleicht sich beim naechsten Aufraeumen
// wieder eine der drei Varianten ein, die nicht funktionieren.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { assFilter, filterPath } from './ffmpeg.ts';
test('macht aus einem Windows-Pfad einen Wert, den ffmpeg als einen liest', () => {
const got = filterPath('C:\\Users\\abdu\\work\\c1\\subs.ass');
// Alle drei Schritte sind noetig - jeder fuer sich reicht nicht:
// Vorwaertsschraegstriche, escapter Doppelpunkt, in Anfuehrungszeichen.
assert.equal(got, "'C\\:/Users/abdu/work/c1/subs.ass'");
});
test('Leerzeichen brauchen nichts Besonderes', () => {
// Es geht kein Shell dazwischen - gespawnt wird ohne Kommandozeile.
assert.equal(filterPath('C:\\Program Files\\abdulive\\fonts'),
"'C\\:/Program Files/abdulive/fonts'");
});
test('Anfuehrungszeichen im Pfad brechen die Gruppe nicht auf', () => {
// Benutzernamen wie O'Brien gibt es wirklich. Innerhalb der Gruppe laesst
// sich das Zeichen nicht escapen: schliessen, escapen, wieder oeffnen.
assert.equal(filterPath("C:\\Users\\O'Brien\\subs.ass"),
"'C\\:/Users/O'\\''Brien/subs.ass'");
});
test('Linux-Pfade bleiben unveraendert bis auf die Anfuehrungszeichen', () => {
assert.equal(filterPath('/work/c1/subs.ass'), "'/work/c1/subs.ass'");
});
test('assFilter haengt fontsdir nur an, wenn es einen Ordner gibt', () => {
assert.equal(assFilter('/work/s.ass'), "ass=filename='/work/s.ass'");
assert.equal(assFilter('/work/s.ass', '/opt/fonts'),
"ass=filename='/work/s.ass':fontsdir='/opt/fonts'");
});
test('der Doppelpunkt aus fontsdir trennt keine Filteroption ab', () => {
// Genau hier stand der Fehler: ohne Escape las ffmpeg alles ab "/Users/"
// als neuen Optionsnamen und brach mit "No option name near" ab.
const vf = assFilter('C:\\w\\s.ass', 'C:\\app\\fonts');
const optionSeparators = vf.split(':').length - 1;
const escaped = vf.split('\\:').length - 1;
assert.equal(escaped, 2, 'beide Laufwerksbuchstaben escapt');
assert.equal(optionSeparators - escaped, 1, 'genau ein echter Trenner');
});
+59 -1
View File
@@ -2,7 +2,10 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Word } from './whisper.ts';
import { wordsFromJson } from './whisper.ts';
import { assColor, assTime, buildAss, buildSrt, isNoise, toLines } from './subtitles.ts';
import {
assColor, assTime, buildAss, buildSrt, DEFAULT_STYLE, isNoise, lineOptionsFor,
fitFontSize, toLines, widthUnits,
} from './subtitles.ts';
function w(text: string, start: number, end: number): Word {
return { text, start, end };
@@ -89,6 +92,61 @@ test('filtert Geraeuschbeschreibungen', () => {
assert.deepEqual(lines.flatMap((l) => l.words.map((x) => x.text)), ['hallo']);
});
// ------------------------------------------------------------ Zeilenbreite
test('keine Zeile laeuft seitlich aus dem Bild', () => {
// Der Fehler, der das ausgeloest hat: die feste Obergrenze von 30 Zeichen
// ergibt bei Schriftgroesse 76 rund 1085 px. Nutzbar sind 1080 minus zwei
// Seitenraender = 940 px. Gemessen mit libass, nicht gerechnet.
const opts = lineOptionsFor(1080);
const satz = ('WARUM MACHT DER DAS DENN EINFACH NICHT RICHTIG das ist doch ' +
'wirklich nicht so schwer zu verstehen oder etwa doch').split(' ');
const lines = toLines(satz.map((t, i) => w(t, i * 0.35, i * 0.35 + 0.3)), opts);
assert.ok(lines.length > 1);
for (const l of lines) {
const text = l.words.map((x) => x.text).join(' ');
const units = widthUnits(text);
assert.ok(units <= opts.maxUnits + 4,
`zu breit (${units.toFixed(1)} von ${opts.maxUnits} Normalzeichen): "${text}"`);
}
});
test('Versalien zaehlen breiter als Kleinbuchstaben', () => {
assert.ok(widthUnits('WARUM') > widthUnits('warum'));
assert.equal(widthUnits('warum'), 5);
});
test('Zeilenlaenge haengt an Breite und Schriftgroesse', () => {
const schmal = lineOptionsFor(1080, { ...DEFAULT_STYLE, fontSize: 110 });
const breit = lineOptionsFor(1080, { ...DEFAULT_STYLE, fontSize: 56 });
assert.ok(schmal.maxUnits < breit.maxUnits,
'groessere Schrift muss frueher umbrechen');
assert.ok(lineOptionsFor(1080).maxUnits < 30, 'die alte 30 war zu viel');
});
test('verkleinert die Schrift fuer Woerter, die nicht umbrechbar sind', () => {
// Innerhalb eines Wortes bricht libass nicht um. Deutsche Komposita sind
// regelmaessig breiter als das Bild - gemessen 1078 px bei 1080 Bildbreite.
const lang = toLines([w('Geschwindigkeitsbegrenzung', 0, 2)]);
const kurz = toLines([w('kurz', 0, 2)]);
assert.ok(fitFontSize(lang[0]!, 940, DEFAULT_STYLE) < DEFAULT_STYLE.fontSize);
assert.equal(fitFontSize(kurz[0]!, 940, DEFAULT_STYLE), DEFAULT_STYLE.fontSize);
const ass = buildAss(lang, { width: 1080, height: 1920 });
assert.match(ass, /\{\\fs\d+\}\{\\k\d+\}Geschwindigkeits/,
'die Zeile braucht eine eigene Schriftgroesse');
// Kurze Zeilen bleiben unangetastet
assert.doesNotMatch(buildAss(kurz, { width: 1080, height: 1920 }), /\\fs/);
});
test('libass darf selbst umbrechen', () => {
// WrapStyle 2 bricht nur an \N um - buildAss setzt aber nie eines.
// Damit war jede zu lange Zeile ein Ueberlauf statt eines Umbruchs.
const ass = buildAss(toLines([w('test', 0, 1)]), { width: 1080, height: 1920 });
assert.match(ass, /^WrapStyle: 0$/m);
});
// ------------------------------------------------------------ ASS-Ausgabe
test('Zeilen ueberlappen sich nie', () => {
+98 -8
View File
@@ -40,14 +40,68 @@ export const DEFAULT_STYLE: SubtitleStyle = {
export type LineOptions = {
maxWords: number;
maxSeconds: number;
maxChars: number;
/** Obergrenze in Normalzeichen, nicht in Zeichen - siehe `widthUnits`. */
maxUnits: number;
};
/**
* Mittlere Zeichenbreite von DejaVu Sans **Bold** als Anteil der
* Schriftgroesse. Nicht geschaetzt, sondern gemessen: deutsche Beispielzeilen
* mit libass auf eine ueberbreite Leinwand gerendert und die Randspalten
* ausgezaehlt. Die Werte lagen zwischen 0,45 und 0,53 - hier steht der obere
* Rand, weil eine zu breite Zeile teurer ist als eine zu frueh gebrochene.
*/
export const CHAR_WIDTH_RATIO = 0.53;
/**
* Versalien sind rund ein Drittel breiter als Kleinbuchstaben. Ohne diesen
* Zuschlag passt gerade der Fall nicht, der in Clips staendig vorkommt:
* gebruellte Zeilen. Gemessen bei Schriftgroesse 76 - "und dann bin ich
* einfach" 899 px, "WARUM MACHT DER DAS DENN" bei gleicher Zeichenzahl
* 1146 px.
*/
const UPPERCASE_FACTOR = 1.3;
/** Geschaetzte Breite eines Textes in Normalzeichen. */
export function widthUnits(text: string): number {
let units = 0;
for (const ch of text) units += /[A-ZÄÖÜ]/.test(ch) ? UPPERCASE_FACTOR : 1;
return units;
}
/**
* Wie viele Normalzeichen zwischen die Seitenraender passen.
*
* Vorher stand hier fest `maxChars: 30`, und das lief ueber: 30 Zeichen sind
* bei Groesse 76 rund 1085 px breit, nutzbar sind aber nur 940. Zusammen mit
* `WrapStyle: 2` - libass bricht dann gar nicht um - schob sich der Text links
* und rechts aus dem Bild.
*/
export function maxUnitsFor(width: number, style: SubtitleStyle): number {
const usable = width - 2 * style.marginSide;
return Math.max(8, Math.floor(usable / (style.fontSize * CHAR_WIDTH_RATIO)));
}
/** Zeilenvorgaben passend zur Zielbreite und zum Stil. */
export function lineOptionsFor(
width: number,
style: SubtitleStyle = DEFAULT_STYLE,
opts: Partial<LineOptions> = {}
): LineOptions {
return { ...DEFAULT_LINES, maxUnits: maxUnitsFor(width, style), ...opts };
}
// maxSeconds war anfangs 2,2 s. An echtem Material zeigte sich, dass bei
// langsamem oder zoegerndem Sprechen dann Zeilen mit einem einzigen Wort
// entstehen ("Dank" / "es," / "Savina,"). 3,2 s plus das Nachverdichten
// weiter unten ergibt lesbare Zeilen.
export const DEFAULT_LINES: LineOptions = { maxWords: 5, maxSeconds: 3.2, maxChars: 30 };
//
// maxUnits gilt fuer das Zielformat 1080 breit. Andere Breiten: lineOptionsFor.
export const DEFAULT_LINES: LineOptions = {
maxWords: 5,
maxSeconds: 3.2,
maxUnits: 23,
};
export type Line = { words: Word[]; start: number; end: number };
@@ -99,12 +153,13 @@ export function toLines(words: Word[], opts: LineOptions = DEFAULT_LINES): Line[
};
for (const w of words.filter((x) => !isNoise(x.text))) {
const wouldBeChars = current.reduce((n, x) => n + x.text.length + 1, 0) + w.text.length;
const wouldBeUnits = current.reduce((n, x) => n + widthUnits(x.text) + 1, 0) +
widthUnits(w.text);
const wouldBeSeconds = current.length ? w.end - current[0]!.start : 0;
if (current.length && (
current.length >= opts.maxWords ||
wouldBeChars > opts.maxChars ||
wouldBeUnits > opts.maxUnits ||
wouldBeSeconds > opts.maxSeconds ||
// Eine Pause von mehr als einer halben Sekunde ist ein Satzende,
// egal was die Zeichensetzung sagt
@@ -127,11 +182,14 @@ function mergeStragglers(lines: Line[], opts: LineOptions): Line[] {
const out: Line[] = [];
for (const line of lines) {
const prev = out[out.length - 1];
const chars = (l: Line) => l.words.reduce((n, w) => n + w.text.length + 1, 0);
const units = (l: Line) => l.words.reduce((n, w) => n + widthUnits(w.text) + 1, 0);
// Der Zuschlag war 8 Zeichen - das sind knapp 320 px und damit weit ueber
// dem Rand. 4 reichen fuer den Zweck, und was doch nicht passt, bricht
// libass jetzt sauber um (WrapStyle 0), statt es aus dem Bild zu schieben.
const soloAndClose = prev && line.words.length === 1 &&
line.start - prev.end < 0.6 &&
prev.words.length + 1 <= opts.maxWords + 1 &&
chars(prev) + chars(line) <= opts.maxChars + 8;
units(prev) + units(line) <= opts.maxUnits + 4;
if (soloAndClose) {
prev.words.push(...line.words);
@@ -154,7 +212,10 @@ export function buildAss(
'ScriptType: v4.00+',
`PlayResX: ${opts.width}`,
`PlayResY: ${opts.height}`,
'WrapStyle: 2',
// 0 = automatischer Umbruch mit moeglichst gleich langen Zeilen. Vorher
// stand hier 2 ("nur an \N umbrechen"), und weil buildAss nie ein \N
// setzt, lief jede zu lange Zeile einfach aus dem Bild heraus.
'WrapStyle: 0',
'ScaledBorderAndShadow: yes',
'',
'[V4+ Styles]',
@@ -179,6 +240,8 @@ export function buildAss(
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
];
const usable = opts.width - 2 * s.marginSide;
const events = lines.map((line, li) => {
// Nachlauf, damit die Zeile nicht im Moment des letzten Lautes verschwindet -
// aber nie in die naechste Zeile hinein. Zwei gleichzeitig sichtbare
@@ -186,6 +249,9 @@ export function buildAss(
const next = lines[li + 1];
const end = next ? Math.min(line.end + 0.25, next.start) : line.end + 0.4;
const fs = fitFontSize(line, usable, s);
const prefix = fs === s.fontSize ? '' : `{\\fs${fs}}`;
const text = line.words.map((w, i) => {
const nextWord = line.words[i + 1];
// Die Marke laeuft bis zum Beginn des naechsten Wortes, damit Pausen
@@ -196,12 +262,36 @@ export function buildAss(
return `{\\k${cs}}${escapeAss(w.text)}`;
}).join(' ');
return `Dialogue: 0,${assTime(line.start)},${assTime(Math.max(end, line.start + 0.3))},` +
`Short,,0,0,0,,${text}`;
`Short,,0,0,0,,${prefix}${text}`;
});
return [...header, ...events, ''].join('\n');
}
/**
* Innerhalb eines Wortes bricht libass nicht um. Im Deutschen ist das keine
* Randerscheinung: "Geschwindigkeitsbegrenzung" ist bei Schriftgroesse 76 rund
* 1090 px breit, das Bild ist 1080 breit - gemessen, das Wort wurde links und
* rechts abgeschnitten. Solche Zeilen bekommen eine kleinere Schrift, statt
* ueber den Rand zu laufen.
*/
export function fitFontSize(line: Line, usable: number, style: SubtitleStyle): number {
// CHAR_WIDTH_RATIO ist ein Mittelwert. Beim Zeilenumbruch faengt WrapStyle 0
// ab, wenn er danebenliegt - hier nicht, hier ist es sofort ein Ueberlauf.
// Deshalb 5 % Reserve; ohne sie landete "Geschwindigkeitsbegrenzung" gemessen
// bei 950 px statt der erlaubten 940.
const budget = usable * 0.95;
const longest = Math.max(...line.words.map((w) => widthUnits(w.text)));
const needed = longest * style.fontSize * CHAR_WIDTH_RATIO;
if (needed <= budget) return style.fontSize;
// Nicht beliebig weit verkleinern: unter 55 % liest das auf einem Handy
// niemand mehr, und dann ist ein Rest Ueberlauf das kleinere Uebel.
return Math.max(
Math.round(style.fontSize * 0.55),
Math.floor((style.fontSize * budget) / needed)
);
}
function escapeAss(text: string): string {
return text.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
}