Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt TypeScript direkt aus - der Container schrieb nur noch drei Programme fest, und dafuer braucht es keine Virtualisierung, sondern einen Ordner. Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste whisper.cpp aus dem Quelltext uebersetzen. Plattformschicht (packages/core/src/platform.ts): - Programme werden neben der Anwendung gesucht, nicht im PATH - Daten unter %LOCALAPPDATA%, damit Installation und Update ohne Administratorrechte auskommen - Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert: - Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche, escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht - `nice` gibt es nicht, ersetzt durch os.setPriority Auslieferung: - scripts/build-package.ts baut das Paket, laeuft auch unter Linux - install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner - update legt neue Fassungen bereit; eingespielt wird beim naechsten Start, nie mitten im Stream - ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts) Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene Vorgabeadresse mehr. Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben. ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
This commit is contained in:
1 parent
71dd660d01
commit
5f6301268a
41 files changed
+2254
-312
No files matched your search
@@ -1,15 +1,25 @@
|
||||
// OBS laeuft auf dem Host und meldet Windows-Pfade; der Container sieht den
|
||||
// Wiedergabepuffer-Ordner unter /replay. Diese Uebersetzung ist die Stelle,
|
||||
// an der die Clip-Pipeline am ehesten scheitert - deshalb eigene Datei mit
|
||||
// eigenen Tests. Siehe docs/07-mvp-plan.md, Scheibe S2.
|
||||
// OBS meldet den Pfad der gespeicherten Pufferdatei. Nativ ist das derselbe
|
||||
// Pfad, den auch die Anwendung sieht - es gibt nichts zu uebersetzen.
|
||||
//
|
||||
// Solange das Toolkit im Container lief, war das anders: OBS meldete
|
||||
// `C:\Users\...`, drinnen hiess derselbe Ordner `/replay`. Diese Uebersetzung
|
||||
// war die Stelle, an der die Clip-Pipeline am ehesten scheiterte. Sie bleibt
|
||||
// samt Tests erhalten, wird aber nur noch gebraucht, wenn `containerDir`
|
||||
// gesetzt ist. Siehe ADR-0007.
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/** `C:\Users\abdu\Videos\replay\Replay 2026-09-09.mkv` -> `/replay/Replay 2026-09-09.mkv` */
|
||||
/**
|
||||
* Nativ: unveraendert zurueck.
|
||||
* Im Container: `C:\Users\abdu\Videos\replay\Replay.mkv` -> `/replay/Replay.mkv`
|
||||
*/
|
||||
export function mapReplayPath(
|
||||
hostPath: string, hostDir: string | undefined, containerDir: string
|
||||
): string {
|
||||
// Kein Zielverzeichnis heisst: dieselbe Dateisicht wie OBS.
|
||||
if (!containerDir) return hostPath;
|
||||
|
||||
const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const file = norm(hostPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CAPTION_HINTS, judgeSource, overlapPercent } from './scene-report.ts';
|
||||
|
||||
// Die Ausschnitte aus dem vorgegebenen Layout (config/layout.yaml, Profil default)
|
||||
const CAM = { x: 0, y: 0, w: 480, h: 270 };
|
||||
const GAME = { x: 515, y: 0, w: 890, h: 1080 };
|
||||
|
||||
const judge = (box: { x: number; y: number; w: number; h: number }, over: {
|
||||
enabled?: boolean; name?: string; kind?: string; url?: string;
|
||||
} = {}) => judgeSource({
|
||||
box, enabled: over.enabled ?? true, name: over.name ?? 'Quelle',
|
||||
kind: over.kind ?? 'text_gdiplus_v3', cam: CAM, game: GAME,
|
||||
...(over.url === undefined ? {} : { url: over.url }),
|
||||
});
|
||||
|
||||
test('rechnet die Ueberdeckung in Prozent des Ausschnitts', () => {
|
||||
assert.equal(overlapPercent({ x: 0, y: 0, w: 480, h: 270 }, CAM), 100);
|
||||
assert.equal(overlapPercent({ x: 0, y: 0, w: 240, h: 270 }, CAM), 50);
|
||||
assert.equal(overlapPercent({ x: 1405, y: 0, w: 515, h: 1080 }, GAME), 0);
|
||||
});
|
||||
|
||||
test('Quellen rechts der Chatlinie sind ausserhalb', () => {
|
||||
// Genau die Vorgabe aus docs/obs-layout.md: Chat und Alerts ab x=1405
|
||||
const chat = judge({ x: 1405, y: 0, w: 515, h: 1080 }, { name: 'Chat-Overlay' });
|
||||
assert.equal(chat.inShort, false);
|
||||
assert.equal(chat.game, 0);
|
||||
});
|
||||
|
||||
test('ein Untertitel-Overlay im Gameplay-Band faellt auf', () => {
|
||||
// Der Fall aus dem echten Clip: Live-Captions ueber der vollen Bildbreite
|
||||
const u = judge({ x: 0, y: 900, w: 1920, h: 120 }, { name: 'Live Captions' });
|
||||
assert.equal(u.inShort, true);
|
||||
assert.equal(u.suspectCaption, true);
|
||||
assert.ok(u.game > 0);
|
||||
});
|
||||
|
||||
test('dasselbe Overlay unten links ist in Ordnung', () => {
|
||||
// Der empfohlene Ausweichplatz: x 0..515, also links neben dem Band
|
||||
const u = judge({ x: 0, y: 900, w: 515, h: 120 }, { name: 'Live Captions' });
|
||||
assert.equal(u.inShort, false);
|
||||
assert.equal(u.suspectCaption, false, 'ausserhalb ist kein Problem, sondern die Loesung');
|
||||
});
|
||||
|
||||
test('ausgeblendete Quellen zaehlen nicht', () => {
|
||||
const u = judge({ x: 0, y: 900, w: 1920, h: 120 },
|
||||
{ name: 'Untertitel', enabled: false });
|
||||
assert.equal(u.inShort, false);
|
||||
assert.equal(u.suspectCaption, false);
|
||||
});
|
||||
|
||||
test('erkennt Untertitelquellen auch an Art und Adresse', () => {
|
||||
const box = { x: 600, y: 900, w: 700, h: 100 };
|
||||
assert.equal(judge(box, { name: 'Overlay 3', kind: 'localvocal_filter' }).suspectCaption, true);
|
||||
assert.equal(judge(box, {
|
||||
name: 'Overlay 4', kind: 'browser_source', url: 'https://webcaptioner.com/captioner/view/x',
|
||||
}).suspectCaption, true);
|
||||
assert.equal(judge(box, { name: 'Killfeed', kind: 'image_source' }).suspectCaption, false);
|
||||
});
|
||||
|
||||
test('die Stichwortliste trifft die ueblichen Verdaechtigen', () => {
|
||||
for (const s of ['Live Captions', 'Untertitel', 'Web Captioner', 'obs-localvocal',
|
||||
'Speech to Text', 'closed captions', 'CC Overlay']) {
|
||||
assert.ok(CAPTION_HINTS.test(s), `nicht erkannt: ${s}`);
|
||||
}
|
||||
for (const s of ['Facecam', 'Gameplay', 'Alerts', 'Soccer', 'Discord']) {
|
||||
assert.equal(CAPTION_HINTS.test(s), false, `Fehlalarm: ${s}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Auswertung einer OBS-Szene gegen den 9:16-Ausschnitt.
|
||||
//
|
||||
// Der Anlass: In einem fertigen Clip lagen fremde Untertitel im Bild, und
|
||||
// niemand wusste, woher sie kommen. Raten hilft da nicht - sie sind ins
|
||||
// Streambild eingebrannt, also muss man nachsehen, welche Quelle sie malt.
|
||||
// Die reine Rechnerei steht hier, damit sie pruefbar ist; das Anzeigen und
|
||||
// das Reden mit OBS bleibt im Entrypoint.
|
||||
|
||||
import type { Rect } from '#core/config.ts';
|
||||
|
||||
export type Box = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/**
|
||||
* Quellenarten und Namen, hinter denen erfahrungsgemaess Untertitel stecken.
|
||||
* Bewusst weit gefasst: ein Fehlalarm kostet einen Blick, eine uebersehene
|
||||
* Quelle kostet jeden Clip.
|
||||
*/
|
||||
export const CAPTION_HINTS =
|
||||
/caption|untertitel|subtitle|transcri|speech|vocal|stt|dictat|closed.?cc|(^|\W)cc(\W|$)/i;
|
||||
|
||||
/** Wie viel Prozent des Ausschnitts die Quelle ueberdeckt. */
|
||||
export function overlapPercent(box: Box, r: Rect): number {
|
||||
const w = Math.max(0, Math.min(box.x + box.w, r.x + r.w) - Math.max(box.x, r.x));
|
||||
const h = Math.max(0, Math.min(box.y + box.h, r.y + r.h) - Math.max(box.y, r.y));
|
||||
const area = r.w * r.h;
|
||||
return area > 0 ? Math.round(((w * h) / area) * 100) : 0;
|
||||
}
|
||||
|
||||
export type SourceVerdict = {
|
||||
cam: number;
|
||||
game: number;
|
||||
/** Steht die Quelle im fertigen Short? */
|
||||
inShort: boolean;
|
||||
/** Sieht sie nach fremden Untertiteln aus? */
|
||||
suspectCaption: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Urteil ueber eine einzelne Quelle.
|
||||
*
|
||||
* Ausgeblendete Quellen sind nie im Bild - egal wo sie liegen. Und eine
|
||||
* Untertitelquelle ausserhalb des Ausschnitts ist kein Problem, sondern genau
|
||||
* das, was docs/obs-layout.md vorschlaegt: im Stream sichtbar lassen, aus dem
|
||||
* Gameplay-Band herausschieben.
|
||||
*/
|
||||
export function judgeSource(args: {
|
||||
box: Box;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
kind: string;
|
||||
url?: string;
|
||||
cam: Rect;
|
||||
game: Rect;
|
||||
}): SourceVerdict {
|
||||
const cam = overlapPercent(args.box, args.cam);
|
||||
const game = overlapPercent(args.box, args.game);
|
||||
const inShort = args.enabled && (cam > 0 || game > 0);
|
||||
return {
|
||||
cam, game, inShort,
|
||||
suspectCaption: inShort &&
|
||||
CAPTION_HINTS.test(`${args.name} ${args.kind} ${args.url ?? ''}`),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user