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, false), 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 } })