Show video posters in the library by building a first-frame thumb when one is missing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-2
@@ -1717,8 +1717,7 @@
|
|||||||
@click="openClip(entry.group.head)"
|
@click="openClip(entry.group.head)"
|
||||||
>
|
>
|
||||||
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
<span v-if="entry.group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||||||
<img v-else-if="entry.group.head.hasThumbnail" :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
|
<img v-else :src="`/api/library/clips/${entry.group.head.id}/thumb`" alt="" loading="lazy" decoding="async" class="h-full w-full object-cover">
|
||||||
<span v-else class="block h-full w-full bg-zinc-800" />
|
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
|
|||||||
+1
-1
@@ -745,7 +745,7 @@ async function toggleFocusedHidden() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function thumbSrc(entry: LibraryEntry) {
|
function thumbSrc(entry: LibraryEntry) {
|
||||||
if (entry.kind === 'clip' && entry.group?.head.hasThumbnail) 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.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { existsSync, createReadStream } from 'node:fs'
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
import { clipThumbPath, fileEtag, sniffImageType } from '~/server/utils/library'
|
import { clipThumbPath, ensureClipThumb, fileEtag, sniffImageType } from '~/server/utils/library'
|
||||||
|
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const clip = getClip(owner, id)
|
const clip = getClip(owner, id)
|
||||||
assertFolderAccess(event, clip.folderId)
|
assertFolderAccess(event, clip.folderId)
|
||||||
const path = clipThumbPath(owner, clip.id)
|
let path = clipThumbPath(owner, clip.id)
|
||||||
if (!existsSync(path)) {
|
if (!existsSync(path)) path = await ensureClipThumb(owner, clip.id)
|
||||||
|
if (!path || !existsSync(path)) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
||||||
}
|
}
|
||||||
const etag = fileEtag(path)
|
const etag = fileEtag(path)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const light = String(getQuery(event).light || '') === '1'
|
const light = String(getQuery(event).light || '') === '1'
|
||||||
const library = publicLibrary(event, { light })
|
const library = publicLibrary(event, { light })
|
||||||
if (!light) {
|
try {
|
||||||
try {
|
const { owner } = assertLibraryOwner(event)
|
||||||
const { owner } = assertLibraryOwner(event)
|
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
||||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
void maintainLibrary(owner, folderId).catch(() => null)
|
||||||
void maintainLibrary(owner, folderId).catch(() => null)
|
} catch { /* locked or anonymous — still return what we can */ }
|
||||||
} catch { /* locked or anonymous — still return what we can */ }
|
|
||||||
}
|
|
||||||
return library
|
return library
|
||||||
})
|
})
|
||||||
|
|||||||
+27
-18
@@ -520,7 +520,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({
|
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({
|
||||||
...clip,
|
...clip,
|
||||||
name: clipTitle(clip),
|
name: clipTitle(clip),
|
||||||
hasThumbnail: light ? !clip.hideThumbnail : !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
|
hasThumbnail: !clip.hideThumbnail,
|
||||||
hasVideo: light ? true : existsSync(clipVideoPath(owner, clip.id))
|
hasVideo: light ? true : existsSync(clipVideoPath(owner, clip.id))
|
||||||
})),
|
})),
|
||||||
stills: catalog.stills
|
stills: catalog.stills
|
||||||
@@ -837,27 +837,36 @@ export function clipThumbPath(owner: string, id: string) {
|
|||||||
const dir = clipDir(owner, id)
|
const dir = clipDir(owner, id)
|
||||||
for (const name of ['thumb.jpg', 'thumb.png', 'thumb']) {
|
for (const name of ['thumb.jpg', 'thumb.png', 'thumb']) {
|
||||||
const path = join(dir, name)
|
const path = join(dir, name)
|
||||||
if (existsSync(path)) return path
|
if (existsSync(path) && statSync(path).size >= 64) return path
|
||||||
}
|
}
|
||||||
return join(dir, 'thumb.jpg')
|
return join(dir, 'thumb.jpg')
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractVideoThumb(videoPath: string, destPath: string) {
|
async function extractVideoThumb(videoPath: string, destPath: string) {
|
||||||
return new Promise<boolean>((resolve) => {
|
try {
|
||||||
const proc = spawn('ffmpeg', ['-y', '-ss', '0.2', '-i', videoPath, '-frames:v', '1', '-update', '1', '-q:v', '4', destPath], { stdio: 'ignore' })
|
await extractFirstFrame(videoPath, destPath)
|
||||||
const timer = setTimeout(() => {
|
return existsSync(destPath) && statSync(destPath).size >= 64
|
||||||
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
} catch {
|
||||||
resolve(false)
|
return false
|
||||||
}, 20000)
|
}
|
||||||
proc.on('close', (code) => {
|
}
|
||||||
clearTimeout(timer)
|
|
||||||
resolve(code === 0 && existsSync(destPath))
|
export async function ensureClipThumb(owner: string, id: string) {
|
||||||
})
|
const existing = clipThumbPath(owner, id)
|
||||||
proc.on('error', () => {
|
if (existsSync(existing) && statSync(existing).size >= 64) return existing
|
||||||
clearTimeout(timer)
|
const video = clipVideoPath(owner, id)
|
||||||
resolve(false)
|
if (!existsSync(video)) return ''
|
||||||
})
|
const dest = join(clipDir(owner, id), 'thumb.jpg')
|
||||||
|
const pending = thumbBuilds.get(dest)
|
||||||
|
if (pending) return pending
|
||||||
|
const build = withThumbSlot(async () => {
|
||||||
|
const ok = await extractVideoThumb(video, dest)
|
||||||
|
return ok ? dest : ''
|
||||||
|
}).finally(() => {
|
||||||
|
thumbBuilds.delete(dest)
|
||||||
})
|
})
|
||||||
|
thumbBuilds.set(dest, build)
|
||||||
|
return build
|
||||||
}
|
}
|
||||||
|
|
||||||
function probeVideoSize(videoPath: string) {
|
function probeVideoSize(videoPath: string) {
|
||||||
@@ -913,7 +922,7 @@ export async function ensureClipThumbnails(owner: string, opts?: { skipHistory?:
|
|||||||
const thumb = clipThumbPath(owner, clip.id)
|
const thumb = clipThumbPath(owner, clip.id)
|
||||||
const video = clipVideoPath(owner, clip.id)
|
const video = clipVideoPath(owner, clip.id)
|
||||||
if (!existsSync(video)) continue
|
if (!existsSync(video)) continue
|
||||||
if (!existsSync(thumb)) {
|
if (!existsSync(thumb) || statSync(thumb).size < 64) {
|
||||||
await extractVideoThumb(video, join(clipDir(owner, clip.id), 'thumb.jpg'))
|
await extractVideoThumb(video, join(clipDir(owner, clip.id), 'thumb.jpg'))
|
||||||
}
|
}
|
||||||
if (!clip.width || !clip.height) {
|
if (!clip.width || !clip.height) {
|
||||||
|
|||||||
Reference in New Issue
Block a user