Files
aigen/utils/libraryBrowse.ts

495 lines
17 KiB
TypeScript

import type { StillGenerationSettings } from '~/utils/libraryGeneration'
import type { LoraStackItem } from '~/utils/loras'
export type LibraryFilter = 'all' | 'images' | 'videos' | 'music'
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
trackCount?: 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
manualGroupId?: string
manualGroupName?: string
manualGroupIndex?: number
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 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
engine?: string
samplerName?: string
scheduler?: string
lyricsStrength?: number
hasAudio?: boolean
}
export type ClipGroup = { key: string; head: LibraryClip; parts: LibraryClip[] }
export type StillGroup = { key: string; head: LibraryStill; parts: LibraryStill[] }
export type LibraryEntry = {
kind: 'clip' | 'still' | 'track'
key: string
createdAt: number
name: string
group?: ClipGroup
stillGroup?: StillGroup
track?: LibraryTrack
}
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 }
export type FamilyDisplayNode =
| { type: 'fork'; key: string; depth: number; label: string; parentId: string }
| { type: 'clip'; key: string; depth: number; clip: LibraryClip; fromLabel: string; branchLabel: string }
function sortTreeSiblings(a: LibraryClip, b: LibraryClip) {
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
if (chain) return chain
return a.createdAt - b.createdAt
}
function buildClipChildren(parts: LibraryClip[]) {
const byId = new Map(parts.map(part => [part.id, part]))
const children = new Map<string, LibraryClip[]>()
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)
return { byId, children, roots }
}
export function treeOrderedParts(parts: LibraryClip[]): ClipTreeNode[] {
if (!parts.length) return []
const { children, roots } = buildClipChildren(parts)
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 familyRootClip(parts: LibraryClip[]): LibraryClip {
if (!parts.length) throw new Error('familyRootClip requires at least one clip')
const { roots } = buildClipChildren(parts)
if (roots.length) return roots[0]
return chronologicalParts(parts)[0] || parts[0]
}
/** Newest part in a family — keeps extended collections at the top of date sorts. */
export function familyActivityAt(parts: Array<{ createdAt?: number }>) {
return parts.reduce((max, part) => Math.max(max, Number(part.createdAt) || 0), 0)
}
export function familyLongestDuration(parts: LibraryClip[]) {
return parts.reduce((max, part) => Math.max(max, Number(part.duration) || 0), 0)
}
export function familyBranchCount(parts: LibraryClip[]) {
const counts = new Map<string, number>()
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
}
/** Plain English family line for collapsed cards. No p2→p3 codes. */
export function familyBranchSummary(parts: LibraryClip[]) {
if (parts.length <= 1) return ''
const forks = familyBranchCount(parts)
const longest = familyLongestDuration(parts)
const bits = [`${parts.length} parts`]
if (forks) bits.push(`${forks} fork${forks === 1 ? '' : 's'}`)
if (longest > 0) bits.push(`longest ${formatClipDuration(longest)}`)
return bits.join(' · ')
}
/** @deprecated Use familyBranchSummary — kept so old imports do not break. */
export function familyBranchHint(parts: LibraryClip[]) {
return familyBranchSummary(parts)
}
export function clipIsFamilyRoot(clip: LibraryClip, parts: LibraryClip[]) {
if (!clip.parentClipId) return true
const byId = new Map(parts.map(part => [part.id, part]))
return !byId.has(clip.parentClipId)
}
/** Short row title: Start / p2 / p3b — not the long shared base name. */
export function clipFamilyPartTitle(clip: LibraryClip, parts: LibraryClip[]) {
if (clipIsFamilyRoot(clip, parts)) return 'Start'
return clipPartLabel(clip.name) || clip.name || 'clip'
}
export function clipParentDisplayLabel(parent: LibraryClip | undefined, parts: LibraryClip[]) {
if (!parent) return ''
if (clipIsFamilyRoot(parent, parts)) return 'Start'
return clipPartLabel(parent.name) || parent.name || 'previous'
}
export function clipBranchLetterLabel(item: LibraryClip) {
const match = String(item.name || '').trim().match(/\s+p\d+([a-z])$/i)
if (!match?.[1]) return ''
return `branch ${match[1].toUpperCase()}`
}
export function clipBranchFromLabel(item: LibraryClip, parts: LibraryClip[]) {
if (!item.parentClipId) return ''
const parent = parts.find(part => part.id === item.parentClipId)
const from = clipParentDisplayLabel(parent, parts)
return from ? `from ${from}` : ''
}
export function familyDisplayNodes(parts: LibraryClip[]): FamilyDisplayNode[] {
if (!parts.length) return []
const { byId, children, roots } = buildClipChildren(parts)
const out: FamilyDisplayNode[] = []
const walk = (clip: LibraryClip, depth: number) => {
const parent = clip.parentClipId ? byId.get(clip.parentClipId) : undefined
const from = clip.parentClipId ? clipParentDisplayLabel(parent, parts) : ''
out.push({
type: 'clip',
key: `clip:${clip.id}`,
depth,
clip,
fromLabel: from ? `from ${from}` : '',
branchLabel: clipBranchLetterLabel(clip)
})
const kids = children.get(clip.id) || []
if (kids.length > 1) {
const parentLabel = clipParentDisplayLabel(clip, parts) || clipPartLabel(clip.name) || 'Start'
out.push({
type: 'fork',
key: `fork:${clip.id}`,
depth: depth + 1,
label: `Forks from ${parentLabel} (${kids.length})`,
parentId: clip.id
})
}
for (const child of kids) walk(child, depth + 1)
}
for (const root of roots) walk(root, 0)
return out
}
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.manualGroupId) return `still-group:${still.folderId}:${still.manualGroupId}`
if (still.familyId) return `still-family:${still.folderId}:${still.familyId}`
return `still-solo:${still.id}`
}
export function sortStillParts(a: LibraryStill, b: LibraryStill) {
if (a.manualGroupId && a.manualGroupId === b.manualGroupId) return (a.manualGroupIndex || 0) - (b.manualGroupIndex || 0)
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) => {
if (a.manualGroupId && a.manualGroupId === b.manualGroupId) return (a.manualGroupIndex || 0) - (b.manualGroupIndex || 0)
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) {
if (group.head.manualGroupId) return group.head.manualGroupName || 'Image group'
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.kind === 'track' && entry.track) return entry.track.name
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.kind === 'track' && entry.track) {
return `${entry.track.name || ''} ${entry.track.tags || ''} ${entry.track.lyrics || ''}`
}
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<string, LibraryClip[]>()
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 head = familyRootClip(parts)
return { key, head, parts: [...parts] }
})
}
function groupStills(stills: LibraryStill[]): StillGroup[] {
const groups = new Map<string, LibraryStill[]>()
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[]
tracks?: LibraryTrack[]
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 tracks = (opts.tracks || []).filter(track => inFolder(track.folderId) && track.hasAudio !== false)
const videos: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'music'
? []
: groupClips(clips).map(group => ({
kind: 'clip' as const,
key: group.key,
createdAt: familyActivityAt(group.parts),
name: collectionTitle(group),
group
}))
const images: LibraryEntry[] = opts.filter === 'videos' || opts.filter === 'music'
? []
: groupStills(stills).map(group => ({
kind: 'still' as const,
key: group.key,
createdAt: familyActivityAt(group.parts),
name: stillCollectionTitle(group),
stillGroup: group
}))
const music: LibraryEntry[] = opts.filter === 'images' || opts.filter === 'videos'
? []
: tracks.map(track => ({
kind: 'track' as const,
key: `track:${track.id}`,
createdAt: track.createdAt,
name: track.name,
track
}))
let entries = [...videos, ...images, ...music]
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') {
const order = { still: 0, clip: 1, track: 2 } as const
if (a.kind !== b.kind) return order[a.kind] - order[b.kind]
return b.createdAt - a.createdAt
}
return b.createdAt - a.createdAt
})
return entries
}
export function folderOutputCount(folder: LibraryFolder, clips: LibraryClip[], stills: LibraryStill[], filter: LibraryFilter, tracks: LibraryTrack[] = []) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
const videos = clips.filter(clip => clip.folderId === folder.id).length
const images = stills.filter(still => still.folderId === folder.id && still.role === 'output').length
const music = tracks.filter(track => track.folderId === folder.id).length
if (filter === 'videos') return videos
if (filter === 'images') return images
if (filter === 'music') return music
return videos + images + music
}
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`
}