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, familyId: crypto.randomUUID(), parentClipId: source.id, chainIndex: (source.chainIndex || 0) + 1 } 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, 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, familyId: job.library.familyId, parentClipId: job.library.parentClipId, chainIndex: job.library.chainIndex }) } emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 }) await done }