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 PromptSequenceShotSize = '' | 'wide' | 'medium' | 'close-up' | 'detail' export const PROMPT_SEQUENCE_SHOT_SIZES = ['wide', 'medium', 'close-up', 'detail'] as const export type PromptSequenceBeat = { id: string name: string text: string /** Empty = always include for any shot size. */ framings: Array<'wide' | 'medium' | 'close-up' | 'detail'> } export type PromptSequenceSet = { id: string name: string description: string beats: PromptSequenceBeat[] } export type PromptSequenceSetState = { setId: string on: boolean } export type PromptSequenceScene = { id: string title: string style: string settings: string shotSize: PromptSequenceShotSize setStates: PromptSequenceSetState[] characterStates: PromptSequenceCharacterState[] prompt: string } export type PromptSequence = { id: string name: string characters: PromptSequenceCharacter[] sets: PromptSequenceSet[] 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 emptyBeat(): PromptSequenceBeat { return { id: newId(), name: '', text: '', framings: [] } } export function emptySet(): PromptSequenceSet { return { id: newId(), name: '', description: '', beats: [emptyBeat()] } } export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState { return { characterId, on, outfitId: null } } export function defaultSetState(setId: string, on = true): PromptSequenceSetState { return { setId, on } } export function emptyScene( index = 1, characters: PromptSequenceCharacter[] = [], sets: PromptSequenceSet[] = [] ): PromptSequenceScene { return { id: newId(), title: `Scene ${Math.max(1, index)}`, style: '', settings: '', shotSize: '', setStates: sets.map(set => defaultSetState(set.id, true)), 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, shotSize: previous.shotSize || '', setStates: (previous.setStates || []).map(state => ({ setId: state.setId, on: !!state.on })), 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 beatMatchesShotSize( beat: { framings?: string[] }, shotSize: string = '' ) { const framings = Array.isArray(beat.framings) ? beat.framings.filter(Boolean) : [] if (!framings.length) return true if (!shotSize) return true return framings.includes(shotSize) } /** Set description + matching beat texts (one per line). No set/beat names or framing tags. */ export function assembleSetBlock( scene: { shotSize?: string; 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 description = String(set.description || '').trim() const beatLines = (set.beats || []) .filter(beat => beatMatchesShotSize(beat, scene.shotSize || '')) .map(beat => String(beat.text || '').trim()) .filter(Boolean) const lines = [description, ...beatLines].filter(Boolean) if (lines.length) chunks.push(lines.join('\n')) } return chunks.join('\n') } 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) 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 shotSize?: string setStates?: PromptSequenceSetState[] characterStates?: PromptSequenceCharacterState[] /** Legacy per-scene characters (migration / old tests). */ characters?: Array<{ name?: string; description?: string }> }, sequenceCharacters?: PromptSequenceCharacter[], sequenceSets?: PromptSequenceSet[] ) { const setBlock = sequenceSets?.length ? assembleSetBlock(scene, sequenceSets) : '' 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(), setBlock, 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[] sets?: PromptSequenceSet[] scenes?: Array<{ style?: string settings?: string prompt?: string shotSize?: string setStates?: PromptSequenceSetState[] characterStates?: PromptSequenceCharacterState[] characters?: Array<{ name?: string; description?: string }> }> }) { const characters = sequence.characters || [] const sets = sequence.sets || [] return (sequence.scenes || []) .map(scene => assembleSceneText( scene, characters.length ? characters : undefined, sets.length ? sets : 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 normalizeShotSize(raw: unknown): PromptSequenceShotSize { const value = String(raw || '').trim() if (value === 'wide' || value === 'medium' || value === 'close-up' || value === 'detail') return value return '' } 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 normalizeBeat(raw: unknown): PromptSequenceBeat { const rec = (raw && typeof raw === 'object' ? raw : {}) as Record const framings = Array.isArray(rec.framings) ? [...new Set(rec.framings.map(item => String(item || '').trim()).filter((item): item is 'wide' | 'medium' | 'close-up' | 'detail' => item === 'wide' || item === 'medium' || item === 'close-up' || item === 'detail' ))] : [] return { id: String(rec.id || '').trim() || newId(), name: String(rec.name || '').trim().slice(0, 120), text: normalizePromptSequenceField(rec.text), framings } } export function normalizeSet(raw: unknown): PromptSequenceSet { const rec = (raw && typeof raw === 'object' ? raw : {}) as Record const beats = Array.isArray(rec.beats) ? rec.beats.map(normalizeBeat) : [] return { id: String(rec.id || '').trim() || newId(), name: String(rec.name || '').trim().slice(0, 120), description: normalizePromptSequenceField(rec.description), beats: beats.length ? beats : [emptyBeat()] } } 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 } } export function normalizeSetState(raw: unknown, fallbackSetId = ''): PromptSequenceSetState { const rec = (raw && typeof raw === 'object' ? raw : {}) as Record return { setId: String(rec.setId || fallbackSetId || '').trim(), on: rec.on === undefined ? true : !!rec.on } } 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) }) } function syncSetStates( statesRaw: unknown, sets: PromptSequenceSet[], defaultOn = true ): PromptSequenceSetState[] { const incoming = Array.isArray(statesRaw) ? statesRaw.map(state => normalizeSetState(state)).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) return { setId: set.id, on: existing.on } return defaultSetState(set.id, defaultOn) }) } export function normalizeScene( raw: unknown, index: number, characters: PromptSequenceCharacter[] = [], sets: PromptSequenceSet[] = [] ): 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), shotSize: normalizeShotSize(rec.shotSize), setStates: syncSetStates(rec.setStates, sets, true), 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 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, 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) return existing const on = newSetId && set.id === newSetId ? i === turnOnIndex : true return defaultSetState(set.id, on) }) } }