Keep queued extension chains running after refresh instead of saving the first clip as final.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 08:42:12 -05:00
co-authored by Cursor
parent 72f4909815
commit 6fec10febd
6 changed files with 524 additions and 300 deletions
+7 -245
View File
@@ -1,5 +1,4 @@
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { frameLength, runGeneration } from '~/server/utils/videoChain'
function isComfyBusyTimeout(error: unknown) {
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
@@ -37,10 +36,6 @@ function parseExtensions(raw: string | undefined) {
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
function frameLength(seconds: number, fps: number) {
return Math.max(5, Math.floor(seconds * fps))
}
function parseFps(raw: string | undefined) {
const fps = Number(raw)
return fps === 12 || fps === 30 || fps === 24 ? fps : 24
@@ -62,19 +57,6 @@ function parseScheduler(raw: string | undefined) {
return SCHEDULERS.has(raw || '') ? raw! : 'simple'
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function assertJobActive(job: ReturnType<typeof createJob>) {
if (job.status === 'cancelled') {
throw new Error('Job interrupted.')
}
if (job.status === 'error') {
throw new Error(job.error || 'Generation failed')
}
}
export default defineEventHandler(async (event) => {
const form = await readMultipartFormData(event)
if (!form?.length) {
@@ -152,10 +134,11 @@ export default defineEventHandler(async (event) => {
height,
hideInput
})
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
await saveStill({
const saved = await saveStill({
ownerKey,
folderId,
filename: ref.filename || `identity-ref-${index + 2}.png`,
@@ -164,6 +147,7 @@ export default defineEventHandler(async (event) => {
height,
hideInput
})
if (saved?.id) referenceStillIds[index] = saved.id
}
}
@@ -193,6 +177,7 @@ export default defineEventHandler(async (event) => {
thumb: isPipelineFrameFilename(image.filename) ? undefined : image.data,
stillId: still?.id,
stillFilename: still?.filename,
referenceStillIds,
duration: durationSeconds,
sound,
draftId: (fields.draftId || '').trim() || undefined,
@@ -202,7 +187,8 @@ export default defineEventHandler(async (event) => {
chainTotal,
chainLabel: extensions.length ? 'Initial' : undefined,
familyId: crypto.randomUUID(),
workflow
workflow,
useIdentityRefs
}
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
@@ -289,227 +275,3 @@ export default defineEventHandler(async (event) => {
chainTotal
}
})
type GenerateParams = {
prompt: string
image: { filename: string; data: Buffer; type?: string }
width: number
height: number
steps: number
seed: number
turbo: boolean
length: number
sound: boolean
cfg: number
fps: number
samplerName: string
scheduler: string
extensions: { prompt: string; duration: number }[]
workflow: 'v1' | 'v2'
duration: number
useIdentityRefs: boolean
referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null>
}
async function queueMiniMax(
job: ReturnType<typeof createJob>,
params: Omit<GenerateParams, 'extensions'> & { persist: boolean }
) {
assertJobActive(job)
job.socketReady = false
job.promptId = undefined
job.video = undefined
job.segmentBuffer = undefined
const done = watchComfyJob(job, { persist: params.persist })
job.status = 'uploading'
const chainIndex = job.library?.chainIndex || 0
const uploading = params.useIdentityRefs
? (chainIndex > 0 ? 'Uploading identity stills for next shot...' : 'Uploading image to ComfyUI...')
: (chainIndex > 0 ? 'Uploading last frame to ComfyUI...' : 'Uploading image to ComfyUI...')
const queueing = (job.library?.chainIndex || 0) > 0
? 'Queueing extension on MiniMax H3...'
: 'Queueing MiniMax H3 job...'
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
const uploaded = await uploadImage(params.image, job.id)
const referenceNames: string[] = ['', '', '', '']
if (params.useIdentityRefs) {
for (const [index, ref] of (params.referenceImages || []).entries()) {
if (!ref?.data?.length) continue
const next = await uploadImage({
...ref,
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
}, job.id)
referenceNames[index] = next.name
}
}
if (job.library) {
job.library.imageName = uploaded.name
job.library.imageSubfolder = uploaded.subfolder
job.library.referenceImageNames = referenceNames.filter(Boolean)
}
emitChainJob(job, { type: 'status', message: queueing, 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: params.sound,
workflow: params.workflow,
duration: params.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImageNames: params.useIdentityRefs ? referenceNames : []
})
const queued = await queuePrompt(graph, job.clientId)
job.promptId = queued.prompt_id
job.status = 'running'
if (job.library?.draftId) {
await deleteRetryDraft(job.library.ownerKey, job.library.draftId).catch(() => null)
job.library.draftId = undefined
}
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,
stillId: job.library.stillId,
sound: job.library.sound,
referenceImageNames: job.library.referenceImageNames
})
}
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
await done
assertJobActive(job)
if (!params.persist && !job.segmentBuffer?.length) {
throw new Error('Segment finished without a video')
}
}
async function runGeneration(
job: ReturnType<typeof createJob>,
params: GenerateParams
) {
const ready = (status: { state: string; message: string; queueRunning?: number; queuePending?: number }) => {
emitChainJob(job, {
type: status.state === 'busy' ? 'busy' : 'status',
message: status.message,
progress: status.state === 'online' ? 3 : 1,
busy: status.state === 'busy',
queueRunning: status.queueRunning,
queuePending: status.queuePending
})
}
await ensureComfyReady(ready)
const { extensions, ...base } = params
await queueMiniMax(job, {
...base,
persist: extensions.length === 0
})
if (!extensions.length || !job.library) return
const tmpDir = extendTempDir(job.library.ownerKey, job.id)
job.library.extendTmpDir = tmpDir
const currentPath = join(tmpDir, 'current.mp4')
const part1Path = join(tmpDir, 'part1.mp4')
const framePath = join(tmpDir, 'last_frame.png')
writeFileSync(currentPath, job.segmentBuffer!)
job.segmentBuffer = undefined
for (let i = 0; i < extensions.length; i++) {
assertJobActive(job)
const ext = extensions[i]
const isLast = i === extensions.length - 1
job.library.chainIndex = i + 1
job.library.chainStep = i + 2
job.library.chainLabel = `Extension ${i + 1}`
job.library.extendPart1Path = undefined
job.library.thumb = undefined
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
await sleep(3000)
assertJobActive(job)
copyFileSync(currentPath, part1Path)
try {
await extractLastFrame(part1Path, framePath)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(detail.includes('last frame')
? detail
: `Could not extract the last frame for the next extension: ${detail}`)
}
const frame = readFileSync(framePath)
job.library.extendPart1Path = part1Path
job.library.prompt = ext.prompt
const sound = await probeHasAudio(currentPath)
const seed = Math.floor(Math.random() * 2_147_483_647)
await ensureComfyReady(ready)
await queueMiniMax(job, {
prompt: ext.prompt,
image: params.useIdentityRefs
? params.image
: { filename: 'last_frame.png', data: frame, type: 'image/png' },
width: params.width,
height: params.height,
steps: params.steps,
seed,
turbo: params.turbo,
length: frameLength(ext.duration, params.fps),
sound,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
persist: isLast,
workflow: params.workflow,
duration: ext.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImages: params.useIdentityRefs ? params.referenceImages : []
})
if (!isLast) {
if (!job.segmentBuffer?.length) {
throw new Error('Extension finished without a stitched video')
}
writeFileSync(currentPath, job.segmentBuffer)
job.segmentBuffer = undefined
job.library.extendPart1Path = undefined
}
}
}