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
+7 -1
View File
@@ -5,6 +5,7 @@ import { readLoraStack } from '~/utils/loras'
import { parseExtendDuration } from '~/server/utils/extendChain'
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
import { composePromptParts } from '~/utils/promptParts'
import { nextFamilyPartName } from '~/server/utils/library'
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(() => ({}))
@@ -45,6 +46,10 @@ export default defineEventHandler(async (event) => {
const turbo = source.turbo !== false
const steps = Number(source.steps || defaultVideoSteps(turbo, workflow))
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({
ownerKey: owner,
@@ -54,7 +59,7 @@ export default defineEventHandler(async (event) => {
promptMid: prompt,
promptPre,
promptPost,
name: nextClipPartName(clipTitle(source)),
name: nextFamilyPartName(owner, source, reservedNames),
folderId: source.folderId,
aspect: source.aspect || '16:9',
width: source.width || 1344,
@@ -95,6 +100,7 @@ export default defineEventHandler(async (event) => {
queued: !liveJobId,
clientId: '',
seed,
name: studio.name,
width: source.width || 1344,
height: source.height || 768,
steps,
+3 -2
View File
@@ -12,7 +12,7 @@ import {
clipVideoPath,
extendTempDir,
getClip,
nextClipPartName,
nextFamilyPartName,
removeExtendTemp
} from '~/server/utils/library'
import { extractLastFrame, probeHasAudio } from '~/server/utils/ffmpeg'
@@ -63,6 +63,7 @@ export async function beginExtendFromClip(params: {
duration?: number
loraStack?: unknown
folderLocked?: boolean
name?: string
}) {
const source = getClip(params.ownerKey, params.clipId)
const sourcePath = clipVideoPath(params.ownerKey, source.id)
@@ -104,7 +105,7 @@ export async function beginExtendFromClip(params: {
const length = frameLength(durationSeconds, fps)
const width = source.width || 1344
const height = source.height || 768
const clipName = nextClipPartName(clipTitle(source))
const clipName = String(params.name || '').trim() || nextFamilyPartName(params.ownerKey, source)
const job = createJob()
job.kind = 'video'
+7 -12
View File
@@ -18,6 +18,9 @@ import {
type KeepPromptKind,
type KeepPromptLists
} from '~/utils/keepPrompts'
import { familyBaseName, nextClipPartName, clipPartLabel } from '~/utils/libraryBrowse'
export { familyBaseName, nextClipPartName, clipPartLabel }
const scryptAsync = promisify(scrypt)
@@ -327,18 +330,10 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
return fromPrompt || 'Untitled clip'
}
export function familyBaseName(name: string) {
return String(name || '').trim().replace(/\s+p\d+$/i, '')
}
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 nextFamilyPartName(owner: string, source: LibraryClip, extraNames: string[] = []) {
const catalog = readCatalog(owner)
const family = clipsInSameFamily(catalog, source)
return nextClipPartName(clipTitle(source), [...family.map(item => clipTitle(item)), ...extraNames])
}
export function sortFamilyParts(a: LibraryClip, b: LibraryClip) {
+2 -1
View File
@@ -951,7 +951,8 @@ async function startStudioExtendJob(item: StudioJob) {
promptPost: item.payload.promptPost,
duration: item.payload.duration,
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)
} 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 { buildWorkflow } from '~/server/utils/workflow'
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 { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { onLiveVideoSettled, onStudioQueueBurstStarted } from '~/server/utils/studioQueue'
@@ -574,7 +574,7 @@ export async function startQueueBurst(owner: string, queueId: string, count: num
hideThumbnail: queue.hideThumbnail,
hideInput: queue.hideInput,
folderLocked: destFolderLocked,
name: nextClipPartName(clipTitle(source)),
name: nextFamilyPartName(owner, source),
prompt: queue.segments[lastIndex]?.prompt || source.prompt,
aspect: queue.aspect,
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 { extractFirstFrameFromBuffer } from '~/server/utils/ffmpeg'
import { mergePermanenceRefs, type PermanenceRef } from '~/utils/globalLocks'
import { nextFamilyPartName } from '~/server/utils/library'
function clipRefsFromJob(library: NonNullable<Job['library']>): PermanenceRef[] | undefined {
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.hideThumbnail = clip.hideThumbnail
job.library.thumb = undefined
job.library.name = nextClipPartName(clip.name)
job.library.name = nextFamilyPartName(job.library.ownerKey, clip)
job.library.parentClipId = clip.id
await syncQueueFromJob(job, clip.id).catch(() => null)
await purgeComfyArtifacts({