import { preserveVideoSources } from './videoSources' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, statSync, createReadStream, openSync, readSync, closeSync } from 'node:fs' import { writeFile, copyFile } from 'node:fs/promises' 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' import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration' import { buildOutputBaseName, nextUniqueName, normalizeLoraAliases, sanitizeLoraAlias } from '~/utils/outputNames' import { loraIdentityKey, normalizeLoraTriggers, sanitizeLoraTrigger, type LoraStackItem } from '~/utils/loras' import { emptyKeepPrompts, normalizeKeepPromptList, normalizeKeepPrompts, type KeepPromptKind, type KeepPromptLists } from '~/utils/keepPrompts' import { familyBaseName, nextClipPartName, clipPartLabel } from '~/utils/libraryBrowse' import { musicExtension } from '~/utils/music' export { familyBaseName, nextClipPartName, clipPartLabel } 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 upscaledFromClipId?: string upscaleJobId?: 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[] promptPre?: string promptPost?: string } export interface LibraryTrack { id: string folderId: string name: string tags: string lyrics: string duration: number steps: number seed: number cfg: number instrumental: boolean createdAt: number filename: string ext: string yueProfile?: 1 | 3 engine?: string samplerName?: string scheduler?: string lyricsStrength?: number comfyFilename?: string } 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 promptPre?: string promptPost?: string familyId?: string manualGroupId?: string manualGroupName?: string manualGroupIndex?: number parentStillId?: string chainIndex?: number settings?: StillGenerationSettings } export interface PublicFolder { id: string name: string createdAt: number thumbnailDefault: boolean protected: boolean unlocked: boolean clipCount: number imageCount: number trackCount: number } export interface QueuedExtension { prompt: string promptPre?: string promptPost?: 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[] tracks: LibraryTrack[] drafts: RetryDraft[] loraAliases: Record loraTriggers: Record keepPrompts: KeepPromptLists } 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: [], tracks: [], drafts: [], loraAliases: {}, loraTriggers: {}, keepPrompts: emptyKeepPrompts() } } 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, promptPre: still.promptPre, promptPost: still.promptPost, familyId: still.familyId, manualGroupId: still.manualGroupId, manualGroupName: still.manualGroupName, manualGroupIndex: still.manualGroupIndex, parentStillId: still.parentStillId, chainIndex: still.chainIndex, settings: normalizeStillSettings(still.settings) } } function normalizeTrack(track: LibraryTrack): LibraryTrack { const ext = String(track.ext || track.filename || 'flac').replace(/^\./, '').toLowerCase() || 'flac' return { id: track.id, folderId: track.folderId, name: String(track.name || '').trim() || 'Untitled track', tags: String(track.tags || ''), lyrics: String(track.lyrics || ''), duration: Number(track.duration) || 0, steps: Number(track.steps) || 0, seed: Number(track.seed) || 0, cfg: Number(track.cfg) || 0, instrumental: track.instrumental === true, createdAt: track.createdAt || Date.now(), filename: String(track.filename || `audio.${ext}`), ext, engine: track.engine, samplerName: track.samplerName, scheduler: track.scheduler, lyricsStrength: track.lyricsStrength, comfyFilename: track.comfyFilename } } 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) : [], tracks: Array.isArray(parsed.tracks) ? parsed.tracks.map(normalizeTrack) : [], drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [], loraAliases: normalizeLoraAliases(parsed.loraAliases), loraTriggers: normalizeLoraTriggers(parsed.loraTriggers), keepPrompts: normalizeKeepPrompts(parsed.keepPrompts) } } 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 }) mkdirSync(join(ownerRoot(owner), 'tracks'), { 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 } 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 nextFamilyPartName(owner: string, source: LibraryClip, extraNames: string[] = []) { const catalog = readCatalog(owner) const family = clipsInSameFamily(catalog, source) return nextClipPartName(clipTitle(source), [...family.map(item => clipTitle(item)), ...extraNames]) } 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') if (still.manualGroupId) return folderStills.filter(item => item.manualGroupId === still.manualGroupId) const ids = new Set([still.id]) if (still.familyId) { for (const item of folderStills) { if (!item.manualGroupId && 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) || 'output' } function existingOutputNames(catalog: Catalog, folderId: string) { const stills = catalog.stills .filter(item => item.folderId === folderId) .map(item => String(item.name || '').trim()) .filter(Boolean) const clips = catalog.clips .filter(item => item.folderId === folderId) .map(item => String(item.name || '').trim()) .filter(Boolean) const tracks = catalog.tracks .filter(item => item.folderId === folderId) .map(item => String(item.name || '').trim()) .filter(Boolean) return [...stills, ...clips, ...tracks] } export function getLoraAliases(owner: string) { return { ...(readCatalog(owner).loraAliases || {}) } } export function getLoraTriggers(owner: string) { return { ...(readCatalog(owner).loraTriggers || {}) } } export async function setKeepPrompts(owner: string, kind: KeepPromptKind, items: unknown) { const list = normalizeKeepPromptList(items) return mutate(owner, (catalog) => { if (!catalog.keepPrompts) catalog.keepPrompts = emptyKeepPrompts() catalog.keepPrompts[kind] = list return { ...catalog.keepPrompts } }) } export async function setLoraTrigger(owner: string, identity: string, trigger: string) { const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase() if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' }) const cleaned = sanitizeLoraTrigger(trigger) return mutate(owner, (catalog) => { if (!catalog.loraTriggers) catalog.loraTriggers = {} if (!cleaned) delete catalog.loraTriggers[key] else catalog.loraTriggers[key] = cleaned return { ...catalog.loraTriggers } }) } export async function setLoraAlias(owner: string, identity: string, alias: string) { const key = loraIdentityKey(identity) || String(identity || '').trim().toLowerCase() if (!key) throw createError({ statusCode: 400, statusMessage: 'A LoRA file is required' }) const cleaned = sanitizeLoraAlias(alias) return mutate(owner, (catalog) => { if (!catalog.loraAliases) catalog.loraAliases = {} if (!cleaned) delete catalog.loraAliases[key] else catalog.loraAliases[key] = cleaned return { ...catalog.loraAliases } }) } export function allocateOutputName(owner: string, folderId: string, requested: string, opts: { inputFilename?: string engine: string loraStack?: LoraStackItem[] | unknown }) { const catalog = readCatalog(owner) const base = buildOutputBaseName({ requested, inputFilename: opts.inputFilename, engine: opts.engine, loraStack: opts.loraStack, aliases: catalog.loraAliases || {} }) return nextUniqueName(existingOutputNames(catalog, folderId), base) } 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, trackCount = 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, trackCount } } export function publicLibrary(event: H3Event, opts?: { light?: boolean }) { const light = opts?.light === true const owner = libraryOwnerKey(event) const catalog = readCatalog(owner) const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase())) const clipCounts = new Map() const imageCounts = new Map() const trackCounts = new Map() for (const clip of catalog.clips) { clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1) } for (const still of catalog.stills) { if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1) } for (const track of catalog.tracks || []) { trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1) } const folders = catalog.folders.map(folder => publicFolder( event, folder, clipCounts.get(folder.id) || 0, imageCounts.get(folder.id) || 0, trackCounts.get(folder.id) || 0 )) 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, hasVideo: light ? true : existsSync(clipVideoPath(owner, clip.id)) })), stills: catalog.stills .filter(still => unlockedIds.has(still.folderId) && !isPipelineStill(catalog, still, clipIds)) .map(still => ({ ...still, role: stillRole(still) })), tracks: (catalog.tracks || []) .filter(track => unlockedIds.has(track.folderId)) .map(track => ({ ...track, hasAudio: light ? true : existsSync(trackAudioPath(owner, track.id, track.ext)) })), drafts: catalog.drafts, loraAliases: catalog.loraAliases || {}, loraTriggers: catalog.loraTriggers || {}, keepPrompts: catalog.keepPrompts || emptyKeepPrompts() } } 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) const tracks = (catalog.tracks || []).filter(track => track.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) catalog.tracks = (catalog.tracks || []).filter(track => track.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) } for (const track of tracks) { rmSync(trackDir(owner, track.id), { recursive: true, 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 trackDir(owner: string, id: string) { const current = join(ownerRoot(owner), 'tracks', id) if (existsSync(current)) return current const legacy = join(libraryRoot(), 'tracks', id) if (existsSync(legacy)) return legacy return current } export function trackAudioPath(owner: string, id: string, ext = 'flac') { const dir = trackDir(owner, id) const wanted = join(dir, `audio.${String(ext || 'flac').replace(/^\./, '')}`) if (existsSync(wanted)) return wanted for (const fallback of ['flac', 'wav', 'mp3', 'ogg', 'opus']) { const path = join(dir, `audio.${fallback}`) if (existsSync(path)) return path } return wanted } export function clipLastFramePath(owner: string, id: string) { return join(clipDir(owner, id), 'last-frame.png') } /** Lossless PNG handoff frame for cleaner sequential extensions (not the preview cache). */ export function clipAnchorFramePath(owner: string, id: string) { return join(clipDir(owner, id), 'anchor-last.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) && statSync(path).size >= 64) return path } return join(dir, 'thumb.jpg') } async function extractVideoThumb(videoPath: string, destPath: string) { try { await extractFirstFrame(videoPath, destPath) return existsSync(destPath) && statSync(destPath).size >= 64 } catch { return false } } export async function ensureClipThumb(owner: string, id: string) { const existing = clipThumbPath(owner, id) if (existsSync(existing) && statSync(existing).size >= 64) return existing const video = clipVideoPath(owner, id) if (!existsSync(video)) return '' const dest = join(clipDir(owner, id), 'thumb.jpg') const pending = thumbBuilds.get(dest) if (pending) return pending const build = withThumbSlot(async () => { const ok = await extractVideoThumb(video, dest) return ok ? dest : '' }).finally(() => { thumbBuilds.delete(dest) }) thumbBuilds.set(dest, build) return build } 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) || statSync(thumb).size < 64) { 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 importMissingComfyAudio(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, clipIds?: Set) { if (lastFrameStillName(still.filename)) return true const clipId = clipIdFromStillFilename(still.filename) if (!clipId) return false if (clipIds) return clipIds.has(clipId) 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 promptPre?: string promptPost?: string familyId?: string parentStillId?: string chainIndex?: number settings?: StillGenerationSettings }): 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 const nextSettings = params.settings ? normalizeStillSettings(params.settings) : undefined if (existing.hideInput !== hideInput || existing.name !== name || existing.prompt !== prompt || nextSettings) { 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.promptPre != null) item.promptPre = params.promptPre || undefined if (params.promptPost != null) item.promptPost = params.promptPost || undefined if (params.familyId) item.familyId = params.familyId if (params.parentStillId) item.parentStillId = params.parentStillId if (typeof params.chainIndex === 'number') item.chainIndex = params.chainIndex if (nextSettings) item.settings = nextSettings }) existing.hideInput = hideInput existing.role = role if (name) existing.name = name if (prompt) existing.prompt = prompt if (params.promptPre != null) existing.promptPre = params.promptPre || undefined if (params.promptPost != null) existing.promptPost = params.promptPost || undefined if (nextSettings) existing.settings = nextSettings } 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, promptPre: params.promptPre || undefined, promptPost: params.promptPost || undefined, familyId: params.familyId, parentStillId: params.parentStillId, chainIndex: params.chainIndex, settings: normalizeStillSettings(params.settings) } 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 keepStillAsInput(owner: string, id: string) { const still = getStill(owner, id) if (stillRole(still) === 'input') return still const path = stillPath(owner, still.id) if (!existsSync(path)) throw createError({ statusCode: 404, statusMessage: 'Image file missing' }) const data = readFileSync(path) const name = (still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'still').trim().slice(0, 80) const base = lastFrameStillName(name) || clipIdFromStillFilename(`${name}.png`) ? 'still' : name const saved = await saveStill({ ownerKey: owner, folderId: still.folderId, filename: `${base}.png`, data, width: still.width, height: still.height, hideInput: still.hideInput, role: 'input', name, prompt: still.prompt, promptPre: still.promptPre, promptPost: still.promptPost }) if (!saved) throw createError({ statusCode: 400, statusMessage: 'Could not save that still as input' }) return saved } 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 if (!still.manualGroupId) item.familyId = familyId } return family }) } export function setStillHidden(owner: string, id: string, hide: boolean, family = false) { return mutate(owner, (catalog) => { const still = catalog.stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) const targets = family ? stillsInSameFamily(catalog, still) : [still] if (!targets.some(item => item.id === still.id)) targets.push(still) for (const item of targets) item.hideInput = hide === true return still }) } 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() if (still.manualGroupId) { for (const item of family) item.manualGroupName = trimmed return family } 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 videoFile?: string thumb?: Buffer | null comfyFilename?: string cfg?: number fps?: number samplerName?: string scheduler?: string duration?: number familyId?: string upscaledFromClipId?: string upscaleJobId?: 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[] promptPre?: string promptPost?: string segmentFirstFrame?: Buffer sourceSegments?: string[] originalSegment?: 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, upscaledFromClipId: params.upscaledFromClipId, upscaleJobId: params.upscaleJobId, 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, promptPre: params.promptPre || undefined, promptPost: params.promptPost || undefined } mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true }) const videoPath = clipVideoPath(params.ownerKey, clip.id) if (params.videoFile) await copyFile(params.videoFile, videoPath) else if (params.video) await writeFile(videoPath, params.video) else throw new Error('Video data is missing') await remuxFaststart(videoPath) if (params.originalSegment) preserveVideoSources(videoPath, params.sourceSegments || [], params.originalSegment) 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 } export function getTrack(owner: string, id: string) { const track = readCatalog(owner).tracks.find(item => item.id === id) if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) return track } export function findYueGpTrack(owner: string, jobId: string) { return readCatalog(owner).tracks.find(track => track.comfyFilename === `yuegp-${jobId}.wav`) } export async function saveTrack(params: { ownerKey: string folderId: string name?: string tags: string lyrics: string duration: number steps: number seed: number cfg: number instrumental: boolean audio: Buffer ext?: string yueProfile?: 1 | 3 engine?: string samplerName?: string scheduler?: string lyricsStrength?: number 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' }) const ext = String(params.ext || 'flac').replace(/^\./, '').toLowerCase() || 'flac' const track: LibraryTrack = { id: crypto.randomUUID(), folderId: folder.id, name: clipTitle({ name: params.name || '', prompt: params.tags }), tags: params.tags, lyrics: params.lyrics, duration: params.duration, steps: params.steps, seed: params.seed, cfg: params.cfg, instrumental: params.instrumental === true, createdAt: Date.now(), filename: `audio.${ext}`, ext, engine: params.engine, yueProfile: params.yueProfile, samplerName: params.samplerName, scheduler: params.scheduler, lyricsStrength: params.lyricsStrength, comfyFilename: params.comfyFilename } mkdirSync(trackDir(params.ownerKey, track.id), { recursive: true }) await writeFile(trackAudioPath(params.ownerKey, track.id, ext), params.audio) if (!(typeof params.duration === 'number' && params.duration > 0)) { try { track.duration = await probeDuration(trackAudioPath(params.ownerKey, track.id, ext)) } catch { /* duration stays as posted */ } } await mutate(params.ownerKey, (next) => { if (!Array.isArray(next.tracks)) next.tracks = [] next.tracks.unshift(track) }) return track } export function deleteTrack(owner: string, id: string) { return mutate(owner, (catalog) => { const track = catalog.tracks.find(item => item.id === id) if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) catalog.tracks = catalog.tracks.filter(item => item.id !== id) rmSync(trackDir(owner, id), { recursive: true, force: true }) return track }) } export function moveTrack(owner: string, id: string, folderId: string) { return mutate(owner, (catalog) => { const track = catalog.tracks.find(item => item.id === id) if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) const folder = catalog.folders.find(item => item.id === folderId) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) track.folderId = folderId return track }) } export function renameTrack(owner: string, id: string, name: string) { return mutate(owner, (catalog) => { const track = catalog.tracks.find(item => item.id === id) if (!track) throw createError({ statusCode: 404, statusMessage: 'Track not found' }) const cleaned = String(name || '').trim().slice(0, 80) if (!cleaned) throw createError({ statusCode: 400, statusMessage: 'A name is required' }) track.name = cleaned return track }) } 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 setClipHidden(owner: string, id: string, hide: boolean, family = false) { return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) const targets = family ? clipsInSameFamily(catalog, clip) : [clip] for (const item of targets) item.hideThumbnail = hide === true return clip }) } 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 }) } function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } function downloadErrorText(error: unknown) { if (!error) return 'Unknown download error' if (typeof error === 'object') { const rec = error as { statusMessage?: string; message?: string; data?: { cause?: string } } const bits = [rec.statusMessage, rec.message, rec.data?.cause].filter(Boolean) if (bits.length) return bits[0] as string } if (error instanceof Error) return error.message return String(error) } async function downloadViaHostAgent(file: { filename: string; subfolder: string; type: string }) { const config = useRuntimeConfig() const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '') const token = String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '') if (!controlUrl) return null const subfolders = [...new Set([file.subfolder, file.type === 'input' ? '' : 'video', file.type === 'input' ? '' : 'audio', ''])] for (const subfolder of subfolders) { const params = new URLSearchParams({ filename: file.filename, subfolder, type: file.type || 'output' }) try { const res = await fetch(`${controlUrl}/view?${params.toString()}`, { headers: { Accept: '*/*', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, signal: AbortSignal.timeout(5 * 60 * 1000) }) if (res.ok) return Buffer.from(await res.arrayBuffer()) } catch { /* try next subfolder / fall through */ } } return null } async function downloadComfyMedia( file: { filename: string; subfolder: string; type: string }, kind: 'video' | 'audio' | 'image' ) { const extras = kind === 'video' ? ['video', ''] : kind === 'audio' ? ['audio', ''] : [''] const subfolders = [...new Set([file.subfolder, ...extras])] let lastStatus = 0 let lastError = '' const attempts = 8 for (let attempt = 1; attempt <= attempts; attempt++) { for (const subfolder of subfolders) { const params = new URLSearchParams({ filename: file.filename, subfolder, type: file.type || 'output' }) try { const res = await comfyFetch(`/view?${params.toString()}`, { signal: AbortSignal.timeout(5 * 60 * 1000) }) lastStatus = res.status if (res.ok) return Buffer.from(await res.arrayBuffer()) } catch (error) { lastError = downloadErrorText(error) } } const disk = await downloadViaHostAgent(file) if (disk?.length) return disk if (attempt < attempts) await sleep(1500 * attempt) } const detail = lastError || `HTTP ${lastStatus || 'n/a'}` throw new Error(`Failed to fetch completed ${kind} from ComfyUI (${detail}). File may still be on the desktop — use Recover.`) } export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) { return downloadComfyMedia(video, 'video') } export async function downloadComfyAudio(audio: { filename: string; subfolder: string; type: string }) { return downloadComfyMedia(audio, 'audio') } export async function downloadComfyImage(image: { filename: string; subfolder: string; type: string }) { return downloadComfyMedia(image, 'image') } 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 } export async function importMissingComfyAudio(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.tracks || []).map(track => track.comfyFilename).filter(Boolean) as string[]) const history = await fetchHistoryAll() const found: { promptId: string audio: { filename: string; subfolder: string; type: string } meta: ReturnType }[] = [] for (const [promptId, entry] of Object.entries(history)) { const audio = extractAudio({ [promptId]: entry as Record }, promptId) if (!audio || known.has(audio.filename) || !isOurComfyAudio(audio)) continue found.push({ promptId, audio, meta: extractAceMetaFromHistory(entry) }) } found.sort((a, b) => a.audio.filename.localeCompare(b.audio.filename, undefined, { numeric: true })) const imported = [] for (const item of found) { const buffer = await downloadComfyAudio(item.audio) const ext = musicExtension(item.audio.filename) const track = await saveTrack({ ownerKey: owner, folderId: folder.id, name: clipTitle({ name: '', prompt: item.meta.tags || 'Recovered track' }), tags: item.meta.tags || 'Recovered from ComfyUI', lyrics: item.meta.lyrics || '', duration: item.meta.duration, steps: item.meta.steps, seed: item.meta.seed, cfg: item.meta.cfg, instrumental: !String(item.meta.lyrics || '').trim(), audio: buffer, ext, engine: item.meta.engine, samplerName: item.meta.samplerName, scheduler: item.meta.scheduler, lyricsStrength: item.meta.lyricsStrength, comfyFilename: item.audio.filename }) imported.push(track) await purgeComfyArtifacts({ audio: item.audio, promptId: item.promptId }) } return imported } // Grouping only changes library organization; generation ancestry remains intact. export function groupLibraryStills(owner: string, ids: string[], ungroup = false) { return mutate(owner, (catalog) => { const selected = ids.map(id => catalog.stills.find(item => item.id === id)) if (selected.some(item => !item || stillRole(item) !== 'output')) throw createError({ statusCode: 400, statusMessage: 'Choose generated images' }) const images = selected as LibraryStill[] if (new Set(images.map(item => item.folderId)).size !== 1) throw createError({ statusCode: 400, statusMessage: 'Move the images into the same folder first' }) const members = [...new Map(images.flatMap(item => stillsInSameFamily(catalog, item)).map(item => [item.id, item])).values()] if (!ungroup && members.length < 2) throw createError({ statusCode: 400, statusMessage: 'Select at least two images' }) const groupId = crypto.randomUUID() const name = images.find(item => item.manualGroupName)?.manualGroupName || 'Image group' members.forEach((item, index) => { if (ungroup) { delete item.manualGroupId delete item.manualGroupName delete item.manualGroupIndex } else { item.manualGroupId = groupId item.manualGroupName = name item.manualGroupIndex = index } }) }) } export function findUpscaledClip(owner: string, jobId: string) { return readCatalog(owner).clips.find(clip => clip.upscaleJobId === jobId) }