Keep remaining multi-shot clips on a Queue page so prompts can be edited between shots, with process-next or overnight run-all.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 14:52:03 -05:00
co-authored by Cursor
parent d50db8e180
commit 73c4356e08
16 changed files with 1049 additions and 25 deletions
+11 -1
View File
@@ -25,6 +25,8 @@ export interface JobEvent {
chainTotal?: number
chainLabel?: string
overallProgress?: number
queuedRemaining?: number
queueId?: string
}
export interface Job {
@@ -86,6 +88,10 @@ export interface Job {
workflow?: 'v1' | 'v2'
chainContinuing?: boolean
passes?: { prompt: string }[]
queueId?: string
queueAutoRun?: boolean
queueBudget?: number
stopAfterCurrent?: boolean
}
error?: string
socketReady?: boolean
@@ -181,7 +187,11 @@ export function jobSnapshot(job: Job) {
draftId: job.library?.draftId,
chainStep: job.library?.chainStep,
chainTotal: job.library?.chainTotal,
chainLabel: job.library?.chainLabel
chainLabel: job.library?.chainLabel,
queueId: job.library?.queueId,
queuedRemaining: job.library?.queueId
? Math.max(0, (job.library.chainTotal || 1) - (job.library.chainStep || 1))
: undefined
}
}
+10 -1
View File
@@ -45,6 +45,9 @@ export interface PendingJob {
extensions?: { prompt: string; duration: number }[]
remainingExtensions?: { prompt: string; duration: number }[]
currentClipId?: string
queueId?: string
queueAutoRun?: boolean
queueBudget?: number
}
function pendingRoot() {
@@ -127,6 +130,9 @@ export function pendingFromJob(job: Job, overrides: Partial<PendingJob> = {}): P
extensions: library.extensions,
remainingExtensions: remaining,
currentClipId: job.clipId,
queueId: library.queueId,
queueAutoRun: library.queueAutoRun,
queueBudget: library.queueBudget,
...overrides
}
}
@@ -168,7 +174,10 @@ export function libraryFromPending(pending: PendingJob): NonNullable<Job['librar
useIdentityRefs: pending.useIdentityRefs,
workflow: pending.workflow,
duration: pending.duration,
extensions: pending.extensions || pending.remainingExtensions
extensions: pending.extensions || pending.remainingExtensions,
queueId: pending.queueId,
queueAutoRun: pending.queueAutoRun,
queueBudget: pending.queueBudget
}
}
+400
View File
@@ -0,0 +1,400 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { getJob, type Job } from '~/server/utils/jobs'
export type ShotQueueStatus = 'idle' | 'running' | 'paused' | 'complete' | 'error'
export type ShotSegmentStatus = 'pending' | 'running' | 'complete' | 'error'
export interface ShotQueueSegment {
index: number
prompt: string
duration: number
status: ShotSegmentStatus
clipId?: string
error?: string
}
export interface ShotQueue {
id: string
name: string
familyId: string
folderId: string
createdAt: number
updatedAt: number
status: ShotQueueStatus
autoRun: boolean
currentClipId?: string
currentJobId?: string
stillId?: string
stillFilename?: string
hideThumbnail: boolean
hideInput?: boolean
aspect: string
width: number
height: number
steps: number
turbo: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
workflow?: 'v1' | 'v2'
sound?: boolean
useIdentityRefs?: boolean
referenceStillIds?: Array<string | null>
segments: ShotQueueSegment[]
lastError?: string
}
const writeChains = new Map<string, Promise<unknown>>()
const activeJobs = new Map<string, string>()
function libraryRoot() {
const config = useRuntimeConfig()
return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '')
}
function queuesPath(owner: string) {
return join(libraryRoot(), 'users', owner, 'shot-queues.json')
}
function ensureOwner(owner: string) {
mkdirSync(join(libraryRoot(), 'users', owner), { recursive: true })
}
function readQueues(owner: string): ShotQueue[] {
ensureOwner(owner)
const path = queuesPath(owner)
if (!existsSync(path)) return []
try {
const parsed = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function writeQueues(owner: string, queues: ShotQueue[]) {
ensureOwner(owner)
const path = queuesPath(owner)
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(queues, null, 2))
renameSync(tmp, path)
}
function mutate<T>(owner: string, fn: (queues: ShotQueue[]) => T): Promise<T> {
const prev = writeChains.get(owner) || Promise.resolve()
const run = prev.then(() => {
const queues = readQueues(owner)
const result = fn(queues)
writeQueues(owner, queues)
return result
})
writeChains.set(owner, run.then(() => undefined, () => undefined))
return run
}
export function pendingSegmentCount(queue: ShotQueue) {
return queue.segments.filter(segment => segment.status === 'pending' || segment.status === 'error').length
}
export function completedSegmentCount(queue: ShotQueue) {
return queue.segments.filter(segment => segment.status === 'complete').length
}
export function summarizeQueue(queue: ShotQueue) {
return {
...queue,
pendingCount: pendingSegmentCount(queue),
completedCount: completedSegmentCount(queue),
totalCount: queue.segments.length
}
}
export function listShotQueues(owner: string) {
return readQueues(owner)
.slice()
.sort((a, b) => b.updatedAt - a.updatedAt)
}
export function getShotQueue(owner: string, id: string) {
return readQueues(owner).find(queue => queue.id === id) || null
}
export function queueIsProcessing(id: string) {
return activeJobs.has(id)
}
export function setQueueJob(id: string, jobId: string | null) {
if (jobId) activeJobs.set(id, jobId)
else activeJobs.delete(id)
}
export function getQueueJobId(id: string) {
return activeJobs.get(id)
}
export async function createShotQueue(params: {
ownerKey: string
name?: string
familyId: string
folderId: string
autoRun: boolean
stillId?: string
stillFilename?: string
hideThumbnail: boolean
hideInput?: boolean
aspect: string
width: number
height: number
steps: number
turbo: boolean
cfg?: number
fps?: number
samplerName?: string
scheduler?: string
workflow?: 'v1' | 'v2'
sound?: boolean
useIdentityRefs?: boolean
referenceStillIds?: Array<string | null>
initial: { prompt: string; duration: number }
extensions: { prompt: string; duration: number }[]
jobId?: string
}): Promise<ShotQueue> {
const now = Date.now()
const queue: ShotQueue = {
id: crypto.randomUUID(),
name: (params.name || '').trim() || params.initial.prompt.slice(0, 80),
familyId: params.familyId,
folderId: params.folderId,
createdAt: now,
updatedAt: now,
status: 'running',
autoRun: params.autoRun,
currentJobId: params.jobId,
stillId: params.stillId,
stillFilename: params.stillFilename,
hideThumbnail: params.hideThumbnail,
hideInput: params.hideInput,
aspect: params.aspect,
width: params.width,
height: params.height,
steps: params.steps,
turbo: params.turbo,
cfg: params.cfg,
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
workflow: params.workflow,
sound: params.sound,
useIdentityRefs: params.useIdentityRefs,
referenceStillIds: params.referenceStillIds,
segments: [
{ index: 0, prompt: params.initial.prompt, duration: params.initial.duration, status: 'running' },
...params.extensions.map((item, index) => ({
index: index + 1,
prompt: item.prompt,
duration: item.duration,
status: 'pending' as const
}))
]
}
await mutate(params.ownerKey, (queues) => {
queues.unshift(queue)
return queue
})
if (params.jobId) setQueueJob(queue.id, params.jobId)
return queue
}
export async function updateShotQueue(owner: string, id: string, patch: (queue: ShotQueue) => void) {
return mutate(owner, (queues) => {
const queue = queues.find(item => item.id === id)
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
patch(queue)
queue.updatedAt = Date.now()
return structuredClone(queue)
})
}
export async function patchShotQueueSegments(
owner: string,
id: string,
segments: Array<{ index: number; prompt?: string; duration?: number }>
) {
return updateShotQueue(owner, id, (queue) => {
if (queue.status === 'complete') {
throw createError({ statusCode: 409, statusMessage: 'This batch is already finished' })
}
for (const next of segments) {
const segment = queue.segments.find(item => item.index === next.index)
if (!segment) continue
if (segment.status === 'complete' || segment.status === 'running') continue
if (typeof next.prompt === 'string') {
const prompt = next.prompt.trim()
if (!prompt) throw createError({ statusCode: 400, statusMessage: `Shot ${next.index + 1} needs a prompt` })
segment.prompt = prompt
}
if (typeof next.duration === 'number' && Number.isFinite(next.duration)) {
segment.duration = Math.min(120, Math.max(0.5, Math.round(next.duration * 10) / 10))
}
}
})
}
export async function markQueueSegment(owner: string, id: string, index: number, status: ShotSegmentStatus, extra: Partial<ShotQueueSegment> = {}) {
return updateShotQueue(owner, id, (queue) => {
const segment = queue.segments.find(item => item.index === index)
if (!segment) return
segment.status = status
if (extra.clipId) segment.clipId = extra.clipId
if (extra.error) segment.error = extra.error
else if (status !== 'error') delete segment.error
if (extra.clipId) queue.currentClipId = extra.clipId
if (status === 'complete' && pendingSegmentCount(queue) === 0) {
queue.status = 'complete'
queue.autoRun = false
queue.currentJobId = undefined
}
})
}
export async function syncQueueFromJob(job: Job, clipId?: string) {
const library = job.library
if (!library?.queueId) return null
const index = library.chainIndex || 0
const failed = job.status === 'error' || job.status === 'cancelled'
if (failed) {
return updateShotQueue(library.ownerKey, library.queueId, (queue) => {
const segment = queue.segments.find(item => item.index === index)
if (segment && segment.status !== 'complete') {
segment.status = 'error'
segment.error = job.error || 'Generation failed'
}
queue.status = job.status === 'cancelled' ? 'paused' : 'error'
queue.lastError = job.error
queue.currentJobId = undefined
queue.autoRun = job.status === 'cancelled' ? false : queue.autoRun
}).catch(() => null)
}
if (!clipId) return getShotQueue(library.ownerKey, library.queueId)
return markQueueSegment(library.ownerKey, library.queueId, index, 'complete', { clipId })
}
export async function prepareQueueBurst(owner: string, id: string, count: number | 'all') {
return mutate(owner, (queues) => {
const queue = queues.find(item => item.id === id)
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
if (activeJobs.has(queue.id)) {
throw createError({ statusCode: 409, statusMessage: 'This batch is already processing' })
}
const remaining = queue.segments.filter(segment => segment.status === 'pending' || segment.status === 'error')
if (!remaining.length) {
throw createError({ statusCode: 400, statusMessage: 'No remaining shots to process' })
}
const initial = queue.segments[0]
if (initial && initial.status !== 'complete') {
throw createError({ statusCode: 409, statusMessage: 'The first shot is still running or did not save. Wait for it, or generate again.' })
}
if (!queue.currentClipId) {
throw createError({ statusCode: 409, statusMessage: 'The previous clip is missing, so the next shot cannot start' })
}
const n = count === 'all' ? remaining.length : Math.min(remaining.length, Math.max(1, Math.floor(count)))
remaining.slice(0, n).forEach((segment) => {
segment.status = segment.index === remaining[0].index ? 'running' : 'pending'
delete segment.error
})
queue.autoRun = count === 'all'
queue.status = 'running'
queue.lastError = undefined
queue.updatedAt = Date.now()
return { queue: structuredClone(queue), count: n }
})
}
export async function finishQueueBurst(owner: string, id: string, paused: boolean, error?: string) {
return updateShotQueue(owner, id, (queue) => {
queue.currentJobId = undefined
for (const segment of queue.segments) {
if (segment.status !== 'running') continue
if (error) {
segment.status = 'error'
segment.error = error
} else {
segment.status = 'pending'
delete segment.error
}
}
if (error) {
queue.status = 'error'
queue.lastError = error
queue.autoRun = false
return
}
if (pendingSegmentCount(queue) === 0) {
queue.status = 'complete'
queue.autoRun = false
return
}
queue.status = paused || !queue.autoRun ? 'paused' : 'idle'
if (paused) queue.autoRun = false
})
}
export async function pauseShotQueue(owner: string, id: string) {
const jobId = activeJobs.get(id)
if (jobId) {
const job = getJob(jobId)
if (job?.library) job.library.stopAfterCurrent = true
if (job?.library) job.library.queueAutoRun = false
}
return updateShotQueue(owner, id, (queue) => {
queue.autoRun = false
if (queue.status === 'running') {
queue.lastError = undefined
} else if (queue.status !== 'complete') {
queue.status = 'paused'
}
})
}
export async function deleteShotQueue(owner: string, id: string) {
if (activeJobs.has(id)) {
throw createError({ statusCode: 409, statusMessage: 'Stop this batch before deleting it' })
}
return mutate(owner, (queues) => {
const index = queues.findIndex(item => item.id === id)
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
const [removed] = queues.splice(index, 1)
return removed
})
}
export function listOwnersWithQueues() {
const root = join(libraryRoot(), 'users')
if (!existsSync(root)) return []
return readdirSync(root, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.filter(owner => existsSync(queuesPath(owner)))
}
export function remainingFromQueue(queue: ShotQueue): { prompt: string; duration: number }[] {
return queue.segments
.filter(segment => segment.index > 0)
.map(segment => ({ prompt: segment.prompt, duration: segment.duration }))
}
export function lastCompletedIndex(queue: ShotQueue) {
const done = queue.segments.filter(segment => segment.status === 'complete')
if (!done.length) return -1
return Math.max(...done.map(segment => segment.index))
}
export function liveSegmentPrompt(queue: ShotQueue, extensionIndex: number) {
const segment = queue.segments.find(item => item.index === extensionIndex + 1)
return {
prompt: segment?.prompt || '',
duration: segment?.duration || 5
}
}
+171 -11
View File
@@ -1,13 +1,23 @@
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Job } from '~/server/utils/jobs'
import { createJob, emitJob, type Job } from '~/server/utils/jobs'
import { pendingFromJob, remainingAfterCurrentShot, writePendingJob } from '~/server/utils/pending'
import { emitChainJob, waitForComfySocket, watchComfyJob } from '~/server/utils/watch'
import { comfyFilenamePrefix, queuePrompt, uploadImage } from '~/server/utils/comfy'
import { buildWorkflow } from '~/server/utils/workflow'
import { clipVideoPath, deleteRetryDraft, extendTempDir, stillPath } from '~/server/utils/library'
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import {
finishQueueBurst,
getShotQueue,
lastCompletedIndex,
liveSegmentPrompt,
prepareQueueBurst,
remainingFromQueue,
setQueueJob,
updateShotQueue
} from '~/server/utils/shotQueue'
export type ChainImage = { filename: string; data: Buffer; type?: string }
@@ -202,11 +212,17 @@ function seedCurrentVideo(job: Job) {
return { tmpDir, currentPath, part1Path: join(tmpDir, 'part1.mp4'), framePath: join(tmpDir, 'last_frame.png') }
}
export async function continueQueuedExtensions(job: Job, params: VideoChainParams) {
export async function continueQueuedExtensions(
job: Job,
params: VideoChainParams,
options: { maxShots?: number; autoRun?: boolean } = {}
) {
const extensions = params.extensions || []
if (!extensions.length || !job.library) return
if (job.library.chainContinuing) return
job.library.chainContinuing = true
const autoRun = options.autoRun === true || job.library.queueAutoRun === true
let shotsLeft = options.maxShots ?? (autoRun ? extensions.length : (job.library.queueBudget || 0))
const ready = (status: { state: string; message: string; queueRunning?: number; queuePending?: number }) => {
emitChainJob(job, {
@@ -220,18 +236,49 @@ export async function continueQueuedExtensions(job: Job, params: VideoChainParam
}
try {
if (shotsLeft <= 0) return
const { currentPath, part1Path, framePath } = seedCurrentVideo(job)
const startFrom = job.library.chainIndex || 0
for (let i = startFrom; i < extensions.length; i++) {
if (shotsLeft <= 0) break
assertJobActive(job)
const ext = extensions[i]
const isLast = i === extensions.length - 1
if (job.library.stopAfterCurrent && i > startFrom) break
const liveQueue = job.library.queueId
? getShotQueue(job.library.ownerKey, job.library.queueId)
: null
const live = liveQueue ? liveSegmentPrompt(liveQueue, i) : extensions[i]
const ext = {
prompt: live.prompt || extensions[i].prompt,
duration: live.duration || extensions[i].duration
}
if (!ext.prompt.trim()) {
throw new Error(`Shot ${i + 2} needs a prompt`)
}
const remainingAfter = extensions.length - (i + 1)
const lastOfChain = remainingAfter === 0
const lastOfBurst = !autoRun && shotsLeft === 1
const persist = lastOfChain || lastOfBurst || job.library.stopAfterCurrent === true
job.library.chainIndex = i + 1
job.library.chainStep = i + 2
job.library.chainLabel = `Extension ${i + 1}`
job.library.extendPart1Path = undefined
job.library.thumb = undefined
job.library.duration = ext.duration
if (liveQueue) {
await updateShotQueue(job.library.ownerKey, liveQueue.id, (queue) => {
const segment = queue.segments.find(item => item.index === i + 1)
if (segment) {
segment.status = 'running'
delete segment.error
}
queue.status = 'running'
queue.currentJobId = job.id
}).catch(() => null)
}
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
await sleep(3000)
@@ -269,14 +316,17 @@ export async function continueQueuedExtensions(job: Job, params: VideoChainParam
fps: params.fps,
samplerName: params.samplerName,
scheduler: params.scheduler,
persist: isLast,
persist,
workflow: params.workflow,
duration: ext.duration,
useIdentityRefs: params.useIdentityRefs,
referenceImages: params.useIdentityRefs ? params.referenceImages : []
})
if (!isLast) {
shotsLeft -= 1
if (job.library) job.library.queueBudget = shotsLeft
if (!lastOfChain && !persist) {
if (!job.segmentBuffer?.length) {
throw new Error('Extension finished without a stitched video')
}
@@ -284,9 +334,22 @@ export async function continueQueuedExtensions(job: Job, params: VideoChainParam
job.segmentBuffer = undefined
job.library.extendPart1Path = undefined
}
if (persist && remainingAfter > 0) break
if (job.library.stopAfterCurrent) break
}
} finally {
if (job.library) job.library.chainContinuing = false
if (job.library?.queueId) {
const paused = job.status !== 'error' && job.status !== 'cancelled'
await finishQueueBurst(
job.library.ownerKey,
job.library.queueId,
paused && !job.library.queueAutoRun,
job.status === 'error' || job.status === 'cancelled' ? (job.error || 'Stopped') : undefined
).catch(() => null)
setQueueJob(job.library.queueId, null)
}
}
}
@@ -295,7 +358,20 @@ export async function continueQueuedExtensionsIfNeeded(job: Job) {
if (job.status === 'error' || job.status === 'cancelled' || job.status === 'complete') return
const remaining = remainingAfterCurrentShot(job.library)
if (!remaining.length) return
await continueQueuedExtensions(job, paramsFromJob(job))
const queue = job.library.queueId
? getShotQueue(job.library.ownerKey, job.library.queueId)
: null
const autoRun = queue ? queue.autoRun === true : job.library.queueAutoRun === true
const budget = job.library.queueBudget || 0
if (queue && !autoRun && budget <= 0) return
if (!queue && !autoRun && budget <= 0) {
// Legacy chains without a queue keep the old auto-continue behavior.
await continueQueuedExtensions(job, paramsFromJob(job), { autoRun: true })
return
}
const maxShots = autoRun ? remaining.length : budget
if (maxShots <= 0) return
await continueQueuedExtensions(job, paramsFromJob(job), { maxShots, autoRun })
}
export async function runGeneration(job: Job, params: VideoChainParams) {
@@ -313,11 +389,95 @@ export async function runGeneration(job: Job, params: VideoChainParams) {
await ensureComfyReady(ready)
const { extensions, ...base } = params
const autoRun = job.library?.queueAutoRun === true
await queueMiniMax(job, {
...base,
persist: extensions.length === 0
persist: extensions.length === 0 || !autoRun
})
if (!extensions.length || !job.library) return
await continueQueuedExtensions(job, params)
if (job.library?.queueId && !autoRun) {
await finishQueueBurst(job.library.ownerKey, job.library.queueId, true).catch(() => null)
setQueueJob(job.library.queueId, null)
}
if (!extensions.length || !job.library || !autoRun) return
await continueQueuedExtensions(job, params, { maxShots: extensions.length, autoRun: true })
}
export async function startQueueBurst(owner: string, queueId: string, count: number | 'all') {
const { queue, count: n } = await prepareQueueBurst(owner, queueId, count)
const lastIndex = lastCompletedIndex(queue)
const clipId = queue.currentClipId
if (!clipId) {
throw createError({ statusCode: 409, statusMessage: 'The previous clip is missing, so the next shot cannot start' })
}
const source = getClip(owner, clipId)
const destFolderLocked = false
const job = createJob()
job.kind = 'video'
job.maxStep = queue.steps
job.hideThumbnail = queue.hideThumbnail
job.clipId = clipId
job.library = {
ownerKey: owner,
folderId: queue.folderId,
hideThumbnail: queue.hideThumbnail,
hideInput: queue.hideInput,
folderLocked: destFolderLocked,
name: nextClipPartName(clipTitle(source)),
prompt: queue.segments[lastIndex]?.prompt || source.prompt,
aspect: queue.aspect,
width: queue.width,
height: queue.height,
steps: queue.steps,
turbo: queue.turbo,
seed: Math.floor(Math.random() * 2_147_483_647),
cfg: queue.cfg,
fps: queue.fps,
samplerName: queue.samplerName,
scheduler: queue.scheduler,
duration: queue.segments[lastIndex]?.duration || source.duration,
stillId: queue.stillId,
stillFilename: queue.stillFilename,
referenceStillIds: queue.referenceStillIds,
sound: queue.sound,
extensions: remainingFromQueue(queue),
chainIndex: Math.max(0, lastIndex),
chainStep: lastIndex + 1,
chainTotal: queue.segments.length,
chainLabel: lastIndex <= 0 ? 'Initial' : `Extension ${lastIndex}`,
familyId: queue.familyId,
parentClipId: clipId,
workflow: queue.workflow,
useIdentityRefs: queue.useIdentityRefs,
queueId: queue.id,
queueAutoRun: count === 'all',
queueBudget: n
}
setQueueJob(queue.id, job.id)
await updateShotQueue(owner, queue.id, (next) => {
next.currentJobId = job.id
next.status = 'running'
}).catch(() => null)
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
void continueQueuedExtensions(job, paramsFromJob(job), {
maxShots: n,
autoRun: count === 'all'
}).catch(async (error) => {
removeExtendTemp(job.library?.extendTmpDir)
const message = error instanceof Error ? error.message : String(error)
if (job.status === 'error' || job.status === 'cancelled') {
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
setQueueJob(queue.id, null)
return
}
job.status = 'error'
job.error = message
emitJob(job, { type: 'error', error: message, message })
await finishQueueBurst(owner, queue.id, false, message).catch(() => null)
setQueueJob(queue.id, null)
})
return { jobId: job.id, queueId: queue.id, count: n, chainTotal: queue.segments.length, chainStep: lastIndex + 1 }
}
+11 -3
View File
@@ -2,7 +2,8 @@ import { existsSync } from 'node:fs'
import type { Job, JobEvent } from '~/server/utils/jobs'
import { getJob, restoreJob } from '~/server/utils/jobs'
import type { PendingJob } from '~/server/utils/pending'
import { isLastChainShot, libraryFromPending, pendingFromJob, writePendingJob, deletePendingJob } from '~/server/utils/pending'
import { isLastChainShot, libraryFromPending, pendingFromJob, remainingAfterCurrentShot, writePendingJob, deletePendingJob } from '~/server/utils/pending'
import { syncQueueFromJob } from '~/server/utils/shotQueue'
function classifyError(message: string) {
const lower = message.toLowerCase()
@@ -132,6 +133,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
job.error = classifyError(error)
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
await syncQueueFromJob(job).catch(() => null)
if (job.id) deletePendingJob(job.id)
removeExtendTemp(job.library?.extendTmpDir)
await purgeComfyArtifacts({
@@ -212,6 +214,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
job.library.thumb = undefined
job.library.name = nextClipPartName(clip.name)
job.library.parentClipId = clip.id
await syncQueueFromJob(job, clip.id).catch(() => null)
await purgeComfyArtifacts({
video,
imageName: job.library.imageName,
@@ -242,11 +245,14 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
job.library.extendTmpDir = undefined
}
job.status = 'complete'
const queuedRemaining = job.library ? remainingAfterCurrentShot(job.library).length : 0
emitJob(job, {
type: 'complete',
message: job.library?.folderLocked
? 'Saved to the locked folder. Unlock it to view.'
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
: queuedRemaining > 0
? `Shot ${chainMeta(job).step} of ${chainMeta(job).total} saved. ${queuedRemaining} remaining on Queue.`
: (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,
@@ -256,7 +262,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
folderLocked: job.library?.folderLocked,
chainStep: job.library?.chainStep,
chainTotal: job.library?.chainTotal,
chainLabel: job.library?.chainLabel
chainLabel: job.library?.chainLabel,
queuedRemaining,
queueId: job.library?.queueId
})
deletePendingJob(job.id)
job.saving = false