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) {
+131 -8
View File
@@ -24,22 +24,33 @@ const {
emptyOutfit,
emptySet,
emptyFraming,
emptyFeature,
cloneSceneForNext,
normalizePromptSequence,
normalizeSectionOrder,
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
ensureSceneStatesForCharacters
ensureSceneStatesForCharacters,
stripBangs,
extractBangPhrases,
compileCharacterForFacing,
defaultVisibilityForGroup
} = await import(pathToFileURL(tmp).href)
unlinkSync(tmp)
test('formatCharacterLine covers name/description and outfit', () => {
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
test('formatCharacterLine copies base and outfit, never name', () => {
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'tall')
assert.equal(
formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }),
'Ada: tall. Outfit: blue jacket'
'tall. Outfit: blue jacket'
)
})
test('stripBangs removes inline ignore spans and cleans commas', () => {
assert.equal(stripBangs('young woman, !brown eyes!, linen robe'), 'young woman, linen robe')
assert.equal(stripBangs('a, !x!, b'), 'a, b')
assert.deepEqual(extractBangPhrases('young woman, !brown eyes!, linen robe'), ['brown eyes'])
})
test('selecting a framing copies only that framing text; labels never appear', () => {
const wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' }
const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' }
@@ -137,6 +148,7 @@ test('legacy beat-only set still opens as one wide framing', () => {
assert.equal(legacy.sets.length, 1)
assert.equal(legacy.sets[0].framings.length, 1)
assert.equal(legacy.sets[0].framings[0].label, 'wide')
assert.equal(legacy.sets[0].framings[0].facing, 'toward')
assert.ok(legacy.sets[0].framings[0].text.includes('stone walls'))
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
assert.ok(legacy.sets[0].framings[0].text.includes('second beat'))
@@ -166,7 +178,8 @@ test('legacy sequence without sets still assembles', () => {
updatedAt: 1
})
assert.deepEqual(legacy.sets, [])
assert.equal(assembleSequenceText(legacy), 'oil\n\nAda: tall\n\nwalk')
assert.equal(assembleSequenceText(legacy), 'oil\n\ntall\n\nwalk')
assert.ok(!assembleSequenceText(legacy).includes('Ada'))
})
test('cloneSceneForNext copies setStates including framingId', () => {
@@ -195,7 +208,8 @@ test('character outfit and off still work with sets', () => {
characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }],
prompt: 'hi'
}, [ada], [])
assert.equal(text, 'Ada: tall. Outfit: red dress\n\nhi')
assert.equal(text, 'tall. Outfit: red dress\n\nhi')
assert.ok(!text.includes('Ada'))
})
test('ensureSceneStatesForCharacters turns new character on only for current scene', () => {
@@ -230,7 +244,7 @@ test('sectionOrder settings then prompt then characters; empty style and set omi
}
const order = ['settings', 'prompt', 'characters']
const text = assembleSceneText(scene, [ada], [set], order)
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
assert.equal(text, 'soft light\n\nlooks up\n\ntall')
assert.ok(!text.includes('open yard'))
assert.ok(!text.includes('Courtyard'))
assert.ok(!text.includes('wide'))
@@ -281,8 +295,117 @@ test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n joi
const full = assembleSequenceText(legacy)
assert.equal(
full,
'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop'
'oil\n\nday\n\nstone hall\n\ntall\n\nwalk\n\n\nink\n\ntall\n\nstop'
)
assert.ok(!full.includes('Hall'))
assert.ok(!full.includes('wide'))
assert.ok(!full.includes('Ada'))
})
test('bang strip applies to set framing and character text before join', () => {
const eyes = {
...emptyFeature('eyes'),
id: 'feat-eyes',
text: '!brown eyes!',
enabled: true,
visibility: defaultVisibilityForGroup('eyes')
}
const ada = {
...emptyCharacter(),
id: 'c1',
name: 'Ada',
description: 'young woman, !scar!, linen robe',
features: [eyes]
}
const set = {
...emptySet(),
id: 's1',
name: 'Yard',
framings: [{
...emptyFraming('wide'),
id: 'f1',
text: 'courtyard, !prop note!, dusk',
facing: 'toward'
}]
}
const text = assembleSceneText({
style: '',
settings: '',
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: ''
}, [ada], [set])
assert.equal(text, 'courtyard, dusk\n\nyoung woman, linen robe')
assert.ok(!text.includes('brown eyes'))
assert.ok(!text.includes('scar'))
assert.ok(!text.includes('prop note'))
assert.ok(!text.includes('!'))
assert.ok(!text.includes('Ada'))
assert.ok(!text.includes('Yard'))
})
test('facing away drops eyes; feature stays omitted with camera reason; out emits no character', () => {
const eyes = {
...emptyFeature('eyes'),
id: 'feat-eyes',
text: 'brown eyes',
enabled: true,
visibility: defaultVisibilityForGroup('eyes')
}
const hairBack = {
...emptyFeature('hair back'),
id: 'feat-hair',
text: 'long braid',
enabled: true,
visibility: defaultVisibilityForGroup('hair back')
}
const clothing = {
...emptyFeature('clothing'),
id: 'feat-clothes',
text: 'linen robe',
enabled: true,
visibility: defaultVisibilityForGroup('clothing')
}
const ada = {
...emptyCharacter(),
id: 'c1',
name: 'Ada',
description: 'young woman',
features: [eyes, hairBack, clothing]
}
const away = compileCharacterForFacing(ada, 'away')
assert.equal(away.text, 'young woman, long braid, linen robe')
assert.equal(away.omittedFeatures.length, 1)
assert.equal(away.omittedFeatures[0].text, 'brown eyes')
assert.equal(away.omittedFeatures[0].reason, 'camera')
const toward = compileCharacterForFacing(ada, 'toward')
assert.equal(toward.text, 'young woman, brown eyes, linen robe')
assert.ok(toward.omittedFeatures.some(item => item.text === 'long braid'))
const hands = compileCharacterForFacing(ada, 'hands')
assert.equal(hands.text, 'young woman, linen robe')
assert.ok(hands.omittedFeatures.some(item => item.text === 'brown eyes'))
const out = compileCharacterForFacing(ada, 'out')
assert.equal(out.text, '')
assert.equal(out.omittedFeatures.length, 3)
const set = {
...emptySet(),
id: 's1',
name: 'Hall',
framings: [{ ...emptyFraming('away'), id: 'f-away', text: 'stone hall', facing: 'away' }]
}
const sceneText = assembleSceneText({
style: '',
settings: '',
setStates: [{ setId: 's1', on: true, framingId: 'f-away' }],
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
prompt: 'walks'
}, [ada], [set])
assert.equal(sceneText, 'stone hall\n\nyoung woman, long braid, linen robe\n\nwalks')
assert.ok(!sceneText.includes('brown eyes'))
assert.ok(!sceneText.includes('Hall'))
})
+270 -36
View File
@@ -4,10 +4,60 @@ export type PromptSequenceOutfit = {
description: string
}
/** Camera facing for a set framing — drives which character features compile. */
export const PROMPT_SEQUENCE_FACINGS = [
'toward',
'three-quarter',
'profile',
'away',
'hands',
'out'
] as const
export type PromptSequenceFacing = (typeof PROMPT_SEQUENCE_FACINGS)[number]
export type PromptSequenceVisibility = PromptSequenceFacing | 'any'
export const PROMPT_SEQUENCE_FEATURE_GROUPS = [
'eyes',
'expression',
'face detail',
'profile',
'hair front',
'hair back',
'clothing',
'worn accessory',
'back only',
'handheld'
] as const
export type PromptSequenceFeatureGroup = (typeof PROMPT_SEQUENCE_FEATURE_GROUPS)[number]
export const DEFAULT_FEATURE_VISIBILITY: Record<PromptSequenceFeatureGroup, PromptSequenceVisibility[]> = {
eyes: ['toward', 'three-quarter'],
expression: ['toward', 'three-quarter'],
'face detail': ['toward', 'three-quarter'],
profile: ['profile', 'three-quarter'],
'hair front': ['toward', 'three-quarter'],
'hair back': ['away', 'three-quarter'],
clothing: ['any'],
'worn accessory': ['any'],
'back only': ['away', 'three-quarter'],
handheld: ['hands', 'toward', 'three-quarter']
}
export type PromptSequenceFeature = {
id: string
text: string
enabled: boolean
group: PromptSequenceFeatureGroup
visibility: PromptSequenceVisibility[]
}
export type PromptSequenceCharacter = {
id: string
/** Label only — never copied into the prompt. */
name: string
/** Base description; always included when in frame (facing !== out). */
description: string
features: PromptSequenceFeature[]
outfits: PromptSequenceOutfit[]
}
@@ -25,6 +75,7 @@ export type PromptSequenceFraming = {
id: string
label: string
text: string
facing: PromptSequenceFacing
}
export type PromptSequenceSet = {
@@ -104,18 +155,195 @@ export function emptyOutfit(): PromptSequenceOutfit {
return { id: newId(), name: '', description: '' }
}
export function defaultVisibilityForGroup(group: string): PromptSequenceVisibility[] {
const key = String(group || '').trim() as PromptSequenceFeatureGroup
const defaults = DEFAULT_FEATURE_VISIBILITY[key]
return defaults ? [...defaults] : [...DEFAULT_FEATURE_VISIBILITY.clothing]
}
export function emptyFeature(group: PromptSequenceFeatureGroup = 'clothing'): PromptSequenceFeature {
return {
id: newId(),
text: '',
enabled: true,
group,
visibility: defaultVisibilityForGroup(group)
}
}
export function emptyCharacter(): PromptSequenceCharacter {
return { id: newId(), name: '', description: '', outfits: [] }
return { id: newId(), name: '', description: '', features: [], outfits: [] }
}
export function emptyFraming(label = ''): PromptSequenceFraming {
return { id: newId(), label, text: '' }
return { id: newId(), label, text: '', facing: 'toward' }
}
export function emptySet(): PromptSequenceSet {
return { id: newId(), name: '', framings: [emptyFraming()] }
}
/** Editor-only spans: !brown eyes! → stripped at compile. */
export function stripBangs(text: string) {
return String(text || '')
.replace(/!([^!\n]+)!/g, ' ')
.replace(/\s{2,}/g, ' ')
.replace(/\s+([,.])/g, '$1')
.replace(/,\s*,+/g, ',')
.replace(/^,\s*/, '')
.replace(/,\s*$/, '')
.trim()
}
export function extractBangPhrases(text: string) {
const out: string[] = []
const re = /!([^!\n]+)!/g
let match: RegExpExecArray | null
const source = String(text || '')
while ((match = re.exec(source))) {
const phrase = match[1].trim()
if (phrase) out.push(phrase)
}
return out
}
export function normalizeFacing(raw: unknown): PromptSequenceFacing {
const value = String(raw || '').trim()
return (PROMPT_SEQUENCE_FACINGS as readonly string[]).includes(value)
? (value as PromptSequenceFacing)
: 'toward'
}
export function normalizeFeatureGroup(raw: unknown): PromptSequenceFeatureGroup {
const value = String(raw || '').trim()
return (PROMPT_SEQUENCE_FEATURE_GROUPS as readonly string[]).includes(value)
? (value as PromptSequenceFeatureGroup)
: 'clothing'
}
export function normalizeVisibilityList(raw: unknown, group: PromptSequenceFeatureGroup): PromptSequenceVisibility[] {
const allowed = new Set<string>([...PROMPT_SEQUENCE_FACINGS, 'any'])
if (!Array.isArray(raw) || !raw.length) return defaultVisibilityForGroup(group)
const seen = new Set<PromptSequenceVisibility>()
const out: PromptSequenceVisibility[] = []
for (const item of raw) {
const key = String(item || '').trim() as PromptSequenceVisibility
if (!allowed.has(key) || seen.has(key)) continue
seen.add(key)
out.push(key)
}
return out.length ? out : defaultVisibilityForGroup(group)
}
/** Facing from the first on set that has a selected framing; default toward. */
export function resolveSceneFacing(
scene: { setStates?: PromptSequenceSetState[] },
sets: PromptSequenceSet[] = []
): PromptSequenceFacing {
const states = scene.setStates || []
const stateById = new Map(states.map(state => [state.setId, state]))
for (const set of sets) {
const state = stateById.get(set.id)
if (state && !state.on) continue
const framingId = state?.framingId || null
if (!framingId) continue
const framing = (set.framings || []).find(item => item.id === framingId)
if (framing) return normalizeFacing(framing.facing)
}
return 'toward'
}
export function isFeatureVisibleForFacing(
feature: { enabled?: boolean; group?: string; visibility?: PromptSequenceVisibility[] },
facing: PromptSequenceFacing
) {
if (!feature?.enabled) return false
if (facing === 'out') return false
const visibility = Array.isArray(feature.visibility) ? feature.visibility : []
if (visibility.includes(facing)) return true
if (visibility.includes('any')) {
if (facing === 'hands') {
return feature.group === 'handheld' || feature.group === 'clothing'
}
return true
}
return false
}
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
if (!outfit) return ''
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
}
export type CompiledCharacterFacing = {
text: string
omittedFeatures: Array<{ id: string; text: string; reason: 'camera' }>
ignoredBangs: string[]
}
/**
* Compile one character for a camera facing.
* Name is never copied. Facing "out" emits nothing.
*/
export function compileCharacterForFacing(
character: {
id?: string
name?: string
description?: string
features?: PromptSequenceFeature[]
outfits?: PromptSequenceOutfit[]
},
facing: PromptSequenceFacing,
outfit?: { name?: string; description?: string } | null
): CompiledCharacterFacing {
const ignoredBangs = [
...extractBangPhrases(String(character?.description || '')),
...(character?.features || []).flatMap(feature => extractBangPhrases(String(feature?.text || ''))),
...extractBangPhrases(String(outfit?.description || '')),
...extractBangPhrases(String(outfit?.name || ''))
]
const omittedFeatures: CompiledCharacterFacing['omittedFeatures'] = []
const features = character?.features || []
if (facing === 'out') {
for (const feature of features) {
if (!feature?.enabled) continue
const text = stripBangs(String(feature.text || ''))
omittedFeatures.push({
id: String(feature.id || newId()),
text: text || String(feature.text || '').trim(),
reason: 'camera'
})
}
return { text: '', omittedFeatures, ignoredBangs }
}
const parts: string[] = []
const base = stripBangs(String(character?.description || ''))
if (base) parts.push(base)
for (const feature of features) {
if (!feature?.enabled) continue
const cleaned = stripBangs(String(feature.text || ''))
if (isFeatureVisibleForFacing(feature, facing)) {
if (cleaned) parts.push(cleaned)
} else {
omittedFeatures.push({
id: String(feature.id || newId()),
text: cleaned || String(feature.text || '').trim(),
reason: 'camera'
})
}
}
let text = parts.join(', ')
const outfitText = stripBangs(outfitPromptText(outfit))
if (outfitText) {
text = text ? `${text}. Outfit: ${outfitText}` : outfitText
}
return { text, omittedFeatures, ignoredBangs }
}
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
return { characterId, on, outfitId: null }
}
@@ -160,32 +388,19 @@ export function cloneSceneForNext(previous: PromptSequenceScene, nextIndex: numb
}
}
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
if (!outfit) return ''
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
}
/**
* Character line when on:
* - with outfit: "Name: description. Outfit: outfit description"
* - without: "Name: description" (or name-only / description-only)
* Legacy helper: base description (+ optional outfit). Name is never copied.
* Prefer compileCharacterForFacing for feature-aware compile.
*/
export function formatCharacterLine(
character: { name?: string; description?: string },
outfit?: { name?: string; description?: string } | null
character: { name?: string; description?: string; features?: PromptSequenceFeature[] },
outfit?: { name?: string; description?: string } | null,
facing: PromptSequenceFacing = 'toward'
) {
const name = String(character?.name || '').trim()
const description = String(character?.description || '').trim()
let base = ''
if (name && description) base = `${name}: ${description}`
else base = name || description
if (!base) return ''
const outfitText = outfitPromptText(outfit)
if (!outfitText) return base
return `${base}. Outfit: ${outfitText}`
return compileCharacterForFacing(character, facing, outfit).text
}
/** Selected framing text for each on set. No set name, no framing label. */
/** Selected framing text for each on set. No set name, no framing label. Bangs stripped. */
export function assembleSetBlock(
scene: { setStates?: PromptSequenceSetState[] },
sets: PromptSequenceSet[] = []
@@ -199,16 +414,18 @@ export function assembleSetBlock(
const framingId = state?.framingId || null
if (!framingId) continue
const framing = (set.framings || []).find(item => item.id === framingId)
const text = String(framing?.text || '').trim()
const text = stripBangs(String(framing?.text || ''))
if (text) chunks.push(text)
}
return chunks.join('\n')
}
export function resolveSceneCharacterLines(
scene: { characterStates?: PromptSequenceCharacterState[] },
characters: PromptSequenceCharacter[] = []
scene: { setStates?: PromptSequenceSetState[]; characterStates?: PromptSequenceCharacterState[] },
characters: PromptSequenceCharacter[] = [],
sets: PromptSequenceSet[] = []
) {
const facing = resolveSceneFacing(scene, sets)
const states = scene.characterStates || []
const stateById = new Map(states.map(state => [state.characterId, state]))
return characters
@@ -219,7 +436,7 @@ export function resolveSceneCharacterLines(
const outfit = outfitId
? (character.outfits || []).find(item => item.id === outfitId) || null
: null
return formatCharacterLine(character, outfit)
return compileCharacterForFacing(character, facing, outfit).text
})
.filter(Boolean)
}
@@ -232,27 +449,29 @@ export function assembleSceneText(
setStates?: PromptSequenceSetState[]
characterStates?: PromptSequenceCharacterState[]
/** Legacy per-scene characters (migration / old tests). */
characters?: Array<{ name?: string; description?: string }>
characters?: Array<{ name?: string; description?: string; features?: PromptSequenceFeature[] }>
},
sequenceCharacters?: PromptSequenceCharacter[],
sequenceSets?: PromptSequenceSet[],
sectionOrder?: PromptSequenceSectionKey[] | unknown
) {
const setBlock = sequenceSets?.length
? assembleSetBlock(scene, sequenceSets)
const sets = sequenceSets || []
const setBlock = sets.length
? assembleSetBlock(scene, sets)
: ''
const facing = resolveSceneFacing(scene, sets)
const characterBlock = sequenceCharacters?.length
? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n')
? resolveSceneCharacterLines(scene, sequenceCharacters, sets).join('\n')
: (scene.characters || [])
.map(character => formatCharacterLine(character))
.map(character => compileCharacterForFacing(character, facing).text)
.filter(Boolean)
.join('\n')
const blocks: Record<PromptSequenceSectionKey, string> = {
style: String(scene.style || '').trim(),
settings: String(scene.settings || '').trim(),
style: stripBangs(String(scene.style || '')),
settings: stripBangs(String(scene.settings || '')),
set: setBlock,
characters: characterBlock,
prompt: String(scene.prompt || '').trim()
prompt: stripBangs(String(scene.prompt || ''))
}
const order = normalizeSectionOrder(sectionOrder)
const parts = order.map(key => blocks[key]).filter(Boolean)
@@ -313,13 +532,27 @@ export function normalizeOutfit(raw: unknown): PromptSequenceOutfit {
}
}
export function normalizeFeature(raw: unknown): PromptSequenceFeature {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const group = normalizeFeatureGroup(rec.group)
return {
id: String(rec.id || '').trim() || newId(),
text: normalizePromptSequenceField(rec.text),
enabled: rec.enabled === undefined ? true : !!rec.enabled,
group,
visibility: normalizeVisibilityList(rec.visibility, group)
}
}
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
const features = Array.isArray(rec.features) ? rec.features.map(normalizeFeature) : []
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
description: normalizePromptSequenceField(rec.description).trim(),
features,
outfits
}
}
@@ -329,7 +562,8 @@ export function normalizeFraming(raw: unknown): PromptSequenceFraming {
return {
id: String(rec.id || '').trim() || newId(),
label: String(rec.label || '').trim().slice(0, 120),
text: normalizePromptSequenceField(rec.text)
text: normalizePromptSequenceField(rec.text),
facing: normalizeFacing(rec.facing)
}
}
@@ -345,7 +579,7 @@ function framingsFromLegacyBeats(rec: Record<string, unknown>): PromptSequenceFr
if (text) parts.push(text)
}
if (!parts.length) return [emptyFraming()]
return [{ id: newId(), label: 'wide', text: parts.join('\n') }]
return [{ id: newId(), label: 'wide', text: parts.join('\n'), facing: 'toward' }]
}
export function normalizeSet(raw: unknown): PromptSequenceSet {