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