diff --git a/.env.example b/.env.example
index 7b8b79f..08a794b 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,12 @@
PORT=3000
HOST=0.0.0.0
COMFY_HOST=http://192.168.77.7:8188
+COMFY_PORT=8188
+# Optional: command used when this server runs on the Comfy host
+COMFY_LAUNCH_CMD=
+# Windows host agent (scripts/comfy-host-agent.mjs) so Coolify can start Comfy remotely
+COMFY_CONTROL_URL=http://192.168.77.7:8199
+COMFY_CONTROL_TOKEN=
NUXT_SESSION_SECRET=change-me
NUXT_PUBLIC_AUTH_ENABLED=true
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6a764ed..c8b455b 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -25,6 +25,10 @@ export default defineNuxtConfig({
},
runtimeConfig: {
comfyHost: process.env.COMFY_HOST || '',
+ comfyPort: process.env.COMFY_PORT || '',
+ comfyLaunchCmd: process.env.COMFY_LAUNCH_CMD || '',
+ comfyControlUrl: process.env.COMFY_CONTROL_URL || '',
+ comfyControlToken: process.env.COMFY_CONTROL_TOKEN || '',
sessionSecret: process.env.NUXT_SESSION_SECRET || '',
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
diff --git a/pages/index.vue b/pages/index.vue
index f1c1f3f..13a9c7c 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -169,7 +169,7 @@
- {{ statusMessage }}
+ {{ statusMessage }}
{{ progress }}%
@@ -404,6 +404,7 @@ const showThumbnail = computed({
const busy = ref(false)
const progress = ref(0)
const statusMessage = ref('Waiting for a still.')
+const statusBusy = ref(false)
const step = ref(0)
const maxStep = ref(8)
const elapsedMs = ref(0)
@@ -661,13 +662,14 @@ async function generate() {
toast('Unlock the selected folder before generating.')
return
}
+ statusBusy.value = false
busy.value = true
settledUi = false
progress.value = 1
videoUrl.value = ''
awaitingReveal.value = false
playerSize.value = outputSize.value
- statusMessage.value = 'Uploading image...'
+ statusMessage.value = 'Checking ComfyUI...'
startTimer()
try {
const body = new FormData()
@@ -694,6 +696,7 @@ async function generate() {
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value)
} catch (error: any) {
busy.value = false
+ statusBusy.value = false
stopTimer()
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
statusMessage.value = message
@@ -712,6 +715,7 @@ function stopListen() {
function applyJobEvent(payload: Record, hidden: boolean) {
if (payload.message) statusMessage.value = payload.message
+ statusBusy.value = payload.type === 'busy' || payload.busy === true
if (typeof payload.progress === 'number') progress.value = payload.progress
if (typeof payload.step === 'number') step.value = payload.step
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
@@ -728,6 +732,7 @@ function applyJobEvent(payload: Record, hidden: boolean) {
videoUrl.value = clipUrl
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
busy.value = false
+ statusBusy.value = false
stopTimer()
stopListen()
loadLibrary().catch(() => null)
@@ -737,6 +742,7 @@ function applyJobEvent(payload: Record, hidden: boolean) {
if (failed) {
settledUi = true
busy.value = false
+ statusBusy.value = false
stopTimer()
stopListen()
toast(payload.error || payload.message || 'Generation failed')
diff --git a/scripts/comfy-host-agent.mjs b/scripts/comfy-host-agent.mjs
new file mode 100644
index 0000000..7507d5a
--- /dev/null
+++ b/scripts/comfy-host-agent.mjs
@@ -0,0 +1,79 @@
+import http from 'node:http'
+import { execFile, spawn } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const execFileAsync = promisify(execFile)
+const port = Number(process.env.COMFY_CONTROL_PORT || 8199)
+const token = process.env.COMFY_CONTROL_TOKEN || ''
+const comfyHttp = (process.env.COMFY_HOST || 'http://127.0.0.1:8188').replace(/\/$/, '')
+
+function json(res, status, body) {
+ const payload = JSON.stringify(body)
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) })
+ res.end(payload)
+}
+
+function authorized(req) {
+ if (!token) return true
+ const header = String(req.headers.authorization || '')
+ return header === `Bearer ${token}`
+}
+
+async function httpUp() {
+ try {
+ const res = await fetch(`${comfyHttp}/system_stats`, { signal: AbortSignal.timeout(2500) })
+ return res.ok
+ } catch {
+ return false
+ }
+}
+
+async function processUp() {
+ try {
+ const { stdout } = await execFileAsync('tasklist', ['/FO', 'CSV', '/NH'], { windowsHide: true, timeout: 4000 })
+ if (/Comfy Desktop|ComfyUI/i.test(stdout)) return true
+ } catch { /* ignore */ }
+ try {
+ const { stdout } = await execFileAsync('powershell.exe', [
+ '-NoProfile', '-Command',
+ "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'ComfyUI\\\\main.py|ComfyUI/main.py' } | Select-Object -First 1 -ExpandProperty ProcessId"
+ ], { windowsHide: true, timeout: 6000 })
+ return Boolean(String(stdout).trim())
+ } catch {
+ return false
+ }
+}
+
+function launchCommand() {
+ if (process.env.COMFY_LAUNCH_CMD) return process.env.COMFY_LAUNCH_CMD
+ const local = process.env.LOCALAPPDATA || ''
+ const desktop = `${local}\\Programs\\Comfy Desktop\\Comfy Desktop.exe`
+ return `start "" "${desktop}"`
+}
+
+function startComfy() {
+ const command = launchCommand()
+ const child = spawn(command, { shell: true, detached: true, stdio: 'ignore', windowsHide: true })
+ child.unref()
+ return { started: true, command }
+}
+
+const server = http.createServer(async (req, res) => {
+ if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
+ const url = new URL(req.url || '/', 'http://localhost')
+ if (req.method === 'GET' && url.pathname === '/status') {
+ const [httpReady, process] = await Promise.all([httpUp(), processUp()])
+ return json(res, 200, { ok: true, http: httpReady, process })
+ }
+ if (req.method === 'POST' && url.pathname === '/start') {
+ if (await httpUp()) return json(res, 200, { ok: true, started: false, already: true })
+ if (await processUp()) return json(res, 200, { ok: true, started: false, booting: true })
+ const launched = startComfy()
+ return json(res, 200, { ok: true, ...launched })
+ }
+ json(res, 404, { ok: false, error: 'not found' })
+})
+
+server.listen(port, '0.0.0.0', () => {
+ console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'listen', port, comfyHttp }))
+})
diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts
index aa0b01b..8e2fc31 100644
--- a/server/api/generate.post.ts
+++ b/server/api/generate.post.ts
@@ -71,7 +71,7 @@ export default defineEventHandler(async (event) => {
seed,
thumb: image.data
}
- emitJob(job, { type: 'status', message: 'Uploading image...', progress: 2 })
+ emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
@@ -107,8 +107,20 @@ async function runGeneration(
length: number
}
) {
+ await ensureComfyReady((status) => {
+ emitJob(job, {
+ type: status.state === 'busy' ? 'busy' : 'status',
+ message: status.message,
+ progress: status.state === 'online' ? Math.max(job.progress, 3) : Math.max(job.progress, 1),
+ busy: status.state === 'busy',
+ queueRunning: status.queueRunning,
+ queuePending: status.queuePending
+ })
+ })
+
const done = watchComfyJob(job)
job.status = 'uploading'
+ emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
const uploaded = await uploadImage(params.image)
if (job.library) job.library.imageName = uploaded.name
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
diff --git a/server/api/health.get.ts b/server/api/health.get.ts
index 995aebf..ba52cd6 100644
--- a/server/api/health.get.ts
+++ b/server/api/health.get.ts
@@ -1,8 +1,12 @@
export default defineEventHandler(async () => {
- const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
+ const comfy = await checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
return {
ok: true,
service: useRuntimeConfig().public.instanceName || 'aigen',
- comfy
+ comfy: {
+ ok: comfy.ok,
+ host: comfy.host || '',
+ stats: comfy.ok ? comfy.stats : undefined
+ }
}
})
diff --git a/server/assets/workflow_minimax_video.json b/server/assets/workflow_minimax_video.json
new file mode 100644
index 0000000..684d444
--- /dev/null
+++ b/server/assets/workflow_minimax_video.json
@@ -0,0 +1,381 @@
+{
+ "92": {
+ "inputs": {
+ "filename_prefix": "video/MiniMax_H3",
+ "format": "auto",
+ "codec": "auto",
+ "video": [
+ "105:91",
+ 0
+ ]
+ },
+ "class_type": "SaveVideo",
+ "_meta": {
+ "title": "Save Video"
+ }
+ },
+ "114": {
+ "inputs": {
+ "image": "Firefly.png"
+ },
+ "class_type": "LoadImage",
+ "_meta": {
+ "title": "Load Image"
+ }
+ },
+ "119": {
+ "inputs": {
+ "upscale_method": "nearest-exact",
+ "megapixels": 0.9,
+ "resolution_steps": 32
+ },
+ "class_type": "ImageScaleToTotalPixels",
+ "_meta": {
+ "title": "Scale Image to Total Pixels"
+ }
+ },
+ "120": {
+ "inputs": {
+ "image": [
+ "119",
+ 0
+ ]
+ },
+ "class_type": "GetImageSize",
+ "_meta": {
+ "title": "Get Image Size"
+ }
+ },
+ "127": {
+ "inputs": {
+ "image": [
+ "128",
+ 0
+ ]
+ },
+ "class_type": "GetImageSize",
+ "_meta": {
+ "title": "Get Image Size"
+ }
+ },
+ "128": {
+ "inputs": {
+ "upscale_method": "nearest-exact",
+ "megapixels": 0.98,
+ "resolution_steps": 32,
+ "image": [
+ "114",
+ 0
+ ]
+ },
+ "class_type": "ImageScaleToTotalPixels",
+ "_meta": {
+ "title": "Scale Image to Total Pixels"
+ }
+ },
+ "105:11": {
+ "inputs": {
+ "vae_name": "minimax_h3_video_vae_fp16.safetensors"
+ },
+ "class_type": "VAELoader",
+ "_meta": {
+ "title": "Load VAE"
+ }
+ },
+ "105:24": {
+ "inputs": {
+ "vae_name": "minimax_h3_audio_vae_fp32.safetensors"
+ },
+ "class_type": "VAELoader",
+ "_meta": {
+ "title": "Load VAE"
+ }
+ },
+ "105:23": {
+ "inputs": {
+ "samples": [
+ "105:14",
+ 0
+ ],
+ "vae": [
+ "105:24",
+ 0
+ ]
+ },
+ "class_type": "VAEDecodeAudio",
+ "_meta": {
+ "title": "VAE Decode Audio"
+ }
+ },
+ "105:10": {
+ "inputs": {
+ "samples": [
+ "105:14",
+ 0
+ ],
+ "vae": [
+ "105:11",
+ 0
+ ]
+ },
+ "class_type": "VAEDecode",
+ "_meta": {
+ "title": "VAE Decode"
+ }
+ },
+ "105:17": {
+ "inputs": {
+ "sampler_name": "res_multistep"
+ },
+ "class_type": "KSamplerSelect",
+ "_meta": {
+ "title": "KSamplerSelect"
+ }
+ },
+ "105:9": {
+ "inputs": {
+ "scheduler": "simple",
+ "steps": [
+ "105:123",
+ 0
+ ],
+ "denoise": 1,
+ "model": [
+ "105:122",
+ 0
+ ]
+ },
+ "class_type": "BasicScheduler",
+ "_meta": {
+ "title": "BasicScheduler"
+ }
+ },
+ "105:14": {
+ "inputs": {
+ "noise": [
+ "105:15",
+ 0
+ ],
+ "guider": [
+ "105:16",
+ 0
+ ],
+ "sampler": [
+ "105:17",
+ 0
+ ],
+ "sigmas": [
+ "105:9",
+ 0
+ ],
+ "latent_image": [
+ "105:104",
+ 1
+ ]
+ },
+ "class_type": "SamplerCustomAdvanced",
+ "_meta": {
+ "title": "SamplerCustomAdvanced"
+ }
+ },
+ "105:16": {
+ "inputs": {
+ "model": [
+ "105:122",
+ 0
+ ],
+ "conditioning": [
+ "105:104",
+ 0
+ ]
+ },
+ "class_type": "BasicGuider",
+ "_meta": {
+ "title": "Basic Guider"
+ }
+ },
+ "105:6": {
+ "inputs": {
+ "unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors",
+ "weight_dtype": "default"
+ },
+ "class_type": "UNETLoader",
+ "_meta": {
+ "title": "Load Diffusion Model"
+ }
+ },
+ "105:13": {
+ "inputs": {
+ "clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
+ "type": "minimax",
+ "device": "default"
+ },
+ "class_type": "CLIPLoader",
+ "_meta": {
+ "title": "Load CLIP"
+ }
+ },
+ "105:15": {
+ "inputs": {
+ "noise_seed": 757358688076805
+ },
+ "class_type": "RandomNoise",
+ "_meta": {
+ "title": "RandomNoise"
+ }
+ },
+ "105:91": {
+ "inputs": {
+ "fps": 24,
+ "bit_depth": 8,
+ "images": [
+ "105:10",
+ 0
+ ],
+ "audio": [
+ "105:23",
+ 0
+ ]
+ },
+ "class_type": "CreateVideo",
+ "_meta": {
+ "title": "Create Video"
+ }
+ },
+ "105:104": {
+ "inputs": {
+ "prompt": "",
+ "width": [
+ "127",
+ 0
+ ],
+ "height": [
+ "127",
+ 1
+ ],
+ "length": [
+ "105:107",
+ 1
+ ],
+ "clip": [
+ "105:13",
+ 0
+ ],
+ "vae": [
+ "105:11",
+ 0
+ ],
+ "first_frame": [
+ "128",
+ 0
+ ]
+ },
+ "class_type": "MiniMaxH3ImageToVideo",
+ "_meta": {
+ "title": "MiniMax H3 Image to Video"
+ }
+ },
+ "105:107": {
+ "inputs": {
+ "expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
+ "values.a": [
+ "105:111",
+ 0
+ ]
+ },
+ "class_type": "ComfyMathExpression",
+ "_meta": {
+ "title": "Math Expression"
+ }
+ },
+ "105:111": {
+ "inputs": {
+ "value": 10
+ },
+ "class_type": "PrimitiveFloat",
+ "_meta": {
+ "title": "Float (duration)"
+ }
+ },
+ "105:121": {
+ "inputs": {
+ "lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors",
+ "strength_model": 1,
+ "model": [
+ "105:6",
+ 0
+ ]
+ },
+ "class_type": "LoraLoaderModelOnly",
+ "_meta": {
+ "title": "Load LoRA"
+ }
+ },
+ "105:122": {
+ "inputs": {
+ "switch": [
+ "105:126",
+ 0
+ ],
+ "on_false": [
+ "105:6",
+ 0
+ ],
+ "on_true": [
+ "105:121",
+ 0
+ ]
+ },
+ "class_type": "ComfySwitchNode",
+ "_meta": {
+ "title": "If/Else Switch (Model)"
+ }
+ },
+ "105:123": {
+ "inputs": {
+ "switch": [
+ "105:126",
+ 0
+ ],
+ "on_false": [
+ "105:124",
+ 0
+ ],
+ "on_true": [
+ "105:125",
+ 0
+ ]
+ },
+ "class_type": "ComfySwitchNode",
+ "_meta": {
+ "title": "If/Else Switch (Steps)"
+ }
+ },
+ "105:124": {
+ "inputs": {
+ "value": 20
+ },
+ "class_type": "PrimitiveInt",
+ "_meta": {
+ "title": "Int"
+ }
+ },
+ "105:125": {
+ "inputs": {
+ "value": 8
+ },
+ "class_type": "PrimitiveInt",
+ "_meta": {
+ "title": "Int"
+ }
+ },
+ "105:126": {
+ "inputs": {
+ "value": false
+ },
+ "class_type": "PrimitiveBoolean",
+ "_meta": {
+ "title": "Boolean (Enable Lightning LoRA)"
+ }
+ }
+}
\ No newline at end of file
diff --git a/server/utils/comfy.ts b/server/utils/comfy.ts
index 3387160..bab2e9e 100644
--- a/server/utils/comfy.ts
+++ b/server/utils/comfy.ts
@@ -1,10 +1,18 @@
function comfyBase() {
const config = useRuntimeConfig()
- const host = (config.comfyHost || process.env.COMFY_HOST || '').replace(/\/$/, '')
+ let host = String(config.comfyHost || process.env.COMFY_HOST || '').trim().replace(/\/$/, '')
+ const port = String(config.comfyPort || process.env.COMFY_PORT || '').trim()
if (!host) {
throw createError({ statusCode: 500, statusMessage: 'COMFY_HOST is not configured' })
}
- return host
+ 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 getComfyHost() {
diff --git a/server/utils/comfyLifecycle.ts b/server/utils/comfyLifecycle.ts
new file mode 100644
index 0000000..048c856
--- /dev/null
+++ b/server/utils/comfyLifecycle.ts
@@ -0,0 +1,280 @@
+import { execFile, spawn } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const execFileAsync = promisify(execFile)
+
+export type ComfyLifecycleState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
+
+export interface ComfyLifecycleStatus {
+ state: ComfyLifecycleState
+ message: string
+ host: string
+ processRunning?: boolean
+ queueRunning?: number
+ queuePending?: number
+}
+
+type StatusFn = (status: ComfyLifecycleStatus) => void
+
+let gate: Promise = Promise.resolve()
+let lastStartAt = 0
+
+function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+function log(event: string, detail: Record = {}) {
+ console.log(JSON.stringify({
+ src: 'comfy-lifecycle',
+ event,
+ at: new Date().toISOString(),
+ ...detail
+ }))
+}
+
+function settings() {
+ const config = useRuntimeConfig()
+ return {
+ host: getComfyHost(),
+ launchCmd: String(config.comfyLaunchCmd || process.env.COMFY_LAUNCH_CMD || '').trim(),
+ controlUrl: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
+ controlToken: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || ''),
+ healthTimeoutMs: 2500,
+ bootPollAttempts: 5,
+ bootPollDelayMs: 2000,
+ startTimeoutMs: 30_000,
+ busyWaitMs: Number(process.env.COMFY_BUSY_WAIT_MS || 180_000)
+ }
+}
+
+export async function checkComfyHttp(timeoutMs = 2500) {
+ const host = getComfyHost()
+ 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 }
+ const stats = await res.json().catch(() => null)
+ return { ok: true as const, host, status: res.status, stats }
+ } catch {
+ return { ok: false as const, host, status: 0, stats: null }
+ }
+}
+
+async function fetchQueue() {
+ const host = getComfyHost()
+ try {
+ 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
+ pid?: number
+ started?: boolean
+ }
+ } catch {
+ return null
+ }
+}
+
+async function findLocalComfyProcess() {
+ const platform = process.platform
+ try {
+ if (platform === 'win32') {
+ const { stdout } = await execFileAsync('tasklist', ['/FO', 'CSV', '/NH'], { timeout: 4000, windowsHide: true })
+ const hit = stdout.split(/\r?\n/).some(line => /comfy|python/i.test(line) && /comfy/i.test(line))
+ if (hit) return true
+ const { stdout: verbose } = await execFileAsync('wmic', ['process', 'where', "name='python.exe'", 'get', 'commandline'], { timeout: 4000, windowsHide: true }).catch(() => ({ stdout: '' }))
+ return /comfyui|main\.py/i.test(verbose)
+ }
+ const { stdout } = await execFileAsync('ps', ['-ax', '-o', 'args='], { timeout: 4000 })
+ return /comfyui|ComfyUI|main\.py.*8188/i.test(stdout)
+ } catch {
+ return false
+ }
+}
+
+async function processRunning() {
+ const remote = await controlRequest('/status')
+ if (remote?.process) return true
+ return findLocalComfyProcess()
+}
+
+function spawnLaunchCommand(command: string) {
+ log('spawn', { command: command.slice(0, 180) })
+ const child = spawn(command, {
+ shell: true,
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true
+ })
+ child.unref()
+}
+
+async function startComfy() {
+ 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', { pid: remote.pid || null })
+ return { started: true, reason: 'remote' as const }
+ }
+
+ const { launchCmd } = settings()
+ if (launchCmd) {
+ spawnLaunchCommand(launchCmd)
+ return { started: true, reason: 'launch-cmd' as const }
+ }
+
+ if (process.platform === 'win32') {
+ const fallback = process.env.LOCALAPPDATA
+ ? `"${process.env.LOCALAPPDATA}\\Programs\\Comfy Desktop\\Comfy Desktop.exe"`
+ : ''
+ if (fallback) {
+ spawnLaunchCommand(`start "" ${fallback}`)
+ return { started: true, reason: 'comfy-desktop' as const }
+ }
+ }
+
+ return { started: false, reason: 'no-launcher' as const }
+}
+
+async function pollUntilHealthy(attempts: number, delayMs: number, onStatus: StatusFn, state: ComfyLifecycleState, message: string) {
+ const host = getComfyHost()
+ for (let i = 1; i <= attempts; i++) {
+ onStatus({ state, message: `${message} (${i}/${attempts})`, host, processRunning: true })
+ const health = await checkComfyHttp()
+ if (health.ok) return true
+ if (i < attempts) await sleep(delayMs)
+ }
+ return false
+}
+
+async function waitWhileBusy(onStatus: StatusFn) {
+ const { busyWaitMs } = settings()
+ const host = getComfyHost()
+ const started = Date.now()
+ while (Date.now() - started < busyWaitMs) {
+ const queue = await fetchQueue()
+ if (queue.running === 0) return
+ onStatus({
+ state: 'busy',
+ message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting...`,
+ host,
+ queueRunning: queue.running,
+ queuePending: queue.pending
+ })
+ log('busy', queue)
+ await sleep(2000)
+ }
+ throw createError({
+ statusCode: 409,
+ statusMessage: 'ComfyUI is busy executing another prompt. Try again in a moment.'
+ })
+}
+
+async function ensureUnlocked(onStatus: StatusFn) {
+ const cfg = settings()
+ const host = cfg.host
+ onStatus({ state: 'offline', message: 'Checking ComfyUI...', host })
+
+ const health = await checkComfyHttp(cfg.healthTimeoutMs)
+ if (health.ok) {
+ log('http-ok', { host })
+ await waitWhileBusy(onStatus)
+ onStatus({ state: 'online', message: 'ComfyUI online', host })
+ return
+ }
+
+ log('http-fail', { host })
+ const running = await processRunning()
+ if (running) {
+ onStatus({
+ state: 'booting',
+ message: 'ComfyUI process is up but the API is not ready yet. Waiting...',
+ host,
+ processRunning: true
+ })
+ const ready = await pollUntilHealthy(cfg.bootPollAttempts, cfg.bootPollDelayMs, onStatus, 'booting', 'Waiting for ComfyUI to finish booting')
+ if (!ready) {
+ throw createError({
+ statusCode: 503,
+ statusMessage: 'ComfyUI is running but port 8188 never became ready. Check the desktop instance.'
+ })
+ }
+ await waitWhileBusy(onStatus)
+ onStatus({ state: 'online', message: 'ComfyUI online', host, processRunning: true })
+ return
+ }
+
+ onStatus({
+ state: 'starting',
+ message: 'ComfyUI is offline. Starting the desktop instance...',
+ host,
+ processRunning: false
+ })
+ const started = await startComfy()
+ if (!started.started) {
+ throw createError({
+ statusCode: 503,
+ statusMessage: 'ComfyUI is offline and no launch command is configured. Start Comfy Desktop, or set COMFY_LAUNCH_CMD / COMFY_CONTROL_URL.'
+ })
+ }
+
+ const deadline = Date.now() + cfg.startTimeoutMs
+ let attempt = 0
+ while (Date.now() < deadline) {
+ attempt += 1
+ onStatus({
+ state: 'starting',
+ message: `Starting ComfyUI... waiting for /system_stats (${attempt})`,
+ host
+ })
+ const next = await checkComfyHttp()
+ if (next.ok) {
+ log('started', { attempt, reason: started.reason })
+ await waitWhileBusy(onStatus)
+ onStatus({ state: 'online', message: 'ComfyUI online', host, processRunning: true })
+ return
+ }
+ await sleep(2000)
+ }
+
+ throw createError({
+ statusCode: 503,
+ statusMessage: 'Started ComfyUI but it did not become healthy within 30 seconds.'
+ })
+}
+
+export function ensureComfyReady(onStatus: StatusFn = () => undefined) {
+ const run = gate.then(() => ensureUnlocked(onStatus))
+ gate = run.then(() => undefined, () => undefined)
+ return run
+}
diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts
index 1ed016b..c3b764f 100644
--- a/server/utils/jobs.ts
+++ b/server/utils/jobs.ts
@@ -14,6 +14,9 @@ export interface JobEvent {
hideThumbnail?: boolean
error?: string
elapsedMs?: number
+ busy?: boolean
+ queueRunning?: number
+ queuePending?: number
}
export interface Job {
diff --git a/server/utils/workflow.ts b/server/utils/workflow.ts
index b0994f9..0d8f29e 100644
--- a/server/utils/workflow.ts
+++ b/server/utils/workflow.ts
@@ -1,5 +1,5 @@
// Nitro bundles this JSON into the production server output.
-import workflowTemplate from '../assets/workflow_api.json'
+import workflowTemplate from '../assets/workflow_minimax_video.json'
export interface GenerateParams {
prompt: string