Bind permanence stills to named lock keys instead of coded categories.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 09:52:27 -05:00
co-authored by Cursor
parent 727d895c10
commit 231c59a7a7
4 changed files with 89 additions and 44 deletions
+1 -1
View File
@@ -220,7 +220,7 @@ const emptyLabel = computed(() => tab.value === 'input'
? 'No uploaded stills in this folder yet.'
: 'No generated stills in this folder yet.')
const pickerHint = computed(() => {
if (props.stillOnly) return 'Pick a still, then label it Subject / Vehicle / Interior / Object on the strip. That name is what the shot prompt inherits.'
if (props.stillOnly) return 'Pick a still, then type a short name on the strip (or pick a GLOBAL LOCKS key). That name is what the shot prompt inherits.'
return tab.value === 'input'
? 'Uploaded stills only. Generated frames and edits live under Output.'
: 'Generated stills from image edits. Video frames stay with their clips.'
+32 -12
View File
@@ -8,7 +8,7 @@
<div
v-for="(item, index) in refs"
:key="item.stillId + '-' + index"
class="flex w-14 flex-col items-center gap-0.5"
class="flex w-16 flex-col items-center gap-0.5"
>
<span class="relative h-12 w-12 overflow-hidden rounded-lg bg-zinc-900">
<span v-if="hidden" class="sensitive-glow block h-full w-full" />
@@ -30,15 +30,21 @@
×
</button>
</span>
<select
class="w-full truncate rounded border border-white/10 bg-zinc-950 px-0.5 py-0 text-[10px] text-zinc-400"
<input
class="w-full truncate rounded border border-white/10 bg-zinc-950 px-0.5 py-0 text-center text-[10px] text-zinc-400 outline-none focus:border-amber-300/40"
:disabled="disabled"
:value="item.label"
@change="emit('updateLabel', index, ($event.target as HTMLSelectElement).value)"
:list="nameListId"
:title="item.label || 'Name'"
placeholder="Name"
maxlength="24"
autocomplete="off"
@input="emit('updateLabel', index, sanitizeRefLabel(($event.target as HTMLInputElement).value))"
>
<option v-for="option in labelOptions(item.label)" :key="option" :value="option">{{ option }}</option>
</select>
</div>
<datalist :id="nameListId">
<option v-for="option in listNames" :key="option" :value="option" />
</datalist>
<button
v-if="!disabled && refs.length < max"
type="button"
@@ -54,10 +60,12 @@
</template>
<script setup lang="ts">
import { PERMANENCE_REF_LABELS, type PermanenceRef } from '~/utils/globalLocks'
import { computed, useId } from 'vue'
import { sanitizeRefLabel, type PermanenceRef } from '~/utils/globalLocks'
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
refs: PermanenceRef[]
nameOptions?: string[]
label?: string
hint?: string
hidden?: boolean
@@ -66,8 +74,9 @@ withDefaults(defineProps<{
addTitle?: string
cta?: string
}>(), {
nameOptions: () => [],
max: 6,
addTitle: 'Choose a still to label',
addTitle: 'Choose a still to name',
cta: 'Add still'
})
@@ -77,8 +86,19 @@ const emit = defineEmits<{
updateLabel: [index: number, label: string]
}>()
function labelOptions(current: string) {
if (PERMANENCE_REF_LABELS.includes(current as typeof PERMANENCE_REF_LABELS[number])) return [...PERMANENCE_REF_LABELS]
return [...PERMANENCE_REF_LABELS, current]
const nameListId = useId()
const listNames = computed(() => {
const seen = new Set<string>()
const names: string[] = []
for (const name of [...props.nameOptions, ...props.refs.map(item => item.label)]) {
const trimmed = String(name || '').trim()
if (!trimmed) continue
const id = trimmed.toLowerCase()
if (seen.has(id)) continue
seen.add(id)
names.push(trimmed)
}
return names
})
</script>
+28 -15
View File
@@ -279,18 +279,19 @@
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip
:refs="familyPermanenceRefs"
:name-options="lockBindingKeys"
label="Permanence refs · all shots"
hint="Shared stills"
hint="Name + still"
cta="Add permanence refs"
add-title="Choose a still to label for every shot"
add-title="Choose a still to name for every shot"
:hidden="hideInputPreview"
@add-ref="openPermanencePicker()"
@remove="removeFamilyRef"
@update-label="updateFamilyRefLabel"
/>
<p class="mt-2 text-[11px] leading-relaxed text-zinc-500">
Add stills, then label each Subject / Vehicle / Interior / Object. Those names are injected into locks and
<span class="text-zinc-300">[INHERITS]</span>, so a shot can say “same Subject, same Vehicle.”
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.
</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>
@@ -311,10 +312,11 @@
<PermanenceRefStrip
class="mt-2"
:refs="shotPermanenceRefs[shot.n] || []"
:name-options="lockBindingKeys"
label="This shot only"
hint="Extra stills"
cta="Add still"
add-title="Choose a still for this shot only"
add-title="Choose a still to name for this shot only"
:hidden="hideInputPreview"
:max="4"
@add-ref="openPermanencePicker(shot.n)"
@@ -352,10 +354,11 @@
<PermanenceRefStrip
class="mt-3"
:refs="shotPermanenceRefs[index + 2] || []"
:name-options="lockBindingKeys"
label="This extension only"
hint="Extra stills"
cta="Add still"
add-title="Choose a still for this extension only"
add-title="Choose a still to name for this extension only"
:hidden="hideInputPreview"
:max="4"
@add-ref="openPermanencePicker(index + 2)"
@@ -1189,6 +1192,7 @@
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
class="mt-2"
:refs="entry.group.head.permanenceRefs || []"
:name-options="clipBindingKeys(entry.group.head)"
cta="Add still"
:hidden="entry.group.head.hideInput || hideInputPreview"
@add-ref="openPermanencePicker(undefined, entry.group.head.id)"
@@ -1263,6 +1267,7 @@
v-if="!item.hideThumbnail"
class="mt-2"
:refs="item.permanenceRefs || []"
:name-options="clipBindingKeys(item, entry.group.head)"
cta="Add still"
:hidden="item.hideInput || hideInputPreview"
@add-ref="openPermanencePicker(undefined, item.id)"
@@ -1537,7 +1542,9 @@ import {
extractGlobalLocks,
isBlankGlobalLocks,
nextPermanenceLabel,
parseLockBindingKeys,
permanenceLabels,
sanitizeRefLabel,
type PermanenceRef
} from '~/utils/globalLocks'
import {
@@ -1871,12 +1878,13 @@ const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
const LOCKS_STORE = 'aigen-global-locks'
const REFS_STORE = 'aigen-permanence-refs'
const pickerHeading = computed(() => {
if (pickerKind.value === 'permanence') return 'Choose a permanence still'
if (pickerKind.value === 'permanence') return 'Choose a still to name'
if (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
if (pickerSlot.value === 'editRef') return 'Choose image 2'
return `Choose Picture ${Number(pickerSlot.value) + 2}`
})
const permanencePicker = computed(() => pickerKind.value === 'permanence')
const lockBindingKeys = computed(() => parseLockBindingKeys(globalLocks.value))
const familyRefLabelLine = computed(() => {
const labels = permanenceLabels(familyPermanenceRefs.value)
if (!labels.length) return ''
@@ -1884,9 +1892,9 @@ const familyRefLabelLine = computed(() => {
})
const permanenceComfyNote = computed(() => {
if (studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value) {
return 'Identity-ref is on: Subject stills can map to Picture 2–5 as extra views of the same person only — never a second character or a vehicle.'
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: these refs are prompt anchors (and last-frame continuity), not extra MiniMax pictures. Turn on identity-ref only for one person.'
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.'
})
let videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null
@@ -2928,15 +2936,20 @@ function openPermanencePicker(shot?: number, clipId?: string) {
})
}
function clipBindingKeys(clip: LibraryClip, fallback?: LibraryClip) {
return parseLockBindingKeys(clip.globalLocks || fallback?.globalLocks || '')
}
function addPermanenceRef(stillId: string, shot?: number) {
const keys = lockBindingKeys.value
if (typeof shot === 'number') {
const current = shotPermanenceRefs.value[shot] || []
if (current.some(item => item.stillId === stillId) || current.length >= 4) return
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: [...current, { stillId, label: nextPermanenceLabel(current) }] }
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: [...current, { stillId, label: nextPermanenceLabel([...familyPermanenceRefs.value, ...current], keys) }] }
return
}
if (familyPermanenceRefs.value.some(item => item.stillId === stillId) || familyPermanenceRefs.value.length >= 6) return
familyPermanenceRefs.value = [...familyPermanenceRefs.value, { stillId, label: nextPermanenceLabel(familyPermanenceRefs.value) }]
familyPermanenceRefs.value = [...familyPermanenceRefs.value, { stillId, label: nextPermanenceLabel(familyPermanenceRefs.value, keys) }]
}
function removeFamilyRef(index: number) {
@@ -2944,7 +2957,7 @@ function removeFamilyRef(index: number) {
}
function updateFamilyRefLabel(index: number, label: string) {
familyPermanenceRefs.value = familyPermanenceRefs.value.map((item, i) => i === index ? { ...item, label } : item)
familyPermanenceRefs.value = familyPermanenceRefs.value.map((item, i) => i === index ? { ...item, label: sanitizeRefLabel(label) } : item)
}
function removeShotRef(shot: number, index: number) {
@@ -2956,7 +2969,7 @@ function removeShotRef(shot: number, index: number) {
}
function updateShotRefLabel(shot: number, index: number, label: string) {
const current = (shotPermanenceRefs.value[shot] || []).map((item, i) => i === index ? { ...item, label } : item)
const current = (shotPermanenceRefs.value[shot] || []).map((item, i) => i === index ? { ...item, label: sanitizeRefLabel(label) } : item)
shotPermanenceRefs.value = { ...shotPermanenceRefs.value, [shot]: current }
}
@@ -2976,7 +2989,7 @@ function removeClipRef(clip: LibraryClip, index: number) {
}
function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
void persistClipRefs(clip, (clip.permanenceRefs || []).map((item, i) => i === index ? { ...item, label } : item))
void persistClipRefs(clip, (clip.permanenceRefs || []).map((item, i) => i === index ? { ...item, label: sanitizeRefLabel(label) } : item))
}
function onPickedPermanence(still: { id: string }) {
@@ -2994,7 +3007,7 @@ function onPickedPermanence(still: { id: string }) {
pickerOpen.value = false
return
}
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || [], parseLockBindingKeys(clip.globalLocks || '')) }])
pickerOpen.value = false
return
}
+27 -15
View File
@@ -1,7 +1,3 @@
export const PERMANENCE_REF_LABELS = ['Subject', 'Vehicle', 'Interior', 'Object'] as const
export type PermanenceRefLabel = (typeof PERMANENCE_REF_LABELS)[number]
export interface PermanenceRef {
stillId: string
label: string
@@ -9,8 +5,6 @@ export interface PermanenceRef {
export const DEFAULT_GLOBAL_LOCKS = `[GLOBAL LOCKS]
Subject:
Vehicle:
Interior:
Object:
Extras:
Camera:
@@ -18,7 +12,28 @@ Speech:
Never:
State:`
const LOCK_FIELD = /^(Subject|Vehicle|Interior|Object|Extras|Camera|Speech|Never|State)\s*:?\s*/i
const KNOWN_LOCK_KEYS = 'Subject|Vehicle|Interior|Object|Extras|Camera|Speech|Never|State'
const LOCK_FIELD = new RegExp(`^(?:(?:${KNOWN_LOCK_KEYS})\\s*:?|[A-Za-z][A-Za-z0-9 _-]{0,22}\\s*:)\\s*`, 'i')
const LOCK_KEY_LINE = /^([A-Za-z][A-Za-z0-9 _-]{0,22})\s*:/
const SKIP_BINDING_KEYS = /^(speech|never|state)$/i
export function parseLockBindingKeys(text: string): string[] {
const keys: string[] = []
const seen = new Set<string>()
for (const line of String(text || '').replace(/\r\n/g, '\n').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('[')) continue
const match = trimmed.match(LOCK_KEY_LINE)
if (!match) continue
const key = match[1].replace(/\s+/g, ' ').trim()
if (!key || SKIP_BINDING_KEYS.test(key)) continue
const id = key.toLowerCase()
if (seen.has(id)) continue
seen.add(id)
keys.push(key)
}
return keys
}
export function normalizePermanenceRefs(raw: unknown, limit = 8): PermanenceRef[] {
if (!Array.isArray(raw)) return []
@@ -36,15 +51,12 @@ export function normalizePermanenceRefs(raw: unknown, limit = 8): PermanenceRef[
}
export function sanitizeRefLabel(raw: unknown) {
const label = String(raw || '').trim().slice(0, 24)
if (!label) return 'Object'
const match = PERMANENCE_REF_LABELS.find(item => item.toLowerCase() === label.toLowerCase())
return match || label
return String(raw || '').replace(/\s+/g, ' ').trim().slice(0, 24)
}
export function nextPermanenceLabel(existing: PermanenceRef[]) {
const used = new Set(existing.map(item => item.label))
return PERMANENCE_REF_LABELS.find(label => !used.has(label)) || 'Object'
export function nextPermanenceLabel(existing: PermanenceRef[], lockKeys: string[] = []) {
const used = new Set(existing.map(item => item.label.trim().toLowerCase()).filter(Boolean))
return lockKeys.find(key => !used.has(key.trim().toLowerCase())) || ''
}
export function mergePermanenceRefs(family?: PermanenceRef[] | null, shot?: PermanenceRef[] | null) {
@@ -90,7 +102,7 @@ export function resolveGlobalLocks(locks: string, firstPrompt: string) {
}
function extractState(locks: string) {
const match = String(locks || '').match(/(?:^|\n)\s*State:\s*(.+?)(?=\n\s*(?:Subject|Vehicle|Interior|Object|Extras|Camera|Speech|Never)\s*:|\n\s*\[|$)/is)
const match = String(locks || '').match(/(?:^|\n)\s*State:\s*(.+?)(?=\n\s*[A-Za-z][A-Za-z0-9 _-]{0,22}\s*:|\n\s*\[|$)/is)
return String(match?.[1] || '').replace(/\s+/g, ' ').trim()
}