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 */
|
||||
.viewer-frame > img,
|
||||
.viewer-frame > video {
|
||||
.viewer-frame > video,
|
||||
.viewer-still {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
@@ -356,6 +357,18 @@
|
||||
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 */
|
||||
.stage-chrome {
|
||||
display: flex;
|
||||
|
||||
+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%;
|
||||
|
||||
+1
-3
@@ -802,11 +802,9 @@ function applyLibrary(data: unknown) {
|
||||
}
|
||||
|
||||
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')
|
||||
applyLibrary(first)
|
||||
const data = await $fetch<any>('/api/library?light=1')
|
||||
applyLibrary(data)
|
||||
}
|
||||
|
||||
function folderChipCount(folder: LibraryFolder) {
|
||||
|
||||
@@ -3,29 +3,21 @@ import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const light = String(query.light || '') === '1'
|
||||
const full = String(query.full || '') === '1'
|
||||
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) {
|
||||
const page = publicLibraryPage(event, {
|
||||
limit: Number(limitRaw) || 50,
|
||||
if (!wantsFull) {
|
||||
return publicLibraryPage(event, {
|
||||
limit: hasLimit ? Number(limitRaw) || 50 : 50,
|
||||
cursor: String(query.cursor || ''),
|
||||
folderId: String(query.folderId || query.folder || ''),
|
||||
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 })
|
||||
try {
|
||||
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
|
||||
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
|
||||
return publicLibrary(event, { light: true })
|
||||
})
|
||||
|
||||
@@ -25,11 +25,18 @@ export default defineEventHandler(async (event) => {
|
||||
assertFolderAccess(event, getStill(owner, id).folderId);
|
||||
if (request.startClipId)
|
||||
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
||||
// Header-only size probe — never decode the archive PNG on Generate click
|
||||
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' };
|
||||
saveRecord(record);
|
||||
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 });
|
||||
kickStudioQueue();
|
||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||
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 });
|
||||
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||
} catch { /* poll / resume will pick up waiting studio2 records */ }
|
||||
kickStudioQueue();
|
||||
})();
|
||||
return ack;
|
||||
});
|
||||
|
||||
+105
-62
@@ -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(
|
||||
event: H3Event,
|
||||
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
|
||||
@@ -624,7 +624,30 @@ export function publicLibraryPage(
|
||||
const cursor = String(opts.cursor || '')
|
||||
const folderId = String(opts.folderId || '')
|
||||
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 = {
|
||||
id: string
|
||||
@@ -646,57 +669,64 @@ export function publicLibraryPage(
|
||||
}
|
||||
|
||||
const rows: Row[] = []
|
||||
for (const still of full.stills) {
|
||||
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
|
||||
if (typeFilter === 'videos' || typeFilter === 'music') continue
|
||||
rows.push({
|
||||
id: still.id,
|
||||
name: still.name || still.filename || still.id,
|
||||
type: 'still',
|
||||
createdAt: still.createdAt || 0,
|
||||
thumbUrl: `/api/library/stills/${still.id}/thumb`,
|
||||
folderId: still.folderId,
|
||||
prompt: still.prompt,
|
||||
promptPre: still.promptPre,
|
||||
promptPost: still.promptPost,
|
||||
width: still.width,
|
||||
height: still.height,
|
||||
role: still.role,
|
||||
hideInput: still.hideInput,
|
||||
filename: still.filename
|
||||
})
|
||||
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 (isPipelineStill(catalog, still, clipIds)) continue
|
||||
rows.push({
|
||||
id: still.id,
|
||||
name: still.name || still.filename || still.id,
|
||||
type: 'still',
|
||||
createdAt: still.createdAt || 0,
|
||||
thumbUrl: `/api/library/stills/${still.id}/thumb`,
|
||||
folderId: still.folderId,
|
||||
prompt: still.prompt,
|
||||
promptPre: still.promptPre,
|
||||
promptPost: still.promptPost,
|
||||
width: still.width,
|
||||
height: still.height,
|
||||
role: stillRole(still),
|
||||
hideInput: still.hideInput,
|
||||
filename: still.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const clip of full.clips) {
|
||||
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
|
||||
if (typeFilter === 'images' || typeFilter === 'music') continue
|
||||
rows.push({
|
||||
id: clip.id,
|
||||
name: clip.name || clip.id,
|
||||
type: 'clip',
|
||||
createdAt: clip.createdAt || 0,
|
||||
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
|
||||
folderId: clip.folderId,
|
||||
prompt: clip.prompt,
|
||||
promptPre: clip.promptPre,
|
||||
promptPost: clip.promptPost,
|
||||
width: clip.width,
|
||||
height: clip.height,
|
||||
duration: clip.duration
|
||||
})
|
||||
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
|
||||
rows.push({
|
||||
id: clip.id,
|
||||
name: clip.name || clip.id,
|
||||
type: 'clip',
|
||||
createdAt: clip.createdAt || 0,
|
||||
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
|
||||
folderId: clip.folderId,
|
||||
prompt: clip.prompt,
|
||||
promptPre: clip.promptPre,
|
||||
promptPost: clip.promptPost,
|
||||
width: clip.width,
|
||||
height: clip.height,
|
||||
duration: clip.duration
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const track of full.tracks || []) {
|
||||
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
|
||||
if (typeFilter === 'images' || typeFilter === 'videos') continue
|
||||
rows.push({
|
||||
id: track.id,
|
||||
name: track.name || track.id,
|
||||
type: 'track',
|
||||
createdAt: track.createdAt || 0,
|
||||
thumbUrl: '',
|
||||
folderId: track.folderId,
|
||||
duration: track.duration,
|
||||
ext: track.ext
|
||||
})
|
||||
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
|
||||
rows.push({
|
||||
id: track.id,
|
||||
name: track.name || track.id,
|
||||
type: 'track',
|
||||
createdAt: track.createdAt || 0,
|
||||
thumbUrl: '',
|
||||
folderId: track.folderId,
|
||||
duration: track.duration,
|
||||
ext: track.ext
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 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))
|
||||
|
||||
return {
|
||||
folders: full.folders,
|
||||
folders,
|
||||
items: page,
|
||||
nextCursor,
|
||||
limit,
|
||||
stills: full.stills.filter(s => stillIds.has(s.id)),
|
||||
clips: full.clips.filter(c => clipIds.has(c.id)),
|
||||
tracks: (full.tracks || []).filter(t => trackIds.has(t.id)),
|
||||
drafts: full.drafts,
|
||||
loraAliases: full.loraAliases,
|
||||
loraTriggers: full.loraTriggers,
|
||||
keepPrompts: full.keepPrompts,
|
||||
stills: catalog.stills
|
||||
.filter(s => stillIds.has(s.id))
|
||||
.map(still => ({ ...still, role: stillRole(still) })),
|
||||
clips: catalog.clips
|
||||
.filter(c => clipIdPage.has(c.id))
|
||||
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1288,11 +1324,17 @@ export async function stillThumbPath(owner: string, id: string) {
|
||||
return build
|
||||
}
|
||||
|
||||
export async function ensureStillThumbnails(owner: string) {
|
||||
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
|
||||
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) {
|
||||
if (built >= cap) break
|
||||
if (isPipelineStill(catalog, still)) continue
|
||||
const dest = stillThumbFile(owner, still.id)
|
||||
if (existsSync(dest)) continue
|
||||
await stillThumbPath(owner, still.id)
|
||||
built++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,7 +1344,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
|
||||
const prev = lastMaintain.get(owner) || 0
|
||||
if (Date.now() - prev < 20000) return
|
||||
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 busy = Boolean(live && live.running > 0)
|
||||
if (!busy) {
|
||||
|
||||
Reference in New Issue
Block a user