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),

1,534 lines • 73.2 kB
import { readFile, copyFile, open, readdir, realpath, stat } from "node:fs/promises";
import { join, basename, extname, isAbsolute, relative, resolve, sep } from "node:path";
import { config, isCloudMode, isRemoteMode } from "../config.js";
import { getHistory, getObjectInfo, resetObjectInfoCache, MAX_PREVIEW_SOURCE_BYTES, MAX_VIEW_RESPONSE_BYTES, } from "../comfyui/client.js";
import { ValidationError, ComfyUIError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { resolveOutputDir, resolveInputDir, resolveTempDir } from "./output-dir.js";
import { tryLoadSharp } from "./sharp-loader.js";
/**
 * Resolve ComfyUI's REAL input directory (honors a custom --input-directory /
 * --base-directory). Used only for genuine local filesystem copies; HTTP uploads
 * go through the server API and don't need a path.
 */
async function getInputDir() {
    return resolveInputDir();
}
/**
 * Reject filenames that would escape the ComfyUI input/ directory.
 * The MCP client is untrusted input, so `filename` must be treated as such.
 */
function assertSafeInputFilename(filename) {
    // Strip any directory components — only allow a bare filename.
    const safe = basename(filename);
    if (!safe ||
        safe === "." ||
        safe === ".." ||
        safe !== filename ||
        safe.includes("/") ||
        safe.includes("\\") ||
        safe.includes("\0")) {
        throw new ValidationError(`Invalid filename "${filename}": must be a bare filename with no path components.`);
    }
    return safe;
}
/**
 * Copy a local image file into ComfyUI's input/ directory so it can be
 * referenced by LoadImage nodes in workflows.
 */
export async function uploadImage(sourcePath, filename) {
    const inputDir = await getInputDir();
    const resolvedFilename = assertSafeInputFilename(filename ?? basename(sourcePath));
    // Validate extension
    const ext = extname(resolvedFilename).toLowerCase();
    const allowed = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif"];
    if (!allowed.includes(ext)) {
        throw new ValidationError(`Unsupported image format "${ext}". Supported: ${allowed.join(", ")}`);
    }
    const targetPath = join(inputDir, resolvedFilename);
    logger.info("Uploading image to ComfyUI input", { sourcePath, targetPath });
    try {
        await copyFile(sourcePath, targetPath);
    }
    catch (err) {
        throw new ValidationError(`Failed to copy image: ${err instanceof Error ? err.message : err}`);
    }
    return { filename: resolvedFilename, path: targetPath };
}
/**
 * Extract embedded workflow JSON from a ComfyUI-generated PNG file.
 * ComfyUI stores workflow data in PNG tEXt chunks under the keys
 * "prompt" (API format) and "workflow" (UI format).
 */
export async function extractWorkflowFromImage(imagePath) {
    const ext = extname(imagePath).toLowerCase();
    if (ext !== ".png") {
        throw new ValidationError("Workflow extraction only works with PNG files. ComfyUI embeds metadata in PNG tEXt chunks.");
    }
    const buffer = await readFile(imagePath);
    const result = {};
    // Parse PNG chunks to find tEXt/iTXt chunks with "prompt" or "workflow" keys
    const chunks = parsePngTextChunks(buffer);
    for (const { keyword, text } of chunks) {
        if (keyword === "prompt") {
            try {
                result.prompt = JSON.parse(text);
            }
            catch {
                logger.warn("Failed to parse 'prompt' PNG metadata as JSON");
            }
        }
        else if (keyword === "workflow") {
            try {
                result.workflow = JSON.parse(text);
            }
            catch {
                logger.warn("Failed to parse 'workflow' PNG metadata as JSON");
            }
        }
    }
    if (!result.prompt && !result.workflow) {
        throw new ValidationError("No ComfyUI workflow metadata found in this PNG. The image may not have been generated by ComfyUI, or metadata embedding may have been disabled.");
    }
    return result;
}
/**
 * Low-level PNG tEXt/iTXt chunk parser.
 * PNG format: 8-byte signature, then chunks of [4-byte length][4-byte type][data][4-byte CRC].
 */
function parsePngTextChunks(buffer) {
    const results = [];
    // Verify PNG signature
    const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
    if (buffer.length < 8 || !buffer.subarray(0, 8).equals(PNG_SIG)) {
        throw new ValidationError("Not a valid PNG file");
    }
    let offset = 8; // Skip signature
    while (offset + 8 <= buffer.length) {
        const chunkLength = buffer.readUInt32BE(offset);
        const chunkType = buffer.subarray(offset + 4, offset + 8).toString("ascii");
        const dataStart = offset + 8;
        const dataEnd = dataStart + chunkLength;
        if (dataEnd > buffer.length)
            break;
        if (chunkType === "tEXt") {
            // tEXt: keyword\0text
            const data = buffer.subarray(dataStart, dataEnd);
            const nullIdx = data.indexOf(0);
            if (nullIdx >= 0) {
                const keyword = data.subarray(0, nullIdx).toString("latin1");
                const text = data.subarray(nullIdx + 1).toString("latin1");
                results.push({ keyword, text });
            }
        }
        else if (chunkType === "iTXt") {
            // iTXt: keyword\0compressionFlag\0compressionMethod\0languageTag\0translatedKeyword\0text
            const data = buffer.subarray(dataStart, dataEnd);
            const nullIdx = data.indexOf(0);
            if (nullIdx >= 0) {
                const keyword = data.subarray(0, nullIdx).toString("utf-8");
                const compressionFlag = data[nullIdx + 1];
                // Skip compression method (1 byte), language tag, translated keyword
                let textStart = nullIdx + 3; // past null, compressionFlag, compressionMethod
                // Skip language tag (null-terminated)
                const langEnd = data.indexOf(0, textStart);
                if (langEnd >= 0) {
                    textStart = langEnd + 1;
                    // Skip translated keyword (null-terminated)
                    const transEnd = data.indexOf(0, textStart);
                    if (transEnd >= 0) {
                        textStart = transEnd + 1;
                    }
                }
                const textData = data.subarray(textStart);
                if (compressionFlag === 0) {
                    results.push({ keyword, text: textData.toString("utf-8") });
                }
                // We skip compressed iTXt chunks — ComfyUI typically uses uncompressed
            }
        }
        if (chunkType === "IEND")
            break;
        // Next chunk: length(4) + type(4) + data(chunkLength) + CRC(4)
        offset = dataEnd + 4;
    }
    return results;
}
// Still-image extensions.
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".bmp"]);
// Video / animation extensions written by VHS_VideoCombine, LTX, WAN, etc.
// NOTE: .webp and .gif can be still OR animated; ComfyUI's video nodes emit
// them as animations, so they're classified as "video" here.
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".mkv", ".m4v", ".avi", ".gif", ".webp"]);
// Everything we list (images + videos/animations).
const MEDIA_EXTS = new Set([...IMAGE_EXTS, ...VIDEO_EXTS]);
function mediaKind(ext) {
    return VIDEO_EXTS.has(ext) ? "video" : "image";
}
/**
 * Recursive media listing of one ComfyUI directory (output/ or temp/).
 * `allowedExts` is MEDIA_EXTS for output/ and VIDEO_EXTS for temp/ so
 * PreviewImage stills in temp/ stay omitted while VHS .mp4s are returned.
 */
async function collectMediaFromDir(root, pattern, type, allowedExts) {
    const dirents = await readdir(root, { recursive: true, withFileTypes: true });
    const images = [];
    let matched = 0;
    for (const dirent of dirents) {
        if (!dirent.isFile())
            continue;
        const ext = extname(dirent.name).toLowerCase();
        if (!allowedExts.has(ext))
            continue;
        const parent = dirent.parentPath ?? root;
        const subfolder = relative(root, parent).split(sep).join("/");
        const relPath = subfolder ? `${subfolder}/${dirent.name}` : dirent.name;
        if (pattern && !relPath.toLowerCase().includes(pattern))
            continue;
        const filePath = join(parent, dirent.name);
        matched += 1;
        try {
            const info = await stat(filePath);
            images.push({
                filename: dirent.name,
                path: filePath,
                size: info.size,
                modified: info.mtime.toISOString(),
                subfolder,
                kind: mediaKind(ext),
                type,
            });
        }
        catch {
            continue;
        }
    }
    return { images, matched };
}
/**
 * The listing PLUS where it came from.
 *
 * `listOutputImages` returns bare filenames, which forces any caller that needs
 * the path to reconstruct it — and the obvious reconstruction (the workspace
 * path from `install_comfyui (action:"environment")`) is WRONG on every install launched with
 * `--output-directory`, in the silent way: the filenames look plausible against
 * it, so a caller that does not stat them reports a directory holding nothing
 * (#899, observed live).
 *
 * The directory is resolved exactly ONCE, here, and travels with the entries it
 * describes. Resolving it a second time in the caller would be the same class of
 * bug this fixes: two answers to one question, free to disagree.
 */
export async function listOutputMedia(options) {
    const limit = options?.limit ?? 20;
    const pattern = options?.pattern?.toLowerCase();
    // REMOTE mode: no local filesystem to scan. Derive the output media from
    // ComfyUI's /history (HTTP, works against a remote instance) instead. Size and
    // modification time are unavailable over HTTP, so they come back as 0 / "".
    // Keyed off isRemoteMode() ALONE (#877): when a remote target coexists with an
    // unrelated local COMFYUI_PATH, scanning the local output dir would report the
    // wrong machine's outputs — but the converse test, `!config.comfyuiPath`, was
    // never a test for "no local path". It reads one ENV VAR, while a local
    // portable install is perfectly well located by the SAVED DEFAULT WORKSPACE,
    // which `resolveOutputDir` consults and `install_comfyui (action:"environment")` reports. So a local
    // install with `COMFYUI_PATH` unset silently took the remote branch, asked
    // /history — which had been emptied by a restart — and returned `[]` over an
    // output directory holding the files the caller was asking about. A silent
    // wrong answer, not an error: the agent concludes the file does not exist.
    if (isRemoteMode()) {
        return {
            images: await listOutputImagesFromHistory(limit, pattern),
            source: { basis: "server-history" },
        };
    }
    // Local. `resolveOutputDir` already knows every way this path can be
    // established, so let it answer rather than pre-judging on one env var.
    let outputDir;
    try {
        outputDir = await resolveOutputDir();
    }
    catch (err) {
        // The local root could not be established. /history is the only source left,
        // and it CANNOT see the disk — so a zero result from it does not establish
        // that there are no outputs. Falling back is right; reporting its emptiness
        // as an answer is not.
        const fromHistory = await listOutputImagesFromHistory(limit, pattern);
        if (fromHistory.length > 0) {
            return { images: fromHistory, source: { basis: "server-history" } };
        }
        throw new ComfyUIError(`Could not determine this ComfyUI's output directory (${err instanceof Error ? err.message : String(err)}), ` +
            `so the local output folder was never scanned. ComfyUI's /history was asked instead and ` +
            `returned nothing — but history is emptied by a restart and cannot see files on disk, so ` +
            `that is NOT a finding that there are no outputs. Set COMFYUI_PATH, or save a default ` +
            `workspace (workspace tool, action 'set_default'), so the output folder can be read directly.`, "OUTPUT_DIR_UNKNOWN");
    }
    // RECURSIVE scan: ComfyUI writes to subfolders when a node's filename_prefix
    // contains a path (SaveVideo / VHS / "video/clip" → output/video/clip_00001.mp4).
    // A flat readdir of the top level silently misses those — so a finished video can
    // look "not found" even though the dir resolved correctly. Walk the whole tree.
    //
    // #2370 — also scan temp/ for VIDEO files. VHS_VideoCombine with `save_output`
    // unchecked writes the completed .mp4 (including the muxed `-audio.mp4` a run
    // completion names) to folder_paths.get_temp_directory() and tags it
    // type:"temp". Scanning output/ only listed that render as nothing. Preview
    // stills in temp/ stay omitted so PreviewImage does not flood the listing.
    const images = [];
    // How many entries MATCHED the query, versus how many we could actually stat.
    // A per-file stat failure is skipped silently, which is fine when it is one
    // file among many — but if it swallows every match, the caller gets `[]` over
    // a directory that demonstrably held what they asked for (codex gate).
    let matched = 0;
    try {
        const outputScan = await collectMediaFromDir(outputDir, pattern, "output", MEDIA_EXTS);
        images.push(...outputScan.images);
        matched += outputScan.matched;
    }
    catch (err) {
        // We know WHERE the outputs are and could not read them. That is not an
        // empty directory, and returning `[]` told the caller their file does not
        // exist when the truth is we could not look (#877).
        throw new ComfyUIError(`Could not read the ComfyUI output directory "${outputDir}": ` +
            `${err instanceof Error ? err.message : String(err)}. This is NOT a finding that there ` +
            `are no outputs — the directory could not be listed at all.`, "OUTPUT_DIR_UNREADABLE");
    }
    let tempDirectory;
    try {
        const tempDir = await resolveTempDir();
        try {
            const tempScan = await collectMediaFromDir(tempDir, pattern, "temp", VIDEO_EXTS);
            images.push(...tempScan.images);
            matched += tempScan.matched;
            tempDirectory = tempDir;
        }
        catch (err) {
            const code = typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
                ? err.code
                : undefined;
            if (code === "ENOENT") {
                // temp/ is created on first preview. Absence is an empty scan, not a miss.
                tempDirectory = tempDir;
            }
            else {
                logger.debug("Could not scan ComfyUI temp directory; listing output/ only", {
                    error: err instanceof Error ? err.message : String(err),
                });
            }
        }
    }
    catch (err) {
        logger.debug("Could not resolve ComfyUI temp directory; listing output/ only", {
            error: err instanceof Error ? err.message : String(err),
        });
    }
    if (matched > 0 && images.length === 0) {
        // Every entry that matched the query failed to stat. Returning `[]` here says
        // "there are none" over a directory that demonstrably held what was asked for
        // — the same fold as the two above, at the last place it can still happen
        // (codex gate).
        throw new ComfyUIError(`Found ${matched} matching file(s) under "${outputDir}", but none of them could be read ` +
            `(every stat failed — a permissions or I/O problem, or they were all removed mid-scan). ` +
            `This is NOT a finding that there are no outputs.`, "OUTPUT_ENTRIES_UNREADABLE");
    }
    // #2539 — a successful local scan of the WRONG directory looks identical to
    // a genuine empty folder: resolveOutputDir fell back to an unrelated
    // COMFYUI_PATH (relative `ComfyUI/main.py`, no --output-directory, no cwd)
    // while the live /view endpoint served the files. Returning `{images:[],
    // source: local-scan}` here is a silent miss, not an error. /history is the
    // same source action:"get" uses; if it has matches, those are the answer.
    // An unreadable history is not a finding that the empty scan was right —
    // keep the empty local result rather than convert a readable empty folder
    // into HISTORY_UNREADABLE.
    if (images.length === 0) {
        try {
            const fromHistory = await listOutputImagesFromHistory(limit, pattern);
            if (fromHistory.length > 0) {
                return {
                    images: fromHistory,
                    source: {
                        directory: outputDir,
                        ...(tempDirectory ? { tempDirectory } : {}),
                        basis: "server-history-fallback",
                    },
                };
            }
        }
        catch (err) {
            logger.debug("Could not fall back to /history after an empty local output scan", {
                error: err instanceof Error ? err.message : String(err),
            });
        }
    }
    // Sort newest first
    images.sort((a, b) => b.modified.localeCompare(a.modified));
    return {
        images: images.slice(0, limit),
        source: {
            directory: outputDir,
            ...(tempDirectory ? { tempDirectory } : {}),
            basis: "local-scan",
        },
    };
}
/**
 * Just the entries. Kept because most callers only want the list; anything that
 * needs to SAY where the files are must use `listOutputMedia`, so the path it
 * reports is the path that was scanned.
 */
export async function listOutputImages(options) {
    return (await listOutputMedia(options)).images;
}
/**
 * Remote-mode source for listOutputImages: derive output media from ComfyUI's
 * /history (HTTP) when there is no local filesystem to scan. Walks every history
 * entry's node outputs (images + videos/gifs), keeps output-directory assets
 * plus temp/ videos (VHS save_output unchecked, #2370), skips temp still
 * previews, dedupes by type/subfolder/filename, and returns them newest-first
 * (history is roughly insertion-ordered, so we iterate in reverse).
 * Size/modified are unavailable over HTTP and come back as 0 / "".
 */
async function listOutputImagesFromHistory(limit, pattern) {
    let history;
    try {
        history = await getHistory();
    }
    catch (err) {
        // A FAILED request is not an empty history (codex gate). Returning `[]` here
        // told the caller there are no outputs when we never got an answer at all —
        // and in remote mode this is the only source, so that `[]` was the whole
        // reply. Throwing makes the difference visible; the caller upstream already
        // distinguishes "history had nothing" from "history could not be asked".
        throw new ComfyUIError(`Could not read ComfyUI's generation history (${err instanceof Error ? err.message : String(err)}). ` +
            `This is NOT a finding that there are no outputs — the history could not be read at all.`, "HISTORY_UNREADABLE");
    }
    const seen = new Set();
    const images = [];
    const entries = Object.values(history);
    // Newest-first: history is keyed in roughly chronological (insertion) order,
    // so iterate from the end.
    for (let i = entries.length - 1; i >= 0; i--) {
        const outputs = entries[i]?.outputs;
        if (!outputs || typeof outputs !== "object")
            continue;
        for (const nodeOutput of Object.values(outputs)) {
            if (!nodeOutput || typeof nodeOutput !== "object")
                continue;
            const out = nodeOutput;
            // SaveImage emits under 'images'; SaveVideo/VHS/CreateVideo under
            // 'videos' | 'video' | 'gifs'. mediaKind() then classifies by extension.
            for (const key of ["images", "videos", "video", "gifs"]) {
                const arr = out[key];
                if (!Array.isArray(arr))
                    continue;
                for (const m of arr) {
                    if (!m || typeof m !== "object")
                        continue;
                    const media = m;
                    if (typeof media.filename !== "string" || media.filename.length === 0) {
                        continue;
                    }
                    // output/ stills+video, plus temp/ videos (VHS save_output unchecked).
                    // Temp stills are PreviewImage previews and stay omitted.
                    const rawType = typeof media.type === "string" ? media.type : "output";
                    const type = rawType === "temp" ? "temp" : rawType === "output" ? "output" : undefined;
                    if (!type)
                        continue;
                    const ext = extname(media.filename).toLowerCase();
                    if (!MEDIA_EXTS.has(ext))
                        continue;
                    if (type === "temp" && !VIDEO_EXTS.has(ext))
                        continue;
                    const subfolder = typeof media.subfolder === "string" ? media.subfolder : "";
                    const relPath = subfolder
                        ? `${subfolder}/${media.filename}`
                        : media.filename;
                    if (pattern && !relPath.toLowerCase().includes(pattern))
                        continue;
                    const dedupeKey = `${type}:${relPath}`.toLowerCase();
                    if (seen.has(dedupeKey))
                        continue;
                    seen.add(dedupeKey);
                    images.push({
                        filename: media.filename,
                        // ComfyUI-relative reference; the absolute path is unknown over HTTP.
                        path: relPath,
                        size: 0,
                        modified: "",
                        subfolder,
                        kind: mediaKind(ext),
                        type,
                    });
                }
            }
        }
        if (images.length >= limit)
            break;
    }
    return images.slice(0, limit);
}
import { fetchImage, uploadImageHttp } from "../comfyui/client.js";
import { readFile as nodeReadFile } from "node:fs/promises";
/**
 * Split and reject filename / subfolder values that could traverse out of
 * ComfyUI's output, input or temp directory when forwarded to the /view
 * endpoint.
 *
 * ComfyUI's /view handler joins the requested `subfolder` and `filename`
 * onto the configured base directory; the server has historically been
 * permissive about ".." segments and absolute paths in those parameters
 * (see e.g. comfyanonymous/ComfyUI#785), so an attacker who can reach the
 * MCP tool surface could otherwise pivot through get_image (action:"get"/"view") /
 * upload_image (action:"stage") to read arbitrary files from the ComfyUI host.
 *
 * get_history (action:"list") prints media as `subfolder/filename`, so callers
 * paste that combined string into get_image's `filename`. A relative prefix is
 * the same thing as the `subfolder` argument and is adopted, not refused.
 * Absolute paths, drive prefixes, and ".." still fail closed BEFORE the
 * request is forwarded.
 */
function normalizeViewRef(filename, subfolder) {
    if (typeof filename !== "string" || filename.length === 0) {
        throw new ValidationError("filename is required");
    }
    if (filename.includes("\0") || (subfolder && subfolder.includes("\0"))) {
        throw new ValidationError("filename / subfolder must not contain NUL bytes");
    }
    const rejectFilename = () => {
        throw new ValidationError(`Invalid filename "${filename}": must be a filename or relative subfolder/filename reference without absolute paths, drive prefixes, or '..' segments`);
    };
    // Rooted / drive-prefixed FILENAME is not a relative output ref. Refuse
    // rather than stripping the prefix into a quietly different destination.
    if (filename.startsWith("/") ||
        filename.startsWith("\\") ||
        /^[A-Za-z]:/.test(filename)) {
        rejectFilename();
    }
    const endsInSeparator = /[/\\]\s*$/.test(filename);
    const parts = filename.split(/[/\\]+/).filter((p) => p !== "" && p !== ".");
    const name = endsInSeparator ? "" : (parts.pop() ?? "");
    if (name === "" || name === "." || name === ".." || /^[A-Za-z]:/.test(name)) {
        rejectFilename();
    }
    if (parts.some((p) => p === "..")) {
        rejectFilename();
    }
    const prefix = parts.join("/");
    let resolvedSubfolder = subfolder;
    if (prefix) {
        resolvedSubfolder = subfolder
            ? `${subfolder.replace(/\\/g, "/").replace(/\/+$/, "")}/${prefix}`
            : prefix;
    }
    if (resolvedSubfolder) {
        // Subfolder must be a forward-slash-joined relative path with no ".."
        // segments and no Windows-style drive / absolute prefix.
        if (resolvedSubfolder.startsWith("/") ||
            resolvedSubfolder.startsWith("\\") ||
            /^[A-Za-z]:/.test(resolvedSubfolder)) {
            throw new ValidationError(`Invalid subfolder "${subfolder || prefix}": must be relative to the ComfyUI media directory`);
        }
        const segments = resolvedSubfolder.split(/[\\/]/);
        if (segments.some((s) => s === "..")) {
            throw new ValidationError(`Invalid subfolder "${subfolder || prefix}": '..' segments are not allowed`);
        }
    }
    return { filename: name, subfolder: resolvedSubfolder };
}
/** Strict lexical containment for the local fallback below. */
function isStrictlyInside(root, candidate) {
    const rel = relative(resolve(root), resolve(candidate));
    return (rel !== "" &&
        rel !== ".." &&
        !rel.startsWith(`..${sep}`) &&
        !rel.startsWith("../") &&
        !isAbsolute(rel));
}
function localViewMimeType(filename) {
    const ext = extname(filename).toLowerCase();
    return (IMAGE_MIME[ext] ??
        VIDEO_MIME[ext] ??
        AUDIO_MIME[ext] ??
        (ext === ".json" ? "application/json" : "application/octet-stream"));
}
/** Read at most `maxBytes`, including when the file grows after stat(). */
async function readLocalViewFileBounded(path, maxBytes) {
    const handle = await open(path, "r");
    const chunks = [];
    let total = 0;
    try {
        for (;;) {
            // Read one byte beyond the limit so growth after the initial stat is
            // refused rather than silently truncated into a valid-looking payload.
            const capacity = Math.min(64 * 1024, maxBytes - total + 1);
            const chunk = Buffer.allocUnsafe(capacity);
            const { bytesRead } = await handle.read(chunk, 0, capacity, null);
            if (bytesRead === 0)
                return Buffer.concat(chunks, total);
            total += bytesRead;
            if (total > maxBytes)
                return undefined;
            chunks.push(chunk.subarray(0, bytesRead));
        }
    }
    finally {
        await handle.close();
    }
}
/**
 * Recover a local file when ComfyUI rejects an otherwise valid /view reference
 * with HTTP 400. Some ComfyUI builds reject basenames containing repeated
 * periods even though the loader can read the file from disk. This is only a
 * local, 400-specific fallback; remote and cloud targets must never read this
 * process's filesystem.
 */
async function readLocalViewFallback(filename, type, subfolder, maxBytes = MAX_VIEW_RESPONSE_BYTES) {
    if (isRemoteMode() || isCloudMode())
        return undefined;
    try {
        // Use the same live/argv-aware roots as local filesystem tools. In
        // particular, input may be redirected with --input-directory and the
        // configured COMFYUI_PATH may belong to a different installed copy.
        const root = type === "input"
            ? await resolveInputDir()
            : type === "output"
                ? await resolveOutputDir()
                : config.comfyuiPath
                    ? resolve(config.comfyuiPath, "temp")
                    : undefined;
        if (!root)
            return undefined;
        const candidate = resolve(root, subfolder, filename);
        if (!isStrictlyInside(root, candidate))
            return undefined;
        // Check the canonical paths too: an input-side symlink must not turn this
        // compatibility path into an arbitrary local file read.
        const [realRoot, realCandidate] = await Promise.all([
            realpath(root),
            realpath(candidate),
        ]);
        if (!isStrictlyInside(realRoot, realCandidate))
            return undefined;
        const candidateStat = await stat(realCandidate);
        if (!candidateStat.isFile() || candidateStat.size > maxBytes)
            return undefined;
        const data = await readLocalViewFileBounded(realCandidate, maxBytes);
        if (!data)
            return undefined;
        return { base64: data.toString("base64"), mimeType: localViewMimeType(filename) };
    }
    catch {
        // Preserve the original /view 400 when the local path cannot be resolved
        // or read; the fallback must not hide the server's actionable error.
        return undefined;
    }
}
function isViewBadRequest(error) {
    if (!(error instanceof ComfyUIError) || error.code !== "VIEW_ERROR")
        return false;
    return (typeof error.details === "object" &&
        error.details !== null &&
        error.details.status === 400);
}
function isViewTooLarge(error) {
    return error instanceof ComfyUIError && error.code === "VIEW_TOO_LARGE";
}
function viewTooLargeForPreview(filename, maxBytes) {
    return new ComfyUIError(`ComfyUI /view response for "${filename}" exceeds the ${maxBytes / 1024 ** 2} MB safety limit, ` +
        `so it was not loaded into memory and the inline preview (max_preview_dimension) could not be built. ` +
        `If the file is on this machine, use get_image action:"convert" with its path under the ComfyUI output directory. ` +
        `The output is still intact on the ComfyUI server — do not re-run the render.`, "VIEW_TOO_LARGE", { filename, maxBytes });
}
// EBML magic and MPEG/ADTS frame syncs are only 2–4 bytes — short enough for
// a truncated error-prefix to satisfy them — so formats with no cheap local
// structure to validate require a plausible minimum file size instead.
const MIN_MEDIA_BYTES = 256;
/**
 * Magic-byte sniff for the video/audio formats get_image can save (#663).
 * Returns the media FORMAT the payload proves itself to be, or null when it
 * matches none of them. Each check validates STRUCTURE, not just a leading
 * signature: MP4/MOV/M4V/M4A need a well-formed "ftyp" box, RIFF containers
 * (WAV/AVI) a chunk size consistent with the delivered body, FLAC the
 * mandatory STREAMINFO first block, Ogg a version-0 page header whose
 * segment table fits inside the body, and ID3 a syncsafe tag size that fits.
 * A textual junk body (JSON/HTML error page) or a truncated prefix matches
 * none of these.
 */
function sniffMediaFormat(base64) {
    // 40 base64 chars decode to the first 30 bytes — enough for the deepest
    // header below (the 27-byte Ogg page header).
    const head = Buffer.from(base64.slice(0, 40), "base64");
    const bodyLength = Math.floor((base64.length * 3) / 4) -
        (base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0);
    const ascii = (start, end) => head.subarray(start, end).toString("ascii");
    if (head.length >= 16 && ascii(4, 8) === "ftyp") {
        // mp4/mov/m4v/m4a — require a well-formed ftyp BOX, not just the four
        // bytes "ftyp": the box size (big-endian uint32) must cover the header +
        // major brand + minor version (>= 16) and not exceed the actual body
        // length, and the major brand must be printable ASCII. An 8-byte
        // truncated body ending in "ftyp" fails every one of these.
        const boxSize = head.readUInt32BE(0);
        const printableBrand = head
            .subarray(8, 12)
            .every((b) => b >= 0x20 && b <= 0x7e);
        if (boxSize >= 16 && boxSize <= bodyLength && printableBrand) {
            // M4A/M4B are audio-only brands (m4a audio in an mp4 container); the
            // rest (isom/mp41/mp42/avc1/qt/M4V…) are video.
            const brand = ascii(8, 12);
            return brand === "M4A " || brand === "M4B " ? "mp4-audio" : "mp4-video";
        }
    }
    if (head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3) {
        // webm/mkv — EBML magic is only 4 bytes; require a plausible file size.
        if (bodyLength >= MIN_MEDIA_BYTES)
            return "ebml";
    }
    if (head.length >= 8 && ascii(0, 4) === "fLaC") {
        // flac — the magic must be followed by the mandatory first metadata
        // block: a STREAMINFO block header (type 0, length exactly 34).
        if ((head[4] & 0x7f) === 0 &&
            head.readUIntBE(5, 3) === 34 &&
            bodyLength >= 42) {
            return "flac";
        }
    }
    if (head.length >= 27 && ascii(0, 4) === "OggS") {
        // ogg — the page version byte must be 0 and the segment table must fit
        // inside the body (27-byte page header + page_segments bytes).
        if (head[4] === 0 && 27 + head[26] <= bodyLength)
            return "ogg";
    }
    if (head.length >= 12 && ascii(0, 4) === "RIFF") {
        // wav/avi — the RIFF chunk size (file size − 8) must be consistent with
        // the body actually delivered: at least the 4-byte form type, and never
        // claiming more bytes than we have (a truncated/fabricated prefix).
        const riffSize = head.readUInt32LE(4);
        if (riffSize >= 4 && riffSize + 8 <= bodyLength) {
            const form = ascii(8, 12);
            if (form === "WAVE")
                return "wav";
            if (form === "AVI ")
                return "avi";
        }
    }
    if (head.length >= 10 && ascii(0, 3) === "ID3") {
        // mp3 with an ID3v2 tag — the 4 tag-size bytes must be syncsafe (7-bit)
        // and the declared tag must fit inside the body; a bare "ID3" prefix
        // fails both, and the floor rejects a tag with no audio behind it.
        if (head.subarray(6, 10).every((b) => (b & 0x80) === 0)) {
            const tagSize = (head[6] << 21) | (head[7] << 14) | (head[8] << 7) | head[9];
            if (tagSize + 10 <= bodyLength && bodyLength >= MIN_MEDIA_BYTES) {
                return "mpegaudio";
            }
        }
    }
    if (head.length >= 2 && head[0] === 0xff && (head[1] & 0xe0) === 0xe0) {
        // mp3 / aac frame sync — 2 bytes is an error-prefix-sized signature, so
        // require a plausible file size. ADTS (aac) frames always have the MPEG
        // layer bits 00; mp3/mp2 frames have them set.
        if (bodyLength >= MIN_MEDIA_BYTES) {
            return (head[1] & 0x06) === 0 ? "aac" : "mpegaudio";
        }
    }
    return null;
}
/**
 * Declared content-type → the media format its payload must sniff as (#663).
 * Cross-format mislabels within a family (MP3 bytes labeled audio/wav) are
 * rejected just like cross-family ones. Unlisted media types fail closed;
 * application/octet-stream is handled separately and accepts any format.
 */
const MEDIA_FORMAT_BY_MIME = {
    "video/mp4": "mp4-video",
    "video/quicktime": "mp4-video",
    "video/x-m4v": "mp4-video",
    "video/webm": "ebml",
    "video/x-matroska": "ebml",
    "video/x-msvideo": "avi",
    "video/avi": "avi",
    "video/msvideo": "avi",
    "audio/wav": "wav",
    "audio/x-wav": "wav",
    "audio/wave": "wav",
    "audio/vnd.wave": "wav",
    "audio/mpeg": "mpegaudio",
    "audio/mp3": "mpegaudio",
    "audio/aac": "aac",
    "audio/x-aac": "aac",
    "audio/flac": "flac",
    "audio/x-flac": "flac",
    "audio/ogg": "ogg",
    "audio/mp4": "mp4-audio",
    "audio/x-m4a": "mp4-audio",
};
/**
 * Filename extension → the media format its payload must sniff as (#663).
 * application/octet-stream declares nothing, so the requested FILENAME is the
 * only remaining claim: a WAV body returned as "clip.mp4" would otherwise be
 * saved under a video extension it isn't. Unlisted extensions declare nothing
 * we can check and pass any valid media format (fail-open for exotic-but-real
 * assets — the bytes still have to prove themselves via the structural sniff).
 */
const MEDIA_FORMAT_BY_EXTENSION = {
    ".mp4": "mp4-video",
    ".mov": "mp4-video",
    ".m4v": "mp4-video",
    ".m4a": "mp4-audio",
    ".m4b": "mp4-audio",
    ".webm": "ebml",
    ".mkv": "ebml",
    ".avi": "avi",
    ".wav": "wav",
    ".mp3": "mpegaudio",
    ".aac": "aac",
    ".flac": "flac",
    ".ogg": "ogg",
    ".oga": "ogg",
};
/** Image extensions claim a still image — never consistent with a sniffed
 *  video/audio payload (an MP4 saved as clip.png is the same corruption). */
const IMAGE_EXTENSIONS = new Set([
    ".png",
    ".jpg",
    ".jpeg",
    ".webp",
    ".gif",
    ".bmp",
    ".tif",
    ".tiff",
    ".avif",
]);
/**
 * Attachment extensions that get_image may save when ComfyUI serves the file
 * as an opaque octet-stream (mesh/material) or a ZIP. This is deliberately an
 * explicit, small allowlist rather than an extension-shaped bypass for the
 * image-content guard.
 */
const ATTACHMENT_EXTENSIONS = new Set([
    ".obj",
    ".glb",
    ".gltf",
    ".fbx",
    ".ply",
    ".stl",
    ".mtl",
    ".zip",
]);
/** MIME labels a ZIP /view may arrive with. Mesh/material still require octet-stream. */
const ZIP_ATTACHMENT_MIMES = new Set([
    "application/zip",
    "application/x-zip-compressed",
    "application/octet-stream",
]);
/** Local-file, empty-archive, and spanned ZIP signatures (#2858). */
function sniffZipMagic(base64) {
    if (base64.length === 0)
        return false;
    const head = Buffer.from(base64.slice(0, 8), "base64");
    if (head.length < 4)
        return false;
    return (head[0] === 0x50 &&
        head[1] === 0x4b &&
        ((head[2] === 0x03 && head[3] === 0x04) ||
            (head[2] === 0x05 && head[3] === 0x06) ||
            (head[2] === 0x07 && head[3] === 0x08)));
}
/**
 * The history reconciler needs stronger evidence than an image/* header. Keep
 * this check bounded: /view already caps the response bytes, and Sharp's input
 * pixel limit bounds decoding before the result is reduced to one pixel.
 */
const HISTORY_IMAGE_MAX_INPUT_PIXELS = 192_000_000;
const MAX_VIEW_RESPONSE_BASE64_CHARS = Math.ceil((MAX_VIEW_RESPONSE_BYTES / 3)) * 4;
async function hasActualImageContent(base64, filename) {
    // The transport normally enforces this first, but keep the consumer safe if
    // another fetch implementation or a test seam hands it an oversized string.
    if (base64.length === 0 || base64.length > MAX_VIEW_RESPONSE_BASE64_CHARS)
        return false;
    const loaded = await tryLoadSharp();
    if (!loaded.ok) {
        logger.debug("Cannot validate history image content because sharp is unavailable", { filename });
        return false;
    }
    try {
        const bytes = Buffer.from(base64, "base64");
        if (bytes.length === 0 || bytes.length > MAX_VIEW_RESPONSE_BYTES)
            return false;
        const sharpOptions = {
            limitInputPixels: HISTORY_IMAGE_MAX_INPUT_PIXELS,
            // The default is warning, but keep the strict untrusted-input setting
            // explicit: malformed pixel data must not be certified by metadata alone.
            failOn: "warning",
        };
        const metadata = await loaded.sharp(bytes, sharpOptions).metadata();
        const hasDimensions = typeof metadata.format === "string" &&
            Number.isInteger(metadata.width) &&
            metadata.width > 0 &&
            Number.isInteger(metadata.height) &&
            metadata.height > 0;
        if (!hasDimensions)
            return false;
        // metadata() only inspects headers. Decode and reduce to one pixel so
        // corrupt compressed data is rejected while the output allocation stays
        // constant even for a large, valid image.
        await loaded
            .sharp(bytes, sharpOptions)
            .resize({ width: 1, height: 1, fit: "inside", withoutEnlargement: true })
            .raw()
            .toBuffer();
        return true;
    }
    catch (error) {
        logger.debug("History image probe could not decode the /view body", {
            filename,
            error: error instanceof Error ? error.message : String(error),
        });
        return false;
    }
}
/**
 * Fetch a generated image from ComfyUI via HTTP /view endpoint.
 * Does NOT require COMFYUI_PATH — works with remote ComfyUI instances.
 *
 * By default only image/* payloads are accepted. Pass `allowMedia: true` to
 * also accept video/* and audio/* (e.g. a VHS_VideoCombine .mp4) so the caller
 * can save them to disk — /view returns raw bytes for any media type. Media
 * acceptance sniffs the payload's magic bytes instead of trusting the declared
 * content-type, so a JSON/HTML error body mislabeled as video/mp4 is still
 * rejected — and genuine media a proxy serves as application/octet-stream
 * (ComfyUI itself reports video/mp4 via mimetypes) still passes. The sniffed
 * media format must also match the declared subtype (MP3 bytes labeled
 * audio/wav are rejected).
 */
export async function getOutputImage(filename, type = "output", subfolder = "", { allowMedia = false, 
/**
 * Accept a known mesh/material or ZIP attachment for get_image (action:"get").
 * Meshes are served as application/octet-stream with no reliable subtype to
 * sniff. ZIPs may also be application/zip or application/x-zip-compressed
 * and must sniff as PK magic. The caller must opt in explicitly and the
 * requested filename must use the narrow attachment extension allowlist.
 */
allowAttachment = false, 
/**
 * Accept a `.json` attachment whose bytes actually PARSE as JSON (#1373).
 *
 * A ComfyUI input directory legitimately holds workflow `.json` files, and `get_image`
 * refused to save them: the reporter's server labelled one `video/json`, which is
 * neither `image/*` nor a sniffable media format.
 *
 * NOT AN ADDITION TO THE ACCEPT LIST. I could not reproduce that header — a stock
 * ComfyUI 0.31.1 here returns `application/json` for the same request, so `video/json`
 * comes from something in their stack, and the next install will invent a different
 * one. Accepting the specific string would fix one machine.
 *
 * The declared type is a claim about the payload; parsing is a fact about it. So this
 * follows the rule the media branch above already applies — bytes must be what they
 * are said to be — and simply asks the question JSON can answer directly.
 */
allowJson = false, 
/**
 * Require an image/* response body to contain decodable image content.
 * This is intentionally opt-in for the history availability probe; the
 * ordinary get_image fetch path keeps its existing MIME-based contract.
 */
requireImageContent = false, signal, 
/**
 * get_image action:"get" will downscale the inline payload. Raise the
 * encoded-body ceiling for still images so a 32–64 MB PNG can be previewed
 * instead of dying as VIEW_TOO_LARGE before max_preview_dimension runs.
 * Videos/attachments keep the default 32 MB /view cap.
 */
forInlinePreview = false, } = {}) {
    ({ filename, subfolder } = normalizeViewRef(filename, subfolder));
    const ext = extname(filename).toLowerCase();
    const maxBytes = forInlinePreview && IMAGE_EXTENSIONS.has(ext) ? MAX_PREVIEW_SOURCE_BYTES : MAX_VIEW_RESPONSE_BYTES;
    const fetchOpts = {
        ...(signal ? { signal } : {}),
        ...(maxBytes !== MAX_VIEW_RESPONSE_BYTES ? { maxBytes } : {}),
    };
    let result;
    try {
        result =
            Object.keys(fetchOpts).length > 0
                ? await fetchImage(filename, type, subfolder, fetchOpts)
                : await fetchImage(filename, type, subfolder);
    }
    catch (error) {
        if (isViewTooLarge(error) || isViewBadRequest(error)) {
            const local = await readLocalViewFallback(filename, type, subfolder, maxBytes);
            if (local) {
                result = local;
            }
            else if (isViewTooLarge(error)) {
                throw viewTooLargeForPreview(filename, maxBytes);
            }
            else {
                throw error;
            }
        }
        else {
            throw error;
        }
    }
    // Guard against non-image /view payloads. ComfyUI (or a reverse proxy in
    // front of it) can answer /view with a 200 whose body is actually a JSON or
    // HTML error page, or an empty body — this happens most often for `type=input`
    // refs, where the caller picked a filename/subfolder that doesn't resolve to a
    // real input file. The old code saved those bytes verbatim as a `.png` and
    // returned them as an inline image block, so the MCP client then choked trying
    // to decode them ("Unexpected end of JSON input"). Fail loudly and structured
    // instead, so get_image surfaces a clean not-found rather than a corrupt image.
    //
    // Under allowMedia the same junk body can arrive labeled video/mp4 — the
    // declared content-type alone is no proof, and get_image would save the junk
    // bytes as a working .mp4. So media payloads must also SNIFF as media, and
    // the sniffed FORMAT must match the declared subtype exactly: MP3 bytes
    // labeled audio/wav are rejected even though both are audio, otherwise the
    // asset is saved under a corrupt extension. The generic
    // application/octet-stream label declares no format, so any genuine media
    // signature passes.
    const mime = result.mimeType.toLowerCase();
    const isImage = mime.startsWith("image/");
    const imageContentOk = !requireImageContent || (isImage && (await hasActualImageContent(result.base64, filename)));
    const sniffedFormat = allowMedia ? sniffMediaFormat(result.base64) : null;
    // The sniffed format must satisfy EVERY claim present: the declared
    // content-type (exact subtype match), and the requested filename's
    // extension whenever we can classify it — audio/wav bytes requested as
    // clip.mp4 pass mime↔sniff yet would still land on disk as a corrupt .mp4.
    const extOk = !IMAGE_EXTENSIONS.has(ext) &&
        (!MEDIA_FORMAT_BY_EXTENSION[ext] || MEDIA_FORMAT_BY_EXTENSION[ext] === sniffedFormat);
    const isMedia = sniffedFormat !== null &&
        extOk &&
        (mime === "application/octet-stream" ||
            MEDIA_FORMAT_BY_MIME[mime] === sniffedFormat);
    // ComfyUI serves mesh/material outputs as opaque octet-streams. There is no
    // MIME subtype or universal magic header to validate, so acceptance is gated
    // by the caller plus the explicit filename allowlist above. ZIP is the
    // exception: /view often labels it application/zip, so those MIME values are
    // accepted only with a PK local-file / empty-archive / spanned signature.
    // This option is used only by get_image (action:"get"); image analysis/
    // conversion keep the default refusal behavior.
    const isZipAttachment = ext === ".zip" && ZIP_ATTACHMENT_MIMES.has(mime) && sniffZipMagic(result.base64);
    const isAttachment = allowAttachment &&
        ATTACHMENT_EXTENSIONS.has(ext) &&
        result.base64.length > 0 &&
        (isZipAttachment || (ext !== ".zip" && mime === "application/octet-stream"));
    // A `.json` request whose bytes parse as JSON is accepted whatever the server called
    // them (#1373). Gated on the REQUESTED extension so this can never widen the image or
    // media paths, and on a successful parse so the rejection this function exists for is
    // untouched: an HTML error page, a login redirect or a truncated body does not parse and
    // is still refused. ComfyUI answers 200 with an error body often enough that this check
    // is the only thing standing between the caller and a corrupt "image".
    const jsonRefusal = allowJson && ext === ".json" ? jsonAttachmentRefusal(result.base64) : null;
    const isJson = allowJson && ext === ".json" && jsonRefusal === null;
    // WHICH REFUSAL THIS IS decides the code, the message and the remedy — see
    // `jsonAttachmentRefusal`. Byte length was the first attempt at this discriminator and it
    // got the error-envelope case backwards: a `/view` that answers `{"error":"not found"}`
    // is the server reporting an ABSENT FILE, and calling that a content refusal sends the
    // caller to inspect a payload when the filename is the thing to look at.
    const contentRejected = jsonRefusal?.kind === "content";
    // An OBJ/ZIP is a real attachment, but it is not an image, supported media,
    // or JSON attachment that this caller opted to save. Do not send a successful
    // non-empty response down the missing-file path.
    const unsupportedAttachmentKind = !allowAttachment &&
        result.base64.length > 0 &&
        !isImage &&
        !isMedia &&
        !isJson
        ? ext === ".obj" && mime === "application/octet-stream"
            ? "OBJ"
            : isZipAttachment
                ? "ZIP"
                : null
        : null;
    if ((!isImage && !isMedia && !isJson && !isAttachment) || !imageContentOk || result.base64.length === 0) {
        const where = subfolder ? `${type}/${subfolder}` : type;
        const received = result.base64.length === 0
            ? "an empty response"
            : requireImageContent && isImage && !imageContentOk
                ? `invalid image content labeled "${result.mimeType}"`
                : `content-type "${result.mimeType}"`;
        throw new ComfyUIError(unsupportedAttachmentKind
            ? `ComfyUI /view returned an existing ${unsupportedAttachmentKind} attachment for "${filename}" (${where}), ` +
                `but get_image cannot save this type. Nothing was saved. ` +
                `Use ComfyUI's file browser or another raw-file download path for ${unsupportedAttachmentKind} attachments.`
            : contentRejected
                ? // NAME THE ACTUAL REASON (#1373). "The file may not exist" for a body that
                    // arrived and was rejected on its CONTENT sends the caller to re-check a
                    // filename that is perfectly correct — the wrong-cause failure this issue is
                    // about, reproduced by its own fix.
                    `ComfyUI /view returned ${jsonRefusal?.reason}, for "${filename}" (${where}). ` +
                        `Nothing was saved.`
                : jsonRefusal
                    ? // A MISSING-kind JSON refusal: say what came back AND point at the name, which
                        // is the part the caller can act on.
                        `ComfyUI /view returned ${jsonRefusal.reason}, for "${filename}" (${where}). ` +
                            `Nothing was saved. The file may not exist in the ComfyUI ${type} directory — ` +
                            `check the filename/subfolder (e.g. via get_image (action:"list_outputs") or get_history).`
                    : `ComfyUI /view did not return an image for "${filename}" (${where}); ` +
                        `got ${received}. ` +
                        `The file may not exist in the ComfyUI ${type} directory — ` +
                        `check the filename/subfolder (e.g. via get_image (action:"list_outputs") or get_history).`, 
        // THE CODE HAS TO AGREE WITH THE MESSAGE (codex P2). The message names the real
        // reason, but a caller that branches on the code — which is the whole point of
        // having one — still read IMAGE_NOT_FOUND and went off to re-check a filename that
        // was never wrong. That is the same wrong-cause failure as the prose, one layer
        // down, and it is the layer that automation reads.
        unsupportedAttachmentKind
            ? "ATTACHMENT_TYPE_UNSUPPORTED"
            : contentRejected
                ? "ATTACHMENT_CONTENT_REJECTED"
                : "IMAGE_NOT_FOUND", {
            filename,
            type,
            subfolder,
            mimeType: result.mimeType,
            // The reason, structured, so a caller does not have to parse the sentence.
            // The reason rides along on BOTH kinds — a caller should not have to parse a
            // sentence to learn the server sent an error envelope rather than nothing at all.
            ...(jsonRefusal ? { rejectedBecause: jsonRefusal.reason } : {}),
        });
    }
    return { ...result, filename };
}
/**
 * Byte ceiling for the JSON validation (#1373).
 *
 * A workflow is tens to hundreds of kilobytes; ComfyUI's own saves are well under a
 * megabyte. Parsing is the acceptance test, and parsing means decoding the whole payload
 * into a string and building an object graph from it — so an enormous body would be paid
 * for twice in memory before anything could reject it. 32 MB is far above any real
 * workflow and far below a problem.
 *
 * Above the ceiling the answer is REFUSE, not accept-unchecked: a `.json` that large is
 * not the workflow attachment this path exists to save, and accepting it unverified would
 * hand back exactly the unvalidated payload the surrounding guard is for.
 */
const MAX_JSON_ATTACHMENT_BYTES = 32 * 1024 * 1024;
/**
 * Why a `.json` payload is not savable, or null when it is (#1373).
 *
 * Returns a REASON rather than a boolean so the refusal can say what was actually wrong.
 * Collapsing "40 MB" and "this is an error envelope" into the generic "the file may not
 * exist in the ComfyUI input directory" sends the caller to check a filename that is
 * perfectly correct — which is the same wrong-cause failure this whole issue is about.
 *
 * WHAT COUNTS AS AN ERROR ENVELOPE (codex). A top-level `error` key alone was calibrated
 * wrong in both directions: it missed ComfyUI's other shapes (`node_errors`, `detail`) and
 * it rejected a legitimate workflow that happens to carry a top-level `error` field. The
 * test is now "carries an error-ish key AND carries none of the markers a workflow always
 * has" — so an error body is caught whatever it is called, and a real workflow is kept even
 * if it has an `error` extension of its own.
 */
function jsonAttachmentRefusal(base64) {
    // WHY THE KIND EXISTS (codex round 3). "Nothing was saved" is one outcome with two
    // different remedies, and the remedy is the whole value of the answer:
    //
    //   missing — the server says it has no such file (an error envelope, or nothing at
    //             all). CHECK THE FILENAME. This is what IMAGE_NOT_FOUND means and what
    //             #385 added it for.
    //   content — a body arrived and it is not the attachment: an HTML login page from a
    //             proxy, a truncated stream, something over the ceiling. Checking the
    //             filename is useless here; the name was right and the payload was wrong.
    //
    // Deciding this on emptiness alone got the error-envelope case backwards: a `/view`
    // answering `{"error":"not found"}` for a file that is genuinely absent is the server
    // TELLING us it is absent, and routing that to "the payload was unusable" sends the
    // caller to inspect content instead of the name.
    if (base64.length === 0)
        return { kind: "missing", reason: "an empty response" };
    // Decoded size from the base64 LENGTH, minus padding, so the ceiling costs nothing to
    // enforce and does not overestimate a padded payload by a byte or two at the boundary.
    const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
    const approxBytes = Math.floor((base64.length * 3) / 4) - padding;
    if (approxBytes > MAX_JSON_ATTACHMENT_BYTES) {
        return {
            kind: "content",
            reason: `a ${(approxBytes / 1024 ** 2).toFixed(1)} MB JSON body, over the ` +
                `${MAX_JSON_ATTACHMENT_BYTES / 1024 ** 2} MB ceiling for a workflow attachment — ` +
                `parsing it to check it would cost that much again in memory, so it was refused unread`,
        };
    }
    let parsed;
    try {
        parsed = JSON.parse(Buffer.from(base64, "base64").toString("utf8"));
    }
    catch {
        return { kind: "content", reason: "a body that is not valid JSON" };
    }
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
        const rec = parsed;
        const errorish = ["error", "node_errors", "detail"].filter((k) => k in rec);
        const workflowMarkers = ["nodes", "links", "last_node_id", "extra", "prompt", "workflow"];
        const looksLikeAWorkflow = workflowMarkers.some((k) => k in rec);
        if (errorish.length > 0 && !looksLikeAWorkflow) {
            return {
                // MISSING, not content. The server answered the request by saying it could not
                // serve it; the actionable fact is the name, not the bytes.
                kind: "missing",
                reason: `a JSON ERROR body (it carries "${errorish[0]}" and none of the keys a workflow has), ` +
                    `not the attachment you asked for — saving it would write a failure to disk under ` +
                    `the name of the thing that failed`,
            };
        }
    }
    return null;
}
/**
 * Upload a local image to ComfyUI via HTTP multipart POST.
 * Falls back to HTTP when COMFYUI_PATH is not available (remote ComfyUI).
 *
 * The returned `subfolder` is part of the answer, not a detail: when the
 * requested `filename` carried a path ("minimax_h3/clip.png"), the server
 * stores the file UNDER that subfolder and the bare `name` does not resolve
 * in a loader — only `subfolder/name` does (#946). If that qualified name is
 * stored but `/object_info` does not expose it on LoadImage, the same bytes
 * are also registered at the input root and the verified root filename is
 * returned instead (#2498 / same check as action:"stage").
 */
export async function uploadImageAuto(sourcePath, filename) {
    const resolvedFilename = filename ?? basename(sourcePath);
    const ext = extname(resolvedFilename).toLowerCase();
    if (!(ext in IMAGE_MIME)) {
        throw new ValidationError(`Unsupported image format "${ext}". Supported: ${Object.keys(IMAGE_MIME).join(", ")}`);
    }
    const data = await nodeReadFile(sourcePath);
    const mimeType = IMAGE_MIME[ext] ?? "application/octet-stream";
    logger.info("Uploading image to ComfyUI via HTTP", { sourcePath, resolvedFilename });
    const uploaded = await uploadAndVerifyLoaderReference({
        kind: "image",
        targetName: resolvedFilename,
        data,
        mimeType,
        requestedName: resolvedFilename,
    });
    return {
        filename: uploaded.filename,
        subfolder: uploaded.subfolder,
        loaderSelectable: uploaded.loaderSelectable,
        ...(uploaded.requestedFilename
            ? { requestedFilename: uploaded.requestedFilename }
            : {}),
    };
}
// ---------------------------------------------------------------------------
// Video / audio upload — mirrors upload_image. ComfyUI's /upload/image endpoint
// (and the input/ directory) accept arbitrary media, which video/audio loader
// nodes (e.g. VHS_LoadVideo, LoadAudio) then read by filename.
// ---------------------------------------------------------------------------
const IMAGE_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".webp": "image/webp",
    ".bmp": "image/bmp",
    ".gif": "image/gif",
    ".tiff": "image/tiff",
    ".tif": "image/tiff",
    ".avif": "image/avif",
};
const VIDEO_MIME = {
    ".mp4": "video/mp4",
    ".mov": "video/quicktime",
    ".webm": "video/webm",
    ".avi": "video/x-msvideo",
    ".mkv": "video/x-matroska",
    ".m4v": "video/x-m4v",
};
const AUDIO_MIME = {
    ".wav": "audio/wav",
    ".mp3": "audio/mpeg",
    ".flac": "audio/flac",
    ".ogg": "audio/ogg",
    ".m4a": "audio/mp4",
    ".aac": "audio/aac",
};
function resolveMediaMime(filename, mimeMap, kind) {
    const ext = extname(filename).toLowerCase();
    const mime = mimeMap[ext];
    if (!mime) {
        throw new ValidationError(`Unsupported ${kind} format "${ext}". Supported: ${Object.keys(mimeMap).join(", ")}`);
    }
    return mime;
}
/** HTTP multipart upload of a media file to ComfyUI's input/ (works remotely). */
async function uploadMediaHttp(sourcePath, filename, mimeMap, kind) {
    const resolvedFilename = filename ?? basename(sourcePath);
    const mimeType = resolveMediaMime(resolvedFilename, mimeMap, kind);
    const data = await nodeReadFile(sourcePath);
    logger.info(`Uploading ${kind} to ComfyUI via HTTP`, { sourcePath, resolvedFilename });
    const result = await uploadImageHttp(resolvedFilename, data, mimeType);
    // Same contract as uploadImageAuto: the subfolder travels with the name,
    // because a subfolder upload's bare name is not a usable loader reference.
    return { filename: result.name, subfolder: result.subfolder ?? "" };
}
/** Local filesystem copy of a media file into ComfyUI's input/ (needs COMFYUI_PATH). */
async function uploadMediaLocal(sourcePath, filename, mimeMap, kind) {
    const inputDir = await getInputDir();
    const resolvedFilename = assertSafeInputFilename(filename ?? basename(sourcePath));
    resolveMediaMime(resolvedFilename, mimeMap, kind);
    const targetPath = join(inputDir, resolvedFilename);
    logger.info(`Uploading ${kind} to ComfyUI input`, { sourcePath, targetPath });
    try {
        await copyFile(sourcePath, targetPath);
    }
    catch (err) {
        throw new ValidationError(`Failed to copy ${kind}: ${err instanceof Error ? err.message : err}`);
    }
    return { filename: resolvedFilename, path: targetPath };
}
export const uploadVideoAuto = (sourcePath, filename) => uploadMediaHttp(sourcePath, filename, VIDEO_MIME, "video");
export const uploadVideoLocal = (sourcePath, filename) => uploadMediaLocal(sourcePath, filename, VIDEO_MIME, "video");
export const uploadAudioAuto = (sourcePath, filename) => uploadMediaHttp(sourcePath, filename, AUDIO_MIME, "audio");
export const uploadAudioLocal = (sourcePath, filename) => uploadMediaLocal(sourcePath, filename, AUDIO_MIME, "audio");
/** Combo loaders whose /object_info lists input/ filenames. Path loaders are
 * STRING filesystem widgets and must not count as combo proof (#2083). */
const STAGE_LOADER_INPUTS = {
    image: new Map([
        ["LoadImage", new Set(["image"])],
        ["LoadImageMask", new Set(["image"])],
        ["LoadImageOutput", new Set(["image"])],
    ]),
    video: new Map([
        ["VHS_LoadVideo", new Set(["video"])],
        ["VHS_LoadVideoFFmpeg", new Set(["video"])],
    ]),
    audio: new Map([
        ["LoadAudio", new Set(["audio"])],
        ["VHS_LoadAudio", new Set(["audio"])],
        ["VHS_LoadAudioUpload", new Set(["audio"])],
    ]),
};
const VHS_PATH_LOADERS = new Set([
    "VHS_LoadVideoPath",
    "VHS_LoadVideoFFmpegPath",
]);
export function comboReferenceOf(staged) {
    return staged.subfolder ? `${staged.subfolder}/${staged.filename}` : staged.filename;
}
/**
 * Map a staged input onto the widget value a loader actually accepts.
 * VHS combo loaders take the input/ filename. VHS *Path loaders take a
 * filesystem path — a combo filename is not a valid path and fails at
 * panel_run with "Invalid file path" (#2083).
 */
export function stagedLoaderWidgetValue(staged, classType) {
    if (VHS_PATH_LOADERS.has(classType))
        return staged.pathReference;
    return comboReferenceOf(staged);
}
async function filesystemPathForStaged(result) {
    try {
        const inputDir = await getInputDir();
        const sub = result.subfolder ?? "";
        return sub ? join(inputDir, sub, result.name) : join(inputDir, result.name);
    }
    catch (err) {
        logger.warn("Could not resolve input dir for staged Path-loader reference", {
            error: err instanceof Error ? err.message : String(err),
        });
        return undefined;
    }
}
async function withVideoPathReference(staged) {
    if (staged.kind !== "video")
        return staged;
    const pathReference = await filesystemPathForStaged({
        name: staged.filename,
        subfolder: staged.subfolder,
    });
    return pathReference ? { ...staged, pathReference } : staged;
}
/** Read every ComfyUI combo shape that can enumerate loader filenames. */
function comboOptions(spec) {
    if (!Array.isArray(spec))
        return null;
    if (Array.isArray(spec[0])) {
        return spec[0].filter((value) => typeof value === "string");
    }
    const options = spec[1]?.options;
    if (!Array.isArray(options))
        return null;
    return options
        .map((value) => value && typeof value === "object" && !Array.isArray(value) && "key" in value
        ? value.key
        : value)
        .filter((value) => typeof value === "string");
}
/**
 * Verify the exact input reference against ComfyUI's fresh loader choices.
 * `null` means the server did not expose a usable loader list, so callers must
 * not turn an unverified upload into a claim that a widget can select it.
 */
function loaderReferenceIsSelectable(objectInfo, kind, reference) {
    let sawEnumerableList = false;
    for (const [classType, inputNames] of STAGE_LOADER_INPUTS[kind]) {
        const def = objectInfo?.[classType];
        if (!def || typeof def !== "object")
            continue;
        for (const inputName of inputNames) {
            const spec = def.input?.required?.[inputName] ?? def.input?.optional?.[inputName];
            if (!spec)
                continue;
            const options = comboOptions(spec);
            if (options)
                sawEnumerableList = true;
            if (options?.includes(reference))
                return true;
        }
    }
    return sawEnumerableList ? false : null;
}
async function verifyLoaderReference(kind, reference) {
    try {
        // The upload changes the server-side file list, not the node definitions.
        // Invalidate the shared snapshot so this check cannot certify against the
        // pre-stage /object_info response (#2082).
        resetObjectInfoCache();
        return loaderReferenceIsSelectable(await getObjectInfo(), kind, reference);
    }
    catch (err) {
        logger.warn("Could not verify staged input against /object_info", {
            kind,
            reference,
            error: err instanceof Error ? err.message : String(err),
        });
        return null;
    }
}
/**
 * POST the bytes, then refuse to return a nested reference that the live
 * loader combo does not enumerate. Hosts that store `subfolder/name` but
 * only list top-level input files get a second, non-overwriting root
 * upload so the value handed to LoadImage / VHS_LoadVideo is selectable
 * (#2082, #2498).
 */
async function uploadAndVerifyLoaderReference(args) {
    const nestedRequested = args.requestedName.includes("/") || args.requestedName.includes("\\");
    const result = await uploadImageHttp(args.targetName, args.data, args.mimeType);
    const stagedReference = comboReferenceOf({
        filename: result.name,
        subfolder: result.subfolder,
    });
    const nestedTarget = nestedRequested || Boolean(result.subfolder);
    const nestedSelectable = await verifyLoaderReference(args.kind, stagedReference);
    const requestedFilename = nestedRequested
        ? args.requestedName.replace(/\\/g, "/")
        : undefined;
    if (nestedTarget && nestedSelectable === false) {
        // Some ComfyUI builds store a nested upload correctly but do not expose
        // nested input paths in the loader combo. Re-register the same bytes at the
        // root so the returned value is actually selectable, rather than claiming
        // that the nested path works because /upload/image returned 200.
        const alreadyRoot = !result.subfolder && args.targetName === basename(args.targetName);
        if (!alreadyRoot) {
            const rootName = basename(args.targetName);
            // Never replace an unrelated root file when the host requires this
            // compatibility fallback. ComfyUI will choose a unique name when the
            // requested root name already exists, and the returned name is verified
            // below before being reported as selectable.
            const rootResult = await uploadImageHttp(rootName, args.data, args.mimeType, false);
            const rootSelectable = await verifyLoaderReference(args.kind, comboReferenceOf({
                filename: rootResult.name,
                subfolder: rootResult.subfolder,
            }));
            return {
                filename: rootResult.name,
                subfolder: rootResult.subfolder ?? "",
                type: rootResult.type,
                loaderSelectable: rootSelectable === true ? "root-fallback" : "unverified",
                requestedFilename: requestedFilename ?? stagedReference,
            };
        }
    }
    return {
        filename: result.name,
        subfolder: result.subfolder ?? "",
        type: result.type,
        loaderSelectable: nestedSelectable === true ? "verified" : "unverified",
    };
}
const MIME_BY_KIND = {
    image: IMAGE_MIME,
    video: VIDEO_MIME,
    audio: AUDIO_MIME,
};
/** Infer image/video/audio from a filename's extension. */
export function inferMediaKind(filename) {
    const ext = extname(filename).toLowerCase();
    if (ext in IMAGE_MIME)
        return "image";
    if (ext in VIDEO_MIME)
        return "video";
    if (ext in AUDIO_MIME)
        return "audio";
    throw new ValidationError(`Cannot infer media kind from extension "${ext}". ` +
        `Pass an explicit kind ("image" | "video" | "audio").`);
}
/** Resolve the upload MIME type for a filename within a given media kind. */
function mimeForKind(filename, kind) {
    const ext = extname(filename).toLowerCase();
    const mime = MIME_BY_KIND[kind][ext];
    if (!mime) {
        throw new ValidationError(`Extension "${ext}" is not a supported ${kind} format. ` +
            `Supported: ${Object.keys(MIME_BY_KIND[kind]).join(", ")}`);
    }
    return mime;
}
/**
 * Stage an EXISTING ComfyUI output (or temp preview) as an INPUT, ready to feed
 * into the next stage's LoadImage / VHS_LoadVideo / LoadAudio loader.
 *
 * Fetches the bytes from the server via /view (the same path get_image uses) and
 * re-registers them as an input via /upload/image (the same path upload_image /
 * upload_image (action:"video") / upload_image (action:"audio") use). Goes entirely through the server API, so it
 * is correct even when ComfyUI runs with a custom input/output directory.
 */
export async function stageOutputAsInput(args) {
    const sourceType = args.type ?? "output";
    const kind = args.kind ?? inferMediaKind(args.filename);
    const requestedName = args.asFilename ?? basename(args.filename);
    const mimeType = mimeForKind(requestedName, kind);
    const nestedRequested = requestedName.includes("/") || requestedName.includes("\\");
    // VHS_LoadVideo combo lists only top-level input/ files. A nested video
    // as_filename is stored in a subfolder that never appears in that combo, and
    // VHS_LoadVideoPath then treats the combo filename as a filesystem path
    // ("Invalid file path"). Stage videos at the input root instead (#2083).
    const flattenVideoToRoot = kind === "video" && nestedRequested;
    const targetName = flattenVideoToRoot ? basename(requestedName) : requestedName;
    // Fetch the existing asset's bytes via the same /view mechanism the asset
    // tools use (fetchImage handles cloud vs local). Despite the name, /view
    // returns raw bytes for any media type, not just images.
    const source = normalizeViewRef(args.filename, args.subfolder ?? "");
    const { base64 } = await fetchImage(source.filename, sourceType, source.subfolder);
    const data = Buffer.from(base64, "base64");
    logger.info("Staging ComfyUI output as input via server API", {
        source: args.filename,
        sourceType,
        kind,
        targetName,
    });
    const uploaded = await uploadAndVerifyLoaderReference({
        kind,
        targetName,
        data,
        mimeType,
        requestedName,
    });
    const requestedFilename = nestedRequested
        ? requestedName.replace(/\\/g, "/")
        : undefined;
    return withVideoPathReference({
        filename: uploaded.filename,
        subfolder: uploaded.subfolder,
        type: uploaded.type,
        kind,
        loaderSelectable: flattenVideoToRoot && uploaded.loaderSelectable === "verified"
            ? "root-fallback"
            : uploaded.loaderSelectable,
        ...(uploaded.requestedFilename
            ? { requestedFilename: uploaded.requestedFilename }
            : flattenVideoToRoot && requestedFilename
                ? { requestedFilename }
                : {}),
    });
}
//# sourceMappingURL=image-management.js.map