Stop polling the library catalog and keep the stage on thumbs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+123
-82
@@ -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,
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user