A finished job no longer closes a draft or jumps the page, and a second extend from the same part saves as p8b instead of another p8. Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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?: { kind?: string; mode?: string; engine?: string }
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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<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)
|
|
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<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
|
|
}
|
|
|
|
export function familyBranchHint(parts: LibraryClip[]) {
|
|
const groups = new Map<string, LibraryClip[]>()
|
|
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<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 sorted = [...parts].sort(sortClipParts)
|
|
return { key, head: sorted[0], parts: sorted }
|
|
})
|
|
}
|
|
|
|
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[]
|
|
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`
|
|
}
|