diff --git a/assets/css/display.css b/assets/css/display.css
index e1fab35..b97b516 100644
--- a/assets/css/display.css
+++ b/assets/css/display.css
@@ -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;
diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue
index 082bd0c..1a068bb 100644
--- a/components/studio2/Bench.vue
+++ b/components/studio2/Bench.vue
@@ -280,17 +280,19 @@
aria-label="Previous"
@click="stepFamily(-1)"
>‹
-
-
- {{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}
-
-
-
+
+
{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}
+
+
@@ -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={}
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('/api/studio-2/jobs',{method:'POST',body}),
- new Promise((_,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('/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('/api/studio-2/jobs'),
- $fetch('/api/studio-2/library'),
- $fetch('/api/studio-2/upscales'),
- $fetch('/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(`/api/generate/${j.liveJobId}`)] as const}
- catch{return [j.liveJobId,null] as const}
- }))
- const nextLive:Record={}
- 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('/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('/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,
diff --git a/components/studio2/Mobile.vue b/components/studio2/Mobile.vue
index 56e31d4..7b403a2 100644
--- a/components/studio2/Mobile.vue
+++ b/components/studio2/Mobile.vue
@@ -49,6 +49,7 @@
@@ -102,22 +103,32 @@
aria-label="Next"
@click.stop="ctx.stepFamily(1)"
>›
-
- {{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}
-
-
-
+
{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}
+
+
-
![Selected still]()
+
Open Library to pick a still or clip
@@ -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%;
diff --git a/pages/library.vue b/pages/library.vue
index 3656062..648f507 100644
--- a/pages/library.vue
+++ b/pages/library.vue
@@ -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('/api/library?limit=50')
applyLibrary(first)
- const data = await $fetch('/api/library?light=1')
- applyLibrary(data)
}
function folderChipCount(folder: LibraryFolder) {
diff --git a/server/api/library/index.get.ts b/server/api/library/index.get.ts
index 7a10894..7b0bd3b 100644
--- a/server/api/library/index.get.ts
+++ b/server/api/library/index.get.ts
@@ -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 })
})
diff --git a/server/api/studio-2/jobs.post.ts b/server/api/studio-2/jobs.post.ts
index 3cc413c..551ddeb 100644
--- a/server/api/studio-2/jobs.post.ts
+++ b/server/api/studio-2/jobs.post.ts
@@ -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;
});
diff --git a/server/utils/library.ts b/server/utils/library.ts
index 1a4de3f..d4de9c6 100644
--- a/server/utils/library.ts
+++ b/server/utils/library.ts
@@ -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()
+ const imageCounts = new Map()
+ const trackCounts = new Map()
+ 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) {