Files
aigen/server/api/generate.post.ts
T
TowstyandCursor dbe45fd2ff Add generation quality controls and bind them to the MiniMax workflow.
Expose aspect, CFG, FPS, sampler, and scheduler in Generation settings, persist them on clips and held jobs, and inject those values into the Comfy graph. Keep the image picker stills-only and allow renaming library clips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 22:55:06 -05:00

282 lines
9.0 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))
}
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
function frameLength(seconds: number, fps: number) {
return Math.max(5, Math.floor(seconds * fps))
}
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<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 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 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 || '16:9',
width,
height,
steps,
turbo,
seed,
cfg,
fps,
samplerName,
scheduler,
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, cfg, fps, samplerName, scheduler }).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,
cfg: job.library.cfg,
fps: job.library.fps,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler
})
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 || '16:9',
cfg,
fps,
sampler_name: samplerName,
scheduler,
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
cfg: number
fps: number
samplerName: string
scheduler: string
}
) {
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,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
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
}