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
+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] || '')
}