Files
aigen/server/api/generate.post.ts
T
TowstyandCursor a7a810a7b3 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>
2026-08-25 21:46:24 -05:00

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
}