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:
Towsty
2026-10-04 20:03:05 -05:00
co-authored by Cursor
parent 45e97cc1c3
commit dd8897a3cf
3 changed files with 689 additions and 58 deletions
+288 -14
View File
@@ -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) {