Serve the bench before the catalog and never await ffmpeg on /thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -280,11 +280,11 @@
|
||||
aria-label="Previous"
|
||||
@click="stepFamily(-1)"
|
||||
>‹</button>
|
||||
<div class="viewer-frame" :class="{ 'viewer-frame--veiled': selected && isHidden(selected) }">
|
||||
<div class="viewer-frame">
|
||||
<p
|
||||
v-if="selected && isHidden(selected)"
|
||||
v-show="selected && isHidden(selected)"
|
||||
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'">
|
||||
<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'" />
|
||||
@@ -303,9 +303,9 @@
|
||||
</template>
|
||||
<template v-else-if="selected">
|
||||
<img
|
||||
v-show="!isHidden(selected)"
|
||||
:key="selected.id"
|
||||
class="viewer-still"
|
||||
:class="{ 'viewer-still--veiled': isHidden(selected) }"
|
||||
:src="stillThumbUrl(selected.id)"
|
||||
alt="Selected still"
|
||||
decoding="async"
|
||||
@@ -681,7 +681,19 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
|
||||
syncPhotosBindings()
|
||||
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)})
|
||||
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))
|
||||
@@ -695,10 +707,11 @@ const musicJobs=computed(()=>studioQueue.value
|
||||
.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 gpuChipLabel=computed(()=>{
|
||||
if(!healthState.value) return 'GPU ?'
|
||||
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
|
||||
const short=raw.match(/(\d{3,4})\b/)?.[1]
|
||||
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 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 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(()=>[
|
||||
@@ -1470,37 +1507,32 @@ let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,dis
|
||||
onMounted(()=>{
|
||||
let storage;try{storage=localStorage}catch{}
|
||||
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()
|
||||
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
|
||||
loadRevealed(form.folderId)
|
||||
clock=setInterval(()=>{now.value=Date.now()},1000)
|
||||
// Deferred heavy work after shell is interactive
|
||||
void (async()=>{
|
||||
void loadLoras(false)
|
||||
void loadLoras(false)
|
||||
// Folder page in background — never blocks Generate / prompt
|
||||
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{
|
||||
await loadLibraryOnce()
|
||||
restoreBenchDraft()
|
||||
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
|
||||
restoreBenchDraft()
|
||||
loadRevealed(form.folderId)
|
||||
await 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 — 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()
|
||||
})()
|
||||
await refreshJobs()
|
||||
healthState.value=await fetchHealth()
|
||||
}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){
|
||||
window.addEventListener('keydown',onStageHotkey)
|
||||
window.addEventListener('pointerdown',onStagePointerDown,true)
|
||||
|
||||
@@ -104,9 +104,9 @@
|
||||
@click.stop="ctx.stepFamily(1)"
|
||||
>›</button>
|
||||
<p
|
||||
v-if="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||
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'">
|
||||
<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'" />
|
||||
@@ -122,9 +122,9 @@
|
||||
/>
|
||||
<img
|
||||
v-else-if="ctx.selected"
|
||||
v-show="!ctx.isHidden(ctx.selected)"
|
||||
:key="ctx.selected.id"
|
||||
class="m-stage-still"
|
||||
:class="{ 'm-stage-still--veiled': ctx.isHidden(ctx.selected) }"
|
||||
:src="ctx.stillThumbUrl(ctx.selected.id)"
|
||||
alt="Selected still"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
@@ -9,28 +9,36 @@ export default defineEventHandler(async (event) => {
|
||||
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||
const original = stillPath(owner, still.id)
|
||||
|
||||
let path = original
|
||||
if (wantThumb) {
|
||||
path = await stillThumbPath(owner, id)
|
||||
// Never fall back to the archive PNG/JPEG for ?thumb=1
|
||||
if (!path || path === original || !existsSync(path)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
||||
const path = existingStillThumbPath(owner, id)
|
||||
if (path && path !== original && existsSync(path)) {
|
||||
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))
|
||||
}
|
||||
} 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' })
|
||||
}
|
||||
|
||||
const etag = fileEtag(path)
|
||||
const etag = fileEtag(original)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(
|
||||
event,
|
||||
'Cache-Control',
|
||||
wantThumb ? 'public, max-age=86400' : 'private, max-age=3600'
|
||||
)
|
||||
setHeader(event, 'Cache-Control', '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))
|
||||
setHeader(event, 'Content-Type', sniffImageType(original))
|
||||
return sendStream(event, createReadStream(original))
|
||||
})
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
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 id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const path = await stillThumbPath(owner, id)
|
||||
const path = existingStillThumbPath(owner, id)
|
||||
const original = stillPath(owner, still.id)
|
||||
// Never stream the archive master as a "thumb"
|
||||
if (!path || !existsSync(path) || path === original) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
||||
// Ready thumb → stream. Never await ffmpeg on the request path.
|
||||
if (path && existsSync(path) && path !== original) {
|
||||
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)
|
||||
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))
|
||||
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
|
||||
void stillThumbPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -187,6 +187,7 @@ interface Catalog {
|
||||
}
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
|
||||
|
||||
export function libraryRoot() {
|
||||
const config = useRuntimeConfig()
|
||||
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
|
||||
function readCatalog(owner: string): Catalog {
|
||||
ensureOwner(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
|
||||
try {
|
||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
|
||||
}
|
||||
catalog = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
return catalog
|
||||
}
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
return catalog
|
||||
}
|
||||
|
||||
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
|
||||
const tmp = catalogPath(owner) + '.tmp'
|
||||
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
||||
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> {
|
||||
@@ -1252,6 +1271,17 @@ export function stillThumbFile(owner: string, id: string) {
|
||||
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) {
|
||||
rmSync(stillPath(owner, id), { force: true })
|
||||
rmSync(stillThumbFile(owner, id), { force: true })
|
||||
|
||||
Reference in New Issue
Block a user