familyBaseName(part.name || '')).find(name => name)
return named || group.head.name || group.head.prompt
diff --git a/server/api/library/clips/[id]/first-frame.get.ts b/server/api/library/clips/[id]/first-frame.get.ts
new file mode 100644
index 0000000..0807f2b
--- /dev/null
+++ b/server/api/library/clips/[id]/first-frame.get.ts
@@ -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))
+})
diff --git a/server/api/library/clips/[id]/last-frame.get.ts b/server/api/library/clips/[id]/last-frame.get.ts
index 5890f9a..9110f3d 100644
--- a/server/api/library/clips/[id]/last-frame.get.ts
+++ b/server/api/library/clips/[id]/last-frame.get.ts
@@ -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))
})
diff --git a/server/utils/ffmpeg.ts b/server/utils/ffmpeg.ts
index 5c27ace..a300cff 100644
--- a/server/utils/ffmpeg.ts
+++ b/server/utils/ffmpeg.ts
@@ -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([
diff --git a/server/utils/library.ts b/server/utils/library.ts
index 0619e5b..366e5de 100644
--- a/server/utils/library.ts
+++ b/server/utils/library.ts
@@ -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 })