import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, statSync, createReadStream, openSync, readSync, closeSync } 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' import { extractFirstFrame, extractFrameAt, extractLastFrame, probeDuration, remuxFaststart } from '~/server/utils/ffmpeg' import { normalizePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks' 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 cfg?: number fps?: number samplerName?: string scheduler?: string duration?: number familyId?: string parentClipId?: string chainIndex?: number workflow?: import('~/utils/videoModels').VideoWorkflowId stillId?: string sound?: boolean hideInput?: boolean globalLocks?: string permanenceRefs?: PermanenceRef[] loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] } export type StillRole = 'input' | 'output' export interface LibraryStill { id: string folderId: string filename: string width: number height: number hash: string createdAt: number hideInput: boolean role: StillRole name?: string prompt?: string familyId?: string parentStillId?: string chainIndex?: number } export interface PublicFolder { id: string name: string createdAt: number thumbnailDefault: boolean protected: boolean unlocked: boolean clipCount: number imageCount: number } export interface QueuedExtension { prompt: string duration: number permanenceRefs?: PermanenceRef[] loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] } export interface RetryDraft { id: string createdAt: number folderId: string stillId?: string name?: string prompt: string aspect: string width: number height: number steps: number turbo: boolean seed: number duration: number hideThumbnail: boolean sound: boolean hideInput?: boolean stillFilename?: string cfg?: number fps?: number samplerName?: string scheduler?: string extensions?: QueuedExtension[] workflow?: import('~/utils/videoModels').VideoWorkflowId globalLocks?: string permanenceRefs?: PermanenceRef[] shotPermanenceRefs?: PermanenceRef[][] loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] shotLoras?: string[] shotLoraStacks?: import('~/utils/loras').LoraStackItem[][] } interface Catalog { folders: LibraryFolder[] clips: LibraryClip[] stills: LibraryStill[] drafts: RetryDraft[] } const writeChains = new Map>() export 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) } export 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: [], drafts: [] } } 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 generatedOutputFilename(name: string) { const filename = String(name || '').trim() return /aigen[_-]?edit/i.test(filename) || /^ComfyUI[_-]/i.test(filename) || /_\d{5}_(?:\.[a-z0-9]+)?$/i.test(filename) } function stillRole(still: Pick): StillRole { if (still.role === 'output' || still.role === 'input') return still.role return generatedOutputFilename(still.filename) ? 'output' : 'input' } function normalizeStill(still: LibraryStill): LibraryStill { return { id: still.id, folderId: still.folderId, filename: still.filename, width: still.width || 0, height: still.height || 0, hash: still.hash || '', createdAt: still.createdAt || Date.now(), hideInput: still.hideInput === true, role: stillRole(still), name: still.name, prompt: still.prompt, familyId: still.familyId, parentStillId: still.parentStillId, chainIndex: still.chainIndex } } 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.map(normalizeStill) : [], drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [] } } 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) const path = catalogPath(owner) let catalog: Catalog try { catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8'))) } catch { if (existsSync(path)) { throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' }) } catalog = emptyCatalog() writeFileSync(path, JSON.stringify(catalog, null, 2)) return catalog } if (purgeStrayExtensionStills(owner, catalog).count) writeCatalog(owner, catalog) return catalog } 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 familyBaseName(name: string) { return String(name || '').trim().replace(/\s+p\d+$/i, '') } export function nextClipPartName(name: string) { const trimmed = (name || '').trim() if (!trimmed) return '' const match = trimmed.match(/^(.*)\s+p(\d+)$/i) const base = (match ? match[1] : trimmed).trim() || trimmed const part = match ? Number(match[2]) + 1 : 2 const suffix = ` p${part}` return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}` } export function sortFamilyParts(a: LibraryClip, b: LibraryClip) { const chain = (a.chainIndex || 0) - (b.chainIndex || 0) if (chain) return chain return a.createdAt - b.createdAt } export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) { const folderClips = catalog.clips.filter(item => item.folderId === clip.folderId) const base = familyBaseName(clip.name || '').toLowerCase() const ids = new Set([clip.id]) if (clip.familyId) { for (const item of folderClips) { if (item.familyId === clip.familyId) ids.add(item.id) } } if (base) { for (const item of folderClips) { if (item.familyId && item.familyId !== clip.familyId) continue if (familyBaseName(item.name || '').toLowerCase() === base) ids.add(item.id) } } return folderClips.filter(item => ids.has(item.id)) } export function stillsInSameFamily(catalog: Catalog, still: LibraryStill) { const folderStills = catalog.stills.filter(item => item.folderId === still.folderId && stillRole(item) === 'output') const ids = new Set([still.id]) if (still.familyId) { for (const item of folderStills) { if (item.familyId === still.familyId) ids.add(item.id) } } return folderStills.filter(item => ids.has(item.id)) } export function stillChainName(base: string, index: number) { const trimmed = (base || '').trim() if (!trimmed || index <= 0) return trimmed.slice(0, 80) const suffix = ` p${index + 1}` return `${trimmed.slice(0, Math.max(1, 80 - suffix.length))}${suffix}` } 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, imageCount = 0): PublicFolder { return { id: folder.id, name: folder.name, createdAt: folder.createdAt, thumbnailDefault: folder.thumbnailDefault, protected: Boolean(folder.passwordHash), unlocked: folderUnlocked(event, folder), clipCount, imageCount } } 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, catalog.stills.filter(still => still.folderId === folder.id && !isPipelineStill(catalog, still) && stillRole(still) === 'output').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)), hasVideo: existsSync(clipVideoPath(owner, clip.id)) })), stills: catalog.stills .filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still)) .map(still => ({ ...still, role: stillRole(still) })), drafts: catalog.drafts } } 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) { removeStillFiles(owner, still.id) } 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 clipLastFramePath(owner: string, id: string) { 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 } type FrameSourceMeta = { videoMtime: number videoSize: number mode: 'file-start' | 'segment-start' | 'last' seek?: number } function frameMetaPath(dest: string) { return `${dest}.src.json` } function readFrameMeta(dest: string): FrameSourceMeta | null { try { const raw = JSON.parse(readFileSync(frameMetaPath(dest), 'utf8')) as FrameSourceMeta if (!raw || typeof raw.videoMtime !== 'number' || typeof raw.videoSize !== 'number') return null return raw } catch { return null } } function writeFrameMeta(dest: string, video: string, mode: FrameSourceMeta['mode'], seek?: number) { try { const info = statSync(video) const meta: FrameSourceMeta = { videoMtime: info.mtimeMs, videoSize: info.size, mode } if (typeof seek === 'number') meta.seek = seek writeFileSync(frameMetaPath(dest), JSON.stringify(meta)) } catch { /* thumbs still work without Comfy */ } } function frameCacheFresh(video: string, dest: string, mode: FrameSourceMeta['mode']) { if (!existsSync(dest) || statSync(dest).size < 64) return false try { const info = statSync(video) const meta = readFrameMeta(dest) if (meta) { return meta.mode === mode && meta.videoMtime === info.mtimeMs && meta.videoSize === info.size } return statSync(dest).mtimeMs >= info.mtimeMs && mode === 'file-start' } catch { return false } } async function parentVideoDuration(owner: string, parentId?: string) { if (!parentId) return 0 const video = clipVideoPath(owner, parentId) if (!existsSync(video)) return 0 try { return await probeDuration(video) } catch { return 0 } } export async function ensureClipLastFrame(owner: string, id: string) { const video = clipVideoPath(owner, id) const dest = clipLastFramePath(owner, id) if (!existsSync(video)) return '' if (!frameCacheFresh(video, dest, 'last')) { await extractLastFrame(video, dest) writeFrameMeta(dest, video, 'last') } 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 '' const catalog = readCatalog(owner) const clip = catalog.clips.find(item => item.id === id) const parentDur = await parentVideoDuration(owner, clip?.parentClipId) let fileDur = 0 try { fileDur = await probeDuration(video) } catch { /* treat as a single segment */ } const stored = Number(clip?.duration) || 0 const stitched = (parentDur > 0.5 && fileDur > parentDur + 0.4) || (stored > 0.5 && fileDur > stored + 0.8) const seek = parentDur > 0.5 && fileDur > parentDur + 0.4 ? parentDur : (stitched && stored > 0.5 ? Math.max(0, fileDur - stored) : 0) const mode: FrameSourceMeta['mode'] = stitched ? 'segment-start' : 'file-start' if (frameCacheFresh(video, dest, mode)) return dest if (stitched) { await extractFrameAt(video, dest, seek) writeFrameMeta(dest, video, 'segment-start', seek) return dest } await extractFirstFrame(video, dest) writeFrameMeta(dest, video, 'file-start', 0) return dest } export function extendTempDir(owner: string, jobId: string) { const dir = join(ownerRoot(owner), 'tmp', 'extend', jobId) mkdirSync(dir, { recursive: true }) return dir } export function removeExtendTemp(dir?: string) { if (!dir) return rmSync(dir, { recursive: true, force: true }) } 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, opts?: { skipHistory?: boolean }) { const catalog = readCatalog(owner) const needsHistory = !opts?.skipHistory && 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) } export function stillThumbFile(owner: string, id: string) { return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`) } function removeStillFiles(owner: string, id: string) { rmSync(stillPath(owner, id), { force: true }) rmSync(stillThumbFile(owner, id), { force: true }) } let thumbSlots = 0 const thumbWaiters: Array<() => void> = [] const thumbBuilds = new Map>() function withThumbSlot(fn: () => Promise) { return new Promise((resolve, reject) => { const run = () => { thumbSlots += 1 fn().then(resolve, reject).finally(() => { thumbSlots -= 1 const next = thumbWaiters.shift() if (next) next() }) } if (thumbSlots < 2) run() else thumbWaiters.push(run) }) } function buildStillThumb(source: string, dest: string) { return new Promise((resolve) => { const proc = spawn('ffmpeg', [ '-y', '-i', source, '-vf', 'scale=320:-2', '-frames:v', '1', '-q:v', '5', dest ], { stdio: 'ignore' }) const timer = setTimeout(() => { try { proc.kill('SIGKILL') } catch { /* ignore */ } resolve(false) }, 20000) proc.on('close', (code) => { clearTimeout(timer) resolve(code === 0 && existsSync(dest)) }) proc.on('error', () => { clearTimeout(timer) resolve(false) }) }) } export async function stillThumbPath(owner: string, id: string) { const source = stillPath(owner, id) const dest = stillThumbFile(owner, id) if (!existsSync(source)) return '' if (existsSync(dest)) { try { if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest } catch { /* rebuild */ } } const key = `${owner}:${id}` 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 ensureStillThumbnails(owner: string) { const catalog = readCatalog(owner) for (const still of catalog.stills) { if (isPipelineStill(catalog, still)) continue await stillThumbPath(owner, still.id) } } const lastMaintain = new Map() export async function maintainLibrary(owner: string, folderId?: string) { const prev = lastMaintain.get(owner) || 0 if (Date.now() - prev < 20000) return lastMaintain.set(owner, Date.now()) void ensureStillThumbnails(owner).catch(() => null) const live = await fetchLiveQueue().catch(() => null) const busy = Boolean(live && live.running > 0) if (!busy) { await importMissingComfyVideos(owner, folderId).catch(() => []) } await ensureClipThumbnails(owner, { skipHistory: busy }) if (!busy) await sweepOwnerExtensionStills(owner) } export function fileEtag(path: string) { const st = statSync(path) return `W/"${st.size}-${Math.round(st.mtimeMs)}"` } function bytesHash(data: Buffer) { return createHash('sha256').update(data).digest('hex') } function fileBytesHash(path: string) { if (!existsSync(path)) return '' try { return bytesHash(readFileSync(path)) } catch { return '' } } function isExtensionClip(clip: LibraryClip) { return Boolean(clip.parentClipId) || (clip.chainIndex || 0) > 0 } function lastFrameStillName(name: string) { return /last[\s._-]*frame/i.test(String(name || '').trim()) } export function isPipelineFrameFilename(name: string) { return lastFrameStillName(name) } function clipIdFromStillFilename(name: string) { const match = String(name || '').trim().match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i) return match ? match[1].toLowerCase() : '' } function isPipelineStill(catalog: Catalog, still: LibraryStill) { if (lastFrameStillName(still.filename)) return true const clipId = clipIdFromStillFilename(still.filename) if (!clipId) return false return catalog.clips.some(clip => clip.id.toLowerCase() === clipId) } function originalInputHashes(owner: string, catalog: Catalog) { const hashes = new Set() for (const clip of catalog.clips) { if (isExtensionClip(clip)) continue const hash = fileBytesHash(clipThumbPath(owner, clip.id)) if (hash) hashes.add(hash) } return hashes } function extensionFrameHashes(owner: string, catalog: Catalog) { const hashes = new Set() for (const clip of catalog.clips) { if (!isExtensionClip(clip)) continue const last = fileBytesHash(clipLastFramePath(owner, clip.id)) if (last) hashes.add(last) const rawThumb = fileBytesHash(join(clipDir(owner, clip.id), 'thumb')) if (rawThumb) hashes.add(rawThumb) } return hashes } function allLastFrameHashes(owner: string, catalog: Catalog) { const hashes = new Set() for (const clip of catalog.clips) { const last = fileBytesHash(clipLastFramePath(owner, clip.id)) if (last) hashes.add(last) } return hashes } function purgeStrayExtensionStills(owner: string, catalog: Catalog) { const protectedIds = new Set((catalog.drafts || []).map(draft => draft.stillId).filter(Boolean) as string[]) const originalHashes = originalInputHashes(owner, catalog) const extHashes = extensionFrameHashes(owner, catalog) const lastHashes = allLastFrameHashes(owner, catalog) const keep: LibraryStill[] = [] const drop: LibraryStill[] = [] for (const still of catalog.stills) { const clipId = clipIdFromStillFilename(still.filename) const namedLastFrame = lastFrameStillName(still.filename) const namedExtClip = Boolean(clipId && catalog.clips.some(clip => clip.id.toLowerCase() === clipId)) const matchesPipelineFrame = Boolean(still.hash && (lastHashes.has(still.hash) || extHashes.has(still.hash))) const isOriginal = Boolean(still.hash && originalHashes.has(still.hash)) const dropThis = !protectedIds.has(still.id) && (namedLastFrame || namedExtClip || (matchesPipelineFrame && !isOriginal)) if (dropThis) drop.push(still) else keep.push(still) } if (!drop.length) return { count: 0, filenames: [] as string[] } catalog.stills = keep for (const still of drop) { removeStillFiles(owner, still.id) } return { count: drop.length, filenames: drop.map(still => still.filename) } } export function sweepOwnerExtensionStills(owner: string) { return mutate(owner, (catalog) => purgeStrayExtensionStills(owner, catalog)) } export function sweepAllExtensionStills() { const users = join(libraryRoot(), 'users') let owners = 0 const filenames: string[] = [] if (!existsSync(users)) return { owners, deleted: 0, filenames } for (const entry of readdirSync(users, { withFileTypes: true })) { if (!entry.isDirectory()) continue owners += 1 const path = catalogPath(entry.name) if (!existsSync(path)) continue try { const catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8'))) const removed = purgeStrayExtensionStills(entry.name, catalog) if (removed.count) writeCatalog(entry.name, catalog) filenames.push(...removed.filenames) } catch { /* leave a bad catalog for the next authenticated read */ } } return { owners, deleted: filenames.length, filenames } } export async function saveStill(params: { ownerKey: string folderId: string filename: string data: Buffer width?: number height?: number hideInput?: boolean role?: StillRole name?: string prompt?: string familyId?: string parentStillId?: string chainIndex?: number }): Promise { if (lastFrameStillName(params.filename)) return null const role = params.role === 'output' ? 'output' : 'input' 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 sameChain = (item: LibraryStill) => !params.familyId || (item.familyId === params.familyId && (item.chainIndex || 0) === (params.chainIndex || 0)) const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash && stillRole(item) === role && sameChain(item)) if (existing) { if (lastFrameStillName(existing.filename) || isPipelineStill(catalog, existing)) return null const hideInput = typeof params.hideInput === 'boolean' ? params.hideInput === true : existing.hideInput const name = typeof params.name === 'string' ? params.name.trim().slice(0, 80) : existing.name const prompt = typeof params.prompt === 'string' ? params.prompt : existing.prompt if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt) { await mutate(params.ownerKey, (next) => { const item = next.stills.find(still => still.id === existing.id) if (!item) return item.hideInput = hideInput item.role = role if (name) item.name = name if (prompt) item.prompt = prompt if (params.familyId) item.familyId = params.familyId if (params.parentStillId) item.parentStillId = params.parentStillId if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex }) existing.hideInput = hideInput existing.role = role if (name) existing.name = name if (prompt) existing.prompt = prompt } return existing } if (role === 'input' && (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash))) { return null } 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(), hideInput: params.hideInput === true, role, name: params.name?.trim().slice(0, 80) || undefined, prompt: params.prompt || undefined, familyId: params.familyId, parentStillId: params.parentStillId, chainIndex: params.chainIndex } 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) }) void stillThumbPath(params.ownerKey, still.id).catch(() => null) return still } export async function rememberInputStill(params: { ownerKey: string folderId: string filename: string data: Buffer width?: number height?: number hideInput?: boolean }): Promise { if (lastFrameStillName(params.filename)) return null 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 && !isPipelineStill(catalog, item)) if (existing) return existing if (/^start\.(jpe?g|png|webp)$/i.test(String(params.filename || '').trim())) return null return saveStill({ ...params, role: 'input' }) } export async function saveRetryDraft(params: { ownerKey: string id?: string folderId: string stillId?: string stillFilename?: string name?: string prompt: string aspect: string width: number height: number steps: number turbo: boolean seed: number duration: number hideThumbnail: boolean sound: boolean hideInput?: boolean cfg?: number fps?: number samplerName?: string scheduler?: string extensions?: QueuedExtension[] workflow?: import('~/utils/videoModels').VideoWorkflowId globalLocks?: string permanenceRefs?: PermanenceRef[] shotPermanenceRefs?: PermanenceRef[][] loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] shotLoras?: string[] shotLoraStacks?: import('~/utils/loras').LoraStackItem[][] }) { return mutate(params.ownerKey, (catalog) => { const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined const draft: RetryDraft = { id: existing?.id || crypto.randomUUID(), createdAt: existing?.createdAt || Date.now(), folderId: params.folderId, stillId: params.stillId, stillFilename: params.stillFilename, name: params.name, prompt: params.prompt, aspect: params.aspect, width: params.width, height: params.height, steps: params.steps, turbo: params.turbo, seed: params.seed, duration: params.duration, hideThumbnail: params.hideThumbnail, sound: params.sound, hideInput: params.hideInput === true, cfg: params.cfg, fps: params.fps, samplerName: params.samplerName, scheduler: params.scheduler, extensions: params.extensions?.length ? params.extensions : undefined, workflow: params.workflow, globalLocks: params.globalLocks, permanenceRefs: params.permanenceRefs, shotPermanenceRefs: params.shotPermanenceRefs, loraName: params.loraName, loraStack: params.loraStack, shotLoras: params.shotLoras, shotLoraStacks: params.shotLoraStacks } catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20) return draft }) } export function getRetryDraft(owner: string, id: string) { const draft = readCatalog(owner).drafts.find(item => item.id === id) if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' }) return draft } export function deleteRetryDraft(owner: string, id: string) { return mutate(owner, (catalog) => { const draft = catalog.drafts.find(item => item.id === id) if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' }) catalog.drafts = catalog.drafts.filter(item => item.id !== id) return draft }) } 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) removeStillFiles(owner, id) return still }) } export function moveStill(owner: string, id: string, folderId: string) { return mutate(owner, (catalog) => { const still = catalog.stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) const folder = catalog.folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) still.folderId = folder.id return still }) } export function deleteStillFamily(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' }) const family = stillsInSameFamily(catalog, still) const ids = new Set(family.map(item => item.id)) catalog.stills = catalog.stills.filter(item => !ids.has(item.id)) for (const item of family) { removeStillFiles(owner, item.id) } return family }) } export function moveStillFamily(owner: string, id: string, folderId: string) { return mutate(owner, (catalog) => { const still = catalog.stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) const folder = catalog.folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) const family = stillsInSameFamily(catalog, still) const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID() for (const item of family) { item.folderId = folder.id item.familyId = familyId } return family }) } export function renameStill(owner: string, id: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' }) return mutate(owner, (catalog) => { const still = catalog.stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) still.name = trimmed return still }) } export function renameStillFamily(owner: string, id: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' }) return mutate(owner, (catalog) => { const still = catalog.stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) const family = [...stillsInSameFamily(catalog, still)].sort((a, b) => (a.chainIndex || 0) - (b.chainIndex || 0) || a.createdAt - b.createdAt) const base = familyBaseName(trimmed) || trimmed const familyId = family.find(item => item.familyId)?.familyId || still.familyId || crypto.randomUUID() family.forEach((item, index) => { item.familyId = familyId item.name = stillChainName(base, index) }) return family }) } export function sniffImageType(path: string) { const buf = Buffer.alloc(16) const fd = openSync(path, 'r') try { readSync(fd, buf, 0, 16, 0) } finally { closeSync(fd) } return imageContentType(buf) } 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 hideInput?: boolean video: Buffer thumb?: Buffer | null comfyFilename?: string cfg?: number fps?: number samplerName?: string scheduler?: string duration?: number familyId?: string parentClipId?: string chainIndex?: number workflow?: import('~/utils/videoModels').VideoWorkflowId stillId?: string sound?: boolean globalLocks?: string permanenceRefs?: PermanenceRef[] loraName?: string loraStack?: import('~/utils/loras').LoraStackItem[] segmentFirstFrame?: Buffer }) { 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 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, cfg: params.cfg, fps: params.fps, samplerName: params.samplerName, scheduler: params.scheduler, familyId: params.familyId, parentClipId: params.parentClipId, chainIndex: params.chainIndex, workflow: params.workflow, stillId: params.stillId, sound: params.sound, hideInput: params.hideInput === true, globalLocks: params.globalLocks, permanenceRefs: params.permanenceRefs?.length ? normalizePermanenceRefs(params.permanenceRefs) : undefined, loraName: params.loraName || undefined, loraStack: params.loraStack?.length ? params.loraStack : undefined } mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true }) const videoPath = clipVideoPath(params.ownerKey, clip.id) writeFileSync(videoPath, params.video) await remuxFaststart(videoPath) if (params.segmentFirstFrame?.length && params.segmentFirstFrame.length >= 64) { const firstPath = clipFirstFramePath(params.ownerKey, clip.id) writeFileSync(firstPath, params.segmentFirstFrame) writeFrameMeta(firstPath, videoPath, 'segment-start') } if (typeof params.duration === 'number' && params.duration > 0) { clip.duration = params.duration } else { try { clip.duration = await probeDuration(videoPath) } catch { /* duration stays unset */ } } if (params.thumb?.length) { writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb) } else { await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg')) } const isInitial = !params.parentClipId && !(params.chainIndex || 0) if (!isInitial) clip.stillId = undefined 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 } function stampFamilyId(family: LibraryClip[], fallback?: string) { const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID() for (const item of family) item.familyId = familyId return familyId } 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 function deleteClipFamily(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' }) const family = clipsInSameFamily(catalog, clip) const ids = new Set(family.map(item => item.id)) catalog.clips = catalog.clips.filter(item => !ids.has(item.id)) for (const item of family) { rmSync(clipDir(owner, item.id), { recursive: true, force: true }) } return family }) } export function moveClip(owner: string, id: string, folderId: string) { return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) const folder = catalog.folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) clip.folderId = folder.id return clip }) } export function moveClipFamily(owner: string, id: string, folderId: string) { return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) const folder = catalog.folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) const family = clipsInSameFamily(catalog, clip) stampFamilyId(family, clip.familyId) for (const item of family) item.folderId = folder.id return family }) } export function patchClipMeta(owner: string, id: string, patch: { globalLocks?: string permanenceRefs?: PermanenceRef[] family?: boolean }) { return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) const family = patch.family ? clipsInSameFamily(catalog, clip) : [clip] if (typeof patch.globalLocks === 'string') { const locks = patch.globalLocks.trim() for (const item of family) { if (locks) item.globalLocks = locks else delete item.globalLocks } } if (patch.permanenceRefs) { const refs = normalizePermanenceRefs(patch.permanenceRefs) if (patch.family) { for (const item of family) { if (refs.length) item.permanenceRefs = refs else delete item.permanenceRefs } } else if (refs.length) clip.permanenceRefs = refs else delete clip.permanenceRefs } return patch.family ? family : clip }) } export function renameClip(owner: string, id: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' }) return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) clip.name = trimmed return clip }) } export function renameClipFamily(owner: string, id: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' }) return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) const family = [...clipsInSameFamily(catalog, clip)].sort(sortFamilyParts) const base = familyBaseName(trimmed) || trimmed stampFamilyId(family, clip.familyId) family.forEach((item, index) => { const suffix = index === 0 ? '' : ` p${index + 1}` item.name = `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}` }) return family }) } 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 }