export type PromptSequenceOutfit = { id: string name: string description: string } export type PromptSequenceCharacter = { id: string name: string description: string outfits: PromptSequenceOutfit[] } export type PromptSequenceCharacterState = { characterId: string on: boolean /** Active outfit id for this scene, or null/'' for none. */ outfitId: string | null } export type PromptSequenceScene = { id: string title: string style: string settings: string characterStates: PromptSequenceCharacterState[] prompt: string } export type PromptSequence = { id: string name: string characters: PromptSequenceCharacter[] scenes: PromptSequenceScene[] createdAt: number updatedAt: number } 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 emptyCharacter(): PromptSequenceCharacter { return { id: newId(), name: '', description: '', outfits: [] } } export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState { return { characterId, on, outfitId: null } } export function emptyScene(index = 1, characters: PromptSequenceCharacter[] = []): PromptSequenceScene { return { id: newId(), title: `Scene ${Math.max(1, index)}`, style: '', settings: '', 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, characterStates: (previous.characterStates || []).map(state => ({ characterId: state.characterId, on: !!state.on, outfitId: state.outfitId || null })), prompt: previous.prompt } } 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) */ export function formatCharacterLine( character: { name?: string; description?: string }, outfit?: { name?: string; description?: string } | null ) { 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}` } export function resolveSceneCharacterLines( scene: { characterStates?: PromptSequenceCharacterState[] }, characters: PromptSequenceCharacter[] = [] ) { const states = scene.characterStates || [] const stateById = new Map(states.map(state => [state.characterId, state])) return characters .map((character) => { const state = stateById.get(character.id) // Missing state (legacy) => on if (state && !state.on) return '' const outfitId = state?.outfitId || null const outfit = outfitId ? (character.outfits || []).find(item => item.id === outfitId) || null : null return formatCharacterLine(character, outfit) }) .filter(Boolean) } export function assembleSceneText( scene: { style?: string settings?: string prompt?: string characterStates?: PromptSequenceCharacterState[] /** Legacy per-scene characters (migration / old tests). */ characters?: Array<{ name?: string; description?: string }> }, sequenceCharacters?: PromptSequenceCharacter[] ) { const characterBlock = sequenceCharacters?.length ? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n') : (scene.characters || []) .map(character => formatCharacterLine(character)) .filter(Boolean) .join('\n') const parts = [ String(scene.style || '').trim(), String(scene.settings || '').trim(), characterBlock, String(scene.prompt || '').trim() ].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: { characters?: PromptSequenceCharacter[] scenes?: Array<{ style?: string settings?: string prompt?: string characterStates?: PromptSequenceCharacterState[] characters?: Array<{ name?: string; description?: string }> }> }) { const characters = sequence.characters || [] return (sequence.scenes || []) .map(scene => assembleSceneText(scene, characters.length ? characters : undefined)) .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 return { id: String(rec.id || '').trim() || newId(), name: String(rec.name || '').trim().slice(0, 120), description: normalizePromptSequenceField(rec.description).trim() } } export function normalizeCharacter(raw: unknown): PromptSequenceCharacter { const rec = (raw && typeof raw === 'object' ? raw : {}) as Record const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : [] return { id: String(rec.id || '').trim() || newId(), name: String(rec.name || '').trim().slice(0, 120), description: normalizePromptSequenceField(rec.description).trim(), outfits } } export function normalizeCharacterState(raw: unknown, fallbackCharacterId = ''): PromptSequenceCharacterState { const rec = (raw && typeof raw === 'object' ? raw : {}) as Record 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 } } function liftLegacyCharacters(scenesRaw: unknown[]): PromptSequenceCharacter[] { const byId = new Map() for (const scene of scenesRaw) { if (!scene || typeof scene !== 'object') continue const chars = (scene as Record).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) }) } export function normalizeScene( raw: unknown, index: number, characters: PromptSequenceCharacter[] = [] ): PromptSequenceScene { const rec = (raw && typeof raw === 'object' ? raw : {}) as Record return { id: String(rec.id || '').trim() || newId(), title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`), style: normalizePromptSequenceField(rec.style), settings: normalizePromptSequenceField(rec.settings), 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 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 scenes = scenesRaw.length ? scenesRaw.map((scene, index) => normalizeScene(scene, index, characters)) : [emptyScene(1, characters)] const createdAt = Number(rec.createdAt) || Date.now() return { id: String(rec.id || '').trim() || newId(), name, characters, 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 newId = 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 = newId && character.id === newId ? i === turnOnIndex : true return defaultCharacterState(character.id, on) }) } }