Start ComfyUI automatically before queueing MiniMax jobs.
Health-check /system_stats, wait if the desktop is booting or busy, and start the host instance when it is fully offline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
COMFY_HOST=http://192.168.77.7:8188
|
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_SESSION_SECRET=change-me
|
||||||
NUXT_PUBLIC_AUTH_ENABLED=true
|
NUXT_PUBLIC_AUTH_ENABLED=true
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
comfyHost: process.env.COMFY_HOST || '',
|
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 || '',
|
sessionSecret: process.env.NUXT_SESSION_SECRET || '',
|
||||||
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
oidcClientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
||||||
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
oidcClientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
||||||
|
|||||||
+8
-2
@@ -169,7 +169,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="mb-2 flex items-center justify-between text-sm">
|
<div class="mb-2 flex items-center justify-between text-sm">
|
||||||
<span class="text-zinc-300">{{ statusMessage }}</span>
|
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span>
|
||||||
<span class="text-amber-200">{{ progress }}%</span>
|
<span class="text-amber-200">{{ progress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||||
@@ -404,6 +404,7 @@ const showThumbnail = computed({
|
|||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const statusMessage = ref('Waiting for a still.')
|
const statusMessage = ref('Waiting for a still.')
|
||||||
|
const statusBusy = ref(false)
|
||||||
const step = ref(0)
|
const step = ref(0)
|
||||||
const maxStep = ref(8)
|
const maxStep = ref(8)
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
@@ -661,13 +662,14 @@ async function generate() {
|
|||||||
toast('Unlock the selected folder before generating.')
|
toast('Unlock the selected folder before generating.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
statusBusy.value = false
|
||||||
busy.value = true
|
busy.value = true
|
||||||
settledUi = false
|
settledUi = false
|
||||||
progress.value = 1
|
progress.value = 1
|
||||||
videoUrl.value = ''
|
videoUrl.value = ''
|
||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
playerSize.value = outputSize.value
|
playerSize.value = outputSize.value
|
||||||
statusMessage.value = 'Uploading image...'
|
statusMessage.value = 'Checking ComfyUI...'
|
||||||
startTimer()
|
startTimer()
|
||||||
try {
|
try {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
@@ -694,6 +696,7 @@ async function generate() {
|
|||||||
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value)
|
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
statusBusy.value = false
|
||||||
stopTimer()
|
stopTimer()
|
||||||
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
|
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
|
||||||
statusMessage.value = message
|
statusMessage.value = message
|
||||||
@@ -712,6 +715,7 @@ function stopListen() {
|
|||||||
|
|
||||||
function applyJobEvent(payload: Record<string, any>, hidden: boolean) {
|
function applyJobEvent(payload: Record<string, any>, hidden: boolean) {
|
||||||
if (payload.message) statusMessage.value = payload.message
|
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.progress === 'number') progress.value = payload.progress
|
||||||
if (typeof payload.step === 'number') step.value = payload.step
|
if (typeof payload.step === 'number') step.value = payload.step
|
||||||
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
|
if (typeof payload.maxStep === 'number') maxStep.value = payload.maxStep
|
||||||
@@ -728,6 +732,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean) {
|
|||||||
videoUrl.value = clipUrl
|
videoUrl.value = clipUrl
|
||||||
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
statusBusy.value = false
|
||||||
stopTimer()
|
stopTimer()
|
||||||
stopListen()
|
stopListen()
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
@@ -737,6 +742,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean) {
|
|||||||
if (failed) {
|
if (failed) {
|
||||||
settledUi = true
|
settledUi = true
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
statusBusy.value = false
|
||||||
stopTimer()
|
stopTimer()
|
||||||
stopListen()
|
stopListen()
|
||||||
toast(payload.error || payload.message || 'Generation failed')
|
toast(payload.error || payload.message || 'Generation failed')
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
})
|
||||||
@@ -71,7 +71,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
seed,
|
seed,
|
||||||
thumb: image.data
|
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) => {
|
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
@@ -107,8 +107,20 @@ async function runGeneration(
|
|||||||
length: number
|
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)
|
const done = watchComfyJob(job)
|
||||||
job.status = 'uploading'
|
job.status = 'uploading'
|
||||||
|
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
||||||
const uploaded = await uploadImage(params.image)
|
const uploaded = await uploadImage(params.image)
|
||||||
if (job.library) job.library.imageName = uploaded.name
|
if (job.library) job.library.imageName = uploaded.name
|
||||||
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
|
const comfy = await checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||||
comfy
|
comfy: {
|
||||||
|
ok: comfy.ok,
|
||||||
|
host: comfy.host || '',
|
||||||
|
stats: comfy.ok ? comfy.stats : undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -1,10 +1,18 @@
|
|||||||
function comfyBase() {
|
function comfyBase() {
|
||||||
const config = useRuntimeConfig()
|
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) {
|
if (!host) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'COMFY_HOST is not configured' })
|
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() {
|
export function getComfyHost() {
|
||||||
|
|||||||
@@ -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<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: '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
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export interface JobEvent {
|
|||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
elapsedMs?: number
|
elapsedMs?: number
|
||||||
|
busy?: boolean
|
||||||
|
queueRunning?: number
|
||||||
|
queuePending?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Job {
|
export interface Job {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Nitro bundles this JSON into the production server output.
|
// 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 {
|
export interface GenerateParams {
|
||||||
prompt: string
|
prompt: string
|
||||||
|
|||||||
Reference in New Issue
Block a user