Files
aigen/server/utils/library.ts
T
TowstyandCursor be5211f488 Show generator settings in the library and restore them as input.
Collapse tiles so prompts stay behind the info button. Use as input opens the matching Video or Image v2 mode with the saved sliders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 23:38:23 -05:00

1670 lines
57 KiB
TypeScript

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'
import { normalizeStillSettings, type StillGenerationSettings } from '~/utils/libraryGeneration'
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
settings?: StillGenerationSettings
}
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<string, Promise<unknown>>()
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<LibraryStill, 'role' | 'filename'>): 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,
settings: normalizeStillSettings(still.settings)
}
}
function normalizeCatalog(parsed: Partial<Catalog> & { 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<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
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<LibraryClip, 'name' | 'prompt'>) {
const named = (clip.name || '').trim()
if (named) return named
const fromPrompt = (clip.prompt || '').split(/[.!?\n]/)[0].trim().slice(0, 60)
return fromPrompt || 'Untitled clip'
}
export function 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<string>([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<string>([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<boolean>((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<string, unknown>))
: {}
const byFile = new Map<string, { prompt: string; width: number; height: number; steps: number; seed: number }>()
for (const [promptId, entry] of Object.entries(history)) {
const video = extractVideo({ [promptId]: entry as Record<string, unknown> }, promptId)
if (!video) continue
const meta = extractClipMetaFromHistory(entry)
byFile.set(video.filename, {
prompt: extractPromptFromHistory(entry),
width: meta.width,
height: meta.height,
steps: meta.steps,
seed: meta.seed
})
}
const sizes = new Map<string, { width: number; height: number }>()
for (const clip of catalog.clips) {
if (clip.hideThumbnail) continue
const thumb = clipThumbPath(owner, clip.id)
const video = clipVideoPath(owner, clip.id)
if (!existsSync(video)) continue
if (!existsSync(thumb)) {
await extractVideoThumb(video, join(clipDir(owner, clip.id), 'thumb.jpg'))
}
if (!clip.width || !clip.height) {
const probed = await probeVideoSize(video)
if (probed) sizes.set(clip.id, probed)
}
}
let dirty = false
for (const clip of catalog.clips) {
const found = clip.comfyFilename ? byFile.get(clip.comfyFilename) : undefined
const probed = sizes.get(clip.id)
if (found) {
if (!clip.width && found.width) {
clip.width = found.width
clip.height = found.height
dirty = true
}
if (!clip.steps && found.steps) {
clip.steps = found.steps
dirty = true
}
if (!clip.seed && found.seed) {
clip.seed = found.seed
dirty = true
}
if ((!clip.prompt || clip.prompt === 'Recovered from ComfyUI') && found.prompt) {
clip.prompt = found.prompt
dirty = true
}
}
if ((!clip.width || !clip.height) && probed) {
clip.width = probed.width
clip.height = probed.height
dirty = true
}
const titled = clipTitle(clip)
if (clip.name !== titled) {
clip.name = titled
dirty = true
}
}
if (dirty) {
await mutate(owner, (next) => {
const byId = new Map(catalog.clips.map(clip => [clip.id, clip]))
for (const clip of next.clips) {
const updated = byId.get(clip.id)
if (!updated) continue
clip.name = updated.name
clip.prompt = updated.prompt
clip.width = updated.width
clip.height = updated.height
clip.steps = updated.steps
clip.seed = updated.seed
}
})
}
}
export function stillPath(owner: string, id: string) {
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<string, Promise<string>>()
function withThumbSlot<T>(fn: () => Promise<T>) {
return new Promise<T>((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<boolean>((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<string, number>()
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<string>()
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<string>()
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<string>()
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
settings?: StillGenerationSettings
}): Promise<LibraryStill | null> {
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.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 (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,
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 rememberInputStill(params: {
ownerKey: string
folderId: string
filename: string
data: Buffer
width?: number
height?: number
hideInput?: boolean
}): Promise<LibraryStill | null> {
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<string, unknown> }, 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
}