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
+123 -82
View File
@@ -280,17 +280,19 @@
aria-label="Previous"
@click="stepFamily(-1)"
>‹</button>
<div class="viewer-frame">
<template v-if="selected && isHidden(selected)">
<p class="placeholder-still">{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
</template>
<template v-else-if="selected?.kind==='track'">
<div class="w-full max-w-xl px-6 py-4">
<div class="viewer-frame" :class="{ 'viewer-frame--veiled': selected && isHidden(selected) }">
<p
v-if="selected && isHidden(selected)"
class="placeholder-still viewer-veil-label"
>{{ 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'" />
</div>
</template>
<template v-else-if="selected?.kind==='clip'">
<video
v-show="!isHidden(selected)"
ref="viewerVideo"
:key="selected.id"
:src="`/api/library/clips/${selected.id}/video`"
@@ -302,10 +304,11 @@
<template v-else-if="selected">
<img
:key="selected.id"
:src="stageStillSrc"
class="viewer-still"
:class="{ 'viewer-still--veiled': isHidden(selected) }"
:src="stillThumbUrl(selected.id)"
alt="Selected still"
decoding="async"
@load="onStageStillLoad"
>
</template>
<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 stillUrl=(id:string)=>`/api/library/stills/${id}`
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. */
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 })
/** Stage always uses /thumb — never auto-upgrade to the archive master. */
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const benchJobs=computed(()=>queueJobs(folderJobs.value))
const musicJobs=computed(()=>studioQueue.value
@@ -1090,12 +1076,13 @@ async function applyIncomingGenerationLoad(){
}
let request=payload.studio2Request
if(!request && payload.studio2Id){
await refresh()
await refreshJobs()
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.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 outId=outputs.at(-1)?.id
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 id=selected.value.id
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}
}
function stepFamily(delta:number){
@@ -1280,7 +1274,23 @@ function loadRevealed(folderId:string){
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})
function chooseTask(task:string){
@@ -1321,6 +1331,54 @@ async function refreshJobs(){
const nextLive:Record<string,any>={}
for(const [id,snap] of liveEntries){if(snap)nextLive[id]=snap}
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(){
const t0=typeof performance!=='undefined'?performance.now():Date.now()
@@ -1347,13 +1405,16 @@ async function queue(){
jobs.value=[optimistic,...jobs.value]
focusedJobId.value=localId
logTiming('local-card')
// Button live in one frame — POST may still be in flight
submitting.value=false
}
try{
if(form.mode==='music'){
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}})
logTiming('POST music ack')
void refresh()
submitting.value=false
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}})
.then(()=>{logTiming('POST music ack');void refreshJobs()})
.catch((e:any)=>{error.value=e.data?.statusMessage||e.message})
return
}
if(photoRoleWarning.value){
@@ -1373,57 +1434,30 @@ async function queue(){
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
})
logTiming('POST /api/studio-2/jobs start')
const queued=await Promise.race([
$fetch<any>('/api/studio-2/jobs',{method:'POST',body}),
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')
if(queued){
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)
focusedJobId.value=queued.id
}
// Jobs only — do not refetch the whole library when a job starts
void refreshJobs().then(()=>logTiming('refreshJobs done'))
void $fetch<any>('/api/studio-2/jobs',{method:'POST',body})
.then((queued)=>{
logTiming('POST ack')
if(queued){
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)
focusedJobId.value=queued.id
}
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){
jobs.value=jobs.value.filter(j=>j.id!==localId)
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 refresh(){
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
$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
async function loadLibraryOnce(){
library.value=await $fetch('/api/studio-2/library')
}
watch(()=>[
@@ -1445,7 +1479,7 @@ onMounted(()=>{
void (async()=>{
void loadLoras(false)
try{
library.value=await $fetch('/api/studio-2/library')
await loadLibraryOnce()
restoreBenchDraft()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
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(route.query.task==='music')form.mode='music'
}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){
window.addEventListener('keydown',onStageHotkey)
@@ -1522,8 +1565,6 @@ const mobileCtx=computed(()=>({
aspectOptions,
stillUrl,
stillThumbUrl,
stageStillSrc:stageStillSrc.value,
onStageStillLoad,
isHidden,
slotHidden,
stillById,
+31 -13
View File
@@ -49,6 +49,7 @@
<div
class="m-stage"
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
@touchstart.passive="onStageTouchStart"
@touchend.passive="onStageTouchEnd"
>
@@ -102,22 +103,32 @@
aria-label="Next"
@click.stop="ctx.stepFamily(1)"
>›</button>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
<p class="m-hidden-note">{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
</template>
<template v-else-if="ctx.selected?.kind==='track'">
<div class="m-audio">
<p
v-if="ctx.selected && ctx.isHidden(ctx.selected)"
class="m-hidden-note"
>{{ 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'" />
</div>
</template>
<video
v-else-if="ctx.selected?.kind==='clip'"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id"
:src="`/api/library/clips/${ctx.selected.id}/video`"
controls
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>
</div>
@@ -477,13 +488,26 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
background: #080808;
}
.m-stage > img,
.m-stage > video {
.m-stage > video,
.m-stage-still {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
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-stage-chrome {
position: absolute;
@@ -535,12 +559,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
padding: 0 4px;
text-align: center;
}
.m-hidden-note {
margin: 0;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.m-nav {
position: absolute;
top: 50%;