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:
+179
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user