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
+130
View File
@@ -0,0 +1,130 @@
<template>
<div class="shrink-0">
<span v-if="hidden" class="sensitive-glow block h-12 w-12 overflow-hidden rounded-lg" />
<div v-else class="flex items-start gap-1.5">
<button
v-if="stillId && !failed.still"
type="button"
class="flex w-12 flex-col items-center gap-0.5"
aria-label="Start still"
@click.stop="openPreview(stillFull, 'Start')"
>
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
<img
:src="stillThumb"
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
@error="failed.still = true"
>
</span>
<span class="text-[10px] leading-none text-zinc-500">Start</span>
</button>
<button
v-if="fromClipId && !failed.from"
type="button"
class="flex w-12 flex-col items-center gap-0.5"
aria-label="Last frame of previous clip"
@click.stop="openPreview(fromFull, 'From prev')"
>
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
<img
:src="fromThumb"
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
@error="failed.from = true"
>
</span>
<span class="text-[10px] leading-none text-zinc-500">From prev</span>
</button>
<button
v-if="showFirstFrame && clipId && !failed.first"
type="button"
class="flex w-12 flex-col items-center gap-0.5"
aria-label="First frame of this clip"
@click.stop="openPreview(firstFull, 'This clip')"
>
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
<img
:src="firstThumb"
alt=""
loading="lazy"
decoding="async"
class="h-full w-full object-cover"
@error="failed.first = true"
>
</span>
<span class="text-[10px] leading-none text-zinc-500">This clip</span>
</button>
</div>
<Teleport to="body">
<div
v-if="preview"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
@click.self="closePreview"
>
<img
:src="preview.src"
:alt="preview.label"
class="max-h-[70vh] max-w-[min(90vw,56rem)] object-contain"
@click.stop
>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
hidden?: boolean
stillId?: string
fromClipId?: string
clipId: string
showFirstFrame?: boolean
}>()
const failed = reactive({ still: false, from: false, first: false })
const preview = ref<{ src: string; label: string } | null>(null)
const stillThumb = computed(() => props.stillId ? `/api/library/stills/${props.stillId}?thumb=1` : '')
const stillFull = computed(() => props.stillId ? `/api/library/stills/${props.stillId}` : '')
const fromThumb = computed(() => props.fromClipId ? `/api/library/clips/${props.fromClipId}/last-frame?thumb=1` : '')
const fromFull = computed(() => props.fromClipId ? `/api/library/clips/${props.fromClipId}/last-frame` : '')
const firstThumb = computed(() => props.clipId ? `/api/library/clips/${props.clipId}/first-frame?thumb=1` : '')
const firstFull = computed(() => props.clipId ? `/api/library/clips/${props.clipId}/first-frame` : '')
watch(
() => [props.hidden, props.stillId, props.fromClipId, props.clipId, props.showFirstFrame],
() => {
failed.still = false
failed.from = false
failed.first = false
}
)
function openPreview(src: string, label: string) {
if (!src) return
preview.value = { src, label }
}
function closePreview() {
preview.value = null
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') closePreview()
}
watch(preview, (value) => {
if (value) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKey)
})
</script>
+13 -6
View File
@@ -1145,15 +1145,17 @@
</div>
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
<li
v-for="item in chronologicalParts(entry.group.parts)"
v-for="(item, partIndex) in chronologicalParts(entry.group.parts)"
:key="item.id"
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
>
<button type="button" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click="openClip(item)">
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else-if="item.hasThumbnail" :src="`/api/library/clips/${item.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>
<ClipFrameThumbs
:hidden="item.hideThumbnail"
:still-id="item.hideThumbnail || partIndex !== 0 ? undefined : item.stillId"
:from-clip-id="item.hideThumbnail ? undefined : previousChronoClipId(entry.group.parts, partIndex)"
:clip-id="item.id"
:show-first-frame="!item.hideThumbnail && partIndex > 0"
/>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<input
@@ -2093,6 +2095,11 @@ function chronologicalParts(parts: LibraryClip[]) {
})
}
function previousChronoClipId(parts: LibraryClip[], index: number) {
if (index <= 0) return undefined
return chronologicalParts(parts)[index - 1]?.id
}
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
return named || group.head.name || group.head.prompt
@@ -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))
})
+18 -12
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)) {
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
}
const framePath = clipLastFramePath(owner, clip.id)
if (!existsSync(framePath)) {
const wantThumb = String(getQuery(event).thumb || '') === '1'
let framePath = ''
try {
await extractLastFrame(videoPath, framePath)
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 etag = fileEtag(framePath)
let path = framePath
if (wantThumb) {
const thumbPath = await ensureScaledThumb(framePath, clipFrameThumbFile(owner, clip.id, 'last'))
if (thumbPath && existsSync(thumbPath)) path = thumbPath
}
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 })