Files
aigen/server/api/generate.post.ts
T
TowstyandCursor 40cd17f35c Add MiniMax and PinkCherry LTX as pickable video engines, with still or text start.
Shot 1 can run text-to-video; later shots still extend from the last frame. Recommend output is also normalized into shot-script cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 19:38:53 -05:00

330 lines
11 KiB
TypeScript

import { frameLength, runGeneration } from '~/server/utils/videoChain'
import { createShotQueue, finishQueueBurst, setQueueJob } from '~/server/utils/shotQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
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, ltx = false) {
const fallback = ltx ? 3.5 : (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, ltx = false) {
return SAMPLERS.has(raw || '') ? raw! : (ltx ? 'euler' : '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<string, string> = {}
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')
}
}
const workflow = parseVideoWorkflow(fields.workflow)
const textToVideo = isTextToVideo(workflow)
if (!image && !textToVideo) {
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 useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const { width, height } = resolveOutputSize(
fields.aspect,
image?.data || Buffer.alloc(0),
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, isLtxWorkflow(workflow))
const samplerName = parseSampler(fields.sampler_name, isLtxWorkflow(workflow))
const scheduler = parseScheduler(fields.scheduler)
const length = frameLength(durationSeconds, fps)
const hideThumbnail = fields.hideThumbnail === 'true'
const hideInput = fields.hideInput === 'true'
const sound = !isLtxWorkflow(workflow) && 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 = image
? await rememberInputStill({
ownerKey,
folderId,
filename: image.filename,
data: image.data,
width,
height,
hideInput
})
: null
const referenceStillIds: Array<string | null> = [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
const familyId = crypto.randomUUID()
const queueAutoRun = fields.queueAutoRun === 'true'
job.library = {
ownerKey,
folderId,
hideThumbnail,
hideInput,
folderLocked,
name: clipName,
prompt,
aspect: fields.aspect || 'auto',
width,
height,
steps,
turbo,
seed,
cfg,
fps,
samplerName,
scheduler,
thumb: image && !isPipelineFrameFilename(image.filename) ? image.data : undefined,
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,
workflow,
useIdentityRefs,
queueAutoRun: extensions.length ? queueAutoRun : false,
queueBudget: extensions.length && queueAutoRun ? extensions.length : 0
}
if (extensions.length) {
const queue = await createShotQueue({
ownerKey,
name: clipName,
familyId,
folderId,
autoRun: queueAutoRun,
stillId: still?.id,
stillFilename: still?.filename,
hideThumbnail,
hideInput,
aspect: fields.aspect || 'auto',
width,
height,
steps,
turbo,
cfg,
fps,
samplerName,
scheduler,
workflow,
sound,
useIdentityRefs,
referenceStillIds,
initial: { prompt, duration: durationSeconds },
extensions,
jobId: job.id
})
job.library.queueId = queue.id
setQueueJob(queue.id, job.id)
}
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 })
if (job.library.queueId) {
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true, 'Held because ComfyUI was busy').catch(() => null)
setQueueJob(job.library.queueId, null)
}
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 })
if (job.library?.queueId) {
await finishQueueBurst(job.library.ownerKey, job.library.queueId, false, message).catch(() => null)
setQueueJob(job.library.queueId, null)
}
})
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,
queueId: job.library?.queueId,
queuedRemaining: extensions.length
}
})