Use the parent clip last frame and new-segment join for extensions, not the stitched file's first frame. Show this account's library storage in Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 10:54:46 -05:00
co-authored by Cursor
parent 231c59a7a7
commit 0af3cddb14
16 changed files with 571 additions and 70 deletions
+37 -18
View File
@@ -290,8 +290,7 @@
@update-label="updateFamilyRefLabel"
/>
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
Name each still, or pick a GLOBAL LOCKS key. Shot prompts inherit those names.
The generate start still stays the first frame — attach it here only if you want a named alias.
MiniMax will not see these photos. Continuity is the last frame of the previous clip. The stills only name the lock lines.
</p>
<p class="mt-1 text-[11px] leading-relaxed text-zinc-500">{{ permanenceComfyNote }}</p>
<p v-if="familyRefLabelLine" class="mt-1 text-[11px] text-amber-200/80">{{ familyRefLabelLine }}</p>
@@ -515,18 +514,18 @@
</div>
<div v-if="videoEngine === 'minimax' && videoStart === 'still' && minimaxGraph === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm" :class="extraPersonLock ? 'cursor-not-allowed opacity-60' : ''">
<span>
<span class="block font-medium text-zinc-200">Use identity references</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses the start still as first-frame only. On keeps that still as Picture 1. Extra stills are optional identity views of the same person, not extra characters.</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses last-frame I2V after shot 1. On keeps the start still as Picture 1 — extra stills are more views of that same person, never a second character. A named extra person forces last-frame I2V.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch">
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch" :disabled="extraPersonLock">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<div v-if="useIdentityRefs" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div v-if="useIdentityRefs && !extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div
v-for="slot in 4"
:key="slot"
@@ -564,7 +563,8 @@
</button>
</div>
</div>
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them — extra views of that same person, not a second character or a vehicle. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Identity refs stay off while a second named person is attached. MiniMax will not see the extra stills; shot 2+ starts from the parent clip’s last frame.</p>
<p v-else-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still and stays Picture 1 on every shot, including queued extensions. Extra stills map to Picture 2–5 only if you add them — extra views of that same person, not a second character or a vehicle. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
</div>
</div>
@@ -1244,9 +1244,9 @@
:hidden="item.hideThumbnail"
:revealed="!!stitchReveal[item.id]"
:still-id="partIndex === 0 ? item.stillId : undefined"
:from-clip-id="previousChronoClipId(entry.group.parts, partIndex)"
:from-clip-id="previousClipIdFor(item, entry.group.parts, partIndex)"
:clip-id="item.id"
:show-first-frame="partIndex > 0 || !item.stillId"
:show-first-frame="partIndex > 0 || !item.stillId || Boolean(item.parentClipId)"
@update:revealed="stitchReveal[item.id] = $event"
/>
<div class="min-w-0 flex-1">
@@ -1541,10 +1541,12 @@ import {
composeShotPrompt,
extractGlobalLocks,
isBlankGlobalLocks,
mergePermanenceRefs,
nextPermanenceLabel,
parseLockBindingKeys,
permanenceLabels,
sanitizeRefLabel,
shouldForceLastFrameI2V,
type PermanenceRef
} from '~/utils/globalLocks'
import {
@@ -1890,11 +1892,18 @@ const familyRefLabelLine = computed(() => {
if (!labels.length) return ''
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.`
})
const extraPersonLock = computed(() => shouldForceLastFrameI2V(
mergePermanenceRefs(familyPermanenceRefs.value, Object.values(shotPermanenceRefs.value).flat()),
globalLocks.value
))
const permanenceComfyNote = computed(() => {
if (extraPersonLock.value) {
return 'A second named person keeps last-frame I2V on. MiniMax never sees the man/bench photos; those names only go in the inherit line.'
}
if (studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) {
return 'Identity-ref still only maps extra views of the start-still person, never a second character. Permanence stills name the lock text; they are not Picture 2–5.'
}
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures; they name what the lock text is talking about. Identity-ref still only maps extra views of the start-still person, never a second character.'
return 'Last-frame I2V does not send these stills to MiniMax as extra pictures. Continuity is the previous clip’s last frame; the names only go in the lock line.'
})
let videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null
@@ -2117,7 +2126,7 @@ const recommendedShots = computed(() => {
if (!recommendText.value || recommendBusy.value) return []
return parseShotScript(recommendText.value)
})
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value)
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
function composedShotPreview(text: string, index: number, shotN?: number) {
const composed = composeShotPrompt({
@@ -2322,9 +2331,16 @@ function chronologicalParts(parts: LibraryClip[]) {
})
}
function previousChronoClipId(parts: LibraryClip[], index: number) {
function previousClipIdFor(item: LibraryClip, parts: LibraryClip[], index: number) {
if (item.parentClipId) return item.parentClipId
if (index <= 0) return undefined
return chronologicalParts(parts)[index - 1]?.id
const ordered = chronologicalParts(parts)
const current = ordered[index]
const currentChain = current?.chainIndex || 0
for (let i = index - 1; i >= 0; i--) {
if ((ordered[i].chainIndex || 0) < currentChain) return ordered[i].id
}
return ordered[index - 1]?.id
}
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
@@ -2688,6 +2704,10 @@ watch(useIdentityRefs, (value) => {
} catch { /* ignore */ }
})
watch(extraPersonLock, (locked) => {
if (locked) useIdentityRefs.value = false
})
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
try {
localStorage.setItem(LOCKS_STORE, globalLocks.value)
@@ -3502,9 +3522,8 @@ async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
const index = Math.max(0, parts.findIndex(part => part.id === item.id))
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
if (isExtension) {
const previous = index > 0
? parts[index - 1]
: clips.value.find(clip => clip.id === item.parentClipId)
const previous = (item.parentClipId && clips.value.find(clip => clip.id === item.parentClipId))
|| (index > 0 ? parts[index - 1] : undefined)
if (previous) {
const blob = await $fetch<Blob>(`/api/library/clips/${previous.id}/last-frame`, { responseType: 'blob' })
readFile(new File([blob], 'last_frame.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
@@ -4401,8 +4420,8 @@ async function generate() {
body.append('sound', String(withSound.value))
body.append('name', clipName.value.trim())
body.append('workflow', videoWorkflow.value)
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !extraPersonLock.value) {
identityRefs.value.forEach((ref, index) => {
if (ref) body.append(`refImage${index}`, ref)
})