Serve the bench before the catalog and never await ffmpeg on /thumb.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-29 20:35:28 -05:00
co-authored by Cursor
parent a465dd68ec
commit 4ec8ffa8d6
5 changed files with 141 additions and 67 deletions
+65 -33
View File
@@ -280,11 +280,11 @@
aria-label="Previous" aria-label="Previous"
@click="stepFamily(-1)" @click="stepFamily(-1)"
>‹</button> >‹</button>
<div class="viewer-frame" :class="{ 'viewer-frame--veiled': selected && isHidden(selected) }"> <div class="viewer-frame">
<p <p
v-if="selected && isHidden(selected)" v-show="selected && isHidden(selected)"
class="placeholder-still viewer-veil-label" class="placeholder-still viewer-veil-label"
>{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p> >{{ selected?.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
<template v-if="selected?.kind==='track'"> <template v-if="selected?.kind==='track'">
<div v-show="!isHidden(selected)" class="w-full max-w-xl px-6 py-4"> <div v-show="!isHidden(selected)" class="w-full max-w-xl px-6 py-4">
<AudioPlayer :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" /> <AudioPlayer :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
@@ -303,9 +303,9 @@
</template> </template>
<template v-else-if="selected"> <template v-else-if="selected">
<img <img
v-show="!isHidden(selected)"
:key="selected.id" :key="selected.id"
class="viewer-still" class="viewer-still"
:class="{ 'viewer-still--veiled': isHidden(selected) }"
:src="stillThumbUrl(selected.id)" :src="stillThumbUrl(selected.id)"
alt="Selected still" alt="Selected still"
decoding="async" decoding="async"
@@ -681,7 +681,19 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
syncPhotosBindings() syncPhotosBindings()
saveBenchDraft(true) saveBenchDraft(true)
}) })
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'}) watch(()=>form.folderId,(id,prev)=>{
selected.value=null
// Wipe Photos only when leaving an existing folder — first restore from '' must keep the draft
if(prev){
form.imageAId=''
form.imageBId=''
form.photos=[]
form.startClipId=''
}
loadRevealed(id)
if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''
void loadLibraryOnce().catch(()=>null)
},{flush:'sync'})
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)}) watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)) const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)) const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
@@ -695,10 +707,11 @@ const musicJobs=computed(()=>studioQueue.value
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null}))) .map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value)) const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
const gpuChipLabel=computed(()=>{ const gpuChipLabel=computed(()=>{
if(!healthState.value) return 'GPU ?'
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim() const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
const short=raw.match(/(\d{3,4})\b/)?.[1] const short=raw.match(/(\d{3,4})\b/)?.[1]
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready' if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
return gpu.value.label return gpu.value.label || 'GPU ?'
}) })
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value}) const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'}))) const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
@@ -1457,7 +1470,31 @@ async function queue(){
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()} async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
async function loadLibraryOnce(){ async function loadLibraryOnce(){
library.value=await $fetch('/api/studio-2/library') // Current folder page only — never the yearly Inbox JSON on /
const qs=new URLSearchParams({limit:'50'})
if(form.folderId) qs.set('folderId',form.folderId)
const page=await $fetch<any>(`/api/library?${qs.toString()}`)
library.value={
folders:page.folders||library.value.folders||[],
stills:page.stills||[],
clips:page.clips||[],
tracks:page.tracks||[],
drafts:page.drafts||library.value.drafts||[],
loraAliases:page.loraAliases||library.value.loraAliases||{},
loraTriggers:page.loraTriggers||library.value.loraTriggers||{},
keepPrompts:page.keepPrompts||library.value.keepPrompts
}
}
async function fetchHealth(){
try{
return await Promise.race([
$fetch<any>('/api/health'),
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error('health_timeout')),800))
])
}catch{
return null
}
} }
watch(()=>[ watch(()=>[
@@ -1470,37 +1507,32 @@ let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,dis
onMounted(()=>{ onMounted(()=>{
let storage;try{storage=localStorage}catch{} let storage;try{storage=localStorage}catch{}
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed() loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
// First paint: chrome + empty stage + draft from localStorage — do not await library / object_info // First paint in ~2s: chrome + draft. Do not await library before the prompt works.
restoreBenchDraft() restoreBenchDraft()
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
loadRevealed(form.folderId) loadRevealed(form.folderId)
clock=setInterval(()=>{now.value=Date.now()},1000) clock=setInterval(()=>{now.value=Date.now()},1000)
// Deferred heavy work after shell is interactive void loadLoras(false)
void (async()=>{ // Folder page in background — never blocks Generate / prompt
void loadLoras(false) void loadLibraryOnce().then(()=>{
restoreBenchDraft()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
loadRevealed(form.folderId)
void applyIncomingGenerationLoad()
const id=String(route.query.still||route.query.clip||route.query.track||'')
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
if(route.query.task==='music')form.mode='music'
}).catch((e:any)=>{error.value=e.data?.statusMessage||e.message})
// Idle poll: jobs + health only — 800ms health budget, chip GPU ? on miss
const poll=async()=>{
try{ try{
await loadLibraryOnce() await refreshJobs()
restoreBenchDraft() healthState.value=await fetchHealth()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||'' }catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}
restoreBenchDraft() if(!disposed)timer=setTimeout(poll,3000)
loadRevealed(form.folderId) }
await applyIncomingGenerationLoad() void poll()
const id=String(route.query.still||route.query.clip||route.query.track||'')
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id)
if(asset){selected.value={...asset,kind:route.query.clip?'clip':route.query.track?'track':'still'};if(route.query.use==='1'&&selected.value.kind!=='track')restore(selected.value);if(xaigen)toggleReveal(asset.id)}
if(route.query.task==='music')form.mode='music'
}catch(e:any){error.value=e.data?.statusMessage||e.message}
// Idle poll: jobs + health only — never /api/studio-2/library
const poll=async()=>{
try{
await refreshJobs()
const health=await $fetch<any>('/api/health')
healthState.value=health
}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}
if(!disposed)timer=setTimeout(poll,3000)
}
void poll()
})()
if(import.meta.client){ if(import.meta.client){
window.addEventListener('keydown',onStageHotkey) window.addEventListener('keydown',onStageHotkey)
window.addEventListener('pointerdown',onStagePointerDown,true) window.addEventListener('pointerdown',onStagePointerDown,true)
+3 -3
View File
@@ -104,9 +104,9 @@
@click.stop="ctx.stepFamily(1)" @click.stop="ctx.stepFamily(1)"
>›</button> >›</button>
<p <p
v-if="ctx.selected && ctx.isHidden(ctx.selected)" v-show="ctx.selected && ctx.isHidden(ctx.selected)"
class="m-hidden-note" class="m-hidden-note"
>{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p> >{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
<template v-if="ctx.selected?.kind==='track'"> <template v-if="ctx.selected?.kind==='track'">
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio"> <div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" /> <AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
@@ -122,9 +122,9 @@
/> />
<img <img
v-else-if="ctx.selected" v-else-if="ctx.selected"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id" :key="ctx.selected.id"
class="m-stage-still" class="m-stage-still"
:class="{ 'm-stage-still--veiled': ctx.isHidden(ctx.selected) }"
:src="ctx.stillThumbUrl(ctx.selected.id)" :src="ctx.stillThumbUrl(ctx.selected.id)"
alt="Selected still" alt="Selected still"
decoding="async" decoding="async"
+24 -16
View File
@@ -1,7 +1,7 @@
import { existsSync, createReadStream } from 'node:fs' import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library' import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
export default defineEventHandler(async (event) => { export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '') const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id) const still = getStill(owner, id)
@@ -9,28 +9,36 @@ export default defineEventHandler(async (event) => {
const wantThumb = String(getQuery(event).thumb || '') === '1' const wantThumb = String(getQuery(event).thumb || '') === '1'
const original = stillPath(owner, still.id) const original = stillPath(owner, still.id)
let path = original
if (wantThumb) { if (wantThumb) {
path = await stillThumbPath(owner, id) const path = existingStillThumbPath(owner, id)
// Never fall back to the archive PNG/JPEG for ?thumb=1 if (path && path !== original && existsSync(path)) {
if (!path || path === original || !existsSync(path)) { const etag = fileEtag(path)
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' }) setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
} }
} else if (!existsSync(path)) { void stillThumbPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
}
if (!existsSync(original)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' }) throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
} }
const etag = fileEtag(path) const etag = fileEtag(original)
setHeader(event, 'ETag', etag) setHeader(event, 'ETag', etag)
setHeader( setHeader(event, 'Cache-Control', 'private, max-age=3600')
event,
'Cache-Control',
wantThumb ? 'public, max-age=86400' : 'private, max-age=3600'
)
if (getHeader(event, 'if-none-match') === etag) { if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304) setResponseStatus(event, 304)
return null return null
} }
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path)) setHeader(event, 'Content-Type', sniffImageType(original))
return sendStream(event, createReadStream(path)) return sendStream(event, createReadStream(original))
}) })
+19 -15
View File
@@ -1,26 +1,30 @@
import { existsSync, createReadStream } from 'node:fs' import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath } from '~/server/utils/library' import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
export default defineEventHandler(async (event) => { export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '') const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id) const still = getStill(owner, id)
assertFolderAccess(event, still.folderId) assertFolderAccess(event, still.folderId)
const path = await stillThumbPath(owner, id) const path = existingStillThumbPath(owner, id)
const original = stillPath(owner, still.id) const original = stillPath(owner, still.id)
// Never stream the archive master as a "thumb" // Ready thumb → stream. Never await ffmpeg on the request path.
if (!path || !existsSync(path) || path === original) { if (path && existsSync(path) && path !== original) {
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' }) const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
} }
const etag = fileEtag(path) // Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
setHeader(event, 'ETag', etag) void stillThumbPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'public, max-age=86400') setHeader(event, 'Cache-Control', 'no-store')
if (getHeader(event, 'if-none-match') === etag) { setResponseStatus(event, 204)
setResponseStatus(event, 304) return null
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}) })
+30
View File
@@ -187,6 +187,7 @@ interface Catalog {
} }
const writeChains = new Map<string, Promise<unknown>>() const writeChains = new Map<string, Promise<unknown>>()
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
export function libraryRoot() { export function libraryRoot() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
function readCatalog(owner: string): Catalog { function readCatalog(owner: string): Catalog {
ensureOwner(owner) ensureOwner(owner)
const path = catalogPath(owner) const path = catalogPath(owner)
let mtimeMs = 0
try {
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
} catch { mtimeMs = 0 }
const hit = catalogCache.get(owner)
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
let catalog: Catalog let catalog: Catalog
try { try {
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8'))) catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
} }
catalog = emptyCatalog() catalog = emptyCatalog()
writeFileSync(path, JSON.stringify(catalog, null, 2)) writeFileSync(path, JSON.stringify(catalog, null, 2))
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
catalogCache.set(owner, { catalog, mtimeMs })
return catalog return catalog
} }
catalogCache.set(owner, { catalog, mtimeMs })
return catalog return catalog
} }
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
const tmp = catalogPath(owner) + '.tmp' const tmp = catalogPath(owner) + '.tmp'
writeFileSync(tmp, JSON.stringify(catalog, null, 2)) writeFileSync(tmp, JSON.stringify(catalog, null, 2))
renameSync(tmp, catalogPath(owner)) renameSync(tmp, catalogPath(owner))
let mtimeMs = Date.now()
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
catalogCache.set(owner, { catalog, mtimeMs })
}
function invalidateCatalogCache(owner?: string) {
if (owner) catalogCache.delete(owner)
else catalogCache.clear()
} }
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> { function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
@@ -1252,6 +1271,17 @@ export function stillThumbFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`) return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
} }
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
export function existingStillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
if (!existsSync(source) || !existsSync(dest)) return ''
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { return '' }
return ''
}
function removeStillFiles(owner: string, id: string) { function removeStillFiles(owner: string, id: string) {
rmSync(stillPath(owner, id), { force: true }) rmSync(stillPath(owner, id), { force: true })
rmSync(stillThumbFile(owner, id), { force: true }) rmSync(stillThumbFile(owner, id), { force: true })