278 lines
9.1 KiB
TypeScript
278 lines
9.1 KiB
TypeScript
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<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')
|
|
}
|
|
}
|
|
|
|
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 = isPipelineFrameFilename(image.filename)
|
|
? null
|
|
: await saveStill({
|
|
ownerKey,
|
|
folderId,
|
|
filename: image.filename,
|
|
data: image.data,
|
|
width,
|
|
height,
|
|
hideInput
|
|
})
|
|
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 saveStill({
|
|
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
|
|
}
|
|
})
|