import type { StillGenerationSettings } from '~/utils/libraryGeneration' import type { LoraStackItem } from '~/utils/loras' 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 promptPre?: string promptPost?: string familyId?: string parentStillId?: string chainIndex?: number settings?: StillGenerationSettings } export type LibraryClip = { id: string folderId: string name?: string prompt: string promptPre?: string promptPost?: 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 cfg?: number fps?: number samplerName?: string scheduler?: string sound?: boolean loraName?: string loraStack?: LoraStackItem[] } 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+[a-z]?$/i, '') } export function clipPartLabel(name?: string) { const match = String(name || '').trim().match(/\s+p(\d+)([a-z])?$/i) if (!match) return '' return `p${match[1]}${(match[2] || '').toLowerCase()}` } export function nextClipPartName(name: string, existingNames: string[] = []) { const trimmed = String(name || '').trim() if (!trimmed) return '' const match = trimmed.match(/^(.*)\s+p(\d+)([a-z])?$/i) const base = (match ? match[1] : trimmed).trim() || trimmed const part = match ? Number(match[2]) + 1 : 2 const taken = new Set(existingNames.map(item => String(item || '').trim().toLowerCase()).filter(Boolean)) const first = `${base} p${part}` if (!taken.has(first.toLowerCase())) { return first.slice(0, 80) } for (const letter of 'bcdefghijklmnopqrstuvwxyz') { const candidate = `${base} p${part}${letter}` if (!taken.has(candidate.toLowerCase())) return candidate.slice(0, 80) } let extra = 2 while (extra < 100) { const candidate = `${base} p${part}-${extra}` if (!taken.has(candidate.toLowerCase())) return candidate.slice(0, 80) extra += 1 } return first.slice(0, 80) } export type ClipTreeNode = { clip: LibraryClip; depth: number } function sortTreeSiblings(a: LibraryClip, b: LibraryClip) { const chain = (a.chainIndex || 0) - (b.chainIndex || 0) if (chain) return chain return a.createdAt - b.createdAt } export function treeOrderedParts(parts: LibraryClip[]): ClipTreeNode[] { if (!parts.length) return [] const byId = new Map(parts.map(part => [part.id, part])) const children = new Map() const roots: LibraryClip[] = [] for (const part of parts) { const parentId = part.parentClipId if (parentId && byId.has(parentId)) { const list = children.get(parentId) || [] list.push(part) children.set(parentId, list) } else { roots.push(part) } } roots.sort(sortTreeSiblings) for (const list of children.values()) list.sort(sortTreeSiblings) const out: ClipTreeNode[] = [] const walk = (clip: LibraryClip, depth: number) => { out.push({ clip, depth }) for (const child of children.get(clip.id) || []) walk(child, depth + 1) } for (const root of roots) walk(root, 0) return out } export function familyBranchCount(parts: LibraryClip[]) { const counts = new Map() for (const part of parts) { if (!part.parentClipId) continue counts.set(part.parentClipId, (counts.get(part.parentClipId) || 0) + 1) } return [...counts.values()].filter(count => count > 1).length } export function familyBranchHint(parts: LibraryClip[]) { const groups = new Map() for (const part of parts) { if (!part.parentClipId) continue const list = groups.get(part.parentClipId) || [] list.push(part) groups.set(part.parentClipId, list) } const branched = [...groups.entries()].filter(([, kids]) => kids.length > 1) if (!branched.length) return '' return branched.map(([parentId, kids]) => { const parent = parts.find(part => part.id === parentId) const from = clipPartLabel(parent?.name) || 'a part' const names = kids.map(kid => clipPartLabel(kid.name) || kid.name || 'part').join(', ') return `${from} → ${names}` }).join(' · ') } export function clipBranchFromLabel(item: LibraryClip, parts: LibraryClip[]) { if (!item.parentClipId) return '' const parent = parts.find(part => part.id === item.parentClipId) const from = clipPartLabel(parent?.name) || parent?.name return from ? `from ${from}` : '' } 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` }