Compare commits
3
Commits
845ee5bbf6
...
cfabfd37ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfabfd37ee | ||
|
|
ffdaa7ce42 | ||
|
|
aa0aa871e4 |
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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}
|
|
||||||
}
|
}
|
||||||
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refresh()}
|
// 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 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
@@ -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 || []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ 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
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
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)
|
||||||
|
if (clip.width && clip.height) {
|
||||||
|
dimensions = { width: clip.width, height: clip.height }
|
||||||
|
} else {
|
||||||
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 })
|
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]
|
dimensions = JSON.parse(result.stdout).streams?.[0]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const id = request.imageAId || request.identityStillId
|
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.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user