Keep the Extend panel put and name sibling clips as branches.

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>
This commit is contained in:
Towsty
2026-09-02 15:16:30 -05:00
co-authored by Cursor
parent 4546aa5f61
commit 1be9bcb23f
9 changed files with 274 additions and 119 deletions
+134 -91
View File
@@ -1,14 +1,16 @@
<template> <template>
<div class="min-h-dvh min-w-0"> <div class="min-h-dvh min-w-0 lg:flex lg:h-dvh lg:flex-col lg:overflow-hidden">
<div class="shrink-0">
<AppHeader <AppHeader
:subtitle="ltxEnabled ? 'MiniMax H3 · LTX-2.3 · Flux.2 Klein' : 'MiniMax H3 · Flux.2 Klein'" :subtitle="ltxEnabled ? 'MiniMax H3 · LTX-2.3 · Flux.2 Klein' : 'MiniMax H3 · Flux.2 Klein'"
current="studio" current="studio"
:queue-count="queueBadgeCount" :queue-count="queueBadgeCount"
@gpu="pollComfyHealth" @gpu="pollComfyHealth"
/> />
</div>
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch"> <main class="mx-auto grid min-w-0 max-w-7xl flex-1 gap-6 px-4 py-6 sm:px-6 lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:items-stretch lg:overflow-hidden">
<section id="studio-input" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5"> <section id="studio-input" class="glass min-w-0 max-w-full space-y-5 rounded-3xl p-5 sm:p-6 lg:min-h-0 lg:overflow-y-auto">
<div> <div>
<div class="mb-4 grid grid-cols-2 gap-3"> <div class="mb-4 grid grid-cols-2 gap-3">
<button <button
@@ -1207,8 +1209,8 @@
</div> </div>
</section> </section>
<section class="flex min-w-0 max-w-full flex-col gap-5 lg:h-full lg:min-h-0"> <section class="flex min-w-0 max-w-full flex-col gap-5 lg:h-full lg:min-h-0 lg:overflow-hidden">
<div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 lg:shrink-0"> <div ref="outputPanel" class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 lg:max-h-[min(58dvh,38rem)] lg:shrink-0 lg:overflow-y-auto">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2> <h2 class="font-display text-2xl font-bold">Output</h2>
@@ -1298,15 +1300,15 @@
<div v-show="outputPreviewKind === 'video'"> <div v-show="outputPreviewKind === 'video'">
<div <div
class="relative mx-auto mt-5 w-full max-w-full overflow-hidden rounded-2xl bg-black/50" class="relative mx-auto mt-5 w-full max-w-full overflow-hidden rounded-2xl bg-black/50 lg:sticky lg:top-0 lg:z-10"
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }" :class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}`, maxHeight: 'min(70dvh, 32rem)' }" :style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}`, maxHeight: 'min(36dvh, 20rem)' }"
> >
<video <video
v-if="videoUrl && !lockedSave" v-if="videoUrl && !lockedSave"
ref="player" ref="player"
:src="videoUrl" :src="videoUrl"
class="h-full w-full max-h-[min(70dvh,32rem)] bg-black object-contain" class="h-full w-full max-h-[min(36dvh,20rem)] bg-black object-contain"
:class="{ 'opacity-0': awaitingReveal }" :class="{ 'opacity-0': awaitingReveal }"
:controls="!awaitingReveal" :controls="!awaitingReveal"
:autoplay="autoplayEnabled && !awaitingReveal" :autoplay="autoplayEnabled && !awaitingReveal"
@@ -1394,7 +1396,10 @@
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<h3 class="text-sm font-semibold text-amber-100">Extend this clip</h3> <h3 class="text-sm font-semibold text-amber-100">Extend this clip</h3>
<p class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together.{{ extendSaveName ? ` Saves as “${extendSaveName}”.` : '' }}</p> <p v-if="extendSourceLabel" class="mt-1 text-xs text-amber-200/90">
Extending {{ extendSourceLabel }}{{ extendSaveName ? ` · next save “${extendSaveName}”` : '' }}
</p>
<p class="mt-1 text-xs text-zinc-500">Continue from the last frame with a new motion prompt, then stitch the segments together. Other jobs finishing will not close this panel.</p>
</div> </div>
<button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button> <button type="button" class="text-xs text-zinc-400 hover:text-white" @click="closeExtend">Close</button>
</div> </div>
@@ -1563,7 +1568,7 @@
</div> </div>
</div> </div>
<div class="glass rounded-3xl p-5 sm:p-6 lg:flex lg:min-h-[24rem] lg:flex-1 lg:flex-col"> <div class="glass rounded-3xl p-5 sm:p-6 lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2> <h2 class="font-display text-xl font-bold">Library</h2>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -1594,7 +1599,7 @@
</form> </form>
<template v-else> <template v-else>
<div class="lg:flex lg:min-h-0 lg:flex-1 lg:flex-col"> <div class="lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden">
<div class="mt-4 flex flex-wrap items-center justify-between gap-2"> <div class="mt-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex min-w-0 flex-wrap gap-1.5"> <div class="flex min-w-0 flex-wrap gap-1.5">
<button <button
@@ -1711,6 +1716,7 @@
/> />
</div> </div>
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p> <p class="truncate text-xs text-zinc-500">{{ clipMetaLine(entry.group.head) }}</p>
<p v-if="familyBranchHint(entry.group.parts)" class="mt-1 truncate text-[11px] text-amber-200/80">{{ familyBranchHint(entry.group.parts) }}</p>
<p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p> <p v-if="entry.group.head.globalLocks" class="mt-1 text-[11px] text-zinc-600">Global locks saved with this collection.</p>
<PermanenceRefStrip <PermanenceRefStrip
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail" v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
@@ -1725,7 +1731,7 @@
/> />
<div class="mt-2 flex flex-wrap items-center gap-3"> <div class="mt-2 flex flex-wrap items-center gap-3">
<button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)"> <button v-if="entry.group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(entry.group.key)">
{{ expandedFamilies[entry.group.key] ? 'Collapse' : `Expand ${entry.group.parts.length} parts` }} {{ familyExpandLabel(entry.group.parts, expandedFamilies[entry.group.key]) }}
</button> </button>
<button <button
v-if="entry.group.parts.length > 1 && entry.group.parts.some(part => part.hideThumbnail)" v-if="entry.group.parts.length > 1 && entry.group.parts.some(part => part.hideThumbnail)"
@@ -1768,89 +1774,98 @@
</div> </div>
<ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3"> <ul v-if="expandedFamilies[entry.group.key] && entry.group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
<li <li
v-for="(item, partIndex) in chronologicalParts(entry.group.parts)" v-for="node in treeOrderedParts(entry.group.parts)"
:key="item.id" :key="node.clip.id"
class="flex cursor-pointer flex-wrap items-start gap-3 rounded-xl border p-2" class="flex cursor-pointer flex-wrap items-start gap-3 rounded-xl border p-2"
:class="item.id === reviewingClipId ? 'border-amber-300/80 bg-amber-400/15 ring-1 ring-amber-300/60' : 'border-white/5 bg-black/20'" :class="node.clip.id === reviewingClipId
:aria-current="item.id === reviewingClipId ? 'true' : undefined" ? 'border-amber-300/80 bg-amber-400/15 ring-1 ring-amber-300/60'
@click="onLibraryClipPartClick(item, $event)" : extendOpen && node.clip.id === extendClipId
? 'border-amber-300/40 bg-amber-400/5'
: 'border-white/5 bg-black/20'"
:style="node.depth ? { marginLeft: `${Math.min(node.depth, 6) * 0.85}rem` } : undefined"
:aria-current="node.clip.id === reviewingClipId ? 'true' : undefined"
@click="onLibraryClipPartClick(node.clip, $event)"
> >
<ClipFrameThumbs <ClipFrameThumbs
:hidden="item.hideThumbnail" :hidden="node.clip.hideThumbnail"
:revealed="!!stitchReveal[item.id]" :revealed="!!stitchReveal[node.clip.id]"
:still-id="partIndex === 0 ? item.stillId : undefined" :still-id="node.depth === 0 ? node.clip.stillId : undefined"
:from-clip-id="previousClipIdFor(item, entry.group.parts, partIndex)" :from-clip-id="previousClipIdFor(node.clip, entry.group.parts)"
:clip-id="item.id" :clip-id="node.clip.id"
:show-first-frame="partIndex > 0 || !item.stillId || Boolean(item.parentClipId)" :show-first-frame="node.depth > 0 || !node.clip.stillId || Boolean(node.clip.parentClipId)"
@update:revealed="stitchReveal[item.id] = $event" @update:revealed="stitchReveal[node.clip.id] = $event"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<input <input
v-if="renamingId === item.id && !renamingFamily" v-if="renamingId === node.clip.id && !renamingFamily"
v-model="renameDraft" v-model="renameDraft"
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2" class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
maxlength="80" maxlength="80"
@keydown.enter.prevent="saveRename(item)" @keydown.enter.prevent="saveRename(node.clip)"
@keydown.escape.prevent="cancelRename" @keydown.escape.prevent="cancelRename"
@blur="saveRename(item)" @blur="saveRename(node.clip)"
> >
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p> <p v-else class="min-w-0 flex-1 truncate text-sm font-medium">
{{ node.clip.name || node.clip.prompt }}
<span v-if="clipBranchFromLabel(node.clip, entry.group.parts)" class="ml-1.5 text-[11px] font-normal text-amber-200/80">{{ clipBranchFromLabel(node.clip, entry.group.parts) }}</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>
</p>
<PromptPreviewTip <PromptPreviewTip
v-if="formatPromptParts(item)" v-if="formatPromptParts(node.clip)"
:text="formatPromptParts(item)" :text="formatPromptParts(node.clip)"
:aria-label="'Copy prompt for ' + (item.name || 'this clip')" :aria-label="'Copy prompt for ' + (node.clip.name || 'this clip')"
@copy="copyPrompt" @copy="copyPrompt"
/> />
</div> </div>
<p class="truncate text-xs text-zinc-500">{{ clipMetaLine(item) }}</p> <p class="truncate text-xs text-zinc-500">{{ clipMetaLine(node.clip) }}</p>
<PermanenceRefStrip <PermanenceRefStrip
v-if="!item.hideThumbnail" v-if="!node.clip.hideThumbnail"
class="mt-2" class="mt-2"
:refs="item.permanenceRefs || []" :refs="node.clip.permanenceRefs || []"
:name-options="clipBindingKeys(item, entry.group.head)" :name-options="clipBindingKeys(node.clip, entry.group.head)"
cta="Add still" cta="Add still"
:hidden="item.hideInput || hideInputPreview" :hidden="node.clip.hideInput || hideInputPreview"
@add-ref="openPermanencePicker(undefined, item.id)" @add-ref="openPermanencePicker(undefined, node.clip.id)"
@remove="(refIndex) => removeClipRef(item, refIndex)" @remove="(refIndex) => removeClipRef(node.clip, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)" @update-label="(refIndex, label) => updateClipRefLabel(node.clip, refIndex, label)"
/> />
<div class="mt-1.5 flex flex-wrap items-center gap-3"> <div class="mt-1.5 flex flex-wrap items-center gap-3">
<button <button
v-if="item.hideThumbnail" v-if="node.clip.hideThumbnail"
type="button" type="button"
class="text-xs text-amber-300" class="text-xs text-amber-300"
@click="stitchReveal[item.id] = !stitchReveal[item.id]" @click="stitchReveal[node.clip.id] = !stitchReveal[node.clip.id]"
> >
{{ stitchReveal[item.id] ? 'Hide frames' : 'Click to reveal' }} {{ stitchReveal[node.clip.id] ? 'Hide frames' : 'Click to reveal' }}
</button> </button>
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button> <button type="button" class="text-xs text-amber-300" @click="openClip(node.clip)">Open</button>
<button type="button" class="text-xs text-amber-300" @click="useClipAsInput(item)">Use as input</button> <button type="button" class="text-xs text-amber-300" @click="useClipAsInput(node.clip)">Use as input</button>
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button> <button type="button" class="text-xs text-amber-300" @click="openExtend(node.clip)">Extend</button>
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button> <button type="button" class="text-xs text-amber-300" @click="startRename(node.clip)">Rename</button>
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)"> <button type="button" class="text-xs text-zinc-400" @click="toggleDetails(node.clip.id)">
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }} {{ expandedDetails[node.clip.id] ? 'Hide details' : 'Details' }}
</button> </button>
<select <select
v-if="folders.length > 1" v-if="folders.length > 1"
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300" class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
:value="''" :value="''"
@change="onMoveClip(item, $event)" @change="onMoveClip(node.clip, $event)"
> >
<option disabled value="">Move to…</option> <option disabled value="">Move to…</option>
<option <option
v-for="folder in folders.filter(folder => folder.id !== item.folderId)" v-for="folder in folders.filter(folder => folder.id !== node.clip.folderId)"
:key="folder.id" :key="folder.id"
:value="folder.id" :value="folder.id"
> >
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }} {{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
</option> </option>
</select> </select>
<button type="button" class="text-xs text-red-300" @click="askRemoveClip(item)">Delete</button> <button type="button" class="text-xs text-red-300" @click="askRemoveClip(node.clip)">Delete</button>
</div> </div>
<LibrarySettingsDetails <LibrarySettingsDetails
v-if="expandedDetails[item.id]" v-if="expandedDetails[node.clip.id]"
:rows="clipSettingsRows(item)" :rows="clipSettingsRows(node.clip)"
empty="No generator settings were saved with this clip." empty="No generator settings were saved with this clip."
/> />
</div> </div>
@@ -2215,6 +2230,15 @@ import {
import { parseImageAspect, generateSizeForAspect } from '~/utils/imageAspect' 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 {
clipBranchFromLabel,
clipPartLabel,
familyBaseName,
familyBranchCount,
familyBranchHint,
nextClipPartName,
treeOrderedParts
} from '~/utils/libraryBrowse'
import { import {
IMAGE_V2_CFG_DEFAULT, IMAGE_V2_CFG_DEFAULT,
IMAGE_V2_CONSISTENCY_LORA, IMAGE_V2_CONSISTENCY_LORA,
@@ -2997,7 +3021,21 @@ const canExtend = computed(() => Boolean(
&& !lockedSave.value && !lockedSave.value
&& (currentClipId.value || clipIdFromPlayer()) && (currentClipId.value || clipIdFromPlayer())
)) ))
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || '')) const extendSourceClip = computed(() => clips.value.find(clip => clip.id === extendClipId.value) || currentClip.value)
const extendSourceLabel = computed(() => {
const clip = extendSourceClip.value
if (!clip) return ''
return clip.name || clipPartLabel(clip.name) || 'this clip'
})
const extendSaveName = computed(() => {
const clip = extendSourceClip.value
if (!clip) return ''
const reserved = [
...familyPartsFor(clip).map(part => part.name || ''),
...studioJobs.value.map(job => job.name || '')
]
return nextClipPartName(clip.name || clip.prompt || '', reserved)
})
const queueReady = computed(() => { const queueReady = computed(() => {
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim()) if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
return extensionQueue.value.every(item => composePromptParts(item.promptPre, item.prompt, item.promptPost)) return extensionQueue.value.every(item => composePromptParts(item.promptPre, item.prompt, item.promptPost))
@@ -3392,10 +3430,6 @@ function folderOutputCount(folder: LibraryFolder) {
return videos + images return videos + images
} }
function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '')
}
function clipFamilyKey(clip: LibraryClip) { function clipFamilyKey(clip: LibraryClip) {
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}` if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
const base = familyBaseName(clip.name || '').toLowerCase() const base = familyBaseName(clip.name || '').toLowerCase()
@@ -3438,18 +3472,31 @@ function chronologicalParts(parts: LibraryClip[]) {
}) })
} }
function previousClipIdFor(item: LibraryClip, parts: LibraryClip[], index: number) { function previousClipIdFor(item: LibraryClip, parts: LibraryClip[]) {
if (item.parentClipId) return item.parentClipId if (item.parentClipId) return item.parentClipId
if (index <= 0) return undefined
const ordered = chronologicalParts(parts) const ordered = chronologicalParts(parts)
const current = ordered[index] const index = ordered.findIndex(part => part.id === item.id)
const currentChain = current?.chainIndex || 0 if (index <= 0) return undefined
const currentChain = item.chainIndex || 0
for (let i = index - 1; i >= 0; i--) { for (let i = index - 1; i >= 0; i--) {
if ((ordered[i].chainIndex || 0) < currentChain) return ordered[i].id if ((ordered[i].chainIndex || 0) < currentChain) return ordered[i].id
} }
return ordered[index - 1]?.id return ordered[index - 1]?.id
} }
function familyExpandLabel(parts: LibraryClip[], expanded?: boolean) {
if (expanded) return 'Collapse'
const branches = familyBranchCount(parts)
if (branches) return `Expand ${parts.length} parts · ${branches} branch${branches === 1 ? '' : 'es'}`
return `Expand ${parts.length} parts`
}
function ensureFamilyExpanded(item: LibraryClip) {
const key = clipFamilyKey(item)
if (expandedFamilies.value[key]) return
expandedFamilies.value = { ...expandedFamilies.value, [key]: true }
}
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) { function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name) const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
return named || group.head.name || group.head.prompt return named || group.head.name || group.head.prompt
@@ -3946,7 +3993,7 @@ watch(hideThumbnail, (value) => {
watch(hideExtendLastFrame, (hidden) => { watch(hideExtendLastFrame, (hidden) => {
if (hidden || !extendOpen.value) return if (hidden || !extendOpen.value) return
const id = currentClipId.value || clipIdFromPlayer() const id = extendClipId.value || currentClipId.value || clipIdFromPlayer()
if (id && !lastFrameUrl.value && !lastFrameLoading.value) void loadLastFrame(id) if (id && !lastFrameUrl.value && !lastFrameLoading.value) void loadLastFrame(id)
}) })
@@ -5128,7 +5175,7 @@ async function refreshStudioQueue() {
} else if (running.id && running.liveJobId === editJobId.value) { } else if (running.id && running.liveJobId === editJobId.value) {
outputStudioJobId.value = running.id outputStudioJobId.value = running.id
} }
} else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value && !watchingLiveVideo()) { } else if (running?.liveJobId && running.liveJobId !== videoJobId.value && !videoBusy.value && !watchingLiveVideo() && !extendOpen.value) {
outputStudioJobId.value = running.id outputStudioJobId.value = running.id
videoJobId.value = running.liveJobId videoJobId.value = running.liveJobId
persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true) persistActiveJob('video', running.liveJobId, running.hideThumbnail === true, running.folderLocked === true)
@@ -5548,16 +5595,6 @@ function stillFileName(still?: Pick<LibraryStill, 'name' | 'filename'> | null) {
return still?.filename || 'still.png' return still?.filename || 'still.png'
} }
function nextClipPartName(name: string) {
const trimmed = (name || '').trim()
if (!trimmed) return ''
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
const base = (match ? match[1] : trimmed).trim() || trimmed
const part = match ? Number(match[2]) + 1 : 2
const suffix = ` p${part}`
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
}
function clipGenerateDuration(item: LibraryClip) { function clipGenerateDuration(item: LibraryClip) {
if (item.parentClipId) { if (item.parentClipId) {
const parent = clips.value.find(clip => clip.id === item.parentClipId) const parent = clips.value.find(clip => clip.id === item.parentClipId)
@@ -5794,7 +5831,7 @@ async function loadLastFrame(id: string) {
function revealExtendLastFrame() { function revealExtendLastFrame() {
lastFrameRevealed.value = true lastFrameRevealed.value = true
const id = currentClipId.value || clipIdFromPlayer() const id = extendClipId.value || currentClipId.value || clipIdFromPlayer()
if (id && !lastFrameUrl.value && !lastFrameLoading.value) void loadLastFrame(id) if (id && !lastFrameUrl.value && !lastFrameLoading.value) void loadLastFrame(id)
} }
@@ -5808,6 +5845,7 @@ async function openExtend(item?: LibraryClip) {
currentClipId.value = id currentClipId.value = id
extendClipId.value = id extendClipId.value = id
const clip = clips.value.find(entry => entry.id === id) const clip = clips.value.find(entry => entry.id === id)
if (clip) ensureFamilyExpanded(clip)
const parts = restorePromptParts(clip?.prompt || prompt.value, { const parts = restorePromptParts(clip?.prompt || prompt.value, {
pre: clip ? (clip.promptPre || '') : promptPre.value, pre: clip ? (clip.promptPre || '') : promptPre.value,
prompt: clip?.prompt || prompt.value, prompt: clip?.prompt || prompt.value,
@@ -5823,8 +5861,6 @@ async function openExtend(item?: LibraryClip) {
lastFrameUrl.value = '' lastFrameUrl.value = ''
lastFrameLoading.value = false lastFrameLoading.value = false
player.value?.pause() player.value?.pause()
await nextTick()
document.getElementById('extend-clip')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
if (!hideExtendLastFrame.value) await loadLastFrame(id) if (!hideExtendLastFrame.value) await loadLastFrame(id)
} }
@@ -5848,6 +5884,7 @@ async function generateExtension() {
folderLocked?: boolean folderLocked?: boolean
width?: number width?: number
height?: number height?: number
name?: string
}>('/api/extend', { }>('/api/extend', {
method: 'POST', method: 'POST',
body: { body: {
@@ -5860,29 +5897,27 @@ async function generateExtension() {
} }
}) })
if (queuedOffLiveOutput(started, 'video')) { if (queuedOffLiveOutput(started, 'video')) {
if (watchingLiveVideo()) { toast(started.name
toast('Queued behind the current job. Output stays on the clip that is generating.') ? `Queued “${started.name}”. Stay here to queue another branch.`
await refreshStudioQueue() : 'Queued behind the current job. This panel stays open.')
return if (!watchingLiveVideo()) {
}
toast('Queued behind the current job.')
outputStudioJobId.value = started.studioJobId || '' outputStudioJobId.value = started.studioJobId || ''
outputFocus.value = 'video' outputFocus.value = 'video'
videoAwaitingBeast.value = true videoAwaitingBeast.value = true
statusMessage.value = 'Queued. Waiting for Beast to be free.' statusMessage.value = 'Queued. Waiting for Beast to be free.'
closeExtend() }
await refreshStudioQueue() await refreshStudioQueue()
return return
} }
closeExtend()
statusBusy.value = false statusBusy.value = false
videoBusy.value = true videoBusy.value = true
watchingLibrary.value = false
stopListen('video') stopListen('video')
videoSettledUi = false videoSettledUi = false
progress.value = 1 progress.value = 1
if (!extendOpen.value && !watchingLibrary.value) {
awaitingReveal.value = false awaitingReveal.value = false
concealOutput.value = started.hideThumbnail === true || hideOut concealOutput.value = started.hideThumbnail === true || hideOut
}
lockedSave.value = false lockedSave.value = false
chainStep.value = 1 chainStep.value = 1
chainTotal.value = 1 chainTotal.value = 1
@@ -5898,6 +5933,9 @@ async function generateExtension() {
maxStep.value = started.steps maxStep.value = started.steps
persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
toast(started.name
? `Started “${started.name}”. This panel stays open so you can queue another branch.`
: 'Extension started. This panel stays open.')
void pollComfyHealth() void pollComfyHealth()
void refreshStudioQueue() void refreshStudioQueue()
} catch (error: any) { } catch (error: any) {
@@ -5916,8 +5954,8 @@ async function generateExtension() {
function openClip(item: LibraryClip) { function openClip(item: LibraryClip) {
studioMode.value = 'video' studioMode.value = 'video'
outputFocus.value = 'video' outputFocus.value = 'video'
closeExtend()
watchingLibrary.value = true watchingLibrary.value = true
ensureFamilyExpanded(item)
currentClipId.value = item.id currentClipId.value = item.id
lockedSave.value = false lockedSave.value = false
concealOutput.value = item.hideThumbnail concealOutput.value = item.hideThumbnail
@@ -7342,6 +7380,12 @@ function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLoc
} }
} }
function keepPlayerOnCurrentView(incomingClipId?: string) {
if (extendOpen.value) return true
if (watchingLibrary.value && currentClipId.value && incomingClipId && incomingClipId !== currentClipId.value) return true
return false
}
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) { function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
if (payload.queueId) persistActiveQueue(String(payload.queueId)) if (payload.queueId) persistActiveQueue(String(payload.queueId))
if (payload.message) statusMessage.value = payload.message if (payload.message) statusMessage.value = payload.message
@@ -7364,7 +7408,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
scheduleLibraryRefresh() scheduleLibraryRefresh()
void refreshLiveQueue() void refreshLiveQueue()
const locked = folderLocked || payload.folderLocked === true const locked = folderLocked || payload.folderLocked === true
if (!watchingLibrary.value && !locked) { if (!keepPlayerOnCurrentView(payload.clipId) && !locked) {
currentClipId.value = payload.clipId currentClipId.value = payload.clipId
videoUrl.value = clipVideoUrl(payload.clipId) videoUrl.value = clipVideoUrl(payload.clipId)
concealOutput.value = hidden || payload.hideThumbnail === true concealOutput.value = hidden || payload.hideThumbnail === true
@@ -7397,7 +7441,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
const locked = dest const locked = dest
? Boolean(dest.protected && !dest.unlocked) ? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true) : (folderLocked || payload.folderLocked === true)
if (!watchingLibrary.value && !locked && payload.clipId) { if (!keepPlayerOnCurrentView(payload.clipId) && !locked && payload.clipId) {
currentClipId.value = payload.clipId currentClipId.value = payload.clipId
videoUrl.value = clipVideoUrl(payload.clipId) videoUrl.value = clipVideoUrl(payload.clipId)
concealOutput.value = hidden || payload.hideThumbnail === true concealOutput.value = hidden || payload.hideThumbnail === true
@@ -7424,8 +7468,7 @@ function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLo
const locked = dest const locked = dest
? Boolean(dest.protected && !dest.unlocked) ? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true) : (folderLocked || payload.folderLocked === true)
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value if (keepPlayerOnCurrentView(payload.clipId)) {
if (keepLibraryView) {
videoBusy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer('video') stopTimer('video')
+13 -3
View File
@@ -261,6 +261,7 @@
> >
<h2 v-else class="mt-3 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2> <h2 v-else class="mt-3 font-display text-lg font-bold leading-snug">{{ focused.name }}</h2>
<p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p> <p class="mt-1 text-xs text-zinc-500">{{ detailMeta }}</p>
<p v-if="focusedBranchHint" class="mt-1 text-xs text-amber-200/80">{{ focusedBranchHint }}</p>
<p v-if="detailPrompt" class="mt-3 max-h-32 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400">{{ detailPrompt }}</p> <p v-if="detailPrompt" class="mt-3 max-h-32 overflow-y-auto whitespace-pre-wrap text-xs text-zinc-400">{{ detailPrompt }}</p>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 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)">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)">Open in Studio</button>
@@ -357,10 +358,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
buildLibraryEntries, buildLibraryEntries,
chronologicalParts,
chronologicalStills, chronologicalStills,
clipBranchFromLabel,
familyBranchHint,
formatClipDuration, formatClipDuration,
formatWhen, formatWhen,
treeOrderedParts,
type LibraryClip, type LibraryClip,
type LibraryEntry, type LibraryEntry,
type LibraryFilter, type LibraryFilter,
@@ -501,7 +504,13 @@ const focusedNavParts = computed(() => {
const parts = focusedParts.value const parts = focusedParts.value
if (parts.length < 2) return [] if (parts.length < 2) return []
if (focused.value?.kind === 'still') return chronologicalStills(parts as LibraryStill[]) if (focused.value?.kind === 'still') return chronologicalStills(parts as LibraryStill[])
return chronologicalParts(parts as LibraryClip[]) return treeOrderedParts(parts as LibraryClip[]).map(node => node.clip)
})
const focusedBranchHint = computed(() => {
if (focused.value?.kind !== 'clip' || !focused.value.group) return ''
const parts = focused.value.group.parts
const current = parts.find(part => part.id === focusedPartId.value)
return (current && clipBranchFromLabel(current, parts)) || familyBranchHint(parts)
}) })
const focusedNavIndex = computed(() => { const focusedNavIndex = computed(() => {
const id = focusedPartId.value const id = focusedPartId.value
@@ -667,7 +676,8 @@ function partCount(entry: LibraryEntry) {
function cardMeta(entry: LibraryEntry) { function cardMeta(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) { if (entry.kind === 'clip' && entry.group) {
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt)].filter(Boolean).join(' · ') const branches = familyBranchHint(entry.group.parts)
return [formatClipDuration(entry.group.head.duration), formatWhen(entry.createdAt), branches].filter(Boolean).join(' · ')
} }
return formatWhen(entry.createdAt) return formatWhen(entry.createdAt)
} }
+7 -1
View File
@@ -5,6 +5,7 @@ import { readLoraStack } from '~/utils/loras'
import { parseExtendDuration } from '~/server/utils/extendChain' import { parseExtendDuration } from '~/server/utils/extendChain'
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels' import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { composePromptParts } from '~/utils/promptParts' import { composePromptParts } from '~/utils/promptParts'
import { nextFamilyPartName } from '~/server/utils/library'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody<{ clipId?: string; prompt?: string; promptPre?: string; promptPost?: string; duration?: number; lora?: unknown; loraStack?: unknown }>(event).catch(() => ({})) const body = await readBody<{ clipId?: string; prompt?: string; promptPre?: string; promptPost?: string; duration?: number; lora?: unknown; loraStack?: unknown }>(event).catch(() => ({}))
@@ -45,6 +46,10 @@ export default defineEventHandler(async (event) => {
const turbo = source.turbo !== false const turbo = source.turbo !== false
const steps = Number(source.steps || defaultVideoSteps(turbo, workflow)) const steps = Number(source.steps || defaultVideoSteps(turbo, workflow))
const seed = Math.floor(Math.random() * 2_147_483_647) const seed = Math.floor(Math.random() * 2_147_483_647)
const reservedNames = listStudioJobs(owner)
.filter(job => job.status === 'waiting' || job.status === 'running' || job.status === 'held')
.map(job => job.name)
.filter(Boolean)
const studio = await addStudioJob({ const studio = await addStudioJob({
ownerKey: owner, ownerKey: owner,
@@ -54,7 +59,7 @@ export default defineEventHandler(async (event) => {
promptMid: prompt, promptMid: prompt,
promptPre, promptPre,
promptPost, promptPost,
name: nextClipPartName(clipTitle(source)), name: nextFamilyPartName(owner, source, reservedNames),
folderId: source.folderId, folderId: source.folderId,
aspect: source.aspect || '16:9', aspect: source.aspect || '16:9',
width: source.width || 1344, width: source.width || 1344,
@@ -95,6 +100,7 @@ export default defineEventHandler(async (event) => {
queued: !liveJobId, queued: !liveJobId,
clientId: '', clientId: '',
seed, seed,
name: studio.name,
width: source.width || 1344, width: source.width || 1344,
height: source.height || 768, height: source.height || 768,
steps, steps,
+3 -2
View File
@@ -12,7 +12,7 @@ import {
clipVideoPath, clipVideoPath,
extendTempDir, extendTempDir,
getClip, getClip,
nextClipPartName, nextFamilyPartName,
removeExtendTemp removeExtendTemp
} from '~/server/utils/library' } from '~/server/utils/library'
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg' import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
@@ -63,6 +63,7 @@ export async function beginExtendFromClip(params: {
duration?: number duration?: number
loraStack?: unknown loraStack?: unknown
folderLocked?: boolean folderLocked?: boolean
name?: string
}) { }) {
const source = getClip(params.ownerKey, params.clipId) const source = getClip(params.ownerKey, params.clipId)
const sourcePath = clipVideoPath(params.ownerKey, source.id) const sourcePath = clipVideoPath(params.ownerKey, source.id)
@@ -104,7 +105,7 @@ export async function beginExtendFromClip(params: {
const length = frameLength(durationSeconds, fps) const length = frameLength(durationSeconds, fps)
const width = source.width || 1344 const width = source.width || 1344
const height = source.height || 768 const height = source.height || 768
const clipName = nextClipPartName(clipTitle(source)) const clipName = String(params.name || '').trim() || nextFamilyPartName(params.ownerKey, source)
const job = createJob() const job = createJob()
job.kind = 'video' job.kind = 'video'
+7 -12
View File
@@ -18,6 +18,9 @@ import {
type KeepPromptKind, type KeepPromptKind,
type KeepPromptLists type KeepPromptLists
} from '~/utils/keepPrompts' } from '~/utils/keepPrompts'
import { familyBaseName, nextClipPartName, clipPartLabel } from '~/utils/libraryBrowse'
export { familyBaseName, nextClipPartName, clipPartLabel }
const scryptAsync = promisify(scrypt) const scryptAsync = promisify(scrypt)
@@ -327,18 +330,10 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
return fromPrompt || 'Untitled clip' return fromPrompt || 'Untitled clip'
} }
export function familyBaseName(name: string) { export function nextFamilyPartName(owner: string, source: LibraryClip, extraNames: string[] = []) {
return String(name || '').trim().replace(/\s+p\d+$/i, '') const catalog = readCatalog(owner)
} const family = clipsInSameFamily(catalog, source)
return nextClipPartName(clipTitle(source), [...family.map(item => clipTitle(item)), ...extraNames])
export function nextClipPartName(name: string) {
const trimmed = (name || '').trim()
if (!trimmed) return ''
const match = trimmed.match(/^(.*)\s+p(\d+)$/i)
const base = (match ? match[1] : trimmed).trim() || trimmed
const part = match ? Number(match[2]) + 1 : 2
const suffix = ` p${part}`
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
} }
export function sortFamilyParts(a: LibraryClip, b: LibraryClip) { export function sortFamilyParts(a: LibraryClip, b: LibraryClip) {
+2 -1
View File
@@ -951,7 +951,8 @@ async function startStudioExtendJob(item: StudioJob) {
promptPost: item.payload.promptPost, promptPost: item.payload.promptPost,
duration: item.payload.duration, duration: item.payload.duration,
loraStack: item.payload.loraStack || item.payload.loraName, loraStack: item.payload.loraStack || item.payload.loraName,
folderLocked: item.payload.folderLocked folderLocked: item.payload.folderLocked,
name: item.payload.name || item.name
}) })
await markStudioLive(item.ownerKey, item.id, live.id) await markStudioLive(item.ownerKey, item.id, live.id)
} catch (error) { } catch (error) {
+2 -2
View File
@@ -6,7 +6,7 @@ import { emitChainJob, waitForComfySocket, watchComfyJob } from '~/server/utils/
import { comfyFilenamePrefix, queuePrompt, uploadImage } from '~/server/utils/comfy' import { comfyFilenamePrefix, queuePrompt, uploadImage } from '~/server/utils/comfy'
import { buildWorkflow } from '~/server/utils/workflow' import { buildWorkflow } from '~/server/utils/workflow'
import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension, type VideoWorkflowId } from '~/utils/videoModels' import { isLtxWorkflow, isTextToVideo, parseVideoWorkflow, workflowForExtension, type VideoWorkflowId } from '~/utils/videoModels'
import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextClipPartName, removeExtendTemp, stillPath } from '~/server/utils/library' import { clipVideoPath, clipTitle, deleteRetryDraft, extendTempDir, getClip, nextFamilyPartName, removeExtendTemp, stillPath } from '~/server/utils/library'
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg' import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
import { ensureComfyReady } from '~/server/utils/comfyLifecycle' import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue' import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue'
@@ -574,7 +574,7 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
hideThumbnail: queue.hideThumbnail, hideThumbnail: queue.hideThumbnail,
hideInput: queue.hideInput, hideInput: queue.hideInput,
folderLocked: destFolderLocked, folderLocked: destFolderLocked,
name: nextClipPartName(clipTitle(source)), name: nextFamilyPartName(owner, source),
prompt: queue.segments[lastIndex]?.prompt || source.prompt, prompt: queue.segments[lastIndex]?.prompt || source.prompt,
aspect: queue.aspect, aspect: queue.aspect,
width: queue.width, width: queue.width,
+2 -1
View File
@@ -7,6 +7,7 @@ import { syncQueueFromJob } from '~/server/utils/shotQueue'
import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow' import { isEncodingNode, NODE_LABELS } from '~/server/utils/workflow'
import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg' import { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks' import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { nextFamilyPartName } from '~/server/utils/library'
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined { function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
const refs = mergePermanenceRefs(library.permanenceRefs, library.shotPermanenceRefs?.[library.chainIndex || 0]) const refs = mergePermanenceRefs(library.permanenceRefs, library.shotPermanenceRefs?.[library.chainIndex || 0])
@@ -252,7 +253,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
job.savedPromptId = job.promptId job.savedPromptId = job.promptId
job.hideThumbnail = clip.hideThumbnail job.hideThumbnail = clip.hideThumbnail
job.library.thumb = undefined job.library.thumb = undefined
job.library.name = nextClipPartName(clip.name) job.library.name = nextFamilyPartName(job.library.ownerKey, clip)
job.library.parentClipId = clip.id job.library.parentClipId = clip.id
await syncQueueFromJob(job, clip.id).catch(() => null) await syncQueueFromJob(job, clip.id).catch(() => null)
await purgeComfyArtifacts({ await purgeComfyArtifacts({
+99 -1
View File
@@ -68,7 +68,105 @@ export type LibraryEntry = {
} }
export function familyBaseName(name: string) { export function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '') 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) { export function clipFamilyKey(clip: LibraryClip) {