import { frameLength, runGeneration } from '~/server/utils/videoChain' 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: unknown) { const seconds = Number(raw) if (!Number.isFinite(seconds)) return 5 return Math.min(120, Math.max(0.5, Math.round(seconds * 10) / 10)) } function parseExtendDuration(raw: unknown) { return parseDuration(raw) } function parseExtensions(raw: string | undefined) { if (!raw) return [] as { prompt: string; duration: number }[] try { const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return [] return parsed .map((item: { prompt?: unknown; duration?: unknown }) => ({ prompt: String(item?.prompt || '').trim(), duration: parseExtendDuration(item?.duration) })) .filter(item => item.prompt) } catch { return [] } } const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m']) const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform']) function parseFps(raw: string | undefined) { const fps = Number(raw) return fps === 12 || fps === 30 || fps === 24 ? fps : 24 } function parseCfg(raw: string | undefined, turbo: boolean) { const fallback = turbo ? 1.5 : 4 const value = Number(raw) if (!Number.isFinite(value)) return fallback const snapped = Math.round(value * 2) / 2 return Math.min(10, Math.max(1, snapped)) } function parseSampler(raw: string | undefined) { return SAMPLERS.has(raw || '') ? raw! : 'res_multistep' } function parseScheduler(raw: string | undefined) { return SCHEDULERS.has(raw || '') ? raw! : 'simple' } 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 = {} let image: { filename: string; data: Buffer; type?: string } | null = null const referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null> = [null, null, null, null] let compactRefSlot = 0 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 && /^refImage(\d)$/.test(part.name) && part.filename && part.data?.length) { const slot = Number(part.name.replace('refImage', '')) if (slot >= 0 && slot < 4) { referenceImages[slot] = { filename: part.filename, data: part.data, type: part.type } } } else if (part.name === 'refImage' && part.filename && part.data?.length) { if (compactRefSlot < 4) { referenceImages[compactRefSlot] = { filename: part.filename, data: part.data, type: part.type } compactRefSlot += 1 } } 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 extensions = parseExtensions(fields.extensions) const workflow = parseVideoWorkflow(fields.workflow) const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true' const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height)) const turbo = fields.turbo !== 'false' const steps = Number(fields.steps || defaultVideoSteps(turbo, workflow)) 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 fps = parseFps(fields.fps) const cfg = parseCfg(fields.cfg, turbo) const samplerName = parseSampler(fields.sampler_name) const scheduler = parseScheduler(fields.scheduler) const length = frameLength(durationSeconds, fps) 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 rememberInputStill({ ownerKey, folderId, filename: image.filename, data: image.data, width, height, hideInput }) const referenceStillIds: Array = [null, null, null, null] if (useIdentityRefs) { for (const [index, ref] of referenceImages.entries()) { if (!ref?.data?.length || isPipelineFrameFilename(ref.filename)) continue const saved = await rememberInputStill({ ownerKey, folderId, filename: ref.filename || `identity-ref-${index + 2}.png`, data: ref.data, width, height, hideInput }) if (saved?.id) referenceStillIds[index] = saved.id } } const chainTotal = 1 + extensions.length const job = createJob() job.kind = 'video' 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, cfg, fps, samplerName, scheduler, thumb: isPipelineFrameFilename(image.filename) ? undefined : image.data, stillId: still?.id, stillFilename: still?.filename, referenceStillIds, duration: durationSeconds, sound, draftId: (fields.draftId || '').trim() || undefined, extensions, chainIndex: 0, chainStep: 1, chainTotal, chainLabel: extensions.length ? 'Initial' : undefined, familyId: crypto.randomUUID(), workflow, useIdentityRefs } emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 }) void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound, cfg, fps, samplerName, scheduler, extensions, workflow, duration: durationSeconds, useIdentityRefs, referenceImages }).catch(async (error) => { removeExtendTemp(job.library?.extendTmpDir) 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, cfg: job.library.cfg, fps: job.library.fps, samplerName: job.library.samplerName, scheduler: job.library.scheduler, extensions: job.library.extensions, workflow: job.library.workflow }) 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 } } if (job.status === 'error' || job.status === 'cancelled' || job.status === 'deferred') return 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', cfg, fps, sampler_name: samplerName, scheduler, steps, turbo, folderId, hideThumbnail, folderLocked, chainTotal } })