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>
235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
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
|
|
return Math.min(120, Math.max(0.5, seconds))
|
|
}
|
|
|
|
function frameLength(seconds: number) {
|
|
const raw = Math.max(5, Math.round(seconds * 24))
|
|
return raw + (5 - (raw % 17) + 17) % 17
|
|
}
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const form = await readMultipartFormData(event)
|
|
if (!form?.length) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Expected multipart form data' })
|
|
}
|
|
|
|
const fields: Record<string, string> = {}
|
|
let image: { filename: string; data: Buffer; type?: string } | null = null
|
|
for (const part of form) {
|
|
if (part.name === 'image' && part.filename && part.data?.length) {
|
|
image = { filename: part.filename, data: part.data, type: part.type }
|
|
} else if (part.name && part.data) {
|
|
fields[part.name] = part.data.toString('utf8')
|
|
}
|
|
}
|
|
|
|
if (!image) {
|
|
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
|
|
}
|
|
const prompt = (fields.prompt || '').trim()
|
|
if (!prompt) {
|
|
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
|
}
|
|
|
|
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
|
const turbo = fields.turbo !== 'false'
|
|
const steps = Number(fields.steps || (turbo ? 8 : 20))
|
|
const seed = fields.seed && fields.seed !== 'random'
|
|
? Number(fields.seed)
|
|
: Math.floor(Math.random() * 2_147_483_647)
|
|
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)
|
|
const folderId = library.folders.some(folder => folder.id === fields.folderId)
|
|
? fields.folderId
|
|
: library.folders[0]?.id
|
|
if (!folderId) {
|
|
throw createError({ statusCode: 400, statusMessage: 'Create a library folder before generating' })
|
|
}
|
|
const destFolder = library.folders.find(folder => folder.id === folderId)
|
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
|
assertFolderExists(event, folderId)
|
|
const still = await saveStill({
|
|
ownerKey,
|
|
folderId,
|
|
filename: image.filename,
|
|
data: image.data,
|
|
width,
|
|
height,
|
|
hideInput
|
|
})
|
|
|
|
const job = createJob()
|
|
job.maxStep = steps
|
|
job.hideThumbnail = hideThumbnail
|
|
job.library = {
|
|
ownerKey,
|
|
folderId,
|
|
hideThumbnail,
|
|
hideInput,
|
|
folderLocked,
|
|
name: clipName,
|
|
prompt,
|
|
aspect: fields.aspect || 'auto',
|
|
width,
|
|
height,
|
|
steps,
|
|
turbo,
|
|
seed,
|
|
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, 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
|
|
emitJob(job, { type: 'error', error: message, message })
|
|
})
|
|
|
|
return {
|
|
jobId: job.id,
|
|
clientId: job.clientId,
|
|
seed,
|
|
width,
|
|
height,
|
|
aspect: fields.aspect || 'auto',
|
|
steps,
|
|
turbo,
|
|
folderId,
|
|
hideThumbnail,
|
|
folderLocked
|
|
}
|
|
})
|
|
|
|
async function runGeneration(
|
|
job: ReturnType<typeof createJob>,
|
|
params: {
|
|
prompt: string
|
|
image: { filename: string; data: Buffer; type?: string }
|
|
width: number
|
|
height: number
|
|
steps: number
|
|
seed: number
|
|
turbo: boolean
|
|
length: number
|
|
sound: boolean
|
|
}
|
|
) {
|
|
await ensureComfyReady((status) => {
|
|
emitJob(job, {
|
|
type: status.state === 'busy' ? 'busy' : 'status',
|
|
message: status.message,
|
|
progress: status.state === 'online' ? Math.max(job.progress, 3) : Math.max(job.progress, 1),
|
|
busy: status.state === 'busy',
|
|
queueRunning: status.queueRunning,
|
|
queuePending: status.queuePending
|
|
})
|
|
})
|
|
|
|
const done = watchComfyJob(job)
|
|
job.status = 'uploading'
|
|
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
|
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)
|
|
|
|
const graph = buildWorkflow({
|
|
prompt: params.prompt,
|
|
imageName: uploaded.name,
|
|
width: params.width,
|
|
height: params.height,
|
|
steps: params.steps,
|
|
seed: params.seed,
|
|
turbo: params.turbo,
|
|
length: params.length,
|
|
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,
|
|
promptId: job.promptId,
|
|
clientId: job.clientId,
|
|
ownerKey: job.library.ownerKey,
|
|
folderId: job.library.folderId,
|
|
hideThumbnail: job.library.hideThumbnail,
|
|
folderLocked: job.library.folderLocked,
|
|
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,
|
|
startedAt: job.startedAt,
|
|
imageName: job.library.imageName,
|
|
imageSubfolder: job.library.imageSubfolder
|
|
})
|
|
}
|
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
|
await done
|
|
}
|