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
+66 -9
View File
@@ -1,3 +1,10 @@
function isComfyBusyTimeout(error: unknown) {
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true
const text = `${err?.statusMessage || ''} ${err?.message || ''}`
return text.includes('still busy after 3 minutes')
}
function parseDuration(raw: string) {
const seconds = Number(raw)
if (!Number.isFinite(seconds)) return 5
@@ -39,8 +46,11 @@ export default defineEventHandler(async (event) => {
const seed = fields.seed && fields.seed !== 'random'
? Number(fields.seed)
: Math.floor(Math.random() * 2_147_483_647)
const length = frameLength(parseDuration(fields.duration || '5'))
const durationSeconds = parseDuration(fields.duration || '5')
const length = frameLength(durationSeconds)
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = fields.sound !== 'false'
const clipName = (fields.name || '').trim().slice(0, 80)
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event)
@@ -53,13 +63,14 @@ export default defineEventHandler(async (event) => {
const destFolder = library.folders.find(folder => folder.id === folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
assertFolderExists(event, folderId)
await saveStill({
const still = await saveStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width,
height
height,
hideInput
})
const job = createJob()
@@ -69,6 +80,7 @@ export default defineEventHandler(async (event) => {
ownerKey,
folderId,
hideThumbnail,
hideInput,
folderLocked,
name: clipName,
prompt,
@@ -78,11 +90,46 @@ export default defineEventHandler(async (event) => {
steps,
turbo,
seed,
thumb: image.data
thumb: image.data,
stillId: still.id,
stillFilename: still.filename,
duration: durationSeconds,
sound,
draftId: (fields.draftId || '').trim() || undefined
}
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => {
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound }).catch(async (error) => {
if (isComfyBusyTimeout(error) && job.library) {
try {
const draft = await saveRetryDraft({
ownerKey: job.library.ownerKey,
id: job.library.draftId,
folderId: job.library.folderId,
stillId: job.library.stillId,
stillFilename: job.library.stillFilename,
name: job.library.name,
prompt: job.library.prompt,
aspect: job.library.aspect,
width: job.library.width,
height: job.library.height,
steps: job.library.steps,
turbo: job.library.turbo,
seed: job.library.seed,
duration: job.library.duration || durationSeconds,
hideThumbnail: job.library.hideThumbnail,
hideInput: job.library.hideInput === true,
sound: job.library.sound !== false
})
job.library.draftId = draft.id
job.status = 'deferred'
const message = 'ComfyUI was still busy after 3 minutes. Saved this job — click it under Held jobs to restore the form.'
emitJob(job, { type: 'deferred', draftId: draft.id, message, progress: job.progress })
return
} catch {
// fall through to a normal error if the held job could not be saved
}
}
const message = error instanceof Error ? error.message : String(error)
job.status = 'error'
job.error = message
@@ -115,6 +162,7 @@ async function runGeneration(
seed: number
turbo: boolean
length: number
sound: boolean
}
) {
await ensureComfyReady((status) => {
@@ -131,8 +179,11 @@ async function runGeneration(
const done = watchComfyJob(job)
job.status = 'uploading'
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
const uploaded = await uploadImage(params.image)
if (job.library) job.library.imageName = uploaded.name
const uploaded = await uploadImage(params.image, job.id)
if (job.library) {
job.library.imageName = uploaded.name
job.library.imageSubfolder = uploaded.subfolder
}
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
await waitForComfySocket(job, 4000)
@@ -145,12 +196,17 @@ async function runGeneration(
seed: params.seed,
turbo: params.turbo,
length: params.length,
filenamePrefix: comfyFilenamePrefix()
filenamePrefix: comfyFilenamePrefix(),
sound: params.sound
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
if (job.library?.draftId) {
await deleteRetryDraft(job.library.ownerKey, job.library.draftId).catch(() => null)
job.library.draftId = undefined
}
if (job.library && job.promptId) {
writePendingJob({
jobId: job.id,
@@ -169,7 +225,8 @@ async function runGeneration(
turbo: job.library.turbo,
seed: job.library.seed,
startedAt: job.startedAt,
imageName: job.library.imageName
imageName: job.library.imageName,
imageSubfolder: job.library.imageSubfolder
})
}
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
if (id) {
const pending = readPendingJob(id)
if (pending) {
const done = await completePendingIfReady(pending).catch(() => null)
const done = await resolvePendingJob(pending).catch(() => null)
if (done) return done
return {
type: 'snapshot',
+3 -1
View File
@@ -1,4 +1,4 @@
export default defineEventHandler((event) => {
export default defineEventHandler(async (event) => {
const owner = libraryOwnerKey(event)
const live = listJobs()
.filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
@@ -16,6 +16,8 @@ export default defineEventHandler((event) => {
.filter(job => job.ownerKey === owner)
.sort((a, b) => b.startedAt - a.startedAt)[0]
if (pending) {
const resolved = await resolvePendingJob(pending).catch(() => null)
if (resolved) return resolved
return {
jobId: pending.jobId,
type: 'snapshot',
+1 -1
View File
@@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => {
if (jobId) {
const pending = readPendingJob(jobId)
if (pending) {
const done = await completePendingIfReady(pending)
const done = await resolvePendingJob(pending)
if (done) return done
return {
type: 'snapshot',
+10 -1
View File
@@ -1,11 +1,20 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
export default defineEventHandler(async () => {
const comfy = await checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
const [comfy, queue] = await Promise.all([
checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null })),
fetchQueue()
])
const busy = Boolean(comfy.ok && queue.running > 0)
return {
ok: true,
service: useRuntimeConfig().public.instanceName || 'aigen',
comfy: {
ok: comfy.ok,
busy,
host: comfy.host || '',
queueRunning: queue.running,
queuePending: queue.pending,
stats: comfy.ok ? comfy.stats : undefined
}
}
+3 -1
View File
@@ -1,10 +1,12 @@
export default defineEventHandler(async (event) => {
const body = await readBody<{ jobId?: string }>(event).catch(() => ({}))
const job = body?.jobId ? getJob(body.jobId) : undefined
const jobId = String(body?.jobId || '')
const job = jobId ? getJob(jobId) : undefined
if (job) {
job.status = 'cancelled'
emitJob(job, { type: 'status', message: 'Cancelling job...' })
}
if (jobId) deletePendingJob(jobId)
const ok = await interruptComfy()
if (job) {
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
+14
View File
@@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ folderId?: string }>(event).catch(() => ({} as { folderId?: string }))
const folderId = String(body?.folderId || '')
if (!folderId) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder' })
}
const clip = getClip(owner, id)
assertFolderAccess(event, clip.folderId)
assertFolderExists(event, folderId)
await moveClip(owner, id, folderId)
return publicLibrary(event)
})
@@ -5,9 +5,6 @@ export default defineEventHandler((event) => {
const id = String(getRouterParam(event, 'id') || '')
const clip = getClip(owner, id)
assertFolderAccess(event, clip.folderId)
if (clip.hideThumbnail) {
throw createError({ statusCode: 404, statusMessage: 'Thumbnail hidden' })
}
const path = clipThumbPath(owner, clip.id)
if (!existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
+6
View File
@@ -0,0 +1,6 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await deleteRetryDraft(owner, id)
return publicLibrary(event)
})
@@ -0,0 +1,19 @@
import { existsSync, readFileSync } from 'node:fs'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const draft = getRetryDraft(owner, id)
if (!draft.stillId) {
throw createError({ statusCode: 404, statusMessage: 'Held job has no still' })
}
const still = getStill(owner, draft.stillId)
const path = stillPath(owner, still.id)
if (!existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
}
const buf = readFileSync(path)
setHeader(event, 'Content-Type', imageContentType(buf))
setHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
})
+2 -1
View File
@@ -30,7 +30,8 @@ export default defineEventHandler(async (event) => {
filename: image.filename,
data: image.data,
width: Number(fields.width || 0),
height: Number(fields.height || 0)
height: Number(fields.height || 0),
hideInput: fields.hideInput === 'true'
})
return { still, ...publicLibrary(event) }
})