Pause the queue after the current clip for real, and carry global locks plus labeled permanence stills through generate and shot prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 07:48:09 -05:00
co-authored by Cursor
parent 010ef6add2
commit efef409537
21 changed files with 1196 additions and 115 deletions
+19 -4
View File
@@ -1,5 +1,6 @@
import { copyFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { composeShotPrompt } from '~/utils/globalLocks'
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
@@ -59,6 +60,12 @@ export default defineEventHandler(async (event) => {
const destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId)
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
const durationSeconds = parseExtendDuration(body?.duration)
const composedPrompt = composeShotPrompt({
globalLocks: source.globalLocks,
prompt,
shotIndex: (source.chainIndex || 0) + 1,
familyRefs: source.permanenceRefs
})
const turbo = source.turbo !== false
const fps = parseFps(source.fps)
const cfg = parseCfg(source.cfg, turbo)
@@ -84,7 +91,7 @@ export default defineEventHandler(async (event) => {
hideThumbnail: source.hideThumbnail,
folderLocked,
name: clipName,
prompt,
prompt: composedPrompt,
aspect: source.aspect || '16:9',
width,
height,
@@ -102,7 +109,9 @@ export default defineEventHandler(async (event) => {
familyId: source.familyId || crypto.randomUUID(),
parentClipId: source.id,
chainIndex: (source.chainIndex || 0) + 1,
workflow: parseVideoWorkflow(source.workflow)
workflow: parseVideoWorkflow(source.workflow),
globalLocks: source.globalLocks,
permanenceRefs: source.permanenceRefs
}
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
@@ -111,7 +120,7 @@ export default defineEventHandler(async (event) => {
sourcePath,
part1Path,
framePath,
prompt,
prompt: composedPrompt,
width,
height,
steps,
@@ -236,7 +245,13 @@ async function runExtend(
extendPart1Path: job.library.extendPart1Path,
familyId: job.library.familyId,
parentClipId: job.library.parentClipId,
chainIndex: job.library.chainIndex
chainIndex: job.library.chainIndex,
stillId: job.library.stillId,
sound: job.library.sound,
workflow: job.library.workflow,
duration: job.library.duration,
globalLocks: job.library.globalLocks,
permanenceRefs: job.library.permanenceRefs
})
}
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
+41 -6
View File
@@ -1,5 +1,6 @@
import { addStudioJob, kickStudioQueue, listStudioJobs } from '~/server/utils/studioQueue'
import { defaultVideoSteps, isLtxWorkflow, isTextToVideo, parseVideoWorkflow } from '~/utils/videoModels'
import { normalizePermanenceRefs, resolveGlobalLocks, type PermanenceRef } from '~/utils/globalLocks'
function parseDuration(raw: unknown) {
const seconds = Number(raw)
@@ -12,14 +13,15 @@ function parseExtendDuration(raw: unknown) {
}
function parseExtensions(raw: string | undefined) {
if (!raw) return [] as { prompt: string; duration: number }[]
if (!raw) return [] as { prompt: string; duration: number; permanenceRefs?: PermanenceRef[] }[]
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.map((item: { prompt?: unknown; duration?: unknown }) => ({
.map((item: { prompt?: unknown; duration?: unknown; permanenceRefs?: unknown }) => ({
prompt: String(item?.prompt || '').trim(),
duration: parseExtendDuration(item?.duration)
duration: parseExtendDuration(item?.duration),
permanenceRefs: normalizePermanenceRefs(item?.permanenceRefs)
}))
.filter(item => item.prompt)
} catch {
@@ -27,6 +29,26 @@ function parseExtensions(raw: string | undefined) {
}
}
function parseFamilyPermanenceRefs(raw: string | undefined) {
if (!raw) return [] as PermanenceRef[]
try {
return normalizePermanenceRefs(JSON.parse(raw))
} catch {
return []
}
}
function parseShotPermanenceRefs(raw: string | undefined, shotCount: number) {
if (!raw) return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
return Array.from({ length: shotCount }, (_, index) => normalizePermanenceRefs(parsed[index]))
} catch {
return Array.from({ length: shotCount }, () => [] as PermanenceRef[])
}
}
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
@@ -84,11 +106,21 @@ export default defineEventHandler(async (event) => {
if (!image && !textToVideo) {
throw createError({ statusCode: 400, statusMessage: 'An initial image is required' })
}
const prompt = (fields.prompt || '').trim()
if (!prompt) {
const promptRaw = (fields.prompt || '').trim()
if (!promptRaw) {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
}
const resolvedLocks = resolveGlobalLocks(fields.globalLocks || '', promptRaw)
const prompt = resolvedLocks.prompt
const globalLocks = resolvedLocks.locks
const extensions = parseExtensions(fields.extensions)
const permanenceRefs = parseFamilyPermanenceRefs(fields.permanenceRefs)
const shotPermanenceRefs = parseShotPermanenceRefs(fields.shotPermanenceRefs, 1 + extensions.length)
for (const [index, item] of extensions.entries()) {
if (!shotPermanenceRefs[index + 1]?.length && item.permanenceRefs?.length) {
shotPermanenceRefs[index + 1] = item.permanenceRefs
}
}
const useIdentityRefs = workflow === 'v2' && !textToVideo && !isLtxWorkflow(workflow) && fields.useIdentityRefs === 'true'
const { width, height } = resolveOutputSize(
fields.aspect,
@@ -180,7 +212,10 @@ export default defineEventHandler(async (event) => {
folderLocked,
referenceStillIds,
extensions,
queueAutoRun
queueAutoRun,
globalLocks: globalLocks || undefined,
permanenceRefs: permanenceRefs.length ? permanenceRefs : undefined,
shotPermanenceRefs: shotPermanenceRefs.some(item => item.length) ? shotPermanenceRefs : undefined
}
})
await kickStudioQueue()
+11 -2
View File
@@ -1,11 +1,13 @@
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
const body = await readBody<{ folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean; globalLocks?: string; permanenceRefs?: unknown }))
const folderId = String(body?.folderId || '')
const hasName = typeof body?.name === 'string'
const family = body?.family === true
if (!folderId && !hasName) {
const hasLocks = typeof body?.globalLocks === 'string'
const hasRefs = Array.isArray(body?.permanenceRefs)
if (!folderId && !hasName && !hasLocks && !hasRefs) {
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
}
const clip = getClip(owner, id)
@@ -19,5 +21,12 @@ export default defineEventHandler(async (event) => {
if (family) await renameClipFamily(owner, id, body.name || '')
else await renameClip(owner, id, body.name || '')
}
if (hasLocks || hasRefs) {
await patchClipMeta(owner, id, {
globalLocks: hasLocks ? body.globalLocks : undefined,
permanenceRefs: hasRefs ? body.permanenceRefs as import('~/utils/globalLocks').PermanenceRef[] : undefined,
family: family || hasLocks
})
}
return publicLibrary(event)
})
+20 -3
View File
@@ -1,10 +1,27 @@
import { pauseShotQueue, summarizeQueue, getShotQueue } from '~/server/utils/shotQueue'
import { setShotQueuePause, summarizeQueue, getShotQueue } from '~/server/utils/shotQueue'
import { toggleStudioQueuePause } from '~/server/utils/studioQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
await pauseShotQueue(owner, id)
const body = await readBody<{ resume?: boolean }>().catch(() => ({} as { resume?: boolean }))
const queue = getShotQueue(owner, id)
if (!queue) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
return summarizeQueue(queue)
const result = await toggleStudioQueuePause(owner, {
resume: body?.resume,
queueId: id
})
const next = getShotQueue(owner, id)
if (!next) throw createError({ statusCode: 404, statusMessage: 'Queue not found' })
if (result.willPause && !next.stopAfterCurrent) {
await setShotQueuePause(owner, id, true)
}
if (!result.willPause && next.stopAfterCurrent) {
await setShotQueuePause(owner, id, false)
}
return {
...summarizeQueue(getShotQueue(owner, id) || next),
paused: result.paused,
willPause: result.willPause
}
})
+3 -2
View File
@@ -1,5 +1,5 @@
import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue'
import { listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
import { isStudioQueuePaused, listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
@@ -35,6 +35,7 @@ export default defineEventHandler((event) => {
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
.map(summarizeQueue)
: [],
waitingCount: jobs.filter(job => job.status === 'waiting').length
waitingCount: jobs.filter(job => job.status === 'waiting').length,
paused: isStudioQueuePaused(owner)
}
})
@@ -0,0 +1,11 @@
import { toggleStudioQueuePause } from '~/server/utils/studioQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const body = await readBody<{ resume?: boolean }>().catch(() => ({} as { resume?: boolean }))
return toggleStudioQueuePause(owner, {
resume: body?.resume,
jobId: id
})
})
+10
View File
@@ -0,0 +1,10 @@
import { toggleStudioQueuePause } from '~/server/utils/studioQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{ resume?: boolean; jobId?: string }>().catch(() => ({} as { resume?: boolean; jobId?: string }))
return toggleStudioQueuePause(owner, {
resume: body?.resume,
jobId: body?.jobId ? String(body.jobId) : undefined
})
})