Compare commits

...
3 Commits
14 changed files with 476 additions and 76 deletions
+1 -1
View File
@@ -95,7 +95,7 @@
> >
<div class="relative h-28 overflow-hidden bg-zinc-900"> <div class="relative h-28 overflow-hidden bg-zinc-900">
<img <img
:src="`/api/library/stills/${still.id}?thumb=1`" :src="`/api/library/stills/${still.id}/thumb`"
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
+1 -1
View File
@@ -55,6 +55,6 @@ defineEmits(['pick', 'remove', 'mention', 'update:role'])
const thumbSrc = computed(() => { const thumbSrc = computed(() => {
if (!props.asset) return '' if (!props.asset) return ''
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb` if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
return `/api/library/stills/${props.asset.id}` return `/api/library/stills/${props.asset.id}/thumb`
}) })
</script> </script>
+115 -14
View File
@@ -286,7 +286,7 @@
</template> </template>
<template v-else-if="selected?.kind==='track'"> <template v-else-if="selected?.kind==='track'">
<div class="w-full max-w-xl px-6 py-4"> <div class="w-full max-w-xl px-6 py-4">
<AudioPlayer :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'">
@@ -300,7 +300,13 @@
/> />
</template> </template>
<template v-else-if="selected"> <template v-else-if="selected">
<img :src="stillUrl(selected.id)" alt="Selected still"> <img
:key="selected.id"
:src="stageStillSrc"
alt="Selected still"
decoding="async"
@load="onStageStillLoad"
>
</template> </template>
<template v-else> <template v-else>
<p class="placeholder-still">Open Library to pick a still or clip</p> <p class="placeholder-still">Open Library to pick a still or clip</p>
@@ -431,7 +437,7 @@
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5"> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-5">
<button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-[var(--radius)] border border-[var(--line)] p-2 text-left" @click="pickAsset(a)"> <button v-for="a in pickerAssets" :key="a.id" type="button" class="rounded-[var(--radius)] border border-[var(--line)] p-2 text-left" @click="pickAsset(a)">
<span v-if="pickerThumbHidden(a)" class="flex h-24 items-center text-xs text-[var(--muted)]">Still loaded · preview hidden</span> <span v-if="pickerThumbHidden(a)" class="flex h-24 items-center text-xs text-[var(--muted)]">Still loaded · preview hidden</span>
<img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillUrl(a.id)" class="h-24 w-full object-contain" alt=""> <img v-else :src="a.kind==='clip'?'/api/library/clips/'+a.id+'/thumb':stillThumbUrl(a.id)" class="h-24 w-full object-contain" alt="">
<span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span> <span class="mt-2 block truncate text-xs">{{ assetLabel(a) }}</span>
</button> </button>
</div> </div>
@@ -677,6 +683,25 @@ watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.e
const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)) const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId))
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`
/** 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 })
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
@@ -1279,16 +1304,63 @@ function selectOutput(item:any){stageMenu.value='';selected.value=item;if(form.m
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''} function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id} function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null);focusedJobId.value=job.id}
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}} async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refresh()} async function clearFinished(){await $fetch('/api/studio-2/clear-finished',{method:'POST'});await refreshJobs()}
async function refreshJobs(){
const [nextJobs,nextUpscales,queue]=await Promise.all([
$fetch<any[]>('/api/studio-2/jobs'),
$fetch<any[]>('/api/studio-2/upscales'),
$fetch<any>('/api/studio-queue?full=1').catch(()=>({jobs:[]}))
])
jobs.value=nextJobs;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
}
async function queue(){ async function queue(){
const t0=typeof performance!=='undefined'?performance.now():Date.now()
const logTiming=(label:string)=>{
const t=typeof performance!=='undefined'?performance.now():Date.now()
console.info(`[bench-queue] ${label} +${Math.round(t-t0)}ms`)
}
submitting.value=true;error.value='' submitting.value=true;error.value=''
// Optimistic card in <100ms — never await library / object_info before the list updates
const localId=`local-${Date.now()}`
if(form.mode!=='music'){
const optimistic={
id:localId,
state:'waiting',
queuedAt:Date.now(),
request:structuredClone({
...form,
promptSections:video.value?form.promptSections:{action:form.promptSections.action},
settings:{...form.settings,...resolvedSize.value}
}),
outputs:[],
purgeResult:'Queued'
}
jobs.value=[optimistic,...jobs.value]
focusedJobId.value=localId
logTiming('local-card')
}
try{ try{
if(form.mode==='music'){ 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}}) 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}})
await refresh() logTiming('POST music ack')
void refresh()
return
}
if(photoRoleWarning.value){
jobs.value=jobs.value.filter(j=>j.id!==localId)
error.value=photoRoleWarning.value
return return
} }
if(photoRoleWarning.value){error.value=photoRoleWarning.value;return}
syncPhotosBindings() syncPhotosBindings()
if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed() if(form.settings.seedMode!=='locked')form.settings.seed=randomSeed()
const body=applyPhotosToRequest({ const body=applyPhotosToRequest({
@@ -1300,13 +1372,30 @@ async function queue(){
settings:{...form.settings,...resolvedSize.value}, settings:{...form.settings,...resolvedSize.value},
batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[] batch:video.value || form.mode==='edit'?parseBatch(batchText.value):[]
}) })
const queued=await $fetch<any>('/api/studio-2/jobs',{method:'POST',body}) 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 form.settings.seed=queued.seed;form.settings.width=queued.width;form.settings.height=queued.height
await refresh() jobs.value=jobs.value.map(j=>j.id===localId?{...j,id:queued.id,state:'waiting'}:j)
}catch(e:any){error.value=e.data?.statusMessage||e.message} focusedJobId.value=queued.id
finally{submitting.value=false} }
// Jobs only — do not refetch the whole library when a job starts
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
}finally{submitting.value=false;logTiming('done')}
} }
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()} async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
async function refresh(){ async function refresh(){
const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([ const [nextJobs,nextLibrary,nextUpscales,queue]=await Promise.all([
@@ -1344,14 +1433,21 @@ watch(()=>[
music.tags,music.lyrics,music.duration,music.seed,music.name music.tags,music.lyrics,music.duration,music.seed,music.name
],()=>saveBenchDraft(),{deep:true}) ],()=>saveBenchDraft(),{deep:true})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{ onMounted(()=>{
let storage;try{storage=localStorage}catch{} let storage;try{storage=localStorage}catch{}
loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed() loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed()
clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras(true) // First paint: chrome + empty stage + draft from localStorage — do not await library / object_info
restoreBenchDraft()
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
loadRevealed(form.folderId)
clock=setInterval(()=>{now.value=Date.now()},1000)
// Deferred heavy work after shell is interactive
void (async()=>{
void loadLoras(false)
try{
library.value=await $fetch('/api/studio-2/library') library.value=await $fetch('/api/studio-2/library')
restoreBenchDraft() restoreBenchDraft()
if(!form.folderId)form.folderId=library.value.folders[0]?.id||'' if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
// Folder watch may have cleared photos when folderId was assigned — re-apply draft photos/prompt.
restoreBenchDraft() restoreBenchDraft()
loadRevealed(form.folderId) loadRevealed(form.folderId)
await applyIncomingGenerationLoad() await applyIncomingGenerationLoad()
@@ -1359,7 +1455,9 @@ onMounted(async()=>{
const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===id) const asset=[...library.value.stills,...library.value.clips,...(library.value.tracks||[])].find((a:any)=>a.id===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(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}
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() 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()
})()
if(import.meta.client){ if(import.meta.client){
window.addEventListener('keydown',onStageHotkey) window.addEventListener('keydown',onStageHotkey)
window.addEventListener('pointerdown',onStagePointerDown,true) window.addEventListener('pointerdown',onStagePointerDown,true)
@@ -1423,6 +1521,9 @@ const mobileCtx=computed(()=>({
loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers}, loraTriggers:{...loraNames.value.triggers,...library.value.loraTriggers},
aspectOptions, aspectOptions,
stillUrl, stillUrl,
stillThumbUrl,
stageStillSrc:stageStillSrc.value,
onStageStillLoad,
isHidden, isHidden,
slotHidden, slotHidden,
stillById, stillById,
+101 -8
View File
@@ -60,6 +60,7 @@
v-for="entry in entries" v-for="entry in entries"
:key="entry.key" :key="entry.key"
class="lib-modal-tile" class="lib-modal-tile"
:data-thumb-key="entry.key"
:data-active="entry.key === focusedKey ? 'true' : undefined" :data-active="entry.key === focusedKey ? 'true' : undefined"
@click="pickEntry(entry)" @click="pickEntry(entry)"
> >
@@ -77,9 +78,10 @@
/> />
</div> </div>
<img <img
v-else-if="thumbSrc(entry)" v-else-if="liveThumbs.has(entry.key) && thumbSrc(entry)"
:src="thumbSrc(entry)" :src="thumbSrc(entry)"
alt="" alt=""
loading="lazy"
decoding="async" decoding="async"
draggable="false" draggable="false"
> >
@@ -170,6 +172,10 @@ const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([]) const stills = ref<LibraryStill[]>([])
const tracks = ref<LibraryTrack[]>([]) const tracks = ref<LibraryTrack[]>([])
const fetchedAt = ref(0) const fetchedAt = ref(0)
const nextCursor = ref<string | null>(null)
const liveThumbs = ref(new Set<string>())
let thumbObserver: IntersectionObserver | null = null
let scrollLoadBound = false
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all') const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
@@ -209,7 +215,7 @@ const detailRows = computed(() => {
function thumbSrc(entry: LibraryEntry) { function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb` if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return '' if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1` if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}/thumb`
return '' return ''
} }
@@ -259,14 +265,37 @@ function onDeleteClick() {
window.setTimeout(() => { void fetchLibrary(true) }, 500) window.setTimeout(() => { void fetchLibrary(true) }, 500)
} }
function applyLibrary(data: any) { function mergeIds(prev: { id: string }[], next: { id: string }[]) {
folders.value = data?.folders || [] const map = new Map(prev.map(item => [item.id, item]))
for (const item of next) map.set(item.id, item)
return [...map.values()]
}
function applyLibraryPage(data: any, replace: boolean) {
folders.value = data?.folders || folders.value
if (replace) {
clips.value = data?.clips || [] clips.value = data?.clips || []
stills.value = data?.stills || [] stills.value = data?.stills || []
tracks.value = data?.tracks || [] tracks.value = data?.tracks || []
} else {
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
}
nextCursor.value = data?.nextCursor || null
fetchedAt.value = Date.now() fetchedAt.value = Date.now()
} }
async function fetchLibraryPage(cursor: string | null, replace: boolean) {
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
const qs = new URLSearchParams({ limit: '50' })
if (folder) qs.set('folderId', folder)
if (type !== 'all') qs.set('type', type)
if (cursor) qs.set('cursor', cursor)
return await $fetch<any>(`/api/library?${qs.toString()}`)
}
async function fetchLibrary(force = false) { async function fetchLibrary(force = false) {
if (loading.value) return if (loading.value) return
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000 const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
@@ -274,15 +303,64 @@ async function fetchLibrary(force = false) {
if (!force && !empty && !stale) return if (!force && !empty && !stale) return
loading.value = true loading.value = true
loadError.value = '' loadError.value = ''
liveThumbs.value = new Set()
try { try {
applyLibrary(await $fetch('/api/library?light=1')) const first = await fetchLibraryPage(null, true)
applyLibraryPage(first, true)
loading.value = false
nextTick(() => observeThumbs())
// Pull remaining pages in the background — first paint already has ≤50 items
let cursor = first?.nextCursor || null
while (cursor) {
const more = await fetchLibraryPage(cursor, false)
applyLibraryPage(more, false)
cursor = more?.nextCursor || null
nextTick(() => observeThumbs())
}
} catch (e: any) { } catch (e: any) {
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library' loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
} finally {
loading.value = false loading.value = false
} }
} }
function observeThumbs() {
if (!import.meta.client) return
const root = gridScrollEl.value
if (!root) return
if (thumbObserver) thumbObserver.disconnect()
thumbObserver = new IntersectionObserver((entries) => {
let changed = false
const next = new Set(liveThumbs.value)
for (const entry of entries) {
if (!entry.isIntersecting) continue
const key = (entry.target as HTMLElement).dataset.thumbKey
if (!key || next.has(key)) continue
next.add(key)
changed = true
}
if (changed) liveThumbs.value = next
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
}
function onGridScroll() {
const el = gridScrollEl.value
if (!el || !nextCursor.value || loading.value) return
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
void fetchMore()
}
async function fetchMore() {
if (!nextCursor.value || loading.value) return
loading.value = true
try {
const more = await fetchLibraryPage(nextCursor.value, false)
applyLibraryPage(more, false)
nextTick(() => observeThumbs())
} catch { /* keep what we have */ }
finally { loading.value = false }
}
function onTab(e: KeyboardEvent) { function onTab(e: KeyboardEvent) {
const root = dialogEl.value const root = dialogEl.value
if (!root) return if (!root) return
@@ -308,15 +386,30 @@ watch(() => props.open, (on) => {
void fetchLibrary(true) void fetchLibrary(true)
nextTick(() => { nextTick(() => {
dialogEl.value?.focus() dialogEl.value?.focus()
if (gridScrollEl.value) gridScrollEl.value.scrollTop = 0 if (gridScrollEl.value) {
gridScrollEl.value.scrollTop = 0
if (!scrollLoadBound) {
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
scrollLoadBound = true
}
observeThumbs()
}
}) })
} else { } else {
document.body.style.overflow = '' document.body.style.overflow = ''
thumbObserver?.disconnect()
thumbObserver = null
} }
}) })
watch(entries, () => nextTick(() => observeThumbs()))
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = '' if (import.meta.client) {
document.body.style.overflow = ''
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
thumbObserver?.disconnect()
}
}) })
</script> </script>
+1 -1
View File
@@ -117,7 +117,7 @@
controls controls
playsinline playsinline
/> />
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still"> <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">
<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>
+10 -7
View File
@@ -802,7 +802,11 @@ function applyLibrary(data: unknown) {
} }
async function loadLibrary() { async function loadLibrary() {
applyLibrary(await $fetch('/api/library?light=1')) // First page of metadata only — grid paints immediately; full catalog follows for grouping/search
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) { function folderChipCount(folder: LibraryFolder) {
@@ -881,7 +885,7 @@ async function toggleFocusedHidden() {
function thumbSrc(entry: LibraryEntry) { function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb` if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return '' if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1` if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
return '' return ''
} }
@@ -907,7 +911,7 @@ function partThumb(part: LibraryClip | LibraryStill) {
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb. // Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}` return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
} }
return `/api/library/stills/${part.id}?thumb=1` return `/api/library/stills/${part.id}/thumb`
} }
function focusPart(id: string) { function focusPart(id: string) {
@@ -1309,10 +1313,9 @@ let resizeObserver: ResizeObserver | null = null
let observedScroller: HTMLElement | null = null let observedScroller: HTMLElement | null = null
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
const [me] = await Promise.all([ // Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })), void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] })) const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
])
instanceName.value = me.instanceName || instanceName.value instanceName.value = me.instanceName || instanceName.value
await nextTick() await nextTick()
measure() measure()
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
} }
const etag = fileEtag(path) const etag = fileEtag(path)
setHeader(event, 'ETag', etag) setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800') setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) { if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304) setResponseStatus(event, 304)
return null return null
+22 -1
View File
@@ -1,5 +1,26 @@
import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const light = String(getQuery(event).light || '') === '1' const query = getQuery(event)
const light = String(query.light || '') === '1'
const limitRaw = query.limit
const wantsPage = limitRaw != null && String(limitRaw) !== ''
if (wantsPage) {
const page = publicLibraryPage(event, {
limit: Number(limitRaw) || 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 }) const library = publicLibrary(event, { light })
try { try {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
+16 -3
View File
@@ -7,13 +7,26 @@ export default defineEventHandler(async (event) => {
const still = getStill(owner, id) const still = getStill(owner, id)
assertFolderAccess(event, still.folderId) assertFolderAccess(event, still.folderId)
const wantThumb = String(getQuery(event).thumb || '') === '1' const wantThumb = String(getQuery(event).thumb || '') === '1'
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id) const original = stillPath(owner, still.id)
if (!path || !existsSync(path)) {
let path = original
if (wantThumb) {
path = await stillThumbPath(owner, id)
// Never fall back to the archive PNG/JPEG for ?thumb=1
if (!path || path === original || !existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
}
} else if (!existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' }) throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
} }
const etag = fileEtag(path) const etag = fileEtag(path)
setHeader(event, 'ETag', etag) setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600') setHeader(
event,
'Cache-Control',
wantThumb ? 'public, max-age=86400' : 'private, max-age=3600'
)
if (getHeader(event, 'if-none-match') === etag) { if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304) setResponseStatus(event, 304)
return null return null
@@ -0,0 +1,26 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = await stillThumbPath(owner, id)
const original = stillPath(owner, still.id)
// Never stream the archive master as a "thumb"
if (!path || !existsSync(path) || path === original) {
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
}
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
})
+15 -9
View File
@@ -1,9 +1,15 @@
import { existsSync } from 'node:fs'; import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'; import { publicLibrary } from '../../utils/library'
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
export default defineEventHandler(event => { export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event); const { owner } = assertLibraryOwner(event)
const library = publicLibrary(event); // light:true — metadata only; never existsSync every still/clip on the volume
const catalog=studio2LoraCatalog(owner); const library = publicLibrary(event, { light: true })
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id))), tracks:library.tracks || []}; const catalog = studio2LoraCatalog(owner)
}); return {
...library,
loraAliases: catalog.aliases,
loraTriggers: catalog.triggers,
tracks: library.tracks || []
}
})
+4 -4
View File
@@ -1,19 +1,19 @@
import { readFileSync,writeFileSync,renameSync } from 'node:fs' import { readFileSync, writeFileSync, renameSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs' import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'
import { listStudioLoras } from '../../utils/loras' import { listStudioLoras } from '../../utils/loras'
import { studio2Root } from '../../utils/studio2/store' import { studio2Root } from '../../utils/studio2/store'
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog' import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
export default defineEventHandler(async event => { export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event) const { owner } = assertLibraryOwner(event)
const path = join(studio2Root(), `loras-${owner}.cache`) const path = join(studio2Root(), `loras-${owner}.cache`)
// Same live Comfy scan as Settings (/api/loras?fresh=1). Always fresh so CACHE_MS cannot freeze adds. const fresh = String(getQuery(event).fresh || '') === '1'
const listed = await cachedLoras({ const listed = await cachedLoras({
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } }, read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) }, write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
live: () => listStudioLoras({ fresh: true }), live: () => listStudioLoras({ fresh: true }),
fresh: true fresh
}) })
return { ...listed, ...studio2LoraCatalog(owner) } return { ...listed, ...studio2LoraCatalog(owner) }
}) })
+119 -3
View File
@@ -615,6 +615,121 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
} }
} }
/** Paginated metadata page — no file bytes, no existsSync join of the volume. */
export function publicLibraryPage(
event: H3Event,
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
) {
const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 60)
const cursor = String(opts.cursor || '')
const folderId = String(opts.folderId || '')
const typeFilter = String(opts.type || 'all')
const full = publicLibrary(event, { light: true })
type Row = {
id: string
name: string
type: 'still' | 'clip' | 'track'
createdAt: number
thumbUrl: string
folderId: string
prompt?: string
promptPre?: string
promptPost?: string
width?: number
height?: number
role?: string
hideInput?: boolean
filename?: string
duration?: number
ext?: string
}
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
})
}
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
})
}
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
})
}
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
let start = 0
if (cursor) {
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
start = idx >= 0 ? idx + 1 : 0
}
const page = rows.slice(start, start + limit)
const last = page[page.length - 1]
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 trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
return {
folders: full.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,
total: rows.length
}
}
export function assertLibraryOwner(event: H3Event) { export function assertLibraryOwner(event: H3Event) {
const owner = libraryOwnerKey(event) const owner = libraryOwnerKey(event)
return { owner, catalog: readCatalog(owner) } return { owner, catalog: readCatalog(owner) }
@@ -830,7 +945,7 @@ export async function ensureScaledThumb(source: string, dest: string) {
if (pending) return pending if (pending) return pending
const build = withThumbSlot(async () => { const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest) const ok = await buildStillThumb(source, dest)
return ok ? dest : source return ok ? dest : ''
}).finally(() => { }).finally(() => {
thumbBuilds.delete(key) thumbBuilds.delete(key)
}) })
@@ -1130,7 +1245,7 @@ function buildStillThumb(source: string, dest: string) {
const proc = spawn('ffmpeg', [ const proc = spawn('ffmpeg', [
'-y', '-y',
'-i', source, '-i', source,
'-vf', 'scale=320:-2', '-vf', 'scale=384:-2',
'-frames:v', '1', '-frames:v', '1',
'-q:v', '5', '-q:v', '5',
dest dest
@@ -1164,7 +1279,8 @@ export async function stillThumbPath(owner: string, id: string) {
if (pending) return pending if (pending) return pending
const build = withThumbSlot(async () => { const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest) const ok = await buildStillThumb(source, dest)
return ok ? dest : source // Never return the archive master — callers must not stream originals as thumbs
return ok ? dest : ''
}).finally(() => { }).finally(() => {
thumbBuilds.delete(key) thumbBuilds.delete(key)
}) })
+31 -10
View File
@@ -1,25 +1,46 @@
import { readFileSync } from 'node:fs' import { openSync, readSync, closeSync } from 'node:fs'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { getClip, getStill, clipVideoPath, stillPath } from '../library' import { getClip, getStill, clipVideoPath, stillPath } from '../library'
import { imageDimensions } from '../resolution' import { imageDimensions } from '../resolution'
import { resolveSize } from '~/shared/studio2/input.mjs' import { resolveSize } from '~/shared/studio2/input.mjs'
const exec = promisify(execFile) const exec = promisify(execFile)
function readImageHeader(path: string, maxBytes = 65536) {
const fd = openSync(path, 'r')
try {
const buf = Buffer.alloc(maxBytes)
const n = readSync(fd, buf, 0, maxBytes, 0)
return buf.subarray(0, n)
} finally {
closeSync(fd)
}
}
export async function resolveRequestSize(owner: string, request: any) { export async function resolveRequestSize(owner: string, request: any) {
const video=['video','extend'].includes(request.mode) const video = ['video', 'extend'].includes(request.mode)
let dimensions:any let dimensions: any
if (video && request.startClipId) { if (video && request.startClipId) {
getClip(owner,request.startClipId) const clip = getClip(owner, request.startClipId)
const result=await exec('ffprobe',['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','json',clipVideoPath(owner,request.startClipId)],{windowsHide:true,timeout:15000}) if (clip.width && clip.height) {
dimensions=JSON.parse(result.stdout).streams?.[0] dimensions = { width: clip.width, height: clip.height }
} else { } else {
const id=request.imageAId || request.identityStillId const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
dimensions = JSON.parse(result.stdout).streams?.[0]
}
} else {
const id = request.imageAId || request.identityStillId
if (id) { if (id) {
getStill(owner,id) const still = getStill(owner, id)
dimensions=imageDimensions(readFileSync(stillPath(owner,id))) if (still.width && still.height) {
dimensions = { width: still.width, height: still.height }
} else {
// Header only — never read the full archive PNG into memory on Generate click
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
}
if (!dimensions) throw new Error('Cannot measure the source still.') if (!dimensions) throw new Error('Cannot measure the source still.')
} }
} }
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.') if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine)) Object.assign(request.settings, resolveSize(request.settings.aspect, dimensions, undefined, request.engine))
} }