/** * Character Template Utilities * * Manages prompt templates for character consistency across panels * Based on research: IP-Adapter with frozen reference at 0.65 strength * and locked attribute order to prevent drift */ import type { Character, CharacterPromptTemplate } from '@/types'; /** * Standard template format with locked attribute order * Text encoders are order-sensitive - this prevents drift */ const CHARACTER_TEMPLATE_STRING = "{age}-year-old {gender}, " + "{hairColor} {hairStyle} hair, " + "{eyeColor} eyes, " + "{skinTone} skin, " + "{bodyType} build, " + "wearing {outfit}, " + "{accessories}, " + "{distinguishingFeatures}"; /** * Parse a natural language description into a structured template * Uses regex patterns to extract key attributes */ export function parseDescriptionToTemplate(description: string): CharacterPromptTemplate { const template: CharacterPromptTemplate = { age: extractAge(description), gender: extractGender(description), hairColor: extractHairColor(description), hairStyle: extractHairStyle(description), eyeColor: extractEyeColor(description), skinTone: extractSkinTone(description), bodyType: extractBodyType(description), outfit: extractOutfit(description), accessories: extractAccessories(description), distinguishingFeatures: extractDistinguishingFeatures(description), }; return template; } /** * Build character prompt from template with locked order * This ensures consistency across all panel generations */ export function buildCharacterPrompt( template: CharacterPromptTemplate, action: string, setting: string, lighting: string, artStyle: string ): string { const base = CHARACTER_TEMPLATE_STRING .replace('{age}', template.age || 'young adult') .replace('{gender}', template.gender || 'person') .replace('{hairColor}', template.hairColor || '') .replace('{hairStyle}', template.hairStyle || '') .replace('{eyeColor}', template.eyeColor || '') .replace('{skinTone}', template.skinTone || '') .replace('{bodyType}', template.bodyType || 'average') .replace('{outfit}', template.outfit || 'casual clothing') .replace('{accessories}', template.accessories || 'no accessories') .replace('{distinguishingFeatures}', template.distinguishingFeatures || ''); return `${artStyle} style. ${base}, ${action}, ${setting}, ${lighting}`.replace(/\s+/g, ' ').trim(); } /** * Generate a deterministic seed from character name * Ensures reproducibility while allowing variation */ export function deriveSeedFromString(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return Math.abs(hash) % 2147483647; } /** * Generate panel-specific seed for consistency */ export function generatePanelSeed( projectId: string, pageNumber: number, panelNumber: number, panelId: string ): number { return deriveSeedFromString(`${projectId}_${pageNumber}_${panelNumber}_${panelId}`); } // Helper extraction functions using regex patterns function extractAge(description: string): string { const patterns = [ /(\d+)[-\s]?year[\s-]?old/i, /(\d+)[-\s]?years?\s+old/i, /(young|teen|adult|middle[\s-]?aged|elderly|old)/i, ]; for (const pattern of patterns) { const match = description.match(pattern); if (match) return match[1] || match[0]; } return 'young adult'; } function extractGender(description: string): string { if (/\b(female|woman|girl|lady)\b/i.test(description)) return 'female'; if (/\b(male|man|boy|guy)\b/i.test(description)) return 'male'; if (/\b(non[\s-]?binary|nb|enby)\b/i.test(description)) return 'non-binary'; return 'person'; } function extractHairColor(description: string): string { const colors = ['black', 'brown', 'blonde', 'blond', 'red', 'auburn', 'brunette', 'silver', 'gray', 'grey', 'white', 'blue', 'green', 'purple', 'pink', 'orange']; for (const color of colors) { const pattern = new RegExp(`\\b(${color}\\s+hair|${color}-haired)\\b`, 'i'); if (pattern.test(description)) return color.replace('blond', 'blonde'); } return ''; } function extractHairStyle(description: string): string { const styles = ['long', 'short', 'medium', 'curly', 'straight', 'wavy', 'spikey', 'spiky', 'ponytail', 'bun', 'braid', 'buzz cut', 'bald']; for (const style of styles) { if (description.toLowerCase().includes(style)) return style; } return ''; } function extractEyeColor(description: string): string { const colors = ['blue', 'brown', 'green', 'hazel', 'amber', 'gray', 'grey', 'violet', 'purple']; for (const color of colors) { if (new RegExp(`\\b${color}\\s+eyes?\\b`, 'i').test(description)) return color; } return ''; } function extractSkinTone(description: string): string { const tones = ['fair', 'pale', 'light', 'medium', 'tan', 'olive', 'brown', 'dark']; for (const tone of tones) { if (new RegExp(`\\b${tone}\\s+(skin|complexion)\\b`, 'i').test(description)) return tone; } return ''; } function extractBodyType(description: string): string { const types = ['slim', 'skinny', 'thin', 'average', 'athletic', 'muscular', 'heavy', 'large', 'tall', 'short', 'petite']; for (const type of types) { if (new RegExp(`\\b${type}\\s+(build|body|frame)\\b`, 'i').test(description)) return type; if (description.toLowerCase().includes(type)) return type; } return 'average'; } function extractOutfit(description: string): string { const patterns = [ /wearing\s+([^,.]+)/i, /dressed\s+(?:in|as)\s+([^,.]+)/i, /outfit[:\s]+([^,.]+)/i, /clothing[:\s]+([^,.]+)/i, ]; for (const pattern of patterns) { const match = description.match(pattern); if (match) return match[1].trim(); } return 'casual clothing'; } function extractAccessories(description: string): string { const accessories: string[] = []; const accessoryPatterns = [ /\b(glasses|sunglasses)\b/i, /\b(hat|cap|beanie)\b/i, /\b(scarf)\b/i, /\b(necklace|pendant)\b/i, /\b(earrings?)\b/i, /\b(bracelet|watch)\b/i, /\b(ring)\b/i, /\b(backpack|bag)\b/i, /\b(gloves)\b/i, /\b(belt)\b/i, ]; for (const pattern of accessoryPatterns) { const match = description.match(pattern); if (match) accessories.push(match[0]); } return accessories.length > 0 ? accessories.join(', ') : 'no accessories'; } function extractDistinguishingFeatures(description: string): string { const features: string[] = []; const featurePatterns = [ /\b(scar|scars)\s+(?:on|across)\s+([^,.]+)/i, /\b(tattoo|tattoos)\s+(?:on|of)\s+([^,.]+)/i, /\b(freckles)\b/i, /\b(mole|birthmark)\b/i, /\b(beard|mustache|goatee)\b/i, /\b(glasses|monocle)\b/i, /\b(prosthetic)\b/i, ]; for (const pattern of featurePatterns) { const match = description.match(pattern); if (match) features.push(match[0]); } return features.join(', '); } /** * Extract color palette from character description * Uses simple keyword matching - can be enhanced with image analysis */ export function extractColorPalette(description: string): { hair: string; eyes: string; skin: string; outfit: string } { return { hair: extractHairColor(description), eyes: extractEyeColor(description), skin: extractSkinTone(description), outfit: extractOutfit(description).split(' ')[0] || '', }; } /** * Update character with parsed template from description */ export function updateCharacterFromDescription(character: Character): Character { const template = parseDescriptionToTemplate(character.description); const colorPalette = extractColorPalette(character.description); return { ...character, promptTemplate: template, colorPalette, seed: character.seed || deriveSeedFromString(character.name), }; }