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:
+270
-36
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user