Keep last-frame continuity when character stills are attached.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 08:21:57 -05:00
co-authored by Cursor
parent 5d828afbd7
commit 0fa64ea765
10 changed files with 29 additions and 51 deletions
+14 -34
View File
@@ -273,10 +273,10 @@
/>
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
Paste a block, or load a saved Director script. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider. Global locks are inherited; each shot should add one beat.
<span v-if="identityPrompting"> The Comfy identity prefix is prepended to every shot, and the same Picture 1 / extra stills are sent with each shot.</span>
<span v-if="identityPrompting"> Character stills ride along as extra pictures. Picture 1 is the current frame (last frame after shot 1), not the original start still.</span>
</p>
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
Write camera, action, and audio. That is the dynamic action prompt. We concatenate the <span class="font-medium text-zinc-300">&lt;Picture 1&gt;</span> identity prefix in front of it and send the result to <span class="font-medium text-zinc-300">Reference to Video</span>, not Image to Video.
Write camera, action, and audio. Picture 1 is the current frame. Extra stills lock face and body only — they do not reset clothes. MiniMax still cannot hard-lock a last frame and character refs in one node, so this path is Reference to Video with the last frame as Picture 1.
</p>
<div
class="grid gap-3"
@@ -667,25 +667,18 @@
:class="minimaxGraph === 'v2' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="minimaxGraph = 'v2'"
>
<span class="block font-semibold">v2 · identity refs</span>
<span class="text-xs text-zinc-400">Optional lock with up to 4 stills</span>
<span class="block font-semibold">v2 · last frame + stills</span>
<span class="text-xs text-zinc-400">Optional character stills. Last frame stays the start.</span>
</button>
</div>
</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" :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 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" :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 && !extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div>
<p class="text-sm font-medium text-zinc-200">Character stills</p>
<p class="mt-0.5 text-xs text-zinc-500">Optional. Extra views of the same person — face, hair, body. Shot 2+ still starts from the last frame (clothes and pose stay). Empty slots are left out. A named extra person turns these off.</p>
</div>
<div v-if="!extraPersonLock" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div
v-for="slot in 4"
:key="slot"
@@ -723,8 +716,8 @@
</button>
</div>
</div>
<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>
<p v-if="extraPersonLock" class="text-xs text-zinc-500">Character stills stay off while a second named person is attached. Shot 2+ starts from the last frame.</p>
<p v-else class="text-xs text-zinc-500">Picture 1 is the start still on shot 1 and the last frame after that. Extra stills are Picture 2–5. They do not put the start-still outfit back on.</p>
</div>
</div>
@@ -2449,7 +2442,6 @@ const imageV2LoraOptions = computed(() => mergeLoraOptions(
filterLorasForImageEngine(imageLoras.value, v2Engine.value),
filterLorasForImageEngine(imageV2LoraStack.value.map(item => item.name), v2Engine.value)
))
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
const globalLocks = ref('')
@@ -2474,12 +2466,13 @@ const extraPersonLock = computed(() => shouldForceLastFrameI2V(
mergePermanenceRefs(familyPermanenceRefs.value, Object.values(shotPermanenceRefs.value).flat()),
globalLocks.value
))
const useIdentityRefs = computed(() => !extraPersonLock.value && identityRefs.value.some(Boolean))
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 'Character stills are extra views of the same person. Picture 1 is the last frame after shot 1. Permanence stills only name the lock text.'
}
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.'
})
@@ -2667,7 +2660,7 @@ const videoModelHint = computed(() => {
: 'PinkCherry image-to-video. Distilled LoRA stays on. Silent MP4 — LTX has no audio VAE here.'
}
if (textToVideo.value) return 'MiniMax H3 text-to-video with native stereo audio. Shot 2+ switches to last-frame image-to-video.'
if (videoWorkflow.value === 'v2') return 'MiniMax H3 image-to-video. Identity refs stay on v2 with a start still.'
if (videoWorkflow.value === 'v2') return 'MiniMax H3 image-to-video. Shot 2+ starts from the last frame. Character stills are optional extras.'
return 'MiniMax H3 image-to-video with native stereo audio.'
})
const frameCount = computed(() => Math.max(5, Math.floor(duration.value * fps.value)))
@@ -3320,7 +3313,6 @@ onMounted(async () => {
applyEngineDefaults('ltx')
}
} catch { /* ignore */ }
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
videoLoraStack.value = readStoredLoraStack('aigen-video-lora')
imageLoraStack.value = readStoredLoraStack('aigen-image-lora')
imageV2LoraStack.value = readStoredLoraStack('aigen-image-v2-lora')
@@ -3469,16 +3461,6 @@ watch(videoStart, (value) => {
} catch { /* ignore */ }
})
watch(useIdentityRefs, (value) => {
try {
localStorage.setItem('aigen-use-identity-refs', String(value))
} catch { /* ignore */ }
})
watch(extraPersonLock, (locked) => {
if (locked) useIdentityRefs.value = false
})
watch([globalLocks, familyPermanenceRefs, shotPermanenceRefs], () => {
try {
localStorage.setItem(LOCKS_STORE, globalLocks.value)
@@ -4493,7 +4475,6 @@ async function restoreStudioJob(id: string) {
}
if (typeof payload.sound === 'boolean') withSound.value = payload.sound
shotScriptMode.value = extensions.length > 0
useIdentityRefs.value = payload.useIdentityRefs === true
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
extensionQueue.value = extensions.map(item => ({
id: crypto.randomUUID(),
@@ -4749,7 +4730,6 @@ async function rerun(item: LibraryClip, collection = false) {
applyVideoWorkflow(target.workflow)
if (typeof target.sound === 'boolean') withSound.value = target.sound
shotScriptMode.value = restoreAll
useIdentityRefs.value = false
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
const ordered = chronologicalParts(parts)
globalLocks.value = ordered.find(part => part.globalLocks)?.globalLocks || target.globalLocks || ''