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:
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<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
|
||||
v-for="slot in visibleSlots"
|
||||
:key="slot.key"
|
||||
@@ -9,13 +21,11 @@
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900"
|
||||
:aria-label="concealed ? `Click to reveal ${slot.label}` : slot.aria"
|
||||
:title="concealed ? 'Click to reveal' : slot.label"
|
||||
:aria-label="slot.aria"
|
||||
:title="slot.label"
|
||||
@click.stop="onSlotClick(slot)"
|
||||
>
|
||||
<span v-if="concealed" class="sensitive-glow block h-full w-full" />
|
||||
<img
|
||||
v-else
|
||||
:src="slot.thumb"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
@@ -92,6 +102,7 @@ const revealed = computed({
|
||||
const concealed = computed(() => props.hidden === true && !revealed.value)
|
||||
|
||||
const visibleSlots = computed(() => {
|
||||
if (concealed.value) return [] as FrameSlot[]
|
||||
const list: FrameSlot[] = []
|
||||
if (props.stillId) {
|
||||
list.push({
|
||||
@@ -120,11 +131,10 @@ const visibleSlots = computed(() => {
|
||||
full: frameFull('first')
|
||||
})
|
||||
}
|
||||
return list.filter(slot => concealed.value || !failed[slot.key])
|
||||
return list.filter(slot => !failed[slot.key])
|
||||
})
|
||||
|
||||
function frameThumb(key: SlotKey) {
|
||||
if (concealed.value) return ''
|
||||
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 === '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) {
|
||||
if (concealed.value) return ''
|
||||
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 === 'first' && props.clipId) return `/api/library/clips/${props.clipId}/first-frame?v=join`
|
||||
@@ -162,10 +171,6 @@ watch(
|
||||
)
|
||||
|
||||
function onSlotClick(slot: FrameSlot) {
|
||||
if (concealed.value) {
|
||||
revealed.value = true
|
||||
return
|
||||
}
|
||||
if (!slot.full) return
|
||||
preview.value = { src: slot.full, label: slot.label }
|
||||
}
|
||||
|
||||
+4
-3
@@ -1785,7 +1785,7 @@
|
||||
<template v-for="node in familyDisplayNodes(entry.group.parts)" :key="node.key">
|
||||
<li
|
||||
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"
|
||||
>
|
||||
{{ node.label }}
|
||||
@@ -1823,7 +1823,7 @@
|
||||
@blur="saveRename(node.clip)"
|
||||
>
|
||||
<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.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>
|
||||
@@ -1835,7 +1835,7 @@
|
||||
@copy="copyPrompt"
|
||||
/>
|
||||
</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
|
||||
v-if="!node.clip.hideThumbnail"
|
||||
class="mt-2"
|
||||
@@ -2258,6 +2258,7 @@ import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect'
|
||||
import { buildOutputBaseName, nextUniqueName } from '~/utils/outputNames'
|
||||
import { emptyKeepPrompts, insertKeepSnippet, normalizeKeepPrompts, type KeepPrompt, type KeepPromptLists } from '~/utils/keepPrompts'
|
||||
import {
|
||||
clipFamilyPartTitle,
|
||||
clipPartLabel,
|
||||
familyBaseName,
|
||||
familyBranchSummary,
|
||||
|
||||
+33
-8
@@ -303,11 +303,11 @@
|
||||
</svg>
|
||||
</button>
|
||||
</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">
|
||||
<p
|
||||
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"
|
||||
>
|
||||
{{ node.label }}
|
||||
@@ -320,28 +320,48 @@
|
||||
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.65}rem` } : undefined"
|
||||
@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="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">
|
||||
{{ [node.fromLabel, node.branchLabel].filter(Boolean).join(' · ') }}
|
||||
</span>
|
||||
<span class="block truncate text-[11px] text-zinc-500">{{ node.clip.name }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</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
|
||||
v-for="part in focusedParts"
|
||||
:key="part.id"
|
||||
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' : ''"
|
||||
@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>
|
||||
</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">
|
||||
<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>
|
||||
@@ -481,6 +501,7 @@ import {
|
||||
chronologicalStills,
|
||||
clipBranchFromLabel,
|
||||
clipBranchLetterLabel,
|
||||
clipFamilyPartTitle,
|
||||
familyBranchSummary,
|
||||
familyDisplayNodes,
|
||||
formatClipDuration,
|
||||
@@ -852,7 +873,11 @@ function cardMeta(entry: LibraryEntry) {
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -213,11 +213,21 @@ 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 ''
|
||||
const byId = new Map(parts.map(part => [part.id, part]))
|
||||
const isRoot = !parent.parentClipId || !byId.has(parent.parentClipId)
|
||||
if (isRoot) return 'Start'
|
||||
if (clipIsFamilyRoot(parent, parts)) return 'Start'
|
||||
return clipPartLabel(parent.name) || parent.name || 'previous'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user