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,4 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, createReadStream } from 'node:fs'
import { clipLastFramePath, clipVideoPath, fileEtag, sniffImageType } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
@@ -18,8 +19,13 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 500, statusMessage: `Could not extract the last frame: ${message}` })
}
}
const buf = readFileSync(framePath)
setHeader(event, 'Content-Type', 'image/png')
setHeader(event, 'Cache-Control', 'private, no-store')
return buf
const etag = fileEtag(framePath)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', sniffImageType(framePath))
return sendStream(event, createReadStream(framePath))
})
+11 -5
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, createReadStream } from 'node:fs'
import { clipThumbPath, fileEtag, sniffImageType } from '~/server/utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
@@ -9,8 +10,13 @@ export default defineEventHandler((event) => {
if (!existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
}
const buf = readFileSync(path)
setHeader(event, 'Content-Type', imageContentType(buf))
setHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', sniffImageType(path))
return sendStream(event, createReadStream(path))
})
+9 -10
View File
@@ -1,13 +1,12 @@
export default defineEventHandler(async (event) => {
export default defineEventHandler((event) => {
const library = publicLibrary(event)
try {
const { owner } = assertLibraryOwner(event)
const folderId = library.folders.find(folder => folder.unlocked)?.id
if (folderId) await importMissingComfyVideos(owner, folderId)
await ensureClipThumbnails(owner)
await sweepOwnerExtensionStills(owner)
} catch {
return publicLibrary(event)
const light = String(getQuery(event).light || '') === '1'
if (!light) {
try {
const { owner } = assertLibraryOwner(event)
const folderId = library.folders.find(folder => folder.unlocked)?.id
void maintainLibrary(owner, folderId).catch(() => null)
} catch { /* locked or anonymous — still return what we can */ }
}
return publicLibrary(event)
return library
})
+15 -8
View File
@@ -1,16 +1,23 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
export default defineEventHandler((event) => {
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = stillPath(owner, still.id)
if (!existsSync(path)) {
const wantThumb = String(getQuery(event).thumb || '') === '1'
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id)
if (!path || !existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
}
const buf = readFileSync(path)
setHeader(event, 'Content-Type', imageContentType(buf))
setHeader(event, 'Cache-Control', 'private, max-age=3600')
return buf
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
return sendStream(event, createReadStream(path))
})
+30 -2
View File
@@ -1,12 +1,40 @@
import { listShotQueues, summarizeQueue } from '~/server/utils/shotQueue'
import { listStudioJobs, summarizeStudioJob } from '~/server/utils/studioQueue'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const jobs = listStudioJobs(owner)
const full = String(getQuery(event).full || '') === '1'
const active = listStudioJobs(owner)
.filter(job => job.status === 'waiting' || job.status === 'running' || job.status === 'held')
.map(summarizeStudioJob)
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
const queues = listShotQueues(owner)
const byId = new Map(queues.map(queue => [queue.id, queue]))
const jobs = active.map((job) => {
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
const shotSummary = shots ? summarizeQueue(shots) : null
const plannedShots = shotSummary
? undefined
: [
{ prompt: job.payload.prompt, duration: job.payload.duration },
...(job.payload.extensions || [])
]
return {
...summarizeStudioJob(job),
...(full ? {
shots: shotSummary,
plannedShots: plannedShots && plannedShots.length > 1 ? plannedShots : undefined
} : {}),
completedCount: shotSummary?.completedCount || 0,
pendingCount: shotSummary?.pendingCount ?? job.shotCount
}
})
return {
jobs,
orphans: full
? queues
.filter(queue => !claimed.has(queue.id) && queue.status !== 'complete')
.map(summarizeQueue)
: [],
waitingCount: jobs.filter(job => job.status === 'waiting').length
}
})
+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')