export type LibraryFilter = 'all' | 'images' | 'videos' export type LibrarySort = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'type' export type LibraryFolder = { id: string name: string createdAt: number thumbnailDefault: boolean protected: boolean unlocked: boolean clipCount?: number imageCount?: number } export type LibraryStill = { id: string folderId: string filename: string width: number height: number createdAt: number hideInput?: boolean role?: 'input' | 'output' name?: string prompt?: string familyId?: string parentStillId?: string chainIndex?: number settings?: { kind?: string; mode?: string; engine?: string } } export type LibraryClip = { id: string folderId: string name?: string prompt: string aspect: string width: number height: number steps: number turbo: boolean seed: number hideThumbnail: boolean hasThumbnail?: boolean hasVideo?: boolean createdAt: number duration?: number familyId?: string parentClipId?: string chainIndex?: number workflow?: string stillId?: string } export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] } export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] } export type LibraryEntry = { kind: 'clip' | 'still' key: string createdAt: number name: string group?: ClipGroup stillGroup?: StillGroup } export function familyBaseName(name: string) { return String(name || '').trim().replace(/\s+p\d+$/i, '') } export function clipFamilyKey(clip: LibraryClip) { if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}` const base = familyBaseName(clip.name || '').toLowerCase() if (base) return `name:${clip.folderId}:${base}` return `solo:${clip.id}` } export function stillFamilyKey(still: LibraryStill) { if (still.familyId) return `still-family:${still.folderId}:${still.familyId}` return `still-solo:${still.id}` } export function sortStillParts(a: LibraryStill, b: LibraryStill) { const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0) if (chainDelta) return chainDelta return b.createdAt - a.createdAt } export function chronologicalStills(parts: LibraryStill[]) { return [...parts].sort((a, b) => { const chain = (a.chainIndex || 0) - (b.chainIndex || 0) if (chain) return chain return a.createdAt - b.createdAt }) } export function stillsInFamily(stills: LibraryStill[], current?: LibraryStill | null) { if (!current) return [] as LibraryStill[] if (!current.familyId) return [current] return chronologicalStills(stills.filter(item => item.familyId === current.familyId && item.role !== 'input')) } export function wrappedIndex(length: number, current: number, delta: number) { if (length <= 0) return 0 return ((current + delta) % length + length) % length } export function sortClipParts(a: LibraryClip, b: LibraryClip) { const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0) if (durationDelta) return durationDelta const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0) if (chainDelta) return chainDelta return a.createdAt - b.createdAt } export function chronologicalParts(parts: LibraryClip[]) { return [...parts].sort((a, b) => { const chain = (a.chainIndex || 0) - (b.chainIndex || 0) if (chain) return chain return a.createdAt - b.createdAt }) } export function stillTitle(still: LibraryStill) { return still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'Edited still' } export function collectionTitle(group: ClipGroup) { const named = group.parts.map(part => familyBaseName(part.name || '')).find(Boolean) return named || group.head.name || group.head.prompt || 'Untitled clip' } export function stillCollectionTitle(group: StillGroup) { const named = group.parts.map(part => familyBaseName(part.name || '')).find(Boolean) return named || stillTitle(group.head) } export function entryTitle(entry: LibraryEntry) { if (entry.kind === 'clip' && entry.group) return collectionTitle(entry.group) if (entry.stillGroup) return stillCollectionTitle(entry.stillGroup) return entry.name } export function entrySearchText(entry: LibraryEntry) { if (entry.kind === 'clip' && entry.group) { return entry.group.parts.map(part => `${part.name || ''} ${part.prompt || ''}`).join(' ') } if (entry.stillGroup) { return entry.stillGroup.parts.map(part => `${part.name || ''} ${part.prompt || ''} ${part.filename || ''}`).join(' ') } return entry.name } function groupClips(clips: LibraryClip[]): ClipGroup[] { const groups = new Map() for (const clip of clips) { const key = clipFamilyKey(clip) const list = groups.get(key) || [] list.push(clip) groups.set(key, list) } return [...groups.entries()].map(([key, parts]) => { const sorted = [...parts].sort(sortClipParts) return { key, head: sorted[0], parts: sorted } }) } function groupStills(stills: LibraryStill[]): StillGroup[] { const groups = new Map() for (const still of stills) { const key = stillFamilyKey(still) const list = groups.get(key) || [] list.push(still) groups.set(key, list) } return [...groups.entries()].map(([key, parts]) => { const sorted = [...parts].sort(sortStillParts) return { key, head: sorted[0], parts: sorted } }) } function compareName(a: string, b: string) { return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }) } export function buildLibraryEntries(opts: { clips: LibraryClip[] stills: LibraryStill[] folderId: string filter: LibraryFilter search: string sort: LibrarySort }): LibraryEntry[] { const inFolder = (folderId: string) => !opts.folderId || opts.folderId === 'all' || folderId === opts.folderId const clips = opts.clips.filter(clip => inFolder(clip.folderId) && clip.hasVideo !== false) const stills = opts.stills.filter(still => inFolder(still.folderId) && still.role === 'output') const videos: LibraryEntry[] = opts.filter === 'images' ? [] : groupClips(clips).map(group => ({ kind: 'clip' as const, key: group.key, createdAt: group.head.createdAt, name: collectionTitle(group), group })) const images: LibraryEntry[] = opts.filter === 'videos' ? [] : groupStills(stills).map(group => ({ kind: 'still' as const, key: group.key, createdAt: group.head.createdAt, name: stillCollectionTitle(group), stillGroup: group })) let entries = [...videos, ...images] const needle = opts.search.trim().toLowerCase() if (needle) { entries = entries.filter(entry => entrySearchText(entry).toLowerCase().includes(needle)) } const sort = opts.sort entries.sort((a, b) => { if (sort === 'name-asc') return compareName(a.name, b.name) || b.createdAt - a.createdAt if (sort === 'name-desc') return compareName(b.name, a.name) || b.createdAt - a.createdAt if (sort === 'date-asc') return a.createdAt - b.createdAt if (sort === 'type') { if (a.kind !== b.kind) return a.kind === 'still' ? -1 : 1 return b.createdAt - a.createdAt } return b.createdAt - a.createdAt }) return entries } export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter) { if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) const videos = clips.filter(clip => clip.folderId === folder.id).length const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length if (filter === 'videos') return videos if (filter === 'images') return images return videos + images } export function formatWhen(createdAt: number) { if (!createdAt) return '' try { return new Date(createdAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) } catch { return '' } } export function formatClipDuration(value?: number) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return '' const rounded = Math.round(value * 10) / 10 return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s` }