S2: obs-websocket, Wiedergabepuffer und der !clip-Ausloeser
Eigener obs-websocket-v5-Client (das Protokoll ist klein genug, und das Projekt bleibt ohne Abhaengigkeiten). Anmeldung, Wiederverbindung mit wachsender Pause, Ereignisse fuer ReplayBufferSaved und Szenenwechsel. Der Clipper loest zwei Wege gleichzeitig aus: OBS-Wiedergabepuffer fuer die Rohdatei in Aufnahmequalitaet, Twitch-Clip fuer die teilbare URL. Faellt einer aus, ist der andere trotzdem etwas wert. Ein direkt in OBS gedrueckter Hotkey wird ohne unseren Umweg erkannt und als vollwertiger Ausloeser behandelt. Die Pfaduebersetzung Windows -> Container hat eine eigene Datei und eigene Tests - das ist die Stelle, an der die Pipeline am ehesten scheitert. Dazu Warten auf den abgeschlossenen Schreibvorgang, bevor die Datei uebernommen wird. Zum Testvektor der OBS-Anmeldung: die Spezifikation nennt Salt und Challenge, aber kein erwartetes Ergebnis. Der Erwartungswert wurde unabhaengig mit Pythons hashlib aus derselben Beschreibung gerechnet; der Test sagt das im Kommentar, statt einen amtlichen Vektor vorzutaeuschen. 29 Tests gruen. Die Abnahme von S2 braucht ein laufendes OBS und steht aus. Ausserdem: ADR-0006 haelt fest, dass alles unter einem Dach laeuft - eine lokale Weboberflaeche plus Kindprozesse fuer Rechenarbeit. Die Prozessliste in der Architekturdoku ist entsprechend ersetzt. Der Onboarding-Assistent ist als Teil von S5 aufgenommen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013VTGjd92LsDDWF3frd9FTB
This commit is contained in:
1 parent
d07c18441b
commit
684f10eb02
13 files changed
+796
-44
No files matched your search
@@ -0,0 +1,163 @@
|
||||
// obs-websocket v5. Eigene Umsetzung statt npm-Paket - das Protokoll ist klein
|
||||
// und das Projekt kommt ohne Abhaengigkeiten aus (ADR-0001, Nachtrag 2).
|
||||
//
|
||||
// Ablauf: Server schickt Hello (op 0), Client antwortet mit Identify (op 1),
|
||||
// Server bestaetigt mit Identified (op 2). Danach Requests (op 6) / Responses
|
||||
// (op 7) und Events (op 5).
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { logger } from '#core/logger.ts';
|
||||
import { forever } from '#core/retry.ts';
|
||||
|
||||
const log = logger('obs');
|
||||
|
||||
/** Ereignisgruppen als Bitmaske. Wir brauchen General, Scenes und Outputs. */
|
||||
export const SUBSCRIPTIONS = { general: 1, config: 2, scenes: 4, outputs: 64 } as const;
|
||||
const DEFAULT_SUBS = SUBSCRIPTIONS.general | SUBSCRIPTIONS.scenes | SUBSCRIPTIONS.outputs;
|
||||
|
||||
/**
|
||||
* Antwort auf die Authentifizierungsaufforderung.
|
||||
* base64(sha256( base64(sha256(passwort + salt)) + challenge ))
|
||||
*/
|
||||
export function authString(password: string, salt: string, challenge: string): string {
|
||||
const secret = crypto.createHash('sha256').update(password + salt).digest('base64');
|
||||
return crypto.createHash('sha256').update(secret + challenge).digest('base64');
|
||||
}
|
||||
|
||||
type Pending = { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout };
|
||||
|
||||
export type ObsEventHandlers = {
|
||||
onReplaySaved?: (path: string) => void;
|
||||
onSceneChanged?: (scene: string) => void;
|
||||
onIdentified?: () => void;
|
||||
onClosed?: () => void;
|
||||
};
|
||||
|
||||
export type ObsClient = {
|
||||
run: (signal: AbortSignal) => Promise<void>;
|
||||
request: <T = unknown>(type: string, data?: unknown) => Promise<T>;
|
||||
isConnected: () => boolean;
|
||||
};
|
||||
|
||||
export function createObs(
|
||||
url: string,
|
||||
password: () => string | undefined,
|
||||
handlers: ObsEventHandlers
|
||||
): ObsClient {
|
||||
let ws: WebSocket | null = null;
|
||||
let identified = false;
|
||||
let seq = 0;
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
function send(op: number, d: unknown): void {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error('OBS ist nicht verbunden');
|
||||
ws.send(JSON.stringify({ op, d }));
|
||||
}
|
||||
|
||||
function request<T>(type: string, data?: unknown): Promise<T> {
|
||||
if (!identified) return Promise.reject(new Error('OBS ist nicht verbunden'));
|
||||
const requestId = `r${++seq}`;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(requestId);
|
||||
reject(new Error(`OBS antwortet nicht auf ${type}`));
|
||||
}, 10_000);
|
||||
pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject, timer });
|
||||
try {
|
||||
send(6, { requestType: type, requestId, ...(data ? { requestData: data } : {}) });
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(requestId);
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(raw: string, fail: (why: string) => void): void {
|
||||
let msg: { op: number; d: Record<string, unknown> };
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
|
||||
switch (msg.op) {
|
||||
case 0: { // Hello
|
||||
const auth = msg.d['authentication'] as { challenge: string; salt: string } | undefined;
|
||||
const identify: Record<string, unknown> = {
|
||||
rpcVersion: 1, eventSubscriptions: DEFAULT_SUBS,
|
||||
};
|
||||
if (auth) {
|
||||
const pw = password();
|
||||
if (!pw) return fail('OBS verlangt ein Passwort, es ist keines hinterlegt (/setup)');
|
||||
identify['authentication'] = authString(pw, auth.salt, auth.challenge);
|
||||
}
|
||||
send(1, identify);
|
||||
break;
|
||||
}
|
||||
case 2: { // Identified
|
||||
identified = true;
|
||||
log.info(`Mit OBS verbunden (RPC ${String(msg.d['negotiatedRpcVersion'])})`);
|
||||
handlers.onIdentified?.();
|
||||
break;
|
||||
}
|
||||
case 5: { // Event
|
||||
const type = msg.d['eventType'] as string;
|
||||
const data = (msg.d['eventData'] ?? {}) as Record<string, unknown>;
|
||||
if (type === 'ReplayBufferSaved') {
|
||||
handlers.onReplaySaved?.(String(data['savedReplayPath'] ?? ''));
|
||||
} else if (type === 'CurrentProgramSceneChanged') {
|
||||
handlers.onSceneChanged?.(String(data['sceneName'] ?? ''));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 7: { // RequestResponse
|
||||
const id = msg.d['requestId'] as string;
|
||||
const entry = pending.get(id);
|
||||
if (!entry) break;
|
||||
pending.delete(id);
|
||||
clearTimeout(entry.timer);
|
||||
const status = msg.d['requestStatus'] as { result: boolean; code: number; comment?: string };
|
||||
if (status?.result) entry.resolve(msg.d['responseData'] ?? {});
|
||||
else entry.reject(new Error(
|
||||
`OBS lehnt ab (${status?.code}): ${status?.comment ?? 'ohne Begruendung'}`));
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
function connectOnce(signal: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
const socket = new WebSocket(url);
|
||||
ws = socket;
|
||||
identified = false;
|
||||
|
||||
const fail = (why: string) => {
|
||||
identified = false;
|
||||
for (const [, p] of pending) { clearTimeout(p.timer); p.reject(new Error(why)); }
|
||||
pending.clear();
|
||||
try { socket.close(); } catch { /* schon zu */ }
|
||||
handlers.onClosed?.();
|
||||
reject(new Error(why));
|
||||
};
|
||||
signal.addEventListener('abort', () => fail('abgebrochen'), { once: true });
|
||||
|
||||
socket.addEventListener('message', (ev) => handleMessage(String(ev.data), fail));
|
||||
socket.addEventListener('error', () => fail('WebSocket-Fehler'));
|
||||
socket.addEventListener('close', (ev) => fail(`Verbindung geschlossen (${ev.code})`));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
run: (signal) => forever(connectOnce, {
|
||||
signal,
|
||||
baseMs: 2000,
|
||||
maxMs: 60_000,
|
||||
onWait: (ms, n) => {
|
||||
// Erst ab dem dritten Versuch laut werden: OBS laeuft oft einfach
|
||||
// noch nicht, wenn das Toolkit startet.
|
||||
const line = `OBS nicht erreichbar, neuer Versuch in ${Math.round(ms / 1000)} s`;
|
||||
if (n >= 3) log.warn(line); else log.debug(line);
|
||||
},
|
||||
}),
|
||||
request,
|
||||
isConnected: () => identified,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user