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>
|
</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">
|
<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
|
<li
|
||||||
v-for="item in chronologicalParts(entry.group.parts)"
|
v-for="(item, partIndex) in chronologicalParts(entry.group.parts)"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
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)">
|
<ClipFrameThumbs
|
||||||
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
:hidden="item.hideThumbnail"
|
||||||
<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">
|
:still-id="item.hideThumbnail || partIndex !== 0 ? undefined : item.stillId"
|
||||||
<span v-else class="block h-full w-full bg-zinc-800" />
|
:from-clip-id="item.hideThumbnail ? undefined : previousChronoClipId(entry.group.parts, partIndex)"
|
||||||
</button>
|
:clip-id="item.id"
|
||||||
|
:show-first-frame="!item.hideThumbnail && partIndex > 0"
|
||||||
|
/>
|
||||||
<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">
|
||||||
<input
|
<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[] }) {
|
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
|
||||||
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
||||||
return named || group.head.name || group.head.prompt
|
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 { 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) => {
|
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 videoPath = clipVideoPath(owner, clip.id)
|
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||||
if (!existsSync(videoPath)) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
|
let framePath = ''
|
||||||
}
|
|
||||||
const framePath = clipLastFramePath(owner, clip.id)
|
|
||||||
if (!existsSync(framePath)) {
|
|
||||||
try {
|
try {
|
||||||
await extractLastFrame(videoPath, framePath)
|
framePath = await ensureClipLastFrame(owner, clip.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
throw createError({ statusCode: 500, statusMessage: `Could not extract the last frame: ${message}` })
|
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, '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) {
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
setResponseStatus(event, 304)
|
setResponseStatus(event, 304)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', sniffImageType(framePath))
|
setHeader(event, 'Content-Type', wantThumb && path !== framePath ? 'image/jpeg' : sniffImageType(path))
|
||||||
return sendStream(event, createReadStream(framePath))
|
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.')
|
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) {
|
export async function probeHasAudio(filePath: string) {
|
||||||
try {
|
try {
|
||||||
const out = await runFfprobe([
|
const out = await runFfprobe([
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process'
|
|||||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import type { H3Event } from 'h3'
|
import type { H3Event } from 'h3'
|
||||||
|
import { extractFirstFrame, extractLastFrame } from '~/server/utils/ffmpeg'
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt)
|
const scryptAsync = promisify(scrypt)
|
||||||
|
|
||||||
@@ -576,6 +577,50 @@ export function clipLastFramePath(owner: string, id: string) {
|
|||||||
return join(clipDir(owner, id), 'last-frame.png')
|
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) {
|
export function extendTempDir(owner: string, jobId: string) {
|
||||||
const dir = join(ownerRoot(owner), 'tmp', 'extend', jobId)
|
const dir = join(ownerRoot(owner), 'tmp', 'extend', jobId)
|
||||||
mkdirSync(dir, { recursive: true })
|
mkdirSync(dir, { recursive: true })
|
||||||
|
|||||||
Reference in New Issue
Block a user