Ship MiniMax v2 identity refs and Qwen sidecar edits.

Keep v1 as the default video graph, relay image edits to the Qwen Lightning box, and keep /api/health up if one Comfy host is unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-26 14:29:15 -05:00
co-authored by Cursor
parent 551c29a62a
commit c55a48a57e
22 changed files with 2061 additions and 75 deletions
+201
View File
@@ -0,0 +1,201 @@
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
import { ensureImageComfyReady } from '~/server/utils/imageComfyLifecycle'
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
import { imageDimensions } from '~/server/utils/resolution'
function parseSteps(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return 4
return Math.min(20, Math.max(1, Math.round(value)))
}
function parseCfg(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return 1
return Math.min(8, Math.max(0.5, Math.round(value * 10) / 10))
}
function parseDenoise(raw: unknown) {
const value = Number(raw)
if (!Number.isFinite(value)) return 0.75
return Math.min(1, Math.max(0.05, Math.round(value * 100) / 100))
}
export default defineEventHandler(async (event) => {
const form = await readMultipartFormData(event).catch(() => null)
const fields: Record<string, string> = {}
let image: { filename: string; data: Buffer; type?: string } | null = null
for (const part of form || []) {
if (part.name === 'image' && part.filename && part.data?.length) {
image = { filename: part.filename, data: part.data, type: part.type }
} else if (part.name && part.data) {
fields[part.name] = part.data.toString('utf8')
}
}
const prompt = (fields.prompt || '').trim()
if (!image) {
throw createError({ statusCode: 400, statusMessage: 'An input image is required' })
}
if (!prompt) {
throw createError({ statusCode: 400, statusMessage: 'An edit prompt is required' })
}
if (!imageComfyConfigured()) {
throw createError({
statusCode: 503,
statusMessage: 'Image edit ComfyUI is not configured yet. Set IMAGE_COMFY_HOST on this instance.'
})
}
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId)
? fields.folderId
: library.folders[0]?.id
if (!folderId) {
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before editing' })
}
assertFolderExists(event, folderId)
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
const hideInput = fields.hideInput === 'true'
const steps = parseSteps(fields.steps)
const cfg = parseCfg(fields.cfg)
const denoise = parseDenoise(fields.denoise)
const seed = fields.seed && fields.seed !== 'random'
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const size = imageDimensions(image.data)
const job = createJob()
job.kind = 'edit'
job.maxStep = steps
job.hideThumbnail = hideInput
job.library = {
ownerKey,
folderId,
hideThumbnail: hideInput,
hideInput,
folderLocked,
name: (fields.name || '').trim().slice(0, 80),
prompt,
aspect: 'auto',
width: size?.width || 0,
height: size?.height || 0,
steps,
turbo: true,
seed,
cfg
}
emitJob(job, { type: 'status', message: 'Checking sidecar ComfyUI...', progress: 2 })
void runEdit(job, {
image,
prompt,
negative: (fields.negative || '').trim(),
steps,
seed,
cfg,
denoise
}).catch(async (error) => {
if (job.status === 'error' || job.status === 'cancelled') return
const message = error instanceof Error ? error.message : String(error)
job.status = 'error'
job.error = message
emitJob(job, { type: 'error', error: message, message })
})
return {
jobId: job.id,
seed,
steps,
hideThumbnail: hideInput,
folderLocked
}
})
async function runEdit(
job: ReturnType<typeof createJob>,
params: {
image: { filename: string; data: Buffer; type?: string }
prompt: string
negative: string
steps: number
seed: number
cfg: number
denoise: number
}
) {
await ensureImageComfyReady((status) => {
emitJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? Math.max(job.progress, 6) : Math.max(job.progress, 3),
busy: status.state === 'busy'
})
})
if (job.status === 'cancelled') throw new Error('Job interrupted.')
emitJob(job, { type: 'status', message: 'Uploading still to Qwen Edit...', progress: 8 })
const uploaded = await uploadImageEdit(params.image, job.id)
if (job.status === 'cancelled') throw new Error('Job interrupted.')
emitJob(job, { type: 'status', message: 'Queueing Qwen Image Edit Lightning...', progress: 12 })
const graph = buildEditWorkflow({
prompt: params.prompt,
negative: params.negative,
imageName: uploaded.name,
steps: params.steps,
seed: params.seed,
cfg: params.cfg,
denoise: params.denoise,
filenamePrefix: `aigen_edit_${job.id.slice(0, 8)}`
})
const queued = await queueImagePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
emitJob(job, { type: 'status', message: 'Editing still...', progress: 18, maxStep: params.steps })
const output = await waitForImageEdit({
promptId: queued.prompt_id,
clientId: job.clientId,
onProgress: (event) => {
emitJob(job, {
type: 'status',
message: event.message,
progress: event.progress,
step: event.step,
maxStep: event.maxStep || params.steps,
node: event.node
})
},
isCancelled: () => job.status === 'cancelled'
})
emitJob(job, { type: 'status', message: 'Saving edited still...', progress: 94 })
const buffer = await downloadEditedImage(output)
const size = imageDimensions(buffer)
const still = await saveStill({
ownerKey: job.library!.ownerKey,
folderId: job.library!.folderId,
filename: job.library?.name ? `${job.library.name}.png` : output.filename,
data: buffer,
width: size?.width || 0,
height: size?.height || 0,
hideInput: job.library?.hideInput
})
job.stillId = still.id
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: 'Edit finished',
progress: 100,
stillId: still.id,
filename: output.filename,
subfolder: output.subfolder,
mediaType: 'image',
hideThumbnail: job.hideThumbnail,
folderLocked: job.library?.folderLocked
})
}
+5 -3
View File
@@ -100,7 +100,8 @@ export default defineEventHandler(async (event) => {
extendSourceClipId: source.id,
familyId: source.familyId || crypto.randomUUID(),
parentClipId: source.id,
chainIndex: (source.chainIndex || 0) + 1
chainIndex: (source.chainIndex || 0) + 1,
workflow: parseVideoWorkflow(source.workflow)
}
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
@@ -163,7 +164,6 @@ async function runExtend(
copyFileSync(params.sourcePath, params.part1Path)
await extractLastFrame(params.part1Path, params.framePath)
const frame = readFileSync(params.framePath)
job.library!.thumb = frame
const sound = await probeHasAudio(params.part1Path)
await ensureComfyReady((status) => {
@@ -202,7 +202,9 @@ async function runExtend(
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: comfyFilenamePrefix(),
sound
sound,
workflow: job.library?.workflow,
duration: job.library?.duration
})
const queued = await queuePrompt(graph, job.clientId)
+41 -5
View File
@@ -83,9 +83,14 @@ export default defineEventHandler(async (event) => {
const fields: Record<string, string> = {}
let image: { filename: string; data: Buffer; type?: string } | null = null
const referenceImages: { filename: string; data: Buffer; type?: string }[] = []
for (const part of form) {
if (part.name === 'image' && part.filename && part.data?.length) {
image = { filename: part.filename, data: part.data, type: part.type }
} else if (part.name === 'refImage' && part.filename && part.data?.length) {
if (referenceImages.length < 4) {
referenceImages.push({ filename: part.filename, data: part.data, type: part.type })
}
} else if (part.name && part.data) {
fields[part.name] = part.data.toString('utf8')
}
@@ -99,6 +104,8 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
}
const extensions = parseExtensions(fields.extensions)
const workflow = parseVideoWorkflow(fields.workflow)
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true' && referenceImages.length > 0
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
const turbo = fields.turbo !== 'false'
@@ -170,7 +177,8 @@ export default defineEventHandler(async (event) => {
chainStep: 1,
chainTotal,
chainLabel: extensions.length ? 'Initial' : undefined,
familyId: crypto.randomUUID()
familyId: crypto.randomUUID(),
workflow
}
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
@@ -188,7 +196,11 @@ export default defineEventHandler(async (event) => {
fps,
samplerName,
scheduler,
extensions
extensions,
workflow,
duration: durationSeconds,
useIdentityRefs,
referenceImages
}).catch(async (error) => {
removeExtendTemp(job.library?.extendTmpDir)
if (isComfyBusyTimeout(error) && job.library) {
@@ -215,7 +227,8 @@ export default defineEventHandler(async (event) => {
fps: job.library.fps,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler,
extensions: job.library.extensions
extensions: job.library.extensions,
workflow: job.library.workflow
})
job.library.draftId = draft.id
job.status = 'deferred'
@@ -268,6 +281,10 @@ type GenerateParams = {
samplerName: string
scheduler: string
extensions: { prompt: string; duration: number }[]
workflow: 'v1' | 'v2'
duration: number
useIdentityRefs: boolean
referenceImages: { filename: string; data: Buffer; type?: string }[]
}
async function queueMiniMax(
@@ -290,6 +307,17 @@ async function queueMiniMax(
: 'Queueing MiniMax H3 job...'
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
const uploaded = await uploadImage(params.image, job.id)
const referenceNames: string[] = []
const chainIndex = job.library?.chainIndex || 0
if (chainIndex === 0) {
for (const [index, ref] of (params.referenceImages || []).entries()) {
const next = await uploadImage({
...ref,
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
}, job.id)
referenceNames.push(next.name)
}
}
if (job.library) {
job.library.imageName = uploaded.name
job.library.imageSubfolder = uploaded.subfolder
@@ -311,7 +339,11 @@ async function queueMiniMax(
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: comfyFilenamePrefix(),
sound: params.sound
sound: params.sound,
workflow: params.workflow,
duration: params.duration,
useIdentityRefs: chainIndex === 0 && params.useIdentityRefs,
referenceImageNames: chainIndex === 0 ? referenceNames : []
})
const queued = await queuePrompt(graph, job.clientId)
@@ -432,7 +464,11 @@ async function runGeneration(
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
persist: isLast
persist: isLast,
workflow: params.workflow,
duration: ext.duration,
useIdentityRefs: false,
referenceImages: []
})
if (!isLast) {
+23 -3
View File
@@ -1,11 +1,23 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageComfyConfigured } from '~/server/utils/imageComfy'
import { checkImageComfyHttp, fetchImageQueue } from '~/server/utils/imageComfyLifecycle'
export default defineEventHandler(async () => {
const [comfy, queue] = await Promise.all([
checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null })),
fetchQueue()
const videoConfigured = comfyConfigured()
const imageConfigured = imageComfyConfigured()
const [comfy, queue, imageComfy, imageQueue] = await Promise.all([
videoConfigured
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
videoConfigured ? fetchQueue() : Promise.resolve({ running: 0, pending: 0 }),
imageConfigured
? checkImageComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
imageConfigured ? fetchImageQueue() : Promise.resolve({ running: 0, pending: 0 })
])
const busy = Boolean(comfy.ok && queue.running > 0)
const imageBusy = Boolean(imageComfy.ok && imageQueue.running > 0)
return {
ok: true,
service: useRuntimeConfig().public.instanceName || 'aigen',
@@ -16,6 +28,14 @@ export default defineEventHandler(async () => {
queueRunning: queue.running,
queuePending: queue.pending,
stats: comfy.ok ? comfy.stats : undefined
},
imageComfy: {
configured: imageConfigured,
ok: imageComfy.ok,
busy: imageBusy,
host: imageComfy.host || '',
queueRunning: imageQueue.running,
queuePending: imageQueue.pending
}
}
})
+3 -1
View File
@@ -8,7 +8,9 @@ export default defineEventHandler(async (event) => {
removeExtendTemp(job.library?.extendTmpDir)
}
if (jobId) deletePendingJob(jobId)
const ok = await interruptComfy()
const ok = job?.kind === 'edit'
? await interruptImageComfy()
: await interruptComfy()
if (job) {
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
}
@@ -0,0 +1,456 @@
{
"92": {
"inputs": {
"filename_prefix": "video/MiniMax_H3",
"format": "auto",
"format.codec": "auto",
"codec": "auto",
"video": [
"172",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "Save Video"
}
},
"114": {
"inputs": {
"image": "Mokker (1).jpeg"
},
"class_type": "LoadImage",
"_meta": {
"title": "Main Image (first_frame / ref)"
}
},
"127": {
"inputs": {
"image": [
"128",
0
]
},
"class_type": "GetImageSize",
"_meta": {
"title": "Get Image Size"
}
},
"128": {
"inputs": {
"upscale_method": "lanczos",
"megapixels": 0.98,
"resolution_steps": 32,
"image": [
"114",
0
]
},
"class_type": "ImageScaleToTotalPixels",
"_meta": {
"title": "Scale Image to Total Pixels"
}
},
"140": {
"inputs": {
"image": "crop IMG_8768.jpg"
},
"class_type": "LoadImage",
"_meta": {
"title": "Identity Ref 1 (optional)"
}
},
"141": {
"inputs": {
"image": ""
},
"class_type": "LoadImage",
"_meta": {
"title": "Identity Ref 2 (optional)"
}
},
"142": {
"inputs": {
"image": ""
},
"class_type": "LoadImage",
"_meta": {
"title": "Identity Ref 3 (optional)"
}
},
"143": {
"inputs": {
"image": ""
},
"class_type": "LoadImage",
"_meta": {
"title": "Identity Ref 4 (optional)"
}
},
"150": {
"inputs": {
"value": true
},
"class_type": "PrimitiveBoolean",
"_meta": {
"title": "Use Identity References (Ref2VA)"
}
},
"155": {
"inputs": {
"prompt": "<Picture 1> is the exact facial identity, hairstyle, body proportions and clothing of the main character. Preserve this identity with high fidelity even when the subject temporarily leaves the frame and returns.\n\n[describe the action, camera and audio here]",
"width": [
"127",
0
],
"height": [
"127",
1
],
"length": 241,
"ref_image_size": "match",
"clip": [
"170",
0
],
"vae": [
"161",
0
],
"audio_vae": [
"162",
0
],
"ref_images.ref_image_0": [
"128",
0
],
"ref_images.ref_image_1": [
"140",
0
],
"ref_images.ref_image_2": [
"141",
0
],
"ref_images.ref_image_3": [
"142",
0
],
"ref_images.ref_image_4": [
"143",
0
]
},
"class_type": "MiniMaxH3ReferenceToVideo",
"_meta": {
"title": "Reference to Video (MiniMax H3) – Identity Locked"
}
},
"161": {
"inputs": {
"vae_name": "minimax_h3_video_vae_fp16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"162": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"163": {
"inputs": {
"samples": [
"167",
0
],
"vae": [
"162",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAE Decode Audio"
}
},
"164": {
"inputs": {
"samples": [
"167",
0
],
"vae": [
"161",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"165": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"166": {
"inputs": {
"scheduler": "simple",
"steps": [
"178",
0
],
"denoise": 1,
"model": [
"177",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"167": {
"inputs": {
"noise": [
"171",
0
],
"guider": [
"168",
0
],
"sampler": [
"165",
0
],
"sigmas": [
"166",
0
],
"latent_image": [
"173",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"168": {
"inputs": {
"model": [
"177",
0
],
"conditioning": [
"173",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "Basic Guider"
}
},
"169": {
"inputs": {
"unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"170": {
"inputs": {
"clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP"
}
},
"171": {
"inputs": {
"noise_seed": 1
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"172": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"images": [
"164",
0
],
"audio": [
"163",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "Create Video"
}
},
"173": {
"inputs": {
"prompt": "Vaporwave title sequence look: pink and blue gradient palette, VHS tracking artifacts, Greek statue motifs, chrome palm trees, RGB chromatic aberration, lo-fi retro atmosphere, mood languid and nostalgic.\n\nTimeline:\n[0s-1s] VHS static opens the frame, the title \"COMFYUI\" appears with RGB split and a slight horizontal jitter.\n[1s-2.5s] Hard cut, a Greek plaster bust close-up, pink-purple gradient sky, a pixelated sun.\n[2.5s-4s] Clean \"STARRING\" credits appear, \"LATENT\" and \"CONTROLNET\" each shown exactly once.\n[4s-5s] Final card \"DIRECTED BY COMFYUI\" holds, one VHS tracking glitch settling into stability.\n\nHard cuts only, transitions landing with tape jumps, no push-ins, no dissolves.\n\nAudio: lo-fi vaporwave score, slow drum machine with soft bass, VHS tape-noise sample joins at 2.5s, melody fading for the last 1s.\n\nAll text must be clearly legible, do not misspell English, no Chinese characters, do not repeat names or job titles, no soft dissolves, no subtitle bars.",
"width": [
"127",
0
],
"height": [
"127",
1
],
"length": [
"174",
1
],
"clip": [
"170",
0
],
"vae": [
"161",
0
],
"first_frame": [
"128",
0
]
},
"class_type": "MiniMaxH3ImageToVideo",
"_meta": {
"title": "MiniMax H3 Image to Video"
}
},
"174": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"175",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "Math Expression"
}
},
"175": {
"inputs": {
"value": 2
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "Float (duration)"
}
},
"176": {
"inputs": {
"lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
"strength_model": 1,
"model": [
"169",
0
]
},
"class_type": "LoraLoaderModelOnly",
"_meta": {
"title": "Load LoRA"
}
},
"177": {
"inputs": {
"switch": [
"181",
0
],
"on_false": [
"169",
0
],
"on_true": [
"176",
0
]
},
"class_type": "ComfySwitchNode",
"_meta": {
"title": "If/Else Switch (Model)"
}
},
"178": {
"inputs": {
"switch": [
"181",
0
],
"on_false": [
"179",
0
],
"on_true": [
"180",
0
]
},
"class_type": "ComfySwitchNode",
"_meta": {
"title": "If/Else Switch (Steps)"
}
},
"179": {
"inputs": {
"value": 20
},
"class_type": "PrimitiveInt",
"_meta": {
"title": "Int"
}
},
"180": {
"inputs": {
"value": 6
},
"class_type": "PrimitiveInt",
"_meta": {
"title": "Int"
}
},
"181": {
"inputs": {
"value": false
},
"class_type": "PrimitiveBoolean",
"_meta": {
"title": "Boolean (Enable Lightning LoRA)"
}
}
}
+98
View File
@@ -0,0 +1,98 @@
{
"1": {
"inputs": {
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": { "title": "Load Qwen Edit UNET" }
},
"11": {
"inputs": {
"model": ["1", 0],
"lora_name": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
"strength_model": 1
},
"class_type": "LoraLoaderModelOnly",
"_meta": { "title": "Lightning LoRA" }
},
"2": {
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": { "title": "Load Qwen CLIP" }
},
"3": {
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
},
"class_type": "VAELoader",
"_meta": { "title": "Load Qwen VAE" }
},
"4": {
"inputs": {
"image": "example.png"
},
"class_type": "LoadImage",
"_meta": { "title": "Load input still" }
},
"5": {
"inputs": {
"text": "",
"clip": ["2", 0]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "Positive prompt" }
},
"6": {
"inputs": {
"text": "blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers",
"clip": ["2", 0]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "Negative prompt" }
},
"7": {
"inputs": {
"pixels": ["4", 0],
"vae": ["3", 0]
},
"class_type": "VAEEncode",
"_meta": { "title": "Encode still" }
},
"8": {
"inputs": {
"seed": 42,
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 0.75,
"model": ["11", 0],
"positive": ["5", 0],
"negative": ["6", 0],
"latent_image": ["7", 0]
},
"class_type": "KSampler",
"_meta": { "title": "Sample edit" }
},
"9": {
"inputs": {
"samples": ["8", 0],
"vae": ["3", 0]
},
"class_type": "VAEDecode",
"_meta": { "title": "Decode still" }
},
"10": {
"inputs": {
"filename_prefix": "Qwen_Edit",
"images": ["9", 0]
},
"class_type": "SaveImage",
"_meta": { "title": "Save edited still" }
}
}
+6
View File
@@ -4,6 +4,12 @@ export function setComfyHostOverride(url: string) {
comfyHostOverride = String(url || '').replace(/\/$/, '')
}
export function comfyConfigured() {
if (comfyHostOverride) return true
const config = useRuntimeConfig()
return Boolean(String(config.comfyHost || process.env.COMFY_HOST || '').trim())
}
function comfyBase() {
if (comfyHostOverride) return comfyHostOverride
const config = useRuntimeConfig()
+13 -7
View File
@@ -1,6 +1,6 @@
import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
import { getComfyHost, setComfyHostOverride } from '~/server/utils/comfy'
import { getComfyHost, setComfyHostOverride, comfyConfigured } from '~/server/utils/comfy'
const execFileAsync = promisify(execFile)
@@ -48,20 +48,26 @@ function settings() {
}
}
export async function checkComfyHttp(timeoutMs = 2500, host = getComfyHost()) {
export async function checkComfyHttp(timeoutMs = 2500, host?: string) {
let resolved = host || ''
try {
const res = await fetch(`${host}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
if (!res.ok) return { ok: false as const, host, status: res.status, stats: null }
if (!resolved) {
if (!comfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null }
resolved = getComfyHost()
}
const res = await fetch(`${resolved}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
if (!res.ok) return { ok: false as const, host: resolved, status: res.status, stats: null }
const stats = await res.json().catch(() => null)
return { ok: true as const, host, status: res.status, stats }
return { ok: true as const, host: resolved, status: res.status, stats }
} catch {
return { ok: false as const, host, status: 0, stats: null }
return { ok: false as const, host: resolved, status: 0, stats: null }
}
}
export async function fetchQueue() {
const host = getComfyHost()
if (!comfyConfigured()) return { running: 0, pending: 0 }
try {
const host = getComfyHost()
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
if (!res.ok) return { running: 0, pending: 0 }
const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] }
+255
View File
@@ -0,0 +1,255 @@
let imageComfyHostOverride = ''
export function setImageComfyHostOverride(url: string) {
imageComfyHostOverride = String(url || '').replace(/\/$/, '')
}
export function getImageComfyHost() {
if (imageComfyHostOverride) return imageComfyHostOverride
const config = useRuntimeConfig()
let host = String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim().replace(/\/$/, '')
const port = String(config.imageComfyPort || process.env.IMAGE_COMFY_PORT || '').trim()
if (!host) {
throw createError({ statusCode: 500, statusMessage: 'IMAGE_COMFY_HOST is not configured' })
}
if (!/^https?:\/\//i.test(host)) host = `http://${host}`
try {
const url = new URL(host)
if (port && !url.port) url.port = port
return url.origin
} catch {
return port ? `${host}:${port}` : host
}
}
export function imageComfyConfigured() {
const config = useRuntimeConfig()
return Boolean(String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim())
}
export async function imageComfyFetch(path: string, init?: RequestInit) {
const host = getImageComfyHost()
try {
return await fetch(`${host}${path}`, init)
} catch (error) {
throw createError({
statusCode: 502,
statusMessage: `Image ComfyUI host unreachable (${host})`,
data: { cause: error instanceof Error ? error.message : String(error) }
})
}
}
export function imageComfyWsUrl(clientId: string) {
return `${getImageComfyHost().replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}`
}
function imageInputFilename(original: string, jobId?: string) {
const raw = String(original || 'still.png')
const dot = raw.lastIndexOf('.')
const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png'
const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still'
const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10)
return `aigen_edit_${id}_${base}${ext}`
}
export async function uploadImageEdit(file: { filename: string; data: Buffer; type?: string }, jobId?: string) {
const body = new FormData()
const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' })
const filename = imageInputFilename(file.filename, jobId)
body.append('image', blob, filename)
body.append('overwrite', 'true')
body.append('type', 'input')
const res = await imageComfyFetch('/upload/image', { method: 'POST', body })
if (!res.ok) {
throw createError({ statusCode: 502, statusMessage: `Image upload failed (${res.status})` })
}
const uploaded = (await res.json()) as { name: string; subfolder?: string; type?: string }
return {
name: uploaded.name || filename,
subfolder: uploaded.subfolder || '',
type: uploaded.type || 'input'
}
}
export async function queueImagePrompt(graph: unknown, clientId: string) {
const res = await imageComfyFetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: graph, client_id: clientId })
})
const payload = await res.json().catch(() => ({}))
if (!res.ok) {
const message = (payload as { error?: { message?: string } }).error?.message
|| (payload as { node_errors?: unknown }).node_errors
|| `Queue failed (${res.status})`
throw createError({ statusCode: 502, statusMessage: String(message), data: payload })
}
return payload as { prompt_id: string; number?: number }
}
export async function interruptImageComfy() {
if (!imageComfyConfigured()) return false
const res = await imageComfyFetch('/interrupt', { method: 'POST' })
return res.ok
}
export async function fetchImageHistory(promptId: string) {
const res = await imageComfyFetch(`/history/${encodeURIComponent(promptId)}`)
if (!res.ok) return null
return (await res.json()) as Record<string, unknown>
}
function isStillFile(item: { filename?: string } | null | undefined) {
if (!item?.filename) return false
return /\.(png|jpe?g|webp)$/i.test(item.filename)
}
function findStill(value: unknown, depth = 0): { filename: string; subfolder: string; type: string } | null {
if (!value || typeof value !== 'object' || depth > 8) return null
if (Array.isArray(value)) {
const files = value.filter((item): item is { filename?: string; subfolder?: string; type?: string } => Boolean(item && typeof item === 'object'))
const match = files.find(isStillFile)
if (match?.filename) {
return {
filename: String(match.filename),
subfolder: String(match.subfolder || ''),
type: String(match.type || 'output')
}
}
for (const item of value) {
const nested = findStill(item, depth + 1)
if (nested) return nested
}
return null
}
const record = value as { filename?: string; subfolder?: string; type?: string; images?: unknown }
if (record.filename && isStillFile(record)) {
return {
filename: String(record.filename),
subfolder: String(record.subfolder || ''),
type: String(record.type || 'output')
}
}
for (const nested of Object.values(value as Record<string, unknown>)) {
const found = findStill(nested, depth + 1)
if (found) return found
}
return null
}
export function extractEditedImage(history: Record<string, unknown> | null, promptId: string) {
if (!history) return null
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | undefined
if (wrapped) return findStill(wrapped.outputs || {}) || findStill(wrapped)
if ((history as { outputs?: unknown }).outputs) {
return findStill((history as { outputs?: unknown }).outputs) || findStill(history)
}
return findStill(history)
}
export async function downloadEditedImage(image: { filename: string; subfolder: string; type: string }) {
const params = new URLSearchParams({
filename: image.filename,
subfolder: image.subfolder || '',
type: image.type || 'output'
})
const res = await imageComfyFetch(`/view?${params.toString()}`)
if (!res.ok) {
throw createError({ statusCode: 502, statusMessage: `Failed to fetch edited still (${res.status})` })
}
return Buffer.from(await res.arrayBuffer())
}
function historyError(history: Record<string, unknown> | null, promptId: string) {
const entry = history?.[promptId] as { status?: { status_str?: string; completed?: boolean } } | undefined
const status = entry?.status?.status_str
if (status === 'interrupted') return 'Job interrupted.'
if (status === 'error') return 'Image ComfyUI reported an execution error'
if (entry?.status?.completed && !extractEditedImage(history, promptId)) {
return 'Image ComfyUI finished without an output still'
}
return null
}
export async function waitForImageEdit(opts: {
promptId: string
clientId: string
timeoutMs?: number
onProgress?: (event: { message: string; progress: number; step?: number; maxStep?: number; node?: string | null }) => void
isCancelled?: () => boolean
}) {
const timeoutMs = opts.timeoutMs || 180_000
const started = Date.now()
let settled = false
let lastError: string | null = null
let result: { filename: string; subfolder: string; type: string } | null = null
const finish = (image: { filename: string; subfolder: string; type: string } | null, error?: string) => {
if (settled) return
settled = true
lastError = error || null
result = image
}
let ws: WebSocket | null = null
try {
ws = new WebSocket(imageComfyWsUrl(opts.clientId))
ws.addEventListener('message', (event) => {
const payload = JSON.parse(String(event.data || '{}')) as {
type?: string
data?: { value?: number; max?: number; node?: string | null; prompt_id?: string }
}
if (payload.type === 'progress' && payload.data) {
const max = Math.max(1, Number(payload.data.max || 4))
const value = Number(payload.data.value || 0)
opts.onProgress?.({
message: 'Sampling edit...',
progress: Math.min(90, 20 + Math.round((value / max) * 60)),
step: value,
maxStep: max,
node: payload.data.node || '8'
})
}
if (payload.type === 'executing' && payload.data?.node) {
opts.onProgress?.({
message: payload.data.node === '8' ? 'Sampling edit...' : 'Running Qwen Edit...',
progress: payload.data.node === '10' ? 92 : 30,
node: payload.data.node
})
}
if (payload.type === 'execution_error') {
finish(null, 'Image ComfyUI reported an execution error')
}
})
} catch {
ws = null
}
while (!settled && Date.now() - started < timeoutMs) {
if (opts.isCancelled?.()) {
finish(null, 'Job interrupted.')
break
}
const history = await fetchImageHistory(opts.promptId)
const image = extractEditedImage(history, opts.promptId)
if (image) {
finish(image)
break
}
const error = historyError(history, opts.promptId)
if (error) {
finish(null, error)
break
}
await new Promise(resolve => setTimeout(resolve, 700))
}
try { ws?.close() } catch { /* ignore */ }
if (result) return result
throw createError({
statusCode: 502,
statusMessage: lastError || 'Image edit timed out waiting for Qwen Edit to finish'
})
}
+218
View File
@@ -0,0 +1,218 @@
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured } from '~/server/utils/imageComfy'
export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
export interface ImageComfyStatus {
state: ImageComfyState
message: string
host: string
processRunning?: boolean
queueRunning?: number
queuePending?: number
}
type StatusFn = (status: ImageComfyStatus) => void
let gate: Promise<unknown> = Promise.resolve()
let lastStartAt = 0
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function log(event: string, detail: Record<string, unknown> = {}) {
console.log(JSON.stringify({
src: 'image-comfy-lifecycle',
event,
at: new Date().toISOString(),
...detail
}))
}
function settings() {
const config = useRuntimeConfig()
return {
host: getImageComfyHost(),
launchCmd: String(config.imageComfyLaunchCmd || process.env.IMAGE_COMFY_LAUNCH_CMD || '').trim(),
controlUrl: String(config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '').replace(/\/$/, ''),
controlToken: String(config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || ''),
healthTimeoutMs: 2500,
bootPollAttempts: Number(process.env.IMAGE_COMFY_BOOT_POLL_ATTEMPTS || 20),
bootPollDelayMs: 3000,
startTimeoutMs: Number(process.env.IMAGE_COMFY_START_TIMEOUT_MS || 180_000),
busyWaitMs: Number(process.env.IMAGE_COMFY_BUSY_WAIT_MS || 180_000)
}
}
export async function checkImageComfyHttp(timeoutMs = 2500, host?: string) {
let resolved = host || ''
try {
if (!resolved) {
if (!imageComfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null }
resolved = getImageComfyHost()
}
const res = await fetch(`${resolved}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
if (!res.ok) return { ok: false as const, host: resolved, status: res.status, stats: null }
const stats = await res.json().catch(() => null)
return { ok: true as const, host: resolved, status: res.status, stats }
} catch {
return { ok: false as const, host: resolved, status: 0, stats: null }
}
}
export async function fetchImageQueue() {
try {
const host = getImageComfyHost()
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
if (!res.ok) return { running: 0, pending: 0 }
const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] }
return {
running: Array.isArray(payload.queue_running) ? payload.queue_running.length : 0,
pending: Array.isArray(payload.queue_pending) ? payload.queue_pending.length : 0
}
} catch {
return { running: 0, pending: 0 }
}
}
async function controlRequest(path: string, method = 'GET') {
const { controlUrl, controlToken } = settings()
if (!controlUrl) return null
try {
const res = await fetch(`${controlUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(controlToken ? { Authorization: `Bearer ${controlToken}` } : {})
},
signal: AbortSignal.timeout(5000)
})
if (!res.ok) return { ok: false, status: res.status }
return await res.json() as {
ok?: boolean
http?: boolean
process?: boolean
started?: boolean
already?: boolean
port?: number | null
proxyPort?: number | null
}
} catch {
return null
}
}
async function waitWhileBusy(onStatus: StatusFn) {
const { busyWaitMs, host } = settings()
const started = Date.now()
while (Date.now() - started < busyWaitMs) {
const queue = await fetchImageQueue()
if (queue.running === 0) return
onStatus({
state: 'busy',
message: `Image ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting up to 3 minutes.`,
host,
queueRunning: queue.running,
queuePending: queue.pending
})
await sleep(2000)
}
throw createError({
statusCode: 409,
statusMessage: 'Image ComfyUI is still busy after 3 minutes.',
data: { code: 'IMAGE_COMFY_BUSY' }
})
}
function hostWithPort(host: string, port: number) {
try {
const url = new URL(host)
url.port = String(port)
return url.origin
} catch {
return host
}
}
async function startImageComfy() {
const now = Date.now()
if (now - lastStartAt < 15_000) {
log('start-skipped', { reason: 'recent-start' })
return { started: false, reason: 'recent-start' as const }
}
lastStartAt = now
const remote = await controlRequest('/start', 'POST')
if (remote?.ok || remote?.started) {
log('start-remote', { port: remote.port || null })
return { started: true, reason: 'remote' as const }
}
return { started: false, reason: 'no-launcher' as const }
}
async function ensureUnlocked(onStatus: StatusFn) {
const cfg = settings()
const host = cfg.host
onStatus({ state: 'offline', message: 'Checking image ComfyUI...', host })
const health = await checkImageComfyHttp(cfg.healthTimeoutMs)
if (health.ok) {
await waitWhileBusy(onStatus)
onStatus({ state: 'online', message: 'Image ComfyUI online', host })
return
}
const remote = await controlRequest('/status')
if (remote?.http && remote.proxyPort) {
const proxyUrl = hostWithPort(host, remote.proxyPort)
const viaProxy = await checkImageComfyHttp(cfg.healthTimeoutMs, proxyUrl)
if (viaProxy.ok) {
setImageComfyHostOverride(proxyUrl)
await waitWhileBusy(onStatus)
onStatus({ state: 'online', message: 'Image ComfyUI online', host: proxyUrl, processRunning: true })
return
}
}
onStatus({
state: 'starting',
message: 'Image ComfyUI is offline. Starting the sidecar instance...',
host,
processRunning: Boolean(remote?.process)
})
const started = await startImageComfy()
if (!started.started && started.reason === 'no-launcher') {
throw createError({
statusCode: 503,
statusMessage: 'Image ComfyUI is offline and the sidecar host agent did not start it. Confirm the agent on 192.168.77.101:8199 is running.'
})
}
const deadline = Date.now() + cfg.startTimeoutMs
let attempt = 0
while (Date.now() < deadline) {
attempt += 1
onStatus({
state: 'starting',
message: `Starting image ComfyUI... waiting for /system_stats (${attempt})`,
host
})
const next = await checkImageComfyHttp()
if (next.ok) {
await waitWhileBusy(onStatus)
onStatus({ state: 'online', message: 'Image ComfyUI online', host, processRunning: true })
return
}
await sleep(2000)
}
throw createError({
statusCode: 503,
statusMessage: 'Started image ComfyUI but the API never answered. Headless Qwen Edit can take a couple of minutes to load.'
})
}
export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) {
const run = gate.then(() => ensureUnlocked(onStatus))
gate = run.then(() => undefined, () => undefined)
return run
}
+52
View File
@@ -0,0 +1,52 @@
import template from '../assets/workflow_qwen_edit.json'
export const DEFAULT_EDIT_NEGATIVE = 'blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers'
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
type WorkflowGraph = Record<string, WorkflowNode>
export interface EditParams {
prompt: string
negative?: string
imageName: string
steps: number
seed: number
cfg: number
denoise: number
samplerName?: string
scheduler?: string
filenamePrefix?: string
}
export function buildEditWorkflow(params: EditParams) {
const graph = structuredClone(template) as WorkflowGraph
if (graph['4']) graph['4'].inputs.image = params.imageName
if (graph['5']) graph['5'].inputs.text = params.prompt
if (graph['6']) graph['6'].inputs.text = (params.negative || DEFAULT_EDIT_NEGATIVE).trim() || DEFAULT_EDIT_NEGATIVE
if (graph['8']) {
graph['8'].inputs.seed = params.seed
graph['8'].inputs.steps = params.steps
graph['8'].inputs.cfg = params.cfg
graph['8'].inputs.denoise = params.denoise
graph['8'].inputs.sampler_name = params.samplerName || 'euler'
graph['8'].inputs.scheduler = params.scheduler || 'simple'
}
if (graph['10'] && params.filenamePrefix) {
graph['10'].inputs.filename_prefix = params.filenamePrefix
}
return graph
}
export const EDIT_NODE_LABELS: Record<string, string> = {
'1': 'Loading Qwen Edit UNET',
'11': 'Applying Lightning LoRA',
'2': 'Loading Qwen CLIP',
'3': 'Loading Qwen VAE',
'4': 'Loading input still',
'5': 'Encoding prompt',
'6': 'Encoding negative',
'7': 'Encoding still',
'8': 'Sampling edit',
'9': 'Decoding still',
'10': 'Saving still'
}
+5
View File
@@ -11,6 +11,7 @@ export interface JobEvent {
subfolder?: string
mediaType?: string
clipId?: string
stillId?: string
hideThumbnail?: boolean
error?: string
elapsedMs?: number
@@ -28,6 +29,7 @@ export interface JobEvent {
export interface Job {
id: string
kind?: 'video' | 'edit'
promptId?: string
clientId: string
status: JobStatus
@@ -38,6 +40,7 @@ export interface Job {
startedAt: number
video?: { filename: string; subfolder: string; type: string }
clipId?: string
stillId?: string
hideThumbnail?: boolean
library?: {
ownerKey: string
@@ -75,6 +78,7 @@ export interface Job {
extendSourceClipId?: string
familyId?: string
parentClipId?: string
workflow?: 'v1' | 'v2'
}
error?: string
socketReady?: boolean
@@ -132,6 +136,7 @@ export function jobSnapshot(job: Job) {
subfolder: job.library?.folderLocked ? undefined : job.video?.subfolder,
mediaType: job.library?.folderLocked ? undefined : job.video?.type,
clipId: job.clipId,
stillId: job.stillId,
hideThumbnail: job.hideThumbnail,
error: job.error,
folderLocked: job.library?.folderLocked,
+11 -4
View File
@@ -38,6 +38,7 @@ export interface LibraryClip {
familyId?: string
parentClipId?: string
chainIndex?: number
workflow?: 'v1' | 'v2'
}
export interface LibraryStill {
@@ -89,6 +90,7 @@ export interface RetryDraft {
samplerName?: string
scheduler?: string
extensions?: QueuedExtension[]
workflow?: 'v1' | 'v2'
}
interface Catalog {
@@ -715,6 +717,7 @@ export async function saveRetryDraft(params: {
samplerName?: string
scheduler?: string
extensions?: QueuedExtension[]
workflow?: 'v1' | 'v2'
}) {
return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
@@ -740,7 +743,8 @@ export async function saveRetryDraft(params: {
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
extensions: params.extensions?.length ? params.extensions : undefined
extensions: params.extensions?.length ? params.extensions : undefined,
workflow: params.workflow
}
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
return draft
@@ -809,6 +813,7 @@ export async function saveClip(params: {
familyId?: string
parentClipId?: string
chainIndex?: number
workflow?: 'v1' | 'v2'
}) {
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
@@ -833,7 +838,8 @@ export async function saveClip(params: {
scheduler: params.scheduler,
familyId: params.familyId,
parentClipId: params.parentClipId,
chainIndex: params.chainIndex
chainIndex: params.chainIndex,
workflow: params.workflow
}
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
const videoPath = clipVideoPath(params.ownerKey, clip.id)
@@ -852,12 +858,13 @@ export async function saveClip(params: {
} else {
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
}
if (params.thumb?.length) {
const saveInputStill = Boolean(params.thumb?.length) && !params.parentClipId && !(params.chainIndex || 0)
if (saveInputStill) {
await saveStill({
ownerKey: params.ownerKey,
folderId: folder.id,
filename: `${clip.id}.png`,
data: params.thumb,
data: params.thumb!,
width: params.width,
height: params.height,
hideInput: params.hideInput
+2 -1
View File
@@ -191,7 +191,8 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
scheduler: job.library.scheduler,
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
parentClipId: job.library.parentClipId,
chainIndex: job.library.chainIndex
chainIndex: job.library.chainIndex,
workflow: job.library.workflow
})
job.clipId = clip.id
job.savedPromptId = job.promptId
+130 -26
View File
@@ -1,5 +1,8 @@
// Nitro bundles this JSON into the production server output.
import workflowTemplate from '../assets/workflow_minimax_video.json'
// Nitro bundles these JSON graphs into the production server output.
import workflowV1 from '../assets/workflow_minimax_video.json'
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
export type VideoWorkflowId = 'v1' | 'v2'
export interface GenerateParams {
prompt: string
@@ -16,6 +19,10 @@ export interface GenerateParams {
scheduler: string
filenamePrefix?: string
sound?: boolean
workflow?: VideoWorkflowId
duration?: number
useIdentityRefs?: boolean
referenceImageNames?: string[]
}
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
@@ -23,6 +30,7 @@ type WorkflowGraph = Record<string, WorkflowNode>
const PROMPT_CLASSES = new Set([
'MiniMaxH3ImageToVideo',
'MiniMaxH3ReferenceToVideo',
'CLIPTextEncode',
'CLIPTextEncodeQwen3VL',
'TextEncodeQwen3VL'
@@ -30,6 +38,7 @@ const PROMPT_CLASSES = new Set([
const SEED_CLASSES = new Set(['RandomNoise', 'KSampler', 'SamplerMiniMax', 'KSamplerAdvanced'])
const IMAGE_CLASSES = new Set(['LoadImage'])
const LORA_CLASSES = new Set(['LoraLoaderModelOnly', 'LoraLoader'])
const V2_REF_LOADERS = ['140', '141', '142', '143'] as const
function entriesOf(graph: WorkflowGraph, classType: string) {
return Object.entries(graph).filter(([, node]) => node.class_type === classType)
@@ -47,13 +56,24 @@ function titleOf(node: WorkflowNode) {
return node._meta?.title || ''
}
function templateFor(id: VideoWorkflowId) {
return (id === 'v2' ? workflowV2 : workflowV1) as WorkflowGraph
}
export function parseVideoWorkflow(raw: unknown): VideoWorkflowId {
return String(raw || '').trim() === 'v2' ? 'v2' : 'v1'
}
export function buildWorkflow(params: GenerateParams) {
const graph = structuredClone(workflowTemplate) as WorkflowGraph
const version = params.workflow === 'v2' ? 'v2' : 'v1'
const graph = structuredClone(templateFor(version))
const width = snap32(params.width)
const height = snap32(params.height)
const refs = (params.referenceImageNames || []).map(name => String(name || '').trim()).filter(Boolean).slice(0, 4)
const useRefs = version === 'v2' && params.useIdentityRefs === true && refs.length > 0
for (const [, node] of Object.entries(graph)) {
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs) {
for (const [id, node] of Object.entries(graph)) {
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs && id === '114') {
node.inputs.image = params.imageName
}
@@ -64,9 +84,14 @@ export function buildWorkflow(params: GenerateParams) {
if (node.class_type === 'MiniMaxH3ImageToVideo') {
node.inputs.prompt = params.prompt
node.inputs.width = width
node.inputs.height = height
node.inputs.length = params.length
if (typeof node.inputs.width === 'number') node.inputs.width = width
if (typeof node.inputs.height === 'number') node.inputs.height = height
if (typeof node.inputs.length === 'number') node.inputs.length = params.length
}
if (node.class_type === 'MiniMaxH3ReferenceToVideo') {
node.inputs.prompt = params.prompt
if (!Array.isArray(node.inputs.length)) node.inputs.length = ['174', 1]
}
if (node.class_type === 'CreateVideo' && 'fps' in node.inputs) {
@@ -93,7 +118,7 @@ export function buildWorkflow(params: GenerateParams) {
if ('seed' in node.inputs) node.inputs.seed = params.seed
}
if (LORA_CLASSES.has(node.class_type)) {
if (LORA_CLASSES.has(node.class_type) && !/identity|ref 1/i.test(titleOf(node))) {
node.inputs.strength_model = params.turbo ? 1 : 0
}
@@ -101,11 +126,22 @@ export function buildWorkflow(params: GenerateParams) {
node.inputs.filename_prefix = params.filenamePrefix || node.inputs.filename_prefix || 'video/MiniMax_H3'
}
if (node.class_type === 'PrimitiveBoolean') {
if (node.class_type === 'PrimitiveBoolean' && /lightning|turbo/i.test(titleOf(node))) {
node.inputs.value = params.turbo
}
if (node.class_type === 'ComfySwitchNode' && typeof node.inputs.switch === 'boolean') {
if (node.class_type === 'PrimitiveBoolean' && /identity|ref2va|reference/i.test(titleOf(node))) {
node.inputs.value = useRefs
}
if (node.class_type === 'PrimitiveFloat' && /duration/i.test(titleOf(node))) {
const seconds = typeof params.duration === 'number' && params.duration > 0
? params.duration
: params.length / Math.max(params.fps, 1)
node.inputs.value = seconds
}
if (node.class_type === 'ComfySwitchNode' && typeof node.inputs.switch === 'boolean' && /lightning|turbo|lora/i.test(titleOf(node))) {
node.inputs.switch = params.turbo
}
@@ -114,7 +150,7 @@ export function buildWorkflow(params: GenerateParams) {
}
}
if (graph['128']) {
if (version === 'v1' && graph['128']?.class_type === 'ImageScaleToTotalPixels') {
graph['128'] = {
class_type: 'ImageScale',
inputs: {
@@ -128,16 +164,18 @@ export function buildWorkflow(params: GenerateParams) {
}
}
graph['105:cfg'] = {
class_type: 'FluxGuidance',
inputs: {
guidance: params.cfg,
conditioning: ['105:104', 0]
},
_meta: { title: 'Guidance scale' }
}
if (graph['105:16']?.inputs) {
graph['105:16'].inputs.conditioning = ['105:cfg', 0]
if (version === 'v1') {
graph['105:cfg'] = {
class_type: 'FluxGuidance',
inputs: {
guidance: params.cfg,
conditioning: ['105:104', 0]
},
_meta: { title: 'Guidance scale' }
}
if (graph['105:16']?.inputs) {
graph['105:16'].inputs.conditioning = ['105:cfg', 0]
}
}
for (const [, node] of entriesOf(graph, 'ComfySwitchNode')) {
@@ -152,15 +190,69 @@ export function buildWorkflow(params: GenerateParams) {
}
}
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs)
if (params.sound === false) {
delete graph['105:23']
delete graph['105:24']
if (graph['105:91']?.inputs) delete graph['105:91'].inputs.audio
delete graph['163']
if (graph['172']?.inputs) delete graph['172'].inputs.audio
}
return graph
}
function applyV2IdentityPath(
graph: WorkflowGraph,
params: GenerateParams,
useRefs: boolean,
refs: string[]
) {
if (useRefs && graph['155'] && graph['167'] && graph['168']) {
graph['167'].inputs.latent_image = ['155', 1]
graph['168'].inputs.conditioning = ['155', 0]
graph['155'].inputs.prompt = params.prompt
graph['155'].inputs.length = ['174', 1]
delete graph['173']
V2_REF_LOADERS.forEach((id, index) => {
const name = refs[index]
const inputKey = `ref_images.ref_image_${index + 1}`
if (name && graph[id]) {
graph[id].inputs.image = name
} else {
delete graph[id]
if (graph['155']?.inputs) delete graph['155'].inputs[inputKey]
}
})
graph['v2:cfg'] = {
class_type: 'FluxGuidance',
inputs: {
guidance: params.cfg,
conditioning: ['155', 0]
},
_meta: { title: 'Guidance scale' }
}
graph['168'].inputs.conditioning = ['v2:cfg', 0]
return
}
delete graph['155']
delete graph['150']
for (const id of V2_REF_LOADERS) delete graph[id]
if (graph['173'] && graph['168']) {
graph['v2:cfg'] = {
class_type: 'FluxGuidance',
inputs: {
guidance: params.cfg,
conditioning: ['173', 0]
},
_meta: { title: 'Guidance scale' }
}
graph['168'].inputs.conditioning = ['v2:cfg', 0]
}
}
const LABEL_OVERRIDES: Record<string, string> = {
'114': 'Loading image',
'128': 'Scaling image',
@@ -168,6 +260,14 @@ const LABEL_OVERRIDES: Record<string, string> = {
'119': 'Scaling image',
'120': 'Reading image size',
'92': 'Saving MP4',
'140': 'Loading identity ref 1',
'141': 'Loading identity ref 2',
'142': 'Loading identity ref 3',
'143': 'Loading identity ref 4',
'150': 'Identity references',
'155': 'Reference to video',
'172': 'Encoding video',
'173': 'Image to video',
'105:6': 'Loading MiniMax H3 model',
'105:11': 'Loading video VAE',
'105:13': 'Loading Qwen3-VL encoder',
@@ -177,6 +277,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'105:14': 'Sampling',
'105:16': 'Preparing guider',
'105:cfg': 'Applying guidance',
'v2:cfg': 'Applying guidance',
'105:17': 'Selecting sampler',
'105:23': 'Decoding audio',
'105:24': 'Loading audio VAE',
@@ -192,13 +293,16 @@ const LABEL_OVERRIDES: Record<string, string> = {
'105:126': 'Selecting turbo LoRA'
}
function labelsFrom(template: WorkflowGraph) {
return Object.entries(template).map(([id, node]) => [id, node._meta?.title || `Node ${id}`] as const)
}
export const NODE_LABELS: Record<string, string> = {
...Object.fromEntries(
Object.entries(workflowTemplate as WorkflowGraph).map(([id, node]) => [id, node._meta?.title || `Node ${id}`])
),
...Object.fromEntries(labelsFrom(workflowV1 as WorkflowGraph)),
...Object.fromEntries(labelsFrom(workflowV2 as WorkflowGraph)),
...LABEL_OVERRIDES
}
export function isEncodingNode(node: string) {
return node === '92' || node === '105:91' || /encoding|saving mp4/i.test(NODE_LABELS[node] || '')
return node === '92' || node === '105:91' || node === '172' || /encoding|saving mp4|create video/i.test(NODE_LABELS[node] || '')
}