Files
aigen/server/api/generate.post.ts
T

289 lines
11 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs'
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { listStudioLoras, parsePostedLoraStack, parseShotLoraStacks, persistLoraFields } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import { clampVideoCfg } from '~/utils/generationPresets'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { allowIdentityRefs, normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
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; permanenceRefs?: PermanenceRef[]; loraName?: string; loraStack?: LoraStackItem[] }[]
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown; loraName?: unknown; loraStack?: unknown }) => {
const loraStack = parsePostedLoraStack(item?.loraStack ?? item?.loraName, 'video')
return {
prompt: String(item?.prompt || '').trim(),
duration: parseExtendDuration(item?.duration),
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs),
...persistLoraFields(loraStack)
}
})
.filter(item => item.prompt)
} catch (error) {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
return []
}
}
function parseFamilyPermanenceRefs(raw: string | undefined) {
if (!raw) return [] as PermanenceRef[]
try {
return normalizePermanenceRefs(JSON.parse(raw))
} catch {
return []
}
}
function parseShotPermanenceRefs(raw: string | undefined, shotCount: number) {
if (!raw) return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
return Array.from({ length: shotCount }, (_, index) => normalizePermanenceRefs(parsed[index]))
} catch {
return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
}
}
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)
return clampVideoCfg(raw, fallback)
}
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')
}
}
await listStudioLoras()
const workflow = parseVideoWorkflow(fields.workflow)
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
}
const textToVideo = isTextToVideo(workflow)
const ownerKey = libraryOwnerKey(event)
const postedStillId = (fields.stillId || '').trim()
let libraryStill: { id: string; filename: string } | null = null
if (postedStillId) {
const path = stillPath(ownerKey, postedStillId)
if (existsSync(path)) {
try {
const existing = getStill(ownerKey, postedStillId)
libraryStill = { id: existing.id, filename: existing.filename }
if (!image) {
image = {
filename: existing.filename || 'still.png',
data: readFileSync(path),
type: 'image/png'
}
}
} catch {
libraryStill = null
}
}
}
if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
}
const promptRaw = (fields.prompt || '').trim()
if (!promptRaw) {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
}
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptRaw)
const prompt = resolvedLocks.prompt
const globalLocks = resolvedLocks.locks
const extensions = parseExtensions(fields.extensions)
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
const shotPermanenceRefs = parseShotPermanenceRefs(fields.shotPermanenceRefs, 1 + extensions.length)
const defaultStack = parsePostedLoraStack(fields.loraStack || fields.lora, 'video')
const shotLoraStacks = parseShotLoraStacks(fields.shotLoraStacks || fields.shotLoras, 1 + extensions.length)
if (defaultStack.length && !shotLoraStacks[0].length) shotLoraStacks[0] = defaultStack
for (const [index, item] of extensions.entries()) {
if (!shotPermanenceRefs[index + 1]?.length && item.permanenceRefs?.length) {
shotPermanenceRefs[index + 1] = item.permanenceRefs
}
const shotStack = shotLoraStacks[index + 1]
if (!shotStack.length && item.loraStack?.length) shotLoraStacks[index + 1] = item.loraStack
else if (shotStack.length) Object.assign(item, persistLoraFields(shotStack))
}
const requestedIdentity = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const useIdentityRefs = allowIdentityRefs(requestedIdentity, permanenceRefs, globalLocks, shotPermanenceRefs)
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 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 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 = libraryStill || (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 familyId = crypto.randomUUID()
const queueAutoRun = fields.queueAutoRun === 'true'
const studio = await addStudioJob({
ownerKey,
familyId,
payload: {
prompt,
name: clipName,
folderId,
aspect: fields.aspect || 'auto',
width,
height,
steps,
turbo,
seed,
cfg,
fps,
samplerName,
scheduler,
duration: durationSeconds,
sound,
workflow,
useIdentityRefs,
stillId: still?.id,
stillFilename: still?.filename,
hideThumbnail,
hideInput,
folderLocked,
referenceStillIds,
extensions,
queueAutoRun,
globalLocks: globalLocks || undefined,
permanenceRefs: permanenceRefs.length ? permanenceRefs : undefined,
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined,
...persistLoraFields(defaultStack),
shotLoras: shotLoraStacks.some(stack => stack.length) ? shotLoraStacks.map(stack => stack[0]?.name || '') : undefined,
shotLoraStacks: shotLoraStacks.some(stack => stack.length) ? shotLoraStacks : undefined
}
})
await kickStudioQueue()
const latest = listStudioJobs(ownerKey).find(item => item.id === studio.id)
const liveJobId = latest?.liveJobId || ''
const queueId = latest?.shotQueueId
return {
jobId: liveJobId || studio.id,
studioJobId: studio.id,
queued: !liveJobId,
clientId: '',
seed,
width,
height,
aspect: fields.aspect || 'auto',
cfg,
fps,
sampler_name: samplerName,
scheduler,
steps,
turbo,
folderId,
hideThumbnail,
folderLocked,
chainTotal,
queueId,
queuedRemaining: extensions.length
}
})