214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
|
|
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
|
|
|
|
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 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 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
|
|
}
|
|
})
|
|
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
|
|
}
|
|
})
|