Let clips move between folders, save busy jobs, and fail dropped Comfy runs.

Held jobs restore the form after a 3-minute wait, hidden stills stay blurred in the picker, and reloading Comfy no longer leaves xaigen spinning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-25 21:46:24 -05:00
co-authored by Cursor
parent ba7a483aac
commit a7a810a7b3
22 changed files with 775 additions and 97 deletions
+54 -6
View File
@@ -43,17 +43,32 @@ export async function comfyFetch(path: string, init?: RequestInit) {
}
}
export async function uploadImage(file: { filename: string; data: Buffer; type?: string }) {
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' })
body.append('image', blob, file.filename)
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})` })
}
return (await res.json()) as { name: string; subfolder?: string; type?: string }
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) {
@@ -173,14 +188,44 @@ export function extractClipMetaFromHistory(entry: unknown) {
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 true
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 true
}
export function inspectHistory(history: Record<string, unknown> | null, promptId: string) {
const video = extractVideo(history, promptId)
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 error = entry?.status?.status_str === 'error'
? 'ComfyUI reported an execution error'
const error = entry?.status?.status_str === 'error' || entry?.status?.status_str === 'interrupted'
? (entry.status.status_str === 'interrupted' ? 'Job interrupted.' : 'ComfyUI reported an execution error')
: null
return { video, completed: Boolean(video), error }
}
@@ -217,6 +262,7 @@ export function purgeComfyEnabled() {
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(/\/$/, '')
@@ -232,6 +278,7 @@ async function purgeOnDesktop(opts: {
},
body: JSON.stringify({
imageName: opts.imageName || '',
imageSubfolder: opts.imageSubfolder || '',
video: opts.video || null
}),
signal: AbortSignal.timeout(8000)
@@ -244,13 +291,14 @@ async function purgeOnDesktop(opts: {
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: '', type: 'input' })
if (opts.imageName) files.push({ filename: opts.imageName, subfolder: opts.imageSubfolder || '', type: 'input' })
for (const file of files) {
try {
await comfyFetch('/aigen/purge', {
+4 -3
View File
@@ -59,7 +59,7 @@ export async function checkComfyHttp(timeoutMs = 2500, host = getComfyHost()) {
}
}
async function fetchQueue() {
export async function fetchQueue() {
const host = getComfyHost()
try {
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
@@ -189,7 +189,7 @@ async function waitWhileBusy(onStatus: StatusFn) {
if (queue.running === 0) return
onStatus({
state: 'busy',
message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting...`,
message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting up to 3 minutes, then this job will be saved.`,
host,
queueRunning: queue.running,
queuePending: queue.pending
@@ -199,7 +199,8 @@ async function waitWhileBusy(onStatus: StatusFn) {
}
throw createError({
statusCode: 409,
statusMessage: 'ComfyUI is busy executing another prompt. Try again in a moment.'
statusMessage: 'ComfyUI is still busy after 3 minutes. Your prompt, still, and settings were saved.',
data: { code: 'COMFY_BUSY' }
})
}
+11 -2
View File
@@ -1,4 +1,4 @@
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled'
export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled' | 'deferred'
export interface JobEvent {
type: string
@@ -19,6 +19,7 @@ export interface JobEvent {
queuePending?: number
folderLocked?: boolean
jobId?: string
draftId?: string
}
export interface Job {
@@ -38,6 +39,7 @@ export interface Job {
ownerKey: string
folderId: string
hideThumbnail: boolean
hideInput?: boolean
folderLocked?: boolean
name?: string
prompt: string
@@ -49,6 +51,12 @@ export interface Job {
seed: number
thumb?: Buffer
imageName?: string
imageSubfolder?: string
stillId?: string
stillFilename?: string
duration?: number
sound?: boolean
draftId?: string
}
error?: string
socketReady?: boolean
@@ -105,7 +113,8 @@ export function jobSnapshot(job: Job) {
clipId: job.clipId,
hideThumbnail: job.hideThumbnail,
error: job.error,
folderLocked: job.library?.folderLocked
folderLocked: job.library?.folderLocked,
draftId: job.library?.draftId
}
}
+130 -8
View File
@@ -40,6 +40,7 @@ export interface LibraryStill {
height: number
hash: string
createdAt: number
hideInput: boolean
}
export interface PublicFolder {
@@ -52,10 +53,31 @@ export interface PublicFolder {
clipCount: number
}
export interface RetryDraft {
id: string
createdAt: number
folderId: string
stillId?: string
name?: string
prompt: string
aspect: string
width: number
height: number
steps: number
turbo: boolean
seed: number
duration: number
hideThumbnail: boolean
sound: boolean
hideInput?: boolean
stillFilename?: string
}
interface Catalog {
folders: LibraryFolder[]
clips: LibraryClip[]
stills: LibraryStill[]
drafts: RetryDraft[]
}
const writeChains = new Map<string, Promise<unknown>>()
@@ -93,7 +115,8 @@ function emptyCatalog(): Catalog {
passwordHash: null
}],
clips: [],
stills: []
stills: [],
drafts: []
}
}
@@ -107,6 +130,19 @@ function normalizeFolder(folder: LibraryFolder): LibraryFolder {
}
}
function normalizeStill(still: LibraryStill): LibraryStill {
return {
id: still.id,
folderId: still.folderId,
filename: still.filename,
width: still.width || 0,
height: still.height || 0,
hash: still.hash || '',
createdAt: still.createdAt || Date.now(),
hideInput: still.hideInput === true
}
}
function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | null }): Catalog {
const folders = Array.isArray(parsed.folders) && parsed.folders.length
? parsed.folders.map(normalizeFolder)
@@ -119,7 +155,8 @@ function normalizeCatalog(parsed: Partial<Catalog> & { passwordHash?: string | n
return {
folders,
clips: Array.isArray(parsed.clips) ? parsed.clips : [],
stills: Array.isArray(parsed.stills) ? parsed.stills : []
stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [],
drafts: Array.isArray(parsed.drafts) ? parsed.drafts : []
}
}
@@ -250,7 +287,8 @@ export function publicLibrary(event: H3Event) {
name: clipTitle(clip),
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id))
})),
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId))
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)),
drafts: catalog.drafts
}
}
@@ -571,13 +609,23 @@ export async function saveStill(params: {
data: Buffer
width?: number
height?: number
hideInput?: boolean
}) {
const catalog = readCatalog(params.ownerKey)
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
const hash = bytesHash(params.data)
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
if (existing) return existing
if (existing) {
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
await mutate(params.ownerKey, (next) => {
const item = next.stills.find(still => still.id === existing.id)
if (item) item.hideInput = params.hideInput === true
})
existing.hideInput = params.hideInput === true
}
return existing
}
const still: LibraryStill = {
id: crypto.randomUUID(),
folderId: folder.id,
@@ -585,7 +633,8 @@ export async function saveStill(params: {
width: params.width || 0,
height: params.height || 0,
hash,
createdAt: Date.now()
createdAt: Date.now(),
hideInput: params.hideInput === true
}
mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true })
writeFileSync(stillPath(params.ownerKey, still.id), params.data)
@@ -595,6 +644,66 @@ export async function saveStill(params: {
return still
}
export async function saveRetryDraft(params: {
ownerKey: string
id?: string
folderId: string
stillId?: string
stillFilename?: string
name?: string
prompt: string
aspect: string
width: number
height: number
steps: number
turbo: boolean
seed: number
duration: number
hideThumbnail: boolean
sound: boolean
hideInput?: boolean
}) {
return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
const draft: RetryDraft = {
id: existing?.id || crypto.randomUUID(),
createdAt: existing?.createdAt || Date.now(),
folderId: params.folderId,
stillId: params.stillId,
stillFilename: params.stillFilename,
name: params.name,
prompt: params.prompt,
aspect: params.aspect,
width: params.width,
height: params.height,
steps: params.steps,
turbo: params.turbo,
seed: params.seed,
duration: params.duration,
hideThumbnail: params.hideThumbnail,
sound: params.sound,
hideInput: params.hideInput === true
}
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
return draft
})
}
export function getRetryDraft(owner: string, id: string) {
const draft = readCatalog(owner).drafts.find(item => item.id === id)
if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' })
return draft
}
export function deleteRetryDraft(owner: string, id: string) {
return mutate(owner, (catalog) => {
const draft = catalog.drafts.find(item => item.id === id)
if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' })
catalog.drafts = catalog.drafts.filter(item => item.id !== id)
return draft
})
}
export function getStill(owner: string, id: string) {
const still = readCatalog(owner).stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
@@ -630,6 +739,7 @@ export async function saveClip(params: {
turbo: boolean
seed: number
hideThumbnail: boolean
hideInput?: boolean
video: Buffer
thumb?: Buffer | null
comfyFilename?: string
@@ -654,9 +764,9 @@ export async function saveClip(params: {
}
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
if (!params.hideThumbnail && params.thumb?.length) {
if (params.thumb?.length) {
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
} else if (!params.hideThumbnail) {
} else {
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
}
if (params.thumb?.length) {
@@ -666,7 +776,8 @@ export async function saveClip(params: {
filename: `${clip.id}.png`,
data: params.thumb,
width: params.width,
height: params.height
height: params.height,
hideInput: params.hideInput
})
}
await mutate(params.ownerKey, (next) => {
@@ -691,6 +802,17 @@ export function deleteClip(owner: string, id: string) {
})
}
export function moveClip(owner: string, id: string, folderId: string) {
return mutate(owner, (catalog) => {
const clip = catalog.clips.find(item => item.id === id)
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
const folder = catalog.folders.find(item => item.id === folderId)
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
clip.folderId = folder.id
return clip
})
}
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
const subfolders = [...new Set([video.subfolder, 'video', ''])]
let lastStatus = 0
+20 -1
View File
@@ -19,6 +19,7 @@ export interface PendingJob {
seed: number
startedAt: number
imageName?: string
imageSubfolder?: string
}
function pendingRoot() {
@@ -84,7 +85,7 @@ export async function completePendingIfReady(pending: PendingJob) {
thumb: null,
comfyFilename: video.filename
})
await purgeComfyArtifacts({ video, imageName: pending.imageName, promptId: pending.promptId })
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
deletePendingJob(pending.jobId)
return {
type: 'complete' as const,
@@ -100,3 +101,21 @@ export async function completePendingIfReady(pending: PendingJob) {
folderLocked: pending.folderLocked
}
}
export async function resolvePendingJob(pending: PendingJob) {
const done = await completePendingIfReady(pending)
if (done) return done
if (await isComfyPromptDropped(pending.promptId)) {
deletePendingJob(pending.jobId)
const message = 'ComfyUI dropped this job. Reloading the Comfy interface clears the queue. Generate again.'
return {
type: 'error' as const,
status: 'error' as const,
jobId: pending.jobId,
error: message,
message,
progress: 0
}
}
return null
}
+17 -10
View File
@@ -69,8 +69,9 @@ export function watchComfyJob(job: Job): Promise<void> {
turbo: job.library.turbo,
seed: job.library.seed,
hideThumbnail: job.library.hideThumbnail,
hideInput: job.library.hideInput,
video: buffer,
thumb: job.library.hideThumbnail ? null : job.library.thumb,
thumb: job.library.thumb,
comfyFilename: video.filename
})
job.clipId = clip.id
@@ -79,6 +80,7 @@ export function watchComfyJob(job: Job): Promise<void> {
await purgeComfyArtifacts({
video,
imageName: job.library.imageName,
imageSubfolder: job.library.imageSubfolder,
promptId: job.promptId
})
}
@@ -105,6 +107,8 @@ export function watchComfyJob(job: Job): Promise<void> {
return true
}
let dropMisses = 0
const pollHistory = async () => {
if (settled || !job.promptId) return
try {
@@ -115,6 +119,15 @@ export function watchComfyJob(job: Job): Promise<void> {
}
if (inspected.video) {
await succeed()
return
}
if (await isComfyPromptDropped(job.promptId)) {
dropMisses += 1
if (dropMisses >= 3) {
await fail('ComfyUI dropped this job. Reloading the Comfy interface clears the queue. Generate again.')
}
} else {
dropMisses = 0
}
} catch {
// Transient ComfyUI history misses are expected while the graph is still running.
@@ -134,15 +147,9 @@ export function watchComfyJob(job: Job): Promise<void> {
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: Math.max(job.progress, 4) })
})
ws.addEventListener('error', () => {
if (settled) return
emitJob(job, {
type: 'status',
message: job.promptId
? 'Live socket unavailable, polling ComfyUI history...'
: 'Waiting for ComfyUI socket...',
progress: Math.max(job.progress, 4)
})
ws.addEventListener('close', () => {
if (settled || !job.promptId) return
void pollHistory()
})
ws.addEventListener('message', async (event) => {
+7
View File
@@ -11,6 +11,7 @@ export interface GenerateParams {
turbo: boolean
length: number
filenamePrefix?: string
sound?: boolean
}
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
@@ -102,6 +103,12 @@ export function buildWorkflow(params: GenerateParams) {
}
}
if (params.sound === false) {
delete graph['105:23']
delete graph['105:24']
if (graph['105:91']?.inputs) delete graph['105:91'].inputs.audio
}
return graph
}