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:
Towsty
2026-08-26 00:01:25 -05:00
co-authored by Cursor
parent 22d99f18db
commit f5c709924b
7 changed files with 653 additions and 79 deletions
+1 -1
View File
@@ -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
View File
@@ -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
}
}
}
+143 -4
View File
@@ -92,6 +92,132 @@ export async function probeHasAudio(filePath: string) {
}
}
const XFADE_DURATION = 0.12
async function probeDuration(filePath: string) {
const out = await runFfprobe([
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=nokey=1:noprint_wrappers=1',
filePath
])
const duration = Number(out)
if (!Number.isFinite(duration) || duration <= 0) {
throw new Error('Could not read video duration')
}
return duration
}
async function probeFps(filePath: string) {
const out = await runFfprobe([
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=avg_frame_rate,r_frame_rate',
'-of', 'default=nokey=1:noprint_wrappers=1',
filePath
])
const line = out.split(/\r?\n/).map(item => item.trim()).find(Boolean) || ''
const [num, den] = line.split('/').map(Number)
if (Number.isFinite(num) && Number.isFinite(den) && den > 0) return num / den
const fps = Number(line)
return Number.isFinite(fps) && fps > 0 ? fps : 24
}
async function probeSize(filePath: string) {
const out = await runFfprobe([
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0',
filePath
])
const [width, height] = out.split(',').map(Number)
return {
width: Number.isFinite(width) && width > 0 ? width : 1344,
height: Number.isFinite(height) && height > 0 ? height : 768
}
}
async function trimLeadingFrame(inputPath: string, outputPath: string) {
const fps = await probeFps(inputPath)
const hasAudio = await probeHasAudio(inputPath)
const args = [
'-y',
'-i', inputPath,
'-vf', 'trim=start_frame=1,setpts=PTS-STARTPTS'
]
if (hasAudio) {
args.push('-af', `atrim=start=${(1 / fps).toFixed(6)},asetpts=PTS-STARTPTS`, '-c:a', 'aac')
} else {
args.push('-an')
}
args.push(
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '17',
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
outputPath
)
await runFfmpeg(args, 180000)
if (!existsSync(outputPath)) {
throw new Error('FFmpeg did not write the trimmed extension segment')
}
}
async function xfadeStitch(part1Path: string, part2Path: string, outputPath: string) {
const [duration1, duration2, fps, size, audio1, audio2] = await Promise.all([
probeDuration(part1Path),
probeDuration(part2Path),
probeFps(part1Path),
probeSize(part1Path),
probeHasAudio(part1Path),
probeHasAudio(part2Path)
])
const fade = Math.min(
XFADE_DURATION,
Math.max(0.04, duration1 - 0.05),
Math.max(0.04, duration2 - 0.05)
)
const offset = Math.max(0, Number((duration1 - fade).toFixed(3)))
const fpsLabel = Number.isInteger(fps) ? String(fps) : fps.toFixed(3)
const prepare = `fps=${fpsLabel},scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p,setpts=PTS-STARTPTS`
const videoFilter = `[0:v]${prepare}[v0];[1:v]${prepare}[v1];[v0][v1]xfade=transition=fade:duration=${fade.toFixed(3)}:offset=${offset},setpts=PTS-STARTPTS[v]`
const encode = [
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '17',
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart'
]
if (audio1 && audio2) {
await runFfmpeg([
'-y',
'-i', part1Path,
'-i', part2Path,
'-filter_complex', `${videoFilter};[0:a]asetpts=PTS-STARTPTS[a0];[1:a]asetpts=PTS-STARTPTS[a1];[a0][a1]acrossfade=d=${fade.toFixed(3)}:c1=tri:c2=tri[a]`,
'-map', '[v]',
'-map', '[a]',
...encode,
'-c:a', 'aac',
outputPath
], 300000)
} else {
await runFfmpeg([
'-y',
'-i', part1Path,
'-i', part2Path,
'-filter_complex', videoFilter,
'-map', '[v]',
...encode,
outputPath
], 300000)
}
if (!existsSync(outputPath)) {
throw new Error('FFmpeg did not write the blended video')
}
}
export async function concatMp4(part1Path: string, part2Path: string, outputPath: string) {
const listPath = join(dirname(outputPath), 'concat_list.txt')
writeFileSync(listPath, `file '${concatPath(part1Path)}'\nfile '${concatPath(part2Path)}'\n`)
@@ -111,7 +237,7 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
'-map', '[a]',
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '18',
'-crf', '17',
'-pix_fmt', 'yuv420p',
'-c:a', 'aac',
'-movflags', '+faststart',
@@ -126,7 +252,7 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
'-map', '[v]',
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '18',
'-crf', '17',
'-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
outputPath
@@ -145,11 +271,24 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath
export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string }) {
const part2Path = join(params.tmpDir, 'part2.mp4')
const trimmedPath = join(params.tmpDir, 'part2_trim.mp4')
const finalPath = join(params.tmpDir, 'final_stitched_video.mp4')
writeFileSync(part2Path, params.part2)
await concatMp4(params.part1Path, part2Path, finalPath)
try {
await trimLeadingFrame(part2Path, trimmedPath)
await xfadeStitch(params.part1Path, trimmedPath, finalPath)
} catch (blendError) {
try {
const trimmedReady = existsSync(trimmedPath)
await concatMp4(params.part1Path, trimmedReady ? trimmedPath : part2Path, finalPath)
} catch (concatError) {
const blendMessage = blendError instanceof Error ? blendError.message : String(blendError)
const concatMessage = concatError instanceof Error ? concatError.message : String(concatError)
throw new Error(`Seam blend failed (${blendMessage}). Concat fallback also failed (${concatMessage}).`)
}
}
const stitched = readFileSync(finalPath)
for (const extra of [part2Path, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
for (const extra of [part2Path, trimmedPath, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) {
try { unlinkSync(extra) } catch { /* ignore */ }
}
return stitched
+20 -1
View File
@@ -20,6 +20,9 @@ export interface JobEvent {
folderLocked?: boolean
jobId?: string
draftId?: string
chainStep?: number
chainTotal?: number
chainLabel?: string
}
export interface Job {
@@ -61,12 +64,18 @@ export interface Job {
duration?: number
sound?: boolean
draftId?: string
extensions?: { prompt: string; duration: number }[]
chainIndex?: number
chainStep?: number
chainTotal?: number
chainLabel?: string
extendTmpDir?: string
extendPart1Path?: string
extendSourceClipId?: string
}
error?: string
socketReady?: boolean
segmentBuffer?: Buffer
events: JobEvent[]
listeners: Set<(event: JobEvent) => void>
}
@@ -121,7 +130,10 @@ export function jobSnapshot(job: Job) {
hideThumbnail: job.hideThumbnail,
error: job.error,
folderLocked: job.library?.folderLocked,
draftId: job.library?.draftId
draftId: job.library?.draftId,
chainStep: job.library?.chainStep,
chainTotal: job.library?.chainTotal,
chainLabel: job.library?.chainLabel
}
}
@@ -131,6 +143,13 @@ export function emitJob(job: Job, event: JobEvent) {
if (typeof event.progress === 'number') job.progress = event.progress
if (typeof event.step === 'number') job.step = event.step
if (typeof event.maxStep === 'number') job.maxStep = event.maxStep
if (typeof event.chainStep === 'number') {
if (job.library) job.library.chainStep = event.chainStep
}
if (typeof event.chainTotal === 'number') {
if (job.library) job.library.chainTotal = event.chainTotal
}
if (event.chainLabel && job.library) job.library.chainLabel = event.chainLabel
event.elapsedMs = Date.now() - job.startedAt
job.events.push(event)
if (job.events.length > 200) job.events.shift()
+25 -3
View File
@@ -57,6 +57,11 @@ export interface PublicFolder {
clipCount: number
}
export interface QueuedExtension {
prompt: string
duration: number
}
export interface RetryDraft {
id: string
createdAt: number
@@ -79,6 +84,7 @@ export interface RetryDraft {
fps?: number
samplerName?: string
scheduler?: string
extensions?: QueuedExtension[]
}
interface Catalog {
@@ -198,11 +204,15 @@ function ensureOwner(owner: string) {
function readCatalog(owner: string): Catalog {
ensureOwner(owner)
const path = catalogPath(owner)
try {
return normalizeCatalog(JSON.parse(readFileSync(catalogPath(owner), 'utf8')))
return normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
} catch {
if (existsSync(path)) {
throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' })
}
const created = emptyCatalog()
writeFileSync(catalogPath(owner), JSON.stringify(created, null, 2))
writeFileSync(path, JSON.stringify(created, null, 2))
return created
}
}
@@ -233,6 +243,16 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
return fromPrompt || 'Untitled clip'
}
export function nextClipPartName(name: string) {
const trimmed = (name || '').trim()
if (!trimmed) return ''
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
const base = (match ? match[1] : trimmed).trim() || trimmed
const part = match ? Number(match[2]) + 1 : 2
const suffix = ` p${part}`
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
}
export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 80) || 'aigen'
@@ -689,6 +709,7 @@ export async function saveRetryDraft(params: {
fps?: number
samplerName?: string
scheduler?: string
extensions?: QueuedExtension[]
}) {
return mutate(params.ownerKey, (catalog) => {
const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined
@@ -713,7 +734,8 @@ export async function saveRetryDraft(params: {
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler
scheduler: params.scheduler,
extensions: params.extensions?.length ? params.extensions : undefined
}
catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20)
return draft
+97 -16
View File
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import type { Job } from '~/server/utils/jobs'
import type { Job, JobEvent } from '~/server/utils/jobs'
function classifyError(message: string) {
const lower = message.toLowerCase()
@@ -19,13 +19,71 @@ function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function watchComfyJob(job: Job): Promise<void> {
function chainMeta(job: Job) {
const total = Math.max(1, job.library?.chainTotal || 1)
const index = job.library?.chainIndex ?? 0
return {
total,
index,
step: index + 1,
label: job.library?.chainLabel || (index === 0 ? 'Initial' : `Extension ${index}`),
chained: total > 1
}
}
export function mapChainProgress(job: Job, localPct: number) {
const { total, index, chained } = chainMeta(job)
const local = Math.max(0, Math.min(100, localPct))
if (!chained) return Math.round(local)
const share = 100 / total
return Math.min(99, Math.round(index * share + (Math.min(local, 99) / 100) * share))
}
export function formatChainMessage(job: Job, message: string, samplePct?: number) {
const { chained, step, total, label } = chainMeta(job)
if (!chained) return message
if (typeof samplePct === 'number') {
return `Step ${step}/${total}: Generating ${label} (${samplePct}%)`
}
if (/waiting 3s buffer/i.test(message)) {
return `Step ${step}/${total}: Waiting 3s buffer...`
}
return `Step ${step}/${total}: ${message}`
}
export function emitChainJob(job: Job, event: JobEvent, samplePct?: number) {
const { chained, step, total, label } = chainMeta(job)
const next: JobEvent = { ...event }
if (next.message) {
next.message = formatChainMessage(job, next.message, samplePct)
}
if (chained && typeof next.progress === 'number' && next.type !== 'complete') {
next.progress = mapChainProgress(job, next.progress)
}
if (chained) {
next.chainStep = step
next.chainTotal = total
next.chainLabel = label
}
emitJob(job, next)
}
export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise<void> {
const persist = options.persist !== false
job.socketReady = false
const ws = new WebSocket(comfyWsUrl(job.clientId))
let settled = false
let pollTimer: ReturnType<typeof setInterval> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
return new Promise((resolve) => {
let localProgress = 0
const emitLocal = (event: JobEvent, samplePct?: number) => {
if (typeof event.progress === 'number') localProgress = event.progress
emitChainJob(job, event, samplePct)
}
const cleanup = () => {
if (timeout) clearTimeout(timeout)
if (pollTimer) clearInterval(pollTimer)
@@ -40,7 +98,7 @@ export function watchComfyJob(job: Job): Promise<void> {
cleanup()
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
job.error = classifyError(error)
emitJob(job, { type: 'error', error: job.error, message: job.error })
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
if (job.id) deletePendingJob(job.id)
removeExtendTemp(job.library?.extendTmpDir)
resolve()
@@ -56,9 +114,9 @@ export function watchComfyJob(job: Job): Promise<void> {
try {
job.video = video
const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir)
emitJob(job, {
emitLocal({
type: 'status',
message: stitching ? 'Extracting last frame & stitching extension...' : 'Saving to library...',
message: stitching ? 'Extracting last frame & stitching extension...' : (persist ? 'Saving to library...' : 'Downloading segment...'),
progress: stitching ? 97 : 98
})
if (job.library) {
@@ -73,6 +131,23 @@ export function watchComfyJob(job: Job): Promise<void> {
tmpDir: job.library.extendTmpDir!
})
}
if (!persist) {
job.segmentBuffer = buffer
await purgeComfyArtifacts({
video,
imageName: job.library.imageName,
imageSubfolder: job.library.imageSubfolder,
promptId: job.promptId
})
deletePendingJob(job.id)
emitLocal({
type: 'status',
message: stitching ? 'Extension stitched' : 'Initial segment ready',
progress: 99
})
resolve()
return true
}
const clip = await saveClip({
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
@@ -111,22 +186,27 @@ export function watchComfyJob(job: Job): Promise<void> {
type: 'complete',
message: job.library?.folderLocked
? 'Saved to the locked folder. Unlock it to view.'
: (job.library?.extendSourceClipId ? 'Extended video ready' : 'Video ready'),
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
progress: 100,
filename: job.library?.folderLocked ? undefined : video.filename,
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
mediaType: job.library?.folderLocked ? undefined : video.type,
clipId: job.clipId,
hideThumbnail: job.hideThumbnail,
folderLocked: job.library?.folderLocked
folderLocked: job.library?.folderLocked,
chainStep: job.library?.chainStep,
chainTotal: job.library?.chainTotal,
chainLabel: job.library?.chainLabel
})
deletePendingJob(job.id)
} catch (saveError) {
removeExtendTemp(job.library?.extendTmpDir)
const message = saveError instanceof Error ? saveError.message : String(saveError)
job.status = 'error'
job.error = `Video generated but library save failed: ${message}`
emitJob(job, { type: 'error', error: job.error, message: job.error })
job.error = persist
? `Video generated but library save failed: ${message}`
: `Segment finished but could not be prepared: ${message}`
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
}
resolve()
return true
@@ -169,7 +249,7 @@ export function watchComfyJob(job: Job): Promise<void> {
ws.addEventListener('open', () => {
job.socketReady = true
emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: Math.max(job.progress, 4) })
emitLocal({ type: 'status', message: 'Connected to ComfyUI', progress: Math.max(localProgress, 4) })
})
ws.addEventListener('close', () => {
@@ -191,15 +271,16 @@ export function watchComfyJob(job: Job): Promise<void> {
const value = Number(data.value || 0)
const max = Number(data.max || 1)
const pct = 12 + Math.round((value / Math.max(max, 1)) * 70)
const samplePct = Math.round((value / Math.max(max, 1)) * 100)
job.status = 'running'
emitJob(job, {
emitLocal({
type: 'progress',
step: value,
maxStep: max,
progress: pct,
node: data.node ? String(data.node) : null,
message: `Sampling step ${value}/${max} (${Math.round((value / Math.max(max, 1)) * 100)}%)`
})
message: `Sampling step ${value}/${max} (${samplePct}%)`
}, samplePct)
}
if (payload.type === 'executing') {
@@ -207,16 +288,16 @@ export function watchComfyJob(job: Job): Promise<void> {
if (node === null) {
const saved = await succeed()
if (!saved) {
emitJob(job, { type: 'status', message: 'Waiting for ComfyUI to save the MP4...', progress: 96 })
emitLocal({ type: 'status', message: 'Waiting for ComfyUI to save the MP4...', progress: 96 })
}
return
}
const label = NODE_LABELS[node] || `Running node ${node}`
const encoding = isEncodingNode(node)
emitJob(job, {
emitLocal({
type: 'executing',
node,
progress: encoding ? 90 : Math.max(job.progress, 10),
progress: encoding ? 90 : Math.max(localProgress, 10),
message: encoding ? 'Encoding video...' : `${label}...`
})
}