Serve the bench before the catalog and never await ffmpeg on /thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -280,11 +280,11 @@
|
|||||||
aria-label="Previous"
|
aria-label="Previous"
|
||||||
@click="stepFamily(-1)"
|
@click="stepFamily(-1)"
|
||||||
>‹</button>
|
>‹</button>
|
||||||
<div class="viewer-frame" :class="{ 'viewer-frame--veiled': selected && isHidden(selected) }">
|
<div class="viewer-frame">
|
||||||
<p
|
<p
|
||||||
v-if="selected && isHidden(selected)"
|
v-show="selected && isHidden(selected)"
|
||||||
class="placeholder-still viewer-veil-label"
|
class="placeholder-still viewer-veil-label"
|
||||||
>{{ selected.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
|
>{{ selected?.kind==='track' ? 'Track loaded · preview hidden' : 'Output hidden' }}</p>
|
||||||
<template v-if="selected?.kind==='track'">
|
<template v-if="selected?.kind==='track'">
|
||||||
<div v-show="!isHidden(selected)" class="w-full max-w-xl px-6 py-4">
|
<div v-show="!isHidden(selected)" class="w-full max-w-xl px-6 py-4">
|
||||||
<AudioPlayer :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
<AudioPlayer :key="selected.id" :src="`/api/library/tracks/${selected.id}/audio`" :download-name="`${selected.name || 'track'}.${selected.ext || 'wav'}`" :label="selected.name || 'Track'" />
|
||||||
@@ -303,9 +303,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="selected">
|
<template v-else-if="selected">
|
||||||
<img
|
<img
|
||||||
|
v-show="!isHidden(selected)"
|
||||||
:key="selected.id"
|
:key="selected.id"
|
||||||
class="viewer-still"
|
class="viewer-still"
|
||||||
:class="{ 'viewer-still--veiled': isHidden(selected) }"
|
|
||||||
:src="stillThumbUrl(selected.id)"
|
:src="stillThumbUrl(selected.id)"
|
||||||
alt="Selected still"
|
alt="Selected still"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -681,7 +681,19 @@ watch(()=>[form.mode,form.engine],([mode,engine])=>{
|
|||||||
syncPhotosBindings()
|
syncPhotosBindings()
|
||||||
saveBenchDraft(true)
|
saveBenchDraft(true)
|
||||||
})
|
})
|
||||||
watch(()=>form.folderId,id=>{selected.value=null;form.imageAId='';form.imageBId='';form.photos=[];form.startClipId='';loadRevealed(id);if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''},{flush:'sync'})
|
watch(()=>form.folderId,(id,prev)=>{
|
||||||
|
selected.value=null
|
||||||
|
// Wipe Photos only when leaving an existing folder — first restore from '' must keep the draft
|
||||||
|
if(prev){
|
||||||
|
form.imageAId=''
|
||||||
|
form.imageBId=''
|
||||||
|
form.photos=[]
|
||||||
|
form.startClipId=''
|
||||||
|
}
|
||||||
|
loadRevealed(id)
|
||||||
|
if(import.meta.client && form.engine!=='qwen21')form.identityStillId=localStorage.getItem(`studio2-hero:${id}`)||''
|
||||||
|
void loadLibraryOnce().catch(()=>null)
|
||||||
|
},{flush:'sync'})
|
||||||
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId&&form.engine!=='qwen21')localStorage.setItem(`studio2-hero:${form.folderId}`,id)})
|
||||||
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))
|
||||||
@@ -695,10 +707,11 @@ const musicJobs=computed(()=>studioQueue.value
|
|||||||
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
|
.map((j:any)=>({...j,live:j.liveJobId?liveMusic.value[j.liveJobId]:null})))
|
||||||
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
|
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value,musicJobs.value))
|
||||||
const gpuChipLabel=computed(()=>{
|
const gpuChipLabel=computed(()=>{
|
||||||
|
if(!healthState.value) return 'GPU ?'
|
||||||
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
|
const raw=String(healthState.value?.comfy?.gpu || healthState.value?.gpuName || healthState.value?.comfy?.device || '').trim()
|
||||||
const short=raw.match(/(\d{3,4})\b/)?.[1]
|
const short=raw.match(/(\d{3,4})\b/)?.[1]
|
||||||
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
|
if(gpu.value.tone==='ready') return short ? `${short} Ready` : 'Ready'
|
||||||
return gpu.value.label
|
return gpu.value.label || 'GPU ?'
|
||||||
})
|
})
|
||||||
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
const sourceSlot=computed(()=>{const clip=clips.value.find((a:any)=>a.id===form.startClipId);return form.mode==='extend'?(clip?{...clip,kind:'clip'}:null):inputStill.value})
|
||||||
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
const pickerAssets=computed(()=>picker.value==='startClipId'?clips.value.map((a:any)=>({...a,kind:'clip'})):stills.value.map((a:any)=>({...a,kind:'still'})))
|
||||||
@@ -1457,7 +1470,31 @@ async function queue(){
|
|||||||
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
|
async function cancel(job:any){await $fetch('/api/studio-2/cancel',{method:'POST',body:{id:job.id}});await refreshJobs()}
|
||||||
|
|
||||||
async function loadLibraryOnce(){
|
async function loadLibraryOnce(){
|
||||||
library.value=await $fetch('/api/studio-2/library')
|
// Current folder page only — never the yearly Inbox JSON on /
|
||||||
|
const qs=new URLSearchParams({limit:'50'})
|
||||||
|
if(form.folderId) qs.set('folderId',form.folderId)
|
||||||
|
const page=await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||||
|
library.value={
|
||||||
|
folders:page.folders||library.value.folders||[],
|
||||||
|
stills:page.stills||[],
|
||||||
|
clips:page.clips||[],
|
||||||
|
tracks:page.tracks||[],
|
||||||
|
drafts:page.drafts||library.value.drafts||[],
|
||||||
|
loraAliases:page.loraAliases||library.value.loraAliases||{},
|
||||||
|
loraTriggers:page.loraTriggers||library.value.loraTriggers||{},
|
||||||
|
keepPrompts:page.keepPrompts||library.value.keepPrompts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHealth(){
|
||||||
|
try{
|
||||||
|
return await Promise.race([
|
||||||
|
$fetch<any>('/api/health'),
|
||||||
|
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error('health_timeout')),800))
|
||||||
|
])
|
||||||
|
}catch{
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(()=>[
|
watch(()=>[
|
||||||
@@ -1470,37 +1507,32 @@ let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,dis
|
|||||||
onMounted(()=>{
|
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()
|
||||||
// First paint: chrome + empty stage + draft from localStorage — do not await library / object_info
|
// First paint in ~2s: chrome + draft. Do not await library before the prompt works.
|
||||||
restoreBenchDraft()
|
restoreBenchDraft()
|
||||||
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
|
if(!form.folderId && library.value.folders?.[0]?.id) form.folderId=library.value.folders[0].id
|
||||||
loadRevealed(form.folderId)
|
loadRevealed(form.folderId)
|
||||||
clock=setInterval(()=>{now.value=Date.now()},1000)
|
clock=setInterval(()=>{now.value=Date.now()},1000)
|
||||||
// Deferred heavy work after shell is interactive
|
|
||||||
void (async()=>{
|
|
||||||
void loadLoras(false)
|
void loadLoras(false)
|
||||||
try{
|
// Folder page in background — never blocks Generate / prompt
|
||||||
await loadLibraryOnce()
|
void loadLibraryOnce().then(()=>{
|
||||||
restoreBenchDraft()
|
restoreBenchDraft()
|
||||||
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
|
if(!form.folderId)form.folderId=library.value.folders[0]?.id||''
|
||||||
restoreBenchDraft()
|
|
||||||
loadRevealed(form.folderId)
|
loadRevealed(form.folderId)
|
||||||
await applyIncomingGenerationLoad()
|
void applyIncomingGenerationLoad()
|
||||||
const id=String(route.query.still||route.query.clip||route.query.track||'')
|
const id=String(route.query.still||route.query.clip||route.query.track||'')
|
||||||
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}
|
}).catch((e:any)=>{error.value=e.data?.statusMessage||e.message})
|
||||||
// Idle poll: jobs + health only — never /api/studio-2/library
|
// Idle poll: jobs + health only — 800ms health budget, chip GPU ? on miss
|
||||||
const poll=async()=>{
|
const poll=async()=>{
|
||||||
try{
|
try{
|
||||||
await refreshJobs()
|
await refreshJobs()
|
||||||
const health=await $fetch<any>('/api/health')
|
healthState.value=await fetchHealth()
|
||||||
healthState.value=health
|
|
||||||
}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}
|
}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}
|
||||||
if(!disposed)timer=setTimeout(poll,3000)
|
if(!disposed)timer=setTimeout(poll,3000)
|
||||||
}
|
}
|
||||||
void poll()
|
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)
|
||||||
|
|||||||
@@ -104,9 +104,9 @@
|
|||||||
@click.stop="ctx.stepFamily(1)"
|
@click.stop="ctx.stepFamily(1)"
|
||||||
>›</button>
|
>›</button>
|
||||||
<p
|
<p
|
||||||
v-if="ctx.selected && ctx.isHidden(ctx.selected)"
|
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||||
class="m-hidden-note"
|
class="m-hidden-note"
|
||||||
>{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||||||
<template v-if="ctx.selected?.kind==='track'">
|
<template v-if="ctx.selected?.kind==='track'">
|
||||||
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
|
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
|
||||||
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||||
@@ -122,9 +122,9 @@
|
|||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
v-else-if="ctx.selected"
|
v-else-if="ctx.selected"
|
||||||
|
v-show="!ctx.isHidden(ctx.selected)"
|
||||||
:key="ctx.selected.id"
|
:key="ctx.selected.id"
|
||||||
class="m-stage-still"
|
class="m-stage-still"
|
||||||
:class="{ 'm-stage-still--veiled': ctx.isHidden(ctx.selected) }"
|
|
||||||
:src="ctx.stillThumbUrl(ctx.selected.id)"
|
:src="ctx.stillThumbUrl(ctx.selected.id)"
|
||||||
alt="Selected still"
|
alt="Selected still"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, createReadStream } from 'node:fs'
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const still = getStill(owner, id)
|
const still = getStill(owner, id)
|
||||||
@@ -9,28 +9,36 @@ export default defineEventHandler(async (event) => {
|
|||||||
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||||
const original = stillPath(owner, still.id)
|
const original = stillPath(owner, still.id)
|
||||||
|
|
||||||
let path = original
|
|
||||||
if (wantThumb) {
|
if (wantThumb) {
|
||||||
path = await stillThumbPath(owner, id)
|
const path = existingStillThumbPath(owner, id)
|
||||||
// Never fall back to the archive PNG/JPEG for ?thumb=1
|
if (path && path !== original && existsSync(path)) {
|
||||||
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' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const etag = fileEtag(path)
|
const etag = fileEtag(path)
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
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
|
||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
return sendStream(event, createReadStream(path))
|
return sendStream(event, createReadStream(path))
|
||||||
|
}
|
||||||
|
void stillThumbPath(owner, id).catch(() => null)
|
||||||
|
setHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
setResponseStatus(event, 204)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(original)) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = fileEtag(original)
|
||||||
|
setHeader(event, 'ETag', etag)
|
||||||
|
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||||
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
|
setResponseStatus(event, 304)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
setHeader(event, 'Content-Type', sniffImageType(original))
|
||||||
|
return sendStream(event, createReadStream(original))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { existsSync, createReadStream } from 'node:fs'
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
import { fileEtag, stillPath, stillThumbPath } from '~/server/utils/library'
|
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const still = getStill(owner, id)
|
const still = getStill(owner, id)
|
||||||
assertFolderAccess(event, still.folderId)
|
assertFolderAccess(event, still.folderId)
|
||||||
|
|
||||||
const path = await stillThumbPath(owner, id)
|
const path = existingStillThumbPath(owner, id)
|
||||||
const original = stillPath(owner, still.id)
|
const original = stillPath(owner, still.id)
|
||||||
// Never stream the archive master as a "thumb"
|
// Ready thumb → stream. Never await ffmpeg on the request path.
|
||||||
if (!path || !existsSync(path) || path === original) {
|
if (path && existsSync(path) && path !== original) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const etag = fileEtag(path)
|
const etag = fileEtag(path)
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
@@ -23,4 +20,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
return sendStream(event, createReadStream(path))
|
return sendStream(event, createReadStream(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
|
||||||
|
void stillThumbPath(owner, id).catch(() => null)
|
||||||
|
setHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
setResponseStatus(event, 204)
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ interface Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const writeChains = new Map<string, Promise<unknown>>()
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
|
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
|
||||||
|
|
||||||
export function libraryRoot() {
|
export function libraryRoot() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
|
|||||||
function readCatalog(owner: string): Catalog {
|
function readCatalog(owner: string): Catalog {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
const path = catalogPath(owner)
|
const path = catalogPath(owner)
|
||||||
|
let mtimeMs = 0
|
||||||
|
try {
|
||||||
|
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
|
||||||
|
} catch { mtimeMs = 0 }
|
||||||
|
const hit = catalogCache.get(owner)
|
||||||
|
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
|
||||||
|
|
||||||
let catalog: Catalog
|
let catalog: Catalog
|
||||||
try {
|
try {
|
||||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||||
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
|
|||||||
}
|
}
|
||||||
catalog = emptyCatalog()
|
catalog = emptyCatalog()
|
||||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||||
|
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
return catalog
|
return catalog
|
||||||
}
|
}
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
return catalog
|
return catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
|
|||||||
const tmp = catalogPath(owner) + '.tmp'
|
const tmp = catalogPath(owner) + '.tmp'
|
||||||
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
||||||
renameSync(tmp, catalogPath(owner))
|
renameSync(tmp, catalogPath(owner))
|
||||||
|
let mtimeMs = Date.now()
|
||||||
|
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateCatalogCache(owner?: string) {
|
||||||
|
if (owner) catalogCache.delete(owner)
|
||||||
|
else catalogCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
||||||
@@ -1252,6 +1271,17 @@ export function stillThumbFile(owner: string, id: string) {
|
|||||||
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
|
||||||
|
export function existingStillThumbPath(owner: string, id: string) {
|
||||||
|
const source = stillPath(owner, id)
|
||||||
|
const dest = stillThumbFile(owner, id)
|
||||||
|
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||||
|
try {
|
||||||
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
|
} catch { return '' }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function removeStillFiles(owner: string, id: string) {
|
function removeStillFiles(owner: string, id: string) {
|
||||||
rmSync(stillPath(owner, id), { force: true })
|
rmSync(stillPath(owner, id), { force: true })
|
||||||
rmSync(stillThumbFile(owner, id), { force: true })
|
rmSync(stillThumbFile(owner, id), { force: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user