Add Prompt Builder bang ignores and facing-aware character features.
Strip !...! at compile time, and omit character features the active framing facing cannot see while keeping them checked in the editor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+288
-14
@@ -116,18 +116,84 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="character.name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
placeholder="Name"
|
||||
>
|
||||
<textarea
|
||||
v-model="character.description"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Description"
|
||||
/>
|
||||
<label class="block text-sm">
|
||||
<input
|
||||
v-model="character.name"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
maxlength="120"
|
||||
placeholder="Name"
|
||||
>
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>
|
||||
</label>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-[11px] text-zinc-500">Base description</span>
|
||||
<textarea
|
||||
v-model="character.description"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Base description (always included when in frame; no face tokens)"
|
||||
/>
|
||||
</label>
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Features</span>
|
||||
<p class="text-[11px] text-zinc-500">Dropped when the camera facing cannot see them.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
@click="addFeature(cIndex)"
|
||||
>
|
||||
Add feature
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!(character.features || []).length" class="text-[11px] text-zinc-600">No features yet.</p>
|
||||
<div
|
||||
v-for="(feature, featIndex) in (character.features || [])"
|
||||
:key="feature.id"
|
||||
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input v-model="feature.enabled" type="checkbox" class="accent-amber-400">
|
||||
<span class="text-[11px] text-zinc-500">On</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="feature.text"
|
||||
class="min-w-0 flex-1 rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Feature text"
|
||||
>
|
||||
<select
|
||||
class="rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||
:value="feature.group"
|
||||
@change="setFeatureGroup(cIndex, featIndex, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="group in PROMPT_SEQUENCE_FEATURE_GROUPS" :key="group" :value="group">
|
||||
{{ group }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="chip in visibilityChipKeys"
|
||||
:key="chip"
|
||||
type="button"
|
||||
class="rounded-full px-2.5 min-h-9 py-1 text-[11px] font-semibold transition"
|
||||
:class="isVisibilityChipActive(feature, chip)
|
||||
? 'bg-amber-400/15 text-amber-100 ring-1 ring-amber-300/40'
|
||||
: 'border border-white/15 text-zinc-400 hover:border-white/30 hover:text-zinc-200'"
|
||||
@click="toggleVisibilityChip(feature, chip)"
|
||||
>
|
||||
{{ chip }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearFeature(cIndex, featIndex)">Clear</button>
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFeature(cIndex, featIndex)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>
|
||||
@@ -243,6 +309,40 @@
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
placeholder="Place / location text copied into the prompt"
|
||||
/>
|
||||
<label class="block text-[11px] text-zinc-500">
|
||||
Character facing
|
||||
<select
|
||||
class="mt-1 w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"
|
||||
:value="framing.facing || 'toward'"
|
||||
@change="framing.facing = (($event.target as HTMLSelectElement).value as typeof framing.facing) || 'toward'"
|
||||
>
|
||||
<option v-for="facing in PROMPT_SEQUENCE_FACINGS" :key="facing" :value="facing">
|
||||
{{ facing }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div
|
||||
v-for="preview in [framingPreview(framing)]"
|
||||
:key="'framing-preview-' + framing.id"
|
||||
class="rounded-lg border border-white/5 bg-zinc-950/70 px-2.5 py-2 space-y-1.5"
|
||||
>
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Framing preview</span>
|
||||
<pre class="whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ preview.text || '—' }}</pre>
|
||||
<div v-if="preview.ignoredBangs.length" class="text-[11px] text-zinc-500">
|
||||
Ignored bangs:
|
||||
<span class="text-zinc-400">{{ preview.ignoredBangs.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="preview.omittedFeatures.length" class="space-y-0.5">
|
||||
<span class="text-[11px] text-zinc-500">Omitted (camera):</span>
|
||||
<p
|
||||
v-for="item in preview.omittedFeatures"
|
||||
:key="item.id"
|
||||
class="text-[11px] text-zinc-500 line-through"
|
||||
>
|
||||
{{ item.text || '(empty feature)' }} — camera
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,6 +558,25 @@
|
||||
v-if="previewOpen"
|
||||
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"
|
||||
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>
|
||||
<div
|
||||
v-if="previewOpen && (activeSceneIgnoredBangs.length || activeSceneOmittedFeatures.length)"
|
||||
class="space-y-1.5 border-t border-white/5 px-4 py-3"
|
||||
>
|
||||
<div v-if="activeSceneIgnoredBangs.length" class="text-[11px] text-zinc-500">
|
||||
Ignored bangs:
|
||||
<span class="text-zinc-400">{{ activeSceneIgnoredBangs.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="activeSceneOmittedFeatures.length" class="space-y-0.5">
|
||||
<span class="text-[11px] text-zinc-500">Omitted (camera):</span>
|
||||
<p
|
||||
v-for="item in activeSceneOmittedFeatures"
|
||||
:key="item.id"
|
||||
class="text-[11px] text-zinc-500 line-through"
|
||||
>
|
||||
{{ item.text || '(empty feature)' }} — camera
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -507,25 +626,37 @@
|
||||
import type {
|
||||
PromptSequence,
|
||||
PromptSequenceCharacter,
|
||||
PromptSequenceFacing,
|
||||
PromptSequenceFeature,
|
||||
PromptSequenceFraming,
|
||||
PromptSequenceScene,
|
||||
PromptSequenceSectionKey,
|
||||
PromptSequenceSet
|
||||
} from '~/utils/promptSequences'
|
||||
import {
|
||||
DEFAULT_FEATURE_VISIBILITY,
|
||||
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||
PROMPT_SEQUENCE_FACINGS,
|
||||
PROMPT_SEQUENCE_FEATURE_GROUPS,
|
||||
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
cloneSceneForNext,
|
||||
compileCharacterForFacing,
|
||||
defaultVisibilityForGroup,
|
||||
emptyCharacter,
|
||||
emptyFeature,
|
||||
emptyFraming,
|
||||
emptyOutfit,
|
||||
emptyScene,
|
||||
emptySet,
|
||||
ensureSceneStatesForCharacters,
|
||||
ensureSceneStatesForSets,
|
||||
extractBangPhrases,
|
||||
normalizePromptSequence,
|
||||
normalizeSectionOrder
|
||||
normalizeSectionOrder,
|
||||
resolveSceneFacing,
|
||||
stripBangs
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
useHead({ title: 'Prompt Builder' })
|
||||
@@ -538,6 +669,10 @@ const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
|
||||
prompt: 'Prompt'
|
||||
}
|
||||
|
||||
type VisibilityChipKey = PromptSequenceFacing | 'any'
|
||||
|
||||
const visibilityChipKeys: VisibilityChipKey[] = [...PROMPT_SEQUENCE_FACINGS, 'any']
|
||||
|
||||
const sequences = ref<PromptSequence[]>([])
|
||||
const selectedId = ref('')
|
||||
const name = ref('')
|
||||
@@ -568,6 +703,72 @@ const activeSceneText = computed(() => {
|
||||
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
|
||||
})
|
||||
|
||||
type FramingPreviewResult = {
|
||||
text: string
|
||||
ignoredBangs: string[]
|
||||
omittedFeatures: Array<{ id: string; text: string; reason: 'camera' }>
|
||||
}
|
||||
|
||||
function buildFramingPreview(
|
||||
framing: PromptSequenceFraming,
|
||||
opts?: { characters?: PromptSequenceCharacter[]; characterStates?: PromptSequenceScene['characterStates'] }
|
||||
): FramingPreviewResult {
|
||||
const facing = (framing.facing || 'toward') as PromptSequenceFacing
|
||||
const previewCharacters = opts?.characters || characters.value
|
||||
const stateById = new Map((opts?.characterStates || []).map(state => [state.characterId, state]))
|
||||
const onCharacters = opts?.characterStates
|
||||
? previewCharacters.filter((character) => {
|
||||
const state = stateById.get(character.id)
|
||||
return state ? !!state.on : true
|
||||
})
|
||||
: previewCharacters
|
||||
const framingClean = stripBangs(String(framing.text || ''))
|
||||
const compiled = onCharacters.map(character => compileCharacterForFacing(character, facing))
|
||||
const text = [framingClean, ...compiled.map(item => item.text).filter(Boolean)].filter(Boolean).join('\n')
|
||||
const ignoredBangs = [
|
||||
...extractBangPhrases(String(framing.text || '')),
|
||||
...compiled.flatMap(item => item.ignoredBangs || [])
|
||||
]
|
||||
const omittedFeatures = compiled.flatMap(item => item.omittedFeatures || [])
|
||||
return { text, ignoredBangs, omittedFeatures }
|
||||
}
|
||||
|
||||
function framingPreview(framing: PromptSequenceFraming) {
|
||||
return buildFramingPreview(framing)
|
||||
}
|
||||
|
||||
const activeSceneDiagnostics = computed(() => {
|
||||
if (!activeScene.value) {
|
||||
return { ignoredBangs: [] as string[], omittedFeatures: [] as Array<{ id: string; text: string; reason: 'camera' }> }
|
||||
}
|
||||
const facing = resolveSceneFacing(activeScene.value, sets.value)
|
||||
const onCharacters = characters.value.filter((character) => {
|
||||
const state = activeScene.value?.characterStates?.find(item => item.characterId === character.id)
|
||||
return state ? !!state.on : true
|
||||
})
|
||||
const ignoredBangs = new Set<string>()
|
||||
const omittedFeatures: Array<{ id: string; text: string; reason: 'camera' }> = []
|
||||
for (const set of sets.value) {
|
||||
const state = activeScene.value.setStates?.find(item => item.setId === set.id)
|
||||
if (state && !state.on) continue
|
||||
const framing = (set.framings || []).find(item => item.id === state?.framingId)
|
||||
if (!framing) continue
|
||||
for (const bang of extractBangPhrases(String(framing.text || ''))) ignoredBangs.add(bang)
|
||||
}
|
||||
for (const character of onCharacters) {
|
||||
const compiled = compileCharacterForFacing(character, facing as PromptSequenceFacing)
|
||||
for (const bang of compiled.ignoredBangs || []) ignoredBangs.add(bang)
|
||||
omittedFeatures.push(...(compiled.omittedFeatures || []))
|
||||
}
|
||||
for (const bang of extractBangPhrases(String(activeScene.value.style || ''))) ignoredBangs.add(bang)
|
||||
for (const bang of extractBangPhrases(String(activeScene.value.settings || ''))) ignoredBangs.add(bang)
|
||||
for (const bang of extractBangPhrases(String(activeScene.value.prompt || ''))) ignoredBangs.add(bang)
|
||||
return { ignoredBangs: [...ignoredBangs], omittedFeatures }
|
||||
})
|
||||
|
||||
const activeSceneIgnoredBangs = computed(() => activeSceneDiagnostics.value.ignoredBangs)
|
||||
const activeSceneOmittedFeatures = computed(() => activeSceneDiagnostics.value.omittedFeatures)
|
||||
|
||||
function sectionLabel(key: PromptSequenceSectionKey) {
|
||||
return SECTION_LABELS[key] || key
|
||||
}
|
||||
@@ -628,12 +829,29 @@ function select(sequence: PromptSequence) {
|
||||
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({
|
||||
...emptyCharacter(),
|
||||
...character,
|
||||
features: (character.features || []).map((feature) => {
|
||||
const group = feature.group || emptyFeature().group
|
||||
const fallbackVisibility = defaultVisibilityForGroup(group)
|
||||
|| [...(DEFAULT_FEATURE_VISIBILITY[group] || [])]
|
||||
return {
|
||||
...emptyFeature(),
|
||||
...feature,
|
||||
group,
|
||||
visibility: Array.isArray(feature.visibility) && feature.visibility.length
|
||||
? [...feature.visibility]
|
||||
: [...fallbackVisibility]
|
||||
}
|
||||
}),
|
||||
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))
|
||||
}))
|
||||
sets.value = (normalized.sets || []).map(set => ({
|
||||
...emptySet(),
|
||||
...set,
|
||||
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))
|
||||
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({
|
||||
...emptyFraming(),
|
||||
...framing,
|
||||
facing: framing.facing || 'toward'
|
||||
}))
|
||||
}))
|
||||
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({
|
||||
...emptyScene(index + 1, characters.value, sets.value),
|
||||
@@ -702,6 +920,7 @@ function clearCharacter(index: number) {
|
||||
if (!character) return
|
||||
character.name = ''
|
||||
character.description = ''
|
||||
character.features = []
|
||||
character.outfits = []
|
||||
for (const scene of scenes.value) {
|
||||
const state = scene.characterStates.find(item => item.characterId === character.id)
|
||||
@@ -709,6 +928,60 @@ function clearCharacter(index: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function addFeature(characterIndex: number) {
|
||||
const character = characters.value[characterIndex]
|
||||
if (!character) return
|
||||
if (!character.features) character.features = []
|
||||
character.features.push(emptyFeature())
|
||||
}
|
||||
|
||||
function clearFeature(characterIndex: number, featureIndex: number) {
|
||||
const feature = characters.value[characterIndex]?.features?.[featureIndex]
|
||||
if (!feature) return
|
||||
const blank = emptyFeature()
|
||||
feature.text = blank.text
|
||||
feature.enabled = blank.enabled
|
||||
feature.group = blank.group
|
||||
feature.visibility = [...blank.visibility]
|
||||
}
|
||||
|
||||
function removeFeature(characterIndex: number, featureIndex: number) {
|
||||
const character = characters.value[characterIndex]
|
||||
if (!character?.features) return
|
||||
character.features.splice(featureIndex, 1)
|
||||
}
|
||||
|
||||
function setFeatureGroup(characterIndex: number, featureIndex: number, group: string) {
|
||||
const feature = characters.value[characterIndex]?.features?.[featureIndex]
|
||||
if (!feature) return
|
||||
feature.group = group as PromptSequenceFeature['group']
|
||||
const next = defaultVisibilityForGroup(feature.group)
|
||||
|| [...(DEFAULT_FEATURE_VISIBILITY[feature.group] || [])]
|
||||
feature.visibility = [...next]
|
||||
}
|
||||
|
||||
function featureVisibilityList(feature: PromptSequenceFeature): VisibilityChipKey[] {
|
||||
return (feature.visibility || []) as VisibilityChipKey[]
|
||||
}
|
||||
|
||||
function isVisibilityChipActive(feature: PromptSequenceFeature, key: VisibilityChipKey) {
|
||||
const visibility = featureVisibilityList(feature)
|
||||
if (key === 'any') return visibility.includes('any') || visibility.length === 0
|
||||
return visibility.includes(key)
|
||||
}
|
||||
|
||||
function toggleVisibilityChip(feature: PromptSequenceFeature, key: VisibilityChipKey) {
|
||||
if (key === 'any') {
|
||||
feature.visibility = ['any'] as PromptSequenceFeature['visibility']
|
||||
return
|
||||
}
|
||||
const withoutAny = featureVisibilityList(feature).filter(item => item !== 'any') as PromptSequenceFacing[]
|
||||
const index = withoutAny.indexOf(key)
|
||||
if (index >= 0) withoutAny.splice(index, 1)
|
||||
else withoutAny.push(key)
|
||||
feature.visibility = (withoutAny.length ? withoutAny : ['any']) as PromptSequenceFeature['visibility']
|
||||
}
|
||||
|
||||
function removeCharacter(index: number) {
|
||||
if (characters.value.length <= 1) return
|
||||
const [removed] = characters.value.splice(index, 1)
|
||||
@@ -785,6 +1058,7 @@ function clearFraming(setIndex: number, framingIndex: number) {
|
||||
if (!framing) return
|
||||
framing.label = ''
|
||||
framing.text = ''
|
||||
framing.facing = 'toward'
|
||||
}
|
||||
|
||||
function sceneSetState(setId: string) {
|
||||
|
||||
Reference in New Issue
Block a user