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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user