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),
180 lines • 7.42 kB
JavaScript
import { createWorkflow } from "./workflow-composer.js";
import { DefaultsManager } from "./defaults-manager.js";
import { ValidationError } from "../utils/errors.js";
import { assertSafeInputFilename, assertSafeFilenamePrefix, } from "../utils/input-paths.js";
const DEFAULT_SECONDS = 4;
const DEFAULT_FPS = 25;
const DEFAULT_WIDTH = 768;
const DEFAULT_HEIGHT = 512;
const MAX_FRAMES = 257; // LTX practical cap (~10s @25fps)
// Canonical LTX-2.3 dependency filenames (render-verified Comfy-Org stack).
const LTX_CHECKPOINTS = [
"ltx-2.3-22b-dev.safetensors",
"ltx-2.3-22b-dev-fp8.safetensors",
];
const GEMMA_ENCODER = "gemma_3_12B_it_fp8_scaled.safetensors";
const DISTILLED_LORA = "ltx_2.3_22b_distilled_1.1_lora_dynamic_fro09_avg_rank_111_bf16.safetensors";
const ABLITERATED_LORA = "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors";
const DEFAULTABLE_KEYS = [
"negative_prompt",
"seed",
"steps",
"cfg",
"fps",
"checkpoint",
"filename_prefix",
];
/** Round to the nearest valid LTX frame count (8n+1), clamped to [9, MAX_FRAMES]. */
export function normalizeFrameCount(frames) {
const n = Math.max(1, Math.round((frames - 1) / 8));
const length = n * 8 + 1;
return Math.min(Math.max(length, 9), MAX_FRAMES);
}
/** Round a dimension to the nearest multiple of 32 (LTX requirement). */
function roundTo32(value) {
return Math.max(32, Math.round(value / 32) * 32);
}
/** Parse a "WIDTHxHEIGHT" string; returns undefined if it doesn't parse. */
export function parseResolution(resolution) {
if (!resolution)
return undefined;
const m = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(resolution.trim());
if (!m)
return undefined;
const width = roundTo32(Number(m[1]));
const height = roundTo32(Number(m[2]));
if (!Number.isFinite(width) || !Number.isFinite(height))
return undefined;
return { width, height };
}
function baseName(p) {
return p.split(/[\\/]/).pop() ?? p;
}
/** True if `name` (by basename, case-insensitive) is in the listing. */
function hasModel(listing, name) {
const target = baseName(name).toLowerCase();
return listing.some((m) => baseName(m).toLowerCase() === target);
}
/**
* Return the first candidate present in `listing`, or push an actionable entry to
* `missing` and return the primary candidate. When `listing` is null the model
* roster couldn't be read (e.g. no running server) — we can't verify, so we
* assume the primary candidate and skip the check rather than false-block.
*/
function requireModel(listing, candidates, label, missing) {
const primary = candidates[0];
if (listing === null)
return primary;
for (const c of candidates) {
if (hasModel(listing, c))
return c;
}
missing.push(`${label} (${candidates.join(" or ")})`);
return primary;
}
/**
* Compose + enqueue an LTX-2.3 distilled video workflow (text-to-video, or
* image-to-video when `image` is given). Verifies the SPECIFIC LTX checkpoint,
* gemma text encoder, and the two required LoRAs are present before enqueuing —
* any missing dependency throws one actionable error pointing at the ltx-2.3
* packs. Normalizes seconds → an 8n+1 frame count. Reuses the `ltx_video`
* composer template.
*/
export async function generateVideo(args, deps) {
if (!args.prompt || !args.prompt.trim()) {
throw new ValidationError("prompt is required for video generation.");
}
if (args.seconds !== undefined && args.seconds <= 0) {
throw new ValidationError("seconds must be a positive number.");
}
if (args.strength !== undefined && (args.strength < 0 || args.strength > 1)) {
throw new ValidationError("strength must be between 0 and 1.");
}
// Sanitize file-ish inputs before they reach LoadImage / SaveVideo.
if (args.image !== undefined)
assertSafeInputFilename(args.image, "image");
const seed = {};
for (const key of DEFAULTABLE_KEYS) {
const v = args[key];
if (v !== undefined)
seed[key] = v;
}
const resolved = DefaultsManager.apply(seed);
const filenamePrefix = resolved.filename_prefix;
if (filenamePrefix !== undefined)
assertSafeFilenamePrefix(filenamePrefix);
// Resolution: reject unparseable input rather than silently defaulting.
let res;
if (args.resolution !== undefined) {
const parsed = parseResolution(args.resolution);
if (!parsed) {
throw new ValidationError(`Invalid resolution "${args.resolution}": use "WIDTHxHEIGHT", e.g. "768x512".`);
}
res = parsed;
}
else {
res = { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
}
// Verify every required LTX dependency is actually present. A null listing
// means we couldn't read the roster (no server) — verification is skipped.
const safeList = async (type) => {
try {
return await deps.listModels(type);
}
catch {
return null;
}
};
const checkpointList = await safeList("checkpoints");
const encoderList = await safeList("text_encoders");
const loraList = await safeList("loras");
// Only a per-call `args.checkpoint` is a deliberate override (presence-checked).
// We deliberately do NOT widen the candidate list with an ambient DEFAULT
// checkpoint: a global default set to a non-LTX model (e.g. an SD1.5 ckpt) would
// otherwise be silently accepted and enqueue a broken LTX graph. With no explicit
// arg we require one of the known LTX checkpoints.
const explicitCheckpoint = args.checkpoint;
const missing = [];
const checkpoint = requireModel(checkpointList, explicitCheckpoint ? [explicitCheckpoint] : LTX_CHECKPOINTS, "LTX checkpoint", missing);
const textEncoder = requireModel(encoderList, [GEMMA_ENCODER], "Gemma text encoder", missing);
requireModel(loraList, [DISTILLED_LORA], "distilled speed LoRA", missing);
requireModel(loraList, [ABLITERATED_LORA], "gemma abliterated LoRA", missing);
if (missing.length > 0) {
throw new ValidationError(`Missing required LTX-2.3 model file(s): ${missing.join("; ")}. Install them with ` +
"apply_manifest --path packs/ltx-2.3-txt2vid/manifest.yaml (or ltx-2.3-img2vid " +
"for image-to-video), or pass an explicit `checkpoint` you already have.");
}
const fps = resolved.fps ?? DEFAULT_FPS;
const seconds = args.seconds ?? DEFAULT_SECONDS;
const length = normalizeFrameCount(seconds * fps);
const mode = args.image ? "i2v" : "t2v";
const workflow = createWorkflow("ltx_video", {
prompt: args.prompt,
negative_prompt: resolved.negative_prompt,
image_path: args.image,
checkpoint,
text_encoder: textEncoder,
width: res.width,
height: res.height,
length,
fps,
steps: resolved.steps,
cfg: resolved.cfg,
seed: resolved.seed,
strength: args.strength,
filename_prefix: filenamePrefix,
});
const { prompt_id, queue_remaining, rejectedOutputs } = await deps.enqueue(workflow);
return {
prompt_id,
queue_remaining,
rejectedOutputs,
mode,
checkpoint,
width: res.width,
height: res.height,
length,
fps,
};
}
//# sourceMappingURL=generate-video.js.map