import { LTX_NEGATIVE } from '~/utils/videoModels' import { comfyJobPrefix } from '~/utils/outputNames' let comfyHostOverride = '' export function setComfyHostOverride(url: string) { comfyHostOverride = String(url || '').replace(/\/$/, '') } function controlUrl() { const config = useRuntimeConfig() return String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').trim() } export function agentFrontDoorOrigin() { const raw = controlUrl() if (!raw) return '' const config = useRuntimeConfig() const proxyPort = String(config.comfyProxyPort || process.env.COMFY_PROXY_PORT || '8198').trim() || '8198' try { const control = new URL(raw.includes('://') ? raw : `http://${raw}`) control.port = proxyPort control.pathname = '' control.search = '' control.hash = '' return control.origin } catch { return '' } } /** Beast HTTP goes through the host-agent proxy so hopped 8188/8189/8190 never reach Coolify. */ export function viaAgentFrontDoor(host: string) { const door = agentFrontDoorOrigin() if (!door || !host) return host try { const url = new URL(host) const front = new URL(door) if (url.hostname !== front.hostname) return host return front.origin } catch { return host } } export function comfyConfigured() { if (comfyHostOverride) return true const config = useRuntimeConfig() return Boolean( String(config.comfyHost || process.env.COMFY_HOST || '').trim() || controlUrl() ) } function configuredComfyBase() { const config = useRuntimeConfig() let host = String(process.env.COMFY_HOST || config.comfyHost || '').trim().replace(/\/$/, '') const port = String(process.env.COMFY_PORT || config.comfyPort || '').trim() const door = agentFrontDoorOrigin() if (!host) { if (door) return door 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() { if (comfyHostOverride) return viaAgentFrontDoor(comfyHostOverride) || comfyHostOverride return viaAgentFrontDoor(configuredComfyBase()) || configuredComfyBase() } export function comfyWsUrl(clientId: string) { return `${getComfyHost().replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(clientId)}` } export async function comfyFetch(path: string, init?: RequestInit) { const url = `${getComfyHost()}${path}` try { return await fetch(url, init) } catch (error) { throw createError({ statusCode: 502, statusMessage: `ComfyUI host unreachable (${getComfyHost()})`, data: { cause: error instanceof Error ? error.message : String(error) } }) } } /** true = installed, false = missing, null = Comfy unreachable / object_info failed */ export async function comfyHasClassType(classType: string): Promise { try { const res = await comfyFetch(`/object_info/${encodeURIComponent(classType)}`, { signal: AbortSignal.timeout(8000) }) if (res.status === 404) return false if (!res.ok) return null const body = await res.json().catch(() => null) if (!body || typeof body !== 'object') return false const rec = body as Record if (rec[classType] && typeof rec[classType] === 'object') return true return String(rec.name || rec.class_type || '') === classType } catch { return null } } export const IMAGE_SCALE_TO_TOTAL_PIXELS = 'ImageScaleToTotalPixels' export const IMAGE_SCALE_MISSING_MESSAGE = 'Scale Image to Total Pixels (ImageScaleToTotalPixels) is not installed on Beast. Turn Scale to 1 MP off, or install the node.' export async function assertImageScaleToTotalPixelsNode() { const present = await comfyHasClassType(IMAGE_SCALE_TO_TOTAL_PIXELS) if (present === false) { throw createError({ statusCode: 400, statusMessage: IMAGE_SCALE_MISSING_MESSAGE }) } } 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' return `${comfyJobPrefix(jobId)}_${base}${ext}` } function comfyQueueMessage(payload: unknown, status: number) { const body = payload as { error?: { message?: string; details?: string } node_errors?: Record }> } const parts: string[] = [] const headline = String(body.error?.message || '').trim() const extra = String(body.error?.details || '').trim() if (headline) parts.push(headline) if (extra) parts.push(extra) for (const [id, node] of Object.entries(body.node_errors || {})) { for (const item of node.errors || []) { const bit = [item.message, item.details, item.extra_info?.input_name].filter(Boolean).join(' — ') if (bit) parts.push(`${node.class_type || `Node ${id}`}: ${bit}`) } } const message = [...new Set(parts.filter(Boolean))].join('. ') return message || `Queue failed (${status})` } export async function queuePrompt(graph: unknown, clientId: string, opts?: { timeoutMs?: number }) { const res = await comfyFetch('/prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: graph, client_id: clientId }), ...(opts?.timeoutMs ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}) }) const payload = await res.json().catch(() => ({})) if (!res.ok) { throw createError({ statusCode: 502, statusMessage: comfyQueueMessage(payload, res.status), data: payload }) } return payload as { prompt_id: string; number?: number } } export async function interruptComfy() { const res = await comfyFetch('/interrupt', { method: 'POST' }).catch(() => null) await comfyFetch('/queue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clear: true }) }).catch(() => null) return Boolean(res?.ok) } /** Ask Comfy to drop cached models so a heavy YuE job starts with a clean 16GB card. */ export async function freeComfyVram() { await comfyFetch('/free', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ unload_models: true, free_memory: true }), signal: AbortSignal.timeout(15000) }).catch(() => null) } 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', { signal: AbortSignal.timeout(12000) }) 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 } function isAudioFile(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('.wav') || name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.ogg') || name.endsWith('.opus') || format.includes('audio') || format.includes('wav') || format.includes('flac') } function normalizeAudio(item: { filename?: string; subfolder?: string; type?: string }) { return { filename: String(item.filename), subfolder: String(item.subfolder || ''), type: String(item.type || 'output') } } function findAudio(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 => { const name = String(item.filename || '').toLowerCase() return name.endsWith('.flac') || name.endsWith('.wav') }) || files.find(isAudioFile) if (match?.filename) return normalizeAudio(match) for (const item of value) { const nested = findAudio(item, depth + 1) if (nested) return nested } return null } const record = value as { filename?: string; format?: string; subfolder?: string; type?: string } if (record.filename && isAudioFile(record)) return normalizeAudio(record) for (const nested of Object.values(value as Record)) { const found = findAudio(nested, depth + 1) if (found) return found } return null } export function extractAudio(history: Record | null, promptId: string) { if (!history) return null const wrapped = history[promptId] as { outputs?: Record } | undefined if (wrapped) { return findAudio(wrapped.outputs || {}) || findAudio(wrapped) } if ((history as { outputs?: unknown }).outputs) { return findAudio((history as { outputs?: unknown }).outputs) || findAudio(history) } return findAudio(history) } export function comfyAudioPrefix() { return String(useRuntimeConfig().comfyAudioPrefix || process.env.COMFY_AUDIO_PREFIX || 'audio/AIGen') } export function isOurComfyAudio(audio: { filename: string; subfolder?: string }) { const prefix = comfyAudioPrefix().replace(/\/$/, '') const parts = prefix.split('/') const namePrefix = parts[parts.length - 1] || 'AIGen' const sub = parts.length > 1 ? parts.slice(0, -1).join('/') : 'audio' const nameOk = String(audio.filename || '').startsWith(namePrefix) const subOk = !audio.subfolder || audio.subfolder === sub return nameOk && subOk } export function extractAceMetaFromHistory(entry: unknown) { const prompt = (entry as { prompt?: unknown[] })?.prompt const graph = Array.isArray(prompt) ? prompt[2] : null const meta = { tags: '', lyrics: '', duration: 0, steps: 0, seed: 0, cfg: 0, lyricsStrength: 0.9, samplerName: '', scheduler: '', engine: 'ace-step' } if (!graph || typeof graph !== 'object') return meta for (const node of Object.values(graph as Record }>)) { const inputs = node?.inputs || {} if (node?.class_type === 'TextEncodeAceStepAudio' || node?.class_type === 'TextEncodeAceStepAudio1.5') { meta.tags = String(inputs.tags || '') meta.lyrics = String(inputs.lyrics || '') meta.lyricsStrength = Number(inputs.lyrics_strength || meta.lyricsStrength) } if (node?.class_type === 'YUE_Stage_A_Sampler') { meta.tags = String(inputs.genres_prompt || '') meta.lyrics = String(inputs.lyrics_prompt || '') meta.engine = 'yue' meta.duration = Number(inputs.prompt_end_time || meta.duration) meta.seed = Number(inputs.seed || meta.seed) } if (node?.class_type === 'EmptyAceStepLatentAudio' || node?.class_type === 'EmptyAceStep1.5LatentAudio') { meta.duration = Number(inputs.seconds || 0) } if (node?.class_type === 'KSampler') { meta.steps = Number(inputs.steps || 0) meta.seed = Number(inputs.seed || 0) meta.cfg = Number(inputs.cfg || 0) meta.samplerName = String(inputs.sampler_name || '') meta.scheduler = String(inputs.scheduler || '') } if (node?.class_type === 'CheckpointLoaderSimple') { const ckpt = String(inputs.ckpt_name || '') if (/1\.5|turbo_aio/i.test(ckpt)) meta.engine = 'ace-step-1.5' } } return meta } function historyCompletedAt(entry: unknown) { const messages = (entry as { status?: { messages?: Array<[string, { timestamp?: number }]> } })?.status?.messages const last = messages?.find(item => item[0] === 'execution_success') return Number(last?.[1]?.timestamp || 0) } export function findHistoryAudio( history: Record | null, opts?: { promptId?: string; tags?: string; since?: number } ) { if (!history) return null if (opts?.promptId) { const audio = extractAudio(history, opts.promptId) if (audio) return { promptId: opts.promptId, audio } } const want = String(opts?.tags || '').trim() const matches: { promptId: string audio: NonNullable> ts: number tags: string }[] = [] for (const [id, entry] of Object.entries(history)) { const audio = extractAudio({ [id]: entry as Record }, id) if (!audio || !isOurComfyAudio(audio)) continue const ts = historyCompletedAt(entry) if (opts?.since && ts && ts < opts.since) continue const meta = extractAceMetaFromHistory(entry) matches.push({ promptId: id, audio, ts: historyCompletedAt(entry), tags: meta.tags }) } if (!matches.length) return null const tagged = want ? matches.filter((item) => { const tags = item.tags.trim() return tags === want || tags.includes(want) || want.includes(tags) }) : matches const pool = want ? tagged : matches if (!pool.length) return null pool.sort((a, b) => b.ts - a.ts) return { promptId: pool[0].promptId, audio: pool[0].audio } } 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 '' let imageToVideo = '' for (const node of Object.values(graph as Record }>)) { const text = String(node.inputs?.prompt || node.inputs?.text || '').trim() if (!text) continue if (node?.class_type === 'MiniMaxH3ReferenceToVideo') return text if (node?.class_type === 'MiniMaxH3ImageToVideo' || node?.class_type === 'MiniMaxH3TextToVideo') imageToVideo = text if (node?.class_type === 'CLIPTextEncode') { const title = String((node as { _meta?: { title?: string } })._meta?.title || '') if (/negative/i.test(title) || text === LTX_NEGATIVE) continue if (!imageToVideo) imageToVideo = text } } return imageToVideo } 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' || node?.class_type === 'MiniMaxH3TextToVideo' || node?.class_type === 'EmptyLTXVLatentVideo') { meta.width = Number(inputs.width || meta.width) meta.height = Number(inputs.height || meta.height) } if (node?.class_type === 'BasicScheduler' || node?.class_type === 'PrimitiveInt' || node?.class_type === 'LTXVScheduler') { const steps = Number(inputs.steps || 0) if (steps > 0) meta.steps = steps } if (node?.class_type === 'ImageResize+' && typeof inputs.width === 'number' && typeof inputs.height === 'number') { meta.width = Number(inputs.width) meta.height = Number(inputs.height) } 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 // Empty Comfy + no history for this id: wiped by restart/clear, or never accepted. if (!entry) return true const inspected = inspectHistory(history, promptId) if (inspected.video) return false if (extractAudio(history, promptId)) 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(`${getComfyHost()}/system_stats`, { signal: AbortSignal.timeout(2500) }) if (!res.ok) return { ok: false, host: getComfyHost() } const stats = await res.json() return { ok: true, host: getComfyHost(), stats } } catch { return { ok: false, host: getComfyHost() } } } export function comfyFilenamePrefix() { return String(useRuntimeConfig().comfyFilenamePrefix || process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3') } export function isOurComfyVideo(video: { filename: string; subfolder: string }) { const prefixes = [comfyFilenamePrefix(), 'video/LTX23'] return prefixes.some((raw) => { const prefix = raw.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) || video.filename.startsWith('LTX23') const subOk = !video.subfolder || video.subfolder === sub return nameOk && subOk }) } export function comfySweepNamePrefixes() { const names = new Set(['MiniMax_H3', 'xAIGen', 'XAIgen', 'LTX23', 'AIGen', 'v2-krea', 'v2-generate']) for (const raw of [comfyFilenamePrefix(), comfyAudioPrefix(), comfyImagePrefix()]) { const last = raw.replace(/\/$/, '').split('/').pop() if (last) names.add(last) } return [...names] } export function comfyStudioFileTag() { const last = comfyFilenamePrefix().replace(/\/$/, '').split('/').pop() || '' if (/xaigen/i.test(last)) return 'xAIGen' return 'AIGen' } export function comfyImagePrefix() { const config = useRuntimeConfig() const explicit = String(config.comfyImagePrefix || process.env.COMFY_IMAGE_PREFIX || '').trim() if (explicit) return explicit.replace(/\/$/, '') return `image/${comfyStudioFileTag()}` } export function purgeComfyEnabled() { return useRuntimeConfig().purgeComfyOutputs !== false } type ComfyDiskFile = { filename: string; subfolder?: string; type?: string } async function deleteComfyHttpFile(file: ComfyDiskFile) { const payload = { filename: file.filename, subfolder: file.subfolder || '', type: file.type || 'output' } for (const path of ['/delete', '/aigen/purge']) { try { const res = await comfyFetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) if (res.ok) return true } catch { /* Comfy may not expose this route */ } } return false } async function purgeOnDesktop(opts: { video?: ComfyDiskFile audio?: ComfyDiskFile output?: ComfyDiskFile imageName?: string imageSubfolder?: string extraImageNames?: 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 const imageNames = [ opts.imageName, ...(Array.isArray(opts.extraImageNames) ? opts.extraImageNames : []) ].map(name => String(name || '').trim()).filter(Boolean) try { // Exact temps only: the input still(s) we uploaded + the output we already copied // into the library. Library on the web server is source of truth — Beast must not keep copies. await fetch(`${controlUrl}/purge`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify({ imageName: imageNames[0] || '', imageNames, imageSubfolder: opts.imageSubfolder || '', video: opts.video || null, audio: opts.audio || null, output: opts.output || (opts.video && /\.(png|jpe?g|webp|gif)$/i.test(opts.video.filename) ? opts.video : null), sweep: false }), signal: AbortSignal.timeout(12000) }) } catch { // Host agent may be down; Comfy HTTP purge is the fallback. } } export async function purgeComfyArtifacts(opts: { video?: ComfyDiskFile audio?: ComfyDiskFile output?: ComfyDiskFile imageName?: string imageSubfolder?: string extraImageNames?: string[] promptId?: string }) { if (!purgeComfyEnabled()) return await purgeOnDesktop(opts) const files: ComfyDiskFile[] = [] if (opts.video?.filename) files.push(opts.video) if (opts.audio?.filename) files.push(opts.audio) if (opts.output?.filename) files.push(opts.output) const inputSub = opts.imageSubfolder || '' if (opts.imageName) { files.push({ filename: opts.imageName, subfolder: inputSub, type: 'input' }) } for (const name of opts.extraImageNames || []) { if (!name) continue files.push({ filename: name, subfolder: inputSub, type: 'input' }) } for (const file of files) { await deleteComfyHttpFile(file) } if (opts.promptId) { try { await comfyFetch('/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delete: [opts.promptId] }) }) } catch { // ignore } } }