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:
+134
-91
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user