Show small stitch-frame thumbs when a video collection is expanded.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 07:14:34 -05:00
co-authored by Cursor
parent 91948dd329
commit 010ef6add2
6 changed files with 264 additions and 21 deletions
@@ -0,0 +1,37 @@
import { existsSync, createReadStream } from 'node:fs'
import { clipFrameThumbFile, ensureClipFirstFrame, ensureScaledThumb, fileEtag, sniffImageType } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const clip = getClip(owner, id)
assertFolderAccess(event, clip.folderId)
const wantThumb = String(getQuery(event).thumb || '') === '1'
let framePath = ''
try {
framePath = await ensureClipFirstFrame(owner, clip.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw createError({ statusCode: 500, statusMessage: `Could not extract the first frame: ${message}` })
}
if (!framePath || !existsSync(framePath)) {
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
}
let path = framePath
if (wantThumb) {
const thumbPath = await ensureScaledThumb(framePath, clipFrameThumbFile(owner, clip.id, 'first'))
if (thumbPath && existsSync(thumbPath)) path = thumbPath
}
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', wantThumb && path !== framePath ? 'image/jpeg' : sniffImageType(path))
return sendStream(event, createReadStream(path))
})
+21 -15
View File
@@ -1,31 +1,37 @@
import { existsSync, createReadStream } from 'node:fs'
import { clipLastFramePath, clipVideoPath, fileEtag, sniffImageType } from '~/server/utils/library'
import { clipFrameThumbFile, ensureClipLastFrame, ensureScaledThumb, fileEtag, sniffImageType } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const clip = getClip(owner, id)
assertFolderAccess(event, clip.folderId)
const videoPath = clipVideoPath(owner, clip.id)
if (!existsSync(videoPath)) {
const wantThumb = String(getQuery(event).thumb || '') === '1'
let framePath = ''
try {
framePath = await ensureClipLastFrame(owner, clip.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw createError({ statusCode: 500, statusMessage: `Could not extract the last frame: ${message}` })
}
if (!framePath || !existsSync(framePath)) {
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
}
const framePath = clipLastFramePath(owner, clip.id)
if (!existsSync(framePath)) {
try {
await extractLastFrame(videoPath, framePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw createError({ statusCode: 500, statusMessage: `Could not extract the last frame: ${message}` })
}
let path = framePath
if (wantThumb) {
const thumbPath = await ensureScaledThumb(framePath, clipFrameThumbFile(owner, clip.id, 'last'))
if (thumbPath && existsSync(thumbPath)) path = thumbPath
}
const etag = fileEtag(framePath)
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', sniffImageType(framePath))
return sendStream(event, createReadStream(framePath))
setHeader(event, 'Content-Type', wantThumb && path !== framePath ? 'image/jpeg' : sniffImageType(path))
return sendStream(event, createReadStream(path))
})
+18
View File
@@ -89,6 +89,24 @@ export async function extractLastFrame(inputPath: string, outputPath: string) {
throw new Error('Could not extract the last frame. The video looks incomplete — interrupting Comfy mid-encode usually causes this. Earlier saved parts are still in the library.')
}
export async function extractFirstFrame(inputPath: string, outputPath: string) {
if (!existsSync(inputPath)) {
throw new Error('Source video is missing')
}
await runFfmpeg([
'-y',
'-i', inputPath,
'-frames:v', '1',
'-update', '1',
'-an',
'-q:v', '4',
outputPath
], 20000)
if (!existsSync(outputPath)) {
throw new Error('Could not extract the first frame')
}
}
export async function probeHasAudio(filePath: string) {
try {
const out = await runFfprobe([
+45
View File
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process'
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import type { H3Event } from 'h3'
import { extractFirstFrame, extractLastFrame } from '~/server/utils/ffmpeg'
const scryptAsync = promisify(scrypt)
@@ -576,6 +577,50 @@ export function clipLastFramePath(owner: string, id: string) {
return join(clipDir(owner, id), 'last-frame.png')
}
export function clipFirstFramePath(owner: string, id: string) {
return join(clipDir(owner, id), 'first-frame.jpg')
}
export function clipFrameThumbFile(owner: string, id: string, kind: 'first' | 'last') {
return join(clipDir(owner, id), kind === 'last' ? 'last-frame.thumb.jpg' : 'first-frame.thumb.jpg')
}
export async function ensureScaledThumb(source: string, dest: string) {
if (!existsSync(source)) return ''
if (existsSync(dest)) {
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = dest
const pending = thumbBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest)
return ok ? dest : source
}).finally(() => {
thumbBuilds.delete(key)
})
thumbBuilds.set(key, build)
return build
}
export async function ensureClipLastFrame(owner: string, id: string) {
const video = clipVideoPath(owner, id)
const dest = clipLastFramePath(owner, id)
if (!existsSync(video)) return ''
if (!existsSync(dest)) await extractLastFrame(video, dest)
return existsSync(dest) ? dest : ''
}
export async function ensureClipFirstFrame(owner: string, id: string) {
const video = clipVideoPath(owner, id)
const dest = clipFirstFramePath(owner, id)
if (!existsSync(video)) return ''
if (!existsSync(dest)) await extractFirstFrame(video, dest)
return existsSync(dest) ? dest : ''
}
export function extendTempDir(owner: string, jobId: string) {
const dir = join(ownerRoot(owner), 'tmp', 'extend', jobId)
mkdirSync(dir, { recursive: true })