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> <template>
<Teleport to="body"> <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="glass w-full max-w-3xl rounded-3xl p-5 sm:p-6">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<h2 class="font-display text-2xl font-bold">{{ heading || 'Choose an image' }}</h2> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
@@ -167,14 +167,17 @@ export interface PickerStill {
name?: string name?: string
} }
const props = defineProps<{ const props = withDefaults(defineProps<{
folders: PickerFolder[] folders: PickerFolder[]
stills: PickerStill[] stills: PickerStill[]
folderId: string folderId: string
hideInputPreview?: boolean hideInputPreview?: boolean
heading?: string heading?: string
stillOnly?: boolean stillOnly?: boolean
}>() open?: boolean
}>(), {
open: true
})
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
@@ -194,6 +197,7 @@ const revealedIds = ref<string[]>([])
const picking = ref(false) const picking = ref(false)
function onKey(event: KeyboardEvent) { function onKey(event: KeyboardEvent) {
if (!props.open) return
if (event.key === 'Escape') emit('close') if (event.key === 'Escape') emit('close')
} }
@@ -215,6 +219,12 @@ const visibleStills = computed(() => props.stills.filter((still) => {
const emptyLabel = computed(() => tab.value === 'input' const emptyLabel = computed(() => tab.value === 'input'
? 'No uploaded stills in this folder yet.' ? 'No uploaded stills in this folder yet.'
: 'No generated 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))) const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
function stillHidden(still: PickerStill) { function stillHidden(still: PickerStill) {
+12 -9
View File
@@ -1,6 +1,6 @@
<template> <template>
<div> <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 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> <p v-if="hint" class="text-[11px] text-zinc-600">{{ hint }}</p>
</div> </div>
@@ -42,11 +42,12 @@
<button <button
v-if="!disabled && refs.length < max" v-if="!disabled && refs.length < max"
type="button" 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" :title="addTitle"
@click="emit('add')" @click.stop="emit('addRef')"
> >
+ Ref {{ refs.length ? '+' : cta }}
</button> </button>
</div> </div>
</div> </div>
@@ -55,7 +56,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PERMANENCE_REF_LABELS, type PermanenceRef } from '~/utils/globalLocks' import { PERMANENCE_REF_LABELS, type PermanenceRef } from '~/utils/globalLocks'
const props = withDefaults(defineProps<{ withDefaults(defineProps<{
refs: PermanenceRef[] refs: PermanenceRef[]
label?: string label?: string
hint?: string hint?: string
@@ -63,16 +64,18 @@ const props = withDefaults(defineProps<{
disabled?: boolean disabled?: boolean
max?: number max?: number
addTitle?: string addTitle?: string
cta?: string
}>(), { }>(), {
max: 6, max: 6,
addTitle: 'Attach a permanence still' addTitle: 'Choose a still to label',
cta: 'Add still'
}) })
defineEmits<{ const emit = defineEmits<{
add: [] addRef: []
remove: [index: number] remove: [index: number]
updateLabel: [index: number, label: string] updateLabel: [index: number, label: string]
}>() }>(['addRef', 'remove', 'updateLabel'])
function labelOptions(current: string) { function labelOptions(current: string) {
if (PERMANENCE_REF_LABELS.includes(current as typeof PERMANENCE_REF_LABELS[number])) return [...PERMANENCE_REF_LABELS] 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> </details>
<div v-if="studioMode === 'video'" class="mt-3 space-y-3"> <div v-if="studioMode === 'video'" class="mt-3 space-y-3">
<GlobalLocksEditor v-model="globalLocks" /> <GlobalLocksEditor v-model="globalLocks" />
<div class="rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2.5">
<PermanenceRefStrip <PermanenceRefStrip
:refs="familyPermanenceRefs" :refs="familyPermanenceRefs"
label="Permanence refs" label="Permanence refs · all shots"
hint="Labels for object permanence. Last-frame I2V inherits them in the prompt; they are not extra Comfy identity pictures." hint="Shared stills"
cta="Add permanence refs"
add-title="Choose a still to label for every shot"
:hidden="hideInputPreview" :hidden="hideInputPreview"
@add="openPermanencePicker()" @add-ref="openPermanencePicker()"
@remove="removeFamilyRef" @remove="removeFamilyRef"
@update-label="updateFamilyRefLabel" @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>
<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> <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 <div
v-for="(shot, index) in parsedShots" v-for="(shot, index) in parsedShots"
@@ -301,10 +311,13 @@
<PermanenceRefStrip <PermanenceRefStrip
class="mt-2" class="mt-2"
:refs="shotPermanenceRefs[shot.n] || []" :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" :hidden="hideInputPreview"
:max="4" :max="4"
@add="openPermanencePicker(shot.n)" @add-ref="openPermanencePicker(shot.n)"
@remove="(refIndex) => removeShotRef(shot.n, refIndex)" @remove="(refIndex) => removeShotRef(shot.n, refIndex)"
@update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)" @update-label="(refIndex, label) => updateShotRefLabel(shot.n, refIndex, label)"
/> />
@@ -339,10 +352,13 @@
<PermanenceRefStrip <PermanenceRefStrip
class="mt-3" class="mt-3"
:refs="shotPermanenceRefs[index + 2] || []" :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" :hidden="hideInputPreview"
:max="4" :max="4"
@add="openPermanencePicker(index + 2)" @add-ref="openPermanencePicker(index + 2)"
@remove="(refIndex) => removeShotRef(index + 2, refIndex)" @remove="(refIndex) => removeShotRef(index + 2, refIndex)"
@update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)" @update-label="(refIndex, label) => updateShotRefLabel(index + 2, refIndex, label)"
/> />
@@ -545,7 +561,7 @@
</button> </button>
</div> </div>
</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>
</div> </div>
@@ -1180,8 +1196,9 @@
v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail" v-if="entry.group.parts.length === 1 && !entry.group.head.hideThumbnail"
class="mt-2" class="mt-2"
:refs="entry.group.head.permanenceRefs || []" :refs="entry.group.head.permanenceRefs || []"
cta="Add still"
:hidden="entry.group.head.hideInput || hideInputPreview" :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)" @remove="(refIndex) => removeClipRef(entry.group.head, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)" @update-label="(refIndex, label) => updateClipRefLabel(entry.group.head, refIndex, label)"
/> />
@@ -1243,8 +1260,9 @@
v-if="!item.hideThumbnail" v-if="!item.hideThumbnail"
class="mt-2" class="mt-2"
:refs="item.permanenceRefs || []" :refs="item.permanenceRefs || []"
cta="Add still"
:hidden="item.hideInput || hideInputPreview" :hidden="item.hideInput || hideInputPreview"
@add="openPermanencePicker(undefined, item.id)" @add-ref="openPermanencePicker(undefined, item.id)"
@remove="(refIndex) => removeClipRef(item, refIndex)" @remove="(refIndex) => removeClipRef(item, refIndex)"
@update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)" @update-label="(refIndex, label) => updateClipRefLabel(item, refIndex, label)"
/> />
@@ -1432,7 +1450,7 @@
@update:open="promptDrawerOpen = $event" @update:open="promptDrawerOpen = $event"
/> />
<ImagePickerModal <ImagePickerModal
v-if="pickerOpen" :open="pickerOpen"
:folders="folders" :folders="folders"
:stills="stills" :stills="stills"
:folder-id="folderId" :folder-id="folderId"
@@ -1520,6 +1538,7 @@ import {
extractGlobalLocks, extractGlobalLocks,
isBlankGlobalLocks, isBlankGlobalLocks,
nextPermanenceLabel, nextPermanenceLabel,
permanenceLabels,
type PermanenceRef type PermanenceRef
} from '~/utils/globalLocks' } from '~/utils/globalLocks'
import { import {
@@ -1830,6 +1849,7 @@ const studioJobs = ref<Array<{
}>>([]) }>>([])
const outputStudioJobId = ref('') const outputStudioJobId = ref('')
const pickerOpen = ref(false) const pickerOpen = ref(false)
const pickerKind = ref<'image' | 'permanence'>('image')
const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main') const pickerSlot = ref<'main' | 'editRef' | number | 'perm' | `perm:${number}` | `clip:${string}`>('main')
const libraryFilter = ref<'all' | 'images' | 'videos'>('all') const libraryFilter = ref<'all' | 'images' | 'videos'>('all')
const selectedKeys = ref<string[]>([]) const selectedKeys = ref<string[]>([])
@@ -1851,12 +1871,23 @@ const shotPermanenceRefs = ref<Record<number, PermanenceRef[]>>({})
const LOCKS_STORE = 'aigen-global-locks' const LOCKS_STORE = 'aigen-global-locks'
const REFS_STORE = 'aigen-permanence-refs' const REFS_STORE = 'aigen-permanence-refs'
const pickerHeading = computed(() => { 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 === 'main') return studioMode.value === 'edit' ? 'Choose image 1' : 'Choose an image'
if (pickerSlot.value === 'editRef') return 'Choose image 2' 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}` 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 videoTimer: ReturnType<typeof setInterval> | null = null
let editTimer: ReturnType<typeof setInterval> | null = null let editTimer: ReturnType<typeof setInterval> | null = null
let videoSource: EventSource | 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') { function openPicker(slot: 'main' | 'editRef' | number = 'main') {
pickerKind.value = 'image'
pickerSlot.value = slot pickerSlot.value = slot
void nextTick(() => {
pickerOpen.value = true pickerOpen.value = true
})
} }
function openPermanencePicker(shot?: number, clipId?: string) { function openPermanencePicker(shot?: number, clipId?: string) {
pickerKind.value = 'permanence'
if (clipId) pickerSlot.value = `clip:${clipId}` if (clipId) pickerSlot.value = `clip:${clipId}`
else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}` else if (typeof shot === 'number') pickerSlot.value = `perm:${shot}`
else pickerSlot.value = 'perm' else pickerSlot.value = 'perm'
void nextTick(() => {
pickerOpen.value = true pickerOpen.value = true
})
} }
function addPermanenceRef(stillId: string, shot?: number) { function addPermanenceRef(stillId: string, shot?: number) {
@@ -2928,21 +2965,29 @@ function updateClipRefLabel(clip: LibraryClip, index: number, label: string) {
} }
function onPickedPermanence(still: { id: string }) { function onPickedPermanence(still: { id: string }) {
if (pickerKind.value !== 'permanence') return
const slot = pickerSlot.value const slot = pickerSlot.value
if (slot === 'main' || slot === 'editRef' || typeof slot === 'number') return if (typeof slot !== 'string' || slot === 'main' || slot === 'editRef') return
pickerOpen.value = false if (slot.startsWith('clip:')) {
if (typeof slot === 'string' && slot.startsWith('clip:')) {
const clipId = slot.slice(5) const clipId = slot.slice(5)
const clip = clips.value.find(item => item.id === clipId) const clip = clips.value.find(item => item.id === clipId)
if (!clip) return if (!clip) {
if ((clip.permanenceRefs || []).some(item => item.stillId === still.id)) return pickerOpen.value = false
void persistClipRefs(clip, [...(clip.permanenceRefs || []), { stillId: still.id, label: nextPermanenceLabel(clip.permanenceRefs || []) }])
return 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)) const shot = slot === 'perm' ? undefined : Number(slot.slice(5))
addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined) addPermanenceRef(still.id, Number.isFinite(shot) ? shot : undefined)
} }
pickerOpen.value = false
} }
function setEditRef(next: File) { function setEditRef(next: File) {
@@ -2979,9 +3024,14 @@ function clearIdentityRef(index: number) {
identityPreviews.value = previews identityPreviews.value = previews
} }
function onPickedImage(next: File, hidden = false) { function onPickedImage(next: File, hidden = false, stillId?: string) {
const slot = pickerSlot.value if (pickerKind.value === 'permanence') {
if (stillId) onPickedPermanence({ id: stillId })
else pickerOpen.value = false
return
}
pickerOpen.value = false pickerOpen.value = false
const slot = pickerSlot.value
if (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return if (typeof slot === 'string' && slot !== 'main' && slot !== 'editRef') return
if (slot === 'editRef') { if (slot === 'editRef') {
setEditRef(next) setEditRef(next)
+6 -2
View File
@@ -94,15 +94,19 @@ function extractState(locks: string) {
return String(match?.[1] || '').replace(/\s+/g, ' ').trim() 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[]) { function permanenceLine(refs: PermanenceRef[]) {
const labels = [...new Set(refs.map(item => item.label).filter(Boolean))] const labels = permanenceLabels(refs)
if (!labels.length) return '' if (!labels.length) return ''
return `Permanence: ${labels.join(', ')} stay the same.` return `Permanence: ${labels.join(', ')} stay the same.`
} }
function inheritLine(locks: string, refs: PermanenceRef[]) { function inheritLine(locks: string, refs: PermanenceRef[]) {
const parts = ['[INHERITS] Global locks.'] 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.`) if (labels.length) parts.push(`Same ${labels.join(', ')} as locked.`)
const state = extractState(locks) const state = extractState(locks)
if (state) parts.push(`State: ${state}`) if (state) parts.push(`State: ${state}`)