249 lines
7.8 KiB
TypeScript
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),
|
|
};
|
|
}
|