Show small stitch-frame thumbs when a video collection is expanded.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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))
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user