// Package personagen provides persona generation services using LLM and media generation pipelines. // It orchestrates a 5-stage spec generation pipeline (text) and image/video generation (media). // // Usage: // // svc := personagen.New(textgenManager, mediagenManager, store, logger) // spec, err := svc.GenerateSpec(ctx, personagen.SeedParams{ // Description: "mysterious woman with dark hair who loves poetry", // Gender: "woman", // }) // err = svc.GenerateImages(ctx, spec, nil) // all 20 positions // video, err := svc.GenerateVideo(ctx, spec, persona.MotionSmileReveal) package personagen import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "log/slog" "strings" "time" "git.threesix.ai/jordan/persona-community-5/pkg/mediagen" "git.threesix.ai/jordan/persona-community-5/pkg/persona" "git.threesix.ai/jordan/persona-community-5/pkg/queue" "git.threesix.ai/jordan/persona-community-5/pkg/realtime" "git.threesix.ai/jordan/persona-community-5/pkg/storage" "git.threesix.ai/jordan/persona-community-5/pkg/textgen" ) // ErrAnchorNotSet is returned when GenerateVideo is called without a set anchor image. // Call SetAnchor() or run GenerateImages() (which generates position 1) first. var ErrAnchorNotSet = errors.New("anchor image not set: call SetAnchor() or GenerateImages() first") // SeedParams contains the initial inputs for the persona generation pipeline. type SeedParams struct { // Description is a natural-language persona concept (required). // Example: "mysterious woman with dark hair who loves poetry" Description string // Gender is the gender identity: "woman", "man", or "non_binary" (required). Gender string // Name is an optional name override. If empty, the LLM generates one. Name string } // Service generates complete persona specs, images, and videos. // Create with New(). Safe for concurrent use — each job should create its own instance // to avoid shared anchor state between concurrent generations. type Service struct { textgen *textgen.Manager mediagen *mediagen.Manager store storage.Store logger *slog.Logger anchor []byte // position 1 PNG bytes — identity anchor for subsequent generations } // New creates a new personagen Service. func New(tg *textgen.Manager, mg *mediagen.Manager, store storage.Store, logger *slog.Logger) *Service { return &Service{ textgen: tg, mediagen: mg, store: store, logger: logger.With("pkg", "personagen"), } } // SetAnchor updates the anchor image used for identity consistency in subsequent generations. // The anchor is the position 1 image — always generated first to establish identity. func (s *Service) SetAnchor(imageBytes []byte) { s.anchor = imageBytes } // GenerateSpec runs the 5-stage LLM pipeline to produce a complete PersonaSpec. // Stages: (1) identity, (2) psychology, (3) lifestyle, (4) visual DNA, (5) image matrix. func (s *Service) GenerateSpec(ctx context.Context, seed SeedParams) (*persona.PersonaSpec, error) { return generatePersonaSpec(ctx, s.textgen, seed, s.logger) } // GenerateImages generates images for the specified positions in the image matrix. // If positions is nil or empty, all 20 positions are generated sequentially. // Position 1 (the anchor) is always generated first when included. // Automatically calls SetAnchor() after position 1 is generated. func (s *Service) GenerateImages(ctx context.Context, spec *persona.PersonaSpec, positions []int) error { if len(positions) == 0 { positions = make([]int, 20) for i := range positions { positions[i] = i + 1 } } // Check if position 1 is in the list — it must be generated first. hasPos1 := false for _, p := range positions { if p == 1 { hasPos1 = true break } } if hasPos1 { if err := s.generatePosition(ctx, spec, 1); err != nil { return fmt.Errorf("generating anchor position 1: %w", err) } // Remove position 1 from remaining remaining := positions[:0] for _, p := range positions { if p != 1 { remaining = append(remaining, p) } } positions = remaining } for _, pos := range positions { if err := s.generatePosition(ctx, spec, pos); err != nil { return fmt.Errorf("generating position %d: %w", pos, err) } } return nil } // GenerateVideo generates a video for the given motion type and uploads it to storage. // Requires SetAnchor() to have been called first (or GenerateImages() for position 1). // Returns ErrAnchorNotSet if no anchor is available. func (s *Service) GenerateVideo(ctx context.Context, spec *persona.PersonaSpec, motionType persona.MotionType) (*persona.VideoSpec, error) { if s.anchor == nil { return nil, ErrAnchorNotSet } videoSpec, videoData, err := generateVideo(ctx, s.mediagen, spec, motionType, s.anchor, s.logger) if err != nil { return nil, err } storagePath := fmt.Sprintf("personas/%s/videos/%s.mp4", spec.ID, string(motionType)) url, err := s.store.Upload(ctx, storagePath, videoData, "video/mp4") if err != nil { videoSpec.Status = persona.VideoStatusFailed return nil, fmt.Errorf("storing video %s: %w", motionType, err) } videoSpec.URL = url return videoSpec, nil } // GenerateAvatar generates a square profile picture (close-up face, 1:1 crop). // Uses the anchor image for identity consistency if available. func (s *Service) GenerateAvatar(ctx context.Context, spec *persona.PersonaSpec) ([]byte, error) { if s.mediagen == nil { return nil, fmt.Errorf("mediagen not configured") } prompt := buildAvatarPrompt(spec) req := mediagen.ImageRequest{ Prompt: prompt, AspectRatio: "1:1", } if s.anchor != nil { req.ReferenceImage = s.anchor req.ReferenceMime = "image/png" } resp, err := s.mediagen.GenerateImage(ctx, req) if err != nil { return nil, fmt.Errorf("generating avatar: %w", err) } if len(resp.Images) == 0 { return nil, fmt.Errorf("no images returned from provider") } return resp.Images[0].Data, nil } // GenerateBanner generates a wide banner image (16:9, landscape). // style hints at the backdrop mood (e.g., "lifestyle", "luxury", "outdoor"). func (s *Service) GenerateBanner(ctx context.Context, spec *persona.PersonaSpec, style string) ([]byte, error) { if s.mediagen == nil { return nil, fmt.Errorf("mediagen not configured") } prompt := buildBannerPrompt(spec, style) req := mediagen.ImageRequest{ Prompt: prompt, AspectRatio: "16:9", } if s.anchor != nil { req.ReferenceImage = s.anchor req.ReferenceMime = "image/png" } resp, err := s.mediagen.GenerateImage(ctx, req) if err != nil { return nil, fmt.Errorf("generating banner: %w", err) } if len(resp.Images) == 0 { return nil, fmt.Errorf("no images returned from provider") } return resp.Images[0].Data, nil } // generatePosition generates and stores a single image position in the spec. func (s *Service) generatePosition(ctx context.Context, spec *persona.PersonaSpec, pos int) error { var imgSpec *persona.ImageSpec for i := range spec.ImageMatrix { if spec.ImageMatrix[i].Position == pos { imgSpec = &spec.ImageMatrix[i] break } } if imgSpec == nil { return fmt.Errorf("position %d not found in image matrix", pos) } imageBytes, err := generateImage(ctx, s.mediagen, spec, imgSpec, s.anchor, s.logger) if err != nil { imgSpec.Status = persona.ImageStatusFailed return err } // Position 1 becomes the anchor for all subsequent generations. if pos == 1 { s.anchor = imageBytes spec.AnchorImage = imageBytes } storagePath := fmt.Sprintf("personas/%s/images/%02d.png", spec.ID, pos) url, err := s.store.Upload(ctx, storagePath, imageBytes, "image/png") if err != nil { imgSpec.Status = persona.ImageStatusFailed return fmt.Errorf("storing position %d: %w", pos, err) } if imgSpec.Prompt != "" { captionPath := fmt.Sprintf("personas/%s/images/%02d.caption", spec.ID, pos) if _, captionErr := s.store.Upload(ctx, captionPath, []byte(imgSpec.Prompt), "text/plain"); captionErr != nil { s.logger.Warn("failed to persist image caption", "error", captionErr, "position", pos) } } imgSpec.URL = url imgSpec.Status = persona.ImageStatusComplete return nil } // QueueHandler returns a queue.Handler for processing persona_generate jobs. // Creates a fresh Service per job to avoid shared anchor state between concurrent jobs. // Publishes SSE events to the user's channel throughout generation. func QueueHandler(tg *textgen.Manager, mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, logger *slog.Logger) queue.Handler { return func(ctx context.Context, job *queue.Job) error { userID, _ := job.Payload["userID"].(string) if userID == "" { return fmt.Errorf("missing userID in persona_generate job payload") } description, _ := job.Payload["description"].(string) gender, _ := job.Payload["gender"].(string) name, _ := job.Payload["name"].(string) sendEvent := func(event *realtime.SSEEvent) { if err := pub.SendToUser(userID, event); err != nil { logger.Warn("failed to send persona SSE event", "error", err, "type", event.Type) } } svc := New(tg, mg, store, logger) seed := SeedParams{Description: description, Gender: gender, Name: name} sendEvent(&realtime.SSEEvent{ Type: "persona_spec_started", JobID: job.ID, Message: "Generating persona profile...", }) spec, err := svc.GenerateSpec(ctx, seed) if err != nil { logger.Error("persona spec generation failed", "error", err, "job_id", job.ID) sendEvent(&realtime.SSEEvent{ Type: "persona_failed", JobID: job.ID, Error: "Spec generation failed: " + err.Error(), }) return err } sendEvent(&realtime.SSEEvent{ Type: "persona_spec_complete", JobID: job.ID, Message: "Profile complete, generating images...", Result: map[string]any{"personaId": spec.ID}, }) // Build an ordered position list — position 1 (anchor) must always be generated first. // generatePosition() mutates the spec.ImageMatrix entry in place (URL, Status), // so we keep a pointer to each entry to read the URL after generation. type posEntry struct { pos int imgSpec *persona.ImageSpec } orderedPositions := make([]posEntry, 0, len(spec.ImageMatrix)) for i := range spec.ImageMatrix { orderedPositions = append(orderedPositions, posEntry{ pos: spec.ImageMatrix[i].Position, imgSpec: &spec.ImageMatrix[i], }) } // Swap position 1 to front if it isn't already. for i, e := range orderedPositions { if e.pos == 1 && i != 0 { orderedPositions[0], orderedPositions[i] = orderedPositions[i], orderedPositions[0] break } } // Generate all 20 image positions, publishing progress events. for _, entry := range orderedPositions { pos := entry.pos sendEvent(&realtime.SSEEvent{ Type: "persona_image_started", JobID: job.ID, Message: fmt.Sprintf("Generating position %d...", pos), Result: map[string]any{"position": pos}, }) if err := svc.generatePosition(ctx, spec, pos); err != nil { logger.Error("persona image generation failed", "error", err, "position", pos, "job_id", job.ID) sendEvent(&realtime.SSEEvent{ Type: "persona_failed", JobID: job.ID, Error: fmt.Sprintf("Image position %d failed: %s", pos, err.Error()), }) return err } progress := (pos * 100) / 20 sendEvent(&realtime.SSEEvent{ Type: "persona_image_progress", JobID: job.ID, Progress: progress, Result: map[string]any{"position": pos, "url": entry.imgSpec.URL}, }) } sendEvent(&realtime.SSEEvent{ Type: "persona_image_complete", JobID: job.ID, Progress: 100, Message: "All images generated", Result: map[string]any{"personaId": spec.ID}, }) // Generate 4 videos. Videos are best-effort — a failed video does not abort the job, // but a persona_video_failed event is sent so the frontend can reflect partial completion. for _, vs := range spec.Videos { sendEvent(&realtime.SSEEvent{ Type: "persona_video_started", JobID: job.ID, Message: fmt.Sprintf("Generating %s video...", vs.MotionType), Result: map[string]any{"motionType": string(vs.MotionType)}, }) videoSpec, err := svc.GenerateVideo(ctx, spec, vs.MotionType) if err != nil { logger.Warn("persona video generation failed (non-fatal)", "error", err, "motion", vs.MotionType, "job_id", job.ID) sendEvent(&realtime.SSEEvent{ Type: "persona_video_failed", JobID: job.ID, Error: fmt.Sprintf("%s video failed: %s", vs.MotionType, err.Error()), Result: map[string]any{"motionType": string(vs.MotionType)}, }) continue } sendEvent(&realtime.SSEEvent{ Type: "persona_video_complete", JobID: job.ID, Message: "Video complete", Result: map[string]any{"motionType": string(vs.MotionType), "url": videoSpec.URL}, }) } logger.Info("persona generation complete", "job_id", job.ID, "persona_id", spec.ID) return nil } } // generateID creates a random hex ID for a new persona spec. func generateID() string { b := make([]byte, 12) _, _ = rand.Read(b) return "ps_" + hex.EncodeToString(b) } // buildAvatarPrompt creates a close-up portrait prompt for avatar generation. func buildAvatarPrompt(spec *persona.PersonaSpec) string { return fmt.Sprintf( "%s Close-up portrait, square 1:1 composition, face centered and sharp, soft bokeh background, professional headshot quality.", buildIdentitySection(spec), ) } // buildBannerPrompt creates a wide landscape prompt for banner generation. func buildBannerPrompt(spec *persona.PersonaSpec, style string) string { if style == "" { style = "lifestyle" } return fmt.Sprintf( "%s Wide landscape banner, 16:9 cinematic composition, %s aesthetic, professional photography quality.", buildIdentitySection(spec), style, ) } // inferGenerationTier infers a generation tier from the description keywords (case-insensitive). func inferGenerationTier(description string) persona.GenerationTier { lower := strings.ToLower(description) for _, kw := range []string{"supermodel", "model", "editorial", "high fashion"} { if strings.Contains(lower, kw) { return persona.GenerationTierSupermodel } } for _, kw := range []string{"influencer", "content creator", "blogger", "social media"} { if strings.Contains(lower, kw) { return persona.GenerationTierInfluencer } } return persona.GenerationTierEveryday } // inferAttractiveness infers an attractiveness tier from the generation tier. func inferAttractiveness(tier persona.GenerationTier) persona.AttractivenessTier { switch tier { case persona.GenerationTierSupermodel: return persona.AttractivenessTierStunning case persona.GenerationTierInfluencer: return persona.AttractivenessTierVery default: return persona.AttractivenessTierAttractive } } // now returns the current time. Useful for overriding in tests. var now = func() time.Time { return time.Now() }