Native Windows-Auslieferung statt Container

Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt
TypeScript direkt aus - der Container schrieb nur noch drei Programme fest,
und dafuer braucht es keine Virtualisierung, sondern einen Ordner.

Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber
nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste
whisper.cpp aus dem Quelltext uebersetzen.

Plattformschicht (packages/core/src/platform.ts):
- Programme werden neben der Anwendung gesucht, nicht im PATH
- Daten unter %LOCALAPPDATA%, damit Installation und Update ohne
  Administratorrechte auskommen
- Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine
  Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite

Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert:
- Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche,
  escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht
- `nice` gibt es nicht, ersetzt durch os.setPriority

Auslieferung:
- scripts/build-package.ts baut das Paket, laeuft auch unter Linux
- install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner
- update legt neue Fassungen bereit; eingespielt wird beim naechsten Start,
  nie mitten im Stream
- ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts)

Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer
Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell
aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene
Vorgabeadresse mehr.

Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben.

ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
This commit is contained in:
ZeasyAlexandClaude Opus 5 committed 2026-09-09 17:39:26 +02:00
1 parent 71dd660d01
commit 5f6301268a
41 files changed
+2254 -312

No files matched your search

+15 -5
View File
@@ -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);
+69
View File
@@ -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}`);
}
});
+63
View File
@@ -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 ?? ''}`),
};
}