evOo-local/packages/adapters/src/comfyui-provider.ts
Claude 3b97805f20
refactor(media): share the ComfyUI JSON request helpers
Queue, interrupt and prompt requests now go through one JSON helper, so the
cancel path reads like the rest of the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:45:50 +00:00

594 lines
22 KiB
TypeScript

import { randomInt, randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import type {
AdapterDescriptor,
GeneratedMedia,
GpuStatus,
ImageGenerationCapabilities,
ImageGenerationProvider,
ImageGenerationRequest,
MediaFile,
MediaProgressListener,
VideoGenerationCapabilities,
VideoGenerationProvider,
VideoGenerationRequest,
} from "@rakazo/adapter-kit";
import type { AttachmentKind } from "@rakazo/contracts";
import {
ARTIFACT_MAX_BYTES_BY_KIND,
attachmentKindOf,
MEDIA_VIDEO_MAX_SECONDS,
} from "@rakazo/contracts";
import { attachmentExtensionForMimeType, inferAttachmentMimeType } from "@rakazo/core";
import type { ComfyWorkflow, WorkflowTemplate, WorkflowValues } from "@rakazo/core/media/workflows";
import {
DEFAULT_WORKFLOWS,
fillWorkflow,
parseWorkflowTemplate,
} from "@rakazo/core/media/workflows";
import {
assertAllowedOpenAiCompatibleRequestUrl,
assertHttpsForKeyedOpenAiCompatibleUrl,
} from "./openai-compatible-url.js";
import { createOpenAiCompatibleFetch } from "./pi-openai-compatible-provider.js";
import { readBodyCapped } from "./web-ssrf.js";
export const COMFYUI_PROVIDER_ID = "comfyui";
/** Frame rate of generated video; frame counts follow LTX-Video's 8n+1 rule. */
export const COMFYUI_VIDEO_FPS = 24;
const MAX_IMAGE_SIDE = 2048;
const REQUEST_TIMEOUT_MS = 30_000;
const TRANSFER_TIMEOUT_MS = 120_000;
const CANCEL_TIMEOUT_MS = 5_000;
const IMAGE_PROMPT_TIMEOUT_MS = 10 * 60_000;
const VIDEO_PROMPT_TIMEOUT_MS = 30 * 60_000;
const POLL_INTERVAL_MS = 1_000;
const MAX_JSON_BYTES = 8 * 1024 * 1024;
const MAX_ERROR_CHARS = 400;
const MIB = 1024 * 1024;
const IMAGE_STEPS = 25;
const VIDEO_STEPS = 30;
const DEFAULT_STRENGTH = 0.6;
const DEFAULT_NEGATIVE_PROMPTS = {
image: "blurry, low quality, distorted, watermark, text",
video: "worst quality, inconsistent motion, blurry, jittery, distorted",
};
const JSON_HEADERS = { Accept: "application/json", "Content-Type": "application/json" };
export interface ComfyUiProviderConfig {
baseUrl: string;
apiKey?: string;
/** Checkpoint for `{{model}}`; the first installed image checkpoint when omitted. */
imageModel?: string;
/** API-format workflow JSON that replaces the matching default image workflow. */
imageWorkflow?: string;
/** API-format workflow JSON that replaces the matching default video workflow. */
videoWorkflow?: string;
fetch?: typeof globalThis.fetch;
/** How long to wait between checks for a finished prompt; tests shorten it. */
pollIntervalMs?: number;
}
export interface ComfyUiProvider extends ImageGenerationProvider, VideoGenerationProvider {
describe(): AdapterDescriptor<ImageGenerationCapabilities & VideoGenerationCapabilities>;
gpuStatus(signal?: AbortSignal): Promise<GpuStatus | undefined>;
}
type MediaKind = "image" | "video";
type ReadBody<T> = (response: Response, signal: AbortSignal) => Promise<T>;
interface OutputFile {
filename: string;
subfolder: string;
type: string;
mimeType: string;
kind: AttachmentKind;
}
interface GenerationJob {
kind: MediaKind;
template: WorkflowTemplate;
prompt: string;
negativePrompt?: string;
width: number;
height: number;
seed?: number;
count: number;
source?: MediaFile;
strength?: number;
frames?: number;
signal?: AbortSignal;
onProgress?: MediaProgressListener;
}
/**
* Image and video generation on a self-hosted ComfyUI server through its HTTP API: submit an
* API-format workflow, poll its history, download the outputs. Requests go through the same
* address-checked fetch as OpenAI-compatible model servers.
*/
export function createComfyUiProvider(config: ComfyUiProviderConfig): ComfyUiProvider {
return new ComfyUiClient(config);
}
class ComfyUiClient implements ComfyUiProvider {
private readonly baseUrl: URL;
private readonly apiKey: string | undefined;
private readonly imageModel: string | undefined;
private readonly imageWorkflow: WorkflowTemplate | undefined;
private readonly videoWorkflow: WorkflowTemplate | undefined;
private readonly fetch: typeof globalThis.fetch;
private readonly pollIntervalMs: number;
private readonly clientId = randomUUID();
constructor(config: ComfyUiProviderConfig) {
this.baseUrl = comfyUiBaseUrl(config.baseUrl);
this.apiKey = config.apiKey?.trim() || undefined;
assertHttpsForKeyedOpenAiCompatibleUrl(this.baseUrl, this.apiKey);
this.imageModel = config.imageModel?.trim() || undefined;
this.imageWorkflow = config.imageWorkflow?.trim()
? parseWorkflowTemplate(config.imageWorkflow)
: undefined;
this.videoWorkflow = config.videoWorkflow?.trim()
? parseWorkflowTemplate(config.videoWorkflow)
: undefined;
this.fetch = createOpenAiCompatibleFetch(config.fetch);
this.pollIntervalMs = config.pollIntervalMs ?? POLL_INTERVAL_MS;
}
describe() {
return {
id: COMFYUI_PROVIDER_ID,
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: {
textToImage: true,
imageToImage: true,
inpaint: false,
maxWidth: MAX_IMAGE_SIDE,
maxHeight: MAX_IMAGE_SIDE,
textToVideo: true,
imageToVideo: true,
maxDurationSeconds: MEDIA_VIDEO_MAX_SECONDS,
},
};
}
async gpuStatus(signal?: AbortSignal): Promise<GpuStatus | undefined> {
return parseGpuStatus(await this.getJson("system_stats", signal));
}
async generateImage(request: ImageGenerationRequest): Promise<GeneratedMedia[]> {
if (request.mask) throw new Error("ComfyUI inpainting is not supported");
return this.generate({
...request,
kind: "image",
template: pickTemplate(
this.imageWorkflow,
Boolean(request.source),
DEFAULT_WORKFLOWS.textToImage,
DEFAULT_WORKFLOWS.imageToImage,
),
width: roundSide(request.width, 8),
height: roundSide(request.height, 8),
count: request.count ?? 1,
});
}
async generateVideo(request: VideoGenerationRequest): Promise<GeneratedMedia[]> {
const seconds = Math.min(Math.max(request.durationSeconds, 1), MEDIA_VIDEO_MAX_SECONDS);
return this.generate({
...request,
kind: "video",
template: pickTemplate(
this.videoWorkflow,
Boolean(request.source),
DEFAULT_WORKFLOWS.textToVideo,
DEFAULT_WORKFLOWS.imageToVideo,
),
width: roundSide(request.width, 32),
height: roundSide(request.height, 32),
frames: 8 * Math.round((seconds * COMFYUI_VIDEO_FPS) / 8) + 1,
count: 1,
});
}
private async generate(job: GenerationJob): Promise<GeneratedMedia[]> {
const { template, signal } = job;
const values: WorkflowValues = {
prompt: job.prompt,
negative_prompt: job.negativePrompt ?? DEFAULT_NEGATIVE_PROMPTS[job.kind],
width: job.width,
height: job.height,
steps: job.kind === "image" ? IMAGE_STEPS : VIDEO_STEPS,
strength: job.strength ?? DEFAULT_STRENGTH,
frames: job.frames ?? 1,
fps: COMFYUI_VIDEO_FPS,
};
if (template.placeholders.has("model")) values.model = await this.imageCheckpoint(signal);
if (template.placeholders.has("image")) {
if (!job.source) throw new Error("ComfyUI workflow needs an input image");
values.image = await this.upload(job.source, signal);
}
const sized = template.placeholders.has("width") && template.placeholders.has("height");
const timed = template.placeholders.has("frames") && template.placeholders.has("fps");
const firstSeed = job.seed ?? randomInt(2 ** 48 - 1);
const results: GeneratedMedia[] = [];
for (let index = 0; index < job.count; index += 1) {
const graph = fillWorkflow(template, { ...values, seed: firstSeed + index });
const outputs = await this.runPrompt(graph, job.kind, signal);
const files = outputFiles(outputs, job.kind);
if (files.length === 0) throw new Error(`ComfyUI produced no ${job.kind}`);
for (const file of files) {
results.push({
bytes: await this.download(file, signal),
mimeType: file.mimeType,
name: file.filename,
...(sized && file.kind === job.kind ? { width: job.width, height: job.height } : {}),
...(timed && file.kind === "video"
? { durationMs: Math.round(((values.frames ?? 1) / COMFYUI_VIDEO_FPS) * 1000) }
: {}),
});
}
job.onProgress?.((index + 1) / job.count);
}
return results;
}
/** Submits a prompt and waits for its outputs; a cancelled or timed-out prompt is dequeued. */
private async runPrompt(
graph: ComfyWorkflow,
kind: MediaKind,
signal: AbortSignal | undefined,
): Promise<Record<string, unknown>> {
const deadline = AbortSignal.timeout(
kind === "image" ? IMAGE_PROMPT_TIMEOUT_MS : VIDEO_PROMPT_TIMEOUT_MS,
);
const runSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
const promptId = await this.submit(graph, runSignal);
try {
for (;;) {
const outputs = await this.finishedOutputs(promptId, runSignal);
if (outputs) return outputs;
await sleep(this.pollIntervalMs, undefined, { signal: runSignal });
}
} catch (error) {
if (!runSignal.aborted) throw error;
await this.cancelPrompt(promptId);
if (signal?.aborted) throw signal.reason ?? error;
throw new Error("ComfyUI timed out");
}
}
private async submit(graph: ComfyWorkflow, signal: AbortSignal): Promise<string> {
const payload = { prompt: graph, client_id: this.clientId };
return this.postJson("prompt", payload, signal, async (response, readSignal) => {
if (response.status === 400) {
const rejection = await readJson(response, readSignal).catch(() => undefined);
throw new Error(describeWorkflowRejection(rejection));
}
const result = await readOkJson(response, readSignal);
const promptId = isRecord(result) ? result.prompt_id : undefined;
if (typeof promptId !== "string" || !promptId) {
throw new Error("ComfyUI did not accept the workflow");
}
return promptId;
});
}
private async finishedOutputs(
promptId: string,
signal: AbortSignal,
): Promise<Record<string, unknown> | undefined> {
const history = await this.getJson(`history/${encodeURIComponent(promptId)}`, signal);
const entry = isRecord(history) ? history[promptId] : undefined;
if (!isRecord(entry)) return undefined;
const status = isRecord(entry.status) ? entry.status : {};
if (status.status_str === "error") throw new Error(describeExecutionError(status.messages));
if (status.completed === false) return undefined;
return isRecord(entry.outputs) ? entry.outputs : {};
}
/** Best effort: drops the prompt from the queue, or interrupts it when it is ours and running. */
private async cancelPrompt(promptId: string): Promise<void> {
try {
await this.postJson(
"queue",
{ delete: [promptId] },
undefined,
discardBody,
CANCEL_TIMEOUT_MS,
);
const queue = await this.getJson("queue", undefined, CANCEL_TIMEOUT_MS);
const running =
isRecord(queue) && Array.isArray(queue.queue_running) ? queue.queue_running : [];
if (running.some((entry) => Array.isArray(entry) && entry[1] === promptId)) {
const interrupt = { prompt_id: promptId };
await this.postJson("interrupt", interrupt, undefined, discardBody, CANCEL_TIMEOUT_MS);
}
} catch {
// The prompt then finishes on its own; nothing waits for it.
}
}
private async imageCheckpoint(signal: AbortSignal | undefined): Promise<string> {
if (this.imageModel) return this.imageModel;
const info = await this.getJson("object_info/CheckpointLoaderSimple", signal);
const videoCheckpoints = new Set(
[DEFAULT_WORKFLOWS.textToVideo, DEFAULT_WORKFLOWS.imageToVideo, this.videoWorkflow].flatMap(
(template) => (template ? checkpointNames(template.graph) : []),
),
);
const checkpoint = parseCheckpointList(info).find((name) => !videoCheckpoints.has(name));
if (!checkpoint) throw new Error("ComfyUI has no image checkpoint installed");
return checkpoint;
}
private async upload(file: MediaFile, signal: AbortSignal | undefined): Promise<string> {
const form = new FormData();
const extension = attachmentExtensionForMimeType(file.mimeType) || ".png";
form.append(
"image",
new Blob([Buffer.from(file.bytes)], { type: file.mimeType }),
`evoo-${randomUUID()}${extension}`,
);
form.append("type", "input");
form.append("overwrite", "true");
// Serialize here: the address-checked fetch runs on the undici package, whose FormData
// class differs from the global one.
const encoded = new Response(form);
const headers = {
Accept: "application/json",
"Content-Type": encoded.headers.get("content-type") ?? "",
};
const body = new Uint8Array(await encoded.arrayBuffer());
return this.send("upload/image", { method: "POST", headers, body }, signal, {
timeoutMs: TRANSFER_TIMEOUT_MS,
read: async (response, readSignal) => {
const result = await readOkJson(response, readSignal);
const name = isRecord(result) && typeof result.name === "string" ? result.name : "";
if (!name) throw new Error("ComfyUI did not accept the image");
const subfolder =
isRecord(result) && typeof result.subfolder === "string" ? result.subfolder : "";
return subfolder ? `${subfolder}/${name}` : name;
},
});
}
private download(file: OutputFile, signal: AbortSignal | undefined): Promise<Uint8Array> {
const query = new URLSearchParams({
filename: file.filename,
subfolder: file.subfolder,
type: file.type,
});
return this.send(`view?${query}`, {}, signal, {
timeoutMs: TRANSFER_TIMEOUT_MS,
read: async (response, readSignal) => {
await assertOk(response);
return readBodyCapped(response, ARTIFACT_MAX_BYTES_BY_KIND[file.kind], readSignal);
},
});
}
private getJson(path: string, signal: AbortSignal | undefined, timeoutMs?: number) {
const init = { headers: { Accept: "application/json" } };
return this.send(path, init, signal, { timeoutMs, read: readOkJson });
}
private postJson<T>(
path: string,
payload: unknown,
signal: AbortSignal | undefined,
read: ReadBody<T>,
timeoutMs?: number,
): Promise<T> {
const init = { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(payload) };
return this.send(path, init, signal, { timeoutMs, read });
}
/** One request, with the timeout covering the response body too. */
private async send<T>(
path: string,
init: {
method?: string;
headers?: Record<string, string>;
body?: string | Uint8Array<ArrayBuffer>;
},
signal: AbortSignal | undefined,
options: { timeoutMs?: number; read: ReadBody<T> },
): Promise<T> {
const timeout = AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const headers: Record<string, string> = { ...init.headers };
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
try {
const response = await this.fetch(new URL(path, this.baseUrl).href, {
method: init.method ?? "GET",
headers,
body: init.body,
redirect: "error",
signal: requestSignal,
});
return await options.read(response, requestSignal);
} catch (error) {
if (signal?.aborted) throw error;
if (timeout.aborted) throw new Error("ComfyUI timed out");
// undici reports refused or reset connections as a bare "fetch failed".
if (error instanceof TypeError && error.message === "fetch failed") {
throw new Error("ComfyUI is unreachable");
}
throw error;
}
}
}
function comfyUiBaseUrl(raw: string): URL {
const url = assertAllowedOpenAiCompatibleRequestUrl(raw.trim());
url.search = "";
url.hash = "";
url.pathname = `${url.pathname.replace(/\/+$/, "")}/`;
return url;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function truncate(text: string): string {
return text.length > MAX_ERROR_CHARS ? `${text.slice(0, MAX_ERROR_CHARS - 1)}…` : text;
}
function roundSide(side: number, multiple: number): number {
const rounded = Math.round(side / multiple) * multiple;
return Math.min(Math.max(rounded, multiple * Math.ceil(64 / multiple)), MAX_IMAGE_SIDE);
}
/** A custom workflow replaces the default that matches whether it takes an input image. */
function pickTemplate(
custom: WorkflowTemplate | undefined,
withImage: boolean,
textDefault: WorkflowTemplate,
imageDefault: WorkflowTemplate,
): WorkflowTemplate {
if (custom && custom.placeholders.has("image") === withImage) return custom;
return withImage ? imageDefault : textDefault;
}
function checkpointNames(graph: ComfyWorkflow): string[] {
return Object.values(graph).flatMap((node) =>
typeof node.inputs.ckpt_name === "string" ? [node.inputs.ckpt_name] : [],
);
}
async function discardBody(response: Response): Promise<void> {
await response.body?.cancel().catch(() => undefined);
}
async function assertOk(response: Response): Promise<void> {
if (response.ok) return;
await discardBody(response);
throw new Error(`ComfyUI returned ${response.status}`);
}
async function readJson(response: Response, signal: AbortSignal): Promise<unknown> {
const bytes = await readBodyCapped(response, MAX_JSON_BYTES, signal);
try {
return JSON.parse(new TextDecoder().decode(bytes));
} catch {
throw new Error("ComfyUI returned invalid JSON");
}
}
async function readOkJson(response: Response, signal: AbortSignal): Promise<unknown> {
await assertOk(response);
return readJson(response, signal);
}
/**
* ComfyUI can evict its own models, so memory its PyTorch process holds counts as available;
* only memory other processes use (another model server on the same GPU) does not.
*/
export function parseGpuStatus(body: unknown): GpuStatus | undefined {
const devices = isRecord(body) && Array.isArray(body.devices) ? body.devices : [];
for (const device of devices) {
if (!isRecord(device) || device.type === "cpu") continue;
const total = finiteNumber(device.vram_total);
const free = finiteNumber(device.vram_free);
if (!total || free === undefined) continue;
const reserved = finiteNumber(device.torch_vram_total) ?? 0;
const reservedFree = finiteNumber(device.torch_vram_free) ?? 0;
const available = Math.min(total, Math.max(0, free - reservedFree + reserved));
return { vramFreeMb: Math.floor(available / MIB), vramTotalMb: Math.floor(total / MIB) };
}
return undefined;
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Older ComfyUI lists combo values as `[[...names], {...}]`, newer as `["COMBO", {options}]`. */
export function parseCheckpointList(body: unknown): string[] {
const loader = isRecord(body) ? body.CheckpointLoaderSimple : undefined;
const input = isRecord(loader) ? loader.input : undefined;
const required = isRecord(input) ? input.required : undefined;
const spec = isRecord(required) ? required.ckpt_name : undefined;
if (!Array.isArray(spec)) return [];
const names = Array.isArray(spec[0])
? spec[0]
: isRecord(spec[1]) && Array.isArray(spec[1].options)
? spec[1].options
: [];
return names.filter((name): name is string => typeof name === "string" && name.length > 0);
}
/** Saved files win over previews; video runs return clips (or animations), not frames. */
function outputFiles(outputs: Record<string, unknown>, kind: MediaKind): OutputFile[] {
const files: OutputFile[] = [];
for (const node of Object.values(outputs)) {
if (!isRecord(node)) continue;
for (const key of ["images", "gifs", "videos"]) {
const entries = node[key];
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!isRecord(entry) || typeof entry.filename !== "string" || !entry.filename) continue;
const mimeType = inferAttachmentMimeType(entry.filename);
if (!mimeType) continue;
files.push({
filename: entry.filename,
subfolder: typeof entry.subfolder === "string" ? entry.subfolder : "",
type: typeof entry.type === "string" ? entry.type : "output",
mimeType,
kind: attachmentKindOf(mimeType),
});
}
}
}
const videos = files.filter((file) => file.kind === "video");
const wanted =
kind === "image"
? files.filter((file) => file.kind === "image")
: videos.length > 0
? videos
: files.filter((file) => file.mimeType === "image/gif" || file.mimeType === "image/webp");
const saved = wanted.filter((file) => file.type === "output");
return saved.length > 0 ? saved : wanted;
}
export function describeWorkflowRejection(body: unknown): string {
const problems: string[] = [];
const nodeErrors = isRecord(body) && isRecord(body.node_errors) ? body.node_errors : {};
for (const node of Object.values(nodeErrors)) {
if (!isRecord(node) || !Array.isArray(node.errors)) continue;
for (const error of node.errors) {
if (!isRecord(error)) continue;
const parts = [node.class_type, error.message, error.details];
problems.push(parts.filter((part) => typeof part === "string" && part).join(": "));
}
}
if (problems.length === 0 && isRecord(body) && isRecord(body.error)) {
if (typeof body.error.message === "string") problems.push(body.error.message);
}
return truncate(
problems.length > 0
? `ComfyUI rejected the workflow: ${problems.join("; ")}`
: "ComfyUI rejected the workflow",
);
}
export function describeExecutionError(messages: unknown): string {
const events = Array.isArray(messages) ? messages : [];
for (const event of events) {
if (!Array.isArray(event)) continue;
const [name, data] = event;
if (name === "execution_interrupted") return "ComfyUI interrupted the run";
if (name !== "execution_error" || !isRecord(data)) continue;
const node = typeof data.node_type === "string" ? data.node_type : "a node";
const message =
typeof data.exception_message === "string"
? data.exception_message.trim().split("\n")[0]
: "";
return truncate(`ComfyUI failed in ${node}${message ? `: ${message}` : ""}`);
}
return "ComfyUI failed";
}