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