UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

199 lines • 9.63 kB
// LLM captioning for dataset images — one vision turn per image through the // user's OWN Claude subscription (the same Agent SDK the panel agent uses), // NOT a paid API. Deliberately a direct `query()` call, not the panel's // ClaudeBackend: the panel persona/session machinery would bleed chat-flavored // prose into what must be a bare caption string. // // Images come from readTrainingFile (contained to the training root, ≤2MB) so // dataset files never touch the /view channel. import { assertDatasetEditable, getDataset, updateDataset } from "./training-datasets.js"; import { buildAgentSpawnEnv } from "./panel-secrets.js"; import { trainingRoot } from "./training-jobs.js"; import { readFileSync, realpathSync, statSync } from "node:fs"; import { extname, resolve, sep } from "node:path"; /** Captioning reads staged TRAINING images, not phone-bound display thumbs — * the 2MB action:"file" cap is a display budget, not a property of the data * (codex finding: a valid 8MP training PNG must still caption). 10MB keeps * the vision context sane without rejecting real sets. Contained + image-only * like readTrainingFile. */ const MAX_CAPTION_BYTES = 10 * 1024 * 1024; const CAPTION_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp"]); function readCaptionFile(absPath) { let p; try { p = realpathSync(absPath); } catch { p = resolve(absPath); } const root = realpathSync(trainingRoot()); const norm = (x) => (process.platform === "win32" ? x.toLowerCase() : x); if (norm(p) !== norm(root) && !norm(p).startsWith(norm(root) + sep)) { throw new Error("path escapes the training root"); } const ext = extname(p).toLowerCase(); if (!CAPTION_IMAGE_EXTS.has(ext)) { throw new Error(`only image files can be captioned (${[...CAPTION_IMAGE_EXTS].join("/")})`); } const st = statSync(p); if (st.size > MAX_CAPTION_BYTES) { throw new Error(`image too large to caption (${(st.size / 1024 / 1024).toFixed(1)} MB > 10 MB)`); } const mimeType = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg"; return { data: readFileSync(p).toString("base64"), mimeType }; } // Lazy SDK import (same pattern as claude-backend — the SDK is a heavy optional // dependency that must not load at MCP boot). let queryFn = null; async function getQuery() { if (!queryFn) { const mod = await import("@anthropic-ai/claude-agent-sdk"); queryFn = mod.query; } return queryFn; } /** Model used for captioning — cheap and fast by default (this is a bulk * utility call on the user's subscription). Override via COMFYUI_MCP_CAPTION_MODEL. */ const CAPTION_MODEL = process.env.COMFYUI_MCP_CAPTION_MODEL?.trim() || "claude-haiku-4-5"; /** A PERSISTENT failure that dooms every remaining image — a missing/invalid * Claude Code login, or a backend that can't run vision captioning at all. * captionDataset bails the batch on the first one instead of re-hitting the * same auth wall 32 times (issue #438: the panel backend was Codex, so every * image returned "Not logged in — Please run /login"). */ export class CaptionAuthError extends Error { constructor(message) { super(message); this.name = "CaptionAuthError"; } } /** Does this SDK error text mean "no usable Claude Code credentials", i.e. a * failure that will repeat identically for every image? Matches the Agent * SDK's not-logged-in / invalid-key / auth phrasings. */ function isAuthFailureText(text) { const t = text.toLowerCase(); return (t.includes("not logged in") || t.includes("/login") || t.includes("please run /login") || t.includes("invalid api key") || t.includes("invalid x-api-key") || t.includes("authentication_error") || t.includes("authentication error") || t.includes("oauth token has expired") || t.includes("unauthorized") || (t.includes("credit balance") && t.includes("too low"))); } function captionPrompt(opts) { const lines = [ "Write ONE caption for this image for LoRA training. Rules:", "- Describe what CHANGES between training images: pose, clothing, background, lighting, expression, framing — NOT the subject's identity (the model learns that from the set, not the captions).", "- One or two short comma-separated phrases. No prefix, no quotes, no commentary.", ]; if (opts.trigger) lines.push(`- Start the caption EXACTLY with "${opts.trigger}, " (the set's trigger word).`); if (opts.guide) lines.push(`- User guidance: ${opts.guide}`); lines.push("Reply with ONLY the caption."); return lines.join("\n"); } /** Caption ONE image (path under the training root). Returns the bare caption. */ export async function captionImage(opts) { const { data, mimeType } = readCaptionFile(opts.imagePath); const query = await getQuery(); const userContent = [ { type: "image", source: { type: "base64", media_type: mimeType, data, }, }, { type: "text", text: captionPrompt(opts) }, ]; async function* turns() { yield { type: "user", message: { role: "user", content: userContent }, parent_tool_use_id: null, }; } // No tools, single turn — a utility call, not an agent session. The spawn // env is SCRUBBED of tool-only secrets (RunPod/CivitAI/HF keys) — the // caption subprocess has no business seeing them (codex finding; same // invariant as ClaudeBackend/ai-proposer). const q = query({ prompt: turns(), options: { model: CAPTION_MODEL, maxTurns: 1, tools: [], env: buildAgentSpawnEnv() }, }); let text = ""; for await (const msg of q) { const m = msg; if (m.type === "assistant" && m.message?.content) { text += m.message.content .filter((c) => c.type === "text") .map((c) => c.text ?? "") .join(""); } else if (m.type === "result" && (m.is_error || (m.subtype && m.subtype !== "success"))) { // The SDK reports a failed run (e.g. "Not logged in — Please run /login") // as an error `result` message, NOT a thrown exception — previously this // was silently dropped and every image failed with the generic "empty // caption" message. Pull the text from `errors[]` (the error variant's // field), and flag auth failures so the batch bails. const detail = (Array.isArray(m.errors) ? m.errors.join("; ") : "").trim() || (m.result ?? "").trim() || `caption run failed (${m.subtype ?? "error"})`; if (isAuthFailureText(detail)) { throw new CaptionAuthError(`Claude captioning is unavailable — the Claude Code session is not authenticated ` + `(SDK said: ${detail}). Run \`claude /login\`, or set ANTHROPIC_API_KEY, then retry. ` + `Captioning always runs through Claude regardless of the panel's active backend.`); } throw new Error(detail); } } const caption = text.trim().replace(/^["']|["']$/g, ""); if (!caption) throw new Error("the model returned an empty caption"); return caption; } /** Caption a whole staged dataset (or a subset) and WRITE the captions into * its .txt files. Sequential on purpose — a subscription isn't a rate pool. */ export async function captionDataset(name, opts = {}) { const detail = getDataset(name); // In-use check BEFORE spending subscription turns: a dataset a running job // trains from would reject every write anyway (codex finding — the loop // used to spend one vision turn per image and write zero captions). await assertDatasetEditable(detail.datasetPath, "captioning"); const only = opts.only?.length ? new Set(opts.only) : null; const targets = detail.items.filter((it) => !only || only.has(it.file)); if (!targets.length) throw new Error(only ? "none of the requested files are in this dataset" : "dataset has no images"); const failed = []; let captioned = 0; for (const it of targets) { try { const caption = await captionImage({ imagePath: `${detail.datasetPath}/${it.file}`, guide: opts.guide, trigger: opts.trigger, }); await updateDataset(name, { setCaptions: { [it.file]: caption } }); captioned++; } catch (err) { // A PERSISTENT auth/credential failure dooms every remaining image — the // whole batch runs on one Claude session (issue #438). Fail fast with an // actionable error instead of iterating N files re-hitting the same wall. // Per-file transient errors (bad image, empty caption, write conflict) // still just get recorded and the loop continues. if (err instanceof CaptionAuthError || (err instanceof Error && isAuthFailureText(err.message))) { const message = err instanceof CaptionAuthError ? err.message : err.message; throw new CaptionAuthError(message); } failed.push({ file: it.file, error: err instanceof Error ? err.message : String(err) }); } } return { captioned, failed }; } //# sourceMappingURL=train-caption.js.map