Files
aigen/server/utils/comfy.ts
T
TowstyandCursor c043890ac0 Stop Comfy input folder filling with duplicate still uploads.
Name uploads by content hash so the same still overwrites itself, enable stale input sweeps on purge, and remove the pile already on the desktop Shared input folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 08:20:39 -05:00

682 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)}`)
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
}
}
}