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
213 lines
8.6 KiB
TypeScript
213 lines
8.6 KiB
TypeScript
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, DEFAULT_STYLE, isNoise, lineOptionsFor,
|
|
fitFontSize, toLines, widthUnits,
|
|
} from './subtitles.ts';
|
|
|
|
function w(text: string, start: number, end: number): Word {
|
|
return { text, start, end };
|
|
}
|
|
|
|
// ------------------------------------------------------------ Wortzerlegung
|
|
|
|
test('setzt Wortstuecke wieder zusammen', () => {
|
|
// So sieht whisper.cpp wirklich aus: Tokens mit fuehrendem Leerzeichen
|
|
// beginnen ein Wort, alles andere haengt sich an.
|
|
const json = JSON.stringify({
|
|
transcription: [{
|
|
offsets: { from: 0, to: 4200 },
|
|
tokens: [
|
|
{ text: '[_BEG_]', offsets: { from: 0, to: 0 } },
|
|
{ text: ' Sav', offsets: { from: 2280, to: 3120 } },
|
|
{ text: 'ina', offsets: { from: 3280, to: 3880 } },
|
|
{ text: ',', offsets: { from: 3990, to: 4140 } },
|
|
],
|
|
}],
|
|
});
|
|
const words = wordsFromJson(json);
|
|
assert.deepEqual(words.map((x) => x.text), ['Savina,']);
|
|
assert.equal(words[0]?.start, 2.28);
|
|
});
|
|
|
|
test('wirft Sondertokens weg', () => {
|
|
const json = JSON.stringify({
|
|
transcription: [{ tokens: [
|
|
{ text: '[_BEG_]', offsets: { from: 0, to: 0 } },
|
|
{ text: ' hallo', offsets: { from: 100, to: 400 } },
|
|
{ text: '[_TT_120]', offsets: { from: 400, to: 400 } },
|
|
] }],
|
|
});
|
|
assert.deepEqual(wordsFromJson(json).map((x) => x.text), ['hallo']);
|
|
});
|
|
|
|
test('zieht rueckwaerts laufende Zeitstempel gerade', () => {
|
|
const json = JSON.stringify({
|
|
transcription: [{ tokens: [
|
|
{ text: ' eins', offsets: { from: 1000, to: 2000 } },
|
|
{ text: ' zwei', offsets: { from: 1500, to: 1800 } },
|
|
] }],
|
|
});
|
|
const words = wordsFromJson(json);
|
|
assert.ok(words[1]!.start >= words[0]!.end, 'zweites Wort darf nicht vor dem ersten enden');
|
|
assert.ok(words[1]!.end > words[1]!.start);
|
|
});
|
|
|
|
// ------------------------------------------------------------ Zeilenbildung
|
|
|
|
test('bricht lange Rede in kurze Zeilen', () => {
|
|
const words = ['eins', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht']
|
|
.map((t, i) => w(t, i * 0.4, i * 0.4 + 0.35));
|
|
const lines = toLines(words);
|
|
assert.ok(lines.length >= 2, 'acht Woerter gehoeren nicht auf eine Zeile');
|
|
for (const l of lines) assert.ok(l.words.length <= 5, 'Zeile zu lang');
|
|
});
|
|
|
|
test('haengt einzeln stehende Woerter an den Nachbarn', () => {
|
|
// Genau der Fall aus dem echten Clip: langsames Sprechen erzeugte
|
|
// Zeilen mit einem Wort ("Dank", "es,", "Savina,")
|
|
const words = [w('Dank', 0, 1.14), w('es,', 1.14, 2.28), w('Savina,', 2.28, 4.14)];
|
|
const lines = toLines(words);
|
|
assert.ok(lines.length < 3, `immer noch zersplittert: ${JSON.stringify(lines.map(l => l.words.map(x => x.text)))}`);
|
|
for (const l of lines) {
|
|
assert.ok(l.words.length > 1 || lines.length === 1, 'Zeile mit nur einem Wort uebrig');
|
|
}
|
|
});
|
|
|
|
test('trennt bei laengeren Sprechpausen', () => {
|
|
const words = [w('vor', 0, 0.4), w('der', 0.4, 0.8), w('Pause', 4.0, 4.4)];
|
|
const lines = toLines(words);
|
|
assert.equal(lines.length, 2);
|
|
assert.equal(lines[1]?.words[0]?.text, 'Pause');
|
|
});
|
|
|
|
test('filtert Geraeuschbeschreibungen', () => {
|
|
assert.equal(isNoise('*Lachen*'), true);
|
|
assert.equal(isNoise('[Musik]'), true);
|
|
assert.equal(isNoise('(Applaus)'), true);
|
|
assert.equal(isNoise('Lachen'), false);
|
|
const lines = toLines([w('hallo', 0, 0.5), w('*Lachen*', 0.6, 1.2)]);
|
|
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', () => {
|
|
// Zwei gleichzeitig sichtbare Dialoge stapelt ASS uebereinander
|
|
const words = Array.from({ length: 12 }, (_, i) => w(`w${i}`, i * 0.5, i * 0.5 + 0.45));
|
|
const ass = buildAss(toLines(words), { width: 1080, height: 1920 });
|
|
const times = [...ass.matchAll(/^Dialogue: 0,([\d:.]+),([\d:.]+),/gm)]
|
|
.map((m) => [toSeconds(m[1]!), toSeconds(m[2]!)] as const);
|
|
assert.ok(times.length >= 2);
|
|
for (let i = 1; i < times.length; i++) {
|
|
assert.ok(times[i]![0] >= times[i - 1]![1] - 0.011,
|
|
`Zeile ${i} beginnt bei ${times[i]![0]}, vorherige endet erst bei ${times[i - 1]![1]}`);
|
|
}
|
|
});
|
|
|
|
test('jede Zeile bleibt mindestens kurz stehen', () => {
|
|
const ass = buildAss(toLines([w('kurz', 0, 0.05)]), { width: 1080, height: 1920 });
|
|
const m = /^Dialogue: 0,([\d:.]+),([\d:.]+),/m.exec(ass);
|
|
assert.ok(m);
|
|
assert.ok(toSeconds(m[2]!) - toSeconds(m[1]!) >= 0.29);
|
|
});
|
|
|
|
test('Kopf enthaelt Aufloesung, Stil und Sicherheitsabstand', () => {
|
|
const ass = buildAss(toLines([w('test', 0, 1)]), { width: 1080, height: 1920 });
|
|
assert.match(ass, /PlayResX: 1080/);
|
|
assert.match(ass, /PlayResY: 1920/);
|
|
// Alignment 2 = unten mittig, MarginV 340 haelt die Bedienoberflaeche frei
|
|
assert.match(ass, /,2,70,70,340,1$/m);
|
|
});
|
|
|
|
test('Farben werden in die ASS-Reihenfolge gedreht', () => {
|
|
// RRGGBB -> &HAABBGGRR
|
|
assert.equal(assColor('FFD400'), '&H0000D4FF');
|
|
assert.equal(assColor('FFFFFF'), '&H00FFFFFF');
|
|
assert.equal(assColor('000000', 0x80), '&H80000000');
|
|
});
|
|
|
|
test('Zeitformat entspricht ASS', () => {
|
|
assert.equal(assTime(0), '0:00:00.00');
|
|
assert.equal(assTime(61.5), '0:01:01.50');
|
|
assert.equal(assTime(3661.239), '1:01:01.24');
|
|
// Aufrundung auf 100 Hundertstel darf nicht ".100" ergeben
|
|
assert.equal(assTime(59.999), '0:01:00.00');
|
|
});
|
|
|
|
test('jedes Wort bekommt eine Karaoke-Marke', () => {
|
|
const ass = buildAss(toLines([w('eins', 0, 0.5), w('zwei', 0.5, 1.0)]),
|
|
{ width: 1080, height: 1920 });
|
|
const line = /^Dialogue:.*,,(.*)$/m.exec(ass)?.[1] ?? '';
|
|
assert.equal((line.match(/\{\\k\d+\}/g) ?? []).length, 2);
|
|
});
|
|
|
|
test('SRT enthaelt denselben Text', () => {
|
|
const lines = toLines([w('hallo', 0, 0.5), w('welt', 0.5, 1.0)]);
|
|
const srt = buildSrt(lines);
|
|
assert.match(srt, /hallo welt/);
|
|
assert.match(srt, /00:00:00,000 --> /);
|
|
});
|
|
|
|
function toSeconds(t: string): number {
|
|
const [h, m, s] = t.split(':');
|
|
return Number(h) * 3600 + Number(m) * 60 + Number(s);
|
|
}
|