Serve the stage from a 1600px preview instead of the grid thumb.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-29 20:57:01 -05:00
co-authored by Cursor
parent ccf852cb0c
commit 17bb42bea4
4 changed files with 113 additions and 10 deletions
+26 -3
View File
@@ -304,11 +304,12 @@
<template v-else-if="selected">
<img
v-show="!isHidden(selected)"
:key="selected.id"
:key="selected.id+'-'+stagePreviewNonce"
class="viewer-still"
:src="stillThumbUrl(selected.id)"
:src="stagePreviewSrc"
alt="Selected still"
decoding="async"
@error="onStagePreviewError"
>
</template>
<template v-else>
@@ -700,7 +701,25 @@ const stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form
const clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId))
const stillUrl=(id:string)=>`/api/library/stills/${id}`
const stillThumbUrl=(id:string)=>`/api/library/stills/${id}/thumb`
/** Stage always uses /thumb — never auto-upgrade to the archive master. */
const stillPreviewUrl=(id:string)=>`/api/library/stills/${id}/preview`
/** Stage uses /preview (1600px). Tiles keep /thumb. Never auto-upgrade to the archive master. */
const stagePreviewNonce=ref(0)
const stagePreviewRetries=ref(0)
const stagePreviewSrc=computed(()=>{
const id=selected.value?.id
if(!id || selected.value?.kind==='clip' || selected.value?.kind==='track') return ''
const base=stillPreviewUrl(id)
return stagePreviewNonce.value?`${base}?t=${stagePreviewNonce.value}`:base
})
function onStagePreviewError(){
if(!selected.value?.id || stagePreviewRetries.value>=8) return
stagePreviewRetries.value+=1
window.setTimeout(()=>{
if(!selected.value?.id) return
stagePreviewNonce.value=Date.now()
}, 700)
}
watch(()=>selected.value?.id,()=>{stagePreviewNonce.value=0;stagePreviewRetries.value=0})
const folderJobs=computed(()=>jobs.value.filter(j=>j.request.folderId===form.folderId))
const benchJobs=computed(()=>queueJobs(folderJobs.value))
const musicJobs=computed(()=>studioQueue.value
@@ -1666,6 +1685,10 @@ const mobileCtx=computed(()=>({
aspectOptions,
stillUrl,
stillThumbUrl,
stillPreviewUrl,
stagePreviewSrc:stagePreviewSrc.value,
stagePreviewNonce:stagePreviewNonce.value,
onStagePreviewError,
isHidden,
slotHidden,
stillById,
+3 -2
View File
@@ -123,11 +123,12 @@
<img
v-else-if="ctx.selected"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id"
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
class="m-stage-still"
:src="ctx.stillThumbUrl(ctx.selected.id)"
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
alt="Selected still"
decoding="async"
@error="ctx.onStagePreviewError?.()"
>
<p v-else class="m-muted">Open Library to pick a still or clip</p>
</div>
@@ -0,0 +1,30 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillPreviewPath, existingStillPreviewPath } from '~/server/utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = existingStillPreviewPath(owner, id)
const original = stillPath(owner, still.id)
// Ready preview → stream. Never await ffmpeg; never stream the archive master.
if (path && existsSync(path) && path !== original) {
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))
}
// Missing → answer now; build .preview.jpg in the background
void stillPreviewPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
})
+53 -4
View File
@@ -1271,6 +1271,10 @@ export function stillThumbFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
}
export function stillPreviewFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.preview.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)
@@ -1282,14 +1286,27 @@ export function existingStillThumbPath(owner: string, id: string) {
return ''
}
/** Sync check for stage preview — never kicks ffmpeg. */
export function existingStillPreviewPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillPreviewFile(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) {
rmSync(stillPath(owner, id), { force: true })
rmSync(stillThumbFile(owner, id), { force: true })
rmSync(stillPreviewFile(owner, id), { force: true })
}
let thumbSlots = 0
const thumbWaiters: Array<() => void> = []
const thumbBuilds = new Map<string, Promise<string>>()
const previewBuilds = new Map<string, Promise<string>>()
function withThumbSlot<T>(fn: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
@@ -1306,14 +1323,14 @@ function withThumbSlot<T>(fn: () => Promise<T>) {
})
}
function buildStillThumb(source: string, dest: string) {
function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
return new Promise<boolean>((resolve) => {
const proc = spawn('ffmpeg', [
'-y',
'-i', source,
'-vf', 'scale=384:-2',
'-vf', scale,
'-frames:v', '1',
'-q:v', '5',
'-q:v', quality,
dest
], { stdio: 'ignore' })
const timer = setTimeout(() => {
@@ -1331,6 +1348,14 @@ function buildStillThumb(source: string, dest: string) {
})
}
function buildStillThumb(source: string, dest: string) {
return buildStillJpeg(source, dest, 'scale=384:-2', '5')
}
function buildStillPreview(source: string, dest: string) {
return buildStillJpeg(source, dest, 'scale=1600:-2', '3')
}
export async function stillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
@@ -1340,7 +1365,7 @@ export async function stillThumbPath(owner: string, id: string) {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}`
const key = `${owner}:${id}:thumb`
const pending = thumbBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
@@ -1354,6 +1379,29 @@ export async function stillThumbPath(owner: string, id: string) {
return build
}
export async function stillPreviewPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillPreviewFile(owner, id)
if (!existsSync(source)) return ''
if (existsSync(dest)) {
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}:preview`
const pending = previewBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillPreview(source, dest)
// Never return the archive master as a stage preview
return ok ? dest : ''
}).finally(() => {
previewBuilds.delete(key)
})
previewBuilds.set(key, build)
return build
}
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
const catalog = readCatalog(owner)
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
@@ -1603,6 +1651,7 @@ export async function saveStill(params: {
next.stills.unshift(still)
})
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
return still
}