-
{{ item.prompt }}
-
{{ item.width }}×{{ item.height }} · {{ item.steps }} steps · seed {{ item.seed }}
+
+
{{ item.name || item.prompt }}
+
+
+
+ {{ item.prompt }}
+
+
+
+
{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}
@@ -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
(null)
const preview = ref('')
const dragging = ref(false)
const prompt = ref('')
+const clipName = ref('')
+const outputDownloadName = ref('aigen')
const aspect = ref('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
diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts
index 929a91d..1d46454 100644
--- a/server/api/generate.post.ts
+++ b/server/api/generate.post.ts
@@ -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,
diff --git a/server/api/library/clips/[id]/video.get.ts b/server/api/library/clips/[id]/video.get.ts
index ba6de37..32ff56e 100644
--- a/server/api/library/clips/[id]/video.get.ts
+++ b/server/api/library/clips/[id]/video.get.ts
@@ -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)
})
diff --git a/server/api/library/index.get.ts b/server/api/library/index.get.ts
index b6a7467..0753aca 100644
--- a/server/api/library/index.get.ts
+++ b/server/api/library/index.get.ts
@@ -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)
})
diff --git a/server/utils/comfy.ts b/server/utils/comfy.ts
index de6d9fd..b8c086c 100644
--- a/server/utils/comfy.ts
+++ b/server/utils/comfy.ts
@@ -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 }>)) {
+ 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 | null, promptId: string) {
const video = extractVideo(history, promptId)
if (!history) return { video: null, completed: false, error: null as string | null }
diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts
index 0d7b3ac..7879923 100644
--- a/server/utils/jobs.ts
+++ b/server/utils/jobs.ts
@@ -33,6 +33,7 @@ export interface Job {
ownerKey: string
folderId: string
hideThumbnail: boolean
+ name?: string
prompt: string
aspect: string
width: number
diff --git a/server/utils/library.ts b/server/utils/library.ts
index ee71770..c5675ee 100644
--- a/server/utils/library.ts
+++ b/server/utils/library.ts
@@ -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(owner: string, fn: (catalog: Catalog) => T): Promise {
return run
}
+export function clipTitle(clip: Pick) {
+ 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((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))
+ : {}
+ const byFile = new Map()
+ for (const [promptId, entry] of Object.entries(history)) {
+ const video = extractVideo({ [promptId]: entry as Record }, 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()
+ 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 }, 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,
diff --git a/server/utils/pending.ts b/server/utils/pending.ts
index ea1ea01..8733038 100644
--- a/server/utils/pending.ts
+++ b/server/utils/pending.ts
@@ -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,
diff --git a/server/utils/watch.ts b/server/utils/watch.ts
index 90a57c3..d3a775c 100644
--- a/server/utils/watch.ts
+++ b/server/utils/watch.ts
@@ -60,6 +60,7 @@ export function watchComfyJob(job: Job): Promise {
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,