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:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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] || '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user