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
+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 {