import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { NativeFrameParser, NativeMessageKind, type VideoFrame, } from "./frame-buffer.ts"; const STDERR_LIMIT = 8 * 1024; const GRACEFUL_STOP_MS = 750; const TERMINATE_MS = 500; export interface NativeClientOptions { binaryPath: string; videoPath: string; fps: number; volume: number; } export interface NativeClientCallbacks { onFrame?: (frame: VideoFrame) => void; onReady?: () => void; onEnd?: (reason: string) => void; onError?: (error: Error) => void; onInfo?: (message: string) => void; } export type SpawnSidecar = ( command: string, args: readonly string[], ) => ChildProcessWithoutNullStreams; export class NativeClient { private readonly parser = new NativeFrameParser(); private readonly options: NativeClientOptions; private readonly callbacks: NativeClientCallbacks; private readonly spawnSidecar: SpawnSidecar; private child: ChildProcessWithoutNullStreams | undefined; private frame: VideoFrame | undefined; private requestedBox: string | undefined; private stderrTail = ""; private started = false; private stopping = false; private completed = false; private exited = false; private closePromise: Promise; private resolveClose!: () => void; constructor( options: NativeClientOptions, callbacks: NativeClientCallbacks = {}, spawnSidecar: SpawnSidecar = defaultSpawnSidecar, ) { this.options = options; this.callbacks = callbacks; this.spawnSidecar = spawnSidecar; this.closePromise = new Promise((resolveClose) => { this.resolveClose = resolveClose; }); } start(width: number, height: number): void { if (this.started) throw new Error("NativeClient has already been started"); validateDimensions(width, height); this.started = true; this.requestedBox = `${width}x${height}`; const args = [ "--video", this.options.videoPath, "--width", String(width), "--height", String(height), "--fps", String(this.options.fps), "--volume", String(this.options.volume), ]; try { this.child = this.spawnSidecar(this.options.binaryPath, args); } catch (error) { this.fail(asError(error)); this.markExited(); return; } this.child.stdout.on("data", (chunk: Buffer) => this.handleStdout(chunk)); this.child.stderr.setEncoding("utf8"); this.child.stderr.on("data", (chunk: string) => this.captureStderr(chunk)); this.child.stdin.on("error", (error) => this.handleStdinError(error)); this.child.once("error", (error) => this.fail(error)); this.child.once("close", (code, signal) => this.handleClose(code, signal)); } latestFrame(): VideoFrame | undefined { return this.frame; } resize(width: number, height: number): void { validateDimensions(width, height); const key = `${width}x${height}`; if (key === this.requestedBox) return; this.requestedBox = key; this.writeControl(`resize ${width} ${height}\n`); } stop(): void { if (this.stopping || this.exited) return; this.stopping = true; this.writeControl("stop\n"); } async dispose(): Promise { if (!this.started || this.exited) return; this.stop(); await Promise.race([this.closePromise, delay(GRACEFUL_STOP_MS)]); if (this.exited) return; this.child?.kill("SIGTERM"); await Promise.race([this.closePromise, delay(TERMINATE_MS)]); if (!this.exited) this.child?.kill("SIGKILL"); await Promise.race([this.closePromise, delay(TERMINATE_MS)]); } private handleStdout(chunk: Buffer): void { let messages; try { messages = this.parser.push(chunk); } catch (error) { this.fail(asError(error)); this.stop(); return; } let receivedFrame = false; for (const message of messages) { switch (message.kind) { case NativeMessageKind.Ready: this.callbacks.onReady?.(); break; case NativeMessageKind.Frame: this.frame = message.frame; receivedFrame = true; break; case NativeMessageKind.End: this.complete(message.reason); break; case NativeMessageKind.Error: this.fail(new Error(message.message)); this.stop(); break; case NativeMessageKind.Info: this.callbacks.onInfo?.(message.message); break; } } if (receivedFrame && this.frame) this.callbacks.onFrame?.(this.frame); } private handleClose(code: number | null, signal: NodeJS.Signals | null): void { this.markExited(); if (this.completed || this.stopping) return; if (code === 0) { this.complete("process-exit"); return; } const detail = this.stderrTail.trim(); const suffix = detail ? `: ${detail}` : ""; this.fail(new Error(`Native sidecar exited with code ${code ?? "null"}, signal ${signal ?? "none"}${suffix}`)); } private writeControl(command: string): void { if (!this.child || this.exited || !this.child.stdin.writable) return; this.child.stdin.write(command, (error) => { if (error) this.handleStdinError(error); }); } private handleStdinError(error: Error): void { if (this.stopping || this.completed || this.exited) return; this.fail(error); } private captureStderr(chunk: string): void { this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_LIMIT); } private complete(reason: string): void { if (this.completed) return; this.completed = true; this.callbacks.onEnd?.(reason); } private fail(error: Error): void { if (this.completed) return; this.completed = true; this.callbacks.onError?.(error); } private markExited(): void { if (this.exited) return; this.exited = true; this.resolveClose(); } } export function resolveNativeBinary(configuredPath?: string): string { if (configuredPath) return resolve(configuredPath); const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const executable = process.platform === "win32" ? "pi-video-tui-native.exe" : "pi-video-tui-native"; const prebuilt = join(packageRoot, "native", "bin", `${process.platform}-${process.arch}`, executable); if (existsSync(prebuilt)) return prebuilt; return join(packageRoot, "native", "target", "release", executable); } function defaultSpawnSidecar(command: string, args: readonly string[]): ChildProcessWithoutNullStreams { return spawn(command, [...args], { stdio: ["pipe", "pipe", "pipe"] }); } function delay(milliseconds: number): Promise { return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); } function validateDimensions(width: number, height: number): void { if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 2) { throw new RangeError( "Native render box width must be positive and height must be at least 2", ); } } function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); }