package personagen import ( "context" "fmt" "log/slog" "strings" "git.threesix.ai/jordan/persona-community-5/pkg/mediagen" "git.threesix.ai/jordan/persona-community-5/pkg/persona" ) // generateImage builds a HEIA prompt and calls the mediagen provider for one image position. // The anchor bytes (position 1 image) are passed as a reference image for identity consistency. func generateImage( ctx context.Context, mg *mediagen.Manager, spec *persona.PersonaSpec, imgSpec *persona.ImageSpec, anchor []byte, logger *slog.Logger, ) ([]byte, error) { if mg == nil { return nil, fmt.Errorf("mediagen not configured") } prompt := buildHEIAPrompt(spec, imgSpec) imgSpec.Prompt = prompt req := mediagen.ImageRequest{ Prompt: prompt, AspectRatio: "9:16", } // For all positions after position 1, use the anchor image as a reference. if imgSpec.Position > 1 && anchor != nil { req.ReferenceImage = anchor req.ReferenceMime = "image/png" } logger.Info("generating image position", "position", imgSpec.Position, "tier", imgSpec.TierName, "expression", imgSpec.Expression, ) resp, err := mg.GenerateImage(ctx, req) if err != nil { return nil, fmt.Errorf("image provider error: %w", err) } if len(resp.Images) == 0 { return nil, fmt.Errorf("no images returned from provider for position %d", imgSpec.Position) } return resp.Images[0].Data, nil } // buildHEIAPrompt assembles the full HEIA (High-Engagement Influencer Aesthetic) prompt. // Section order: [IDENTITY] [FACE] [BODY] [POSE] [CLOTHING] [SCENE] [CONSTRAINTS] func buildHEIAPrompt(spec *persona.PersonaSpec, imgSpec *persona.ImageSpec) string { var sb strings.Builder sb.WriteString(buildIdentitySection(spec)) sb.WriteString(" ") sb.WriteString(buildBiologicalSection(spec)) sb.WriteString(" ") sb.WriteString(buildPoseSection(imgSpec)) sb.WriteString(" ") sb.WriteString(buildClothingSection(spec, imgSpec)) sb.WriteString(" ") sb.WriteString(buildSceneSection(imgSpec)) sb.WriteString(" ") sb.WriteString(buildConstraintsSection(spec)) return strings.TrimSpace(sb.String()) } // buildIdentitySection creates the [IDENTITY] section. // Example: "[IDENTITY] 26-year-old Korean woman, 5'4" (163cm), slender-athletic build." // For mixed-race personas with a resolved heritage breakdown, produces e.g. // "[IDENTITY] 26-year-old East Asian and Latina/Hispanic heritage woman, ..." func buildIdentitySection(spec *persona.PersonaSpec) string { if spec.DNA == nil { return "" } id := spec.DNA.Identity body := spec.DNA.Body ethnicityDesc := ethnicitToAdj(id.Ethnicity) if id.SecondaryHeritage != nil { ethnicityDesc = fmt.Sprintf( "%s and %s heritage", ethnicitToAdj(id.PrimaryHeritage), ethnicitToAdj(*id.SecondaryHeritage), ) } heightFt := cmToFeet(body.HeightCM) return fmt.Sprintf( "[IDENTITY] %d-year-old %s %s, %s (%dcm), %s build.", id.Age, ethnicityDesc, strings.ToLower(string(id.Gender)), heightFt, body.HeightCM, strings.ReplaceAll(string(body.Build), "_", "-"), ) } // buildBiologicalSection creates [FACE] and [BODY] sections from DNA. func buildBiologicalSection(spec *persona.PersonaSpec) string { if spec.DNA == nil { return "" } face := spec.DNA.Face body := spec.DNA.Body faceDesc := fmt.Sprintf( "[FACE] %s face with %s cheekbones, %s jawline, %s eyes (%s, %s, %s), %s nose, %s %s lips, %s brows, %s %s skin, %s %s hair.", string(face.FaceShape), string(face.Cheekbones), string(face.Jawline), string(face.EyeShape), string(face.EyeColor), string(face.EyeSize), string(face.EyeSpacing), string(face.NoseShape), string(face.LipFullness), string(face.LipShape), string(face.BrowShape), string(face.SkinTone), string(face.SkinUndertone), string(face.HairLength), string(face.HairTexture), ) bodyDesc := fmt.Sprintf( "[BODY] %s build, %s shoulders, %.2f WHR, %s posture.", string(body.Build), string(body.ShoulderWidth), body.WHRatio, string(body.PostureType), ) return faceDesc + " " + bodyDesc } // buildPoseSection creates the [POSE] section from the image spec. func buildPoseSection(imgSpec *persona.ImageSpec) string { return fmt.Sprintf( "[POSE] %s distance, %s angle, %s, %s expression, %s.", imgSpec.Distance, imgSpec.Angle, imgSpec.SubjectPosition, imgSpec.Expression, imgSpec.Pose, ) } // buildClothingSection creates the [CLOTHING] section. func buildClothingSection(spec *persona.PersonaSpec, imgSpec *persona.ImageSpec) string { outfit := imgSpec.Outfit if outfit == "" { // Fall back to fashion context key pieces if outfit not populated. if imgSpec.FashionContext != "" { ctx := persona.FashionContextFor(persona.FashionContextName(imgSpec.FashionContext)) if len(ctx.KeyPieces) > 0 { outfit = strings.Join(ctx.KeyPieces[:min(2, len(ctx.KeyPieces))], ", ") } } } clothingState := imgSpec.ClothingState if clothingState == "" { clothingState = "stylish" } return fmt.Sprintf("[CLOTHING] %s — %s style.", outfit, clothingState) } // buildSceneSection creates the [SCENE] section from the image spec. func buildSceneSection(imgSpec *persona.ImageSpec) string { scene := imgSpec.Scene if scene == "" { scene = "neutral background, professional lighting" } return fmt.Sprintf("[SCENE] %s.", scene) } // buildConstraintsSection creates the [CONSTRAINTS] section with anatomical integrity rules. func buildConstraintsSection(spec *persona.PersonaSpec) string { if spec.DNA == nil { return "" } genderUpper := strings.ToUpper(string(spec.DNA.Identity.Gender)) return fmt.Sprintf( "[CONSTRAINTS] %s SUBJECT ONLY. Human body has EXACTLY 2 arms, 2 legs, 10 fingers total. "+ "NO extra limbs, merged fingers, or anatomical errors. "+ "Single coherent face, no duplicate facial features. "+ "Maintain consistent skin tone throughout: %s %s skin.", genderUpper, string(spec.DNA.Face.SkinTone), string(spec.DNA.Face.SkinUndertone), ) } // ── Conversion helpers ───────────────────────────────────────────────────── // cmToFeet converts centimeters to a "5'5\"" style string. func cmToFeet(cm int) string { totalInches := float64(cm) / 2.54 feet := int(totalInches) / 12 inches := int(totalInches) % 12 return fmt.Sprintf(`%d'%d"`, feet, inches) } // ethnicitToAdj converts an EthnicityCode to a natural-language adjective. func ethnicitToAdj(e persona.EthnicityCode) string { switch e { case persona.EthnicityEastAsian: return "East Asian" case persona.EthnicitySouthAsian: return "South Asian" case persona.EthnicitySoutheastAsian: return "Southeast Asian" case persona.EthnicityAfrican: return "Black" case persona.EthnicityHispanic: return "Latina/Hispanic" case persona.EthnicityMiddleEastern: return "Middle Eastern" case persona.EthnicityCaucasian: return "white" case persona.EthnicityMixed: return "mixed-race" default: return string(e) } }