Add permanence refs opens picker, labels inject into INHERITS

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 09:20:44 -05:00
co-authored by Cursor
parent fb748e9b6e
commit adc583aad6
4 changed files with 112 additions and 45 deletions
+14 -4
View File
@@ -1,11 +1,11 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8" @click.self="emit('close')">
<div v-if="open" class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8" @click.self="emit('close')">
<div class="glass w-full max-w-3xl rounded-3xl p-5 sm:p-6">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">{{ heading || 'Choose an image' }}</h2>
<p class="mt-1 text-sm text-zinc-400">{{ tab === 'input' ? 'Uploaded stills only. Generated frames and edits live under Output.' : 'Generated stills from image edits. Video frames stay with their clips.' }}</p>
<p class="mt-1 text-sm text-zinc-400">{{ pickerHint }}</p>
</div>
<div class="flex items-center gap-2">
<button
@@ -167,14 +167,17 @@ export interface PickerStill {
name?: string
}
const props = defineProps<{
const props = withDefaults(defineProps<{
folders: PickerFolder[]
stills: PickerStill[]
folderId: string
hideInputPreview?: boolean
heading?: string
stillOnly?: boolean
}>()
open?: boolean
}>(), {
open: true
})
const emit = defineEmits<{
close: []
@@ -194,6 +197,7 @@ const revealedIds = ref<string[]>([])
const picking = ref(false)
function onKey(event: KeyboardEvent) {
if (!props.open) return
if (event.key === 'Escape') emit('close')
}
@@ -215,6 +219,12 @@ const visibleStills = computed(() => props.stills.filter((still) => {
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.'
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.'
})
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
function stillHidden(still: PickerStill) {
+12 -9
View File
@@ -1,6 +1,6 @@
<template>
<div>
<div v-if="label" class="mb-1.5 flex items-baseline justify-between gap-2">
<div v-if="label" class="mb-1.5 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<p class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">{{ label }}</p>
<p v-if="hint" class="text-[11px] text-zinc-600">{{ hint }}</p>
</div>
@@ -42,11 +42,12 @@
<button
v-if="!disabled && refs.length < max"
type="button"
class="flex h-12 w-12 flex-col items-center justify-center rounded-lg border border-dashed border-white/15 text-[10px] text-zinc-500 hover:border-amber-300/40 hover:text-amber-100"
class="flex min-h-12 items-center justify-center rounded-lg border border-dashed border-white/15 text-[10px] leading-tight text-zinc-400 hover:border-amber-300/40 hover:text-amber-100"
:class="refs.length ? 'h-12 w-12' : 'min-w-[8.5rem] px-2.5 py-1.5'"
:title="addTitle"
@click="emit('add')"
@click.stop="emit('addRef')"
>
+ Ref
{{ refs.length ? '+' : cta }}
</button>
</div>
</div>
@@ -55,7 +56,7 @@
<script setup lang="ts">
import { PERMANENCE_REF_LABELS, type PermanenceRef } from '~/utils/globalLocks'
const props = withDefaults(defineProps<{
withDefaults(defineProps<{
refs: PermanenceRef[]
label?: string
hint?: string
@@ -63,16 +64,18 @@ const props = withDefaults(defineProps<{
disabled?: boolean
max?: number
addTitle?: string
cta?: string
}>(), {
max: 6,
addTitle: 'Attach a permanence still'
addTitle: 'Choose a still to label',
cta: 'Add still'
})
defineEmits<{
add: []
const emit = defineEmits<{
addRef: []
remove: [index: number]
updateLabel: [index: number, label: string]
}>()
}>(['addRef', 'remove', 'updateLabel'])
function labelOptions(current: string) {
if (PERMANENCE_REF_LABELS.includes(current as typeof PERMANENCE_REF_LABELS[number])) return [...PERMANENCE_REF_LABELS]
+73 -23
View File
@@ -276,17 +276,27 @@
</details>
<div v-if="studioMode === 'video'" class="mt-3 space-y-3">
<GlobalLocksEditor v-model="globalLocks" />
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip
:refs="familyPermanenceRefs"
label="Permanence refs"
hint="Labels for object permanence. Last-frame I2V inherits them in the prompt; they are not extra Comfy identity pictures."
label="Permanence refs · all shots"
hint="Shared stills"
cta="Add permanence refs"
add-title="Choose a still to label for every shot"
:hidden="hideInputPreview"
@add="openPermanencePicker()"
@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.”
</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>
</div>
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 hidden space-y-2 md:block">
</div>
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
<div
v-for="(shot, index) in parsedShots"
@@ -301,10 +311,13 @@
<PermanenceRefStrip
class="mt-2"
:refs="shotPermanenceRefs[shot.n] || []"
hint="This shot"
label="This shot only"
hint="Extra stills"
cta="Add still"
add-title="Choose a still for this shot only"
:hidden="hideInputPreview"
:max="4"
@add="openPermanencePicker(shot.n)"
@add-ref="openPermanencePicker(shot.n)"
@remove="(refIndex) => removeShotRef(shot.n, refIndex)"
@update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)"
/>
@@ -339,10 +352,13 @@
<PermanenceRefStrip
class="mt-3"
:refs="shotPermanenceRefs[index + 2] || []"
hint="This extension"
label="This extension only"
hint="Extra stills"
cta="Add still"
add-title="Choose a still for this extension only"
:hidden="hideInputPreview"
:max="4"
@add="openPermanencePicker(index + 2)"
@add-ref="openPermanencePicker(index + 2)"
@remove="(refIndex) => removeShotRef(index + 2, refIndex)"
@update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)"
/>
@@ -545,7 +561,7 @@
</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. Empty slots are left out of the graph, not filled with copies of Picture 1.</p>
<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>
</div>
</div>
@@ -1180,8 +1196,9 @@
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
class="mt-2"
:refs="entry.group.head.permanenceRefs || []"
cta="Add still"
:hidden="entry.group.head.hideInput || hideInputPreview"
@add="openPermanencePicker(undefined, entry.group.head.id)"
@add-ref="openPermanencePicker(undefined, entry.group.head.id)"
@remove="(refIndex) => removeClipRef(entry.group.head, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)"
/>
@@ -1243,8 +1260,9 @@
v-if="!item.hideThumbnail"
class="mt-2"
:refs="item.permanenceRefs || []"
cta="Add still"
:hidden="item.hideInput || hideInputPreview"
@add="openPermanencePicker(undefined, item.id)"
@add-ref="openPermanencePicker(undefined, item.id)"
@remove="(refIndex) => removeClipRef(item, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)"
/>
@@ -1432,7 +1450,7 @@
@update:open="promptDrawerOpen = $event"
/>
<ImagePickerModal
v-if="pickerOpen"
:open="pickerOpen"
:folders="folders"
:stills="stills"
:folder-id="folderId"
@@ -1520,6 +1538,7 @@ import {
extractGlobalLocks,
isBlankGlobalLocks,
nextPermanenceLabel,
permanenceLabels,
type PermanenceRef
} from '~/utils/globalLocks'
import {
@@ -1830,6 +1849,7 @@ const studioJobs = ref<Array<{
}>>([])
const outputStudioJobId = ref('')
const pickerOpen = ref(false)
const pickerKind = ref<'image' | 'permanence'>('image')
const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
const selectedKeys = ref<string[]>([])
@@ -1851,12 +1871,23 @@ 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 (pickerSlot.value === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
if (pickerSlot.value === 'editRef') return 'Choose image 2'
if (typeof pickerSlot.value === 'string') return 'Choose a permanence still'
return `Choose Picture ${Number(pickerSlot.value) + 2}`
})
const permanencePicker = computed(() => typeof pickerSlot.value === 'string' && pickerSlot.value !== 'main' && pickerSlot.value !== 'editRef')
const permanencePicker = computed(() => pickerKind.value === 'permanence')
const familyRefLabelLine = computed(() => {
const labels = permanenceLabels(familyPermanenceRefs.value)
if (!labels.length) return ''
return `In the prompt: Permanence: ${labels.join(', ')} stay the same.`
})
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 'Last-frame I2V: these refs are prompt anchors (and last-frame continuity), not extra MiniMax pictures. Turn on identity-ref only for one person.'
})
let videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null
let videoSource: EventSource | null = null
@@ -2865,15 +2896,21 @@ function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) {
}
function openPicker(slot: 'main' | 'editRef' | number = 'main') {
pickerKind.value = 'image'
pickerSlot.value = slot
void nextTick(() => {
pickerOpen.value = true
})
}
function openPermanencePicker(shot?: number, clipId?: string) {
pickerKind.value = 'permanence'
if (clipId) pickerSlot.value = `clip:${clipId}`
else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}`
else pickerSlot.value = 'perm'
void nextTick(() => {
pickerOpen.value = true
})
}
function addPermanenceRef(stillId: string, shot?: number) {
@@ -2928,21 +2965,29 @@ function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
}
function onPickedPermanence(still: { id: string }) {
if (pickerKind.value !== 'permanence') return
const slot = pickerSlot.value
if (slot === 'main' || slot === 'editRef' || typeof slot === 'number') return
pickerOpen.value = false
if (typeof slot === 'string' && slot.startsWith('clip:')) {
if (typeof slot !== 'string' || slot === 'main' || slot === 'editRef') return
if (slot.startsWith('clip:')) {
const clipId = slot.slice(5)
const clip = clips.value.find(item => item.id === clipId)
if (!clip) return
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) return
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
if (!clip) {
pickerOpen.value = false
return
}
if (typeof slot === 'string' && (slot === 'perm' || slot.startsWith('perm:'))) {
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) {
pickerOpen.value = false
return
}
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
pickerOpen.value = false
return
}
if (slot === 'perm' || slot.startsWith('perm:')) {
const shot = slot === 'perm' ? undefined : Number(slot.slice(5))
addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined)
}
pickerOpen.value = false
}
function setEditRef(next: File) {
@@ -2979,9 +3024,14 @@ function clearIdentityRef(index: number) {
identityPreviews.value = previews
}
function onPickedImage(next: File, hidden = false) {
const slot = pickerSlot.value
function onPickedImage(next: File, hidden = false, stillId?: string) {
if (pickerKind.value === 'permanence') {
if (stillId) onPickedPermanence({ id: stillId })
else pickerOpen.value = false
return
}
pickerOpen.value = false
const slot = pickerSlot.value
if (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return
if (slot === 'editRef') {
setEditRef(next)
+6 -2
View File
@@ -94,15 +94,19 @@ function extractState(locks: string) {
return String(match?.[1] || '').replace(/\s+/g, ' ').trim()
}
export function permanenceLabels(refs: PermanenceRef[] | null | undefined) {
return [...new Set(normalizePermanenceRefs(refs).map(item => item.label).filter(Boolean))]
}
function permanenceLine(refs: PermanenceRef[]) {
const labels = [...new Set(refs.map(item => item.label).filter(Boolean))]
const labels = permanenceLabels(refs)
if (!labels.length) return ''
return `Permanence: ${labels.join(', ')} stay the same.`
}
function inheritLine(locks: string, refs: PermanenceRef[]) {
const parts = ['[INHERITS] Global locks.']
const labels = [...new Set(refs.map(item => item.label).filter(Boolean))]
const labels = permanenceLabels(refs)
if (labels.length) parts.push(`Same ${labels.join(', ')} as locked.`)
const state = extractState(locks)
if (state) parts.push(`State: ${state}`)