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