Stop polling the library catalog and keep the stage on thumbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-29 20:15:03 -05:00
co-authored by Cursor
parent cfabfd37ee
commit a465dd68ec
7 changed files with 294 additions and 182 deletions
+14 -1
View File
@@ -347,7 +347,8 @@
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */ /* Grow until width or height hits the stage; letterbox/pillarbox via contain */
.viewer-frame > img, .viewer-frame > img,
.viewer-frame > video { .viewer-frame > video,
.viewer-still {
width: 100%; width: 100%;
height: 100%; height: 100%;
max-width: 100%; max-width: 100%;
@@ -356,6 +357,18 @@
object-position: center; object-position: center;
} }
.viewer-still--veiled {
opacity: 0;
pointer-events: none;
}
.viewer-frame--veiled .viewer-veil-label {
position: absolute;
z-index: 2;
margin: 0;
pointer-events: none;
}
/* Top chrome is a sibling of the media pane — never absolute over pixels */ /* Top chrome is a sibling of the media pane — never absolute over pixels */
.stage-chrome { .stage-chrome {
display: flex; display: flex;
+116 -75
View File
@@ -280,17 +280,19 @@
aria-label="Previous" aria-label="Previous"
@click="stepFamily(-1)" @click="stepFamily(-1)"
>‹</button> >‹</button>
<div class="viewer-frame"> <div class="viewer-frame" :class="{ 'viewer-frame--veiled': selected && isHidden(selected) }">
<template v-if="selected && isHidden(selected)"> <p
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p> v-if="selected && isHidden(selected)"
</template> class="placeholder-still viewer-veil-label"
<template v-else-if="selected?.kind==='track'"> >{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
<div class="w-full max-w-xl px-6 py-4"> <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'" /> <AudioPlayer :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
</div> </div>
</template> </template>
<template v-else-if="selected?.kind==='clip'"> <template v-else-if="selected?.kind==='clip'">
<video <video
v-show="!isHidden(selected)"
ref="viewerVideo" ref="viewerVideo"
:key="selected.id" :key="selected.id"
:src="`/api/library/clips/${selected.id}/video`" :src="`/api/library/clips/${selected.id}/video`"
@@ -302,10 +304,11 @@
<template v-else-if="selected"> <template v-else-if="selected">
<img <img
:key="selected.id" :key="selected.id"
:src="stageStillSrc" class="viewer-still"
:class="{ 'viewer-still--veiled': isHidden(selected) }"
:src="stillThumbUrl(selected.id)"
alt="Selected still" alt="Selected still"
decoding="async" decoding="async"
@load="onStageStillLoad"
> >
</template> </template>
<template v-else> <template v-else>
@@ -684,24 +687,7 @@ const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form
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))
const stillUrl=(id:string)=>`/api/library/stills/${id}` const stillUrl=(id:string)=>`/api/library/stills/${id}`
const stillThumbUrl=(id:string)=>`/api/library/stills/${id}/thumb` const stillThumbUrl=(id:string)=>`/api/library/stills/${id}/thumb`
/** Stage shows thumb first, then swaps to original after decode — never leave the previous still painted. */ /** Stage always uses /thumb — never auto-upgrade to the archive master. */
const stageStillHiRes=ref(false)
const stageStillSrc=computed(()=>{
const id=selected.value?.id
if(!id || selected.value?.kind==='clip' || selected.value?.kind==='track') return ''
if(isHidden(selected.value)) return ''
return stageStillHiRes.value ? stillUrl(id) : stillThumbUrl(id)
})
function onStageStillLoad(e:Event){
const id=selected.value?.id
if(!id || stageStillHiRes.value) return
const img=e.target as HTMLImageElement
const bump=()=>{ if(selected.value?.id===id) stageStillHiRes.value=true }
if(typeof img.decode==='function') void img.decode().then(bump).catch(bump)
else bump()
}
watch(()=>selected.value?.id,()=>{ stageStillHiRes.value=false })
watch(eyeOpen,()=>{ if(eyeOpen.value) stageStillHiRes.value=false })
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId)) const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const benchJobs=computed(()=>queueJobs(folderJobs.value)) const benchJobs=computed(()=>queueJobs(folderJobs.value))
const musicJobs=computed(()=>studioQueue.value const musicJobs=computed(()=>studioQueue.value
@@ -1090,12 +1076,13 @@ async function applyIncomingGenerationLoad(){
} }
let request=payload.studio2Request let request=payload.studio2Request
if(!request && payload.studio2Id){ if(!request && payload.studio2Id){
await refresh() await refreshJobs()
request=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.request request=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.request
} }
if(!request){error.value='That generation has no Studio 2 request to load.';return} if(!request){error.value='That generation has no Studio 2 request to load.';return}
if(request.folderId)form.folderId=request.folderId if(request.folderId)form.folderId=request.folderId
await refresh() await refreshJobs()
if(!(library.value.stills||[]).length) await loadLibraryOnce()
const outputs=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.outputs||[] const outputs=jobs.value.find((j:any)=>j.id===payload.studio2Id)?.outputs||[]
const outId=outputs.at(-1)?.id const outId=outputs.at(-1)?.id
const output=outId?family.value.find((a:any)=>a.id===outId):null const output=outId?family.value.find((a:any)=>a.id===outId):null
@@ -1136,7 +1123,14 @@ async function deleteOutput(){
const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/' const kind=selected.value.kind==='clip'?'clips/':selected.value.kind==='track'?'tracks/':'stills/'
const id=selected.value.id const id=selected.value.id
await $fetch('/api/library/'+kind+id,{method:'DELETE'}) await $fetch('/api/library/'+kind+id,{method:'DELETE'})
await refresh() library.value={
...library.value,
stills:(library.value.stills||[]).filter((s:any)=>s.id!==id),
clips:(library.value.clips||[]).filter((c:any)=>c.id!==id),
tracks:(library.value.tracks||[]).filter((t:any)=>t.id!==id)
}
selected.value=null
await refreshJobs()
}catch(e:any){error.value=e.data?.statusMessage||e.message} }catch(e:any){error.value=e.data?.statusMessage||e.message}
} }
function stepFamily(delta:number){ function stepFamily(delta:number){
@@ -1280,7 +1274,23 @@ function loadRevealed(folderId:string){
catch{revealed.value=new Set()} catch{revealed.value=new Set()}
} }
watch(()=>sourceAsset.value?.id,id=>{const asset=sourceAsset.value;if(!import.meta.client||!asset||measured[id])return;const media=asset.id===form.startClipId?document.createElement('video'):new Image();if(media.tagName==='VIDEO')(media as HTMLVideoElement).preload='metadata';const done=()=>{measured[asset.id]={width:(media as HTMLVideoElement).videoWidth||(media as HTMLImageElement).naturalWidth,height:(media as HTMLVideoElement).videoHeight||(media as HTMLImageElement).naturalHeight}};media.addEventListener(media.tagName==='VIDEO'?'loadedmetadata':'load',done,{once:true});media.src=media.tagName==='VIDEO'?`/api/library/clips/${asset.id}/video`:stillUrl(asset.id)}) watch(()=>sourceAsset.value?.id,id=>{
const asset=sourceAsset.value
if(!import.meta.client||!asset||measured[id])return
if(asset.width && asset.height){
measured[asset.id]={width:Number(asset.width),height:Number(asset.height)}
return
}
// Clips without stored size: probe video metadata only (never the still archive master)
if(asset.id===form.startClipId || asset.kind==='clip'){
const media=document.createElement('video')
media.preload='metadata'
media.addEventListener('loadedmetadata',()=>{
measured[asset.id]={width:media.videoWidth,height:media.videoHeight}
},{once:true})
media.src=`/api/library/clips/${asset.id}/video`
}
})
watch(()=>selected.value?.id,()=>{videoTime.value=0;videoDuration.value=0}) watch(()=>selected.value?.id,()=>{videoTime.value=0;videoDuration.value=0})
function chooseTask(task:string){ function chooseTask(task:string){
@@ -1321,6 +1331,54 @@ async function refreshJobs(){
const nextLive:Record<string,any>={} const nextLive:Record<string,any>={}
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap} for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
liveMusic.value=nextLive liveMusic.value=nextLive
ingestNewOutputs(nextJobs)
}
/** Merge freshly completed outputs into the local catalog — never refetch the whole library. */
function ingestNewOutputs(nextJobs:any[]){
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
for(const job of nextJobs){
for(const output of job.outputs||[]){
if(seenOutputs.has(output.id)) continue
seenOutputs.add(output.id)
const meta=output.studio2||{}
const folderId=job.request?.folderId||form.folderId
if(output.kind==='clip'){
if(!(library.value.clips||[]).some((c:any)=>c.id===output.id)){
library.value={...library.value,clips:[...(library.value.clips||[]),{
id:output.id,folderId,createdAt:meta.savedAt||Date.now(),
name:meta.name||output.id.slice(0,8),hasThumbnail:true,hasVideo:true,
width:meta.outputWidth,height:meta.outputHeight,studio2:meta
}]}
}
}else if(output.kind!=='track'){
if(!(library.value.stills||[]).some((s:any)=>s.id===output.id)){
library.value={...library.value,stills:[...(library.value.stills||[]),{
id:output.id,folderId,createdAt:meta.savedAt||Date.now(),
name:meta.name||output.id.slice(0,8),role:'output',
width:meta.outputWidth,height:meta.outputHeight,
prompt:meta.prompt,promptRaw:meta.promptRaw,studio2:meta
}]}
}
}
}
}
if(fresh){
const existingFresh=family.value.find(a=>a.id===fresh.id)
if(existingFresh) selected.value=existingFresh
else{
const kind=fresh.kind==='clip'?'clip':'still'
selected.value={...fresh,kind,folderId:form.folderId,studio2:fresh.studio2}
}
}else{
let next=reconcileSelection(selected.value,family.value)
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
selected.value=next
}
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){
seenOutputs.add(newestTrack.id)
selected.value={...newestTrack,kind:'track'}
}
} }
async function queue(){ async function queue(){
const t0=typeof performance!=='undefined'?performance.now():Date.now() const t0=typeof performance!=='undefined'?performance.now():Date.now()
@@ -1347,13 +1405,16 @@ async function queue(){
jobs.value=[optimistic,...jobs.value] jobs.value=[optimistic,...jobs.value]
focusedJobId.value=localId focusedJobId.value=localId
logTiming('local-card') logTiming('local-card')
// Button live in one frame — POST may still be in flight
submitting.value=false
} }
try{ try{
if(form.mode==='music'){ if(form.mode==='music'){
logTiming('POST /api/generate/music start') logTiming('POST /api/generate/music start')
await $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}}) submitting.value=false
logTiming('POST music ack') void $fetch('/api/generate/music',{method:'POST',body:{engine:'yue2',tags:music.tags,lyrics:music.lyrics,duration:music.duration,seed:music.seed||'random',folderId:form.folderId,name:music.name}})
void refresh() .then(()=>{logTiming('POST music ack');void refreshJobs()})
.catch((e:any)=>{error.value=e.data?.statusMessage||e.message})
return return
} }
if(photoRoleWarning.value){ if(photoRoleWarning.value){
@@ -1373,57 +1434,30 @@ async function queue(){
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[] batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
}) })
logTiming('POST /api/studio-2/jobs start') logTiming('POST /api/studio-2/jobs start')
const queued=await Promise.race([ void $fetch<any>('/api/studio-2/jobs',{method:'POST',body})
$fetch<any>('/api/studio-2/jobs',{method:'POST',body}), .then((queued)=>{
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error('queue_ack_timeout')),12000))
]).catch(async(err)=>{
logTiming(`POST fail/timeout: ${err?.message||err}`)
// Keep the local card queued; poll will reconcile
if(String(err?.message||err).includes('queue_ack_timeout')) return null
throw err
})
logTiming('POST ack') logTiming('POST ack')
if(queued){ if(queued){
form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
jobs.value=jobs.value.map(j=>j.id===localId?{...j,id:queued.id,state:'waiting'}:j) jobs.value=jobs.value.map(j=>j.id===localId?{...j,id:queued.id,state:'waiting'}:j)
focusedJobId.value=queued.id focusedJobId.value=queued.id
} }
// Jobs only — do not refetch the whole library when a job starts
void refreshJobs().then(()=>logTiming('refreshJobs done')) void refreshJobs().then(()=>logTiming('refreshJobs done'))
})
.catch((e:any)=>{
jobs.value=jobs.value.filter(j=>j.id!==localId)
error.value=e.data?.statusMessage||e.message
})
}catch(e:any){ }catch(e:any){
jobs.value=jobs.value.filter(j=>j.id!==localId) jobs.value=jobs.value.filter(j=>j.id!==localId)
error.value=e.data?.statusMessage||e.message error.value=e.data?.statusMessage||e.message
}finally{submitting.value=false;logTiming('done')} submitting.value=false
}
} }
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 refresh(){ async function loadLibraryOnce(){
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([ library.value=await $fetch('/api/studio-2/library')
$fetch<any[]>('/api/studio-2/jobs'),
$fetch<any>('/api/studio-2/library'),
$fetch<any[]>('/api/studio-2/upscales'),
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
])
jobs.value=nextJobs;library.value=nextLibrary;upscales.value=nextUpscales
studioQueue.value=Array.isArray(queue)?queue:(queue?.jobs||[])
const musicRows=studioQueue.value.filter((j:any)=>j.kind==='music' && j.liveJobId && !['complete','done'].includes(j.status))
const liveEntries=await Promise.all(musicRows.map(async(j:any)=>{
try{return [j.liveJobId,await $fetch<any>(`/api/generate/${j.liveJobId}`)] as const}
catch{return [j.liveJobId,null] as const}
}))
const nextLive:Record<string,any>={}
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
liveMusic.value=nextLive
const fresh=newestOutput(nextJobs,seenOutputs,form.folderId)
for(const job of nextJobs)for(const output of job.outputs||[])seenOutputs.add(output.id)
const existingFresh=family.value.find(a=>a.id===fresh?.id)
const newestTrack=(library.value.tracks||[]).filter((t:any)=>t.folderId===form.folderId).sort((a:any,b:any)=>(b.createdAt||0)-(a.createdAt||0))[0]
// Only auto-select new tracks on Music — never leave a waveform mounted over Generate/Klein/Video.
if(form.mode==='music' && newestTrack && !seenOutputs.has(newestTrack.id)){seenOutputs.add(newestTrack.id);selected.value={...newestTrack,kind:'track'};return}
if(newestTrack) seenOutputs.add(newestTrack.id)
let next=existingFresh || reconcileSelection(selected.value,family.value)
if(form.mode!=='music' && next?.kind==='track') next=family.value.find((a:any)=>a.kind!=='track') || null
selected.value=next
} }
watch(()=>[ watch(()=>[
@@ -1445,7 +1479,7 @@ onMounted(()=>{
void (async()=>{ void (async()=>{
void loadLoras(false) void loadLoras(false)
try{ try{
library.value=await $fetch('/api/studio-2/library') await loadLibraryOnce()
restoreBenchDraft() restoreBenchDraft()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||'' if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
restoreBenchDraft() restoreBenchDraft()
@@ -1456,7 +1490,16 @@ onMounted(()=>{
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(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' if(route.query.task==='music')form.mode='music'
}catch(e:any){error.value=e.data?.statusMessage||e.message} }catch(e:any){error.value=e.data?.statusMessage||e.message}
const poll=async()=>{try{await refresh();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() // 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)
@@ -1522,8 +1565,6 @@ const mobileCtx=computed(()=>({
aspectOptions, aspectOptions,
stillUrl, stillUrl,
stillThumbUrl, stillThumbUrl,
stageStillSrc:stageStillSrc.value,
onStageStillLoad,
isHidden, isHidden,
slotHidden, slotHidden,
stillById, stillById,
+31 -13
View File
@@ -49,6 +49,7 @@
<div <div
class="m-stage" class="m-stage"
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
@touchstart.passive="onStageTouchStart" @touchstart.passive="onStageTouchStart"
@touchend.passive="onStageTouchEnd" @touchend.passive="onStageTouchEnd"
> >
@@ -102,22 +103,32 @@
aria-label="Next" aria-label="Next"
@click.stop="ctx.stepFamily(1)" @click.stop="ctx.stepFamily(1)"
>›</button> >›</button>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)"> <p
<p class="m-hidden-note">{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p> v-if="ctx.selected && ctx.isHidden(ctx.selected)"
</template> class="m-hidden-note"
<template v-else-if="ctx.selected?.kind==='track'"> >{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
<div class="m-audio"> <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'" /> <AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
</div> </div>
</template> </template>
<video <video
v-else-if="ctx.selected?.kind==='clip'" v-else-if="ctx.selected?.kind==='clip'"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id" :key="ctx.selected.id"
:src="`/api/library/clips/${ctx.selected.id}/video`" :src="`/api/library/clips/${ctx.selected.id}/video`"
controls controls
playsinline playsinline
/> />
<img v-else-if="ctx.selected" :key="ctx.selected.id" :src="ctx.stageStillSrc || ctx.stillUrl(ctx.selected.id)" alt="Selected still" decoding="async" @load="ctx.onStageStillLoad"> <img
v-else-if="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"
>
<p v-else class="m-muted">Open Library to pick a still or clip</p> <p v-else class="m-muted">Open Library to pick a still or clip</p>
</div> </div>
@@ -477,13 +488,26 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
background: #080808; background: #080808;
} }
.m-stage > img, .m-stage > img,
.m-stage > video { .m-stage > video,
.m-stage-still {
width: 100%; width: 100%;
height: 100%; height: 100%;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
} }
.m-stage-still--veiled {
opacity: 0;
pointer-events: none;
}
.m-hidden-note {
position: absolute;
z-index: 3;
margin: 0;
color: var(--muted);
font-size: var(--type-sm);
pointer-events: none;
}
.m-audio { width: 100%; max-width: 100%; padding: 12px; } .m-audio { width: 100%; max-width: 100%; padding: 12px; }
.m-stage-chrome { .m-stage-chrome {
position: absolute; position: absolute;
@@ -535,12 +559,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
padding: 0 4px; padding: 0 4px;
text-align: center; text-align: center;
} }
.m-hidden-note {
margin: 0;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.m-nav { .m-nav {
position: absolute; position: absolute;
top: 50%; top: 50%;
+1 -3
View File
@@ -802,11 +802,9 @@ function applyLibrary(data: unknown) {
} }
async function loadLibrary() { async function loadLibrary() {
// First page of metadata only — grid paints immediately; full catalog follows for grouping/search // First page of metadata only — never follow with a full light=1 catalog scan
const first = await $fetch<any>('/api/library?limit=50') const first = await $fetch<any>('/api/library?limit=50')
applyLibrary(first) applyLibrary(first)
const data = await $fetch<any>('/api/library?light=1')
applyLibrary(data)
} }
function folderChipCount(folder: LibraryFolder) { function folderChipCount(folder: LibraryFolder) {
+9 -17
View File
@@ -3,29 +3,21 @@ import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const query = getQuery(event) const query = getQuery(event)
const light = String(query.light || '') === '1' const light = String(query.light || '') === '1'
const full = String(query.full || '') === '1'
const limitRaw = query.limit const limitRaw = query.limit
const wantsPage = limitRaw != null && String(limitRaw) !== '' const hasLimit = limitRaw != null && String(limitRaw) !== ''
// Default GET /api/library → page helper (limit 50). Opt into full catalog with full=1 or legacy light=1.
const wantsFull = full || (light && !hasLimit)
if (wantsPage) { if (!wantsFull) {
const page = publicLibraryPage(event, { return publicLibraryPage(event, {
limit: Number(limitRaw) || 50, limit: hasLimit ? Number(limitRaw) || 50 : 50,
cursor: String(query.cursor || ''), cursor: String(query.cursor || ''),
folderId: String(query.folderId || query.folder || ''), folderId: String(query.folderId || query.folder || ''),
type: String(query.type || query.filter || 'all') type: String(query.type || query.filter || 'all')
}) })
try {
const { owner } = assertLibraryOwner(event)
const folderId = page.folders.find(folder => folder.unlocked)?.id
void maintainLibrary(owner, folderId).catch(() => null)
} catch { /* locked or anonymous */ }
return page
} }
const library = publicLibrary(event, { light }) // Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
try { return publicLibrary(event, { light: true })
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 library
}) })
+8 -1
View File
@@ -25,11 +25,18 @@ export default defineEventHandler(async (event) => {
assertFolderAccess(event, getStill(owner, id).folderId); assertFolderAccess(event, getStill(owner, id).folderId);
if (request.startClipId) if (request.startClipId)
assertFolderAccess(event, getClip(owner, request.startClipId).folderId); assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
// Header-only size probe — never decode the archive PNG on Generate click
await resolveRequestSize(owner,request); await resolveRequestSize(owner,request);
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' }; const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record); saveRecord(record);
const ack = { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
// Enqueue + kick after the client has the id — do not block the response on the worker wake path
void (async () => {
try {
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload }); const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
saveRecord({ ...readRecord(owner, id), queueId: row.id }); saveRecord({ ...readRecord(owner, id), queueId: row.id });
} catch { /* poll / resume will pick up waiting studio2 records */ }
kickStudioQueue(); kickStudioQueue();
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height }; })();
return ack;
}); });
+63 -20
View File
@@ -615,7 +615,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
} }
} }
/** Paginated metadata page — no file bytes, no existsSync join of the volume. */ /** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
export function publicLibraryPage( export function publicLibraryPage(
event: H3Event, event: H3Event,
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {} opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
@@ -624,7 +624,30 @@ export function publicLibraryPage(
const cursor = String(opts.cursor || '') const cursor = String(opts.cursor || '')
const folderId = String(opts.folderId || '') const folderId = String(opts.folderId || '')
const typeFilter = String(opts.type || 'all') const typeFilter = String(opts.type || 'all')
const full = publicLibrary(event, { light: true }) const owner = libraryOwnerKey(event)
const catalog = readCatalog(owner)
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
const clipCounts = new Map<string, number>()
const imageCounts = new Map<string, number>()
const trackCounts = new Map<string, number>()
for (const clip of catalog.clips) {
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
}
for (const still of catalog.stills) {
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
}
for (const track of catalog.tracks || []) {
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
}
const folders = catalog.folders.map(folder => publicFolder(
event,
folder,
clipCounts.get(folder.id) || 0,
imageCounts.get(folder.id) || 0,
trackCounts.get(folder.id) || 0
))
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
type Row = { type Row = {
id: string id: string
@@ -646,9 +669,11 @@ export function publicLibraryPage(
} }
const rows: Row[] = [] const rows: Row[] = []
for (const still of full.stills) { if (typeFilter !== 'videos' && typeFilter !== 'music') {
for (const still of catalog.stills) {
if (!unlockedIds.has(still.folderId)) continue
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
if (typeFilter === 'videos' || typeFilter === 'music') continue if (isPipelineStill(catalog, still, clipIds)) continue
rows.push({ rows.push({
id: still.id, id: still.id,
name: still.name || still.filename || still.id, name: still.name || still.filename || still.id,
@@ -661,14 +686,16 @@ export function publicLibraryPage(
promptPost: still.promptPost, promptPost: still.promptPost,
width: still.width, width: still.width,
height: still.height, height: still.height,
role: still.role, role: stillRole(still),
hideInput: still.hideInput, hideInput: still.hideInput,
filename: still.filename filename: still.filename
}) })
} }
for (const clip of full.clips) { }
if (typeFilter !== 'images' && typeFilter !== 'music') {
for (const clip of catalog.clips) {
if (!unlockedIds.has(clip.folderId)) continue
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
if (typeFilter === 'images' || typeFilter === 'music') continue
rows.push({ rows.push({
id: clip.id, id: clip.id,
name: clip.name || clip.id, name: clip.name || clip.id,
@@ -684,9 +711,11 @@ export function publicLibraryPage(
duration: clip.duration duration: clip.duration
}) })
} }
for (const track of full.tracks || []) { }
if (typeFilter !== 'images' && typeFilter !== 'videos') {
for (const track of catalog.tracks || []) {
if (!unlockedIds.has(track.folderId)) continue
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
if (typeFilter === 'images' || typeFilter === 'videos') continue
rows.push({ rows.push({
id: track.id, id: track.id,
name: track.name || track.id, name: track.name || track.id,
@@ -698,6 +727,7 @@ export function publicLibraryPage(
ext: track.ext ext: track.ext
}) })
} }
}
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id))) rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
@@ -711,21 +741,27 @@ export function publicLibraryPage(
const nextCursor = page.length === limit && last ? `${last.type}:${last.id}` : null const nextCursor = page.length === limit && last ? `${last.type}:${last.id}` : null
const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id)) const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
const clipIds = new Set(page.filter(r => r.type === 'clip').map(r => r.id)) const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id))
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id)) const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
return { return {
folders: full.folders, folders,
items: page, items: page,
nextCursor, nextCursor,
limit, limit,
stills: full.stills.filter(s => stillIds.has(s.id)), stills: catalog.stills
clips: full.clips.filter(c => clipIds.has(c.id)), .filter(s => stillIds.has(s.id))
tracks: (full.tracks || []).filter(t => trackIds.has(t.id)), .map(still => ({ ...still, role: stillRole(still) })),
drafts: full.drafts, clips: catalog.clips
loraAliases: full.loraAliases, .filter(c => clipIdPage.has(c.id))
loraTriggers: full.loraTriggers, .map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
keepPrompts: full.keepPrompts, tracks: (catalog.tracks || [])
.filter(t => trackIds.has(t.id))
.map(track => ({ ...track, hasAudio: true })),
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {},
loraTriggers: catalog.loraTriggers || {},
keepPrompts: catalog.keepPrompts || emptyKeepPrompts(),
total: rows.length total: rows.length
} }
} }
@@ -1288,11 +1324,17 @@ export async function stillThumbPath(owner: string, id: string) {
return build return build
} }
export async function ensureStillThumbnails(owner: string) { export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
const catalog = readCatalog(owner) const catalog = readCatalog(owner)
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
let built = 0
for (const still of catalog.stills) { for (const still of catalog.stills) {
if (built >= cap) break
if (isPipelineStill(catalog, still)) continue if (isPipelineStill(catalog, still)) continue
const dest = stillThumbFile(owner, still.id)
if (existsSync(dest)) continue
await stillThumbPath(owner, still.id) await stillThumbPath(owner, still.id)
built++
} }
} }
@@ -1302,7 +1344,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
const prev = lastMaintain.get(owner) || 0 const prev = lastMaintain.get(owner) || 0
if (Date.now() - prev < 20000) return if (Date.now() - prev < 20000) return
lastMaintain.set(owner, Date.now()) lastMaintain.set(owner, Date.now())
void ensureStillThumbnails(owner).catch(() => null) // At most 2 missing thumbs per tick — never walk the whole catalog on list
void ensureStillThumbnails(owner, { limit: 2 }).catch(() => null)
const live = await fetchLiveQueue().catch(() => null) const live = await fetchLiveQueue().catch(() => null)
const busy = Boolean(live && live.running > 0) const busy = Boolean(live && live.running > 0)
if (!busy) { if (!busy) {