comic/src/utils/characterTemplates.ts
2026-05-18 19:58:01 +02:00

249 lines
7.8 KiB
TypeScript

/**
* 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),
};
}