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

854 lines • 31.8 kB
import { ValidationError } from "../utils/errors.js"; // --- Helpers --- export function getNextNodeId(workflow) { const ids = Object.keys(workflow).map(Number).filter((n) => !Number.isNaN(n)); return String(ids.length === 0 ? 1 : Math.max(...ids) + 1); } function conn(nodeId, outputIndex) { return [nodeId, outputIndex]; } // --- Templates --- function buildTxt2Img(p) { const ckpt = p.checkpoint ?? "sd_xl_base_1.0.safetensors"; const positive = p.positive_prompt ?? ""; const negative = p.negative_prompt ?? ""; const width = p.width ?? 1024; const height = p.height ?? 1024; const steps = p.steps ?? 20; const cfg = p.cfg ?? 8.0; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const sampler = p.sampler_name ?? "euler"; const scheduler = p.scheduler ?? "normal"; return { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: ckpt }, }, "2": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: conn("1", 1) }, _meta: { title: "Positive Prompt" }, }, "3": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: conn("1", 1) }, _meta: { title: "Negative Prompt" }, }, "4": { class_type: "EmptyLatentImage", inputs: { width, height, batch_size: 1 }, }, "5": { class_type: "KSampler", inputs: { model: conn("1", 0), positive: conn("2", 0), negative: conn("3", 0), latent_image: conn("4", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise: 1.0, }, }, "6": { class_type: "VAEDecode", inputs: { samples: conn("5", 0), vae: conn("1", 2) }, }, "7": { class_type: "SaveImage", inputs: { images: conn("6", 0), filename_prefix: "ComfyUI" }, }, }; } function buildImg2Img(p) { const ckpt = p.checkpoint ?? "sd_xl_base_1.0.safetensors"; const positive = p.positive_prompt ?? ""; const negative = p.negative_prompt ?? ""; const steps = p.steps ?? 20; const cfg = p.cfg ?? 8.0; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const sampler = p.sampler_name ?? "euler"; const scheduler = p.scheduler ?? "normal"; const denoise = p.denoise ?? 0.75; const imagePath = p.image_path ?? "input.png"; return { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: ckpt }, }, "2": { class_type: "LoadImage", inputs: { image: imagePath }, }, "3": { class_type: "VAEEncode", inputs: { pixels: conn("2", 0), vae: conn("1", 2) }, }, "4": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: conn("1", 1) }, _meta: { title: "Positive Prompt" }, }, "5": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: conn("1", 1) }, _meta: { title: "Negative Prompt" }, }, "6": { class_type: "KSampler", inputs: { model: conn("1", 0), positive: conn("4", 0), negative: conn("5", 0), latent_image: conn("3", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise, }, }, "7": { class_type: "VAEDecode", inputs: { samples: conn("6", 0), vae: conn("1", 2) }, }, "8": { class_type: "SaveImage", inputs: { images: conn("7", 0), filename_prefix: "ComfyUI" }, }, }; } function buildUpscale(p) { // 4x-ClearRealityV1 is a clean general-purpose ESRGAN model already shipped by // the anima/ernie packs (models/upscale_models/). The model itself is 4x; a // scale of 2 supersamples the 4x result back down by half (sharper, fewer // artifacts) — the "4x→2x supersample" recipe from the video-upscale skill. const model = p.upscale_model ?? "4x-ClearRealityV1.pth"; const imagePath = p.image_path ?? "input.png"; const scale = p.scale ?? 4; const wf = { "1": { class_type: "LoadImage", inputs: { image: imagePath }, }, "2": { class_type: "UpscaleModelLoader", inputs: { model_name: model }, }, "3": { class_type: "ImageUpscaleWithModel", inputs: { upscale_model: conn("2", 0), image: conn("1", 0) }, }, }; let imageOut = "3"; if (scale === 2) { // Downsample the model's 4x output by half → net 2x, with anti-aliasing. wf["4"] = { class_type: "ImageScaleBy", inputs: { image: conn("3", 0), upscale_method: "lanczos", scale_by: 0.5 }, }; imageOut = "4"; } const saveId = scale === 2 ? "5" : "4"; wf[saveId] = { class_type: "SaveImage", inputs: { images: conn(imageOut, 0), filename_prefix: "ComfyUI_upscale" }, }; return wf; } // Uses ComfyUI-RMBG's BiRefNetRMBG node (cnr id: comfyui-rmbg). The node loads a // BiRefNet matting model (auto-downloaded to models/RMBG/BiRefNet/ on first use) // and outputs the cutout (IMAGE, output 0), a MASK, and a MASK_IMAGE. We save the // transparent cutout. Proven in packs/wan-transparent. function buildRemoveBackground(p) { const imagePath = p.image_path ?? "input.png"; const model = p.model ?? "BiRefNet_toonout"; const prefix = p.filename_prefix ?? "ComfyUI_cutout"; return { "1": { class_type: "LoadImage", inputs: { image: imagePath }, }, "2": { class_type: "BiRefNetRMBG", // `background: "Alpha"` forces a transparent RGBA cutout (matches // packs/wan-transparent). The other widgets are declared "optional" in the // node schema but ComfyUI-RMBG's process_image reads them by key, so omitting // them KeyErrors at runtime over the API (defaults are only injected by the // UI). Pass every one explicitly with the node's documented defaults. inputs: { image: conn("1", 0), model, mask_blur: 0, mask_offset: 0, invert_output: false, refine_foreground: false, background: "Alpha", background_color: "#222222", }, }, "3": { class_type: "SaveImage", inputs: { images: conn("2", 0), filename_prefix: prefix }, }, }; } // LTX-2.3 distilled, video-only, single-stage graph built on the render-verified // Comfy-Org node stack (all CORE comfy_extras): CheckpointLoaderSimple + // LTXAVTextEncoderLoader (gemma + checkpoint) + the gemma-abliterated CLIP LoRA // (prompt/eyes accuracy) + the dynamic distilled speed LoRA @0.5, sampled with // SamplerCustomAdvanced (CFGGuider cfg=1, euler, LTXVScheduler). This deliberately // OMITS the 48kHz audio track and the stage-2 latent spatial upscale that the full // ltx-2.3 packs ship — load those packs for maximum fidelity. Node I/O verified // against packs/ltx-2.3-img2vid/workflow.json. function buildLtxVideo(p) { const ckpt = p.checkpoint ?? "ltx-2.3-22b-dev.safetensors"; const textEncoder = p.text_encoder ?? "gemma_3_12B_it_fp8_scaled.safetensors"; const distilledLora = p.distilled_lora ?? "ltx_2.3_22b_distilled_1.1_lora_dynamic_fro09_avg_rank_111_bf16.safetensors"; const abliteratedLora = p.abliterated_lora ?? "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors"; const width = p.width ?? 768; const height = p.height ?? 512; const length = p.length ?? 97; const fps = p.fps ?? 25; const steps = p.steps ?? 8; const cfg = p.cfg ?? 1.0; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const sampler = p.sampler_name ?? "euler"; const prompt = p.prompt ?? ""; const negative = p.negative_prompt ?? ""; const strength = p.strength ?? 0.6; const prefix = p.filename_prefix ?? "video/ltx-2.3"; const isI2v = Boolean(p.image_path); const wf = { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: ckpt } }, "2": { class_type: "LTXAVTextEncoderLoader", // `device` is a required widget (default = load on the compute device). inputs: { text_encoder: textEncoder, ckpt_name: ckpt, device: "default" }, }, // distilled speed LoRA on the checkpoint model @0.5 (this is the model the // guider samples with — matches the pack's LoraLoaderModelOnly placement). "3": { class_type: "LoraLoaderModelOnly", inputs: { model: conn("1", 0), lora_name: distilledLora, strength_model: 0.5 }, }, // gemma abliterated LoRA — used for its CLIP output (prompt/eyes accuracy); // the model output is left unused, mirroring the pack. "4": { class_type: "LoraLoader", inputs: { model: conn("3", 0), clip: conn("2", 0), lora_name: abliteratedLora, strength_model: 1.0, strength_clip: 1.0, }, }, "5": { class_type: "CLIPTextEncode", inputs: { text: prompt, clip: conn("4", 1) }, _meta: { title: "Positive Prompt" }, }, "6": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: conn("4", 1) }, _meta: { title: "Negative Prompt" }, }, "7": { class_type: "LTXVConditioning", inputs: { positive: conn("5", 0), negative: conn("6", 0), frame_rate: fps }, }, }; // Conditioning (positive/negative) is shared by both modes — it comes straight // from LTXVConditioning (#7). Only the LATENT differs: t2v uses an empty video // latent; i2v bakes the start frame into that same empty latent via // LTXVImgToVideoInplace (the node the ltx-2.3 packs actually use — there is no // `LTXVImgToVideo` in any pack). LTXVImgToVideoInplace takes vae+image+latent // and re-emits a LATENT; `strength` and `bypass` are WIDGETS, not outputs. const condPos = conn("7", 0); const condNeg = conn("7", 1); let latent; // Empty video latent (matches the pack's EmptyLTXVLatentVideo widget order). wf["8"] = { class_type: "EmptyLTXVLatentVideo", inputs: { width, height, length, batch_size: 1 }, }; if (isI2v) { wf["9"] = { class_type: "LoadImage", inputs: { image: p.image_path } }; wf["10"] = { class_type: "LTXVImgToVideoInplace", inputs: { vae: conn("1", 2), image: conn("9", 0), latent: conn("8", 0), strength, // widget: start-frame adherence (the "LTX strength gotcha") bypass: false, // widget: false = apply the image (true = pure t2v) }, }; latent = conn("10", 0); } else { latent = conn("8", 0); } wf["20"] = { class_type: "RandomNoise", inputs: { noise_seed: seed } }; wf["21"] = { class_type: "KSamplerSelect", inputs: { sampler_name: sampler } }; wf["22"] = { class_type: "LTXVScheduler", inputs: { steps, max_shift: 2.05, base_shift: 0.95, stretch: true, terminal: 0.1, latent, }, }; wf["23"] = { // Model = distilled-LoRA path (#3); the abliterated LoRA (#4) only conditions // the CLIP that feeds the text encodes — mirrors the pack's guider wiring. class_type: "CFGGuider", inputs: { model: conn("3", 0), positive: condPos, negative: condNeg, cfg }, }; wf["24"] = { class_type: "SamplerCustomAdvanced", inputs: { noise: conn("20", 0), guider: conn("23", 0), sampler: conn("21", 0), sigmas: conn("22", 0), latent_image: latent, }, }; wf["25"] = { class_type: "VAEDecode", inputs: { samples: conn("24", 0), vae: conn("1", 2) }, }; wf["26"] = { class_type: "CreateVideo", inputs: { images: conn("25", 0), fps } }; wf["27"] = { class_type: "SaveVideo", // `format` and `codec` are required widgets ("auto" lets ComfyUI pick mp4/h264). inputs: { video: conn("26", 0), filename_prefix: prefix, format: "auto", codec: "auto" }, }; return wf; } function buildInpaint(p) { const ckpt = p.checkpoint ?? "sd_xl_base_1.0.safetensors"; const positive = p.positive_prompt ?? ""; const negative = p.negative_prompt ?? ""; const steps = p.steps ?? 20; const cfg = p.cfg ?? 8.0; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const sampler = p.sampler_name ?? "euler"; const scheduler = p.scheduler ?? "normal"; const denoise = p.denoise ?? 0.85; const imagePath = p.image_path ?? "input.png"; const maskPath = p.mask_path ?? "mask.png"; return { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: ckpt }, }, "2": { class_type: "LoadImage", inputs: { image: imagePath }, _meta: { title: "Input Image" }, }, "3": { class_type: "LoadImage", inputs: { image: maskPath }, _meta: { title: "Mask" }, }, "4": { class_type: "VAEEncode", inputs: { pixels: conn("2", 0), vae: conn("1", 2) }, }, "5": { class_type: "SetLatentNoiseMask", inputs: { samples: conn("4", 0), mask: conn("3", 1) }, }, "6": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: conn("1", 1) }, _meta: { title: "Positive Prompt" }, }, "7": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: conn("1", 1) }, _meta: { title: "Negative Prompt" }, }, "8": { class_type: "KSampler", inputs: { model: conn("1", 0), positive: conn("6", 0), negative: conn("7", 0), latent_image: conn("5", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise, }, }, "9": { class_type: "VAEDecode", inputs: { samples: conn("8", 0), vae: conn("1", 2) }, }, "10": { class_type: "SaveImage", inputs: { images: conn("9", 0), filename_prefix: "ComfyUI_inpaint" }, }, }; } function buildControlNet(p) { const ckpt = p.checkpoint ?? "sd_xl_base_1.0.safetensors"; const positive = p.positive_prompt ?? ""; const negative = p.negative_prompt ?? ""; const width = p.width ?? 1024; const height = p.height ?? 1024; const steps = p.steps ?? 20; const cfg = p.cfg ?? 8.0; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const sampler = p.sampler_name ?? "euler"; const scheduler = p.scheduler ?? "normal"; const controlNet = p.controlnet_model ?? "control_v11p_sd15_canny.pth"; const controlImage = p.control_image ?? "control.png"; const strength = p.strength ?? 1.0; return { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: ckpt } }, "2": { class_type: "LoadImage", inputs: { image: controlImage }, _meta: { title: "Control Image" }, }, "3": { class_type: "ControlNetLoader", inputs: { control_net_name: controlNet } }, "4": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: conn("1", 1) }, _meta: { title: "Positive Prompt" }, }, "5": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: conn("1", 1) }, _meta: { title: "Negative Prompt" }, }, "6": { class_type: "ControlNetApplyAdvanced", inputs: { positive: conn("4", 0), negative: conn("5", 0), control_net: conn("3", 0), image: conn("2", 0), strength, start_percent: 0.0, end_percent: 1.0, }, }, "7": { class_type: "EmptyLatentImage", inputs: { width, height, batch_size: 1 }, }, "8": { class_type: "KSampler", inputs: { model: conn("1", 0), positive: conn("6", 0), negative: conn("6", 1), latent_image: conn("7", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise: 1.0, }, }, "9": { class_type: "VAEDecode", inputs: { samples: conn("8", 0), vae: conn("1", 2) } }, "10": { class_type: "SaveImage", inputs: { images: conn("9", 0), filename_prefix: "ComfyUI_controlnet" }, }, }; } function buildAceStep15Txt2Audio(p) { const unet = p.unet ?? "acestep_v1.5_xl_sft_bf16.safetensors"; const vae = p.vae ?? "ace_1.5_vae.safetensors"; const clipA = p.clip_a ?? "qwen_0.6b_ace15.safetensors"; const clipB = p.clip_b ?? "qwen_4b_ace15.safetensors"; const prompt = p.prompt ?? ""; const lyrics = p.lyrics ?? ""; const duration = p.duration ?? 60; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const steps = p.steps ?? 50; const cfg = p.cfg ?? 7; const sampler = p.sampler_name ?? "euler"; const scheduler = p.scheduler ?? "simple"; const shift = p.shift ?? 3; const language = p.language ?? "en"; const key = p.musical_key ?? "C major"; // `cfg_scale` on TextEncodeAceStepAudio1.5 defaults to 2 in the node schema; // it is distinct from the sampler's `cfg` and from `temperature` (0.85). const cfgScale = p.guidance_scale ?? 2; const bpm = p.bpm ?? 120; const timesignature = p.timesignature ?? "4"; const temperature = p.temperature ?? 0.85; const topP = p.top_p ?? 0.9; const topK = p.top_k ?? 0; const minP = p.min_p ?? 0; const generateAudioCodes = p.generate_audio_codes ?? true; const audioQuality = p.audio_quality ?? "320k"; const prefix = p.filename_prefix ?? "audio/ace_step"; return { "1": { class_type: "UNETLoader", inputs: { unet_name: unet, weight_dtype: "default" }, }, "2": { class_type: "DualCLIPLoader", inputs: { clip_name1: clipA, clip_name2: clipB, type: "ace", }, }, "3": { class_type: "VAELoader", inputs: { vae_name: vae }, }, "4": { class_type: "ModelSamplingAuraFlow", inputs: { model: conn("1", 0), shift }, }, "5": { class_type: "EmptyAceStep1.5LatentAudio", inputs: { seconds: duration, batch_size: 1 }, }, "6": { class_type: "TextEncodeAceStepAudio1.5", inputs: { clip: conn("2", 0), tags: prompt, lyrics, seed, bpm, duration, timesignature, language, keyscale: key, generate_audio_codes: generateAudioCodes, cfg_scale: cfgScale, temperature, top_p: topP, top_k: topK, min_p: minP, }, }, "7": { class_type: "ConditioningZeroOut", inputs: { conditioning: conn("6", 0) }, }, "8": { class_type: "KSampler", inputs: { model: conn("4", 0), positive: conn("6", 0), negative: conn("7", 0), latent_image: conn("5", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise: 1.0, }, }, "9": { class_type: "VAEDecodeAudio", inputs: { samples: conn("8", 0), vae: conn("3", 0) }, }, "10": { class_type: "SaveAudioMP3", inputs: { audio: conn("9", 0), filename_prefix: prefix, quality: audioQuality }, }, }; } function buildStableAudio3Txt2Audio(p) { const checkpoint = p.checkpoint ?? "stable_audio_3_medium_base.safetensors"; const clip = p.clip ?? "t5gemma_b_b_ul2.safetensors"; const prompt = p.prompt ?? ""; const negativePrompt = p.negative_prompt ?? ""; const duration = p.duration ?? 60; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const steps = p.steps ?? 50; const cfg = p.cfg ?? 7; // #1458 — NOT "lcm". lcm at cfg 7 renders DIGITAL SILENCE (mean -91 dB) while // ComfyUI reports success: a correctly-sized, correctly-durated file of nothing, // with nothing in the logs to say so. The reporter measured six combinations and // that pair was the only silent one — lcm at cfg 1 is fine — so it was specifically // the pairing this template shipped. // // dpmpp_2m rather than euler, also measured: at 42s euler produced ~53.7 audible // click artifacts/sec against ~0.8/sec for dpmpp_2m. Both avoid the silence; only // one stays clean as the render gets longer, and a default is judged on the long // case because that is where nobody is watching. const sampler = p.sampler_name ?? "dpmpp_2m"; const scheduler = p.scheduler ?? "simple"; const prefix = p.filename_prefix ?? "audio/stable_audio_3"; const audioQuality = p.audio_quality ?? "320k"; return { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: checkpoint }, }, "2": { class_type: "CLIPLoader", inputs: { clip_name: clip, type: "stable_audio" }, }, "3": { class_type: "CLIPTextEncode", inputs: { clip: conn("2", 0), text: prompt }, _meta: { title: "Positive Prompt" }, }, "4": { class_type: "CLIPTextEncode", inputs: { clip: conn("2", 0), text: negativePrompt }, _meta: { title: "Negative Prompt" }, }, "5": { class_type: "EmptyLatentAudio", inputs: { seconds: duration, batch_size: 1 }, }, "6": { class_type: "KSampler", inputs: { model: conn("1", 0), positive: conn("3", 0), negative: conn("4", 0), latent_image: conn("5", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise: 1.0, }, }, "7": { class_type: "VAEDecodeAudio", inputs: { samples: conn("6", 0), vae: conn("1", 2) }, }, "8": { class_type: "SaveAudioMP3", // #1458 — `quality` is REQUIRED on SaveAudioMP3. Omitting it made every // audio generation for this family fail validation before execution: // "SaveAudioMP3 (node 8): Required input is missing (quality)". The ace_step // builder above always set it; only this sibling did not. inputs: { audio: conn("7", 0), filename_prefix: prefix, quality: audioQuality }, }, }; } // Requires the ComfyUI_IPAdapter_plus custom node pack (IPAdapterUnifiedLoader, IPAdapter). function buildIpAdapter(p) { const ckpt = p.checkpoint ?? "sd_xl_base_1.0.safetensors"; const positive = p.positive_prompt ?? ""; const negative = p.negative_prompt ?? ""; const width = p.width ?? 1024; const height = p.height ?? 1024; const steps = p.steps ?? 20; const cfg = p.cfg ?? 8.0; const seed = p.seed ?? Math.floor(Math.random() * 2 ** 48); const sampler = p.sampler_name ?? "euler"; const scheduler = p.scheduler ?? "normal"; const refImage = p.reference_image ?? "reference.png"; const weight = p.weight ?? 0.8; const preset = p.preset ?? "PLUS (high strength)"; return { "1": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: ckpt } }, "2": { class_type: "LoadImage", inputs: { image: refImage }, _meta: { title: "Reference Image" }, }, "3": { class_type: "IPAdapterUnifiedLoader", inputs: { model: conn("1", 0), preset }, }, "4": { class_type: "IPAdapter", inputs: { model: conn("3", 0), ipadapter: conn("3", 1), image: conn("2", 0), weight, // Required by current IPAdapter_plus — without it the prompt fails // validation with "Required input is missing: weight_type" (#305). weight_type: p.weight_type ?? "standard", start_at: 0.0, end_at: 1.0, }, }, "5": { class_type: "CLIPTextEncode", inputs: { text: positive, clip: conn("1", 1) }, _meta: { title: "Positive Prompt" }, }, "6": { class_type: "CLIPTextEncode", inputs: { text: negative, clip: conn("1", 1) }, _meta: { title: "Negative Prompt" }, }, "7": { class_type: "EmptyLatentImage", inputs: { width, height, batch_size: 1 }, }, "8": { class_type: "KSampler", inputs: { model: conn("4", 0), positive: conn("5", 0), negative: conn("6", 0), latent_image: conn("7", 0), seed, steps, cfg, sampler_name: sampler, scheduler, denoise: 1.0, }, }, "9": { class_type: "VAEDecode", inputs: { samples: conn("8", 0), vae: conn("1", 2) } }, "10": { class_type: "SaveImage", inputs: { images: conn("9", 0), filename_prefix: "ComfyUI_ipadapter" }, }, }; } const TEMPLATES = { txt2img: (p) => buildTxt2Img(p), img2img: (p) => buildImg2Img(p), upscale: (p) => buildUpscale(p), inpaint: (p) => buildInpaint(p), controlnet: (p) => buildControlNet(p), ip_adapter: (p) => buildIpAdapter(p), ace_step_15: (p) => buildAceStep15Txt2Audio(p), stable_audio_3: (p) => buildStableAudio3Txt2Audio(p), remove_background: (p) => buildRemoveBackground(p), ltx_video: (p) => buildLtxVideo(p), }; export const TEMPLATE_NAMES = Object.keys(TEMPLATES); export function createWorkflow(template, params = {}) { const builder = TEMPLATES[template]; if (!builder) { throw new ValidationError(`Unknown template "${template}". Available: ${TEMPLATE_NAMES.join(", ")}`); } return builder(params); } function applySetInput(wf, op) { const node = wf[op.node_id]; if (!node) throw new ValidationError(`Node "${op.node_id}" not found`); node.inputs[op.input_name] = op.value; } function applyAddNode(wf, op) { const id = op.id ?? getNextNodeId(wf); if (wf[id]) throw new ValidationError(`Node ID "${id}" already exists`); wf[id] = { class_type: op.class_type, inputs: op.inputs ?? {}, }; return id; } function applyRemoveNode(wf, op) { if (!wf[op.node_id]) throw new ValidationError(`Node "${op.node_id}" not found`); delete wf[op.node_id]; // Clean up any connections pointing to the removed node for (const node of Object.values(wf)) { for (const [key, val] of Object.entries(node.inputs)) { if (Array.isArray(val) && val.length === 2 && typeof val[0] === "string" && val[0] === op.node_id) { delete node.inputs[key]; } } } } function applyConnect(wf, op) { if (!wf[op.source_id]) throw new ValidationError(`Source node "${op.source_id}" not found`); if (!wf[op.target_id]) throw new ValidationError(`Target node "${op.target_id}" not found`); wf[op.target_id].inputs[op.input_name] = [op.source_id, op.output_index]; } function applyInsertBetween(wf, op) { if (!wf[op.source_id]) throw new ValidationError(`Source node "${op.source_id}" not found`); if (!wf[op.target_id]) throw new ValidationError(`Target node "${op.target_id}" not found`); const newId = getNextNodeId(wf); const newInputs = { ...(op.new_inputs ?? {}) }; // Connect the new node's first input to the original source // Find the first input name that isn't already set -- use a convention-based approach // The new node receives the source output on its primary input // We'll figure out the right input name by looking for common patterns const primaryInputNames = ["model", "clip", "samples", "latent_image", "image", "conditioning", "pixels"]; let connected = false; for (const name of primaryInputNames) { if (!(name in newInputs)) { newInputs[name] = [op.source_id, op.output_index]; connected = true; break; } } if (!connected) { // Fallback: add as first unused slot newInputs["input"] = [op.source_id, op.output_index]; } wf[newId] = { class_type: op.new_class_type, inputs: newInputs, }; // Rewire: target's input now points to the new node's output 0 wf[op.target_id].inputs[op.input_name] = [newId, 0]; return newId; } export function modifyWorkflow(workflow, operations) { // Deep clone to avoid mutating the original const wf = JSON.parse(JSON.stringify(workflow)); const addedIds = []; for (const op of operations) { switch (op.op) { case "set_input": applySetInput(wf, op); break; case "add_node": { const id = applyAddNode(wf, op); addedIds.push(id); break; } case "remove_node": applyRemoveNode(wf, op); break; case "connect": applyConnect(wf, op); break; case "insert_between": { const id = applyInsertBetween(wf, op); addedIds.push(id); break; } default: throw new ValidationError(`Unknown operation: ${op.op}`); } } return { workflow: wf, added_ids: addedIds }; } //# sourceMappingURL=workflow-composer.js.map