rdev/internal/adapter/templates/templates/skeleton/pkg/personagen/imagegen.go.tmpl
jordan 002c32aedb feat: add album generation system to skeleton
Adds anchor-based image album generation across docs, skeleton, and rendered
full-monorepo. One subject description + one anchor image + N directed shots,
covering personas, products, characters, and brand assets out of the box.

## What ships

**Skeleton packages:**
- pkg/album/types.go — Album, Shot, ShotStatus, ShotTemplate, AlbumUpdater
- pkg/album/templates.go — PortraitSession, ProductShoot, CharacterSheet built-ins
- pkg/album/handler.go — AnchorHandler + ShotHandler queue job handlers
- packages/realtime/src/useAlbumGeneration.ts — SSE hook owning all album state
- packages/ui/src/components/AlbumGrid.tsx — responsive shot grid with shimmer
- packages/ui/src/components/ShotCard.tsx — pending/generating/complete/failed states
- packages/ui/src/components/AnchorPreview.tsx — anchor CTA + image with controls

**Component service template:**
- internal/port/album.go — AlbumRepository interface
- internal/adapter/memory/album.go — in-memory repo for standalone dev
- internal/service/album.go — create, list, get, generateAnchor, generateAllShots
- internal/api/handlers/album.go — HTTP handlers (CRUD + 202 generation endpoints)
- Routes: GET/POST /albums, GET/DELETE /albums/{id}, POST /albums/{id}/anchor,
  POST/DELETE /albums/{id}/shots, POST /albums/{id}/shots/{index}

**Documentation:**
- .claude/guides/album.md — full guide with API, SSE events, frontend usage

**Key architecture decisions:**
- Anchor bytes never stored in queue payload — workers fetch AnchorURL at runtime
- Generation order enforced: POST /shots returns 422 if no anchor exists
- All album SSE events on existing user:<userId> channel (no new channel)
- AlbumUpdater interface lets job handlers update repo from inside queue workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 23:57:21 -07:00

230 lines
6.4 KiB
Cheetah

package personagen
import (
"context"
"fmt"
"log/slog"
"strings"
"{{GO_MODULE}}/pkg/mediagen"
"{{GO_MODULE}}/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."
func buildIdentitySection(spec *persona.PersonaSpec) string {
if spec.DNA == nil {
return ""
}
id := spec.DNA.Identity
body := spec.DNA.Body
heightFt := cmToFeet(body.HeightCM)
return fmt.Sprintf(
"[IDENTITY] %d-year-old %s %s, %s (%dcm), %s build.",
id.Age,
ethnicitToAdj(id.Ethnicity),
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)
}
}