let comfyHostOverride = '' 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() 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' }) } 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() { return comfyBase() } export function comfyWsUrl(clientId: string) { return `${comfyBase().replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}` } export async function comfyFetch(path: string, init?: RequestInit) { const url = `${comfyBase()}${path}` try { return await fetch(url, init) } catch (error) { throw createError({ statusCode: 502, statusMessage: `ComfyUI host unreachable (${comfyBase()})`, data: { cause: error instanceof Error ? error.message : String(error) } }) } } export async function uploadImage(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 = comfyInputFilename(file.filename, jobId) body.append('image', blob, filename) body.append('overwrite', 'true') body.append('type', 'input') const res = await comfyFetch('/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 function comfyInputFilename(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_${id}_${base}${ext}` } export async function queuePrompt(graph: unknown, clientId: string) { const res = await comfyFetch('/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 interruptComfy() { const res = await comfyFetch('/interrupt', { method: 'POST' }) return res.ok } export async function fetchHistory(promptId: string) { const res = await comfyFetch(`/history/${encodeURIComponent(promptId)}`) if (!res.ok) return null return (await res.json()) as Record } export async function fetchHistoryAll() { const res = await comfyFetch('/history') if (!res.ok) return {} return (await res.json()) as Record } function isVideoFile(item: { filename?: string; format?: string } | null | undefined) { if (!item) return false const name = String(item.filename || '').toLowerCase() const format = String(item.format || '').toLowerCase() return name.endsWith('.mp4') || name.endsWith('.webm') || format.includes('video') || format.includes('mp4') } function normalizeVideo(item: { filename?: string; subfolder?: string; type?: string }) { return { filename: String(item.filename), subfolder: String(item.subfolder || ''), type: String(item.type || 'output') } } function findVideo(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; format?: string; subfolder?: string; type?: string } => Boolean(item && typeof item === 'object')) const match = files.find(item => String(item.filename || '').toLowerCase().endsWith('.mp4')) || files.find(isVideoFile) if (match?.filename) return normalizeVideo(match) for (const item of value) { const nested = findVideo(item, depth + 1) if (nested) return nested } return null } const record = value as { filename?: string; format?: string; subfolder?: string; type?: string } if (record.filename && isVideoFile(record)) return normalizeVideo(record) for (const nested of Object.values(value as Record)) { const found = findVideo(nested, depth + 1) if (found) return found } return null } export function extractVideo(history: Record | null, promptId: string) { if (!history) return null const wrapped = history[promptId] as { outputs?: Record } | undefined if (wrapped) { return findVideo(wrapped.outputs || {}) || findVideo(wrapped) } if ((history as { outputs?: unknown }).outputs) { return findVideo((history as { outputs?: unknown }).outputs) || findVideo(history) } return findVideo(history) } export function extractPromptFromHistory(entry: unknown) { const prompt = (entry as { prompt?: unknown[] })?.prompt const graph = Array.isArray(prompt) ? prompt[2] : null if (!graph || typeof graph !== 'object') return '' for (const node of Object.values(graph as Record }>)) { if (node?.class_type === 'MiniMaxH3ImageToVideo') { return String(node.inputs?.prompt || node.inputs?.text || '').trim() } } return '' } export function extractClipMetaFromHistory(entry: unknown) { const prompt = (entry as { prompt?: unknown[] })?.prompt const graph = Array.isArray(prompt) ? prompt[2] : null const meta = { width: 0, height: 0, steps: 0, seed: 0 } if (!graph || typeof graph !== 'object') return meta for (const node of Object.values(graph as Record }>)) { const inputs = node?.inputs || {} if (node?.class_type === 'MiniMaxH3ImageToVideo') { meta.width = Number(inputs.width || meta.width) meta.height = Number(inputs.height || meta.height) } if (node?.class_type === 'BasicScheduler' || node?.class_type === 'PrimitiveInt') { const steps = Number(inputs.steps || 0) if (steps > 0) meta.steps = steps } if (node?.class_type === 'RandomNoise' || node?.class_type === 'SamplerMiniMax' || node?.class_type === 'KSampler') { const seed = Number(inputs.noise_seed ?? inputs.seed ?? 0) if (seed) meta.seed = seed } } return meta } export async function fetchLiveQueue() { try { const res = await comfyFetch('/queue', { signal: AbortSignal.timeout(2500) }) if (!res.ok) return null 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 null } } export async function isComfyPromptDropped(promptId: string) { if (!promptId) return false const queue = await fetchLiveQueue() if (!queue) return false if (queue.running > 0 || queue.pending > 0) return false const history = await fetchHistory(promptId) const entry = history?.[promptId] as { status?: { status_str?: string; completed?: boolean } } | undefined if (!entry) return false const inspected = inspectHistory(history, promptId) if (inspected.video) return false const status = entry.status?.status_str if (status === 'error' || status === 'interrupted') return true if (entry.status?.completed) return true return false } export function inspectHistory(history: Record | null, promptId: string) { const video = extractVideo(history, promptId) if (video) return { video, completed: true, error: null as string | null } if (!history) return { video: null, completed: false, error: null as string | null } const entry = (history[promptId] || history) as { status?: { status_str?: string; completed?: boolean } } const status = entry?.status?.status_str if (status === 'interrupted') return { video: null, completed: false, error: 'Job interrupted.' } if (status === 'error') return { video: null, completed: false, error: 'ComfyUI reported an execution error' } if (entry?.status?.completed) return { video: null, completed: true, error: 'ComfyUI finished without an MP4' } return { video: null, completed: false, error: null as string | null } } export async function probeComfy() { try { const res = await fetch(`${comfyBase()}/system_stats`, { signal: AbortSignal.timeout(2500) }) if (!res.ok) return { ok: false, host: comfyBase() } const stats = await res.json() return { ok: true, host: comfyBase(), stats } } catch { return { ok: false, host: comfyBase() } } } export function comfyFilenamePrefix() { return String(useRuntimeConfig().comfyFilenamePrefix || process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3') } export function isOurComfyVideo(video: { filename: string; subfolder: string }) { const prefix = comfyFilenamePrefix().replace(/\/$/, '') const parts = prefix.split('/') const namePrefix = parts[parts.length - 1] const sub = parts.length > 1 ? parts.slice(0, -1).join('/') : 'video' const nameOk = video.filename.startsWith(namePrefix) const subOk = !video.subfolder || video.subfolder === sub return nameOk && subOk } export function purgeComfyEnabled() { return useRuntimeConfig().purgeComfyOutputs !== false } async function purgeOnDesktop(opts: { video?: { filename: string; subfolder: string; type: string } imageName?: string imageSubfolder?: string }) { const config = useRuntimeConfig() const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '') const token = String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '') if (!controlUrl) return try { await fetch(`${controlUrl}/purge`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ imageName: opts.imageName || '', imageSubfolder: opts.imageSubfolder || '', video: opts.video || null }), signal: AbortSignal.timeout(8000) }) } catch { // Host agent may be down; Comfy HTTP purge is the fallback. } } export async function purgeComfyArtifacts(opts: { video?: { filename: string; subfolder: string; type: string } imageName?: string imageSubfolder?: string promptId?: string }) { if (!purgeComfyEnabled()) return await purgeOnDesktop(opts) const files: { filename: string; subfolder: string; type: string }[] = [] if (opts.video?.filename) files.push(opts.video) if (opts.imageName) files.push({ filename: opts.imageName, subfolder: opts.imageSubfolder || '', type: 'input' }) for (const file of files) { try { await comfyFetch('/aigen/purge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(file) }) } catch { // Comfy may not have the purge route loaded yet; never fail the saved clip. } } if (opts.promptId) { try { await comfyFetch('/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delete: [opts.promptId] }) }) } catch { // ignore } } }