138 lines
5.3 KiB
TypeScript
138 lines
5.3 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
|
import { persistLoraFields, parsePostedLoraStack, listStudioLoras } from '~/server/utils/loras'
|
|
import { readLoraStack } from '~/utils/loras'
|
|
import { clipPartDuration, clampVideoDuration } from '~/utils/videoDuration'
|
|
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
|
|
import { composePromptParts } from '~/utils/promptParts'
|
|
import { nextFamilyPartName } from '~/server/utils/library'
|
|
import {
|
|
clampRefinementDenoise,
|
|
parseRefineExtensionFrame,
|
|
parseSaveLosslessAnchor,
|
|
REFINEMENT_DENOISE_DEFAULT
|
|
} from '~/utils/extensionFrame'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
const body = await readBody<{
|
|
clipId?: string
|
|
prompt?: string
|
|
promptPre?: string
|
|
promptPost?: string
|
|
duration?: number
|
|
lora?: unknown
|
|
loraStack?: unknown
|
|
refineExtensionFrame?: boolean | string
|
|
saveLosslessAnchor?: boolean | string
|
|
refinementDenoise?: number | string
|
|
}>(event).catch(() => ({}))
|
|
const clipId = String(body?.clipId || '').trim()
|
|
const prompt = String(body?.prompt || '').trim()
|
|
if (!clipId) {
|
|
throw createError({ statusCode: 400, statusMessage: 'A clip is required to extend' })
|
|
}
|
|
|
|
const { owner } = assertLibraryOwner(event)
|
|
const source = getClip(owner, clipId)
|
|
assertFolderAccess(event, source.folderId)
|
|
const sourcePath = clipVideoPath(owner, source.id)
|
|
if (!existsSync(sourcePath)) {
|
|
throw createError({ statusCode: 404, statusMessage: 'Source video is missing' })
|
|
}
|
|
|
|
const destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId)
|
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
|
const parent = source.parentClipId
|
|
? (() => { try { return getClip(owner, source.parentClipId!) } catch { return null } })()
|
|
: null
|
|
const inherited = clipPartDuration(source, parent)
|
|
const durationSeconds = body?.duration == null
|
|
? inherited
|
|
: clampVideoDuration(body.duration, inherited)
|
|
const promptPre = body && Object.prototype.hasOwnProperty.call(body, 'promptPre')
|
|
? String(body.promptPre || '').trim()
|
|
: String(source.promptPre || '').trim()
|
|
const promptPost = body && Object.prototype.hasOwnProperty.call(body, 'promptPost')
|
|
? String(body.promptPost || '').trim()
|
|
: String(source.promptPost || '').trim()
|
|
if (!composePromptParts(promptPre, prompt, promptPost)) {
|
|
throw createError({ statusCode: 400, statusMessage: 'An extension prompt is required' })
|
|
}
|
|
const workflow = parseVideoWorkflow(source.workflow)
|
|
if (isLtxWorkflow(workflow) && !ltxWorkflowEnabled()) {
|
|
throw createError({ statusCode: 400, statusMessage: LTX_DISABLED_MESSAGE })
|
|
}
|
|
await listStudioLoras()
|
|
const postedStack = parsePostedLoraStack(body?.loraStack ?? body?.lora, 'video')
|
|
const loraStack = postedStack.length ? postedStack : parsePostedLoraStack(readLoraStack(source), 'video')
|
|
const familyId = source.familyId || crypto.randomUUID()
|
|
const turbo = source.turbo !== false
|
|
const steps = Number(source.steps || defaultVideoSteps(turbo, workflow))
|
|
const seed = Math.floor(Math.random() * 2_147_483_647)
|
|
const reservedNames = listStudioJobs(owner)
|
|
.filter(job => job.status === 'waiting' || job.status === 'running' || job.status === 'held')
|
|
.map(job => job.name)
|
|
.filter(Boolean)
|
|
|
|
const studio = await addStudioJob({
|
|
ownerKey: owner,
|
|
familyId,
|
|
payload: {
|
|
prompt,
|
|
promptMid: prompt,
|
|
promptPre,
|
|
promptPost,
|
|
name: nextFamilyPartName(owner, source, reservedNames),
|
|
folderId: source.folderId,
|
|
aspect: source.aspect || '16:9',
|
|
width: source.width || 1344,
|
|
height: source.height || 768,
|
|
steps,
|
|
turbo,
|
|
seed,
|
|
cfg: Number(source.cfg || 0),
|
|
fps: Number(source.fps || 24),
|
|
samplerName: source.samplerName || 'res_multistep',
|
|
scheduler: source.scheduler || 'simple',
|
|
duration: durationSeconds,
|
|
sound: source.sound !== false,
|
|
workflow,
|
|
useIdentityRefs: false,
|
|
hideThumbnail: source.hideThumbnail,
|
|
folderLocked,
|
|
referenceStillIds: [null, null, null, null],
|
|
extensions: [],
|
|
queueAutoRun: false,
|
|
extendFromClipId: clipId,
|
|
refineExtensionFrame: parseRefineExtensionFrame(body?.refineExtensionFrame, true),
|
|
saveLosslessAnchor: parseSaveLosslessAnchor(body?.saveLosslessAnchor, true),
|
|
refinementDenoise: clampRefinementDenoise(body?.refinementDenoise, REFINEMENT_DENOISE_DEFAULT),
|
|
globalLocks: source.globalLocks,
|
|
permanenceRefs: source.permanenceRefs,
|
|
...persistLoraFields(loraStack)
|
|
}
|
|
})
|
|
await kickStudioQueue()
|
|
let latest = listStudioJobs(owner).find(item => item.id === studio.id)
|
|
if (!latest?.liveJobId && !(await videoJobsBusy())) {
|
|
await kickStudioQueue()
|
|
latest = listStudioJobs(owner).find(item => item.id === studio.id)
|
|
}
|
|
const liveJobId = latest?.liveJobId || ''
|
|
|
|
return {
|
|
jobId: liveJobId || studio.id,
|
|
studioJobId: studio.id,
|
|
queued: !liveJobId,
|
|
clientId: '',
|
|
seed,
|
|
name: studio.name,
|
|
width: source.width || 1344,
|
|
height: source.height || 768,
|
|
steps,
|
|
duration: durationSeconds,
|
|
hideThumbnail: source.hideThumbnail,
|
|
folderLocked
|
|
}
|
|
})
|