Show family branches in plain English with Start as the card head.
Collapsed cards use parts/forks/longest instead of pN codes; expanded Studio and Library trees get fork headers and from/branch labels. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+84
-20
@@ -136,14 +136,17 @@ export function nextClipPartName(name: string, existingNames: string[] = []) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function treeOrderedParts(parts: LibraryClip[]): ClipTreeNode[] {
|
||||
if (!parts.length) return []
|
||||
function buildClipChildren(parts: LibraryClip[]) {
|
||||
const byId = new Map(parts.map(part => [part.id, part]))
|
||||
const children = new Map<string, LibraryClip[]>()
|
||||
const roots: LibraryClip[] = []
|
||||
@@ -159,6 +162,12 @@ export function treeOrderedParts(parts: LibraryClip[]): ClipTreeNode[] {
|
||||
}
|
||||
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 })
|
||||
@@ -168,6 +177,17 @@ export function treeOrderedParts(parts: LibraryClip[]): ClipTreeNode[] {
|
||||
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]
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -177,31 +197,75 @@ export function familyBranchCount(parts: LibraryClip[]) {
|
||||
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[]) {
|
||||
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(' · ')
|
||||
return familyBranchSummary(parts)
|
||||
}
|
||||
|
||||
export function clipParentDisplayLabel(parent: LibraryClip | undefined, parts: LibraryClip[]) {
|
||||
if (!parent) return ''
|
||||
const byId = new Map(parts.map(part => [part.id, part]))
|
||||
const isRoot = !parent.parentClipId || !byId.has(parent.parentClipId)
|
||||
if (isRoot) 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 = clipPartLabel(parent?.name) || parent?.name
|
||||
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()
|
||||
@@ -298,8 +362,8 @@ function groupClips(clips: LibraryClip[]): ClipGroup[] {
|
||||
groups.set(key, list)
|
||||
}
|
||||
return [...groups.entries()].map(([key, parts]) => {
|
||||
const sorted = [...parts].sort(sortClipParts)
|
||||
return { key, head: sorted[0], parts: sorted }
|
||||
const head = familyRootClip(parts)
|
||||
return { key, head, parts: [...parts] }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user