Make family branches readable and fix Library part previews.

Lead rows with Start/pN labels and fork headers, collapse hidden frame strips to one glow, and stop Library part thumbs from leaking or cloning the Start frame.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-03 20:32:40 -05:00
co-authored by Cursor
parent 8f80cbd39b
commit 47f14d7be2
4 changed files with 67 additions and 26 deletions
+17 -12
View File
@@ -1,6 +1,18 @@
<template> <template>
<div class="shrink-0"> <div class="shrink-0">
<div class="flex items-start gap-1.5"> <div v-if="concealed" class="flex w-[6.5rem] flex-col items-center gap-0.5">
<button
type="button"
class="relative h-12 w-full overflow-hidden rounded-lg bg-zinc-900"
aria-label="Click to reveal frames"
title="Click to reveal"
@click.stop="revealed = true"
>
<span class="sensitive-glow block h-full w-full" />
</button>
<span class="text-[10px] leading-none text-zinc-500">Hidden</span>
</div>
<div v-else class="flex items-start gap-1.5">
<div <div
v-for="slot in visibleSlots" v-for="slot in visibleSlots"
:key="slot.key" :key="slot.key"
@@ -9,13 +21,11 @@
<button <button
type="button" type="button"
class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900" class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900"
:aria-label="concealed ? `Click to reveal ${slot.label}` : slot.aria" :aria-label="slot.aria"
:title="concealed ? 'Click to reveal' : slot.label" :title="slot.label"
@click.stop="onSlotClick(slot)" @click.stop="onSlotClick(slot)"
> >
<span v-if="concealed" class="sensitive-glow block h-full w-full" />
<img <img
v-else
:src="slot.thumb" :src="slot.thumb"
alt="" alt=""
loading="lazy" loading="lazy"
@@ -92,6 +102,7 @@ const revealed = computed({
const concealed = computed(() => props.hidden === true && !revealed.value) const concealed = computed(() => props.hidden === true && !revealed.value)
const visibleSlots = computed(() => { const visibleSlots = computed(() => {
if (concealed.value) return [] as FrameSlot[]
const list: FrameSlot[] = [] const list: FrameSlot[] = []
if (props.stillId) { if (props.stillId) {
list.push({ list.push({
@@ -120,11 +131,10 @@ const visibleSlots = computed(() => {
full: frameFull('first') full: frameFull('first')
}) })
} }
return list.filter(slot => concealed.value || !failed[slot.key]) return list.filter(slot => !failed[slot.key])
}) })
function frameThumb(key: SlotKey) { function frameThumb(key: SlotKey) {
if (concealed.value) return ''
if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}?thumb=1` if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}?thumb=1`
if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?thumb=1&v=parent` if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?thumb=1&v=parent`
if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?thumb=1&v=join` if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?thumb=1&v=join`
@@ -132,7 +142,6 @@ function frameThumb(key: SlotKey) {
} }
function frameFull(key: SlotKey) { function frameFull(key: SlotKey) {
if (concealed.value) return ''
if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}` if (key === 'still' && props.stillId) return `/api/library/stills/${props.stillId}`
if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?v=parent` if (key === 'from' && props.fromClipId) return `/api/library/clips/${props.fromClipId}/last-frame?v=parent`
if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?v=join` if (key === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?v=join`
@@ -162,10 +171,6 @@ watch(
) )
function onSlotClick(slot: FrameSlot) { function onSlotClick(slot: FrameSlot) {
if (concealed.value) {
revealed.value = true
return
}
if (!slot.full) return if (!slot.full) return
preview.value = { src: slot.full, label: slot.label } preview.value = { src: slot.full, label: slot.label }
} }
+4 -3
View File
@@ -1785,7 +1785,7 @@
<template v-for="node in familyDisplayNodes(entry.group.parts)" :key="node.key"> <template v-for="node in familyDisplayNodes(entry.group.parts)" :key="node.key">
<li <li
v-if="node.type === 'fork'" v-if="node.type === 'fork'"
class="rounded-lg px-2 py-1 text-[11px] font-medium uppercase tracking-wide text-amber-200/70" class="rounded-lg border-l-2 border-amber-300/50 bg-amber-400/5 px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-100"
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.85}rem` } : undefined" :style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.85}rem` } : undefined"
> >
{{ node.label }} {{ node.label }}
@@ -1823,7 +1823,7 @@
@blur="saveRename(node.clip)" @blur="saveRename(node.clip)"
> >
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium"> <p v-else class="min-w-0 flex-1 truncate text-sm font-medium">
{{ node.clip.name || node.clip.prompt }} <span class="text-amber-100">{{ clipFamilyPartTitle(node.clip, entry.group.parts) }}</span>
<span v-if="node.fromLabel" class="ml-1.5 text-[11px] font-normal text-amber-200/80">{{ node.fromLabel }}</span> <span v-if="node.fromLabel" class="ml-1.5 text-[11px] font-normal text-amber-200/80">{{ node.fromLabel }}</span>
<span v-if="node.branchLabel" class="ml-1.5 text-[11px] font-normal text-zinc-400">{{ node.branchLabel }}</span> <span v-if="node.branchLabel" class="ml-1.5 text-[11px] font-normal text-zinc-400">{{ node.branchLabel }}</span>
<span v-if="extendOpen && node.clip.id === extendClipId" class="ml-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200">extending</span> <span v-if="extendOpen && node.clip.id === extendClipId" class="ml-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200">extending</span>
@@ -1835,7 +1835,7 @@
@copy="copyPrompt" @copy="copyPrompt"
/> />
</div> </div>
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(node.clip) }}</p> <p class="truncate text-xs text-zinc-500">{{ node.clip.name || node.clip.prompt }} · {{ clipMetaLine(node.clip) }}</p>
<PermanenceRefStrip <PermanenceRefStrip
v-if="!node.clip.hideThumbnail" v-if="!node.clip.hideThumbnail"
class="mt-2" class="mt-2"
@@ -2258,6 +2258,7 @@ import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames' import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
import { emptyKeepPrompts, insertKeepSnippet, normalizeKeepPrompts, type KeepPrompt, type KeepPromptLists } from '~/utils/keepPrompts' import { emptyKeepPrompts, insertKeepSnippet, normalizeKeepPrompts, type KeepPrompt, type KeepPromptLists } from '~/utils/keepPrompts'
import { import {
clipFamilyPartTitle,
clipPartLabel, clipPartLabel,
familyBaseName, familyBaseName,
familyBranchSummary, familyBranchSummary,
+33 -8
View File
@@ -303,11 +303,11 @@
</svg> </svg>
</button> </button>
</div> </div>
<div v-if="focused.kind === 'clip' && focusedDisplayNodes.length > 1" class="mt-3 space-y-1.5"> <div v-if="focused.kind === 'clip' && focusedDisplayNodes.length > 1 && !detailsHidden" class="mt-3 space-y-1.5">
<template v-for="node in focusedDisplayNodes" :key="node.key"> <template v-for="node in focusedDisplayNodes" :key="node.key">
<p <p
v-if="node.type === 'fork'" v-if="node.type === 'fork'"
class="px-1 text-[11px] font-medium uppercase tracking-wide text-amber-200/70" class="rounded-md border-l-2 border-amber-300/50 bg-amber-400/5 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-amber-100"
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined" :style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined"
> >
{{ node.label }} {{ node.label }}
@@ -320,28 +320,48 @@
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined" :style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined"
@click="focusPart(node.clip.id)" @click="focusPart(node.clip.id)"
> >
<img :src="partThumb(node.clip)" alt="" class="h-12 w-12 shrink-0 rounded object-cover" loading="lazy"> <span class="relative h-12 w-12 shrink-0 overflow-hidden rounded bg-zinc-900">
<span v-if="node.clip.hideThumbnail" class="sensitive-glow block h-full w-full" />
<img
v-else
:src="partThumb(node.clip)"
alt=""
class="h-full w-full object-cover"
loading="lazy"
>
</span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate text-xs font-medium text-zinc-100">{{ node.clip.name }}</span> <span class="block truncate text-xs font-medium text-amber-100">{{ clipFamilyPartTitle(node.clip, focused.group!.parts) }}</span>
<span v-if="node.fromLabel || node.branchLabel" class="block truncate text-[11px] text-amber-200/80"> <span v-if="node.fromLabel || node.branchLabel" class="block truncate text-[11px] text-amber-200/80">
{{ [node.fromLabel, node.branchLabel].filter(Boolean).join(' · ') }} {{ [node.fromLabel, node.branchLabel].filter(Boolean).join(' · ') }}
</span> </span>
<span class="block truncate text-[11px] text-zinc-500">{{ node.clip.name }}</span>
</span> </span>
</button> </button>
</template> </template>
</div> </div>
<div v-else-if="focusedParts.length > 1" class="mt-3 grid grid-cols-5 gap-1.5"> <div v-else-if="focusedParts.length > 1 && !detailsHidden" class="mt-3 grid grid-cols-5 gap-1.5">
<button <button
v-for="part in focusedParts" v-for="part in focusedParts"
:key="part.id" :key="part.id"
type="button" type="button"
class="overflow-hidden rounded-lg border border-white/10" class="relative overflow-hidden rounded-lg border border-white/10"
:class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''" :class="part.id === focusedPartId ? 'ring-2 ring-amber-300' : ''"
@click="focusPart(part.id)" @click="focusPart(part.id)"
> >
<img :src="partThumb(part)" alt="" class="aspect-square w-full object-cover" loading="lazy"> <span v-if="'hideThumbnail' in part && part.hideThumbnail" class="sensitive-glow block aspect-square w-full" />
<img
v-else
:src="partThumb(part)"
alt=""
class="aspect-square w-full object-cover"
loading="lazy"
>
</button> </button>
</div> </div>
<p v-else-if="focused.kind === 'clip' && focusedDisplayNodes.length > 1 && detailsHidden" class="mt-3 text-xs text-zinc-500">
{{ focusedDisplayNodes.filter(node => node.type === 'clip').length }} parts hidden with preview
</p>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">{{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }}</button> <button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">{{ focused.kind === 'track' ? 'Open in Music' : 'Open in Studio' }}</button>
<button v-if="focused.kind !== 'track'" type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button> <button v-if="focused.kind !== 'track'" type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
@@ -481,6 +501,7 @@ import {
chronologicalStills, chronologicalStills,
clipBranchFromLabel, clipBranchFromLabel,
clipBranchLetterLabel, clipBranchLetterLabel,
clipFamilyPartTitle,
familyBranchSummary, familyBranchSummary,
familyDisplayNodes, familyDisplayNodes,
formatClipDuration, formatClipDuration,
@@ -852,7 +873,11 @@ function cardMeta(entry: LibraryEntry) {
} }
function partThumb(part: LibraryClip | LibraryStill) { function partThumb(part: LibraryClip | LibraryStill) {
if ('hideThumbnail' in part) return `/api/library/clips/${part.id}/thumb` if ('hideThumbnail' in part && !('role' in part)) {
const clip = part as LibraryClip
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
}
return `/api/library/stills/${part.id}?thumb=1` return `/api/library/stills/${part.id}?thumb=1`
} }
+13 -3
View File
@@ -213,11 +213,21 @@ export function familyBranchHint(parts: LibraryClip[]) {
return familyBranchSummary(parts) 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[]) { export function clipParentDisplayLabel(parent: LibraryClip | undefined, parts: LibraryClip[]) {
if (!parent) return '' if (!parent) return ''
const byId = new Map(parts.map(part => [part.id, part])) if (clipIsFamilyRoot(parent, parts)) return 'Start'
const isRoot = !parent.parentClipId || !byId.has(parent.parentClipId)
if (isRoot) return 'Start'
return clipPartLabel(parent.name) || parent.name || 'previous' return clipPartLabel(parent.name) || parent.name || 'previous'
} }