Files
openmaic/OpenMAIC/lib/media/adapters/comfyui-image-adapter.ts
2026-08-16 14:58:47 +08:00

683 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ComfyUI Image Generation Adapter
*
* Submits a prompt to a local (or remote) ComfyUI instance via its
* REST API, polls for completion, and returns the image as base64.
*
* Endpoint: http://localhost:8188 (configurable via baseUrl)
* No API key required.
*
* Workflow loading strategy:
* 1. If config.workflowJson is set (an already-parsed object), use it.
* 2. Else if config.model names a workflow, load that file (validated against
* the public/ allowlist server-side).
* 3. Else default to the first workflow file discovered in public/.
* → Ship at least one comfyui-*.json in your Next.js public/ directory.
*
* Nodes patched at runtime:
* "String (Multiline - Prompt)" → inputs.value = prompt
* "Empty Flux 2 Latent" → inputs.width / height
* "KSampler" → inputs.seed = random int
*/
import type {
ImageGenerationConfig,
ImageGenerationOptions,
ImageGenerationResult,
} from '../types';
import { aspectRatioToDimensions, IMAGE_PROVIDERS } from '../image-providers';
// ---------------------------------------------------------------------------
// Logger (matches openmaic's [TIMESTAMP] [LEVEL] [Component] format)
// ---------------------------------------------------------------------------
const COMPONENT = 'ComfyUI Image';
const log = {
info: (msg: string) => console.log(`[${new Date().toISOString()}] [INFO] [${COMPONENT}] ${msg}`),
warn: (msg: string) =>
console.warn(`[${new Date().toISOString()}] [WARN] [${COMPONENT}] ${msg}`),
error: (msg: string) =>
console.error(`[${new Date().toISOString()}] [ERROR] [${COMPONENT}] ${msg}`),
debug: (msg: string) =>
console.debug(`[${new Date().toISOString()}] [DEBUG] [${COMPONENT}] ${msg}`),
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_BASE_URL = 'http://localhost:8188';
/** Default workflow filename (relative to Next.js public/) */
const DEFAULT_WORKFLOW_FILENAME = 'comfyui-workflow.json';
/** Default public path for the workflow JSON (relative to Next.js public/) */
const DEFAULT_WORKFLOW_PUBLIC_PATH = `/${DEFAULT_WORKFLOW_FILENAME}`;
/** Polling interval while waiting for the queue to finish (ms) */
const POLL_INTERVAL_MS = 1500;
/** Hard timeout for a single generation request (ms) */
const GENERATION_TIMEOUT_MS = 300_000; // 5 minutes
/**
* Per-request timeout for individual ComfyUI HTTP calls (ms). The 5-minute
* bound above is on the *polling loop* only — without this, an awaited call
* whose socket connects but never responds (queuePrompt / pollHistory /
* fetchImageAsBase64) could hang indefinitely.
*/
const FETCH_TIMEOUT_MS = 30_000;
/** Timeout for the lightweight connectivity probe (ms) */
const CONNECTIVITY_TIMEOUT_MS = 10_000;
/** Fallback max dimension if provider has no maxResolution defined */
const DEFAULT_MAX_WIDTH = 1024;
// ---------------------------------------------------------------------------
// Extended config type (avoids touching shared types.ts)
// ---------------------------------------------------------------------------
interface ComfyUIImageGenerationConfig extends ImageGenerationConfig {
/**
* Pre-parsed workflow object. When supplied the adapter skips the
* fetch() call and uses this directly (deep-cloned on each request).
*/
workflowJson?: Record<string, unknown>;
/**
* Public URL path to fetch the workflow JSON from.
* Defaults to "/comfyui-workflow.json" (served from Next.js public/).
*/
workflowPublicPath?: string;
}
// ---------------------------------------------------------------------------
// Workflow helpers
// ---------------------------------------------------------------------------
/**
* Load and deep-clone the workflow.
* - Browser: fetch() from the Next.js public/ origin
* - Server (API route): read directly from disk with fs — relative URLs
* don't work in Node fetch since there's no browser origin to resolve against
*
* Security: config.model is client-controlled (it flows straight from the
* x-image-model request header). It is never trusted as a raw path — it is
* checked against isComfyuiWorkflowFilename() (bare basename, no traversal)
* AND against the live directory listing from listComfyuiWorkflowFilenames()
* (the same list /api/comfyui-workflows shows the user), before it's used to
* build a filesystem path. See lib/media/comfyui-workflows.ts.
*/
async function loadWorkflow(
config: ComfyUIImageGenerationConfig,
): Promise<Record<string, unknown>> {
// Fast path: caller already supplied the parsed JSON.
if (config.workflowJson) {
log.debug('Using pre-supplied workflowJson (skipping fetch)');
return JSON.parse(JSON.stringify(config.workflowJson)) as Record<string, unknown>;
}
// Server-side: read from disk (public/ directory relative to cwd)
if (typeof window === 'undefined') {
const fs = await import('fs');
const path = await import('path');
const { isComfyuiWorkflowFilename, listComfyuiWorkflowFilenames } =
await import('../comfyui-workflows');
let filename: string;
if (config.workflowPublicPath) {
// Server-set override (e.g. a caller passing workflowJson's sibling
// path directly) — not client-controlled, so a plain basename() is
// enough here rather than the full allowlist check below.
filename = path.basename(config.workflowPublicPath);
} else if (config.model) {
// Client-controlled (x-image-model header) — must be a safe basename
// AND a real file /api/comfyui-workflows would also list. Anything
// else is rejected outright rather than silently falling back, so a
// caller can't probe the filesystem via unexpected .json files.
if (!isComfyuiWorkflowFilename(config.model)) {
log.error(`Rejected unsafe workflow identifier: "${config.model}"`);
throw new Error(`ComfyUI: "${config.model}" is not a valid workflow filename.`);
}
const known = await listComfyuiWorkflowFilenames();
if (!known.includes(config.model)) {
log.error(`Rejected unknown workflow identifier: "${config.model}"`);
throw new Error(
`ComfyUI: workflow "${config.model}" was not found. ` +
'Choose one returned by /api/comfyui-workflows.',
);
}
filename = config.model;
} else {
// No workflow specified. This happens on two real paths:
// 1. Classroom / autonomous media generation, which has no model id
// to pass for ComfyUI (IMAGE_PROVIDERS['comfyui-image'].models is []).
// 2. The provider is selected in Settings but no workflow has been
// clicked yet, so x-image-model is empty.
// Default to the first workflow actually discovered in public/ rather
// than a hard-coded name: the set of workflow files is user-supplied and
// nothing guarantees any particular filename (e.g. comfyui-workflow.json)
// exists. This is the same list /api/comfyui-workflows shows the user.
const known = await listComfyuiWorkflowFilenames();
if (known.length === 0) {
log.error('No ComfyUI workflow files found in public/');
throw new Error(
'ComfyUI: no workflow JSON files found in the public/ folder. ' +
'Add at least one comfyui-*.json workflow — see comfyui-setup-instructions.md.',
);
}
filename = known[0];
log.info(`No workflow specified — defaulting to first available: "${filename}"`);
}
const publicDir = path.join(process.cwd(), 'public');
const filePath = path.join(publicDir, filename);
// Defense in depth: even after the allowlist check above, verify the
// resolved path is still inside public/ before reading it. path.join
// does NOT stop ".." segments from escaping a base directory, so this
// check — not the join above — is what actually prevents traversal.
const resolvedPublicDir = path.resolve(publicDir) + path.sep;
if (!path.resolve(filePath).startsWith(resolvedPublicDir)) {
log.error(`Refusing to read outside public/ directory: "${filePath}"`);
throw new Error('ComfyUI: resolved workflow path escapes the public/ directory.');
}
log.info(`Loading workflow from disk: "${filePath}"`);
if (!fs.existsSync(filePath)) {
log.error(`Workflow file not found at "${filePath}"`);
throw new Error(
`ComfyUI: workflow file not found at "${filePath}". ` +
'Place comfyui-workflow.json in your Next.js public/ folder.',
);
}
const raw = fs.readFileSync(filePath, 'utf-8');
log.debug(`Workflow loaded from disk successfully`);
return JSON.parse(raw) as Record<string, unknown>;
}
// Browser-side: fetch from origin.
//
// NOTE: generation always runs server-side (via the API route), so this
// branch is currently unreachable for real generations. It enforces the
// basename/traversal check (isComfyuiWorkflowFilename) but NOT the live
// directory allowlist the server branch uses — listComfyuiWorkflowFilenames()
// reads the filesystem and deliberately returns [] in the browser, so there
// is nothing to check against here. The basename check is still the
// traversal guard; the missing allowlist is an intentional consequence of
// this path being client-side, not an oversight.
let publicPath = DEFAULT_WORKFLOW_PUBLIC_PATH;
if (config.workflowPublicPath) {
publicPath = config.workflowPublicPath;
} else if (config.model) {
const { isComfyuiWorkflowFilename } = await import('../comfyui-workflows');
if (!isComfyuiWorkflowFilename(config.model)) {
log.error(`Rejected unsafe workflow identifier: "${config.model}"`);
throw new Error(`ComfyUI: "${config.model}" is not a valid workflow filename.`);
}
publicPath = `/${config.model}`;
}
const url = `${window.location.origin}${publicPath}`;
log.info(`Loading workflow from "${url}"`);
const response = await fetch(url);
if (!response.ok) {
log.error(`Failed to load workflow from "${url}" (HTTP ${response.status})`);
throw new Error(
`ComfyUI: could not load workflow from "${url}" (HTTP ${response.status}). ` +
'Place comfyui-workflow.json in your Next.js public/ folder.',
);
}
log.debug(`Workflow loaded from URL successfully`);
return (await response.json()) as Record<string, unknown>;
}
/**
* Resolve the output dimensions for a request, clamped to fit inside the
* provider's maxResolution bounding box while preserving aspect ratio.
*
* aspectRatioToDimensions() always pins width to maxWidth and scales height,
* so a portrait ratio overflows the declared max height (e.g. 9:16 at
* maxWidth 1920 → 1920×3413, well past a 1920×1920 cap). Feeding that
* straight into the latent-size node can OOM or fail generation. Scaling
* both axes down by the tighter of the width/height ratios keeps the result
* inside maxWidth × maxHeight without distorting the aspect ratio.
*
* Returns null when no dimensions can be resolved (caller uses workflow
* defaults in that case).
*/
function resolveDimensions(
options: ImageGenerationOptions,
maxWidth: number,
maxHeight: number,
): { width: number; height: number } | null {
const raw = options.aspectRatio
? aspectRatioToDimensions(options.aspectRatio, maxWidth)
: options.width && options.height
? { width: options.width, height: options.height }
: null;
if (!raw) return null;
const scale = Math.min(1, maxWidth / raw.width, maxHeight / raw.height);
return {
width: Math.round(raw.width * scale),
height: Math.round(raw.height * scale),
};
}
/**
* Safely return a workflow node's `inputs` object, or undefined if the node
* is malformed (missing/!object inputs). Guards the direct `node.inputs[...]`
* assignments below against a TypeError on a hand-edited workflow file.
*/
function nodeInputs(node: unknown): Record<string, unknown> | undefined {
const inputs = (node as Record<string, unknown> | undefined)?.['inputs'];
return inputs && typeof inputs === 'object' ? (inputs as Record<string, unknown>) : undefined;
}
/**
* Walk every node in the workflow and return the id of the first node
* whose _meta.title matches title (case-insensitive).
*/
function findNodeIdByTitle(workflow: Record<string, unknown>, title: string): string | undefined {
const lower = title.toLowerCase();
for (const [id, node] of Object.entries(workflow)) {
const meta = (node as Record<string, unknown>)['_meta'] as Record<string, unknown> | undefined;
if (typeof meta?.title === 'string' && meta.title.toLowerCase() === lower) {
return id;
}
}
return undefined;
}
/**
* Patch the workflow clone with the caller-supplied generation options.
*
* Prompt injection priority:
* 1. Node titled "Input Prompt" (preferred — explicit dedicated node)
* 2. Node titled "String (Multiline - Prompt)" (legacy fallback)
*
* Dimension injection priority:
* 1. Nodes titled "Width" and "Height" (preferred — explicit dedicated nodes)
* 2. Node titled "Empty Flux 2 Latent" (legacy fallback — patches inputs directly)
*/
function patchWorkflow(
workflow: Record<string, unknown>,
options: ImageGenerationOptions,
maxWidth: number,
maxHeight: number,
): void {
// --- Resolve dimensions once (clamped to the provider's max resolution) ----
const dims = resolveDimensions(options, maxWidth, maxHeight);
// --- Prompt node -----------------------------------------------------------
// Try "Input Prompt" first, fall back to "String (Multiline - Prompt)"
const promptNodeId =
findNodeIdByTitle(workflow, 'Input Prompt') ??
findNodeIdByTitle(workflow, 'String (Multiline - Prompt)');
if (!promptNodeId) {
log.error('No prompt node found — add a node titled "Input Prompt" to your workflow');
throw new Error(
'ComfyUI workflow is missing a prompt input node. ' +
'Add a node titled "Input Prompt" (or "String (Multiline - Prompt)") to your workflow.',
);
}
const promptInputs = nodeInputs(workflow[promptNodeId]);
if (!promptInputs) {
log.error(`Prompt node (id: ${promptNodeId}) has no "inputs" object`);
throw new Error(
`ComfyUI workflow prompt node (id: ${promptNodeId}) is malformed — it has no "inputs" object. ` +
'Re-export the workflow in API format (see comfyui-setup-instructions.md).',
);
}
promptInputs['value'] = options.prompt;
log.debug(
`Patched prompt node (id: ${promptNodeId}) → "${options.prompt.slice(0, 80)}${options.prompt.length > 80 ? '…' : ''}"`,
);
// --- Width / Height nodes (preferred) -------------------------------------
const widthNodeId = findNodeIdByTitle(workflow, 'Width');
const heightNodeId = findNodeIdByTitle(workflow, 'Height');
if (widthNodeId && heightNodeId) {
// Explicit Width / Height primitive nodes found — patch them
if (dims) {
const widthInputs = nodeInputs(workflow[widthNodeId]);
const heightInputs = nodeInputs(workflow[heightNodeId]);
if (widthInputs && heightInputs) {
widthInputs['value'] = dims.width;
heightInputs['value'] = dims.height;
log.debug(`Patched Width node (id: ${widthNodeId}) → ${dims.width}`);
log.debug(`Patched Height node (id: ${heightNodeId}) → ${dims.height}`);
} else {
log.warn('Width/Height nodes are malformed (missing "inputs") — using workflow defaults');
}
} else {
log.debug('Width/Height nodes found but no dimensions resolved — using workflow defaults');
}
} else {
// Fall back to patching the latent size node directly
if (widthNodeId || heightNodeId) {
log.warn(
'Only one of "Width"/"Height" nodes found — both are needed. Falling back to latent node.',
);
}
const latentNodeId = findNodeIdByTitle(workflow, 'Empty Flux 2 Latent');
if (latentNodeId) {
const latentInputs = nodeInputs(workflow[latentNodeId]);
if (dims && latentInputs) {
latentInputs['width'] = dims.width;
latentInputs['height'] = dims.height;
log.debug(
`Patched latent size node (id: ${latentNodeId}) → ${dims.width}×${dims.height} (aspectRatio: ${options.aspectRatio ?? 'none'})`,
);
} else if (dims && !latentInputs) {
log.warn(
`Latent size node (id: ${latentNodeId}) is malformed (missing "inputs") — using workflow defaults`,
);
} else {
log.debug(
`Latent size node (id: ${latentNodeId}) — no dimensions resolved, using workflow defaults`,
);
}
} else {
log.warn(
'No dimension nodes found ("Width"/"Height" or "Empty Flux 2 Latent") — using workflow defaults',
);
}
}
// --- KSampler seed ---------------------------------------------------------
const samplerNodeId = findNodeIdByTitle(workflow, 'KSampler');
if (samplerNodeId) {
const samplerInputs = nodeInputs(workflow[samplerNodeId]);
if (samplerInputs) {
const seed = Math.floor(Math.random() * 1e15);
samplerInputs['seed'] = seed;
log.debug(`Patched KSampler seed (id: ${samplerNodeId}) → ${seed}`);
} else {
log.warn(
`KSampler node (id: ${samplerNodeId}) is malformed (missing "inputs") — seed not randomised`,
);
}
} else {
log.warn('KSampler node not found — seed not randomised');
}
}
// ---------------------------------------------------------------------------
// ComfyUI REST helpers
// ---------------------------------------------------------------------------
interface QueuePromptResponse {
prompt_id: string;
number: number;
node_errors: Record<string, unknown>;
}
interface HistoryEntry {
outputs: Record<
string,
{
images?: Array<{ filename: string; subfolder: string; type: string }>;
}
>;
status: {
status_str: string;
completed: boolean;
// ComfyUI records execution events here as [eventName, data] tuples.
// On failure an "execution_error" event carries the real reason.
messages?: Array<[string, Record<string, unknown>]>;
};
}
/**
* Pull a human-readable reason out of a failed history entry's message log.
* Returns undefined if no execution_error detail is present.
*/
function extractExecutionError(entry: HistoryEntry): string | undefined {
const messages = entry.status?.messages;
if (!Array.isArray(messages)) return undefined;
for (const [event, data] of messages) {
if (event === 'execution_error' && data) {
const nodeType = typeof data['node_type'] === 'string' ? data['node_type'] : undefined;
const exception =
typeof data['exception_message'] === 'string' ? data['exception_message'] : undefined;
const parts = [nodeType, exception].filter(Boolean);
if (parts.length > 0) return parts.join(': ');
return 'execution_error';
}
}
return undefined;
}
async function queuePrompt(
baseUrl: string,
workflow: Record<string, unknown>,
clientId: string,
): Promise<string> {
log.info(`Submitting workflow to queue [client_id: ${clientId}]`);
const response = await fetch(`${baseUrl}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const text = await response.text();
log.error(`/prompt request failed (HTTP ${response.status}): ${text}`);
throw new Error(`ComfyUI /prompt failed (${response.status}): ${text}`);
}
const data = (await response.json()) as QueuePromptResponse;
if (data.node_errors && Object.keys(data.node_errors).length > 0) {
log.error(`Node errors returned: ${JSON.stringify(data.node_errors)}`);
throw new Error(`ComfyUI reported node errors: ${JSON.stringify(data.node_errors)}`);
}
log.info(`Queued successfully — prompt_id: ${data.prompt_id} (queue position: ${data.number})`);
return data.prompt_id;
}
async function pollHistory(baseUrl: string, promptId: string): Promise<HistoryEntry | null> {
// A single poll timing out or blipping must not abort the whole generation —
// return null so the caller's loop simply tries again on the next interval.
try {
const response = await fetch(`${baseUrl}/history/${promptId}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
const data = (await response.json()) as Record<string, HistoryEntry>;
return data[promptId] ?? null;
} catch (err) {
log.debug(`Poll request failed (will retry): ${err}`);
return null;
}
}
async function fetchImageAsBase64(
baseUrl: string,
filename: string,
subfolder: string,
type: string,
): Promise<string> {
const params = new URLSearchParams({ filename, subfolder, type });
const response = await fetch(`${baseUrl}/view?${params.toString()}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`ComfyUI /view failed (${response.status}) for image "${filename}"`);
}
const buffer = await response.arrayBuffer();
// This runs server-side, so encode with Buffer (single native pass) rather
// than a multi-MB per-byte String.fromCharCode loop. The btoa fallback is
// retained only to keep this module import-safe in the browser bundle; it
// is never exercised in practice because generation runs server-side.
if (typeof Buffer !== 'undefined') {
return Buffer.from(buffer).toString('base64');
}
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Lightweight connectivity test — calls GET /system_stats.
* Returns 200 when ComfyUI is running and reachable.
*/
export async function testComfyuiImageConnectivity(
config: ImageGenerationConfig,
): Promise<{ success: boolean; message: string }> {
const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
log.info(`Testing connectivity to ${baseUrl}`);
try {
const response = await fetch(`${baseUrl}/system_stats`, {
redirect: 'manual',
signal: AbortSignal.timeout(CONNECTIVITY_TIMEOUT_MS),
});
if (response.ok) {
log.info(`Connectivity test passed — ComfyUI is reachable at ${baseUrl}`);
return { success: true, message: 'Connected to ComfyUI' };
}
log.warn(`Connectivity test failed — HTTP ${response.status} from ${baseUrl}`);
return {
success: false,
message: `ComfyUI returned HTTP ${response.status}. Is it running at ${baseUrl}?`,
};
} catch (err) {
log.error(`Connectivity test error: ${err}`);
return {
success: false,
message: `ComfyUI connectivity error: ${err}. Is it running at ${baseUrl}?`,
};
}
}
export async function generateWithComfyuiImage(
config: ImageGenerationConfig,
options: ImageGenerationOptions,
): Promise<ImageGenerationResult> {
const baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
const comfyConfig = config as ComfyUIImageGenerationConfig;
log.info(`Starting image generation [baseUrl: ${baseUrl}] [model: ${config.model ?? 'default'}]`);
log.info(`Prompt: "${options.prompt.slice(0, 120)}${options.prompt.length > 120 ? '…' : ''}"`);
log.debug(
`Options: ${JSON.stringify({ width: options.width, height: options.height, aspectRatio: options.aspectRatio })}`,
);
const startTime = Date.now();
// Resolve the provider's max resolution (set in image-providers.ts). Both
// axes are needed so portrait ratios can be clamped to the bounding box.
const maxResolution = IMAGE_PROVIDERS[config.providerId]?.maxResolution;
const maxWidth = maxResolution?.width ?? DEFAULT_MAX_WIDTH;
const maxHeight = maxResolution?.height ?? DEFAULT_MAX_WIDTH;
// 1. Load and patch the workflow -------------------------------------------
const workflow = await loadWorkflow(comfyConfig);
patchWorkflow(workflow, options, maxWidth, maxHeight);
// 2. Client ID for this request --------------------------------------------
const clientId = `openmaic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// 3. Submit to the queue ---------------------------------------------------
const promptId = await queuePrompt(baseUrl, workflow, clientId);
// 4. Poll history until complete -------------------------------------------
const deadline = Date.now() + GENERATION_TIMEOUT_MS;
let entry: HistoryEntry | null = null;
let pollCount = 0;
log.info(`Polling for completion [prompt_id: ${promptId}]`);
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
pollCount++;
entry = await pollHistory(baseUrl, promptId);
// Fail fast on a runtime execution error. A workflow that errors mid-run
// records completed:false with status_str:"error", so without this check
// the loop would poll the full timeout and then throw a misleading
// "timed out" instead of the real cause.
if (entry?.status?.status_str === 'error') {
const detail = extractExecutionError(entry);
log.error(`Workflow execution error [prompt_id: ${promptId}]${detail ? `: ${detail}` : ''}`);
throw new Error(
`ComfyUI workflow execution failed (prompt_id: ${promptId})` +
(detail ? `: ${detail}` : '. Check the ComfyUI server logs for details.'),
);
}
if (entry?.status?.completed) {
log.info(
`Generation complete after ${pollCount} poll(s) (${((Date.now() - startTime) / 1000).toFixed(1)}s)`,
);
break;
}
if (pollCount % 10 === 0) {
log.debug(
`Still waiting… ${pollCount} polls, ${((Date.now() - startTime) / 1000).toFixed(0)}s elapsed`,
);
}
}
if (!entry?.status?.completed) {
log.error(
`Generation timed out after ${GENERATION_TIMEOUT_MS / 1000}s [prompt_id: ${promptId}]`,
);
throw new Error(
`ComfyUI generation timed out after ${GENERATION_TIMEOUT_MS / 1000}s ` +
`(prompt_id: ${promptId})`,
);
}
// 5. Extract the first output image ----------------------------------------
let imageInfo: { filename: string; subfolder: string; type: string } | undefined;
for (const nodeOutput of Object.values(entry.outputs)) {
if (nodeOutput.images && nodeOutput.images.length > 0) {
imageInfo = nodeOutput.images[0];
break;
}
}
if (!imageInfo) {
log.error('Generation finished but no images found in output nodes');
throw new Error(
'ComfyUI finished but returned no images. ' +
'Check that your workflow includes a SaveImage node.',
);
}
log.info(`Fetching image "${imageInfo.filename}" from ComfyUI /view`);
// 6. Download and encode the image -----------------------------------------
const base64 = await fetchImageAsBase64(
baseUrl,
imageInfo.filename,
imageInfo.subfolder,
imageInfo.type,
);
const totalMs = Date.now() - startTime;
// Report the same clamped dimensions that were patched into the workflow.
const dims = resolveDimensions(options, maxWidth, maxHeight);
log.info(
`Image generation complete — ${imageInfo.filename} (${dims?.width ?? options.width ?? 1024}×${dims?.height ?? options.height ?? 1024}) in ${(totalMs / 1000).toFixed(1)}s`,
);
return {
base64,
width: dims?.width ?? options.width ?? 1024,
height: dims?.height ?? options.height ?? 1024,
};
}