Serve the stage from a 1600px preview instead of the grid thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -304,11 +304,12 @@
|
|||||||
<template v-else-if="selected">
|
<template v-else-if="selected">
|
||||||
<img
|
<img
|
||||||
v-show="!isHidden(selected)"
|
v-show="!isHidden(selected)"
|
||||||
:key="selected.id"
|
:key="selected.id+'-'+stagePreviewNonce"
|
||||||
class="viewer-still"
|
class="viewer-still"
|
||||||
:src="stillThumbUrl(selected.id)"
|
:src="stagePreviewSrc"
|
||||||
alt="Selected still"
|
alt="Selected still"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
@error="onStagePreviewError"
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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 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`
|
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 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
|
||||||
@@ -1666,6 +1685,10 @@ const mobileCtx=computed(()=>({
|
|||||||
aspectOptions,
|
aspectOptions,
|
||||||
stillUrl,
|
stillUrl,
|
||||||
stillThumbUrl,
|
stillThumbUrl,
|
||||||
|
stillPreviewUrl,
|
||||||
|
stagePreviewSrc:stagePreviewSrc.value,
|
||||||
|
stagePreviewNonce:stagePreviewNonce.value,
|
||||||
|
onStagePreviewError,
|
||||||
isHidden,
|
isHidden,
|
||||||
slotHidden,
|
slotHidden,
|
||||||
stillById,
|
stillById,
|
||||||
|
|||||||
@@ -123,11 +123,12 @@
|
|||||||
<img
|
<img
|
||||||
v-else-if="ctx.selected"
|
v-else-if="ctx.selected"
|
||||||
v-show="!ctx.isHidden(ctx.selected)"
|
v-show="!ctx.isHidden(ctx.selected)"
|
||||||
:key="ctx.selected.id"
|
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
|
||||||
class="m-stage-still"
|
class="m-stage-still"
|
||||||
:src="ctx.stillThumbUrl(ctx.selected.id)"
|
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
|
||||||
alt="Selected still"
|
alt="Selected still"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
@error="ctx.onStagePreviewError?.()"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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
@@ -1271,6 +1271,10 @@ export function stillThumbFile(owner: string, id: string) {
|
|||||||
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
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. */
|
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
|
||||||
export function existingStillThumbPath(owner: string, id: string) {
|
export function existingStillThumbPath(owner: string, id: string) {
|
||||||
const source = stillPath(owner, id)
|
const source = stillPath(owner, id)
|
||||||
@@ -1282,14 +1286,27 @@ export function existingStillThumbPath(owner: string, id: string) {
|
|||||||
return ''
|
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) {
|
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 })
|
||||||
|
rmSync(stillPreviewFile(owner, id), { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumbSlots = 0
|
let thumbSlots = 0
|
||||||
const thumbWaiters: Array<() => void> = []
|
const thumbWaiters: Array<() => void> = []
|
||||||
const thumbBuilds = new Map<string, Promise<string>>()
|
const thumbBuilds = new Map<string, Promise<string>>()
|
||||||
|
const previewBuilds = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
function withThumbSlot<T>(fn: () => Promise<T>) {
|
function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||||
return new Promise<T>((resolve, reject) => {
|
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) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
const proc = spawn('ffmpeg', [
|
const proc = spawn('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', source,
|
'-i', source,
|
||||||
'-vf', 'scale=384:-2',
|
'-vf', scale,
|
||||||
'-frames:v', '1',
|
'-frames:v', '1',
|
||||||
'-q:v', '5',
|
'-q:v', quality,
|
||||||
dest
|
dest
|
||||||
], { stdio: 'ignore' })
|
], { stdio: 'ignore' })
|
||||||
const timer = setTimeout(() => {
|
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) {
|
export async function stillThumbPath(owner: string, id: string) {
|
||||||
const source = stillPath(owner, id)
|
const source = stillPath(owner, id)
|
||||||
const dest = stillThumbFile(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
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
} catch { /* rebuild */ }
|
} catch { /* rebuild */ }
|
||||||
}
|
}
|
||||||
const key = `${owner}:${id}`
|
const key = `${owner}:${id}:thumb`
|
||||||
const pending = thumbBuilds.get(key)
|
const pending = thumbBuilds.get(key)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const build = withThumbSlot(async () => {
|
const build = withThumbSlot(async () => {
|
||||||
@@ -1354,6 +1379,29 @@ export async function stillThumbPath(owner: string, id: string) {
|
|||||||
return build
|
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 }) {
|
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
|
||||||
const catalog = readCatalog(owner)
|
const catalog = readCatalog(owner)
|
||||||
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
|
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)
|
next.stills.unshift(still)
|
||||||
})
|
})
|
||||||
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
||||||
|
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
|
||||||
return still
|
return still
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user