Ship extension queues, library extend, and smoother seam stitching.
Users can pre-queue extensions, extend from the library with p2 titles, blend seams with xfade, and a unreadable catalog is no longer replaced with an empty one. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -68,7 +68,7 @@ export default defineEventHandler(async (event) => {
|
||||
const length = frameLength(durationSeconds, fps)
|
||||
const width = source.width || 1344
|
||||
const height = source.height || 768
|
||||
const clipName = `${clipTitle(source)} extended`.slice(0, 80)
|
||||
const clipName = nextClipPartName(clipTitle(source))
|
||||
|
||||
const job = createJob()
|
||||
job.maxStep = steps
|
||||
|
||||
+191
-37
@@ -1,3 +1,6 @@
|
||||
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
function isComfyBusyTimeout(error: unknown) {
|
||||
const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string }
|
||||
if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true
|
||||
@@ -11,6 +14,28 @@ function parseDuration(raw: string) {
|
||||
return Math.min(120, Math.max(0.5, seconds))
|
||||
}
|
||||
|
||||
function parseExtendDuration(raw: unknown) {
|
||||
const seconds = Number(raw)
|
||||
return seconds === 3 || seconds === 5 ? seconds : 5
|
||||
}
|
||||
|
||||
function parseExtensions(raw: string | undefined) {
|
||||
if (!raw) return [] as { prompt: string; duration: number }[]
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.slice(0, 8)
|
||||
.map((item: { prompt?: unknown; duration?: unknown }) => ({
|
||||
prompt: String(item?.prompt || '').trim(),
|
||||
duration: parseExtendDuration(item?.duration)
|
||||
}))
|
||||
.filter(item => item.prompt)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
||||
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
||||
|
||||
@@ -39,6 +64,19 @@ 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) {
|
||||
@@ -62,6 +100,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!prompt) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
|
||||
}
|
||||
const extensions = parseExtensions(fields.extensions)
|
||||
|
||||
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
||||
const turbo = fields.turbo !== 'false'
|
||||
@@ -100,6 +139,7 @@ export default defineEventHandler(async (event) => {
|
||||
hideInput
|
||||
})
|
||||
|
||||
const chainTotal = 1 + extensions.length
|
||||
const job = createJob()
|
||||
job.maxStep = steps
|
||||
job.hideThumbnail = hideThumbnail
|
||||
@@ -126,11 +166,32 @@ export default defineEventHandler(async (event) => {
|
||||
stillFilename: still.filename,
|
||||
duration: durationSeconds,
|
||||
sound,
|
||||
draftId: (fields.draftId || '').trim() || undefined
|
||||
draftId: (fields.draftId || '').trim() || undefined,
|
||||
extensions,
|
||||
chainIndex: 0,
|
||||
chainStep: 1,
|
||||
chainTotal,
|
||||
chainLabel: extensions.length ? 'Initial' : undefined
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||
|
||||
void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound, cfg, fps, samplerName, scheduler }).catch(async (error) => {
|
||||
void runGeneration(job, {
|
||||
prompt,
|
||||
image,
|
||||
width,
|
||||
height,
|
||||
steps,
|
||||
seed,
|
||||
turbo,
|
||||
length,
|
||||
sound,
|
||||
cfg,
|
||||
fps,
|
||||
samplerName,
|
||||
scheduler,
|
||||
extensions
|
||||
}).catch(async (error) => {
|
||||
removeExtendTemp(job.library?.extendTmpDir)
|
||||
if (isComfyBusyTimeout(error) && job.library) {
|
||||
try {
|
||||
const draft = await saveRetryDraft({
|
||||
@@ -154,7 +215,8 @@ export default defineEventHandler(async (event) => {
|
||||
cfg: job.library.cfg,
|
||||
fps: job.library.fps,
|
||||
samplerName: job.library.samplerName,
|
||||
scheduler: job.library.scheduler
|
||||
scheduler: job.library.scheduler,
|
||||
extensions: job.library.extensions
|
||||
})
|
||||
job.library.draftId = draft.id
|
||||
job.status = 'deferred'
|
||||
@@ -165,6 +227,7 @@ export default defineEventHandler(async (event) => {
|
||||
// fall through to a normal error if the held job could not be saved
|
||||
}
|
||||
}
|
||||
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'deferred') return
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
job.status = 'error'
|
||||
job.error = message
|
||||
@@ -186,48 +249,53 @@ export default defineEventHandler(async (event) => {
|
||||
turbo,
|
||||
folderId,
|
||||
hideThumbnail,
|
||||
folderLocked
|
||||
folderLocked,
|
||||
chainTotal
|
||||
}
|
||||
})
|
||||
|
||||
async function runGeneration(
|
||||
job: ReturnType<typeof createJob>,
|
||||
params: {
|
||||
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
|
||||
}
|
||||
) {
|
||||
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, 1),
|
||||
busy: status.state === 'busy',
|
||||
queueRunning: status.queueRunning,
|
||||
queuePending: status.queuePending
|
||||
})
|
||||
})
|
||||
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 }[]
|
||||
}
|
||||
|
||||
const done = watchComfyJob(job)
|
||||
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'
|
||||
emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 })
|
||||
const uploading = (job.library?.chainIndex || 0) > 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)
|
||||
if (job.library) {
|
||||
job.library.imageName = uploaded.name
|
||||
job.library.imageSubfolder = uploaded.subfolder
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||
emitChainJob(job, { type: 'status', message: queueing, progress: 6 })
|
||||
await waitForComfySocket(job, 4000)
|
||||
|
||||
const graph = buildWorkflow({
|
||||
@@ -278,6 +346,92 @@ async function runGeneration(
|
||||
extendPart1Path: job.library.extendPart1Path
|
||||
})
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||
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
|
||||
|
||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||
await sleep(3000)
|
||||
assertJobActive(job)
|
||||
|
||||
copyFileSync(currentPath, part1Path)
|
||||
await extractLastFrame(part1Path, framePath)
|
||||
const frame = readFileSync(framePath)
|
||||
job.library.extendPart1Path = part1Path
|
||||
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: { 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
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user