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:
@@ -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