@@ -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,
diff --git a/components/studio2/Mobile.vue b/components/studio2/Mobile.vue
index 418488a..139fe8f 100644
--- a/components/studio2/Mobile.vue
+++ b/components/studio2/Mobile.vue
@@ -123,11 +123,12 @@
Open Library to pick a still or clip
diff --git a/server/api/library/stills/[id]/preview.get.ts b/server/api/library/stills/[id]/preview.get.ts
new file mode 100644
index 0000000..a4d9fdd
--- /dev/null
+++ b/server/api/library/stills/[id]/preview.get.ts
@@ -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
+})
diff --git a/server/utils/library.ts b/server/utils/library.ts
index 7801a38..c06a69b 100644
--- a/server/utils/library.ts
+++ b/server/utils/library.ts
@@ -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>()
+const previewBuilds = new Map>()
function withThumbSlot(fn: () => Promise) {
return new Promise((resolve, reject) => {
@@ -1306,14 +1323,14 @@ function withThumbSlot(fn: () => Promise) {
})
}
-function buildStillThumb(source: string, dest: string) {
+function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
return new Promise((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
}