Nest shot scripts under Queue jobs, keep the studio from overflowing on phones, and lighten library thumbs and polling so the UI stays responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-27 22:27:17 -05:00
co-authored by Cursor
parent dec1a49bef
commit b2c59c3423
14 changed files with 634 additions and 133 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ export async function fetchHistory(promptId: string) {
}
export async function fetchHistoryAll() {
const res = await comfyFetch('/history')
const res = await comfyFetch('/history', { signal: AbortSignal.timeout(12000) })
if (!res.ok) return {}
return (await res.json()) as Record<string, unknown>
}
+123 -7
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, statSync, createReadStream, openSync, readSync, closeSync } from 'node:fs'
import { join } from 'node:path'
import { spawn } from 'node:child_process'
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
@@ -483,7 +483,7 @@ export function deleteFolder(owner: string, id: string) {
rmSync(clipDir(owner, clip.id), { recursive: true, force: true })
}
for (const still of stills) {
rmSync(stillPath(owner, still.id), { force: true })
removeStillFiles(owner, still.id)
}
return { fallbackFolderId: catalog.folders[0].id }
})
@@ -641,9 +641,9 @@ function probeVideoSize(videoPath: string) {
})
}
export async function ensureClipThumbnails(owner: string) {
export async function ensureClipThumbnails(owner: string, opts?: { skipHistory?: boolean }) {
const catalog = readCatalog(owner)
const needsHistory = catalog.clips.some(clip => clip.comfyFilename && (!clip.steps || !clip.seed))
const needsHistory = !opts?.skipHistory && catalog.clips.some(clip => clip.comfyFilename && (!clip.steps || !clip.seed))
const history = needsHistory
? await fetchHistoryAll().catch(() => ({} as Record<string, unknown>))
: {}
@@ -731,6 +731,110 @@ export function stillPath(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', id)
}
export function stillThumbFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
}
function removeStillFiles(owner: string, id: string) {
rmSync(stillPath(owner, id), { force: true })
rmSync(stillThumbFile(owner, id), { force: true })
}
let thumbSlots = 0
const thumbWaiters: Array<() => void> = []
const thumbBuilds = new Map<string, Promise<string>>()
function withThumbSlot<T>(fn: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
const run = () => {
thumbSlots += 1
fn().then(resolve, reject).finally(() => {
thumbSlots -= 1
const next = thumbWaiters.shift()
if (next) next()
})
}
if (thumbSlots < 2) run()
else thumbWaiters.push(run)
})
}
function buildStillThumb(source: string, dest: string) {
return new Promise<boolean>((resolve) => {
const proc = spawn('ffmpeg', [
'-y',
'-i', source,
'-vf', 'scale=320:-2',
'-frames:v', '1',
'-q:v', '5',
dest
], { stdio: 'ignore' })
const timer = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* ignore */ }
resolve(false)
}, 20000)
proc.on('close', (code) => {
clearTimeout(timer)
resolve(code === 0 && existsSync(dest))
})
proc.on('error', () => {
clearTimeout(timer)
resolve(false)
})
})
}
export async function stillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
if (!existsSync(source)) return ''
if (existsSync(dest)) {
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}`
const pending = thumbBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest)
return ok ? dest : source
}).finally(() => {
thumbBuilds.delete(key)
})
thumbBuilds.set(key, build)
return build
}
export async function ensureStillThumbnails(owner: string) {
const catalog = readCatalog(owner)
for (const still of catalog.stills) {
if (isPipelineStill(catalog, still)) continue
await stillThumbPath(owner, still.id)
}
}
const lastMaintain = new Map<string, number>()
export async function maintainLibrary(owner: string, folderId?: string) {
const prev = lastMaintain.get(owner) || 0
if (Date.now() - prev < 20000) return
lastMaintain.set(owner, Date.now())
void ensureStillThumbnails(owner).catch(() => null)
const live = await fetchLiveQueue().catch(() => null)
const busy = Boolean(live && live.running > 0)
if (!busy) {
await importMissingComfyVideos(owner, folderId).catch(() => [])
}
await ensureClipThumbnails(owner, { skipHistory: busy })
if (!busy) await sweepOwnerExtensionStills(owner)
}
export function fileEtag(path: string) {
const st = statSync(path)
return `W/"${st.size}-${Math.round(st.mtimeMs)}"`
}
function bytesHash(data: Buffer) {
return createHash('sha256').update(data).digest('hex')
}
@@ -820,7 +924,7 @@ function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
if (!drop.length) return { count: 0, filenames: [] as string[] }
catalog.stills = keep
for (const still of drop) {
rmSync(stillPath(owner, still.id), { force: true })
removeStillFiles(owner, still.id)
}
return { count: drop.length, filenames: drop.map(still => still.filename) }
}
@@ -923,6 +1027,7 @@ export async function saveStill(params: {
await mutate(params.ownerKey, (next) => {
next.stills.unshift(still)
})
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
return still
}
@@ -1029,7 +1134,7 @@ export function deleteStill(owner: string, id: string) {
const still = catalog.stills.find(item => item.id === id)
if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' })
catalog.stills = catalog.stills.filter(item => item.id !== id)
rmSync(stillPath(owner, id), { force: true })
removeStillFiles(owner, id)
return still
})
}
@@ -1053,7 +1158,7 @@ export function deleteStillFamily(owner: string, id: string) {
const ids = new Set(family.map(item => item.id))
catalog.stills = catalog.stills.filter(item => !ids.has(item.id))
for (const item of family) {
rmSync(stillPath(owner, item.id), { force: true })
removeStillFiles(owner, item.id)
}
return family
})
@@ -1103,6 +1208,17 @@ export function renameStillFamily(owner: string, id: string, name: string) {
})
}
export function sniffImageType(path: string) {
const buf = Buffer.alloc(16)
const fd = openSync(path, 'r')
try {
readSync(fd, buf, 0, 16, 0)
} finally {
closeSync(fd)
}
return imageContentType(buf)
}
export function imageContentType(buf: Buffer) {
if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png'
if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg'
+4
View File
@@ -202,6 +202,10 @@ export async function cancelStudioJob(owner: string, id: string) {
}
return structuredClone(job)
})
if (result.shotQueueId) {
const { deleteShotQueue } = await import('~/server/utils/shotQueue')
await deleteShotQueue(owner, result.shotQueueId).catch(() => null)
}
const jobs = readJobs(owner)
if (!jobs.some(item => item.status === 'waiting' && item.cutIn)) {
const running = jobs.find(item => item.status === 'running')