diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 381af1e..0000000 --- a/.dockerignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -**/node_modules -**/dist -.git -data -logs -.env -docs -*.md diff --git a/.gitignore b/.gitignore index 561fd59..fb3d872 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ logs/ Thumbs.db .vscode/ .idea/ + +# Zwischenspeicher des Paketbaus und sein Ergebnis +.cache/ +dist/ diff --git a/README.md b/README.md index d8457ef..f44a500 100644 --- a/README.md +++ b/README.md @@ -45,29 +45,60 @@ Dazu der Dienst, der unter allen anderen liegt: - [`docs/projects/`](docs/projects/) — je ein Spec-Dokument pro Tool - [`docs/decisions/`](docs/decisions/) — Architekturentscheidungen (ADRs) -## Schnellstart +## Auf dem Stream-PC installieren -Alles läuft in Docker — auf dem Rechner werden nur Docker Desktop und OBS -gebraucht, ffmpeg und whisper.cpp stecken im Image. -Siehe [`docs/05-setup.md`](docs/05-setup.md). +Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften +sind mitgeliefert — vorausgesetzt wird nur OBS. Kein Docker, kein WSL2, nichts +zu übersetzen ([ADR-0007](docs/decisions/ADR-0007-native-windows.md)). + +Das Paket herunterladen, entpacken, darin `install.ps1` ausführen. Ohne +Administratorrechte; Programm und Daten landen im Benutzerprofil. ```powershell -Copy-Item config\config.example.yaml config\config.yaml -docker compose build -docker compose run --rm toolkit check-env -docker compose run --rm toolkit fetch-model -docker compose up -d +Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive +.\abdulive\install.ps1 +``` + +> Einen `irm … | iex`-Einzeiler gibt es nicht: `git.zeasy.dev` verlangt +> instanzweit eine Anmeldung und antwortet auf **jede** Adresse mit einer +> Umleitung auf `/user/login`. `iex` führt die Anmeldeseite dann als +> PowerShell aus. Wo die Pakete künftig liegen sollen, ist noch offen — +> siehe [`offene-punkte.md`](docs/offene-punkte.md). + +Danach einmalig: + +```powershell +abdulive fetch-model # Sprachmodell, ca. 465 MB +abdulive check-env # Selbsttest +Start-ScheduledTask -TaskName abdulive ``` Dann **http://localhost:4792/setup** öffnen und die Twitch-Zugangsdaten -eintragen. Sie werden verschlüsselt im Docker-Volume abgelegt, nicht in einer -Datei im Projekt ([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)). +eintragen. Sie werden verschlüsselt abgelegt, nicht in einer Datei im Projekt +([ADR-0005](docs/decisions/ADR-0005-zugangsdaten.md)). + +Ausführlich: [`docs/05-setup.md`](docs/05-setup.md). + +## Paket bauen und ausliefern + +```powershell +node scripts/build-package.ts --version 0.5.0 +``` + +Lädt Node, ffmpeg und whisper.cpp bei ihren Herausgebern, packt alles nach +`dist/` und schreibt `abdulive.json` — das Manifest, das der Updater auf +Abdus Rechner abfragt. Läuft auch in der CI unter Linux. + +Auf dem Stream-PC sieht `abdulive update` täglich nach und legt eine neue +Fassung bereit; eingespielt wird sie beim nächsten Start, nie mitten im +Stream. ## Aktueller Stand -Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut: Container-Toolchain, +Vier von sieben Scheiben des MVP „Clip-Knopf" sind gebaut: Toolchain, Einrichtung im Browser, Twitch-Auth, Datenbank, Chat-Aufnahme, OBS-Anbindung mit -`!clip`, 9:16-Render und Untertitel. 53 Tests grün. +`!clip`, 9:16-Render und Untertitel. Dazu die native Auslieferung mit Installer +und Updater. 79 Tests grün. Offen: die Abnahme der OBS-Anbindung an einem laufenden OBS, dann S5 — Onboarding-Assistent und Sichtungsseite. @@ -77,5 +108,5 @@ Details: [`docs/handoff.md`](docs/handoff.md) ## Tests ```powershell -docker run --rm -v "${PWD}:/app" -w /app node:24-bookworm-slim node --test "packages/**/*.test.ts" "apps/**/*.test.ts" +node --test "packages/**/*.test.ts" "apps/**/*.test.ts" ``` diff --git a/docker/entrypoint.ts b/apps/cli/main.ts similarity index 65% rename from docker/entrypoint.ts rename to apps/cli/main.ts index a21cac7..03ac666 100644 --- a/docker/entrypoint.ts +++ b/apps/cli/main.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node -// Einstiegspunkt des Container-Images. +// Einstiegspunkt der Anwendung. Bis 2026-09 war das der Container- +// Entrypoint; siehe ADR-0007. import { execFile, execFileSync } from 'node:child_process'; import { createWriteStream } from 'node:fs'; @@ -8,16 +9,19 @@ import net from 'node:net'; import path from 'node:path'; import { pipeline } from 'node:stream/promises'; import { loadLayout, paths, type Rect } from '#core/config.ts'; -import { probe, run as runMedia } from '#media/ffmpeg.ts'; +import { binaries, fontsDir } from '#core/platform.ts'; +import { assFilter, probe, run as runMedia } from '#media/ffmpeg.ts'; import { micTrackArgs, thumbnailArgs, verticalArgs, verticalFilter } from '#media/vertical.ts'; import { transcribe } from '#media/whisper.ts'; -import { buildAss, buildSrt, toLines } from '#media/subtitles.ts'; +import { buildAss, buildSrt, lineOptionsFor, toLines } from '#media/subtitles.ts'; import { loadConfig } from '#core/config.ts'; import { openDb } from '#db/index.ts'; import { secretStatus } from '#twitch/secrets.ts'; +import { judgeSource } from '#obs/scene-report.ts'; -const WHISPER = process.env.WHISPER_BIN || 'whisper-cli'; -const OBS_HOST = process.env.OBS_WS_HOST || 'host.docker.internal'; +// Nativ laeuft OBS auf demselben Rechner. `host.docker.internal` war die +// Bruecke aus dem Container heraus und gibt es hier nicht mehr. +const OBS_HOST = process.env.OBS_WS_HOST || '127.0.0.1'; const OBS_PORT = Number(process.env.OBS_WS_PORT || 4455); const MODELS: Record = { @@ -81,7 +85,7 @@ function tcpProbe(host: string, port: number, timeout = 2500): Promise // ---------------------------------------------------------------- check-env async function checkEnv(): Promise { - console.log('\nabdulive-toolkit - Selbsttest der Umgebung'); + console.log('\nabdulive - Selbsttest der Umgebung'); section('Laufzeit'); const major = Number(process.versions.node.split('.')[0]); @@ -96,22 +100,32 @@ async function checkEnv(): Promise { report(typeof WebSocket !== 'undefined' ? 'ok' : 'bad', 'WebSocket (eingebaut)'); section('Medienwerkzeuge'); + // Die Programme liegen neben der Anwendung, nicht im PATH. + const nichtGefunden = (bin: string) => + bin.includes('/') || bin.includes('\\') + ? `nicht startbar: ${bin}` + : 'nicht gefunden - weder im mitgelieferten bin-Ordner noch im PATH'; try { - const v = run('ffmpeg', ['-version']).split('\n')[0] ?? ''; + const v = run(binaries.ffmpeg, ['-version']).split('\n')[0] ?? ''; report('ok', 'ffmpeg', v.replace('ffmpeg version ', '').slice(0, 40)); - const cfg = run('ffmpeg', ['-hide_banner', '-buildconf']); + const cfg = run(binaries.ffmpeg, ['-hide_banner', '-buildconf']); report(cfg.includes('--enable-libx264') ? 'ok' : 'bad', ' libx264 (H.264-Encoder)'); report(cfg.includes('--enable-libass') ? 'ok' : 'bad', ' libass (Untertitel einbrennen)'); - } catch { report('bad', 'ffmpeg', 'nicht im PATH'); } - try { run('ffprobe', ['-version']); report('ok', 'ffprobe'); } - catch { report('bad', 'ffprobe', 'nicht im PATH'); } + // Ohne die mitgelieferten Schriften setzt libass wortlos eine + // Ersatzschrift ein - der Untertitel sieht dann anders aus als gedacht. + const fonts = fontsDir(); + report(fonts ? 'ok' : 'bad', ' Schriften', fonts ?? 'kein fonts-Ordner gefunden'); + } catch { report('bad', 'ffmpeg', nichtGefunden(binaries.ffmpeg)); } + try { run(binaries.ffprobe, ['-version']); report('ok', 'ffprobe'); } + catch { report('bad', 'ffprobe', nichtGefunden(binaries.ffprobe)); } try { - run(WHISPER, ['--help']); - report('ok', 'whisper-cli', WHISPER); + run(binaries.whisper, ['--help']); + report('ok', 'whisper-cli', binaries.whisper); } catch (err) { const text = `${(err as { stdout?: string }).stdout ?? ''}${(err as { stderr?: string }).stderr ?? ''}`; - report(text.includes('whisper') || text.includes('usage') ? 'ok' : 'bad', 'whisper-cli', - text.includes('whisper') ? WHISPER : 'nicht lauffaehig'); + const laeuft = text.includes('whisper') || text.includes('usage'); + report(laeuft ? 'ok' : 'bad', 'whisper-cli', + laeuft ? binaries.whisper : nichtGefunden(binaries.whisper)); } section('Sprachmodell'); @@ -127,10 +141,10 @@ async function checkEnv(): Promise { section('Verzeichnisse'); for (const [label, dir] of [ - ['Datenbank (Volume)', paths.db], - ['Arbeitsverzeichnis (Volume)', paths.work], - ['Sprachmodelle (Volume)', paths.models], - ['Clips (Bind-Mount)', paths.clips], + ['Datenbank', paths.db], + ['Arbeitsverzeichnis', paths.work], + ['Sprachmodelle', paths.models], + ['Clips', paths.clips], ] as const) { report(await canWrite(dir) ? 'ok' : 'bad', label, `${dir} beschreibbar`); } @@ -141,7 +155,7 @@ async function checkEnv(): Promise { try { const n = (await fs.readdir(dir)).length; report('ok', label, `${dir} lesbar, ${n} Eintraege`); - } catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar - Mount gesetzt?`); } + } catch { report(hard ? 'bad' : 'warn', label, `${dir} nicht lesbar`); } } section('Datenbank'); @@ -355,12 +369,14 @@ async function renderOne(input?: string, profileName = 'default'): Promise console.log(' Transkribiere ...'); const words = await transcribe(wav, { - binary: process.env['WHISPER_BIN'] ?? 'whisper-cli', + binary: binaries.whisper, model, language: cfg.clipper.whisper.language, threads: cfg.clipper.whisper.threads, }); - const lines = toLines(words); + // Zeilenlaenge aus der Zielbreite ableiten, nicht raten - sonst laeuft + // der Text seitlich aus dem Bild (siehe subtitles.ts, maxUnitsFor). + const lines = toLines(words, lineOptionsFor(1080)); const ass = path.join(outDir, 'subs.ass'); await fs.writeFile(ass, buildAss(lines, { width: 1080, height: 1920 })); await fs.writeFile(path.join(outDir, 'subs.srt'), buildSrt(lines)); @@ -370,7 +386,7 @@ async function renderOne(input?: string, profileName = 'default'): Promise finalFile = path.join(outDir, 'final.mp4'); await runMedia('ffmpeg', [ '-y', '-hide_banner', '-loglevel', 'error', '-i', vertical, - '-vf', `ass=${ass}`, '-c:a', 'copy', + '-vf', assFilter(ass, fontsDir()), '-c:a', 'copy', '-c:v', cfg.clipper.ffmpeg.encoder, '-preset', cfg.clipper.ffmpeg.preset, '-crf', String(cfg.clipper.ffmpeg.crf), '-pix_fmt', 'yuv420p', '-movflags', '+faststart', finalFile, @@ -388,17 +404,172 @@ async function renderOne(input?: string, profileName = 'default'): Promise `Rechenzeit ${((Date.now() - started) / 1000).toFixed(1)} s`); } +// ---------------------------------------------------------------- sources + +/** + * Listet auf, was OBS in der laufenden Szene zusammensetzt - und sagt zu jeder + * Quelle, ob sie im 9:16-Ausschnitt landet. + * + * Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und + * niemand wusste, woher sie kommen. Das ist auch nicht zu erraten - sie sind + * ins Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt. + * Genau das macht dieser Befehl. + */ + +type SceneItemTransform = { + positionX: number; positionY: number; + width: number; height: number; + cropLeft: number; cropRight: number; cropTop: number; cropBottom: number; +}; + +type SceneItem = { + sceneItemId: number; + sourceName: string; + inputKind: string | null; + isGroup: boolean | null; + sceneItemEnabled: boolean; + sceneItemTransform: SceneItemTransform; +}; + +async function listSources(profileName = 'default'): Promise { + const cfg = loadConfig(); + const layout = loadLayout(); + const prof = layout.profiles?.[profileName]; + if (!prof) throw new Error(`Profil "${profileName}" steht nicht in config/layout.yaml`); + + const { createObs } = await import('#obs/client.ts'); + const { readSecrets } = await import('#twitch/secrets.ts'); + + const abort = new AbortController(); + let ready: (() => void) | null = null; + const identified = new Promise((res) => { ready = res; }); + + const obs = createObs(cfg.obs.url, () => readSecrets().obs?.password, { + onIdentified: () => ready?.(), + }); + + const running = obs.run(abort.signal); + running.catch(() => { /* Fehler kommt ueber das Rennen unten */ }); + + // Der Zeitgeber muss wieder weg. Bleibt er stehen, haengt der Befehl nach + // dem Bericht noch bis zu 15 s im Container, obwohl er fertig ist. + let ticker: NodeJS.Timeout | undefined; + const timeout = new Promise((_, rej) => { + ticker = setTimeout( + () => rej(new Error(`OBS antwortet nicht unter ${cfg.obs.url}. Laeuft OBS, und ist ` + + 'Werkzeuge -> WebSocket-Server-Einstellungen -> Server aktivieren gesetzt?')), 15_000); + }); + + try { + await Promise.race([identified, running, timeout]); + + const { currentProgramSceneName: scene } = + await obs.request<{ currentProgramSceneName: string }>('GetCurrentProgramScene'); + + console.log(`\nSzene: ${scene}`); + console.log(`Layoutprofil: ${profileName} (Cam ${rectStr(prof.cam.crop)}, ` + + `Gameplay ${rectStr(prof.game.crop)})\n`); + + const rows: string[] = []; + const verdaechtig: string[] = []; + + // Von hinten nach vorne: OBS liefert die oberste Quelle zuerst, im Bild + // liegt sie aber oben drauf. Umgedreht liest es sich wie der Stapel. + const walk = async (name: string, tiefe: number): Promise => { + const req = tiefe === 0 ? 'GetSceneItemList' : 'GetGroupSceneItemList'; + const { sceneItems } = await obs.request<{ sceneItems: SceneItem[] }>( + req, { sceneName: name }); + + for (const it of [...sceneItems].reverse()) { + const t = it.sceneItemTransform; + const box = { + x: Math.round(t.positionX), y: Math.round(t.positionY), + w: Math.round(t.width), h: Math.round(t.height), + }; + const kind = it.isGroup ? 'Gruppe' : (it.inputKind ?? 'unbekannt'); + + // Browser-Quellen verraten ueber die Adresse, welcher Dienst dahinter + // steckt - bei fremden Untertiteln ist das meistens die Antwort. + let url = ''; + if (!it.isGroup && it.inputKind === 'browser_source') { + const got = await obs.request<{ inputSettings: { url?: string } }>( + 'GetInputSettings', { inputName: it.sourceName }).catch(() => null); + url = got?.inputSettings?.url ?? ''; + } + + const urteil = judgeSource({ + box, enabled: it.sceneItemEnabled, name: it.sourceName, kind, url, + cam: prof.cam.crop, game: prof.game.crop, + }); + + const einzug = ' '.repeat(tiefe); + const sicht = it.sceneItemEnabled ? '' : `${C.dim} (ausgeblendet)${C.off}`; + const lage = `${String(box.x).padStart(5)},${String(box.y).padStart(4)} ` + + `${String(box.w).padStart(4)}x${String(box.h).padEnd(4)}`; + const treffer = !it.sceneItemEnabled ? `${C.dim}—${C.off}` + : urteil.inShort + ? `${C.warn}im Short${C.off} (Cam ${urteil.cam} %, Gameplay ${urteil.game} %)` + : `${C.ok}ausserhalb${C.off}`; + + rows.push(`${einzug}${it.sourceName.padEnd(30 - tiefe * 2)} ${C.dim}${kind.padEnd(20)}` + + `${C.off}${lage} ${treffer}${sicht}`); + if (url) rows.push(`${einzug} ${C.dim}${url.slice(0, 110)}${C.off}`); + + if (urteil.suspectCaption) { + verdaechtig.push(`${it.sourceName} (${kind})${url ? ` - ${url}` : ''}`); + } + + if (it.isGroup) await walk(it.sourceName, tiefe + 1); + } + }; + + await walk(scene, 0); + + console.log(`${'Quelle'.padEnd(30)} ${'Art'.padEnd(20)}${'x,y'.padStart(10)} ` + + `${'Groesse'.padEnd(10)} Im 9:16-Ausschnitt?`); + console.log('-'.repeat(100)); + for (const r of rows) console.log(r); + + console.log('\nAlles, was mit "im Short" markiert ist, landet im fertigen Clip.'); + if (verdaechtig.length) { + console.log(`\n${C.warn}Sieht nach fremden Untertiteln aus:${C.off}`); + for (const v of verdaechtig) console.log(` - ${v}`); + console.log('\nDiese Quellen aus dem Gameplay-Band schieben (oder ausblenden) -\n' + + 'das Toolkit macht eigene Untertitel. Plaetze: docs/obs-layout.md'); + } else { + console.log('Nichts erkannt, was nach Untertiteln aussieht. Steht trotzdem Text im\n' + + 'Clip, kommt er aus dem Spiel selbst (dort abschalten) oder aus einer Quelle,\n' + + 'die anders heisst - dann die Liste oben von oben nach unten durchgehen.'); + } + } finally { + clearTimeout(ticker); + abort.abort(); + // Klemmt OBS, bleibt der WebSocket im Schliess-Handshake stehen und der + // Prozess laeuft weiter, obwohl der Bericht schon dasteht. Genau dann + // fuehrt man dieses Werkzeug aber aus. Der Wachhund ist ge-unref-t: im + // Normalfall haelt er nichts auf und schlaegt nie an. + setTimeout(() => { + console.error(`${C.dim}(OBS schliesst die Verbindung nicht - beende trotzdem)${C.off}`); + process.exit(process.exitCode === undefined ? 0 : Number(process.exitCode)); + }, 3000).unref(); + } +} + +const rectStr = (r: Rect) => `x ${r.x}..${r.x + r.w}, y ${r.y}..${r.y + r.h}`; + // ---------------------------------------------------------------- help function help(): void { console.log(` -abdulive-toolkit - Container-Einstiegspunkt +abdulive - Werkzeugkasten fuer Abdus Stream ingest Daemon starten (Chat, Zuschauerzahlen, Einrichtungsseite) check-env Umgebung pruefen db:migrate Migrationen anwenden fetch-model [name] Whisper-Modell laden (Vorgabe: ggml-small.bin) calibrate [datei] [profil] Facecam-Ausschnitt pruefen, Vorschau rendern + sources [profil] OBS-Quellen auflisten: was landet im 9:16-Ausschnitt? + update [--force] Nach einer neuen Fassung sehen und sie bereitlegen render [profil] Rohdatei nach 9:16 rendern (zum Nachrendern) help Diese Uebersicht @@ -415,7 +586,7 @@ const [cmd, ...args] = process.argv.slice(2); try { switch (cmd) { case 'ingest': { - const { runIngest } = await import('../apps/ingest/src/index.ts'); + const { runIngest } = await import('../ingest/src/index.ts'); await runIngest(); break; } @@ -423,6 +594,12 @@ try { case 'db:migrate': openDb(); console.log('Migrationen sind aktuell.'); break; case 'fetch-model': await fetchModel(args[0]); break; case 'calibrate': await calibrate(args[0], args[1]); break; + case 'sources': await listSources(args[0]); break; + case 'update': { + const { checkForUpdate } = await import('./update.ts'); + await checkForUpdate({ force: args.includes('--force') }); + break; + } case 'render': await renderOne(args[0], args[1]); break; case 'help': case undefined: help(); break; default: diff --git a/apps/cli/update.test.ts b/apps/cli/update.test.ts new file mode 100644 index 0000000..050331f --- /dev/null +++ b/apps/cli/update.test.ts @@ -0,0 +1,44 @@ +// Tests fuer den Updater. Der Anlass fuer `looksLikeLogin` war ein echter +// Fehlschlag: git.zeasy.dev verlangt instanzweit eine Anmeldung und antwortet +// auf jede Adresse mit 303 auf /user/login. `irm ... | iex` folgte der +// Umleitung und versuchte, die Anmeldeseite als PowerShell auszufuehren. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isNewer, looksLikeLogin } from './update.ts'; + +test('vergleicht Versionen als Zahlen, nicht als Text', () => { + // Der Fall, der bei Zeichenkettenvergleich falsch herauskaeme + assert.equal(isNewer('2026.10.1', '2026.9.9'), true); + assert.equal(isNewer('2026.9.9', '2026.10.1'), false); + assert.equal(isNewer('0.5.1', '0.5.0'), true); + assert.equal(isNewer('0.5.0', '0.5.0'), false); + assert.equal(isNewer('0.10.0', '0.9.0'), true); +}); + +test('fehlende Stellen zaehlen als Null', () => { + assert.equal(isNewer('1.1', '1'), true); + assert.equal(isNewer('1', '1.0.0'), false); +}); + +test('erkennt eine Anmeldeseite am Inhaltstyp', () => { + assert.equal(looksLikeLogin('text/html; charset=utf-8', Buffer.from('x')), true); + assert.equal(looksLikeLogin('application/json', Buffer.from('{"version":"1"}')), false); + assert.equal(looksLikeLogin('application/zip', Buffer.from([0x50, 0x4b, 3, 4])), false); +}); + +test('erkennt sie auch, wenn der Inhaltstyp schweigt', () => { + // Genau die Antwort, die git.zeasy.dev geliefert hat + const gitea = Buffer.from( + 'See Other.'); + assert.equal(looksLikeLogin(null, gitea), true); + assert.equal(looksLikeLogin(null, Buffer.from('')), true); +}); + +test('haelt ein echtes Manifest nicht faelschlich fuer HTML', () => { + const manifest = Buffer.from(JSON.stringify({ + version: '0.5.0', datei: 'abdulive-0.5.0-win-x64.zip', groesse: 119_000_000, + sha256: 'a'.repeat(64), + })); + assert.equal(looksLikeLogin(null, manifest), false); +}); diff --git a/apps/cli/update.ts b/apps/cli/update.ts new file mode 100644 index 0000000..7a07664 --- /dev/null +++ b/apps/cli/update.ts @@ -0,0 +1,154 @@ +// Nach einer neuen Fassung sehen und sie bereitlegen. +// +// Bereitlegen, nicht einspielen: Windows sperrt die Dateien laufender +// Programme, und mitten im Stream soll ohnehin nichts ausgetauscht werden. +// Das neue Paket landet unter `pending\`; `launcher.ps1` tauscht es beim +// naechsten Start aus, wenn nichts laeuft. Siehe ADR-0007. +// +// Die Adresse steht in der Konfiguration und hat bewusst KEINE Vorgabe. Ein +// erfundener Standardwert sieht so lange richtig aus, bis jemand ihn aufruft +// und eine Anmeldeseite als Skript ausgefuehrt bekommt. + +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { loadConfig } from '#core/config.ts'; +import { installRoot } from '#core/platform.ts'; +import { readZip } from '#core/zip.ts'; +import { readSecrets } from '#twitch/secrets.ts'; + +type Manifest = { + version: string; + datei: string; + groesse: number; + sha256: string; +}; + +/** + * Versionen vergleichen. `2026.9.9` ist aelter als `2026.10.1` - ein + * Zeichenkettenvergleich saehe das andersherum. + */ +export function isNewer(candidate: string, current: string): boolean { + const parts = (v: string) => v.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p)); + const a = parts(candidate); + const b = parts(current); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i] ?? 0; + const y = b[i] ?? 0; + if (x === y) continue; + if (typeof x === 'number' && typeof y === 'number') return x > y; + return String(x) > String(y); + } + return false; +} + +/** + * Eine Antwort, die wie eine Anmeldeseite aussieht, ist keine Datei. + * + * Genau das ist einmal passiert: `git.zeasy.dev` ist komplett angemeldet-only + * und antwortet auf jede Adresse mit 303 auf `/user/login`. Wer dem folgt, + * bekommt HTML - und `iex` versucht, es als PowerShell auszufuehren. + */ +export function looksLikeLogin(contentType: string | null, body: Buffer): boolean { + if (contentType?.includes('text/html')) return true; + const head = body.subarray(0, 200).toString('utf8').toLowerCase(); + return head.includes(' { + const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null); + if (!raw) return '0'; + return (JSON.parse(raw) as { version?: string }).version ?? '0'; +} + +/** Adresse und - falls hinterlegt - der Zugriffstoken. */ +function quelle(): { url: string; headers: Record } { + const url = (process.env['ABDULIVE_RELEASE_URL'] ?? loadConfig().update?.url ?? '').trim(); + if (!url) { + throw new Error( + 'Keine Update-Adresse hinterlegt.\n\n' + + ' In config.yaml eintragen:\n' + + ' update:\n' + + ' url: "https://.../abdulive"\n\n' + + ' Dort muessen `abdulive.json` und die darin genannte ZIP-Datei liegen.\n' + + ' Gebaut werden beide mit: node scripts/build-package.ts'); + } + const headers: Record = { 'user-agent': 'abdulive-update' }; + const token = process.env['ABDULIVE_UPDATE_TOKEN'] ?? readSecrets().update?.token; + // Gitea und Forgejo erwarten genau diese Form; ein Bearer-Token lehnen sie ab. + if (token) headers['authorization'] = `token ${token}`; + return { url: url.replace(/\/+$/, ''), headers }; +} + +async function hole(url: string, headers: Record): Promise { + // Umleitungen NICHT automatisch folgen: eine Anmeldeseite ist genau das, + // und ihr zu folgen liefert HTML statt einer Fehlermeldung. + const res = await fetch(url, { headers, redirect: 'manual' }); + + if (res.status >= 300 && res.status < 400) { + const ziel = res.headers.get('location') ?? '(ohne Angabe)'; + throw new Error( + `${url}\n antwortet mit ${res.status} und leitet nach ${ziel} um.\n\n` + + (ziel.includes('login') + ? ' Das ist eine Anmeldeseite - die Ablage ist nicht oeffentlich.\n' + + ' Entweder die Pakete an einen Ort ohne Anmeldung legen, oder einen\n' + + ' Lese-Token auf der Einrichtungsseite hinterlegen.' + : ' Umleitungen werden hier absichtlich nicht verfolgt.')); + } + if (!res.ok) throw new Error(`${url}\n antwortet mit HTTP ${res.status}.`); + + const body = Buffer.from(await res.arrayBuffer()); + if (looksLikeLogin(res.headers.get('content-type'), body)) { + throw new Error( + `${url}\n liefert eine HTML-Seite statt einer Datei.\n\n` + + ' Vermutlich eine Anmelde- oder Fehlerseite. Die Adresse im Browser\n' + + ' aufrufen zeigt, was dort wirklich steht.'); + } + return body; +} + +export async function checkForUpdate(opts: { force?: boolean } = {}): Promise { + const { url, headers } = quelle(); + const current = await localVersion(); + console.log(`Quelle: ${url}`); + console.log(`Installiert: ${current}`); + + const manifest = JSON.parse((await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest; + console.log(`Angeboten: ${manifest.version}`); + + if (!opts.force && !isNewer(manifest.version, current)) { + console.log('\nNichts zu tun.'); + return; + } + + const pending = path.join(installRoot, 'pending'); + // Ein Rest von einem abgebrochenen Versuch darf nicht mit eingespielt + // werden - also erst weg damit. + await fs.rm(pending, { recursive: true, force: true }); + + process.stdout.write(`\nLade ${manifest.datei} ... `); + const zip = await hole(`${url}/${manifest.datei}`, headers); + console.log(`${(zip.length / 1e6).toFixed(0)} MB`); + + const sum = crypto.createHash('sha256').update(zip).digest('hex'); + if (sum !== manifest.sha256.toLowerCase()) { + throw new Error( + `Pruefsumme stimmt nicht.\n erwartet ${manifest.sha256}\n bekommen ${sum}\n` + + 'Das Paket wird nicht eingespielt.'); + } + console.log('Pruefsumme stimmt.'); + + let count = 0; + for (const entry of readZip(zip)) { + if (entry.isDirectory) continue; + const target = path.join(pending, entry.name); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, entry.data()); + count++; + } + console.log(`${count} Dateien nach ${pending} gelegt.`); + console.log('\nEingespielt wird beim naechsten Start - waehrend eines Streams'); + console.log('wird nichts ausgetauscht. Sofort uebernehmen:'); + console.log(' Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive'); +} diff --git a/apps/ingest/src/index.ts b/apps/ingest/src/index.ts index 901910d..10b071f 100644 --- a/apps/ingest/src/index.ts +++ b/apps/ingest/src/index.ts @@ -11,6 +11,7 @@ import { sleep } from '#core/retry.ts'; import { openDb, closeDb } from '#db/index.ts'; import { repos } from '#db/repos.ts'; import { createObs } from '#obs/client.ts'; +import { isWindows } from '#core/platform.ts'; import { helix } from '#twitch/helix.ts'; import { createIrc } from '#twitch/irc.ts'; import { readSecrets } from '#twitch/secrets.ts'; @@ -95,7 +96,9 @@ export async function runIngest(): Promise { cooldownUserSeconds: cfg.clipper.cooldownUserSeconds, followersOnly: cfg.clipper.followersOnly, ...(process.env.OBS_REPLAY_DIR ? { replayDirHost: process.env.OBS_REPLAY_DIR } : {}), - replayDirContainer: paths.replay, + // Nativ sieht die Anwendung denselben Pfad wie OBS. Leer heisst: + // nicht uebersetzen (siehe #obs/replay-path.ts). + replayDirContainer: isWindows ? '' : paths.replay, clipsDir: paths.clips, workDir: paths.work, }, diff --git a/assets/fonts/DejaVuSans-Bold.ttf b/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..2cef1aa Binary files /dev/null and b/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/assets/fonts/DejaVuSans.ttf b/assets/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..5789a29 Binary files /dev/null and b/assets/fonts/DejaVuSans.ttf differ diff --git a/assets/fonts/LIZENZ.txt b/assets/fonts/LIZENZ.txt new file mode 100644 index 0000000..b3d93a1 --- /dev/null +++ b/assets/fonts/LIZENZ.txt @@ -0,0 +1,78 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: DejaVu fonts +Upstream-Author: Stepan Roh (original author), + see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list +Source: https://dejavu-fonts.github.io/ + +Files: * +Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. + Bitstream Vera is a trademark of Bitstream, Inc. + DejaVu changes are in public domain. +License: bitstream-vera + Permission is hereby granted, free of charge, to any person obtaining a copy + of the fonts accompanying this license ("Fonts") and associated + documentation files (the "Font Software"), to reproduce and distribute the + Font Software, including without limitation the rights to use, copy, merge, + publish, distribute, and/or sell copies of the Font Software, and to permit + persons to whom the Font Software is furnished to do so, subject to the + following conditions: + . + The above copyright and trademark notices and this permission notice shall + be included in all copies of one or more of the Font Software typefaces. + . + The Font Software may be modified, altered, or added to, and in particular + the designs of glyphs or characters in the Fonts may be modified and + additional glyphs or characters may be added to the Fonts, only if the fonts + are renamed to names not containing either the words "Bitstream" or the word + "Vera". + . + This License becomes null and void to the extent applicable to Fonts or Font + Software that has been modified and is distributed under the "Bitstream + Vera" names. + . + The Font Software may be sold as part of a larger software package but no + copy of one or more of the Font Software typefaces may be sold by itself. + . + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME + FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING + ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF + THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE + FONT SOFTWARE. + . + Except as contained in this notice, the names of Gnome, the Gnome + Foundation, and Bitstream Inc., shall not be used in advertising or + otherwise to promote the sale, use or other dealings in this Font Software + without prior written authorization from the Gnome Foundation or Bitstream + Inc., respectively. For further information, contact: fonts at gnome dot + org. + +Files: debian/* +Copyright: (C) 2005-2006 Peter Cernak + (C) 2006-2011 Davide Viti + (C) 2011-2013 Christian Perrier + (C) 2013 Fabian Greffrath +License: GPL-2+ + This program is free software; you can redistribute it + and/or modify it under the terms of the GNU General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later + version. + . + This program is distributed in the hope that it will be + useful, but WITHOUT ANY WARRANTY; without even the implied + warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + PURPOSE. See the GNU General Public License for more + details. + . + You should have received a copy of the GNU General Public + License along with this package; if not, write to the Free + Software Foundation, Inc., 51 Franklin St, Fifth Floor, + Boston, MA 02110-1301 USA + . + On Debian systems, the full text of the GNU General Public + License version 2 can be found in the file + /usr/share/common-licenses/GPL-2'. diff --git a/config/config.example.yaml b/config/config.example.yaml index 5e651d0..cd7999c 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -22,6 +22,15 @@ ingest: armed: false # erst nach Kalibrierung auf true laughEmotes: [KEKW, LUL, OMEGALUL, PogChamp, Pog] +# Woher `abdulive update` neue Fassungen holt. Erwartet wird dort +# `abdulive.json` und die darin genannte ZIP-Datei. +# +# Leer = keine automatischen Updates. Liegt die Adresse hinter einer +# Anmeldung (privates Gitea), gehoert der Zugriffstoken NICHT hierher, +# sondern auf die Einrichtungsseite - siehe ADR-0005. +update: + url: "" + obs: url: ws://127.0.0.1:4455 replayBufferSeconds: 90 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 971ce55..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Stand: Scheiben S0 und S1 aus docs/07-mvp-plan.md. -# -# Nach `docker compose up -d` laeuft der Ingest-Daemon und die Einrichtung -# ist unter http://localhost:4792/setup erreichbar. Zugangsdaten werden dort -# eingegeben, nicht hier - siehe docs/decisions/ADR-0005-zugangsdaten.md. -# -# OBS_REPLAY_DIR in .env auf den Wiedergabepuffer-Ordner setzen, z. B. -# OBS_REPLAY_DIR=C:/Users/abdu/Videos/replay - -services: - toolkit: - build: - context: . - dockerfile: docker/Dockerfile - image: abdulive/toolkit:dev - container_name: abdulive-toolkit - env_file: - - path: .env - required: false - command: ["ingest"] - - # OBS laeuft auf dem Host, nicht im Container - extra_hosts: - - "host.docker.internal:host-gateway" - - ports: - # Ein Server fuer Einrichtung, OAuth-Rueckruf, Sichtungsseite und - # Dashboard - siehe docs/decisions/ADR-0005-zugangsdaten.md - - "127.0.0.1:4792:4792" - - "127.0.0.1:4791:4791" # Overlay-Server (ab M3) - - volumes: - # Konfiguration: nur lesend - - ./config:/app/config:ro - # OBS-Wiedergabepuffer: nur lesend, wird zum Verarbeiten einmal kopiert - - ${OBS_REPLAY_DIR:-./data/replay}:/replay:ro - # Fertige Clips landen wieder auf der Windows-Seite - - ./data/clips:/app/data/clips - # DB, Modelle und Arbeitsdateien in benannten Volumes - NICHT als - # Bind-Mount, siehe docs/decisions/ADR-0004-docker.md - - abdulive-db:/db - - abdulive-models:/models - - abdulive-work:/work - - # Damit das Encoding dem Stream nicht die CPU wegnimmt - cpus: "3.0" - mem_limit: 3g - - healthcheck: - test: ["CMD", "curl", "-fsS", "http://127.0.0.1:4792/health"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 20s - -volumes: - abdulive-db: - abdulive-models: - abdulive-work: diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 8bc6ecd..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# Scheiben S0 und S1 aus docs/07-mvp-plan.md. -# -# Das Projekt hat KEINE npm-Abhaengigkeiten: Node 24 bringt SQLite -# (node:sqlite), WebSocket und fetch mit und fuehrt TypeScript direkt aus -# (Type Stripping). Deshalb gibt es weder eine deps-Stufe noch einen -# Build-Schritt. Siehe ADR-0001 und ADR-0004. - -# ---------- whisper.cpp bauen ---------- -FROM debian:bookworm-slim AS whisper -ARG WHISPER_VERSION=v1.7.4 -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake git ca-certificates \ - && rm -rf /var/lib/apt/lists/* -RUN git clone --depth 1 --branch ${WHISPER_VERSION} \ - https://github.com/ggerganov/whisper.cpp /src -WORKDIR /src -RUN cmake -B build -DCMAKE_BUILD_TYPE=Release -DWHISPER_BUILD_TESTS=OFF \ - -DWHISPER_BUILD_EXAMPLES=ON \ - && cmake --build build --config Release -j"$(nproc)" \ - && mkdir -p /out \ - && cp build/bin/whisper-cli /out/ \ - && find build -name 'libwhisper.so*' -o -name 'libggml*.so' | xargs -r cp -P -t /out/ - -# ---------- Laufzeit ---------- -FROM node:24-bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg fonts-dejavu-core ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=whisper /out/ /usr/local/lib/whisper/ -RUN ln -s /usr/local/lib/whisper/whisper-cli /usr/local/bin/whisper-cli \ - && echo /usr/local/lib/whisper > /etc/ld.so.conf.d/whisper.conf \ - && ldconfig - -WORKDIR /app -# package.json zuerst: sie enthaelt die "imports"-Map, ueber die #core/... aufgeloest wird -COPY package.json ./ -COPY packages/ ./packages/ -COPY apps/ ./apps/ -COPY docker/ ./docker/ - -# Datenbank und Sprachmodelle liegen in benannten Volumes, nicht im -# Bind-Mount - siehe docs/decisions/ADR-0004-docker.md -ENV WHISPER_BIN=/usr/local/bin/whisper-cli \ - MODEL_DIR=/models \ - DB_DIR=/db \ - WORK_DIR=/work \ - CLIPS_DIR=/app/data/clips \ - REPLAY_DIR=/replay \ - CONFIG_DIR=/app/config \ - NODE_ENV=production \ - NODE_OPTIONS=--disable-warning=ExperimentalWarning \ - BIND_HOST=0.0.0.0 - -EXPOSE 4790 4791 4792 - -ENTRYPOINT ["node", "/app/docker/entrypoint.ts"] -CMD ["help"] diff --git a/docs/02-twitch-integration.md b/docs/02-twitch-integration.md index 64dfda1..c0bc2d7 100644 --- a/docs/02-twitch-integration.md +++ b/docs/02-twitch-integration.md @@ -37,7 +37,8 @@ Einrichtungs-Assistenten unter `http://localhost:4792/setup`, **nicht** in einer | `bits:read` | Bits-Events | Broadcaster | Der Autorisierungs-Flow läuft über den Einrichtungs-Assistenten: -`docker compose up -d`, dann `http://localhost:4792/setup` öffnen und je einmal +`Start-ScheduledTask -TaskName abdulive`, dann `http://localhost:4792/setup` +öffnen und je einmal für Broadcaster- und Bot-Konto auf „Mit Twitch verbinden" klicken. Die Redirect-URI, die in beiden Twitch-Apps hinterlegt sein muss, lautet `http://localhost:4792/callback`. diff --git a/docs/05-setup.md b/docs/05-setup.md index d1c5ca3..c380d43 100644 --- a/docs/05-setup.md +++ b/docs/05-setup.md @@ -1,34 +1,51 @@ # Setup -Alles läuft in Containern. Auf dem Rechner selbst wird nur Docker Desktop und -OBS gebraucht — ffmpeg, whisper.cpp, Node und pnpm stecken im Image. -Begründung: [ADR-0004](decisions/ADR-0004-docker.md). +Eine native Windows-Anwendung. Node, ffmpeg, whisper.cpp und die Schriften +sind mitgeliefert — vorausgesetzt wird nur OBS. +Begruendung: [ADR-0007](decisions/ADR-0007-native-windows.md). ## Voraussetzungen -| Was | Version | Wo | +| Was | Version | Anmerkung | |---|---|---| -| Docker Desktop | aktuell, WSL2-Backend | Host | -| OBS Studio | 30+ mit obs-websocket v5 | Host | -| Git | beliebig | Host | +| Windows | 10 oder 11, 64 Bit | | +| OBS Studio | 30+ mit obs-websocket v5 | derselbe Rechner | -Alles Weitere (Node 22, pnpm, ffmpeg mit libx264/libass, whisper.cpp, -Sprachmodell) liefert das Image. +Mehr nicht. Kein Docker, kein WSL2, kein Compiler, kein Git. -## WSL2 begrenzen +## Installieren -Docker Desktop startet eine VM, die sich ohne Grenze so viel RAM nimmt, wie sie -kriegen kann. Auf einem Stream-PC ist das nicht egal. `%USERPROFILE%\.wslconfig` -anlegen: +Das Paket herunterladen, entpacken, darin `install.ps1` ausfuehren: -```ini -[wsl2] -memory=4GB -processors=4 -swap=2GB +```powershell +Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive +.\abdulive\install.ps1 ``` -Danach `wsl --shutdown` und Docker Desktop neu starten. +Liegt das Paket an einer Adresse **ohne** Anmeldung, holt der Installer es +selbst: + +```powershell +.\install.ps1 -Quelle https://.../abdulive +``` + +> Ein `irm … | iex`-Einzeiler funktioniert mit `git.zeasy.dev` nicht. Die +> Instanz verlangt fuer alles eine Anmeldung und antwortet mit einer Umleitung +> auf `/user/login`; `iex` fuehrt dann die Anmeldeseite als PowerShell aus. +> Wo die Pakete liegen sollen, ist noch zu entscheiden. + +Der Installer laeuft **ohne Administratorrechte**: + +| Was | Wohin | +|---|---| +| Programm | `%LOCALAPPDATA%\Programs\abdulive` | +| Daten, Datenbank, Clips | `%LOCALAPPDATA%\abdulive` | +| Autostart | Aufgabe `abdulive`, bei der Anmeldung | +| Update-Pruefung | Aufgabe `abdulive-update`, taeglich 05:00 | + +Beim ersten Start warnt Windows SmartScreen vor einem unbekannten Herausgeber — +das Paket ist nicht signiert. Einmal auf *Weitere Informationen* → *Trotzdem +ausfuehren*. ## Twitch-Apps anlegen @@ -64,60 +81,73 @@ im Einrichtungs-Assistenten einfügen (siehe unten). Begründung: ## Erste Einrichtung ```powershell -Copy-Item config\config.example.yaml config\config.yaml - -docker compose build -docker compose run --rm toolkit check-env # ffmpeg, whisper, Modell, Volumes, Layout -docker compose run --rm toolkit fetch-model # Sprachmodell, ca. 465 MB -docker compose up -d +abdulive check-env # ffmpeg, whisper, Schriften, Verzeichnisse, Layout +abdulive fetch-model # Sprachmodell, ca. 465 MB +Start-ScheduledTask -TaskName abdulive ``` -Dann **http://localhost:4792/setup** öffnen. Dort werden nacheinander -eingetragen: +Dann **http://localhost:4792/setup** oeffnen: Client-ID und Secret der beiden +Twitch-Apps eintragen und je einmal auf „Mit Twitch verbinden" klicken. -1. Client-ID und Client-Secret der beiden Twitch-Apps -2. „Mit Twitch verbinden" für Broadcaster- und Bot-Konto (OAuth) -3. obs-websocket-Passwort -4. später: Discord-Webhook, Bluesky-Zugang - -Alles landet verschlüsselt im Docker-Volume, nicht in einer Datei im Projekt. - -> **Diese Seite zeigt Zugangsdaten. Nicht öffnen, während gesendet wird.** - -Eine `.env` ist optional und enthält nur noch den Pfad zum Wiedergabepuffer -und das Log-Level (Vorlage: `.env.example`). - -## Facecam einmessen - -Einmalig, sobald eine Replay-Datei vorliegt: +## Layout einmessen ```powershell -docker compose run --rm toolkit calibrate +abdulive calibrate ``` -Legt einen Frame als PNG in `data/clips/_calibration/` ab, fragt die vier -Crop-Werte ab und schreibt sie nach `config/layout.yaml`. +Legt einen Frame als PNG unter `%LOCALAPPDATA%\abdulive\clips\_calibration` +ab und rendert die 9:16-Vorschau dazu. Anleitung: [`obs-layout.md`](obs-layout.md). + +Welche OBS-Quelle im Ausschnitt landet — etwa fremde Untertitel: + +```powershell +abdulive sources +``` ## Betrieb ```powershell -docker compose up -d # startet -docker compose logs -f # Logs -docker compose down # stoppt +Start-ScheduledTask -TaskName abdulive # startet +Stop-ScheduledTask -TaskName abdulive # stoppt +Get-Content "$env:LOCALAPPDATA\abdulive\logs\abdulive.log" -Wait -Tail 40 ``` Sichtungsseite: http://localhost:4792 +## Aktualisieren + +```powershell +abdulive update +``` + +Prueft das Manifest, laedt das Paket, vergleicht die SHA256 und legt es +bereit. Die Adresse steht in `config.yaml` unter `update.url` und hat +**keine Vorgabe** — ohne Eintrag sagt der Befehl das auch. Liegt die Ablage +hinter einer Anmeldung, gehoert ein Lese-Token in den verschluesselten +Speicher (Einrichtungsseite), nicht in die `config.yaml`. **Eingespielt wird es beim naechsten Start** — waehrend eines Streams +wird nichts ausgetauscht. Sofort uebernehmen: + +```powershell +Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive +``` + +Die Aufgabe `abdulive-update` macht die Pruefung taeglich um 05:00 von selbst. + ## Backup ```powershell .\scripts\backup.ps1 ``` -Kopiert die Datenbank per `VACUUM INTO` aus dem Volume `abdulive-db` nach -`data/db/backup/` und hält 30 Generationen. Die DB liegt bewusst in einem -Docker-Volume und nicht in einem Windows-Ordner — Dateisperren über die -WSL2-Brücke sind für SQLite nicht verlässlich. +Kopiert die Datenbank per `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db` +nach `backup\` und haelt 30 Generationen. `VACUUM INTO` und nicht einfach +kopieren: die Datei wird im laufenden Betrieb gesichert, und ein Kopieren +waehrend eines Schreibvorgangs ergaebe eine unbrauchbare Sicherung. + +Die Datenbank liegt seit [ADR-0007](decisions/ADR-0007-native-windows.md) +in einem gewoehnlichen Windows-Ordner. Der Grund fuer das fruehere +Docker-Volume - unzuverlaessige Dateisperren ueber die WSL2-Bruecke - ist +mit dem Container weggefallen. ## Was nicht ins Repo gehört diff --git a/docs/07-mvp-plan.md b/docs/07-mvp-plan.md index 7dc60e3..60c1236 100644 --- a/docs/07-mvp-plan.md +++ b/docs/07-mvp-plan.md @@ -113,10 +113,10 @@ Scheibe gibt es etwas, das man anschauen kann. ### S0 — Container und Toolchain · ~1 Tag `docker/Dockerfile` (Node 22, ffmpeg mit libx264 + libass, whisper.cpp), -`docker-compose.yml` mit Volumes und Ports, Modell-Download beim ersten Start +Paketbau, Installer und Updater, Modell-Download bei der Einrichtung in ein eigenes Volume, `scripts/check-env` als Selbsttest im Container. -**Fertig, wenn:** `docker compose run --rm toolkit check-env` meldet ffmpeg, +**Fertig, wenn:** `abdulive check-env` meldet ffmpeg, whisper.cpp, Modell und die Schreibrechte auf allen Volumes als OK. ### S1 — Einrichtung, Auth und Datenaufnahme · ~3 Tage @@ -184,7 +184,7 @@ kopiert und dort verarbeitet — genau das, was [ADR-0004](decisions/ADR-0004-do Punkt 2 verbietet. Jetzt liegt die Rohdatei im Arbeits-Volume, und auf die Windows-Seite kommt nur das fertige, kleine Ergebnis. -Neu dazu: `docker compose run --rm toolkit render ` rendert eine Rohdatei +Neu dazu: `abdulive render ` rendert eine Rohdatei von Hand — gedacht zum Nachrendern, wenn sich das Layout ändert. ### S4 — Untertitel · ~1,5 Tage @@ -301,11 +301,11 @@ Ein echter Stream, ohne Eingriff von außen: | whisper.cpp versteht Spielgeräusche statt Sprache | ~~mittel~~ **gelöst** | OBS nimmt getrennte Spuren auf; transkribiert wird nur das Mikrofon (Spur 2). | | Twitch ändert die Ableitung der Clip-MP4-URL | niedrig | Betrifft nur den Zweitweg; die Rohdatei kommt aus OBS. Gekapselt in einer Datei. | | Replay-Buffer-Pfad-Abbildung Windows ↔ Container | mittel | In S2 als Erstes lösen, mit einem Testlauf vor allem anderen | -| Docker Desktop belastet den Stream-PC | mittel | `.wslconfig` mit Speicher- und CPU-Grenze; wenn es stört, ist ein Betrieb ohne Container jederzeit möglich, weil nur ffmpeg und whisper.cpp fehlen | +| ~~Docker Desktop belastet den Stream-PC~~ | — | **eingetreten und geloest**: seit [ADR-0007](decisions/ADR-0007-native-windows.md) laeuft alles nativ, die WSL2-VM und ihre 1-2 GB entfallen | ## Was Abdu vorher tun muss -1. Docker Desktop installieren, WSL2-Backend aktivieren +1. `install.ps1` ausfuehren (kein Docker, keine Administratorrechte) 2. Zwei Twitch-Apps anlegen, Redirect-URI `http://localhost:4792/callback` (siehe [`05-setup.md`](05-setup.md)). Client-ID und Secret **nicht** irgendwo eintragen — die kommen in den Einrichtungs-Assistenten. diff --git a/docs/decisions/ADR-0004-docker.md b/docs/decisions/ADR-0004-docker.md index ec53911..4476d5d 100644 --- a/docs/decisions/ADR-0004-docker.md +++ b/docs/decisions/ADR-0004-docker.md @@ -1,6 +1,12 @@ # ADR-0004: Betrieb in Docker -**Status:** akzeptiert · **Datum:** 2026-09-09 +**Status:** ~~akzeptiert~~ **ersetzt durch [ADR-0007](ADR-0007-native-windows.md)** · **Datum:** 2026-09-09 + +> Diese Entscheidung gilt nicht mehr. Das Toolkit laeuft nativ unter Windows. +> Die Analyse unten bleibt lesenswert - die vier Fallen, die sie beschreibt, +> waren real, und zwei davon (Pfaduebersetzung, Kopieren ueber die +> WSL2-Bruecke) sind mit dem Container weggefallen statt geloest worden zu +> sein. Warum gewechselt wurde, steht in ADR-0007. ## Kontext diff --git a/docs/decisions/ADR-0007-native-windows.md b/docs/decisions/ADR-0007-native-windows.md new file mode 100644 index 0000000..acaf270 --- /dev/null +++ b/docs/decisions/ADR-0007-native-windows.md @@ -0,0 +1,134 @@ +# ADR-0007: Native Windows-Anwendung statt Container + +**Status:** akzeptiert · **Datum:** 2026-09-09 · **ersetzt:** [ADR-0004](ADR-0004-docker.md) + +## Kontext + +ADR-0004 hat den Betrieb in Docker festgelegt und dabei zwei Dinge versprochen: +„Die einzige Voraussetzung auf dem Stream-PC ist Docker Desktop und OBS" und +„Updates sind ein `docker compose pull && up -d`". + +Das zweite Versprechen wurde nie eingelöst. Es gab keine CI, kein +veröffentlichtes Image, und `image: abdulive/toolkit:dev` zeigte auf nichts. +Wer das Toolkit installieren wollte, musste `docker compose build` ausführen — +und damit **whisper.cpp aus dem Quelltext übersetzen**, gut zehn Minuten, mit +allen Fehlermöglichkeiten eines Compilers. Für Abdu ist das nicht zumutbar. + +Beim Nachdenken über die Reparatur fiel auf, dass die Voraussetzung selbst +teurer ist als der Nutzen. Die Kosten stehen ehrlich in ADR-0004: die WSL2-VM +belegt im Leerlauf ein bis zwei GB RAM, und Intel QSV ist aus WSL2 heraus nicht +verlässlich erreichbar, also bleibt CPU-Encoding — beides auf einem Rechner, +der gleichzeitig streamt. + +Dem steht ein Umstand gegenüber, der die ursprüngliche Begründung untergräbt: +**Das Projekt hat keine npm-Abhängigkeiten**, und Node führt TypeScript direkt +aus (ADR-0001). Es gibt nichts zu bauen. Der Container schrieb also nur noch +drei Programme fest — Node, ffmpeg, whisper.cpp — und dafür braucht es keine +Virtualisierung, sondern einen Ordner. + +## Entscheidung + +Das Toolkit wird als **native Windows-Anwendung** ausgeliefert. Ein Ordner mit +allem darin, ein PowerShell-Installer, ein Updater. Kein Docker, kein WSL2. + +``` +abdulive/ + node.exe portable Node-Laufzeit + bin/ ffmpeg, ffprobe, whisper-cli und ihre DLLs + fonts/ DejaVu Sans (im Repo mitgeliefert, siehe unten) + app/ die TypeScript-Quellen, unverändert + abdulive.cmd Kommandozeile + launcher.ps1 vom Aufgabenplaner gestartet + version.json +``` + +Gebaut wird mit `node scripts/build-package.ts` — lädt die drei Bausteine bei +ihren Herausgebern, packt alles zusammen, schreibt Prüfsummen. Das läuft auch +in der CI unter Linux. + +## Begründung + +- **Abdu baut nichts.** Herunterladen, `install.ps1`, fertig. Kein Docker + Desktop, kein `.wslconfig`, kein Compiler. +- **Ein bis zwei GB RAM** stehen wieder dem Stream zur Verfügung. +- **Hardware-Encoding wird erreichbar.** Der mitgelieferte ffmpeg ist mit + `--enable-libvpl`, `--enable-amf` und `--enable-ffnvcodec` gebaut; QSV und + AMF sind nativ ansprechbar. Genutzt wird das noch nicht — aber es ist jetzt + möglich, und aus WSL2 heraus war es das nicht. +- **Die Pfadübersetzung entfällt.** `mapReplayPath` musste OBS' Windows-Pfade + auf `/replay` abbilden; laut Übergabe die Stelle, an der die Clip-Pipeline + am ehesten scheitert. Nativ sieht die Anwendung denselben Pfad wie OBS. +- **Kein Kopieren über die WSL2-Brücke mehr.** ADR-0004 Punkt 2 verbot das + Verarbeiten auf dem Bind-Mount, weil es „ein Vielfaches der Zeit" kostet. + Diese Klasse von Fehlern gibt es nicht mehr. + +## Konsequenzen — was dabei zu beachten ist + +**1. Programme werden neben der Anwendung gesucht, nicht im PATH.** +`packages/core/src/platform.ts` löst auf: erst die Umgebungsvariable +(`FFMPEG_BIN` …), dann `bin/`, dann der PATH. Die Umgebungsvariable steht +bewusst vorn — eine kaputte Mitlieferung lässt sich so ohne Neuinstallation +überschreiben. + +**2. Schriften müssen mitgeliefert werden.** +Im Container kam DejaVu Sans aus `fonts-dejavu-core` und libass fand sie über +fontconfig. Windows hat DejaVu nicht. Fehlt der Ordner, setzt libass +**wortlos** eine Ersatzschrift ein — gemessen: dieselbe Zeile wird 720 statt +826 Pixel breit, ohne jede Fehlermeldung. Deshalb liegen die Schriftdateien +unter `assets/fonts/` im Repo, und `assFilter` setzt `fontsdir`. + +**3. Windows-Pfade im ffmpeg-Filtergraphen brauchen drei Schritte.** +`C:\Users\…` enthält einen Doppelpunkt (trennt Filteroptionen) und +Rückwärtsschrägstriche (ffmpeg-Escapes). An echtem ffmpeg 8.1 durchprobiert: + +| Form | Ergebnis | +|---|---| +| `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'` | läuft | + +Anführungszeichen allein schützen den Doppelpunkt nicht, das Escape allein +auch nicht. `filterPath()` macht alle drei Schritte, mit Tests. + +**4. `nice` gibt es unter Windows nicht.** +Ersetzt durch `os.setPriority` auf die Kind-PID. Das kann beides, braucht +keinen Hilfsprozess davor, und das Zurückstufen darf jeder Benutzer. + +**5. Daten liegen unter `%LOCALAPPDATA%\abdulive`, nicht neben dem Programm.** +Ein Update tauscht den Programmordner komplett aus; die Datenbank darf dabei +nicht mitgehen. `LOCALAPPDATA` und nicht `ProgramData`, weil die Anwendung als +angemeldeter Benutzer läuft — dieselbe Sitzung wie OBS. Damit braucht weder +Installation noch Update Administratorrechte. Genau daran scheitern +Aktualisierungen sonst auf einem Rechner, an dem gerade gestreamt wird. + +**6. Autostart über den Aufgabenplaner, nicht als Windows-Dienst.** +Ein Dienst liefe unter SYSTEM in einer anderen Sitzung als OBS, bräuchte +Administratorrechte und ein zusätzliches Wrapper-Programm (nssm, WinSW). Die +Aufgabe `abdulive` startet bei der Anmeldung, versteckt, mit Neustart nach +zwei Minuten bei Absturz. + +**7. Updates werden bereitgelegt, nicht sofort eingespielt.** +Windows sperrt die Dateien laufender Programme — `node.exe` lässt sich nicht +ersetzen, solange es läuft. `abdulive update` prüft das Manifest, lädt das +Paket, **prüft die SHA256** und legt es unter `pending/` ab. `launcher.ps1` +tauscht beim nächsten Start aus, bevor irgendetwas startet. Damit gibt es nie +einen halb ausgetauschten Ordner, und mitten im Stream wird nichts angefasst. +Schlägt der Tausch fehl, läuft die alte Fassung weiter. + +**Kosten, ehrlich benannt:** + +- **Die Umgebung ist nicht mehr festgeschrieben.** Der Container garantierte + „kein läuft bei mir". Jetzt garantiert das nur noch der Paketinhalt — Node, + ffmpeg und whisper sind mitgeliefert, aber Windows selbst ist es nicht. +- **Kein Code-Signing.** SmartScreen warnt beim ersten Start vor einem + unbekannten Herausgeber. Ein Zertifikat kostet Geld; bis dahin muss man + einmal auf „Trotzdem ausführen" klicken. Steht in `offene-punkte.md`. +- **Windows-only.** Ein Linux-Paket gibt es nicht mehr. Für ein Werkzeug, das + neben OBS auf einem Windows-Stream-PC läuft, ist das kein Verlust — die + Pfade in `platform.ts` kennen den Linux-Fall aber weiterhin, und die Tests + laufen dort. +- **Drei Bausteine wollen gepflegt werden.** Node, ffmpeg und whisper.cpp + aktualisieren sich nicht von selbst. `build-package.ts` holt jeweils die + aktuelle Ausgabe und schreibt Name und Prüfsumme nach `version.json` — was + drin ist, lässt sich also nachlesen. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 03fd5a0..0d5c707 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -5,9 +5,10 @@ | [0001](ADR-0001-monorepo-typescript.md) | TypeScript-Monorepo statt gemischter Sprachen | akzeptiert | | [0002](ADR-0002-sqlite.md) | SQLite statt Postgres | akzeptiert | | [0003](ADR-0003-eventsub-websocket.md) | EventSub ueber WebSocket, Chat ueber IRC | akzeptiert | -| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | akzeptiert | +| [0004](ADR-0004-docker.md) | Betrieb in Docker, whisper.cpp statt Python | ersetzt durch 0007 | | [0005](ADR-0005-zugangsdaten.md) | Zugangsdaten ueber Einrichtungs-Assistent statt .env | akzeptiert | | [0006](ADR-0006-ein-dach.md) | Ein Dach: lokale Weboberflaeche, lokale Arbeitsprozesse | akzeptiert | +| [0007](ADR-0007-native-windows.md) | Native Windows-Anwendung statt Container | akzeptiert | Neue Entscheidung: Datei `ADR--.md`, Aufbau Kontext / Entscheidung / Begruendung / Konsequenzen. diff --git a/docs/handoff.md b/docs/handoff.md index f5a2ef8..78b0cc8 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -11,7 +11,7 @@ Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist | Scheibe | Stand | Nachweis | |---|---|---| -| S0 Container + Toolchain | fertig | `check-env` ohne harte Fehler | +| S0 Toolchain + Auslieferung | fertig | `check-env` nativ ohne harte Fehler, Paket gebaut und installiert | | S1 Einrichtung, Auth, DB, Chat | fertig | Daemon läuft, Chat verbunden, Secrets verschlüsselt | | S2 OBS + `!clip` | Code fertig, **nicht abgenommen** | braucht laufendes OBS | | S3 9:16-Render | fertig | Durchlauf mit künstlicher Aufnahme | @@ -19,68 +19,76 @@ Auslöser bis zum fertigen Short steht **im Code vollständig** — geprüft ist | S5 Onboarding + Sichtung | offen | — | | S6 Erstchatter-Begrüßung | offen | — | -53 Tests, alle grün. Vollständige Liste dessen, was noch fehlt: +79 Tests, alle grün. Vollständige Liste dessen, was noch fehlt: [`offene-punkte.md`](offene-punkte.md). ## Was jemand wissen muss, bevor er etwas anfasst **Es gibt keine Abhängigkeiten und keinen Build.** Node 24 bringt SQLite, -WebSocket, `fetch` und die Ausführung von TypeScript mit. `node docker/entrypoint.ts` +WebSocket, `fetch` und die Ausführung von TypeScript mit. `node apps/cli/main.ts` läuft direkt. Deshalb gilt: nur *löschbare* TypeScript-Syntax — keine `enum`, keine Parameter-Properties im Konstruktor, keine `namespace`. `erasableSyntaxOnly` in der `tsconfig.json` erzwingt das und hat schon einen echten Fehler gefangen. -**Alles läuft unter einem Dach**, einem Prozess in einem Container. Schwere +**Alles läuft unter einem Dach**, in einem Prozess. Schwere Arbeit (ffmpeg, whisper) läuft als Kindprozess und darf abstürzen. Der Ingest darf nie sterben — was er nicht mitschreibt, ist für immer weg. **Zugangsdaten kommen aus der Weboberfläche**, nicht aus einer Datei. Sie liegen -verschlüsselt in einem Docker-Volume, bewusst außerhalb der Datenbank, weil die -gesichert und dabei auf die Windows-Seite kopiert wird. +verschlüsselt neben der Datenbank, aber bewusst nicht *in* ihr — die Datenbank +wird gesichert und weitergereicht, die Geheimnisse nicht. -**Rohdateien gehören ins Arbeits-Volume**, nicht in den Windows-Bind-Mount. Über -die WSL2-Brücke zu encodieren kostet ein Vielfaches. Dagegen habe ich einmal -selbst verstoßen — der Fehler fiel erst in S3 auf. +**Rohdateien gehören in den Arbeitsordner**, nicht neben den OBS-Puffer. Das +war unter Docker ein Zeitproblem (Encodieren über die WSL2-Brücke kostete ein +Vielfaches) und ist nativ nur noch Ordnung — der Wiedergabepuffer-Ordner +gehört OBS. -Die Begründungen stehen in [`decisions/`](decisions/), sechs ADRs. Wer eine +Die Begründungen stehen in [`decisions/`](decisions/), sieben ADRs. Wer eine davon umwerfen will, sollte sie vorher lesen; jede hat einen Grund, der beim -Bauen aufgetaucht ist. +Bauen aufgetaucht ist. ADR-0004 (Docker) ist durch ADR-0007 ersetzt — die +Analyse darin bleibt trotzdem lesenswert. ## Loslegen -```powershell -git clone https://git.zeasy.dev/ZeasyAlex/abdulive.git -cd abdulive -Copy-Item config\config.example.yaml config\config.yaml +**Auf Abdus Rechner** läuft eine native Windows-Anwendung — kein Docker, kein +WSL2, nichts zu übersetzen ([ADR-0007](decisions/ADR-0007-native-windows.md)): -docker compose build -docker compose run --rm toolkit check-env -docker compose run --rm toolkit fetch-model # ca. 465 MB -docker compose up -d +```powershell +Expand-Archive .\abdulive-0.5.0-win-x64.zip -DestinationPath .\abdulive +.\abdulive\install.ps1 +abdulive fetch-model +abdulive check-env ``` -Dann `http://localhost:4792/setup` öffnen. Ausführlich in -[`05-setup.md`](05-setup.md). +Einen Einzeiler zum Nachladen gibt es nicht — `git.zeasy.dev` ist komplett +angemeldet-only. **Wo die Pakete liegen sollen, ist die naechste offene +Entscheidung**, und ohne sie funktioniert auch `abdulive update` nicht. -Tests: +Ausführlich in [`05-setup.md`](05-setup.md). + +**Zum Entwickeln** reicht ein Checkout und Node 24 oder neuer: ```powershell -docker run --rm -v "${PWD}:/app" -w /app node:24-bookworm-slim ` - node --test "packages/**/*.test.ts" "apps/**/*.test.ts" +node apps/cli/main.ts check-env +node --test "packages/**/*.test.ts" "apps/**/*.test.ts" +node scripts/build-package.ts --version 0.5.0 # baut dist/*.zip ``` Typprüfung (das Einzige, wofür npm gebraucht wird): ```powershell -docker run --rm -v "${PWD}:/app" -w /tmp node:24-bookworm-slim sh -c ` - "npm i -s typescript@5.9 @types/node && cd /app && /tmp/node_modules/.bin/tsc --noEmit --typeRoots /tmp/node_modules/@types" +npm i typescript@5.9 @types/node +npx tsc --noEmit ``` ## Die drei Dinge, die Abdu tun muss -1. **Zwei Twitch-Apps anlegen** (dev.twitch.tv/console/apps), Redirect-URI - `http://localhost:4792/callback`. Client-ID und Secret danach auf der - Einrichtungsseite eintragen — nicht in eine Datei. +1. **Broadcaster-Konto verbinden.** Die beiden Twitch-Apps + (dev.twitch.tv/console/apps, Redirect-URI `http://localhost:4792/callback`) + kann auch jemand anders anlegen — sie hängen nicht am Kanal. Was **nur Abdu** + kann: auf der Einrichtungsseite eingeloggt als er selbst auf „Mit Twitch + verbinden" klicken. Dafür muss die Anwendung auf seinem Rechner schon + laufen; die Seite hört nur auf `127.0.0.1`. 2. **OBS einrichten**: obs-websocket an (Port 4455), Wiedergabepuffer 90 s, Hotkey auf „Wiedergabepuffer speichern", getrennte Audiospuren. 3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md). Die @@ -109,5 +117,10 @@ Die Untertitel sind brauchbar, nicht gut. `ggml-small` verhört sich hörbar Einfärben ist nicht der Stil, der auf Shorts trägt. Sechs konkrete Verbesserungen stehen in [`offene-punkte.md`](offene-punkte.md). -Und: Das Aufräumen im Arbeits-Volume fehlt komplett. Rohdateien sammeln sich +Und: Das Aufräumen im Arbeitsordner fehlt komplett. Rohdateien sammeln sich unbegrenzt an. Das ist die Lücke, die als Erstes weh tun wird. + +Der Updater ist gebaut und in Teilen geprüft — Manifest, Download, Prüfsumme +und Bereitlegen laufen. Der eigentliche Tausch durch `launcher.ps1` ist noch +nie im Feld passiert. Vor dem ersten echten Update einmal künstlich +durchspielen. diff --git a/docs/obs-layout.md b/docs/obs-layout.md index a110f24..1ded35c 100644 --- a/docs/obs-layout.md +++ b/docs/obs-layout.md @@ -52,16 +52,32 @@ schiebt dann nicht sofort einen Streifen Gesicht in das Gameplay-Bild. ## Prüfen ```powershell -docker compose run --rm toolkit calibrate +abdulive calibrate ``` -Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder in -`data/clips/_calibration/` ab: das Einzelbild, dasselbe mit eingezeichneten +Nimmt das neueste Video aus dem Wiedergabepuffer-Ordner und legt drei Bilder +unter `%LOCALAPPDATA%\abdulive\clips\_calibration` ab: das Einzelbild, dasselbe mit eingezeichneten Ausschnitten, und die fertige 9:16-Vorschau. Sitzt in der Vorschau der Kopf mittig und ist im Gameplay-Teil kein Chat zu sehen, stimmt das Layout. ## Text im Bild — hier wird es eng +**Wer malt den Text?** Das ist nicht zu erraten — die Overlays sind ins +Streambild eingebrannt, man muss nachsehen, welche OBS-Quelle sie zeichnet: + +```powershell +abdulive sources +``` + +Der Befehl fragt das laufende OBS und listet jede Quelle der aktuellen Szene +mit Position, Größe und dem Urteil, ob sie im 9:16-Ausschnitt landet. Bei +Browser-Quellen steht die Adresse dabei — daran erkennt man den Dienst +dahinter. Was nach Untertiteln aussieht, wird am Ende noch einmal +zusammengefasst. + +Dafür muss in OBS **Werkzeuge → WebSocket-Server-Einstellungen → Server +aktivieren** gesetzt sein (Port 4455) und das Passwort unter `/setup` liegen. + Bei der Prüfung eines echten Clips lagen **drei Textebenen übereinander**: 1. die Untertitel des Spiels („Genau, ich mach da … sag zu dem Typen") @@ -115,5 +131,5 @@ fest, wie es vor der Vorgabe aussah — damit lassen sich alte Aufnahmen noch korrekt rendern: ```powershell -docker compose run --rm toolkit render bestand-2026-09 +abdulive render bestand-2026-09 ``` diff --git a/docs/offene-punkte.md b/docs/offene-punkte.md index eae348d..d0954c6 100644 --- a/docs/offene-punkte.md +++ b/docs/offene-punkte.md @@ -16,7 +16,7 @@ Erledigtes wandert raus, nicht ins Archiv — die Historie steht in Git. ## Bekannte Lücke im Betrieb -- [ ] **Aufräumen im Arbeits-Volume.** Jede Rohdatei landet unter `/work//` +- [ ] **Aufräumen im Arbeitsordner.** Jede Rohdatei landet unter `work//` und bleibt dort liegen. Bei fünf Clips pro Stream und mehreren hundert MB je Datei läuft das Volume in wenigen Wochen voll. Gebraucht wird eine Aufbewahrungsregel — Vorschlag: Rohdateien der letzten 30 Clips behalten, @@ -47,6 +47,10 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel: - [ ] **Position prüfen.** 340 px Abstand nach unten ist geschätzt, nicht gemessen. An einem echten Handy gegen die TikTok-, Reels- und Shorts-Oberfläche halten. +- [ ] **Seitliche Ränder gegengeprüft**, aber nur rechnerisch gegen eine + gemessene mittlere Zeichenbreite. Bei einer anderen Schrift (siehe + Punkt oben) muss `CHAR_WIDTH_RATIO` in `subtitles.ts` neu eingemessen + werden — sonst bricht die Zeile wieder an der falschen Stelle. - [ ] **Nur transkribieren, wenn Abdu spricht.** Bei getrennten Tonspuren reicht ein Pegelfilter auf der Mikrofonspur, damit aus Stille kein erfundener Text wird. @@ -55,11 +59,47 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel: - [ ] **Live-Captions verschieben.** Sie liegen im Gameplay-Band und sind fest eingebrannt. Zwei Vorschläge stehen in [`obs-layout.md`](obs-layout.md). + Welche OBS-Quelle sie überhaupt zeichnet, sagt + `abdulive sources` — das war vorher unbekannt. - [ ] **Spieluntertitel abschalten.** - [ ] **Bessere Bildquelle für die Facecam** — Plugin `Source Record` oder `Aitum Vertical`. Damit entfällt die 2,25-fache Vergrößerung. Lohnt sich erst, wenn regelmäßig Shorts entstehen. +## Auslieferung + +- [ ] **Wo liegen die Pakete?** Das ist die Entscheidung, die als Naechstes + ansteht — ohne sie funktioniert `abdulive update` gar nicht. + `git.zeasy.dev` verlangt **instanzweit** eine Anmeldung: `/explore`, + die API und jede Rohdatei antworten mit 303 auf `/user/login`. Ein + `irm … | iex`-Einzeiler bekommt dadurch die Anmeldeseite und versucht, + sie als PowerShell auszufuehren. Drei Wege: + *(a)* eine Ablage ohne Anmeldung, etwa `dl.zeasy.software` — am + einfachsten, und das Paket enthaelt ohnehin den vollstaendigen Quelltext + als lesbare `.ts`-Dateien; + *(b)* Gitea-Release plus Lese-Token auf Abdus Rechner — der Token darf + dann aber das ganze private Repo lesen; + *(c)* kein Updater, Alexander spielt neue Fassungen per Fernwartung ein. + Code und Installer sind auf *(a)* und *(b)* vorbereitet: `update.url` in + der `config.yaml`, Token im verschluesselten Speicher. + +- [ ] **Code-Signing.** Das Paket ist nicht signiert, SmartScreen warnt bei + jeder Installation vor einem unbekannten Herausgeber. Ein + OV-Zertifikat kostet rund 200 EUR im Jahr, ein EV-Zertifikat mehr und + braucht Hardware. Solange nur Abdu installiert, reicht ein Klick auf + "Trotzdem ausfuehren" - sobald jemand Drittes das Paket bekommt, nicht + mehr. +- [ ] **CI auf git.zeasy.dev.** `scripts/build-package.ts` laeuft bisher von + Hand. Als Forgejo-Action beim Tag gebaut und als Veroeffentlichung + hochgeladen, findet `abdulive update` sie von allein. +- [ ] **Update noch nie im Feld gelaufen.** Bereitlegen und Pruefsumme sind + getestet, der Tausch durch `launcher.ps1` nur gelesen. Vor dem ersten + echten Update einmal kuenstlich durchspielen. +- [ ] **Hardware-Encoding.** Der mitgelieferte ffmpeg kann QSV, AMF und NVENC + (`--enable-libvpl`, `--enable-amf`, `--enable-ffnvcodec`) - aus WSL2 + heraus ging das nicht. `clipper.ffmpeg.encoder` steht weiter auf + `libx264`. Auf Abdus Rechner messen, ob `h264_qsv` etwas bringt. + ## Nach dem MVP - [ ] **Spitzen-Erkennung scharf schalten.** Sie läuft mit und schreibt jeden @@ -72,11 +112,5 @@ Der Durchlauf am echten Clip war brauchbar, aber nicht gut. Was auffiel: ## Kleinigkeiten -- [ ] `docker compose run` reicht ein TTY durch, dadurch schreibt die - Fortschrittsanzeige `\r`-Zeilen ins Protokoll. Mit `-T` sauber, aber - unschön als Standard. -- [ ] `ldconfig` meldet beim Bauen `libwhisper.so.1 is not a symbolic link`. - Funktioniert, ist aber unsauber. -- [ ] `docs/05-setup.md` nennt ffmpeg 6+; im Image steckt 5.1.9 aus Debian - bookworm. Für unseren Filtergraph irrelevant, die Angabe stimmt trotzdem - nicht. +- [ ] Die Fortschrittsanzeige schreibt `\r`-Zeilen; im Protokoll der Aufgabe + sieht das unschoen aus. Beim Umleiten in eine Datei besser unterdruecken. diff --git a/docs/projects/clip-shorts.md b/docs/projects/clip-shorts.md index 9e8cb36..57222e9 100644 --- a/docs/projects/clip-shorts.md +++ b/docs/projects/clip-shorts.md @@ -95,7 +95,7 @@ ffmpeg -i in.mp4 -filter_complex " Nachmessen und Vorschau rendern: ```powershell -docker compose run --rm toolkit calibrate +abdulive calibrate ``` Das legt drei Bilder in `data/clips/_calibration/` ab: das Einzelbild, dasselbe diff --git a/package.json b/package.json index bb1ec5e..ae6464d 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,9 @@ "#media/*": "./packages/media/src/*" }, "scripts": { - "start": "docker compose up -d", - "stop": "docker compose down", - "status": "docker compose ps", - "logs": "docker compose logs -f", - "check-env": "node docker/entrypoint.ts check-env", - "ingest": "node docker/entrypoint.ts ingest", + "start": "node apps/cli/main.ts ingest", + "check-env": "node apps/cli/main.ts check-env", + "package": "node scripts/build-package.ts", "typecheck": "tsc --noEmit", "test": "node --test \"packages/**/*.test.ts\" \"apps/**/*.test.ts\"" }, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 4115c46..4bfee58 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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, diff --git a/packages/core/src/platform.ts b/packages/core/src/platform.ts new file mode 100644 index 0000000..afab740 --- /dev/null +++ b/packages/core/src/platform.ts @@ -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(); + +/** + * 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'), + }; +} diff --git a/packages/core/src/zip.test.ts b/packages/core/src/zip.test.ts new file mode 100644 index 0000000..a55d2fa --- /dev/null +++ b/packages/core/src/zip.test.ts @@ -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); +}); diff --git a/packages/core/src/zip.ts b/packages/core/src/zip.ts new file mode 100644 index 0000000..b75327e --- /dev/null +++ b/packages/core/src/zip.ts @@ -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; +} diff --git a/packages/media/src/ffmpeg.ts b/packages/media/src/ffmpeg.ts index 2b4e515..abc23fd 100644 --- a/packages/media/src/ffmpeg.ts +++ b/packages/media/src/ffmpeg.ts @@ -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 { - 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[] = []; diff --git a/packages/media/src/filter-path.test.ts b/packages/media/src/filter-path.test.ts new file mode 100644 index 0000000..5eb54b5 --- /dev/null +++ b/packages/media/src/filter-path.test.ts @@ -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'); +}); diff --git a/packages/media/src/subtitles.test.ts b/packages/media/src/subtitles.test.ts index 7bc6105..3e1513b 100644 --- a/packages/media/src/subtitles.test.ts +++ b/packages/media/src/subtitles.test.ts @@ -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', () => { diff --git a/packages/media/src/subtitles.ts b/packages/media/src/subtitles.ts index 098946f..5dc3246 100644 --- a/packages/media/src/subtitles.ts +++ b/packages/media/src/subtitles.ts @@ -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 { + 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, '\\}'); } diff --git a/packages/obs/src/replay-path.ts b/packages/obs/src/replay-path.ts index bd97145..719f1c5 100644 --- a/packages/obs/src/replay-path.ts +++ b/packages/obs/src/replay-path.ts @@ -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); diff --git a/packages/obs/src/scene-report.test.ts b/packages/obs/src/scene-report.test.ts new file mode 100644 index 0000000..0539e51 --- /dev/null +++ b/packages/obs/src/scene-report.test.ts @@ -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}`); + } +}); diff --git a/packages/obs/src/scene-report.ts b/packages/obs/src/scene-report.ts new file mode 100644 index 0000000..0129fb9 --- /dev/null +++ b/packages/obs/src/scene-report.ts @@ -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 ?? ''}`), + }; +} diff --git a/packages/twitch/src/secrets.ts b/packages/twitch/src/secrets.ts index 0874b70..ac52c94 100644 --- a/packages/twitch/src/secrets.ts +++ b/packages/twitch/src/secrets.ts @@ -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: {} }; diff --git a/scripts/README.md b/scripts/README.md index 2ae379c..4409d9b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,12 +1,18 @@ # scripts -Skripte laufen entweder im Container (`docker compose run --rm toolkit `) -oder auf dem Host, wo sie mit Docker sprechen. +Befehle der Anwendung ruft man ueber `abdulive.cmd ` auf (im Repo: +`node apps/cli/main.ts `). Daneben stehen hier die Skripte fuer +Paketbau, Installation und Start. | Skript | Wo | Zweck | |---|---|---| -| `check-env` | Container | Prueft ffmpeg, whisper-cli, Sprachmodell, Volumes, OBS-Erreichbarkeit | -| `auth` | Container | OAuth-Flow fuer Broadcaster- und Bot-Token (braucht `--service-ports`) | -| `db:migrate` | Container | Migrationen anwenden | -| `calibrate` | Container | Facecam-Crop einmessen, schreibt `config/layout.yaml` | -| `backup.ps1` | Host | `VACUUM INTO` aus dem Volume `abdulive-db` nach `data/db/backup/`, 30 Generationen | +| `build-package.ts` | Entwicklungsrechner / CI | Baut `dist/abdulive--win-x64.zip` samt Updater-Manifest | +| `install.ps1` | Stream-PC | Installiert Programm, Daten und Autostart. Ohne Administratorrechte | +| `launcher.ps1` | Stream-PC | Vom Aufgabenplaner gestartet: Update anwenden, dann Daemon starten | +| `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS | +| `auth` | Stream-PC | OAuth-Flow fuer Broadcaster- und Bot-Token | +| `db:migrate` | Stream-PC | Migrationen anwenden | +| `calibrate` | Stream-PC | Facecam-Crop einmessen, schreibt `config/layout.yaml` | +| `sources` | Stream-PC | Listet die OBS-Quellen der laufenden Szene und sagt, welche im 9:16-Ausschnitt landen | +| `backup.ps1` | Stream-PC | `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db`, 30 Generationen | +| `update` | Stream-PC | Neue Fassung pruefen und bereitlegen; eingespielt wird beim naechsten Start | diff --git a/scripts/backup.ps1 b/scripts/backup.ps1 new file mode 100644 index 0000000..900fc61 --- /dev/null +++ b/scripts/backup.ps1 @@ -0,0 +1,56 @@ +# Sichert die Datenbank. +# +# `VACUUM INTO` und nicht kopieren: die Datenbank laeuft im WAL-Modus und wird +# waehrend des Streams beschrieben. Eine im Schreibvorgang kopierte Datei ist +# unbrauchbar - VACUUM INTO schreibt dagegen einen in sich stimmigen Stand, +# ohne den laufenden Betrieb anzuhalten. +# +# Die Sicherung enthaelt Chatverlaeufe und damit personenbezogene Daten. Sie +# gehoert nicht ins Repo und nicht in eine Cloud ohne Absprache. + +[CmdletBinding()] +param( + [string]$DatenOrdner = (Join-Path $env:LOCALAPPDATA 'abdulive'), + [string]$Ziel = (Join-Path $env:LOCALAPPDATA 'abdulive\backup'), + [int]$Generationen = 30 +) + +$ErrorActionPreference = 'Stop' + +$db = Join-Path $DatenOrdner 'db\abdulive.db' +if (-not (Test-Path $db)) { throw "Keine Datenbank unter $db" } + +$node = Join-Path $env:LOCALAPPDATA 'Programs\abdulive\node.exe' +if (-not (Test-Path $node)) { $node = (Get-Command node -ErrorAction Stop).Source } + +New-Item -ItemType Directory -Force -Path $Ziel | Out-Null +$stand = Get-Date -Format 'yyyy-MM-dd_HHmm' +$datei = Join-Path $Ziel "abdulive-$stand.db" + +# node:sqlite kann VACUUM INTO direkt - dafuer braucht es kein sqlite3.exe. +$skript = @' +const { DatabaseSync } = require('node:sqlite'); +const [quelle, ziel] = process.argv.slice(2); +const db = new DatabaseSync(quelle, { readOnly: true }); +db.exec(`VACUUM INTO '${ziel.replace(/'/g, "''")}'`); +db.close(); +'@ +$tmp = Join-Path $env:TEMP "abdulive-backup-$PID.cjs" +Set-Content -Path $tmp -Value $skript -Encoding utf8 +try { + & $node $tmp $db $datei + if ($LASTEXITCODE -ne 0) { throw "VACUUM INTO fehlgeschlagen (Code $LASTEXITCODE)" } +} finally { + Remove-Item $tmp -ErrorAction SilentlyContinue +} + +$mb = [math]::Round((Get-Item $datei).Length / 1MB, 1) +Write-Host "Gesichert: $datei ($mb MB)" + +# Aeltere Generationen abraeumen +$alt = Get-ChildItem $Ziel -Filter 'abdulive-*.db' | + Sort-Object LastWriteTime -Descending | Select-Object -Skip $Generationen +foreach ($f in $alt) { + Remove-Item $f.FullName + Write-Host " entfernt: $($f.Name)" +} diff --git a/scripts/build-package.ts b/scripts/build-package.ts new file mode 100644 index 0000000..e1550d5 --- /dev/null +++ b/scripts/build-package.ts @@ -0,0 +1,217 @@ +// Baut das Windows-Paket, das Abdu bekommt. +// +// node scripts/build-package.ts [--version 0.5.0] [--out dist] +// +// Ergebnis in dist/: +// abdulive--win-x64.zip das Paket +// abdulive.json Manifest fuer den Updater +// +// Laeuft auf jedem Rechner, auch unter Linux in der CI - es wird nichts +// ausgefuehrt, nur heruntergeladen, entpackt und wieder eingepackt. +// Siehe ADR-0007. + +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readZip, writeZip } from '#core/zip.ts'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// ---------------------------------------------------------------- Bausteine + +/** + * Node liefert versionierte, dauerhafte Adressen. ffmpeg und whisper.cpp + * nicht: BtbN raeumt alte Autobuilds nach einigen Wochen ab. Deshalb wird + * die jeweils aktuelle Ausgabe ueber die GitHub-API bestimmt und ihr genauer + * Name samt Pruefsumme ins Paket geschrieben - reproduzierbar ist damit das + * Paket, nicht die Adresse. + */ +const NODE_VERSION = 'v24.9.0'; + +type Component = { + name: string; + url: () => Promise; + /** Welche Dateien aus dem Archiv, und wohin im Paket. */ + pick: (entryName: string) => string | null; +}; + +const COMPONENTS: Component[] = [ + { + name: 'node', + url: async () => + `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`, + pick: (n) => (n.endsWith('/node.exe') ? 'node.exe' : null), + }, + { + name: 'ffmpeg', + url: () => githubAsset('BtbN/FFmpeg-Builds', + /^ffmpeg-n\d[\w.-]*win64-gpl-shared-[\d.]+\.zip$/), + // Aus dem shared-Build nur bin/: die beiden Programme und ihre DLLs. + // ffplay wird nicht gebraucht. + pick: (n) => { + const m = /\/bin\/(.+)$/.exec(n); + if (!m?.[1] || m[1].startsWith('ffplay')) return null; + return /\.(exe|dll)$/i.test(m[1]) ? `bin/${m[1]}` : null; + }, + }, + { + name: 'whisper', + url: () => githubAsset('ggml-org/whisper.cpp', /^whisper-bin-x64\.zip$/), + // whisper-cli plus die Bibliotheken. Die ggml-cpu-*.dll sind Varianten + // fuer verschiedene Befehlssaetze; ausgewaehlt wird zur Laufzeit, es + // muessen also alle mit. + pick: (n) => { + const m = /^Release\/(.+)$/.exec(n); + const f = m?.[1]; + if (!f) return null; + if (f === 'whisper-cli.exe') return `bin/${f}`; + if (/^(whisper|ggml)[\w.-]*\.dll$/.test(f)) return `bin/${f}`; + return null; + }, + }, +]; + +async function githubAsset(repoName: string, pattern: RegExp): Promise { + const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, { + headers: { 'user-agent': 'abdulive-build' }, + }); + if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`); + const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> }; + const asset = rel.assets.find((a) => pattern.test(a.name)); + if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`); + return asset.browser_download_url; +} + +// ---------------------------------------------------------------- Werkzeug + +const sha256 = (b: Buffer) => crypto.createHash('sha256').update(b).digest('hex'); + +async function download(url: string): Promise { + const cacheDir = path.join(repo, '.cache'); + await fs.mkdir(cacheDir, { recursive: true }); + const file = path.join(cacheDir, path.basename(new URL(url).pathname)); + + const cached = await fs.readFile(file).catch(() => null); + if (cached) { + console.log(` aus dem Zwischenspeicher: ${path.basename(file)} ` + + `(${(cached.length / 1e6).toFixed(0)} MB)`); + return cached; + } + + process.stdout.write(` lade ${path.basename(file)} ... `); + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`HTTP ${res.status} fuer ${url}`); + const buf = Buffer.from(await res.arrayBuffer()); + await fs.writeFile(file, buf); + console.log(`${(buf.length / 1e6).toFixed(0)} MB`); + return buf; +} + +/** Alle Dateien unterhalb `dir`, relativ, ohne die ausgeschlossenen. */ +async function walk(dir: string, base = dir): Promise { + const out: string[] = []; + for (const e of await fs.readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) out.push(...await walk(full, base)); + else out.push(path.relative(base, full).replace(/\\/g, '/')); + } + return out; +} + +// ---------------------------------------------------------------- Bau + +async function main(): Promise { + const args = process.argv.slice(2); + const arg = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; + const version = arg('--version') ?? new Date().toISOString().slice(0, 10).replace(/-/g, '.'); + const outDir = path.resolve(repo, arg('--out') ?? 'dist'); + + console.log(`abdulive ${version} fuer Windows x64\n`); + + const files: Array<{ name: string; data: Buffer }> = []; + const used: Record = {}; + + // --- mitgelieferte Programme + for (const c of COMPONENTS) { + console.log(`${c.name}:`); + const url = await c.url(); + const zip = await download(url); + used[c.name] = { archiv: path.basename(new URL(url).pathname), sha256: sha256(zip) }; + + let taken = 0; + for (const entry of readZip(zip)) { + if (entry.isDirectory) continue; + const target = c.pick(entry.name); + if (!target) continue; + files.push({ name: target, data: entry.data() }); + taken++; + } + if (!taken) throw new Error(`${c.name}: keine Datei uebernommen - hat sich der Aufbau geaendert?`); + console.log(` ${taken} Datei(en) uebernommen`); + } + + // --- Anwendung: Quellen, ohne Tests + console.log('\nAnwendung:'); + for (const dir of ['packages', 'apps']) { + for (const rel of await walk(path.join(repo, dir))) { + if (rel.endsWith('.test.ts')) continue; + if (!/\.(ts|sql|json)$/.test(rel)) continue; + files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) }); + } + } + // Die "imports"-Map darin loest #core/... auf - ohne sie startet nichts. + files.push({ name: 'app/package.json', data: await fs.readFile(path.join(repo, 'package.json')) }); + for (const f of ['config.example.yaml', 'layout.yaml']) { + files.push({ name: `app/config/${f}`, data: await fs.readFile(path.join(repo, 'config', f)) }); + } + for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) { + files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) }); + } + for (const f of ['launcher.ps1', 'install.ps1', 'backup.ps1']) { + files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) }); + } + console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`); + + // --- Kopfdaten + const manifestInPackage = { + version, + gebaut: new Date().toISOString(), + node: NODE_VERSION, + bausteine: used, + }; + files.push({ + name: 'version.json', + data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'), + }); + files.push({ + name: 'abdulive.cmd', + data: Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*\r\n'), + }); + + // --- packen + await fs.mkdir(outDir, { recursive: true }); + const zipName = `abdulive-${version}-win-x64.zip`; + const zip = writeZip(files); + await fs.writeFile(path.join(outDir, zipName), zip); + + // Das Manifest, das der Updater abfragt. Die Adresse traegt die + // Veroeffentlichung ein - hier steht nur, wie die Datei heisst. + await fs.writeFile(path.join(outDir, 'abdulive.json'), JSON.stringify({ + version, + datei: zipName, + groesse: zip.length, + sha256: sha256(zip), + veroeffentlicht: new Date().toISOString(), + }, null, 2) + '\n'); + + const roh = files.reduce((n, f) => n + f.data.length, 0); + console.log(`\nFertig: ${path.join(outDir, zipName)}`); + console.log(` ${files.length} Dateien, ${(roh / 1e6).toFixed(0)} MB roh, ` + + `${(zip.length / 1e6).toFixed(0)} MB gepackt`); + console.log(` sha256 ${sha256(zip)}`); + console.log('\nDas Sprachmodell (ca. 465 MB) ist nicht dabei - es wird bei der'); + console.log('Einrichtung geladen (`abdulive fetch-model`).'); +} + +await main(); diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..09ee59e --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,187 @@ +# Installiert abdulive auf dem Stream-PC. +# +# .\install.ps1 +# +# Aus einem entpackten Paket heraus ausfuehren - install.ps1 liegt darin neben +# node.exe. +# +# Einen `irm ... | iex`-Einzeiler gibt es bewusst nicht: git.zeasy.dev verlangt +# instanzweit eine Anmeldung und antwortet auf jede Adresse mit einer Umleitung +# auf /user/login. `iex` fuehrt die Anmeldeseite dann als PowerShell aus, mit +# einer Fehlermeldung, aus der niemand die Ursache ablesen kann. Wer eine +# Ablage ohne Anmeldung hat, gibt sie mit -Quelle an; dann laedt das Skript +# das Paket selbst. +# +# Ohne Administratorrechte, mit Absicht: Programm und Daten liegen im Profil +# des Benutzers, damit auch Updates ohne Rueckfrage durchlaufen. Siehe +# ADR-0007. + +[CmdletBinding()] +param( + # Nur noetig, wenn nicht aus einem entpackten Paket heraus installiert + # wird. Dort muessen abdulive.json und die ZIP-Datei ohne Anmeldung + # erreichbar sein. + [string]$Quelle, + [string]$Ziel = (Join-Path $env:LOCALAPPDATA 'Programs\abdulive'), + [switch]$KeinAutostart +) + +$ErrorActionPreference = 'Stop' +$daten = Join-Path $env:LOCALAPPDATA 'abdulive' + +function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan } +function Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray } + +# ---------------------------------------------------------------- Pruefen + +Schritt 'Umgebung pruefen' +if ([Environment]::Is64BitOperatingSystem -ne $true) { + throw 'Das Paket gibt es nur fuer 64-Bit-Windows.' +} +$obs = Get-Process obs64, obs -ErrorAction SilentlyContinue +Hinweis ("OBS laeuft: " + $(if ($obs) { 'ja' } else { 'nein - macht nichts, wird spaeter gebraucht' })) + +# ---------------------------------------------------------------- Quelle + +$hier = if ($PSScriptRoot) { $PSScriptRoot } else { $null } +$ausPaket = $hier -and (Test-Path (Join-Path $hier 'node.exe')) + +if ($ausPaket) { + Schritt "Installiere aus dem entpackten Paket ($hier)" + $quellordner = $hier +} elseif (-not $Quelle) { + Write-Host '' + Write-Host 'Dieses Skript liegt nicht neben einem Paket, und -Quelle ist nicht gesetzt.' -ForegroundColor Red + Write-Host '' + Write-Host 'Entweder:' + Write-Host ' - das Paket herunterladen, entpacken und darin install.ps1 ausfuehren' + Write-Host ' - oder eine Ablage angeben, unter der abdulive.json ohne Anmeldung liegt:' + Write-Host ' .\install.ps1 -Quelle https://.../abdulive' + Write-Host '' + exit 1 +} else { + Schritt 'Lade das aktuelle Paket' + $tmp = Join-Path $env:TEMP ("abdulive-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + + # Nicht auf die Form der Ausnahme verlassen: `-MaximumRedirection 0` wirft + # in Windows PowerShell 5.1 eine WebException ganz ohne `Response`, und ein + # Zugriff auf .Response.StatusCode laeuft dann selbst auf die Nase. + # Stattdessen holen, was kommt, und den Inhalt ansehen. + try { + $antwort = Invoke-WebRequest "$Quelle/abdulive.json" -UseBasicParsing -ErrorAction Stop + } catch { + Write-Host '' + Write-Host "$Quelle/abdulive.json ist nicht erreichbar." -ForegroundColor Red + Write-Host " $($_.Exception.Message)" + Write-Host '' + exit 1 + } + + # Eine Anmeldeseite ist HTML und kommt mit Status 200 an, wenn der Umleitung + # gefolgt wurde. Genau das ist bei git.zeasy.dev passiert. + $typ = [string]$antwort.Headers['Content-Type'] + $anfang = [string]$antwort.Content + if ($typ -like 'text/html*' -or $anfang -match '(?i)^\s*(&1 | ForEach-Object { + Add-Content -Path (Join-Path $log 'abdulive.log') -Value $_ -Encoding utf8 +} +Write-Log "Daemon beendet (Code $LASTEXITCODE)." +exit $LASTEXITCODE