Files
SnapOtter/packages/ai/src/bridge.ts
T
Siddharth Kumar Sah 29a382e9e0 feat: add GPU/CUDA acceleration support (:cuda Docker tag)
Add a :cuda Docker image tag that auto-detects NVIDIA GPU at runtime
and falls back gracefully to CPU. Same pattern as Immich.

- New gpu.py shared utility for cached CUDA detection
- Background removal (rembg): pass CUDAExecutionProvider to ONNX Runtime
- Upscaling (Real-ESRGAN): use CUDA device + FP16 when GPU available
- OCR (PaddleOCR): enable use_gpu when CUDA detected
- Dispatcher reports GPU status at startup via readiness signal
- Admin health endpoint exposes GPU availability
- Dockerfile uses ARG GPU=false with conditional NVIDIA CUDA base image
- docker-compose.gpu.yml override for GPU users
- CI/CD workflows build and publish :cuda tag (amd64 only)

Three tags: :latest (CPU), :lite (no AI), :cuda (GPU with CPU fallback)
2026-04-05 19:12:45 +08:00

365 lines
11 KiB
TypeScript

import { type ChildProcess, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PYTHON_DIR = resolve(__dirname, "../python");
/** Try venv first, then system python. */
function getPythonPath(): string {
const venvPath = process.env.PYTHON_VENV_PATH || resolve(__dirname, "../../../.venv");
return `${venvPath}/bin/python3`;
}
/**
* Extract a user-friendly error from a Python process error.
*/
function extractPythonError(error: unknown): string {
if (error && typeof error === "object") {
const execError = error as {
stderr?: string;
stdout?: string;
message?: string;
};
for (const output of [execError.stdout, execError.stderr]) {
if (output) {
try {
const parsed = JSON.parse(output.trim());
if (parsed.error) return parsed.error;
} catch {
const trimmed = output.trim();
if (trimmed && !trimmed.startsWith("Traceback")) {
return trimmed;
}
}
}
}
if (execError.message) return execError.message;
}
return String(error);
}
export type ProgressCallback = (percent: number, stage: string) => void;
// ── Persistent dispatcher ───────────────────────────────────────────
interface PendingRequest {
resolve: (result: { stdout: string; stderr: string }) => void;
reject: (err: Error) => void;
onProgress?: ProgressCallback;
stderrLines: string[];
}
let dispatcher: ChildProcess | null = null;
let dispatcherReady = false;
let dispatcherFailed = false;
// biome-ignore lint/style/useConst: reassigned on dispatcher readiness signal
let dispatcherGpuAvailable = false;
const pendingRequests = new Map<string, PendingRequest>();
let stdoutBuffer = "";
function startDispatcher(): ChildProcess | null {
if (dispatcherFailed) return null;
try {
const child = spawn(getPythonPath(), [resolve(PYTHON_DIR, "dispatcher.py")], {
stdio: ["pipe", "pipe", "pipe"],
});
let stderrBuffer = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
// Readiness signal
if (parsed.ready === true) {
dispatcherReady = true;
dispatcherGpuAvailable = parsed.gpu === true;
continue;
}
// Progress event - route to the currently active request
if (typeof parsed.progress === "number" && typeof parsed.stage === "string") {
// Progress goes to all pending requests (only one should be active at a time
// since Python processes synchronously)
for (const req of pendingRequests.values()) {
req.onProgress?.(parsed.progress, parsed.stage);
}
}
} catch {
// Not JSON - collect as error output for pending requests
for (const req of pendingRequests.values()) {
req.stderrLines.push(trimmed);
}
}
}
});
child.stdout?.on("data", (chunk: Buffer) => {
stdoutBuffer += chunk.toString();
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const response = JSON.parse(trimmed);
const reqId = response.id;
const pending = pendingRequests.get(reqId);
if (pending) {
pendingRequests.delete(reqId);
if (response.exitCode !== 0) {
pending.reject(
new Error(
extractPythonError({
stdout: response.stdout,
stderr: pending.stderrLines.join("\n"),
}) || `Python script exited with code ${response.exitCode}`,
),
);
} else {
pending.resolve({
stdout: response.stdout || "",
stderr: pending.stderrLines.join("\n"),
});
}
}
} catch {
// Not a valid response line
}
}
});
child.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
// Venv python not found - mark as failed, will fall back to per-request
dispatcherFailed = true;
}
// Reject all pending requests
for (const [id, req] of pendingRequests.entries()) {
req.reject(new Error(extractPythonError(err)));
pendingRequests.delete(id);
}
dispatcher = null;
dispatcherReady = false;
});
child.on("close", () => {
// Reject all pending requests
for (const [id, req] of pendingRequests.entries()) {
req.reject(new Error("Python dispatcher exited unexpectedly"));
pendingRequests.delete(id);
}
dispatcher = null;
dispatcherReady = false;
});
return child;
} catch {
dispatcherFailed = true;
return null;
}
}
function getDispatcher(): ChildProcess | null {
if (dispatcherFailed) return null;
if (!dispatcher || dispatcher.killed) {
dispatcher = startDispatcher();
}
return dispatcher;
}
/**
* Send a request to the persistent Python dispatcher.
* Returns null if the dispatcher is unavailable (caller should fall back).
*/
function dispatcherRun(
scriptName: string,
args: string[],
options: { onProgress?: ProgressCallback; timeout?: number } = {},
): Promise<{ stdout: string; stderr: string }> | null {
const proc = getDispatcher();
if (!proc || !proc.stdin || !dispatcherReady) return null;
const id = randomUUID();
const timeout = options.timeout ?? 300000;
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
pendingRequests.delete(id);
rejectPromise(new Error("Python script timed out"));
}, timeout);
const wrappedResolve = (result: { stdout: string; stderr: string }) => {
clearTimeout(timer);
resolvePromise(result);
};
const wrappedReject = (err: Error) => {
clearTimeout(timer);
rejectPromise(err);
};
pendingRequests.set(id, {
resolve: wrappedResolve,
reject: wrappedReject,
onProgress: options.onProgress,
stderrLines: [],
});
const request = JSON.stringify({ id, script: scriptName.replace(".py", ""), args });
proc.stdin!.write(request + "\n");
});
}
/**
* Whether the Python dispatcher detected a CUDA GPU at startup.
*/
export function isGpuAvailable(): boolean {
return dispatcherGpuAvailable;
}
/**
* Shut down the persistent dispatcher process.
*/
export function shutdownDispatcher(): void {
if (dispatcher && !dispatcher.killed) {
dispatcher.stdin?.end();
dispatcher.kill("SIGTERM");
dispatcher = null;
dispatcherReady = false;
}
}
// ── Per-request fallback (original implementation) ──────────────────
function runPythonPerRequest(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
const scriptPath = resolve(PYTHON_DIR, scriptName);
const timeout = options.timeout ?? 300000;
return new Promise((resolvePromise, rejectPromise) => {
const trySpawn = (pythonBin: string, isFallback: boolean) => {
const child = spawn(pythonBin, [scriptPath, ...args], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
const stderrLines: string[] = [];
let stderrBuffer = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, timeout);
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderrBuffer += chunk.toString();
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed.progress === "number" && typeof parsed.stage === "string") {
options.onProgress?.(parsed.progress, parsed.stage);
continue;
}
} catch {
// Not JSON - collect as regular stderr
}
stderrLines.push(trimmed);
}
});
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT" && !isFallback) {
trySpawn("python3", true);
} else {
rejectPromise(new Error(extractPythonError(err)));
}
});
child.on("close", (code) => {
clearTimeout(timer);
if (stderrBuffer.trim()) {
stderrLines.push(stderrBuffer.trim());
}
if (timedOut) {
rejectPromise(new Error("Python script timed out"));
return;
}
const stderr = stderrLines.join("\n");
if (code !== 0) {
const errorText =
extractPythonError({ stdout: stdout.trim(), stderr }) ||
`Python script exited with code ${code}`;
rejectPromise(new Error(errorText));
return;
}
resolvePromise({ stdout: stdout.trim(), stderr });
});
};
trySpawn(getPythonPath(), false);
});
}
// ── Public API (unchanged signature) ────────────────────────────────
/**
* Run a Python script with real-time progress streaming via stderr.
*
* Tries the persistent dispatcher first for warm-start performance.
* Falls back to per-request spawning if the dispatcher is unavailable.
*/
export function runPythonWithProgress(
scriptName: string,
args: string[],
options: {
onProgress?: ProgressCallback;
timeout?: number;
} = {},
): Promise<{ stdout: string; stderr: string }> {
// Try persistent dispatcher first
const dispatcherPromise = dispatcherRun(scriptName, args, options);
if (dispatcherPromise) {
return dispatcherPromise;
}
// Fall back to per-request spawning
return runPythonPerRequest(scriptName, args, options);
}