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' const scryptAsync = promisify(scrypt) export interface LibraryFolder { id: string name: string createdAt: number thumbnailDefault: boolean passwordHash: string | null } export interface LibraryClip { id: string folderId: string name: string prompt: string aspect: string width: number height: number steps: number turbo: boolean seed: number hideThumbnail: boolean createdAt: number comfyFilename?: string } export interface LibraryStill { id: string folderId: string filename: string width: number height: number hash: string createdAt: number } export interface PublicFolder { id: string name: string createdAt: number thumbnailDefault: boolean protected: boolean unlocked: boolean clipCount: number } interface Catalog { folders: LibraryFolder[] clips: LibraryClip[] stills: LibraryStill[] } const writeChains = new Map>() function libraryRoot() { const config = useRuntimeConfig() return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '') } export function libraryOwnerKey(event: H3Event) { if (passwordAuthEnabled()) return privateInstanceId() const user = getSessionUser(event) const raw = user?.sub || user?.email || (!authEnabled() ? 'local' : '') if (!raw) { throw createError({ statusCode: 401, statusMessage: 'Authentication required' }) } return createHash('sha256').update(raw).digest('hex').slice(0, 24) } function ownerRoot(owner: string) { return join(libraryRoot(), 'users', owner) } function catalogPath(owner: string) { return join(ownerRoot(owner), 'catalog.json') } function emptyCatalog(): Catalog { return { folders: [{ id: 'inbox', name: 'Inbox', createdAt: Date.now(), thumbnailDefault: true, passwordHash: null }], clips: [], stills: [] } } function normalizeFolder(folder: LibraryFolder): LibraryFolder { return { id: folder.id, name: folder.name, createdAt: folder.createdAt || Date.now(), thumbnailDefault: folder.thumbnailDefault !== false, passwordHash: folder.passwordHash || null } } function normalizeCatalog(parsed: Partial & { passwordHash?: string | null }): Catalog { const folders = Array.isArray(parsed.folders) && parsed.folders.length ? parsed.folders.map(normalizeFolder) : emptyCatalog().folders if (parsed.passwordHash) { for (const folder of folders) { if (!folder.passwordHash) folder.passwordHash = parsed.passwordHash } } return { folders, clips: Array.isArray(parsed.clips) ? parsed.clips : [], stills: Array.isArray(parsed.stills) ? parsed.stills : [] } } function migrateLegacy(owner: string) { const dest = catalogPath(owner) if (existsSync(dest)) return mkdirSync(ownerRoot(owner), { recursive: true }) const legacyCatalog = join(libraryRoot(), 'catalog.json') const legacyFiles = join(libraryRoot(), 'files') if (!existsSync(legacyCatalog)) { writeFileSync(dest, JSON.stringify(emptyCatalog(), null, 2)) return } try { const catalog = normalizeCatalog(JSON.parse(readFileSync(legacyCatalog, 'utf8'))) writeFileSync(dest, JSON.stringify(catalog, null, 2)) if (existsSync(legacyFiles) && !existsSync(join(ownerRoot(owner), 'files'))) { renameSync(legacyFiles, join(ownerRoot(owner), 'files')) } renameSync(legacyCatalog, `${legacyCatalog}.migrated`) } catch { writeFileSync(dest, JSON.stringify(emptyCatalog(), null, 2)) } } function ensureOwner(owner: string) { mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true }) mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true }) migrateLegacy(owner) } function readCatalog(owner: string): Catalog { ensureOwner(owner) try { return normalizeCatalog(JSON.parse(readFileSync(catalogPath(owner), 'utf8'))) } catch { const created = emptyCatalog() writeFileSync(catalogPath(owner), JSON.stringify(created, null, 2)) return created } } function writeCatalog(owner: string, catalog: Catalog) { ensureOwner(owner) const tmp = catalogPath(owner) + '.tmp' writeFileSync(tmp, JSON.stringify(catalog, null, 2)) renameSync(tmp, catalogPath(owner)) } function mutate(owner: string, fn: (catalog: Catalog) => T): Promise { const prev = writeChains.get(owner) || Promise.resolve() const run = prev.then(() => { const catalog = readCatalog(owner) const result = fn(catalog) writeCatalog(owner, catalog) return result }) writeChains.set(owner, run.then(() => undefined, () => undefined)) 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' } export async function hashLibraryPassword(password: string) { const salt = randomBytes(16) const hash = await scryptAsync(password, salt, 64) as Buffer return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}` } async function verifyStoredPassword(stored: string | null, password: string) { if (!stored) return true const parts = stored.split(':') if (parts.length !== 3 || parts[0] !== 'scrypt') return false const salt = Buffer.from(parts[1], 'hex') const expected = Buffer.from(parts[2], 'hex') const actual = await scryptAsync(password, salt, 64) as Buffer return actual.length === expected.length && timingSafeEqual(actual, expected) } function folderUnlocks(event: H3Event) { return getFolderUnlocks(event) } export function folderUnlocked(event: H3Event, folder: LibraryFolder) { if (!folder.passwordHash) return true return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash) } export function publicFolder(event: H3Event, folder: LibraryFolder, clipCount = 0): PublicFolder { return { id: folder.id, name: folder.name, createdAt: folder.createdAt, thumbnailDefault: folder.thumbnailDefault, protected: Boolean(folder.passwordHash), unlocked: folderUnlocked(event, folder), clipCount } } export function publicLibrary(event: H3Event) { const owner = libraryOwnerKey(event) const catalog = readCatalog(owner) const folders = catalog.folders.map(folder => publicFolder( event, folder, catalog.clips.filter(clip => clip.folderId === folder.id).length )) const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id)) return { folders, 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)) } } export function assertLibraryOwner(event: H3Event) { const owner = libraryOwnerKey(event) return { owner, catalog: readCatalog(owner) } } export function assertFolderExists(event: H3Event, folderId: string) { const { owner, catalog } = assertLibraryOwner(event) const folder = catalog.folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) return { owner, catalog, folder } } export function assertFolderAccess(event: H3Event, folderId: string) { const { owner, catalog, folder } = assertFolderExists(event, folderId) if (!folderUnlocked(event, folder)) { throw createError({ statusCode: 403, statusMessage: 'Folder is locked' }) } return { owner, catalog, folder } } export function assertLibraryAccess(event: H3Event) { return assertLibraryOwner(event).catalog } export function createFolder(owner: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'Folder name is required' }) return mutate(owner, (catalog) => { if (catalog.folders.some(folder => folder.name.toLowerCase() === trimmed.toLowerCase())) { throw createError({ statusCode: 409, statusMessage: 'A folder with that name already exists' }) } const folder: LibraryFolder = { id: crypto.randomUUID(), name: trimmed, createdAt: Date.now(), thumbnailDefault: true, passwordHash: null } catalog.folders.push(folder) return folder }) } export function renameFolder(owner: string, id: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'Folder name is required' }) return mutate(owner, (catalog) => { const folder = catalog.folders.find(item => item.id === id) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) if (catalog.folders.some(item => item.id !== id && item.name.toLowerCase() === trimmed.toLowerCase())) { throw createError({ statusCode: 409, statusMessage: 'A folder with that name already exists' }) } folder.name = trimmed return folder }) } export function deleteFolder(owner: string, id: string) { return mutate(owner, (catalog) => { if (catalog.folders.length <= 1) { throw createError({ statusCode: 400, statusMessage: 'Keep at least one library folder' }) } const folder = catalog.folders.find(item => item.id === id) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) const clips = catalog.clips.filter(clip => clip.folderId === id) const stills = catalog.stills.filter(still => still.folderId === id) catalog.folders = catalog.folders.filter(item => item.id !== id) catalog.clips = catalog.clips.filter(clip => clip.folderId !== id) catalog.stills = catalog.stills.filter(still => still.folderId !== id) for (const clip of clips) { rmSync(clipDir(owner, clip.id), { recursive: true, force: true }) } for (const still of stills) { rmSync(stillPath(owner, still.id), { force: true }) } return { fallbackFolderId: catalog.folders[0].id } }) } export async function updateFolder(owner: string, id: string, patch: { name?: string thumbnailDefault?: boolean password?: string | null currentPassword?: string }) { const catalog = readCatalog(owner) const folder = catalog.folders.find(item => item.id === id) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) let nextName = folder.name if (patch.name !== undefined) { nextName = patch.name.trim().slice(0, 80) if (!nextName) throw createError({ statusCode: 400, statusMessage: 'Folder name is required' }) if (catalog.folders.some(item => item.id !== id && item.name.toLowerCase() === nextName.toLowerCase())) { throw createError({ statusCode: 409, statusMessage: 'A folder with that name already exists' }) } } let nextHash: string | null | undefined if (patch.password !== undefined) { const next = String(patch.password || '').trim() if (folder.passwordHash && !(await verifyStoredPassword(folder.passwordHash, String(patch.currentPassword || '')))) { throw createError({ statusCode: 401, statusMessage: 'Current folder password is incorrect' }) } if (next && next.length < 4) { throw createError({ statusCode: 400, statusMessage: 'Use at least 4 characters, or leave blank to remove the password' }) } nextHash = next ? await hashLibraryPassword(next) : null } return mutate(owner, (nextCatalog) => { const item = nextCatalog.folders.find(entry => entry.id === id) if (!item) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) item.name = nextName if (typeof patch.thumbnailDefault === 'boolean') item.thumbnailDefault = patch.thumbnailDefault if (nextHash !== undefined) item.passwordHash = nextHash return item }) } export async function verifyFolderPassword(owner: string, folderId: string, password: string) { const folder = readCatalog(owner).folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) if (!folder.passwordHash) return folder if (!(await verifyStoredPassword(folder.passwordHash, password))) { throw createError({ statusCode: 401, statusMessage: 'Incorrect folder password' }) } return folder } export async function unlockMatchingFolders(owner: string, password: string, folderId?: string) { const catalog = readCatalog(owner) const targets = folderId ? catalog.folders.filter(folder => folder.id === folderId) : catalog.folders.filter(folder => folder.passwordHash) if (folderId && !targets.length) { throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) } const unlocked: LibraryFolder[] = [] for (const folder of targets) { if (!folder.passwordHash || await verifyStoredPassword(folder.passwordHash, password)) { unlocked.push(folder) } } if (!unlocked.length) { throw createError({ statusCode: 401, statusMessage: 'Incorrect folder password' }) } return unlocked } function clipDir(owner: string, id: string) { 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) { return join(clipDir(owner, id), 'video.mp4') } export function clipThumbPath(owner: string, id: string) { 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', '-update', '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) { return join(ownerRoot(owner), 'stills', id) } function bytesHash(data: Buffer) { return createHash('sha256').update(data).digest('hex') } export async function saveStill(params: { ownerKey: string folderId: string filename: string data: Buffer width?: number height?: number }) { const catalog = readCatalog(params.ownerKey) const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) const hash = bytesHash(params.data) const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash) if (existing) return existing const still: LibraryStill = { id: crypto.randomUUID(), folderId: folder.id, filename: params.filename.slice(0, 120) || 'still.png', width: params.width || 0, height: params.height || 0, hash, createdAt: Date.now() } mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true }) writeFileSync(stillPath(params.ownerKey, still.id), params.data) await mutate(params.ownerKey, (next) => { next.stills.unshift(still) }) return still } export function getStill(owner: string, id: string) { const still = readCatalog(owner).stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) return still } export function deleteStill(owner: string, id: string) { return mutate(owner, (catalog) => { const still = catalog.stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) catalog.stills = catalog.stills.filter(item => item.id !== id) rmSync(stillPath(owner, id), { force: true }) return still }) } export function imageContentType(buf: Buffer) { if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png' if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg' if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp' return 'application/octet-stream' } export async function saveClip(params: { ownerKey: string folderId: string name?: string prompt: string aspect: string width: number height: number steps: number turbo: boolean seed: number hideThumbnail: boolean video: Buffer thumb?: Buffer | null comfyFilename?: string }) { const catalog = readCatalog(params.ownerKey) const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) if (params.comfyFilename && catalog.clips.some(clip => clip.comfyFilename === params.comfyFilename)) { return catalog.clips.find(clip => clip.comfyFilename === params.comfyFilename) as LibraryClip } 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, height: params.height, steps: params.steps, turbo: params.turbo, seed: params.seed, hideThumbnail: params.hideThumbnail, createdAt: Date.now(), comfyFilename: params.comfyFilename } mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true }) writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video) if (!params.hideThumbnail && params.thumb?.length) { 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({ ownerKey: params.ownerKey, folderId: folder.id, filename: `${clip.id}.png`, data: params.thumb, width: params.width, height: params.height }) } await mutate(params.ownerKey, (next) => { next.clips.unshift(clip) }) return clip } export function getClip(owner: string, id: string) { const clip = readCatalog(owner).clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) return clip } export function deleteClip(owner: string, id: string) { return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) catalog.clips = catalog.clips.filter(item => item.id !== id) rmSync(clipDir(owner, id), { recursive: true, force: true }) return clip }) } export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) { const subfolders = [...new Set([video.subfolder, 'video', ''])] let lastStatus = 0 for (const subfolder of subfolders) { const params = new URLSearchParams({ filename: video.filename, subfolder, type: video.type || 'output' }) const res = await comfyFetch(`/view?${params.toString()}`) lastStatus = res.status if (res.ok) return Buffer.from(await res.arrayBuffer()) } throw new Error(`Failed to fetch completed video from ComfyUI (${lastStatus})`) } export async function importMissingComfyVideos(owner: string, folderId?: string) { const catalog = readCatalog(owner) const folder = catalog.folders.find(item => item.id === folderId) || catalog.folders[0] if (!folder) return [] 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; 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) || !isOurComfyVideo(video)) continue const prompt = extractPromptFromHistory(entry) || 'Recovered from ComfyUI' const meta = extractClipMetaFromHistory(entry) found.push({ promptId, video, 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 })) const toImport = found.slice(unlabeled) const imported = [] for (const item of toImport) { const buffer = await downloadComfyVideo(item.video) const clip = await saveClip({ ownerKey: owner, folderId: folder.id, name: clipTitle({ name: '', prompt: item.prompt }), prompt: item.prompt, aspect: 'auto', width: item.width, height: item.height, steps: item.steps, turbo: true, seed: item.seed, hideThumbnail: false, video: buffer, thumb: null, comfyFilename: item.video.filename }) imported.push(clip) await purgeComfyArtifacts({ video: item.video, promptId: item.promptId }) } return imported }