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:
1 parent
71dd660d01
commit
5f6301268a
41 files changed
+2254
-312
No files matched your search
@@ -4,14 +4,19 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseYaml, type YamlMap } from './yaml.ts';
|
||||
import { defaultDirs } from './platform.ts';
|
||||
|
||||
// Die Vorgaben haengen am Betriebssystem (siehe platform.ts); die
|
||||
// Umgebungsvariablen ueberschreiben sie wie bisher.
|
||||
const dirs = defaultDirs();
|
||||
|
||||
export const paths = {
|
||||
config: process.env.CONFIG_DIR || '/app/config',
|
||||
db: process.env.DB_DIR || '/db',
|
||||
work: process.env.WORK_DIR || '/work',
|
||||
clips: process.env.CLIPS_DIR || '/app/data/clips',
|
||||
replay: process.env.REPLAY_DIR || '/replay',
|
||||
models: process.env.MODEL_DIR || '/models',
|
||||
config: process.env.CONFIG_DIR || dirs.config,
|
||||
db: process.env.DB_DIR || dirs.db,
|
||||
work: process.env.WORK_DIR || dirs.work,
|
||||
clips: process.env.CLIPS_DIR || dirs.clips,
|
||||
replay: process.env.REPLAY_DIR || dirs.replay,
|
||||
models: process.env.MODEL_DIR || dirs.models,
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
@@ -27,6 +32,7 @@ export type Config = {
|
||||
};
|
||||
};
|
||||
obs: { url: string; replayBufferSeconds: number; chapterScenes: string[] };
|
||||
update: { url: string };
|
||||
clipper: {
|
||||
enabled: boolean;
|
||||
alsoCreateTwitchClip: boolean;
|
||||
@@ -57,7 +63,8 @@ const DEFAULTS = {
|
||||
laughEmotes: ['KEKW', 'LUL', 'OMEGALUL', 'PogChamp', 'Pog'],
|
||||
},
|
||||
},
|
||||
obs: { url: 'ws://host.docker.internal:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
obs: { url: 'ws://127.0.0.1:4455', replayBufferSeconds: 90, chapterScenes: [] },
|
||||
update: { url: '' },
|
||||
clipper: {
|
||||
enabled: true,
|
||||
alsoCreateTwitchClip: true,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Wo die Anwendung ihre Programme, Schriften und Daten findet.
|
||||
//
|
||||
// Bis 2026-09 lief alles in einem Container: ffmpeg lag im PATH, die Daten
|
||||
// unter festen Linux-Absolutpfaden (/db, /work, /models). Nativ unter Windows
|
||||
// gibt es beides nicht - die Programme liegen neben der Anwendung, die Daten
|
||||
// unter %ProgramData%. Siehe ADR-0007.
|
||||
//
|
||||
// Die Linux-Pfade bleiben als Vorgabe stehen, damit die Tests im Container
|
||||
// unveraendert laufen und ein Betrieb unter Linux moeglich bleibt.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const isWindows = process.platform === 'win32';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Wurzel des Quellbaums - im Paket der Ordner `app`. */
|
||||
export const appRoot = path.resolve(HERE, '..', '..', '..');
|
||||
|
||||
/**
|
||||
* Wurzel der Installation. Im Paket liegt darin `app/`, `bin/`, `fonts/` und
|
||||
* `node.exe`; im Quell-Checkout ist es schlicht der Ordner ueber dem Repo und
|
||||
* enthaelt nichts davon - dann greift die Suche im PATH.
|
||||
*/
|
||||
export const installRoot = path.resolve(appRoot, '..');
|
||||
|
||||
/** Verzeichnisse, in denen mitgelieferte Dateien gesucht werden. */
|
||||
const bundleDirs = (sub: string): string[] =>
|
||||
[path.join(installRoot, sub), path.join(appRoot, sub)];
|
||||
|
||||
const found = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Ein Programm auflösen: erst die Umgebungsvariable, dann das mitgelieferte
|
||||
* `bin/`, sonst der blanke Name - dann sucht das Betriebssystem im PATH.
|
||||
*
|
||||
* Die Reihenfolge ist Absicht. Die Umgebungsvariable gewinnt, damit sich eine
|
||||
* kaputte Mitlieferung ohne Neuinstallation ueberschreiben laesst.
|
||||
*/
|
||||
export function findBinary(name: string, envVar: string): string {
|
||||
const fromEnv = process.env[envVar];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const cached = found.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const exe = isWindows ? `${name}.exe` : name;
|
||||
for (const dir of bundleDirs('bin')) {
|
||||
const p = path.join(dir, exe);
|
||||
if (fs.existsSync(p)) { found.set(name, p); return p; }
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export const binaries = {
|
||||
get ffmpeg(): string { return findBinary('ffmpeg', 'FFMPEG_BIN'); },
|
||||
get ffprobe(): string { return findBinary('ffprobe', 'FFPROBE_BIN'); },
|
||||
get whisper(): string { return findBinary('whisper-cli', 'WHISPER_BIN'); },
|
||||
};
|
||||
|
||||
/**
|
||||
* Ordner mit den mitgelieferten Schriften, oder `undefined`.
|
||||
*
|
||||
* Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie
|
||||
* ueber fontconfig. Windows hat DejaVu nicht. Ohne diesen Ordner setzt libass
|
||||
* still eine Ersatzschrift ein - der Untertitel sieht dann anders aus, ohne
|
||||
* dass irgendetwas eine Warnung ausgibt.
|
||||
*/
|
||||
export function fontsDir(): string | undefined {
|
||||
if (process.env['FONTS_DIR']) return process.env['FONTS_DIR'];
|
||||
for (const dir of bundleDirs('fonts')) if (fs.existsSync(dir)) return dir;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Vorgabe fuer die Datenverzeichnisse, je nach Betriebssystem. */
|
||||
export function defaultDirs(): Record<
|
||||
'config' | 'db' | 'work' | 'clips' | 'replay' | 'models', string
|
||||
> {
|
||||
if (!isWindows) {
|
||||
return {
|
||||
config: '/app/config', db: '/db', work: '/work',
|
||||
clips: '/app/data/clips', replay: '/replay', models: '/models',
|
||||
};
|
||||
}
|
||||
// Nicht neben die Programme: ein Update tauscht den Programmordner
|
||||
// komplett aus, und die Datenbank darf dabei nicht mitgehen.
|
||||
//
|
||||
// LOCALAPPDATA und nicht ProgramData, weil die Anwendung als angemeldeter
|
||||
// Benutzer laeuft - dieselbe Sitzung wie OBS. Damit braucht weder die
|
||||
// Installation noch ein Update Administratorrechte. Genau daran scheitern
|
||||
// Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird.
|
||||
const local = process.env['LOCALAPPDATA'] ??
|
||||
path.join(process.env['USERPROFILE'] ?? 'C:\\', 'AppData', 'Local');
|
||||
const base = path.join(local, 'abdulive');
|
||||
return {
|
||||
config: path.join(base, 'config'),
|
||||
db: path.join(base, 'db'),
|
||||
work: path.join(base, 'work'),
|
||||
clips: path.join(base, 'clips'),
|
||||
replay: path.join(base, 'replay'),
|
||||
models: path.join(base, 'models'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
import { readZip, writeZip } from './zip.ts';
|
||||
|
||||
/**
|
||||
* Baut ein ZIP von Hand. Ein Schreiber gehoert nicht ins Projekt, aber ohne
|
||||
* eigenes Archiv braeuchte der Test eine Beispieldatei im Repo - und die
|
||||
* saehe niemand mehr an.
|
||||
*/
|
||||
function makeZip(files: Array<{ name: string; body: string; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name, 'utf8');
|
||||
const raw = Buffer.from(f.body, 'utf8');
|
||||
const data = f.deflate ? zlib.deflateRawSync(raw) : raw;
|
||||
const method = f.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt32LE(data.length, 18);
|
||||
local.writeUInt32LE(raw.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, data);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt32LE(data.length, 20);
|
||||
central.writeUInt32LE(raw.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + data.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, dir, eocd]);
|
||||
}
|
||||
|
||||
test('liest gespeicherte und komprimierte Eintraege', () => {
|
||||
const zip = makeZip([
|
||||
{ name: 'bin/ffmpeg.exe', body: 'MZ-so-tun-als-ob' },
|
||||
{ name: 'liesmich.txt', body: 'Hallo '.repeat(200), deflate: true },
|
||||
]);
|
||||
const entries = readZip(zip);
|
||||
|
||||
assert.equal(entries.length, 2);
|
||||
assert.equal(entries[0]?.data().toString(), 'MZ-so-tun-als-ob');
|
||||
assert.equal(entries[1]?.data().toString(), 'Hallo '.repeat(200));
|
||||
assert.equal(entries[1]?.size, 1200);
|
||||
});
|
||||
|
||||
test('erkennt Verzeichniseintraege am Schraegstrich', () => {
|
||||
const entries = readZip(makeZip([{ name: 'bin/', body: '' }, { name: 'bin/a', body: 'x' }]));
|
||||
assert.equal(entries[0]?.isDirectory, true);
|
||||
assert.equal(entries[1]?.isDirectory, false);
|
||||
});
|
||||
|
||||
test('findet das Inhaltsverzeichnis auch hinter einem Kommentar', () => {
|
||||
// Der Kommentar steht hinter dem EOCD - von hinten zu suchen reicht nicht,
|
||||
// man muss das Feld tatsaechlich pruefen.
|
||||
const zip = makeZip([{ name: 'a.txt', body: 'inhalt' }]);
|
||||
const withComment = Buffer.concat([zip, Buffer.from('# angehaengter Kommentar')]);
|
||||
withComment.writeUInt16LE(24, withComment.length - 24 - 22 + 20);
|
||||
assert.equal(readZip(withComment)[0]?.data().toString(), 'inhalt');
|
||||
});
|
||||
|
||||
test('sagt deutlich, wenn kein ZIP vorliegt', () => {
|
||||
assert.throws(() => readZip(Buffer.alloc(200)), /Inhaltsverzeichnis/);
|
||||
});
|
||||
|
||||
test('meldet ZIP64 statt still Unsinn zu liefern', () => {
|
||||
const zip = makeZip([{ name: 'a', body: 'x' }]);
|
||||
zip.writeUInt32LE(0xffffffff, zip.length - 22 + 16); // dirStart
|
||||
assert.throws(() => readZip(zip), /ZIP64/);
|
||||
});
|
||||
|
||||
test('Geschriebenes laesst sich wieder lesen', () => {
|
||||
const gross = Buffer.from('x'.repeat(50_000));
|
||||
const zip = writeZip([
|
||||
{ name: 'bin/ffmpeg.exe', data: Buffer.from([0x4d, 0x5a, 0x90, 0x00]) },
|
||||
{ name: 'app/gross.txt', data: gross },
|
||||
]);
|
||||
const back = readZip(zip);
|
||||
assert.deepEqual(back.map((e) => e.name), ['bin/ffmpeg.exe', 'app/gross.txt']);
|
||||
assert.deepEqual(back[1]?.data(), gross);
|
||||
// Gut komprimierbar - der Eintrag muss deutlich kleiner geworden sein
|
||||
assert.ok(zip.length < 5_000, `Archiv ist ${zip.length} Bytes gross`);
|
||||
});
|
||||
|
||||
test('schreibt eine gueltige CRC32 - sonst meckert der Explorer', () => {
|
||||
const data = Buffer.from('Wiedergabepuffer');
|
||||
const zip = writeZip([{ name: 'a.txt', data }]);
|
||||
// Im lokalen Kopf steht die Pruefsumme an Offset 14
|
||||
assert.equal(zip.readUInt32LE(14), zlib.crc32(data));
|
||||
});
|
||||
|
||||
test('legt bereits komprimierte Daten unveraendert ab', () => {
|
||||
// Echter Zufall laesst sich nicht packen, deflate waere groesser als das
|
||||
// Original. Eine gerechnete Folge taugt dafuer nicht - `i * k % 256`
|
||||
// wiederholt sich nach 256 Werten und ist bestens komprimierbar.
|
||||
const rauschen = crypto.randomBytes(4096);
|
||||
const zip = writeZip([{ name: 'r.bin', data: rauschen }]);
|
||||
assert.equal(zip.readUInt16LE(8), 0, 'Verfahren 0 = gespeichert');
|
||||
assert.deepEqual(readZip(zip)[0]?.data(), rauschen);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// ZIP lesen. Node bringt zlib mit, aber kein Archivformat, und das Projekt
|
||||
// kommt ohne Abhaengigkeiten aus (ADR-0001) - wie beim YAML-Parser und beim
|
||||
// obs-websocket-Client also selbst gebaut.
|
||||
//
|
||||
// Gebraucht wird das an zwei Stellen: beim Paketbau kommen ffmpeg,
|
||||
// whisper.cpp und Node als ZIP von ihren Herausgebern, und der Updater auf
|
||||
// Abdus Rechner packt damit das neue Paket aus - ohne PowerShell, ohne
|
||||
// externes Programm.
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const EOCD = 0x06054b50; // End of Central Directory
|
||||
const CENTRAL = 0x02014b50; // Eintrag im Inhaltsverzeichnis
|
||||
const LOCAL = 0x04034b50; // Kopf vor den eigentlichen Daten
|
||||
|
||||
export type ZipEntry = {
|
||||
name: string;
|
||||
/** Unkomprimierte Groesse in Bytes. */
|
||||
size: number;
|
||||
/** Verzeichniseintraege haben keine Daten. */
|
||||
isDirectory: boolean;
|
||||
data: () => Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inhaltsverzeichnis eines ZIP lesen.
|
||||
*
|
||||
* Die Daten werden erst beim Aufruf von `data()` entpackt - beim Paketbau
|
||||
* werden von 800 Eintraegen keine 30 gebraucht.
|
||||
*/
|
||||
export function readZip(buf: Buffer): ZipEntry[] {
|
||||
const eocd = findEocd(buf);
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
const dirSize = buf.readUInt32LE(eocd + 12);
|
||||
const dirStart = buf.readUInt32LE(eocd + 16);
|
||||
|
||||
if (dirStart === 0xffffffff || dirSize === 0xffffffff || count === 0xffff) {
|
||||
throw new Error('ZIP64 wird nicht unterstuetzt - das Archiv ist zu gross.');
|
||||
}
|
||||
|
||||
const entries: ZipEntry[] = [];
|
||||
let p = dirStart;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (buf.readUInt32LE(p) !== CENTRAL) {
|
||||
throw new Error(`Beschaedigtes Inhaltsverzeichnis bei Eintrag ${i}`);
|
||||
}
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const compressed = buf.readUInt32LE(p + 20);
|
||||
const size = buf.readUInt32LE(p + 24);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const localOffset = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
||||
|
||||
entries.push({
|
||||
name,
|
||||
size,
|
||||
isDirectory: name.endsWith('/'),
|
||||
data: () => extract(buf, localOffset, method, compressed, size, name),
|
||||
});
|
||||
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extract(
|
||||
buf: Buffer, localOffset: number, method: number,
|
||||
compressed: number, size: number, name: string
|
||||
): Buffer {
|
||||
if (buf.readUInt32LE(localOffset) !== LOCAL) {
|
||||
throw new Error(`Kein lokaler Dateikopf fuer ${name}`);
|
||||
}
|
||||
// Der lokale Kopf hat eigene Laengen fuer Name und Extra-Feld; sie koennen
|
||||
// von denen im Inhaltsverzeichnis abweichen und muessen von hier kommen.
|
||||
const nameLen = buf.readUInt16LE(localOffset + 26);
|
||||
const extraLen = buf.readUInt16LE(localOffset + 28);
|
||||
const start = localOffset + 30 + nameLen + extraLen;
|
||||
const raw = buf.subarray(start, start + compressed);
|
||||
|
||||
if (method === 0) return Buffer.from(raw);
|
||||
if (method === 8) {
|
||||
const out = zlib.inflateRawSync(raw);
|
||||
if (out.length !== size) {
|
||||
throw new Error(`${name}: ${out.length} Bytes entpackt, ${size} erwartet`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
throw new Error(`${name}: unbekanntes Kompressionsverfahren ${method}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Inhaltsverzeichnis steht am Dateiende, hinter einem Kommentar
|
||||
* beliebiger Laenge. Also von hinten suchen - hoechstens 64 KB weit, mehr
|
||||
* darf der Kommentar nicht sein.
|
||||
*/
|
||||
function findEocd(buf: Buffer): number {
|
||||
const min = Math.max(0, buf.length - 0x10000 - 22);
|
||||
for (let i = buf.length - 22; i >= min; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD) return i;
|
||||
}
|
||||
throw new Error('Kein ZIP-Inhaltsverzeichnis gefunden.');
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP schreiben. Nur so viel Format, wie der Windows-Explorer und
|
||||
* `Expand-Archive` verlangen - insbesondere die CRC32-Pruefsumme, sonst
|
||||
* halten beide das Archiv fuer beschaedigt.
|
||||
*/
|
||||
export function writeZip(files: Array<{ name: string; data: Buffer }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const name = Buffer.from(f.name.replace(/\\/g, '/'), 'utf8');
|
||||
const packed = zlib.deflateRawSync(f.data, { level: 9 });
|
||||
// Groesser als das Original kommt bei schon komprimierten Dateien vor -
|
||||
// dann unveraendert ablegen.
|
||||
const useDeflate = packed.length < f.data.length;
|
||||
const body = useDeflate ? packed : f.data;
|
||||
const crc = zlib.crc32(f.data);
|
||||
|
||||
const local = Buffer.alloc(30 + name.length);
|
||||
local.writeUInt32LE(LOCAL, 0);
|
||||
local.writeUInt16LE(20, 4); // benoetigte Version: 2.0
|
||||
local.writeUInt16LE(useDeflate ? 8 : 0, 8);
|
||||
local.writeUInt32LE(crc, 14);
|
||||
local.writeUInt32LE(body.length, 18);
|
||||
local.writeUInt32LE(f.data.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
name.copy(local, 30);
|
||||
locals.push(local, body);
|
||||
|
||||
const central = Buffer.alloc(46 + name.length);
|
||||
central.writeUInt32LE(CENTRAL, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(useDeflate ? 8 : 0, 10);
|
||||
central.writeUInt32LE(crc, 16);
|
||||
central.writeUInt32LE(body.length, 20);
|
||||
central.writeUInt32LE(f.data.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
name.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += local.length + body.length;
|
||||
}
|
||||
|
||||
const dir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(EOCD, 0);
|
||||
eocd.writeUInt16LE(files.length, 8);
|
||||
eocd.writeUInt16LE(files.length, 10);
|
||||
eocd.writeUInt32LE(dir.length, 12);
|
||||
eocd.writeUInt32LE(offset, 16);
|
||||
|
||||
const out = Buffer.concat([...locals, dir, eocd]);
|
||||
if (out.length > 0xffffffff) throw new Error('Ueber 4 GB braeuchte ZIP64.');
|
||||
return out;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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, '\\}');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
// OBS laeuft auf dem Host und meldet Windows-Pfade; der Container sieht den
|
||||
// Wiedergabepuffer-Ordner unter /replay. Diese Uebersetzung ist die Stelle,
|
||||
// an der die Clip-Pipeline am ehesten scheitert - deshalb eigene Datei mit
|
||||
// eigenen Tests. Siehe docs/07-mvp-plan.md, Scheibe S2.
|
||||
// OBS meldet den Pfad der gespeicherten Pufferdatei. Nativ ist das derselbe
|
||||
// Pfad, den auch die Anwendung sieht - es gibt nichts zu uebersetzen.
|
||||
//
|
||||
// Solange das Toolkit im Container lief, war das anders: OBS meldete
|
||||
// `C:\Users\...`, drinnen hiess derselbe Ordner `/replay`. Diese Uebersetzung
|
||||
// war die Stelle, an der die Clip-Pipeline am ehesten scheiterte. Sie bleibt
|
||||
// samt Tests erhalten, wird aber nur noch gebraucht, wenn `containerDir`
|
||||
// gesetzt ist. Siehe ADR-0007.
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/** `C:\Users\abdu\Videos\replay\Replay 2026-09-09.mkv` -> `/replay/Replay 2026-09-09.mkv` */
|
||||
/**
|
||||
* Nativ: unveraendert zurueck.
|
||||
* Im Container: `C:\Users\abdu\Videos\replay\Replay.mkv` -> `/replay/Replay.mkv`
|
||||
*/
|
||||
export function mapReplayPath(
|
||||
hostPath: string, hostDir: string | undefined, containerDir: string
|
||||
): string {
|
||||
// Kein Zielverzeichnis heisst: dieselbe Dateisicht wie OBS.
|
||||
if (!containerDir) return hostPath;
|
||||
|
||||
const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const file = norm(hostPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CAPTION_HINTS, judgeSource, overlapPercent } from './scene-report.ts';
|
||||
|
||||
// Die Ausschnitte aus dem vorgegebenen Layout (config/layout.yaml, Profil default)
|
||||
const CAM = { x: 0, y: 0, w: 480, h: 270 };
|
||||
const GAME = { x: 515, y: 0, w: 890, h: 1080 };
|
||||
|
||||
const judge = (box: { x: number; y: number; w: number; h: number }, over: {
|
||||
enabled?: boolean; name?: string; kind?: string; url?: string;
|
||||
} = {}) => judgeSource({
|
||||
box, enabled: over.enabled ?? true, name: over.name ?? 'Quelle',
|
||||
kind: over.kind ?? 'text_gdiplus_v3', cam: CAM, game: GAME,
|
||||
...(over.url === undefined ? {} : { url: over.url }),
|
||||
});
|
||||
|
||||
test('rechnet die Ueberdeckung in Prozent des Ausschnitts', () => {
|
||||
assert.equal(overlapPercent({ x: 0, y: 0, w: 480, h: 270 }, CAM), 100);
|
||||
assert.equal(overlapPercent({ x: 0, y: 0, w: 240, h: 270 }, CAM), 50);
|
||||
assert.equal(overlapPercent({ x: 1405, y: 0, w: 515, h: 1080 }, GAME), 0);
|
||||
});
|
||||
|
||||
test('Quellen rechts der Chatlinie sind ausserhalb', () => {
|
||||
// Genau die Vorgabe aus docs/obs-layout.md: Chat und Alerts ab x=1405
|
||||
const chat = judge({ x: 1405, y: 0, w: 515, h: 1080 }, { name: 'Chat-Overlay' });
|
||||
assert.equal(chat.inShort, false);
|
||||
assert.equal(chat.game, 0);
|
||||
});
|
||||
|
||||
test('ein Untertitel-Overlay im Gameplay-Band faellt auf', () => {
|
||||
// Der Fall aus dem echten Clip: Live-Captions ueber der vollen Bildbreite
|
||||
const u = judge({ x: 0, y: 900, w: 1920, h: 120 }, { name: 'Live Captions' });
|
||||
assert.equal(u.inShort, true);
|
||||
assert.equal(u.suspectCaption, true);
|
||||
assert.ok(u.game > 0);
|
||||
});
|
||||
|
||||
test('dasselbe Overlay unten links ist in Ordnung', () => {
|
||||
// Der empfohlene Ausweichplatz: x 0..515, also links neben dem Band
|
||||
const u = judge({ x: 0, y: 900, w: 515, h: 120 }, { name: 'Live Captions' });
|
||||
assert.equal(u.inShort, false);
|
||||
assert.equal(u.suspectCaption, false, 'ausserhalb ist kein Problem, sondern die Loesung');
|
||||
});
|
||||
|
||||
test('ausgeblendete Quellen zaehlen nicht', () => {
|
||||
const u = judge({ x: 0, y: 900, w: 1920, h: 120 },
|
||||
{ name: 'Untertitel', enabled: false });
|
||||
assert.equal(u.inShort, false);
|
||||
assert.equal(u.suspectCaption, false);
|
||||
});
|
||||
|
||||
test('erkennt Untertitelquellen auch an Art und Adresse', () => {
|
||||
const box = { x: 600, y: 900, w: 700, h: 100 };
|
||||
assert.equal(judge(box, { name: 'Overlay 3', kind: 'localvocal_filter' }).suspectCaption, true);
|
||||
assert.equal(judge(box, {
|
||||
name: 'Overlay 4', kind: 'browser_source', url: 'https://webcaptioner.com/captioner/view/x',
|
||||
}).suspectCaption, true);
|
||||
assert.equal(judge(box, { name: 'Killfeed', kind: 'image_source' }).suspectCaption, false);
|
||||
});
|
||||
|
||||
test('die Stichwortliste trifft die ueblichen Verdaechtigen', () => {
|
||||
for (const s of ['Live Captions', 'Untertitel', 'Web Captioner', 'obs-localvocal',
|
||||
'Speech to Text', 'closed captions', 'CC Overlay']) {
|
||||
assert.ok(CAPTION_HINTS.test(s), `nicht erkannt: ${s}`);
|
||||
}
|
||||
for (const s of ['Facecam', 'Gameplay', 'Alerts', 'Soccer', 'Discord']) {
|
||||
assert.equal(CAPTION_HINTS.test(s), false, `Fehlalarm: ${s}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Auswertung einer OBS-Szene gegen den 9:16-Ausschnitt.
|
||||
//
|
||||
// Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und
|
||||
// niemand wusste, woher sie kommen. Raten hilft da nicht - sie sind ins
|
||||
// Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt.
|
||||
// Die reine Rechnerei steht hier, damit sie pruefbar ist; das Anzeigen und
|
||||
// das Reden mit OBS bleibt im Entrypoint.
|
||||
|
||||
import type { Rect } from '#core/config.ts';
|
||||
|
||||
export type Box = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/**
|
||||
* Quellenarten und Namen, hinter denen erfahrungsgemaess Untertitel stecken.
|
||||
* Bewusst weit gefasst: ein Fehlalarm kostet einen Blick, eine uebersehene
|
||||
* Quelle kostet jeden Clip.
|
||||
*/
|
||||
export const CAPTION_HINTS =
|
||||
/caption|untertitel|subtitle|transcri|speech|vocal|stt|dictat|closed.?cc|(^|\W)cc(\W|$)/i;
|
||||
|
||||
/** Wie viel Prozent des Ausschnitts die Quelle ueberdeckt. */
|
||||
export function overlapPercent(box: Box, r: Rect): number {
|
||||
const w = Math.max(0, Math.min(box.x + box.w, r.x + r.w) - Math.max(box.x, r.x));
|
||||
const h = Math.max(0, Math.min(box.y + box.h, r.y + r.h) - Math.max(box.y, r.y));
|
||||
const area = r.w * r.h;
|
||||
return area > 0 ? Math.round(((w * h) / area) * 100) : 0;
|
||||
}
|
||||
|
||||
export type SourceVerdict = {
|
||||
cam: number;
|
||||
game: number;
|
||||
/** Steht die Quelle im fertigen Short? */
|
||||
inShort: boolean;
|
||||
/** Sieht sie nach fremden Untertiteln aus? */
|
||||
suspectCaption: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Urteil ueber eine einzelne Quelle.
|
||||
*
|
||||
* Ausgeblendete Quellen sind nie im Bild - egal wo sie liegen. Und eine
|
||||
* Untertitelquelle ausserhalb des Ausschnitts ist kein Problem, sondern genau
|
||||
* das, was docs/obs-layout.md vorschlaegt: im Stream sichtbar lassen, aus dem
|
||||
* Gameplay-Band herausschieben.
|
||||
*/
|
||||
export function judgeSource(args: {
|
||||
box: Box;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
kind: string;
|
||||
url?: string;
|
||||
cam: Rect;
|
||||
game: Rect;
|
||||
}): SourceVerdict {
|
||||
const cam = overlapPercent(args.box, args.cam);
|
||||
const game = overlapPercent(args.box, args.game);
|
||||
const inShort = args.enabled && (cam > 0 || game > 0);
|
||||
return {
|
||||
cam, game, inShort,
|
||||
suspectCaption: inShort &&
|
||||
CAPTION_HINTS.test(`${args.name} ${args.kind} ${args.url ?? ''}`),
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export type SecretStore = {
|
||||
obs?: { password: string };
|
||||
discord?: { goLiveWebhook?: string; recapWebhook?: string };
|
||||
bluesky?: { handle: string; appPassword: string };
|
||||
/** Nur noetig, wenn die Pakete hinter einer Anmeldung liegen. */
|
||||
update?: { token: string };
|
||||
};
|
||||
|
||||
const EMPTY: SecretStore = { apps: {}, tokens: {} };
|
||||
|
||||
Reference in new issue
Block a user