Files
aigen/server/utils/comfy.ts
T

684 lines
26 KiB
TypeScript

import { LTX_NEGATIVE } from '~/utils/videoModels'
import { createHash } from 'node:crypto'
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<boolean | null> {
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<string, unknown>
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, file.data, 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'
}
}
/** Content-hash names so the same still overwrites itself (overwrite:true). Job id alone created hundreds of copies. */
export function comfyInputFilename(original: string, data?: Buffer, 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 hash = data?.length
? createHash('sha1').update(data).digest('hex').slice(0, 12)
: comfyJobPrefix(jobId)
return `aigen_${hash}_${base}${ext}`
}
function comfyQueueMessage(payload: unknown, status: number) {
const body = payload as {
error?: { message?: string; details?: string }
node_errors?: Record<string, {
class_type?: string
errors?: Array<{ message?: string; details?: string; extra_info?: { input_name?: string } }>
}>
}
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)}`, {
signal: AbortSignal.timeout(12000)
})
if (!res.ok) return null
return (await res.json()) as Record<string, unknown>
}
export async function fetchHistoryAll() {
const res = await comfyFetch('/history', { signal: AbortSignal.timeout(12000) })
if (!res.ok) return {}
return (await res.json()) as Record<string, unknown>
}
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<string, unknown>)) {
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<string, unknown>)) {
const found = findAudio(nested, depth + 1)
if (found) return found
}
return null
}
export function extractAudio(history: Record<string, unknown> | null, promptId: string) {
if (!history) return null
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | 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<string, { class_type?: string; inputs?: Record<string, unknown> }>)) {
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<string, unknown> | 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<ReturnType<typeof extractAudio>>
ts: number
tags: string
}[] = []
for (const [id, entry] of Object.entries(history)) {
const audio = extractAudio({ [id]: entry as Record<string, unknown> }, 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<string, unknown> | null, promptId: string) {
if (!history) return null
const wrapped = history[promptId] as { outputs?: Record<string, unknown> } | 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<string, { class_type?: string; inputs?: Record<string, unknown> }>)) {
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<string, { class_type?: string; inputs?: Record<string, unknown> }>)) {
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<string, unknown> | 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 leftover studio uploads — exact delete alone left hundreds of copies when a job failed mid-way.
sweep: true,
sweepMaxAgeMs: 2 * 60 * 1000
}),
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
}
}
}