Fix library thumbnails and let clips keep a display name.

Extract a poster frame when a still-thumb is missing, and show the clip name in the library with the prompt on the info icon.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-25 18:49:46 -05:00
co-authored by Cursor
parent b7196ecde3
commit 2913909199
11 changed files with 276 additions and 19 deletions
+1
View File
@@ -7,6 +7,7 @@ RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
RUN apk add --no-cache ffmpeg
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
+12 -2
View File
@@ -66,9 +66,17 @@
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 text-left hover:border-amber-300/50"
@click="pickClip(clip)"
>
<img :src="`/api/library/clips/${clip.id}/thumb`" alt="" class="h-28 w-full object-cover">
<img v-if="clip.hasThumbnail" :src="`/api/library/clips/${clip.id}/thumb`" alt="" class="h-28 w-full object-cover">
<video
v-else
:src="`/api/library/clips/${clip.id}/video#t=0.1`"
class="h-28 w-full object-cover"
muted
playsinline
preload="metadata"
/>
<div class="px-3 py-2">
<span class="truncate text-xs text-zinc-400">From a clip</span>
<span class="truncate text-xs text-zinc-400">{{ clip.name || 'From a clip' }}</span>
</div>
</button>
</div>
@@ -100,6 +108,8 @@ export interface PickerClip {
id: string
folderId: string
hideThumbnail: boolean
hasThumbnail?: boolean
name?: string
prompt: string
}
+51 -5
View File
@@ -87,6 +87,15 @@
{{ folder.name }}{{ folder.protected ? (folder.unlocked ? ' · unlocked' : ' · locked') : '' }}
</option>
</select>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Name</span>
<input
v-model="clipName"
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm"
placeholder="Shown in the library and used for the download filename"
maxlength="80"
>
</label>
<form v-if="currentFolder?.protected && !currentFolder.unlocked" class="space-y-2" @submit.prevent="unlockFolder(folderId)">
<p class="text-sm text-zinc-400">Unlock this folder to generate into it.</p>
<div class="flex gap-2">
@@ -200,7 +209,7 @@
<a
v-if="videoUrl && !awaitingReveal"
:href="videoUrl"
download="aigen.mp4"
:download="`${outputDownloadName}.mp4`"
class="mt-4 inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download MP4
@@ -247,11 +256,36 @@
<li v-for="item in visibleClips" :key="item.id" class="flex gap-3 rounded-2xl border border-white/5 bg-zinc-950/40 p-3">
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(item)">
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img v-else :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
<img v-else-if="item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
<video
v-else
:src="`/api/library/clips/${item.id}/video#t=0.1`"
class="h-full w-full object-cover"
muted
playsinline
preload="metadata"
/>
</button>
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ item.prompt }}</p>
<p class="text-xs text-zinc-500">{{ item.width }}×{{ item.height }} · {{ item.steps }} steps · seed {{ item.seed }}</p>
<div class="flex items-start gap-2">
<p class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
<div class="group relative shrink-0">
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
:aria-label="'Prompt for ' + (item.name || 'clip')"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
</svg>
</button>
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
{{ item.prompt }}
</div>
</div>
</div>
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
<div class="mt-2 flex gap-3">
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
@@ -320,6 +354,7 @@ interface LibraryStill {
interface LibraryClip {
id: string
folderId: string
name?: string
prompt: string
aspect: string
width: number
@@ -328,6 +363,7 @@ interface LibraryClip {
turbo: boolean
seed: number
hideThumbnail: boolean
hasThumbnail?: boolean
createdAt: number
}
@@ -349,6 +385,8 @@ const file = ref<File | null>(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
const clipName = ref('')
const outputDownloadName = ref('aigen')
const aspect = ref<AspectId>('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
@@ -543,8 +581,13 @@ function stopTimer() {
timer = null
}
function clipFileName(name: string) {
return name.replace(/[<>:"/\\|?*'`]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 80) || 'aigen'
}
function rerun(item: LibraryClip) {
prompt.value = item.prompt
clipName.value = item.name || ''
turbo.value = item.turbo
seedInput.value = String(item.seed)
aspect.value = (item.aspect as AspectId) || 'auto'
@@ -574,8 +617,9 @@ async function lockFolder(id: string) {
function openClip(item: LibraryClip) {
videoUrl.value = `/api/library/clips/${item.id}/video`
awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width, height: item.height }
playerSize.value = { width: item.width || 1344, height: item.height || 768 }
browseFolderId.value = item.folderId
outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100
}
@@ -631,6 +675,8 @@ async function generate() {
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
body.append('hideThumbnail', String(hideThumbnail.value))
body.append('name', clipName.value.trim())
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.trim().split(/[.!?\n]/)[0] || 'aigen')
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean }>('/api/generate', {
method: 'POST',
body
+3
View File
@@ -35,6 +35,7 @@ export default defineEventHandler(async (event) => {
: Math.floor(Math.random() * 2_147_483_647)
const length = frameLength(Number(fields.duration || 5))
const hideThumbnail = fields.hideThumbnail === 'true'
const clipName = (fields.name || '').trim().slice(0, 80)
const ownerKey = libraryOwnerKey(event)
const library = publicLibrary(event)
const folderId = library.folders.some(folder => folder.id === fields.folderId)
@@ -60,6 +61,7 @@ export default defineEventHandler(async (event) => {
ownerKey,
folderId,
hideThumbnail,
name: clipName,
prompt,
aspect: fields.aspect || 'auto',
width,
@@ -133,6 +135,7 @@ async function runGeneration(
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
hideThumbnail: job.library.hideThumbnail,
name: job.library.name,
prompt: job.library.prompt,
aspect: job.library.aspect,
width: job.library.width,
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineEventHandler((event) => {
throw createError({ statusCode: 404, statusMessage: 'Video file is missing' })
}
setHeader(event, 'Content-Type', 'video/mp4')
setHeader(event, 'Content-Disposition', `inline; filename="${clip.id}.mp4"`)
setHeader(event, 'Content-Disposition', `inline; filename="${safeDownloadName(clipTitle(clip))}.mp4"`)
setHeader(event, 'Cache-Control', 'private, max-age=3600')
return readFileSync(path)
})
+2 -1
View File
@@ -4,8 +4,9 @@ export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const folderId = library.folders.find(folder => folder.unlocked)?.id
if (folderId) await importMissingComfyVideos(owner, folderId)
await ensureClipThumbnails(owner)
} catch {
return library
return publicLibrary(event)
}
return publicLibrary(event)
})
+23
View File
@@ -135,6 +135,29 @@ export function extractPromptFromHistory(entry: unknown) {
return ''
}
export function extractClipMetaFromHistory(entry: unknown) {
const prompt = (entry as { prompt?: unknown[] })?.prompt
const graph = Array.isArray(prompt) ? prompt[2] : null
const meta = { width: 0, height: 0, steps: 0, seed: 0 }
if (!graph || typeof graph !== 'object') return meta
for (const node of Object.values(graph as Record<string, { class_type?: string; inputs?: Record<string, unknown> }>)) {
const inputs = node?.inputs || {}
if (node?.class_type === 'MiniMaxH3ImageToVideo') {
meta.width = Number(inputs.width || meta.width)
meta.height = Number(inputs.height || meta.height)
}
if (node?.class_type === 'BasicScheduler' || node?.class_type === 'PrimitiveInt') {
const steps = Number(inputs.steps || 0)
if (steps > 0) meta.steps = steps
}
if (node?.class_type === 'RandomNoise' || node?.class_type === 'SamplerMiniMax' || node?.class_type === 'KSampler') {
const seed = Number(inputs.noise_seed ?? inputs.seed ?? 0)
if (seed) meta.seed = seed
}
}
return meta
}
export function inspectHistory(history: Record<string, unknown> | null, promptId: string) {
const video = extractVideo(history, promptId)
if (!history) return { video: null, completed: false, error: null as string | null }
+1
View File
@@ -33,6 +33,7 @@ export interface Job {
ownerKey: string
folderId: string
hideThumbnail: boolean
name?: string
prompt: string
aspect: string
width: number
+179 -10
View File
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { spawn } from 'node:child_process'
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import type { H3Event } from 'h3'
@@ -18,6 +19,7 @@ export interface LibraryFolder {
export interface LibraryClip {
id: string
folderId: string
name: string
prompt: string
aspect: string
width: number
@@ -177,6 +179,18 @@ function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
return run
}
export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
const named = (clip.name || '').trim()
if (named) return named
const fromPrompt = (clip.prompt || '').split(/[.!?\n]/)[0].trim().slice(0, 60)
return fromPrompt || 'Untitled clip'
}
export function safeDownloadName(name: string) {
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
return cleaned.slice(0, 80) || 'aigen'
}
export function passwordVersion(hash: string | null) {
return hash ? hash.slice(-24) : 'open'
}
@@ -224,7 +238,11 @@ export function publicLibrary(event: H3Event) {
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
return {
folders,
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)),
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({
...clip,
name: clipTitle(clip),
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id))
})),
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId))
}
}
@@ -375,7 +393,11 @@ export async function unlockMatchingFolders(owner: string, password: string, fol
}
function clipDir(owner: string, id: string) {
return join(ownerRoot(owner), 'files', id)
const current = join(ownerRoot(owner), 'files', id)
if (existsSync(current)) return current
const legacy = join(libraryRoot(), 'files', id)
if (existsSync(legacy)) return legacy
return current
}
export function clipVideoPath(owner: string, id: string) {
@@ -383,7 +405,143 @@ export function clipVideoPath(owner: string, id: string) {
}
export function clipThumbPath(owner: string, id: string) {
return join(clipDir(owner, id), 'thumb')
const dir = clipDir(owner, id)
for (const name of ['thumb.jpg', 'thumb.png', 'thumb']) {
const path = join(dir, name)
if (existsSync(path)) return path
}
return join(dir, 'thumb.jpg')
}
function extractVideoThumb(videoPath: string, destPath: string) {
return new Promise<boolean>((resolve) => {
const proc = spawn('ffmpeg', ['-y', '-ss', '0.2', '-i', videoPath, '-frames:v', '1', '-q:v', '4', destPath], { stdio: 'ignore' })
const timer = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* ignore */ }
resolve(false)
}, 20000)
proc.on('close', (code) => {
clearTimeout(timer)
resolve(code === 0 && existsSync(destPath))
})
proc.on('error', () => {
clearTimeout(timer)
resolve(false)
})
})
}
function probeVideoSize(videoPath: string) {
return new Promise<{ width: number; height: number } | null>((resolve) => {
const proc = spawn('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=p=0:s=x',
videoPath
], { stdio: ['ignore', 'pipe', 'ignore'] })
let out = ''
proc.stdout?.on('data', (chunk: Buffer | string) => { out += String(chunk) })
const timer = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* ignore */ }
resolve(null)
}, 10000)
proc.on('close', () => {
clearTimeout(timer)
const [width, height] = out.trim().split(/[x,]/).map(Number)
resolve(width && height ? { width, height } : null)
})
proc.on('error', () => {
clearTimeout(timer)
resolve(null)
})
})
}
export async function ensureClipThumbnails(owner: string) {
const catalog = readCatalog(owner)
const needsHistory = catalog.clips.some(clip => clip.comfyFilename && (!clip.steps || !clip.seed))
const history = needsHistory
? await fetchHistoryAll().catch(() => ({} as Record<string, unknown>))
: {}
const byFile = new Map<string, { prompt: string; width: number; height: number; steps: number; seed: number }>()
for (const [promptId, entry] of Object.entries(history)) {
const video = extractVideo({ [promptId]: entry as Record<string, unknown> }, promptId)
if (!video) continue
const meta = extractClipMetaFromHistory(entry)
byFile.set(video.filename, {
prompt: extractPromptFromHistory(entry),
width: meta.width,
height: meta.height,
steps: meta.steps,
seed: meta.seed
})
}
const sizes = new Map<string, { width: number; height: number }>()
for (const clip of catalog.clips) {
if (clip.hideThumbnail) continue
const thumb = clipThumbPath(owner, clip.id)
const video = clipVideoPath(owner, clip.id)
if (!existsSync(video)) continue
if (!existsSync(thumb)) {
await extractVideoThumb(video, join(clipDir(owner, clip.id), 'thumb.jpg'))
}
if (!clip.width || !clip.height) {
const probed = await probeVideoSize(video)
if (probed) sizes.set(clip.id, probed)
}
}
let dirty = false
for (const clip of catalog.clips) {
const found = clip.comfyFilename ? byFile.get(clip.comfyFilename) : undefined
const probed = sizes.get(clip.id)
if (found) {
if (!clip.width && found.width) {
clip.width = found.width
clip.height = found.height
dirty = true
}
if (!clip.steps && found.steps) {
clip.steps = found.steps
dirty = true
}
if (!clip.seed && found.seed) {
clip.seed = found.seed
dirty = true
}
if ((!clip.prompt || clip.prompt === 'Recovered from ComfyUI') && found.prompt) {
clip.prompt = found.prompt
dirty = true
}
}
if ((!clip.width || !clip.height) && probed) {
clip.width = probed.width
clip.height = probed.height
dirty = true
}
const titled = clipTitle(clip)
if (clip.name !== titled) {
clip.name = titled
dirty = true
}
}
if (dirty) {
await mutate(owner, (next) => {
const byId = new Map(catalog.clips.map(clip => [clip.id, clip]))
for (const clip of next.clips) {
const updated = byId.get(clip.id)
if (!updated) continue
clip.name = updated.name
clip.prompt = updated.prompt
clip.width = updated.width
clip.height = updated.height
clip.steps = updated.steps
clip.seed = updated.seed
}
})
}
}
export function stillPath(owner: string, id: string) {
@@ -451,6 +609,7 @@ export function imageContentType(buf: Buffer) {
export async function saveClip(params: {
ownerKey: string
folderId: string
name?: string
prompt: string
aspect: string
width: number
@@ -472,6 +631,7 @@ export async function saveClip(params: {
const clip: LibraryClip = {
id: crypto.randomUUID(),
folderId: folder.id,
name: clipTitle({ name: params.name || '', prompt: params.prompt }),
prompt: params.prompt,
aspect: params.aspect,
width: params.width,
@@ -486,7 +646,9 @@ export async function saveClip(params: {
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
if (!params.hideThumbnail && params.thumb?.length) {
writeFileSync(clipThumbPath(params.ownerKey, clip.id), params.thumb)
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
} else if (!params.hideThumbnail) {
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
}
if (params.thumb?.length) {
await saveStill({
@@ -543,14 +705,20 @@ export async function importMissingComfyVideos(owner: string, folderId?: string)
const known = new Set(catalog.clips.map(clip => clip.comfyFilename).filter(Boolean) as string[])
const unlabeled = catalog.clips.filter(clip => !clip.comfyFilename).length
const history = await fetchHistoryAll()
const found: { promptId: string; video: { filename: string; subfolder: string; type: string }; prompt: string }[] = []
const found: { promptId: string; video: { filename: string; subfolder: string; type: string }; prompt: string; width: number; height: number; steps: number; seed: number }[] = []
for (const [promptId, entry] of Object.entries(history)) {
const video = extractVideo({ [promptId]: entry as Record<string, unknown> }, promptId)
if (!video || known.has(video.filename)) continue
const prompt = extractPromptFromHistory(entry) || 'Recovered from ComfyUI'
const meta = extractClipMetaFromHistory(entry)
found.push({
promptId,
video,
prompt: extractPromptFromHistory(entry) || 'Recovered from ComfyUI'
prompt,
width: meta.width,
height: meta.height,
steps: meta.steps,
seed: meta.seed
})
}
found.sort((a, b) => a.video.filename.localeCompare(b.video.filename, undefined, { numeric: true }))
@@ -561,13 +729,14 @@ export async function importMissingComfyVideos(owner: string, folderId?: string)
const clip = await saveClip({
ownerKey: owner,
folderId: folder.id,
name: clipTitle({ name: '', prompt: item.prompt }),
prompt: item.prompt,
aspect: 'auto',
width: 0,
height: 0,
steps: 0,
width: item.width,
height: item.height,
steps: item.steps,
turbo: true,
seed: 0,
seed: item.seed,
hideThumbnail: false,
video: buffer,
thumb: null,
+2
View File
@@ -8,6 +8,7 @@ export interface PendingJob {
ownerKey: string
folderId: string
hideThumbnail: boolean
name?: string
prompt: string
aspect: string
width: number
@@ -68,6 +69,7 @@ export async function completePendingIfReady(pending: PendingJob) {
const clip = await saveClip({
ownerKey: pending.ownerKey,
folderId: pending.folderId,
name: pending.name,
prompt: pending.prompt,
aspect: pending.aspect,
width: pending.width,
+1
View File
@@ -60,6 +60,7 @@ export function watchComfyJob(job: Job): Promise<void> {
const clip = await saveClip({
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
name: job.library.name,
prompt: job.library.prompt,
aspect: job.library.aspect,
width: job.library.width,