Files
aigen/utils/promptSequences.ts
T
TowstyandCursor f6c5ef8af5 Restore character names on Prompt Builder compile lines.
Character lines again use Name: description with outfit and facing features; set names and framing labels stay uncopied.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 23:17:15 -05:00

818 lines
27 KiB
TypeScript

export type PromptSequenceOutfit = {
id: string
name: string
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
/** Copied into the prompt as "Name: …" when the character is on and facing is not out. */
name: string
/** Base description; included with the name when in frame (facing !== out). */
description: string
features: PromptSequenceFeature[]
outfits: PromptSequenceOutfit[]
}
export type PromptSequenceCharacterState = {
characterId: string
on: boolean
/** Active outfit id for this scene, or null/'' for none. */
outfitId: string | null
}
/** Suggested labels only — not a hard enum. */
export const PROMPT_SEQUENCE_FRAMING_SUGGESTIONS = ['wide', 'medium', 'narrow'] as const
export type PromptSequenceFraming = {
id: string
label: string
text: string
facing: PromptSequenceFacing
}
export type PromptSequenceSet = {
id: string
name: string
framings: PromptSequenceFraming[]
}
export type PromptSequenceSetState = {
setId: string
on: boolean
/** Chosen framing for this set in this scene (like outfitId). Empty = copy nothing. */
framingId: string | null
}
export type PromptSequenceScene = {
id: string
title: string
style: string
settings: string
setStates: PromptSequenceSetState[]
characterStates: PromptSequenceCharacterState[]
prompt: string
}
/** Scene prompt sections. Default copy order. */
export const PROMPT_SEQUENCE_SECTION_KEYS = ['style', 'settings', 'set', 'characters', 'prompt'] as const
export type PromptSequenceSectionKey = (typeof PROMPT_SEQUENCE_SECTION_KEYS)[number]
export const DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER: PromptSequenceSectionKey[] = [
...PROMPT_SEQUENCE_SECTION_KEYS
]
export type PromptSequence = {
id: string
name: string
/** Order of scene sections when assembling / in the editor. */
sectionOrder: PromptSequenceSectionKey[]
characters: PromptSequenceCharacter[]
sets: PromptSequenceSet[]
scenes: PromptSequenceScene[]
createdAt: number
updatedAt: number
}
/** Valid unique keys in given order; missing keys append in default order. */
export function normalizeSectionOrder(raw: unknown): PromptSequenceSectionKey[] {
const allowed = new Set<string>(PROMPT_SEQUENCE_SECTION_KEYS)
const seen = new Set<PromptSequenceSectionKey>()
const ordered: PromptSequenceSectionKey[] = []
if (Array.isArray(raw)) {
for (const item of raw) {
const key = String(item || '').trim()
if (!allowed.has(key) || seen.has(key as PromptSequenceSectionKey)) continue
const typed = key as PromptSequenceSectionKey
seen.add(typed)
ordered.push(typed)
}
}
for (const key of DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER) {
if (!seen.has(key)) ordered.push(key)
}
return ordered
}
export const PROMPT_SEQUENCE_NAME_MAX = 80
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
export const PROMPT_SEQUENCE_TITLE_MAX = 60
export function newId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `ps-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
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: '', features: [], outfits: [] }
}
export function emptyFraming(label = ''): PromptSequenceFraming {
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 copied ("Name: description"). Facing "out" emits nothing.
* Feature text appends after the description, before the outfit.
*/
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 name = stripBangs(String(character?.name || ''))
const description = stripBangs(String(character?.description || ''))
let head = ''
if (name && description) head = `${name}: ${description}`
else head = name || description
const featureParts: string[] = []
for (const feature of features) {
if (!feature?.enabled) continue
const cleaned = stripBangs(String(feature.text || ''))
if (isFeatureVisibleForFacing(feature, facing)) {
if (cleaned) featureParts.push(cleaned)
} else {
omittedFeatures.push({
id: String(feature.id || newId()),
text: cleaned || String(feature.text || '').trim(),
reason: 'camera'
})
}
}
let text = head
if (featureParts.length) {
const featureText = featureParts.join(', ')
text = text ? `${text}, ${featureText}` : featureText
}
const outfitText = stripBangs(outfitPromptText(outfit))
if (outfitText) {
text = text ? `${text}. Outfit: ${outfitText}` : `Outfit: ${outfitText}`
}
return { text, omittedFeatures, ignoredBangs }
}
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
return { characterId, on, outfitId: null }
}
export function defaultSetState(setId: string, on = true, framingId: string | null = null): PromptSequenceSetState {
return { setId, on, framingId }
}
export function emptyScene(
index = 1,
characters: PromptSequenceCharacter[] = [],
sets: PromptSequenceSet[] = []
): PromptSequenceScene {
return {
id: newId(),
title: `Scene ${Math.max(1, index)}`,
style: '',
settings: '',
setStates: sets.map(set => defaultSetState(set.id, true, null)),
characterStates: characters.map(character => defaultCharacterState(character.id, true)),
prompt: ''
}
}
export function cloneSceneForNext(previous: PromptSequenceScene, nextIndex: number): PromptSequenceScene {
return {
id: newId(),
title: `Scene ${nextIndex}`,
style: previous.style,
settings: previous.settings,
setStates: (previous.setStates || []).map(state => ({
setId: state.setId,
on: !!state.on,
framingId: state.framingId || null
})),
characterStates: (previous.characterStates || []).map(state => ({
characterId: state.characterId,
on: !!state.on,
outfitId: state.outfitId || null
})),
prompt: previous.prompt
}
}
/**
* Legacy helper for name/description/outfit (+ optional facing features).
* Prefer compileCharacterForFacing for feature-aware compile.
*/
export function formatCharacterLine(
character: { name?: string; description?: string; features?: PromptSequenceFeature[] },
outfit?: { name?: string; description?: string } | null,
facing: PromptSequenceFacing = 'toward'
) {
return compileCharacterForFacing(character, facing, outfit).text
}
/** Selected framing text for each on set. No set name, no framing label. Bangs stripped. */
export function assembleSetBlock(
scene: { setStates?: PromptSequenceSetState[] },
sets: PromptSequenceSet[] = []
) {
const states = scene.setStates || []
const stateById = new Map(states.map(state => [state.setId, state]))
const chunks: string[] = []
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)
const text = stripBangs(String(framing?.text || ''))
if (text) chunks.push(text)
}
return chunks.join('\n')
}
export function resolveSceneCharacterLines(
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
.map((character) => {
const state = stateById.get(character.id)
if (state && !state.on) return ''
const outfitId = state?.outfitId || null
const outfit = outfitId
? (character.outfits || []).find(item => item.id === outfitId) || null
: null
return compileCharacterForFacing(character, facing, outfit).text
})
.filter(Boolean)
}
export function assembleSceneText(
scene: {
style?: string
settings?: string
prompt?: string
setStates?: PromptSequenceSetState[]
characterStates?: PromptSequenceCharacterState[]
/** Legacy per-scene characters (migration / old tests). */
characters?: Array<{ name?: string; description?: string; features?: PromptSequenceFeature[] }>
},
sequenceCharacters?: PromptSequenceCharacter[],
sequenceSets?: PromptSequenceSet[],
sectionOrder?: PromptSequenceSectionKey[] | unknown
) {
const sets = sequenceSets || []
const setBlock = sets.length
? assembleSetBlock(scene, sets)
: ''
const facing = resolveSceneFacing(scene, sets)
const characterBlock = sequenceCharacters?.length
? resolveSceneCharacterLines(scene, sequenceCharacters, sets).join('\n')
: (scene.characters || [])
.map(character => compileCharacterForFacing(character, facing).text)
.filter(Boolean)
.join('\n')
const blocks: Record<PromptSequenceSectionKey, string> = {
style: stripBangs(String(scene.style || '')),
settings: stripBangs(String(scene.settings || '')),
set: setBlock,
characters: characterBlock,
prompt: stripBangs(String(scene.prompt || ''))
}
const order = normalizeSectionOrder(sectionOrder)
const parts = order.map(key => blocks[key]).filter(Boolean)
return parts.join('\n\n')
}
/**
* Whole sequence as one copyable block.
* Scenes join with a double blank line (\n\n\n) — same separator desktop Batch uses.
* Sequence name is never included.
*/
export function assembleSequenceText(sequence: {
sectionOrder?: PromptSequenceSectionKey[] | unknown
characters?: PromptSequenceCharacter[]
sets?: PromptSequenceSet[]
scenes?: Array<{
style?: string
settings?: string
prompt?: string
setStates?: PromptSequenceSetState[]
characterStates?: PromptSequenceCharacterState[]
characters?: Array<{ name?: string; description?: string }>
}>
}) {
const characters = sequence.characters || []
const sets = sequence.sets || []
const sectionOrder = normalizeSectionOrder(sequence.sectionOrder)
return (sequence.scenes || [])
.map(scene => assembleSceneText(
scene,
characters.length ? characters : undefined,
sets.length ? sets : undefined,
sectionOrder
))
.filter(Boolean)
.join('\n\n\n')
}
export function normalizePromptSequenceName(raw: unknown) {
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_NAME_MAX)
}
export function normalizePromptSequenceField(raw: unknown) {
return String(raw || '').replace(/\r\n/g, '\n').slice(0, PROMPT_SEQUENCE_FIELD_MAX)
}
export function normalizePromptSequenceTitle(raw: unknown, fallback: string) {
const title = String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_TITLE_MAX)
return title || fallback
}
export function normalizeOutfit(raw: unknown): PromptSequenceOutfit {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
description: normalizePromptSequenceField(rec.description).trim()
}
}
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
}
}
export function normalizeFraming(raw: unknown): PromptSequenceFraming {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
return {
id: String(rec.id || '').trim() || newId(),
label: String(rec.label || '').trim().slice(0, 120),
text: normalizePromptSequenceField(rec.text),
facing: normalizeFacing(rec.facing)
}
}
/** Fold legacy beat-only sets into one framing labeled "wide". */
function framingsFromLegacyBeats(rec: Record<string, unknown>): PromptSequenceFraming[] {
const parts: string[] = []
const description = normalizePromptSequenceField(rec.description).trim()
if (description) parts.push(description)
const beats = Array.isArray(rec.beats) ? rec.beats : []
for (const beat of beats) {
if (!beat || typeof beat !== 'object') continue
const text = normalizePromptSequenceField((beat as Record<string, unknown>).text).trim()
if (text) parts.push(text)
}
if (!parts.length) return [emptyFraming()]
return [{ id: newId(), label: 'wide', text: parts.join('\n'), facing: 'toward' }]
}
export function normalizeSet(raw: unknown): PromptSequenceSet {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
let framings: PromptSequenceFraming[]
if (Array.isArray(rec.framings) && rec.framings.length) {
framings = rec.framings.map(normalizeFraming)
} else if (Array.isArray(rec.beats) && rec.beats.length) {
framings = framingsFromLegacyBeats(rec)
} else if (String(rec.description || '').trim()) {
framings = framingsFromLegacyBeats(rec)
} else {
framings = [emptyFraming()]
}
return {
id: String(rec.id || '').trim() || newId(),
name: String(rec.name || '').trim().slice(0, 120),
framings
}
}
export function normalizeCharacterState(raw: unknown, fallbackCharacterId = ''): PromptSequenceCharacterState {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const characterId = String(rec.characterId || fallbackCharacterId || '').trim()
const outfitRaw = rec.outfitId
const outfitId = outfitRaw == null || outfitRaw === '' ? null : String(outfitRaw).trim() || null
return {
characterId,
on: rec.on === undefined ? true : !!rec.on,
outfitId
}
}
export function normalizeSetState(
raw: unknown,
fallbackSetId = '',
sets: PromptSequenceSet[] = [],
legacyShotSize = ''
): PromptSequenceSetState {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const setId = String(rec.setId || fallbackSetId || '').trim()
const framingRaw = rec.framingId
let framingId = framingRaw == null || framingRaw === '' ? null : String(framingRaw).trim() || null
const set = sets.find(item => item.id === setId)
if (framingId && set && !set.framings.some(framing => framing.id === framingId)) {
framingId = null
}
if (!framingId && legacyShotSize && set) {
const match = set.framings.find(
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
)
if (match) framingId = match.id
}
return {
setId,
on: rec.on === undefined ? true : !!rec.on,
framingId
}
}
function liftLegacyCharacters(scenesRaw: unknown[]): PromptSequenceCharacter[] {
const byId = new Map<string, PromptSequenceCharacter>()
for (const scene of scenesRaw) {
if (!scene || typeof scene !== 'object') continue
const chars = (scene as Record<string, unknown>).characters
if (!Array.isArray(chars)) continue
for (const character of chars) {
const normalized = normalizeCharacter(character)
if (!byId.has(normalized.id)) byId.set(normalized.id, normalized)
}
}
return [...byId.values()]
}
function syncCharacterStates(
statesRaw: unknown,
characters: PromptSequenceCharacter[],
defaultOn = true
): PromptSequenceCharacterState[] {
const incoming = Array.isArray(statesRaw)
? statesRaw.map(state => normalizeCharacterState(state)).filter(state => state.characterId)
: []
const byId = new Map(incoming.map(state => [state.characterId, state]))
return characters.map((character) => {
const existing = byId.get(character.id)
if (existing) {
const outfitOk = existing.outfitId
&& character.outfits.some(outfit => outfit.id === existing.outfitId)
return {
characterId: character.id,
on: existing.on,
outfitId: outfitOk ? existing.outfitId : null
}
}
return defaultCharacterState(character.id, defaultOn)
})
}
function syncSetStates(
statesRaw: unknown,
sets: PromptSequenceSet[],
defaultOn = true,
legacyShotSize = ''
): PromptSequenceSetState[] {
const incoming = Array.isArray(statesRaw)
? statesRaw.map(state => normalizeSetState(state, '', sets, legacyShotSize)).filter(state => state.setId)
: []
const byId = new Map(incoming.map(state => [state.setId, state]))
return sets.map((set) => {
const existing = byId.get(set.id)
if (existing) {
const framingOk = existing.framingId
&& set.framings.some(framing => framing.id === existing.framingId)
return {
setId: set.id,
on: existing.on,
framingId: framingOk ? existing.framingId : null
}
}
let framingId: string | null = null
if (legacyShotSize) {
const match = set.framings.find(
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
)
if (match) framingId = match.id
}
return defaultSetState(set.id, defaultOn, framingId)
})
}
export function normalizeScene(
raw: unknown,
index: number,
characters: PromptSequenceCharacter[] = [],
sets: PromptSequenceSet[] = []
): PromptSequenceScene {
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
const legacyShotSize = String(rec.shotSize || '').trim()
return {
id: String(rec.id || '').trim() || newId(),
title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`),
style: normalizePromptSequenceField(rec.style),
settings: normalizePromptSequenceField(rec.settings),
setStates: syncSetStates(rec.setStates, sets, true, legacyShotSize),
characterStates: syncCharacterStates(rec.characterStates, characters, true),
prompt: normalizePromptSequenceField(rec.prompt)
}
}
export function normalizePromptSequence(raw: unknown): PromptSequence | null {
if (!raw || typeof raw !== 'object') return null
const rec = raw as Record<string, unknown>
const name = normalizePromptSequenceName(rec.name)
if (!name) return null
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : []
let characters = Array.isArray(rec.characters)
? rec.characters.map(normalizeCharacter)
: liftLegacyCharacters(scenesRaw)
if (!characters.length) characters = [emptyCharacter()]
const sets = Array.isArray(rec.sets) ? rec.sets.map(normalizeSet) : []
const scenes = scenesRaw.length
? scenesRaw.map((scene, index) => normalizeScene(scene, index, characters, sets))
: [emptyScene(1, characters, sets)]
const createdAt = Number(rec.createdAt) || Date.now()
return {
id: String(rec.id || '').trim() || newId(),
name,
sectionOrder: normalizeSectionOrder(rec.sectionOrder),
characters,
sets,
scenes,
createdAt,
updatedAt: Number(rec.updatedAt) || createdAt
}
}
/** Ensure every scene has a state row for every sequence character. */
export function ensureSceneStatesForCharacters(
scenes: PromptSequenceScene[],
characters: PromptSequenceCharacter[],
opts?: { newCharacterId?: string; turnOnSceneIndex?: number }
) {
const newCharacterId = opts?.newCharacterId
const turnOnIndex = opts?.turnOnSceneIndex
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i]
const byId = new Map(scene.characterStates.map(state => [state.characterId, state]))
scene.characterStates = characters.map((character) => {
const existing = byId.get(character.id)
if (existing) return existing
const on = newCharacterId && character.id === newCharacterId
? i === turnOnIndex
: true
return defaultCharacterState(character.id, on)
})
}
}
/** Ensure every scene has a state row for every sequence set. */
export function ensureSceneStatesForSets(
scenes: PromptSequenceScene[],
sets: PromptSequenceSet[],
opts?: { newSetId?: string; turnOnSceneIndex?: number }
) {
const newSetId = opts?.newSetId
const turnOnIndex = opts?.turnOnSceneIndex
for (let i = 0; i < scenes.length; i++) {
const scene = scenes[i]
const byId = new Map((scene.setStates || []).map(state => [state.setId, state]))
scene.setStates = sets.map((set) => {
const existing = byId.get(set.id)
if (existing) {
const framingOk = existing.framingId
&& set.framings.some(framing => framing.id === existing.framingId)
return {
setId: set.id,
on: existing.on,
framingId: framingOk ? existing.framingId : null
}
}
const on = newSetId && set.id === newSetId
? i === turnOnIndex
: true
return defaultSetState(set.id, on, null)
})
}
}