Files
aigen/server/api/extend.post.ts
T
towsty f4d8e1fb70 Let queued and player extensions use the full duration slider.
Stop clamping extensions to 3s or 5s so each segment can use the same length control as generate.
2026-08-26 00:38:50 -05:00

235 lines
6.9 KiB
TypeScript

import { copyFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
function parseExtendDuration(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 frameLength(seconds: number, fps: number) {
return Math.max(5, Math.floor(seconds * fps))
}
function parseFps(raw: unknown) {
const fps = Number(raw)
return fps === 12 || fps === 30 || fps === 24 ? fps : 24
}
function parseCfg(raw: unknown, 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: unknown) {
const value = String(raw || '')
return SAMPLERS.has(value) ? value : 'res_multistep'
}
function parseScheduler(raw: unknown) {
const value = String(raw || '')
return SCHEDULERS.has(value) ? value : 'simple'
}
export default defineEventHandler(async (event) => {
const body = await readBody<{ clipId?: string; prompt?: string; duration?: number }>(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' })
}
if (!prompt) {
throw createError({ statusCode: 400, statusMessage: 'An extension prompt is required' })
}
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 durationSeconds = parseExtendDuration(body?.duration)
const turbo = source.turbo !== false
const fps = parseFps(source.fps)
const cfg = parseCfg(source.cfg, turbo)
const samplerName = parseSampler(source.samplerName)
const scheduler = parseScheduler(source.scheduler)
const steps = Number(source.steps || (turbo ? 8 : 20))
const seed = Math.floor(Math.random() * 2_147_483_647)
const length = frameLength(durationSeconds, fps)
const width = source.width || 1344
const height = source.height || 768
const clipName = nextClipPartName(clipTitle(source))
const job = createJob()
job.maxStep = steps
job.hideThumbnail = source.hideThumbnail
const tmpDir = extendTempDir(owner, job.id)
const part1Path = join(tmpDir, 'part1.mp4')
const framePath = join(tmpDir, 'last_frame.png')
job.library = {
ownerKey: owner,
folderId: source.folderId,
hideThumbnail: source.hideThumbnail,
folderLocked,
name: clipName,
prompt,
aspect: source.aspect || '16:9',
width,
height,
steps,
turbo,
seed,
cfg,
fps,
samplerName,
scheduler,
duration: durationSeconds,
extendTmpDir: tmpDir,
extendPart1Path: part1Path,
extendSourceClipId: source.id
}
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
void runExtend(job, {
sourcePath,
part1Path,
framePath,
prompt,
width,
height,
steps,
seed,
turbo,
length,
cfg,
fps,
samplerName,
scheduler
}).catch((error) => {
removeExtendTemp(job.library?.extendTmpDir)
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,
steps,
duration: durationSeconds,
hideThumbnail: source.hideThumbnail,
folderLocked
}
})
async function runExtend(
job: ReturnType<typeof createJob>,
params: {
sourcePath: string
part1Path: string
framePath: string
prompt: string
width: number
height: number
steps: number
seed: number
turbo: boolean
length: number
cfg: number
fps: number
samplerName: string
scheduler: string
}
) {
copyFileSync(params.sourcePath, params.part1Path)
await extractLastFrame(params.part1Path, params.framePath)
const frame = readFileSync(params.framePath)
job.library!.thumb = frame
const sound = await probeHasAudio(params.part1Path)
await ensureComfyReady((status) => {
emitJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? Math.max(job.progress, 3) : Math.max(job.progress, 2),
busy: status.state === 'busy',
queueRunning: status.queueRunning,
queuePending: status.queuePending
})
})
const done = watchComfyJob(job)
job.status = 'uploading'
emitJob(job, { type: 'status', message: 'Uploading last frame to ComfyUI...', progress: 4 })
const uploaded = await uploadImage({ filename: 'last_frame.png', data: frame, type: 'image/png' }, job.id)
if (job.library) {
job.library.imageName = uploaded.name
job.library.imageSubfolder = uploaded.subfolder
}
emitJob(job, { type: 'status', message: 'Queueing extension on MiniMax H3...', progress: 6 })
await waitForComfySocket(job, 4000)
const graph = buildWorkflow({
prompt: params.prompt,
imageName: uploaded.name,
width: params.width,
height: params.height,
steps: params.steps,
seed: params.seed,
turbo: params.turbo,
length: params.length,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
filenamePrefix: comfyFilenamePrefix(),
sound
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
if (job.library && job.promptId) {
writePendingJob({
jobId: job.id,
promptId: job.promptId,
clientId: job.clientId,
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
hideThumbnail: job.library.hideThumbnail,
folderLocked: job.library.folderLocked,
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,
startedAt: job.startedAt,
imageName: job.library.imageName,
imageSubfolder: job.library.imageSubfolder,
extendTmpDir: job.library.extendTmpDir,
extendPart1Path: job.library.extendPart1Path
})
}
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
await done
}