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>
This commit is contained in:
jordan 2026-02-22 23:57:21 -07:00
parent 4603402b84
commit 002c32aedb
36 changed files with 5073 additions and 14 deletions

3
.gitignore vendored
View File

@ -47,3 +47,6 @@ tmp/
# Rendered example monorepo (regenerated from templates)
examples/full-monorepo/
# SDK: spec is generated at build time, not committed
sdk/openapi.json

View File

@ -3,6 +3,8 @@ package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
@ -44,6 +46,21 @@ import (
var version = "dev"
func main() {
// Export OpenAPI spec to stdout and exit (no DB/K8s/secrets needed)
exportOpenAPI := flag.Bool("export-openapi", false, "Export OpenAPI spec to stdout and exit")
flag.Parse()
if *exportOpenAPI {
spec := buildOpenAPISpec()
jsonBytes, err := spec.JSON()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to generate OpenAPI spec: %v\n", err)
os.Exit(1)
}
fmt.Println(string(jsonBytes))
os.Exit(0)
}
// Initialize structured logging from environment configuration
logCfg := logging.ConfigFromEnv()
appLogger := logging.New(logCfg)

View File

@ -198,6 +198,7 @@ func registerProjectPaths(spec *api.OpenAPISpec) {
))
spec.AddPath("/projects/cleanup", "delete", map[string]any{
"operationId": "cleanupProjects",
"summary": "Cleanup test projects",
"description": `Deletes test projects matching the given name patterns that are older than a specified age.
@ -339,6 +340,7 @@ func registerCommandPaths(spec *api.OpenAPISpec) {
func registerEventPaths(spec *api.OpenAPISpec) {
spec.AddPath("/projects/{id}/events", "get", map[string]any{
"operationId": "streamProjectEvents",
"summary": "Stream events",
"description": `Server-Sent Events stream for real-time command output.
@ -487,6 +489,7 @@ func registerConfigTypePaths(spec *api.OpenAPISpec, typePlural, typeSingular, ty
func registerAuditPaths(spec *api.OpenAPISpec) {
spec.AddPath("/audit-log", "get", map[string]any{
"operationId": "listAuditLogEntries",
"summary": "List audit log entries",
"description": `Returns audit log entries with optional filtering.

View File

@ -1,6 +1,36 @@
package main
import "github.com/orchard9/rdev/pkg/api"
import (
"strings"
"github.com/orchard9/rdev/pkg/api"
)
// summaryToOperationID converts a human-readable summary to a camelCase operationId.
// "List API keys" → "listAPIKeys", "Health check" → "healthCheck"
func summaryToOperationID(summary string) string {
words := strings.Fields(summary)
var sb strings.Builder
first := true
for _, word := range words {
clean := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, word)
if clean == "" {
continue
}
if first {
sb.WriteString(strings.ToLower(clean[:1]) + clean[1:])
first = false
} else {
sb.WriteString(strings.ToUpper(clean[:1]) + clean[1:])
}
}
return sb.String()
}
func registerAgentPaths(spec *api.OpenAPISpec) {
spec.AddPath("/agents", "get", withAuth(
@ -104,6 +134,7 @@ type param struct {
// withAuth creates an operation that requires authentication.
func withAuth(summary, description, tag, scope, example string) map[string]any {
return map[string]any{
"operationId": summaryToOperationID(summary),
"summary": summary,
"description": description + "\n\n**Required scope**: `" + scope + "`",
"tags": []string{tag},
@ -128,6 +159,7 @@ func withAuth(summary, description, tag, scope, example string) map[string]any {
// withAuthAndBody creates an operation with auth and request body.
func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any {
return map[string]any{
"operationId": summaryToOperationID(summary),
"summary": summary,
"description": description + "\n\n**Required scope**: `" + scope + "`",
"tags": []string{tag},
@ -171,6 +203,7 @@ func withAuthAndParams(summary, description, tag, scope string, params []param)
}
}
return map[string]any{
"operationId": summaryToOperationID(summary),
"summary": summary,
"description": description + "\n\n**Required scope**: `" + scope + "`",
"tags": []string{tag},
@ -200,6 +233,7 @@ func withAuthBodyAndParams(summary, description, tag, scope string, params []par
}
}
return map[string]any{
"operationId": summaryToOperationID(summary),
"summary": summary,
"description": description + "\n\n**Required scope**: `" + scope + "`",
"tags": []string{tag},
@ -282,6 +316,7 @@ func registerSDLCPaths(spec *api.OpenAPISpec) {
))
spec.AddPath("/projects/{id}/sdlc/next", "get", map[string]any{
"operationId": "getSdlcNextAction",
"summary": "Get next action",
"description": "Returns the classifier's recommended next action.\n\n**Required scope**: `projects:read`",
"tags": []string{"SDLC"},
@ -741,6 +776,7 @@ All components are created in a single transaction. If any component fails, the
// Use wildcard for dynamic component path
spec.AddPath("/projects/{id}/components/{path}", "delete", map[string]any{
"operationId": "removeComponent",
"summary": "Remove component",
"description": "Removes a component from the project's monorepo.\n\n**Required scope**: `projects:execute`",
"tags": []string{"Components"},
@ -774,6 +810,7 @@ All components are created in a single transaction. If any component fails, the
func registerCredentialPaths(spec *api.OpenAPISpec) {
spec.AddPath("/credentials", "get", map[string]any{
"operationId": "listCredentials",
"summary": "List credentials",
"description": `Returns all infrastructure credentials with values masked.
@ -867,6 +904,7 @@ Useful for bulk credential loading from configuration files.`,
))
spec.AddPath("/credentials/{key}", "delete", map[string]any{
"operationId": "deleteCredential",
"summary": "Delete credential",
"description": "Removes a credential permanently.\n\n**Required scope**: `admin`",
"tags": []string{"Credentials"},
@ -946,6 +984,7 @@ Includes screenshot URLs, video URL (if recorded), and duration.`,
))
spec.AddPath("/verify/{taskId}/stream", "get", map[string]any{
"operationId": "streamVerifyEvents",
"summary": "Stream verification events",
"description": `Streams real-time verification task events via Server-Sent Events (SSE).
@ -1001,6 +1040,7 @@ Stops Playwright browser and marks task as cancelled.`,
))
spec.AddPath("/projects/{id}/verify", "get", map[string]any{
"operationId": "listProjectVerifications",
"summary": "List project verifications",
"description": `Returns verification tasks for a project with pagination.
@ -1139,6 +1179,7 @@ Useful for applying configuration changes or recovering from errors.`,
))
spec.AddPath("/projects/{id}/deploy/logs", "get", map[string]any{
"operationId": "getDeploymentLogs",
"summary": "Get deployment logs",
"description": `Returns recent logs from a project's deployment pods.
@ -1190,6 +1231,7 @@ Creates DNS A record if domain is a subdomain of the configured base domain (e.g
))
spec.AddPath("/projects/{id}/domain", "delete", map[string]any{
"operationId": "removeProjectDomain",
"summary": "Remove domain",
"description": `Removes a custom domain from a project.
@ -1306,6 +1348,7 @@ Part of the enterprise resilience architecture for handling transient failures.`
func registerWebhookPaths(spec *api.OpenAPISpec) {
spec.AddPath("/webhooks/woodpecker", "post", map[string]any{
"operationId": "receiveWoodpeckerWebhook",
"summary": "Woodpecker CI webhook",
"description": `Receives build event webhooks from Woodpecker CI.
@ -1406,6 +1449,7 @@ Sagas are distributed workflows with automatic compensation on failure. Useful f
))
spec.AddPath("/sagas", "get", map[string]any{
"operationId": "listSagas",
"summary": "List sagas",
"description": `Returns sagas with optional filtering.

View File

@ -11,7 +11,9 @@ import (
"github.com/redis/go-redis/v9"
"{{GO_MODULE}}/pkg/album"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/personagen"
"{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/gemini"
"{{GO_MODULE}}/pkg/laozhang"
@ -91,6 +93,7 @@ func main() {
// With DATABASE_URL: Postgres repos + DB queue (production)
// Without DATABASE_URL: in-memory repos + in-process AI (development)
exampleRepo := memory.NewExampleRepository()
albumRepo := memory.NewAlbumRepository()
var userRepo port.UserRepository
var sessionRepo port.SessionRepository
var authCodeRepo port.AuthCodeRepository
@ -139,7 +142,7 @@ func main() {
sessionRepo = memory.NewSessionRepository()
authCodeRepo = memory.NewAuthCodeRepository()
mediaRepo = memory.NewMediaRepository()
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, sseHub, logger)
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger)
}
// Validate required config.
@ -183,6 +186,7 @@ func main() {
// Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger)
albumService := service.NewAlbumService(albumRepo, jobQueue, logger)
authService := service.NewAuthService(
userRepo, sessionRepo, authCodeRepo, emailSender,
cfg.JWTSecret, cfg.RegistrationEnabled, logger,
@ -200,6 +204,7 @@ func main() {
api.RegisterRoutes(application, &api.Dependencies{
ExampleService: exampleService,
AuthService: authService,
AlbumService: albumService,
Queue: jobQueue,
JobReader: jobReader,
SSEHub: sseHub,
@ -255,7 +260,7 @@ func setupDBQueue(ctx context.Context, cfg *config.Config, pool *database.Pool,
// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
// This mode requires no database or Redis — everything runs in a single process.
// Returns both Producer (for enqueue) and JobReader (for status polling).
func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) {
func setupStandaloneQueue(ctx context.Context, store storage.Store, albumUpdater album.AlbumUpdater, sseHub *realtime.SSEHub, logger *logging.Logger) (queue.Producer, queue.JobReader) {
memQueue := queue.NewMemoryQueue(logger.Logger)
// LocalPublisher delivers events directly to the SSE hub (no Redis needed).
@ -269,12 +274,19 @@ func setupStandaloneQueue(ctx context.Context, store storage.Store, sseHub *real
if mediagenManager != nil {
memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
memQueue.RegisterHandler("generate_video", generation.VideoHandler(mediagenManager, store, pub, logger))
memQueue.RegisterHandler("generate_anchor", album.AnchorHandler(mediagenManager, store, pub, albumUpdater, logger))
memQueue.RegisterHandler("generate_shot", album.ShotHandler(mediagenManager, store, pub, albumUpdater, logger))
}
if textgenManager != nil {
memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(textgenManager, pub, logger))
}
// Persona generation requires both textgen (5-stage LLM pipeline) and mediagen (20 images + 4 videos).
if textgenManager != nil && mediagenManager != nil {
memQueue.RegisterHandler("persona_generate", personagen.QueueHandler(textgenManager, mediagenManager, store, pub, logger.Logger))
}
return memQueue, memQueue
}

View File

@ -0,0 +1,177 @@
package memory
import (
"context"
"fmt"
"sync"
"time"
"{{GO_MODULE}}/pkg/album"
)
// AlbumRepository is an in-memory implementation of port.AlbumRepository.
// Used in standalone dev mode (no DATABASE_URL). Not safe for persistence across restarts.
type AlbumRepository struct {
mu sync.RWMutex
albums map[album.AlbumID]*album.Album
}
// NewAlbumRepository creates an in-memory album repository.
func NewAlbumRepository() *AlbumRepository {
return &AlbumRepository{
albums: make(map[album.AlbumID]*album.Album),
}
}
// Create persists a new album. The caller must set ID, Name, SubjectDesc, Shots before calling.
func (r *AlbumRepository) Create(ctx context.Context, a *album.Album) error {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
copy := *a
r.albums[a.ID] = &copy
return nil
}
// Get returns an album by ID and userID. Returns error if not found or wrong user.
func (r *AlbumRepository) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
r.mu.RLock()
defer r.mu.RUnlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return nil, fmt.Errorf("album not found: %s", id)
}
copy := *a
shots := make([]album.Shot, len(a.Shots))
copy.Shots = shots
for i, s := range a.Shots {
shots[i] = s
}
return &copy, nil
}
// List returns all albums for a user, ordered by CreatedAt DESC.
func (r *AlbumRepository) List(ctx context.Context, userID string) ([]album.Album, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var result []album.Album
for _, a := range r.albums {
if a.UserID != userID {
continue
}
copy := *a
shots := make([]album.Shot, len(a.Shots))
for i, s := range a.Shots {
shots[i] = s
}
copy.Shots = shots
result = append(result, copy)
}
// Sort by CreatedAt DESC (simple insertion sort — in-memory is small).
for i := 1; i < len(result); i++ {
for j := i; j > 0 && result[j].CreatedAt.After(result[j-1].CreatedAt); j-- {
result[j], result[j-1] = result[j-1], result[j]
}
}
return result, nil
}
// Delete removes an album by ID and userID.
func (r *AlbumRepository) Delete(ctx context.Context, id album.AlbumID, userID string) error {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return fmt.Errorf("album not found: %s", id)
}
delete(r.albums, id)
return nil
}
// UpdateAnchor stores the generated anchor URL.
func (r *AlbumRepository) UpdateAnchor(ctx context.Context, id album.AlbumID, userID, anchorURL, anchorJobID string) error {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return fmt.Errorf("album not found: %s", id)
}
a.AnchorURL = anchorURL
a.AnchorJobID = anchorJobID
a.UpdatedAt = time.Now().UTC()
return nil
}
// UpdateShot stores the generated image URL and status for a specific shot.
func (r *AlbumRepository) UpdateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int, imageURL string, status album.ShotStatus, shotError string) error {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return fmt.Errorf("album not found: %s", id)
}
if shotIndex < 0 || shotIndex >= len(a.Shots) {
return fmt.Errorf("shot index out of range: %d", shotIndex)
}
now := time.Now().UTC()
a.Shots[shotIndex].ImageURL = imageURL
a.Shots[shotIndex].Status = status
a.Shots[shotIndex].Error = shotError
if status == album.ShotComplete {
a.Shots[shotIndex].GeneratedAt = &now
}
a.UpdatedAt = now
return nil
}
// ResetShot clears a shot back to pending.
func (r *AlbumRepository) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return fmt.Errorf("album not found: %s", id)
}
if shotIndex < 0 || shotIndex >= len(a.Shots) {
return fmt.Errorf("shot index out of range: %d", shotIndex)
}
a.Shots[shotIndex].ImageURL = ""
a.Shots[shotIndex].JobID = ""
a.Shots[shotIndex].Status = album.ShotPending
a.Shots[shotIndex].Error = ""
a.Shots[shotIndex].GeneratedAt = nil
a.UpdatedAt = time.Now().UTC()
return nil
}
// UpdateAnchorJobID stores the anchor job ID when the anchor generation is enqueued.
func (r *AlbumRepository) UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return fmt.Errorf("album not found: %s", id)
}
a.AnchorJobID = jobID
a.UpdatedAt = time.Now().UTC()
return nil
}
// UpdateShotJobID stores the job ID for a shot when its generation is enqueued.
func (r *AlbumRepository) UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error {
r.mu.Lock()
defer r.mu.Unlock()
a, ok := r.albums[id]
if !ok || a.UserID != userID {
return fmt.Errorf("album not found: %s", id)
}
if shotIndex < 0 || shotIndex >= len(a.Shots) {
return fmt.Errorf("shot index out of range: %d", shotIndex)
}
a.Shots[shotIndex].JobID = jobID
a.Shots[shotIndex].Status = album.ShotGenerating
a.UpdatedAt = time.Now().UTC()
return nil
}

View File

@ -0,0 +1,281 @@
package handlers
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"{{GO_MODULE}}/pkg/album"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
// Album handles HTTP requests for album CRUD and generation endpoints.
// All generation endpoints are async: they enqueue a job and return 202.
// Results arrive via SSE events on the user:<userId> channel.
type Album struct {
albums *service.AlbumService
logger *logging.Logger
}
// NewAlbum creates a new Album handler.
func NewAlbum(albums *service.AlbumService, logger *logging.Logger) *Album {
return &Album{
albums: albums,
logger: logger.WithComponent("AlbumHandler"),
}
}
// ---------------------------------------------------------------------------
// Request/response types
// ---------------------------------------------------------------------------
// CreateAlbumRequest is the request body for POST /albums.
type CreateAlbumRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
SubjectDesc string `json:"subjectDesc" validate:"required,min=1,max=500"`
Shots []ShotTemplateBody `json:"shots" validate:"required,min=1,max=20"`
TemplateSet string `json:"templateSet"` // Optional: "portrait", "product", "character"
}
// ShotTemplateBody is a single shot spec in the create request.
type ShotTemplateBody struct {
Label string `json:"label" validate:"required"`
Direction string `json:"direction" validate:"required"`
}
// AlbumJobResponse is the response for generation enqueue endpoints.
type AlbumJobResponse struct {
JobID string `json:"jobId"`
}
// AlbumJobsResponse is the response for bulk generation enqueue.
type AlbumJobsResponse struct {
JobIDs []string `json:"jobIds"`
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
// Create handles POST /albums — creates a new album with shot specs.
func (h *Album) Create(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
var req CreateAlbumRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// If a template set was provided, use it (overrides explicit shots).
var shots []album.ShotTemplate
if req.TemplateSet != "" {
set, ok := album.ShotTemplateSets[req.TemplateSet]
if !ok {
return httperror.BadRequest("unknown template set: " + req.TemplateSet)
}
shots = set
} else {
// Convert body shots to ShotTemplate.
shots = make([]album.ShotTemplate, len(req.Shots))
for i, s := range req.Shots {
shots[i] = album.ShotTemplate{Label: s.Label, Direction: s.Direction}
}
}
a, err := h.albums.Create(r.Context(), user.ID, req.Name, req.SubjectDesc, shots)
if err != nil {
h.logger.Error("failed to create album", "error", err, "user_id", user.ID)
return httperror.BadRequest(err.Error())
}
httpresponse.Created(w, r, a)
return nil
}
// List handles GET /albums — returns all albums for the authenticated user.
func (h *Album) List(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
albums, err := h.albums.List(r.Context(), user.ID)
if err != nil {
h.logger.Error("failed to list albums", "error", err, "user_id", user.ID)
return httperror.Internal("failed to list albums")
}
if albums == nil {
albums = []album.Album{}
}
httpresponse.OK(w, r, albums)
return nil
}
// Get handles GET /albums/{id} — returns a single album with all shot statuses.
func (h *Album) Get(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
id := album.AlbumID(chi.URLParam(r, "id"))
if id == "" {
return httperror.BadRequest("album ID is required")
}
a, err := h.albums.Get(r.Context(), id, user.ID)
if err != nil {
return httperror.NotFound("album not found")
}
httpresponse.OK(w, r, a)
return nil
}
// Delete handles DELETE /albums/{id} — deletes an album.
func (h *Album) Delete(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
id := album.AlbumID(chi.URLParam(r, "id"))
if id == "" {
return httperror.BadRequest("album ID is required")
}
if err := h.albums.Delete(r.Context(), id, user.ID); err != nil {
return httperror.NotFound("album not found")
}
httpresponse.NoContent(w)
return nil
}
// ---------------------------------------------------------------------------
// Generation (async — returns 202)
// ---------------------------------------------------------------------------
// GenerateAnchor handles POST /albums/{id}/anchor — enqueues anchor generation.
// Returns 202 with job ID. Result arrives via album_anchor_complete SSE event.
func (h *Album) GenerateAnchor(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
id := album.AlbumID(chi.URLParam(r, "id"))
if id == "" {
return httperror.BadRequest("album ID is required")
}
jobID, err := h.albums.GenerateAnchor(r.Context(), id, user.ID)
if err != nil {
h.logger.Error("failed to enqueue anchor job", "error", err, "album_id", string(id))
return httperror.NotFound("album not found")
}
h.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
return nil
}
// GenerateAllShots handles POST /albums/{id}/shots — enqueues all pending shots.
// Returns 422 if the album has no anchor yet.
// Returns 202 with job IDs for all enqueued shots.
func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
id := album.AlbumID(chi.URLParam(r, "id"))
if id == "" {
return httperror.BadRequest("album ID is required")
}
jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID)
if err != nil {
if err.Error() == "anchor must be generated before shots" {
return httperror.UnprocessableEntity("anchor must be generated before shots")
}
return httperror.NotFound("album not found")
}
if jobIDs == nil {
jobIDs = []string{}
}
h.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
httpresponse.Accepted(w, r, AlbumJobsResponse{JobIDs: jobIDs})
return nil
}
// GenerateShot handles POST /albums/{id}/shots/{index} — enqueues a single shot (for regeneration).
func (h *Album) GenerateShot(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
id := album.AlbumID(chi.URLParam(r, "id"))
if id == "" {
return httperror.BadRequest("album ID is required")
}
shotIndex := 0
if idx := chi.URLParam(r, "index"); idx != "" {
if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil {
return httperror.BadRequest("invalid shot index")
}
}
jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex)
if err != nil {
if err.Error() == "anchor must be generated before shots" {
return httperror.UnprocessableEntity("anchor must be generated before shots")
}
return httperror.NotFound("album or shot not found")
}
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
return nil
}
// ResetShot handles DELETE /albums/{id}/shots/{index} — resets a shot to pending.
func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error {
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
id := album.AlbumID(chi.URLParam(r, "id"))
if id == "" {
return httperror.BadRequest("album ID is required")
}
shotIndex := 0
if idx := chi.URLParam(r, "index"); idx != "" {
if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil {
return httperror.BadRequest("invalid shot index")
}
}
if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil {
return httperror.NotFound("album or shot not found")
}
httpresponse.NoContent(w)
return nil
}

View File

@ -0,0 +1,85 @@
package handlers
import (
"net/http"
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/queue"
)
// Persona handles HTTP requests for persona generation.
// All generation is async: validate request, enqueue job, return 202 with job ID.
// Results are delivered via SSE events to the user's `user:<userId>` channel:
//
// - persona_spec_started: LLM pipeline started
// - persona_spec_complete: Persona profile generated
// - persona_image_started: Starting a specific image position
// - persona_image_progress: Image position complete with URL
// - persona_image_complete: All 20 images generated
// - persona_video_started: Starting a video motion type
// - persona_video_complete: Video complete with URL
// - persona_failed: Generation failed (check error field)
type Persona struct {
queue queue.Producer
jobReader queue.JobReader
logger *logging.Logger
}
// NewPersona creates a new Persona handler with injected dependencies.
func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona {
return &Persona{
queue: q,
jobReader: jr,
logger: logger.WithComponent("PersonaHandler"),
}
}
// GeneratePersonaRequest is the request body for persona generation.
type GeneratePersonaRequest struct {
// Description is a natural-language persona concept (required).
// Example: "mysterious woman with dark hair who loves poetry"
Description string `json:"description" validate:"required,min=3,max=1000"`
// Gender is the gender identity: "woman", "man", or "non_binary" (required).
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
// Name is an optional name override for the generated persona.
Name string `json:"name"`
}
// GeneratePersona queues a persona generation job.
// Returns immediately with job ID. Full lifecycle results come via SSE.
//
// Subscribe to SSE channel `user:<userId>` at /api/{{COMPONENT_NAME}}/events before calling.
// Poll job status at GET /generate/jobs/{id} as a fallback to SSE.
func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error {
var req GeneratePersonaRequest
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
user := auth.GetUser(r.Context())
if user == nil {
return httperror.Unauthorized("authentication required")
}
jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{
"description": req.Description,
"gender": req.Gender,
"name": req.Name,
"userID": user.ID,
})
if err != nil {
h.logger.Error("failed to enqueue persona job", "error", err)
return httperror.Internal("failed to queue persona generation")
}
h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID)
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
return nil
}

View File

@ -34,6 +34,8 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger)
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, logger)
albumHandler := handlers.NewAlbum(deps.AlbumService, logger)
personaHandler := handlers.NewPersona(deps.Queue, deps.JobReader, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()
@ -151,6 +153,19 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
// Media library (upload, list, delete)
r.Mount("/media", mediaHandler.Routes())
// Album generation (anchor + shots)
r.Get("/albums", app.Wrap(albumHandler.List))
r.Post("/albums", app.Wrap(albumHandler.Create))
r.Get("/albums/{id}", app.Wrap(albumHandler.Get))
r.Delete("/albums/{id}", app.Wrap(albumHandler.Delete))
r.Post("/albums/{id}/anchor", app.Wrap(albumHandler.GenerateAnchor))
r.Post("/albums/{id}/shots", app.Wrap(albumHandler.GenerateAllShots))
r.Post("/albums/{id}/shots/{index}", app.Wrap(albumHandler.GenerateShot))
r.Delete("/albums/{id}/shots/{index}", app.Wrap(albumHandler.ResetShot))
// Persona generation (5-stage LLM + 20 images + 4 videos, all async)
r.Post("/persona/generate", app.Wrap(personaHandler.GeneratePersona))
})
})
}
@ -159,6 +174,7 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
type Dependencies struct {
ExampleService *service.ExampleService
AuthService *service.AuthService
AlbumService *service.AlbumService
Queue queue.Producer
JobReader queue.JobReader
SSEHub *realtime.SSEHub

View File

@ -0,0 +1,34 @@
package port
import (
"context"
"{{GO_MODULE}}/pkg/album"
)
// AlbumRepository defines persistence operations for albums.
// It extends album.AlbumUpdater so implementations satisfy both interfaces.
type AlbumRepository interface {
album.AlbumUpdater
// Create persists a new album. Sets ID, CreatedAt, UpdatedAt.
Create(ctx context.Context, a *album.Album) error
// Get returns an album by ID. Returns ErrAlbumNotFound if not found.
Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error)
// List returns all albums for a user, ordered by CreatedAt DESC.
List(ctx context.Context, userID string) ([]album.Album, error)
// Delete removes an album and all its shots. Does NOT delete stored images.
Delete(ctx context.Context, id album.AlbumID, userID string) error
// ResetShot clears a shot's ImageURL, JobID, Error, and sets Status to pending.
ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error
// UpdateAnchorJobID sets the AnchorJobID when the anchor generation job is enqueued.
UpdateAnchorJobID(ctx context.Context, id album.AlbumID, userID, jobID string) error
// UpdateShotJobID sets the shot's JobID when a shot generation job is enqueued.
UpdateShotJobID(ctx context.Context, id album.AlbumID, userID string, shotIndex int, jobID string) error
}

View File

@ -0,0 +1,186 @@
package service
import (
"context"
"fmt"
"{{GO_MODULE}}/pkg/album"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
)
// AlbumService handles album creation, retrieval, and generation orchestration.
// All generation is async: service enqueues jobs and returns immediately.
// Results arrive via SSE on the user:<userId> channel.
type AlbumService struct {
albums port.AlbumRepository
queue queue.Producer
logger *logging.Logger
}
// NewAlbumService creates a new AlbumService.
func NewAlbumService(albums port.AlbumRepository, q queue.Producer, logger *logging.Logger) *AlbumService {
return &AlbumService{
albums: albums,
queue: q,
logger: logger.WithComponent("AlbumService"),
}
}
// Create creates a new album with the given shots and persists it.
// Shots are provided as ShotTemplate slices (Label + Direction).
func (s *AlbumService) Create(ctx context.Context, userID, name, subjectDesc string, shots []album.ShotTemplate) (*album.Album, error) {
if name == "" {
return nil, fmt.Errorf("album name is required")
}
if subjectDesc == "" {
return nil, fmt.Errorf("subject description is required")
}
if len(shots) == 0 {
return nil, fmt.Errorf("at least one shot is required")
}
if len(shots) > 20 {
return nil, fmt.Errorf("maximum 20 shots per album")
}
shotList := make([]album.Shot, len(shots))
for i, tmpl := range shots {
shotList[i] = album.Shot{
Index: i,
Label: tmpl.Label,
Direction: tmpl.Direction,
Status: album.ShotPending,
}
}
a := &album.Album{
ID: album.AlbumID("alb_" + generateID()),
UserID: userID,
Name: name,
SubjectDesc: subjectDesc,
Shots: shotList,
}
if err := s.albums.Create(ctx, a); err != nil {
return nil, fmt.Errorf("create album: %w", err)
}
s.logger.Info("album created", "album_id", string(a.ID), "user_id", userID, "shots", len(shotList))
return a, nil
}
// Get returns an album by ID, enforcing user ownership.
func (s *AlbumService) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
a, err := s.albums.Get(ctx, id, userID)
if err != nil {
return nil, fmt.Errorf("album not found: %w", err)
}
return a, nil
}
// List returns all albums for a user.
func (s *AlbumService) List(ctx context.Context, userID string) ([]album.Album, error) {
return s.albums.List(ctx, userID)
}
// Delete removes an album. Does NOT delete stored images.
func (s *AlbumService) Delete(ctx context.Context, id album.AlbumID, userID string) error {
return s.albums.Delete(ctx, id, userID)
}
// GenerateAnchor enqueues an anchor generation job for an album.
// Returns the job ID. Result arrives via album_anchor_complete SSE event.
func (s *AlbumService) GenerateAnchor(ctx context.Context, id album.AlbumID, userID string) (string, error) {
a, err := s.albums.Get(ctx, id, userID)
if err != nil {
return "", fmt.Errorf("album not found: %w", err)
}
jobID, err := s.queue.Enqueue(ctx, "generate_anchor", map[string]any{
"albumId": string(a.ID),
"userId": userID,
"subjectDesc": a.SubjectDesc,
})
if err != nil {
return "", fmt.Errorf("enqueue anchor job: %w", err)
}
if err := s.albums.UpdateAnchorJobID(ctx, id, userID, jobID); err != nil {
s.logger.Warn("failed to persist anchor job ID", "error", err, "album_id", string(id))
}
s.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
return jobID, nil
}
// GenerateAllShots enqueues generation jobs for all pending shots.
// Returns 422 if the album has no anchor yet (shots require an anchor reference).
func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, userID string) ([]string, error) {
a, err := s.albums.Get(ctx, id, userID)
if err != nil {
return nil, fmt.Errorf("album not found: %w", err)
}
if a.AnchorURL == "" {
return nil, fmt.Errorf("anchor must be generated before shots")
}
var jobIDs []string
for _, shot := range a.Shots {
if shot.Status != album.ShotPending && shot.Status != album.ShotFailed {
continue
}
jobID, err := s.enqueueShotJob(ctx, a, shot.Index)
if err != nil {
s.logger.Error("failed to enqueue shot", "error", err, "album_id", string(id), "shot_index", shot.Index)
continue
}
jobIDs = append(jobIDs, jobID)
}
s.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
return jobIDs, nil
}
// GenerateShot enqueues a generation job for a single shot (for regeneration).
func (s *AlbumService) GenerateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) (string, error) {
a, err := s.albums.Get(ctx, id, userID)
if err != nil {
return "", fmt.Errorf("album not found: %w", err)
}
if a.AnchorURL == "" {
return "", fmt.Errorf("anchor must be generated before shots")
}
if shotIndex < 0 || shotIndex >= len(a.Shots) {
return "", fmt.Errorf("shot index out of range: %d", shotIndex)
}
return s.enqueueShotJob(ctx, a, shotIndex)
}
// ResetShot clears a shot back to pending so it can be regenerated.
func (s *AlbumService) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
return s.albums.ResetShot(ctx, id, userID, shotIndex)
}
// enqueueShotJob is the internal helper that enqueues a single shot generation job.
func (s *AlbumService) enqueueShotJob(ctx context.Context, a *album.Album, shotIndex int) (string, error) {
shot := a.Shots[shotIndex]
jobID, err := s.queue.Enqueue(ctx, "generate_shot", map[string]any{
"albumId": string(a.ID),
"userId": a.UserID,
"shotIndex": shotIndex,
"anchorUrl": a.AnchorURL,
"subjectDesc": a.SubjectDesc,
"direction": shot.Direction,
})
if err != nil {
return "", fmt.Errorf("enqueue shot job: %w", err)
}
if err := s.albums.UpdateShotJobID(ctx, a.ID, a.UserID, shotIndex, jobID); err != nil {
s.logger.Warn("failed to persist shot job ID", "error", err, "shot_index", shotIndex)
}
return jobID, nil
}

View File

@ -0,0 +1,231 @@
# Album Generation Guide
Albums are the right abstraction for generating multiple images of the same subject with visual consistency. One anchor image + N directed shots.
## Mental Model: Photography Session
| Term | Meaning | Example |
|------|---------|---------|
| **Subject** | What's being photographed | "Woman, dark curly hair, early 30s, artistic style" |
| **Anchor** | The reference image that ties all shots together | Generated from subject description |
| **Shot** | One image with a specific direction | "Headshot, direct eye contact, studio lighting" |
| **Album** | The full session: subject + anchor + shots | "Jordan Headshots" |
## Use Cases
The album abstraction covers persona headshots, product photography, character sheets, and brand assets — same mechanism, different subject descriptions.
```
Personas: subject="Woman, dark hair, 30s" + shots=[Headshot, Casual, Professional]
Products: subject="Titanium water bottle, brushed finish" + shots=[Hero, Lifestyle, Detail]
Characters: subject="Cartoon raccoon mascot, mischievous" + shots=[Neutral, Expression, Action]
```
## API Endpoints
All generation endpoints return **202 Accepted** with a job ID. Results arrive via SSE.
```
POST /api/example-api/albums Create album with shots
GET /api/example-api/albums List user's albums
GET /api/example-api/albums/{id} Get album with shot statuses
DELETE /api/example-api/albums/{id} Delete album
POST /api/example-api/albums/{id}/anchor Enqueue anchor generation → {jobId}
POST /api/example-api/albums/{id}/shots Enqueue all pending shots → {jobIds:[...]}
POST /api/example-api/albums/{id}/shots/{i} Regenerate one shot → {jobId}
DELETE /api/example-api/albums/{id}/shots/{i} Reset shot to pending
```
### Create Album
```json
POST /albums
{
"name": "Jordan Headshots",
"subjectDesc": "Professional woman, dark curly hair, early 30s, warm smile",
"shots": [
{"label": "Headshot", "direction": "close-up, direct eye contact, studio lighting"},
{"label": "Casual", "direction": "relaxed smile, natural light, outdoor setting"}
]
}
```
Or use a built-in template set:
```json
POST /albums
{
"name": "Jordan Headshots",
"subjectDesc": "Professional woman, dark curly hair, early 30s, warm smile",
"templateSet": "portrait"
}
```
Available template sets: `portrait` (6 shots), `product` (4 shots), `character` (4 shots).
## Generation Order
1. **Generate anchor first** — POST `/albums/{id}/anchor`
2. **Wait for `album_anchor_complete` SSE** — anchor URL arrives
3. **Generate shots** — POST `/albums/{id}/shots` (returns 422 if no anchor)
4. **Shots complete**`album_shot_complete` events arrive per shot
**Enforcement:** The service returns 422 Unprocessable Entity if shots are requested before the anchor exists. The frontend disables "Generate All Shots" until the anchor is ready.
## SSE Events
All events arrive on the `user:<userId>` channel (existing subscription).
```typescript
// Anchor events
{ type: "album_anchor_complete", result: { albumId, anchorUrl } }
{ type: "album_anchor_failed", result: { albumId, error } }
// Shot events
{ type: "album_shot_generating", result: { albumId, shotIndex } }
{ type: "album_shot_complete", result: { albumId, shotIndex, imageUrl } }
{ type: "album_shot_failed", result: { albumId, shotIndex, error } }
```
## Frontend Usage
### useAlbumGeneration Hook
```tsx
import { useAlbumGeneration } from '@example-project/realtime';
function AlbumPage({ albumId }: { albumId: string }) {
const { user, token } = useAuth();
const {
album,
isLoading,
error,
loadAlbum,
generateAnchor,
generateAllShots,
regenerateShot,
resetShot,
} = useAlbumGeneration({
apiBase: '/api/example-api',
userId: user.id,
albumId,
authToken: token,
});
// Load on mount
useEffect(() => { void loadAlbum(); }, [loadAlbum]);
return (
<AlbumGrid
name={album?.name}
anchorUrl={album?.anchorUrl}
shots={album?.shots ?? []}
onGenerateAnchor={generateAnchor}
onGenerateAllShots={generateAllShots}
onRegenerateShot={regenerateShot}
onResetShot={resetShot}
/>
);
}
```
### AlbumGrid Component
```tsx
import { AlbumGrid } from '@example-project/ui';
<AlbumGrid
name="Jordan Headshots"
anchorUrl={album.anchorUrl}
anchorGenerating={isAnchorGenerating}
shots={album.shots}
onGenerateAnchor={generateAnchor}
onRegenerateAnchor={generateAnchor}
onGenerateAllShots={generateAllShots}
onRegenerateShot={(index) => regenerateShot(index)}
onResetShot={(index) => resetShot(index)}
onImageClick={(indexOrAnchor) => openLightbox(indexOrAnchor)}
/>
```
### Individual Components
```tsx
import { AnchorPreview, ShotCard } from '@example-project/ui';
// Anchor card
<AnchorPreview
anchorUrl={album.anchorUrl}
isGenerating={!!album.anchorJobId && !album.anchorUrl}
onGenerate={generateAnchor}
onRegenerate={generateAnchor}
/>
// Individual shot card
<ShotCard
label="Headshot"
status={shot.status} // 'pending' | 'generating' | 'complete' | 'failed'
imageUrl={shot.imageUrl}
error={shot.error}
anchorReady={!!album.anchorUrl}
onGenerate={() => regenerateShot(shot.index)}
onRegenerate={() => regenerateShot(shot.index)}
onReset={() => resetShot(shot.index)}
/>
```
## Backend Architecture
### Skeleton Package (`pkg/album/`)
Ships in every project. Provides:
- `Album`, `Shot`, `ShotStatus`, `ShotTemplate` types
- `AlbumUpdater` interface (minimal interface for job handlers)
- `AnchorHandler(mg, store, pub, updater, logger)` — job handler for `generate_anchor`
- `ShotHandler(mg, store, pub, updater, logger)` — job handler for `generate_shot`
- Built-in template sets: `PortraitSession`, `ProductShoot`, `CharacterSheet`
### Job Handler Architecture
Anchor bytes are **NOT stored in the job payload** (megabytes in DB = bad).
Instead:
1. Anchor is generated and stored at `albums/{userId}/{albumId}/anchor.png`
2. The anchor URL is stored in the album record
3. Shot jobs carry `anchorUrl` in their payload
4. The `ShotHandler` fetches anchor bytes at execution time via HTTP
5. Bytes are passed as `ReferenceImage` to the mediagen provider
### Component Service Layer
```
port.AlbumRepository — CRUD + AlbumUpdater
memory.AlbumRepository — in-memory (standalone dev mode)
postgres.AlbumRepository — postgres (production, not yet implemented)
service.AlbumService — business logic (create, list, get, generateAnchor, generateShots)
handlers.Album — HTTP handlers
```
## Dev Mode
In standalone mode (`DATABASE_URL` not set), albums persist in memory until the server restarts.
```bash
# Start the backend
go run ./services/example-api/cmd/server
# Create an album
curl -X POST http://localhost:8001/api/example-api/albums \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"name":"Test","subjectDesc":"A cat","templateSet":"portrait"}'
# Generate anchor
curl -X POST http://localhost:8001/api/example-api/albums/<id>/anchor \
-H "Authorization: Bearer <token>"
# Watch SSE for album_anchor_complete
curl -N "http://localhost:8001/api/example-api/events?channel=user:<userId>" \
-H "Authorization: Bearer <token>"
```

View File

@ -0,0 +1,161 @@
# Persona Generation Guide
`pkg/personagen` provides a complete pipeline for generating synthetic persona profiles with
biological DNA, personality psychology, lifestyle preferences, a 20-position image matrix,
and 4-motion-type video specs.
## Overview
A `PersonaSpec` is the top-level output — it contains:
- **CoreIdentity**: name, age, gender, ethnicity, occupation, location
- **DNA**: immutable biological characteristics (face, body, voice)
- **Psychology**: HEXACO personality profile, attachment style, values
- **Lifestyle**: interests (5 categories), fashion sense (15 contexts), vacation style
- **ImageMatrix**: 20-position image generation specs (4 tiers: Identity, Expressions, Angles, Context)
- **Videos**: 4 video specs (smile_reveal, personality_moment, lifestyle, invitation)
## Generation Flow
```
POST /api/{service}/persona/generate
→ "persona_generate" job enqueued → 202 {jobId}
→ Worker picks up job
→ personagen.Service.GenerateSpec() → 5-stage LLM pipeline
→ personagen.Service.GenerateImages() → 20 image positions (position 1 = anchor first)
→ personagen.Service.GenerateVideo() → 4 video motion types
→ SSE events delivered to user:<userId>
```
## 5-Stage Spec Pipeline
| Stage | What it does | LLM calls |
|-------|-------------|-----------|
| 1 | Identity: name, age, ethnicity, occupation, location | 1 |
| 2 | Psychology: HEXACO scores, attachment, values | 1 |
| 3 | Lifestyle: interests, fashion context, vacation style | 1 |
| 4 | Visual DNA: all face/body/voice characteristics | 1 |
| 5 | Populate image matrix from lifestyle (no LLM needed) | 0 |
Total: **4 LLM calls** per persona spec.
## Image Matrix Tiers
| Tier | Positions | Focus |
|------|-----------|-------|
| 1 Identity | 15 | Core look, position 1 is the anchor |
| 2 Expressions | 611 | Personality through facial expressions |
| 3 Angles | 1216 | Camera angle variety |
| 4 Context | 1720 | Situational lifestyle shots |
**Position 1 is always generated first** and becomes the anchor image for all subsequent
positions (passed as `ReferenceImage` to the mediagen provider).
## HEIA Prompt Structure
Images use the HEIA (High-Engagement Influencer Aesthetic) prompt format:
```
[IDENTITY] 26-year-old Korean woman, 5'4" (163cm), slender build.
[FACE] oval face with high cheekbones, defined jawline, almond-shaped dark brown eyes...
[BODY] slender build, narrow shoulders, 0.72 WHR, upright posture.
[POSE] mid distance, 3/4 angle, standing, soft gaze expression, slight over-shoulder turn.
[CLOTHING] crisp white off-shoulder blouse, minimal gold jewelry — slim, structured.
[SCENE] neutral light background, subtle gradient.
[CONSTRAINTS] WOMAN SUBJECT ONLY. Human body has EXACTLY 2 arms, 2 legs, 10 fingers total...
```
## SSE Events
Subscribe to `user:<userId>` channel before calling the generate endpoint:
```json
{"type": "persona_spec_started", "jobId": "...", "message": "Generating persona profile..."}
{"type": "persona_spec_complete", "jobId": "...", "result": {"personaId": "ps_abc123"}}
{"type": "persona_image_started", "jobId": "...", "result": {"position": 1}}
{"type": "persona_image_progress", "jobId": "...", "progress": 45, "result": {"position": 9, "url": "..."}}
{"type": "persona_image_complete", "jobId": "...", "progress": 100, "result": {"personaId": "..."}}
{"type": "persona_video_started", "jobId": "...", "result": {"motionType": "smile_reveal"}}
{"type": "persona_video_complete", "jobId": "...", "result": {"motionType": "smile_reveal", "url": "..."}}
{"type": "persona_failed", "jobId": "...", "error": "Spec generation failed: ..."}
```
## Environment Requirements
Both AI providers must be configured for persona generation to work:
| Env Var | Purpose |
|---------|---------|
| `LAOZHANG_API_KEY` | Image and video generation (Flux, Kling) |
| `GEMINI_API_KEY` | Text generation (Gemini) + additional media (Imagen, Veo) |
These are auto-injected by rdev into every deployed service. Locally, source from `.secrets`.
## Using the Service Directly
```go
// Create the service
svc := personagen.New(textgenMgr, mediagenMgr, store, logger.Logger)
// Generate a full persona spec (5-stage LLM pipeline)
spec, err := svc.GenerateSpec(ctx, personagen.SeedParams{
Description: "confident Latina entrepreneur who loves fashion and travel",
Gender: "woman",
Name: "Sofia Reyes", // optional
})
if err != nil {
return err
}
// Generate all 20 images (position 1 first, sets anchor automatically)
if err := svc.GenerateImages(ctx, spec, nil); err != nil {
return err
}
// Generate a specific video
videoSpec, err := svc.GenerateVideo(ctx, spec, persona.MotionSmileReveal)
// Generate utility images
avatarBytes, err := svc.GenerateAvatar(ctx, spec)
bannerBytes, err := svc.GenerateBanner(ctx, spec, "lifestyle")
```
## Fashion Contexts
15 named fashion contexts are available in `pkg/persona`:
| Context Name | Style |
|-------------|-------|
| `classic_minimalist` | Clean lines, neutral palette |
| `streetwear_chic` | Urban oversized fits, sneakers |
| `bohemian_free_spirit` | Flowing fabrics, earth tones |
| `athleisure_pro` | Performance fabrics, gym-to-street |
| `business_casual` | Polished professional with comfort |
| `romantic_feminine` | Florals, lace, pastels |
| `edgy_alternative` | Dark palette, leather, hardware |
| `coastal_casual` | Relaxed, beachy, linen |
| `urban_professional` | Sharp city-dweller aesthetic |
| `festival_glam` | Bold prints, glitter, maximalist |
| `preppy_classic` | Collegiate, polo shirts, chinos |
| `dark_academia` | Literary, tweed, earth tones |
| `cottagecore` | Prairie dresses, pastoral romance |
| `y2k_revival` | Low-rise, metallics, early 2000s |
| `luxe_loungewear` | Elevated comfort in premium fabrics |
Use `persona.AllFashionContexts()` for the full catalog or `persona.FashionContextFor(name)` for a single context.
## Motion Types
| Motion Type | Description | Duration | Aspect |
|------------|-------------|----------|--------|
| `smile_reveal` | Warm genuine smile moment | 5s | 9:16 |
| `personality_moment` | Expressive personality showcase | 8s | 9:16 |
| `lifestyle` | Contextual lifestyle shot | 8s | 16:9 |
| `invitation` | Direct-address to viewer | 5s | 9:16 |
## Errors
| Error | Cause |
|-------|-------|
| `ErrAnchorNotSet` | `GenerateVideo()` called before position 1 image was generated |
| `"mediagen not configured"` | No AI provider keys set |
| `"stage N X: ..."` | LLM call or JSON parse failure in specgen pipeline |

View File

@ -13,6 +13,8 @@
| **Auth & user management** | [auth.md](.claude/guides/auth.md) |
| **Event channels** | [events.md](.claude/guides/events.md) |
| **Media pipeline** | [media.md](.claude/guides/media.md) |
| **Album generation** | [album.md](.claude/guides/album.md) |
| **Generate personas** | [personagen.md](.claude/guides/personagen.md) |
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
## Quick Reference

View File

@ -3,3 +3,4 @@ export { useEventChannel, type ChannelEvent, type SSEState, type UseEventChannel
export { useMediaGeneration, type GenerationStatus, type UseMediaGenerationConfig, type UseMediaGenerationResult } from './useMediaGeneration';
export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat';
export { useMediaUpload, type UploadProgress, type UploadResult, type UseMediaUploadConfig, type UseMediaUploadResult } from './useMediaUpload';
export { useAlbumGeneration, type Album, type Shot, type ShotStatus, type UseAlbumGenerationConfig, type UseAlbumGenerationResult } from './useAlbumGeneration';

View File

@ -16,7 +16,13 @@ export type EventType =
| 'upload_started'
| 'upload_progress'
| 'upload_complete'
| 'upload_failed';
| 'upload_failed'
// Album generation events (pkg/album)
| 'album_anchor_complete'
| 'album_anchor_failed'
| 'album_shot_generating'
| 'album_shot_complete'
| 'album_shot_failed';
/**
* Chat message data payload.

View File

@ -0,0 +1,317 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useEventChannel, type ChannelEvent } from './useEventChannel';
/**
* Shot status values matching Go ShotStatus constants.
*/
export type ShotStatus = 'pending' | 'generating' | 'complete' | 'failed';
/**
* A single shot in an album.
*/
export interface Shot {
index: number;
label: string;
direction: string;
imageUrl: string;
jobId: string;
status: ShotStatus;
error?: string;
generatedAt?: string;
}
/**
* An album: one subject, one anchor, N shots.
*/
export interface Album {
id: string;
userId: string;
name: string;
subjectDesc: string;
anchorUrl: string;
anchorJobId: string;
shots: Shot[];
createdAt: string;
updatedAt: string;
}
/**
* Configuration for the useAlbumGeneration hook.
*/
export interface UseAlbumGenerationConfig {
/** API base path for album endpoints (default: '/api/example-api') */
apiBase: string;
/** User ID for subscribing to user channel */
userId: string;
/** Album to track. Pass null to manage without an active album. */
albumId: string | null;
/** SSE endpoint (default: '/api/example-api/events') */
sseEndpoint?: string;
/** Auth token for API requests */
authToken?: string;
}
/**
* Result of the useAlbumGeneration hook.
*/
export interface UseAlbumGenerationResult {
/** The currently loaded album (null if not yet loaded) */
album: Album | null;
/** Whether the album is being loaded */
isLoading: boolean;
/** Error message */
error: string | null;
/** Load (or refresh) the album from the API */
loadAlbum: () => Promise<void>;
/** Enqueue anchor generation for the album */
generateAnchor: () => Promise<void>;
/** Enqueue all pending shots (requires anchor to exist) */
generateAllShots: () => Promise<void>;
/** Enqueue a single shot by index (for regeneration) */
regenerateShot: (index: number) => Promise<void>;
/** Reset a shot to pending so it can be regenerated */
resetShot: (index: number) => Promise<void>;
}
/**
* Hook for managing album generation state with real-time progress via SSE.
*
* Subscribe to SSE events for the user channel and update album shot states
* as generation_complete and anchor_complete events arrive.
*
* @example
* ```tsx
* const { album, generateAnchor, generateAllShots, regenerateShot } =
* useAlbumGeneration({
* apiBase: '/api/example-api',
* userId: currentUser.id,
* albumId: album.id,
* authToken: token,
* });
*
* return (
* <AlbumGrid album={album} onRegenerateShot={regenerateShot} />
* );
* ```
*/
export function useAlbumGeneration(
config: UseAlbumGenerationConfig
): UseAlbumGenerationResult {
const { apiBase, userId, albumId, sseEndpoint, authToken } = config;
const [album, setAlbum] = useState<Album | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Keep album in a ref for event handler closure (avoids stale state).
const albumRef = useRef<Album | null>(null);
albumRef.current = album;
const effectiveSseEndpoint = sseEndpoint ?? `${apiBase}/events`;
// Build auth headers.
const headers = useCallback((): Record<string, string> => {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (authToken) h['Authorization'] = `Bearer ${authToken}`;
return h;
}, [authToken]);
const loadAlbum = useCallback(async () => {
if (!albumId) return;
setIsLoading(true);
setError(null);
try {
const res = await fetch(`${apiBase}/albums/${albumId}`, {
headers: headers(),
});
if (!res.ok) throw new Error(`Failed to load album: ${res.status}`);
const json = (await res.json()) as { data: Album };
const loaded = json.data ?? (json as unknown as Album);
setAlbum(loaded);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load album');
} finally {
setIsLoading(false);
}
}, [albumId, apiBase, headers]);
// Handle incoming SSE events for this album.
const handleEvent = useCallback((event: ChannelEvent) => {
const current = albumRef.current;
if (!current) return;
// Extract albumId from the event result payload.
const result = event.result as Record<string, unknown> | undefined;
if (!result) return;
const eventAlbumId = result['albumId'] as string | undefined;
if (eventAlbumId && eventAlbumId !== current.id) return;
switch (event.type) {
case 'album_anchor_complete': {
const anchorUrl = result['anchorUrl'] as string;
setAlbum((prev) => prev ? { ...prev, anchorUrl } : prev);
break;
}
case 'album_anchor_failed': {
// Reload to get consistent state.
void loadAlbum();
break;
}
case 'album_shot_generating': {
const shotIndex = result['shotIndex'] as number;
setAlbum((prev) => {
if (!prev) return prev;
const shots = prev.shots.map((s) =>
s.index === shotIndex ? { ...s, status: 'generating' as ShotStatus } : s
);
return { ...prev, shots };
});
break;
}
case 'album_shot_complete': {
const shotIndex = result['shotIndex'] as number;
const imageUrl = result['imageUrl'] as string;
setAlbum((prev) => {
if (!prev) return prev;
const shots = prev.shots.map((s) =>
s.index === shotIndex
? { ...s, imageUrl, status: 'complete' as ShotStatus, error: undefined }
: s
);
return { ...prev, shots };
});
break;
}
case 'album_shot_failed': {
const shotIndex = result['shotIndex'] as number;
const errMsg = result['error'] as string;
setAlbum((prev) => {
if (!prev) return prev;
const shots = prev.shots.map((s) =>
s.index === shotIndex
? { ...s, status: 'failed' as ShotStatus, error: errMsg }
: s
);
return { ...prev, shots };
});
break;
}
}
}, [loadAlbum]);
// Subscribe to the user SSE channel.
useEventChannel({
endpoint: effectiveSseEndpoint,
channel: `user:${userId}`,
onEvent: handleEvent,
});
const generateAnchor = useCallback(async () => {
if (!albumId) return;
setError(null);
try {
const res = await fetch(`${apiBase}/albums/${albumId}/anchor`, {
method: 'POST',
headers: headers(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
throw new Error(body.message ?? `HTTP ${res.status}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start anchor generation');
}
}, [albumId, apiBase, headers]);
const generateAllShots = useCallback(async () => {
if (!albumId) return;
setError(null);
try {
const res = await fetch(`${apiBase}/albums/${albumId}/shots`, {
method: 'POST',
headers: headers(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
throw new Error(body.message ?? `HTTP ${res.status}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start shot generation');
}
}, [albumId, apiBase, headers]);
const regenerateShot = useCallback(
async (index: number) => {
if (!albumId) return;
setError(null);
try {
const res = await fetch(`${apiBase}/albums/${albumId}/shots/${index}`, {
method: 'POST',
headers: headers(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
throw new Error(body.message ?? `HTTP ${res.status}`);
}
// Optimistically set shot to generating.
setAlbum((prev) => {
if (!prev) return prev;
const shots = prev.shots.map((s) =>
s.index === index ? { ...s, status: 'generating' as ShotStatus } : s
);
return { ...prev, shots };
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to regenerate shot');
}
},
[albumId, apiBase, headers]
);
const resetShot = useCallback(
async (index: number) => {
if (!albumId) return;
setError(null);
try {
const res = await fetch(`${apiBase}/albums/${albumId}/shots/${index}`, {
method: 'DELETE',
headers: headers(),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
// Update shot to pending locally.
setAlbum((prev) => {
if (!prev) return prev;
const shots = prev.shots.map((s) =>
s.index === index
? { ...s, status: 'pending' as ShotStatus, imageUrl: '', error: undefined }
: s
);
return { ...prev, shots };
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset shot');
}
},
[albumId, apiBase, headers]
);
return {
album,
isLoading,
error,
loadAlbum,
generateAnchor,
generateAllShots,
regenerateShot,
resetShot,
};
}

View File

@ -0,0 +1,161 @@
import * as React from 'react';
import { cn } from '../utils/cn';
import { AnchorPreview } from './AnchorPreview';
import { ShotCard } from './ShotCard';
export type ShotStatus = 'pending' | 'generating' | 'complete' | 'failed';
export interface AlbumShot {
index: number;
label: string;
direction: string;
imageUrl: string;
status: ShotStatus;
error?: string;
}
export interface AlbumGridProps extends React.HTMLAttributes<HTMLDivElement> {
/** Album name */
name?: string;
/** Anchor image URL — empty string if not yet generated */
anchorUrl?: string;
/** Whether the anchor is currently being generated */
anchorGenerating?: boolean;
/** The list of shots in the album */
shots: AlbumShot[];
/** Number of columns (default: auto 3-4 based on screen) */
columns?: 3 | 4 | 5;
/** Called when the user requests anchor generation */
onGenerateAnchor?: () => void;
/** Called when the user requests anchor regeneration */
onRegenerateAnchor?: () => void;
/** Called when the user requests generation of all pending shots */
onGenerateAllShots?: () => void;
/** Called when the user requests regeneration of a specific shot */
onRegenerateShot?: (index: number) => void;
/** Called when the user resets a shot back to pending */
onResetShot?: (index: number) => void;
/** Called when the user clicks an image (e.g., to open lightbox) */
onImageClick?: (index: number | 'anchor') => void;
}
/**
* AlbumGrid displays an album as a responsive image grid.
*
* Layout:
*
* [Anchor] Headshot Casual Professional
* Reference [shimmer] [image] [image]
*
* Candid Outdoor Editorial
* [pending] [pending] [pending]
*
*
* - Anchor occupies the first slot with accent styling
* - Pending shots show the label + "Generate" button (disabled until anchor ready)
* - Generating shots show a shimmer animation
* - Complete shots show the image with hover controls
*
* @example
* ```tsx
* <AlbumGrid
* anchorUrl={album.anchorUrl}
* shots={album.shots}
* onGenerateAnchor={generateAnchor}
* onGenerateAllShots={generateAllShots}
* onRegenerateShot={regenerateShot}
* onResetShot={resetShot}
* />
* ```
*/
const AlbumGrid = React.forwardRef<HTMLDivElement, AlbumGridProps>(
(
{
name,
anchorUrl,
anchorGenerating = false,
shots,
columns = 4,
onGenerateAnchor,
onRegenerateAnchor,
onGenerateAllShots,
onRegenerateShot,
onResetShot,
onImageClick,
className,
...props
},
ref
) => {
const anchorReady = !!anchorUrl;
const pendingShots = shots.filter((s) => s.status === 'pending' || s.status === 'failed');
const allComplete = shots.every((s) => s.status === 'complete');
const colClass =
columns === 3
? 'grid-cols-3'
: columns === 5
? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5'
: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4';
return (
<div ref={ref} className={cn('space-y-3', className)} {...props}>
{/* Header row with name + action buttons */}
{(name || onGenerateAllShots) && (
<div className="flex items-center justify-between gap-2">
{name && (
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{name}</h3>
)}
<div className="flex items-center gap-2 ml-auto">
{onGenerateAllShots && anchorReady && pendingShots.length > 0 && (
<button
onClick={onGenerateAllShots}
className="px-3 py-1.5 text-xs rounded-md bg-[var(--accent)] text-white hover:opacity-80 transition-opacity font-medium"
>
Generate All ({pendingShots.length})
</button>
)}
{allComplete && (
<span className="text-xs text-[var(--text-secondary)]">
All complete
</span>
)}
</div>
</div>
)}
{/* Grid */}
<div className={cn('grid gap-2', colClass)}>
{/* Anchor slot — always first */}
<AnchorPreview
anchorUrl={anchorUrl}
isGenerating={anchorGenerating}
label="Reference"
onGenerate={onGenerateAnchor}
onRegenerate={onRegenerateAnchor}
onImageClick={onImageClick ? () => onImageClick('anchor') : undefined}
/>
{/* Shot slots */}
{shots.map((shot) => (
<ShotCard
key={shot.index}
label={shot.label}
status={shot.status}
imageUrl={shot.imageUrl}
error={shot.error}
anchorReady={anchorReady}
onGenerate={onRegenerateShot ? () => onRegenerateShot(shot.index) : undefined}
onRegenerate={onRegenerateShot ? () => onRegenerateShot(shot.index) : undefined}
onReset={onResetShot ? () => onResetShot(shot.index) : undefined}
onImageClick={onImageClick ? () => onImageClick(shot.index) : undefined}
/>
))}
</div>
</div>
);
}
);
AlbumGrid.displayName = 'AlbumGrid';
export { AlbumGrid };

View File

@ -0,0 +1,164 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export interface AnchorPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
/** URL of the anchor image — if empty, shows the "Generate Anchor First" CTA */
anchorUrl?: string;
/** Whether anchor generation is currently in progress */
isGenerating?: boolean;
/** Label shown above the anchor image */
label?: string;
/** Called when the user clicks the "Generate Anchor" button */
onGenerate?: () => void;
/** Called when the user clicks the "Regenerate" button on an existing anchor */
onRegenerate?: () => void;
/** Called when the user clicks the anchor image (e.g., to open a lightbox) */
onImageClick?: () => void;
}
/**
* AnchorPreview displays the album's reference/anchor image.
*
* The anchor is the first card in every album it defines the visual identity
* that all shots reference. When missing, it shows a prominent CTA because
* no shots can be generated until the anchor exists.
*
* Three states:
* - No anchor, not generating: Show "Generate Anchor" CTA
* - Generating: Show shimmer animation
* - Has anchor: Show image with Regenerate hover overlay
*
* @example
* ```tsx
* <AnchorPreview
* anchorUrl={album.anchorUrl}
* isGenerating={album.anchorJobId !== '' && !album.anchorUrl}
* onGenerate={generateAnchor}
* onRegenerate={generateAnchor}
* />
* ```
*/
const AnchorPreview = React.forwardRef<HTMLDivElement, AnchorPreviewProps>(
(
{
anchorUrl,
isGenerating = false,
label = 'Reference',
onGenerate,
onRegenerate,
onImageClick,
className,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
'relative aspect-square rounded-lg overflow-hidden group',
'border-2 border-dashed border-[var(--accent)]/40',
anchorUrl && 'border-solid border-[var(--accent)]/60',
className
)}
{...props}
>
{/* GENERATING state — shimmer */}
{isGenerating && !anchorUrl && (
<>
<div className="w-full h-full bg-gradient-to-r from-[var(--surface-100)] via-[var(--surface-200)] to-[var(--surface-100)] animate-[shimmer_1.5s_ease-in-out_infinite] bg-[length:200%_100%]" />
<style>{`
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`}</style>
<div className="absolute bottom-2 inset-x-2 text-center">
<span className="text-xs font-medium text-[var(--text-secondary)]">
Generating anchor...
</span>
</div>
</>
)}
{/* NO ANCHOR state — CTA */}
{!anchorUrl && !isGenerating && (
<div className="flex flex-col items-center justify-center h-full gap-3 p-4 bg-[var(--surface-50)]">
{/* Camera/anchor icon */}
<div className="w-10 h-10 rounded-full bg-[var(--accent)]/10 flex items-center justify-center">
<svg
className="w-5 h-5 text-[var(--accent)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM18.75 10.5h.008v.008h-.008V10.5z"
/>
</svg>
</div>
<div className="text-center">
<p className="text-xs font-semibold text-[var(--text-primary)]">{label}</p>
<p className="text-[10px] text-[var(--text-tertiary)] mt-0.5">
Generate first
</p>
</div>
{onGenerate && (
<button
onClick={onGenerate}
className="px-3 py-1 text-xs rounded-md bg-[var(--accent)] text-white hover:opacity-80 transition-opacity font-medium"
>
Generate Anchor
</button>
)}
</div>
)}
{/* HAS ANCHOR state */}
{anchorUrl && (
<>
<img
src={anchorUrl}
alt={label}
className={cn(
'w-full h-full object-cover',
onImageClick && 'cursor-pointer'
)}
onClick={onImageClick}
/>
{/* Accent border indicator */}
<div className="absolute inset-0 ring-2 ring-[var(--accent)]/30 rounded-lg pointer-events-none" />
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex flex-col justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs font-semibold text-white drop-shadow">
{label}
</span>
<div className="flex justify-end">
{onRegenerate && (
<button
onClick={(e) => { e.stopPropagation(); onRegenerate(); }}
className="px-2 py-0.5 text-xs rounded bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm transition-colors"
>
Regenerate
</button>
)}
</div>
</div>
</>
)}
</div>
);
}
);
AnchorPreview.displayName = 'AnchorPreview';
export { AnchorPreview };

View File

@ -0,0 +1,177 @@
import * as React from 'react';
import { cn } from '../utils/cn';
export type ShotStatus = 'pending' | 'generating' | 'complete' | 'failed';
export interface ShotCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Human-readable label for this shot (e.g., "Headshot", "Casual") */
label: string;
/** Current generation status */
status: ShotStatus;
/** Image URL — shown when status is 'complete' */
imageUrl?: string;
/** Error message — shown when status is 'failed' */
error?: string;
/** Whether the anchor exists (controls if generate button is enabled) */
anchorReady?: boolean;
/** Called when the user clicks Generate on a pending shot */
onGenerate?: () => void;
/** Called when the user clicks Regenerate on a complete/failed shot */
onRegenerate?: () => void;
/** Called when the user clicks Reset (removes the image, back to pending) */
onReset?: () => void;
/** Called when the user clicks the image (e.g., to open a lightbox) */
onImageClick?: () => void;
}
/**
* ShotCard displays a single shot in an album grid.
*
* Three visual states:
* - pending: Gray placeholder with label and optional Generate button
* - generating: Shimmer animation with label
* - complete: The generated image with hover controls (Regenerate / Reset)
* - failed: Red border with error message and Regenerate button
*
* @example
* ```tsx
* <ShotCard
* label="Headshot"
* status={shot.status}
* imageUrl={shot.imageUrl}
* anchorReady={!!album.anchorUrl}
* onGenerate={() => regenerateShot(shot.index)}
* />
* ```
*/
const ShotCard = React.forwardRef<HTMLDivElement, ShotCardProps>(
(
{
label,
status,
imageUrl,
error,
anchorReady = false,
onGenerate,
onRegenerate,
onReset,
onImageClick,
className,
...props
},
ref
) => {
return (
<div
ref={ref}
className={cn(
'relative aspect-square rounded-lg overflow-hidden group',
'border border-[var(--border)]',
status === 'failed' && 'border-red-500/50',
className
)}
{...props}
>
{/* PENDING state */}
{status === 'pending' && (
<div className="flex flex-col items-center justify-center h-full gap-2 bg-[var(--surface-100)] p-3">
<span className="text-xs font-medium text-[var(--text-secondary)] text-center leading-tight">
{label}
</span>
{anchorReady && onGenerate && (
<button
onClick={onGenerate}
className="mt-1 px-2 py-0.5 text-xs rounded bg-[var(--accent)] text-white hover:opacity-80 transition-opacity"
>
Generate
</button>
)}
{!anchorReady && (
<span className="text-[10px] text-[var(--text-tertiary)] text-center">
Generate anchor first
</span>
)}
</div>
)}
{/* GENERATING state — shimmer */}
{status === 'generating' && (
<div className="flex flex-col items-center justify-center h-full gap-2 bg-[var(--surface-100)] p-3">
<div className="w-full h-full absolute inset-0 bg-gradient-to-r from-[var(--surface-100)] via-[var(--surface-200)] to-[var(--surface-100)] animate-[shimmer_1.5s_ease-in-out_infinite] bg-[length:200%_100%]" />
<style>{`
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`}</style>
<span className="relative z-10 text-xs font-medium text-[var(--text-secondary)] text-center">
{label}
</span>
</div>
)}
{/* COMPLETE state */}
{status === 'complete' && imageUrl && (
<>
<img
src={imageUrl}
alt={label}
className={cn(
'w-full h-full object-cover',
onImageClick && 'cursor-pointer'
)}
onClick={onImageClick}
/>
{/* Hover overlay with label + controls */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex flex-col justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs font-medium text-white drop-shadow">{label}</span>
<div className="flex gap-1 justify-end">
{onRegenerate && (
<button
onClick={(e) => { e.stopPropagation(); onRegenerate(); }}
className="px-2 py-0.5 text-xs rounded bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm transition-colors"
>
Regenerate
</button>
)}
{onReset && (
<button
onClick={(e) => { e.stopPropagation(); onReset(); }}
className="px-2 py-0.5 text-xs rounded bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm transition-colors"
>
Reset
</button>
)}
</div>
</div>
</>
)}
{/* FAILED state */}
{status === 'failed' && (
<div className="flex flex-col items-center justify-center h-full gap-2 bg-red-50 dark:bg-red-950/20 p-3">
<span className="text-xs font-medium text-[var(--text-secondary)] text-center">
{label}
</span>
{error && (
<span className="text-[10px] text-red-500 text-center line-clamp-2" title={error}>
{error}
</span>
)}
{onRegenerate && (
<button
onClick={onRegenerate}
className="mt-1 px-2 py-0.5 text-xs rounded bg-red-500 text-white hover:opacity-80 transition-opacity"
>
Retry
</button>
)}
</div>
)}
</div>
);
}
);
ShotCard.displayName = 'ShotCard';
export { ShotCard };

View File

@ -36,6 +36,11 @@ export { VideoPlayer, VideoGrid, type VideoPlayerProps, type VideoGridProps } fr
export { MediaUploader, type MediaUploaderProps } from './components/MediaUploader';
export { MediaLibrary, type MediaLibraryProps, type MediaItem } from './components/MediaLibrary';
// Album Components
export { AlbumGrid, type AlbumGridProps, type AlbumShot } from './components/AlbumGrid';
export { ShotCard, type ShotCardProps, type ShotStatus } from './components/ShotCard';
export { AnchorPreview, type AnchorPreviewProps } from './components/AnchorPreview';
// Icons (re-export commonly used ones)
export {
AlertCircle,

View File

@ -0,0 +1,265 @@
package album
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/mediagen"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
"{{GO_MODULE}}/pkg/storage"
)
// httpClient fetches anchor images at job execution time.
// 30-second timeout is sufficient for public storage URLs.
var httpClient = &http.Client{Timeout: 30 * time.Second}
// sendAlbumEvent sends an SSE event to the user channel and logs failures at warn level.
func sendAlbumEvent(pub realtime.EventPublisher, userID string, eventType string, result any) {
event := &realtime.SSEEvent{
Type: eventType,
Result: result,
}
if err := pub.SendToUser(userID, event); err != nil {
slog.Warn("failed to send album SSE event", "error", err, "type", eventType)
}
}
// AnchorHandler returns a queue.Handler that generates the anchor image for an album.
// The anchor is generated from the subject description alone (no reference image).
// On success it persists the URL and emits album_anchor_complete via SSE.
// On failure it emits album_anchor_failed via SSE.
//
// Job type: "generate_anchor"
func AnchorHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, updater AlbumUpdater, logger *logging.Logger) queue.Handler {
return func(ctx context.Context, job *queue.Job) error {
albumID, _ := job.Payload["albumId"].(string)
userID, _ := job.Payload["userId"].(string)
subjectDesc, _ := job.Payload["subjectDesc"].(string)
if albumID == "" || userID == "" {
return fmt.Errorf("generate_anchor: missing albumId or userId in payload")
}
start := time.Now()
resp, err := mg.GenerateImage(ctx, mediagen.ImageRequest{
Prompt: subjectDesc,
Count: 1,
})
elapsed := time.Since(start)
if err != nil {
logger.Error("anchor generation failed", "error", err, "job_id", job.ID, "album_id", albumID)
sendAlbumEvent(pub, userID, EventAlbumAnchorFailed, AlbumAnchorFailedData{
AlbumID: albumID,
Error: "Anchor generation failed: " + err.Error(),
})
return err
}
if len(resp.Images) == 0 {
err = fmt.Errorf("no images returned from provider")
logger.Error("anchor generation returned no images", "job_id", job.ID, "album_id", albumID)
sendAlbumEvent(pub, userID, EventAlbumAnchorFailed, AlbumAnchorFailedData{
AlbumID: albumID,
Error: err.Error(),
})
return err
}
img := resp.Images[0]
// Persist anchor to storage.
anchorURL := img.URL
if store != nil && len(img.Data) > 0 {
storagePath := fmt.Sprintf("albums/%s/%s/anchor.png", userID, albumID)
url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png")
if uploadErr != nil {
logger.Warn("failed to persist anchor to storage, using inline URL", "error", uploadErr, "job_id", job.ID)
} else {
anchorURL = url
}
}
if anchorURL == "" {
err = fmt.Errorf("anchor has no URL after generation")
sendAlbumEvent(pub, userID, EventAlbumAnchorFailed, AlbumAnchorFailedData{
AlbumID: albumID,
Error: err.Error(),
})
return err
}
// Persist anchor URL to the album repository.
if err := updater.UpdateAnchor(ctx, AlbumID(albumID), userID, anchorURL, job.ID); err != nil {
logger.Error("failed to persist anchor URL", "error", err, "job_id", job.ID, "album_id", albumID)
sendAlbumEvent(pub, userID, EventAlbumAnchorFailed, AlbumAnchorFailedData{
AlbumID: albumID,
Error: "Failed to save anchor: " + err.Error(),
})
return err
}
logger.Info("anchor generation complete",
"job_id", job.ID, "album_id", albumID,
"provider", resp.Provider, "elapsed", elapsed)
sendAlbumEvent(pub, userID, EventAlbumAnchorComplete, AlbumAnchorCompleteData{
AlbumID: albumID,
AnchorURL: anchorURL,
})
return nil
}
}
// ShotHandler returns a queue.Handler that generates a single shot for an album.
// The anchor image bytes are fetched from AnchorURL at execution time.
// On success it persists the URL and emits album_shot_complete via SSE.
// On failure it emits album_shot_failed via SSE.
//
// Job type: "generate_shot"
func ShotHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventPublisher, updater AlbumUpdater, logger *logging.Logger) queue.Handler {
return func(ctx context.Context, job *queue.Job) error {
albumID, _ := job.Payload["albumId"].(string)
userID, _ := job.Payload["userId"].(string)
anchorURL, _ := job.Payload["anchorUrl"].(string)
subjectDesc, _ := job.Payload["subjectDesc"].(string)
direction, _ := job.Payload["direction"].(string)
shotIndex := 0
if si, ok := job.Payload["shotIndex"].(float64); ok {
shotIndex = int(si)
}
if albumID == "" || userID == "" {
return fmt.Errorf("generate_shot: missing albumId or userId in payload")
}
// Emit shot-generating event so the frontend shows a shimmer immediately.
sendAlbumEvent(pub, userID, EventAlbumShotGenerating, map[string]any{
"albumId": albumID,
"shotIndex": shotIndex,
})
// Fetch anchor image bytes from storage.
var anchorBytes []byte
var anchorMime string
if anchorURL != "" {
data, err := fetchBytes(ctx, anchorURL)
if err != nil {
logger.Warn("failed to fetch anchor image, proceeding without reference",
"error", err, "job_id", job.ID, "anchor_url", anchorURL)
} else {
anchorBytes = data
anchorMime = "image/png"
}
}
// Build prompt: subject description + shot direction.
prompt := subjectDesc
if direction != "" {
prompt = subjectDesc + ", " + direction
}
imageReq := mediagen.ImageRequest{
Prompt: prompt,
Count: 1,
}
if len(anchorBytes) > 0 {
imageReq.ReferenceImage = anchorBytes
imageReq.ReferenceMime = anchorMime
}
start := time.Now()
resp, err := mg.GenerateImage(ctx, imageReq)
elapsed := time.Since(start)
if err != nil {
logger.Error("shot generation failed",
"error", err, "job_id", job.ID, "album_id", albumID, "shot_index", shotIndex)
_ = updater.UpdateShot(ctx, AlbumID(albumID), userID, shotIndex, "", ShotFailed, err.Error())
sendAlbumEvent(pub, userID, EventAlbumShotFailed, AlbumShotFailedData{
AlbumID: albumID,
ShotIndex: shotIndex,
Error: "Shot generation failed: " + err.Error(),
})
return err
}
if len(resp.Images) == 0 {
errMsg := "no images returned from provider"
_ = updater.UpdateShot(ctx, AlbumID(albumID), userID, shotIndex, "", ShotFailed, errMsg)
sendAlbumEvent(pub, userID, EventAlbumShotFailed, AlbumShotFailedData{
AlbumID: albumID,
ShotIndex: shotIndex,
Error: errMsg,
})
return fmt.Errorf(errMsg)
}
img := resp.Images[0]
// Persist shot image to storage.
imageURL := img.URL
if store != nil && len(img.Data) > 0 {
storagePath := fmt.Sprintf("albums/%s/%s/shots/%d.png", userID, albumID, shotIndex)
url, uploadErr := store.Upload(ctx, storagePath, img.Data, "image/png")
if uploadErr != nil {
logger.Warn("failed to persist shot to storage, using inline URL",
"error", uploadErr, "job_id", job.ID)
} else {
imageURL = url
}
}
// Persist shot URL to the album repository.
if err := updater.UpdateShot(ctx, AlbumID(albumID), userID, shotIndex, imageURL, ShotComplete, ""); err != nil {
logger.Error("failed to persist shot URL",
"error", err, "job_id", job.ID, "album_id", albumID, "shot_index", shotIndex)
sendAlbumEvent(pub, userID, EventAlbumShotFailed, AlbumShotFailedData{
AlbumID: albumID,
ShotIndex: shotIndex,
Error: "Failed to save shot: " + err.Error(),
})
return err
}
logger.Info("shot generation complete",
"job_id", job.ID, "album_id", albumID,
"shot_index", shotIndex, "provider", resp.Provider, "elapsed", elapsed)
sendAlbumEvent(pub, userID, EventAlbumShotComplete, AlbumShotCompleteData{
AlbumID: albumID,
ShotIndex: shotIndex,
ImageURL: imageURL,
})
return nil
}
}
// fetchBytes downloads raw bytes from a URL.
// Used to load anchor images at shot-generation time.
func fetchBytes(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch: HTTP %d", resp.StatusCode)
}
const maxSize = 20 << 20 // 20 MB limit for anchor images
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize))
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
return data, nil
}

View File

@ -0,0 +1,83 @@
package album
// Built-in shot template sets. Teams use these on day one, replace with domain-specific
// templates immediately. Three sets cover the main use cases out of the box.
// PortraitSession is a 6-shot set for photographing people.
// Covers headshots through editorial — everything a brand headshot session needs.
var PortraitSession = []ShotTemplate{
{
Label: "Headshot",
Direction: "close-up portrait, direct eye contact, neutral expression, clean studio lighting",
},
{
Label: "Casual",
Direction: "relaxed smile, slight angle, soft natural window light, approachable mood",
},
{
Label: "Professional",
Direction: "confident posture, formal attire, neutral gradient background, polished look",
},
{
Label: "Candid",
Direction: "mid-shot, laughing naturally, environmental context, spontaneous feel",
},
{
Label: "Outdoor",
Direction: "golden hour light, three-quarter angle, natural outdoor setting, warm tones",
},
{
Label: "Editorial",
Direction: "dramatic side lighting, strong shadow, high contrast, artistic mood, magazine style",
},
}
// ProductShoot is a 4-shot set for photographing physical products.
// Covers hero through packaging — standard e-commerce photography.
var ProductShoot = []ShotTemplate{
{
Label: "Hero",
Direction: "centered, white seamless background, clean even studio lighting, product fills 80% of frame",
},
{
Label: "Lifestyle",
Direction: "product in use, aspirational natural setting, shallow depth of field, subject sharp, background blurred",
},
{
Label: "Detail",
Direction: "macro close-up, texture and material quality visible, extreme shallow depth of field, pristine clarity",
},
{
Label: "Packaging",
Direction: "box or packaging visible alongside product, minimal flat lay composition, brand colors present",
},
}
// CharacterSheet is a 4-shot set for illustrated or animated characters.
// Covers neutral through action — standard character design reference set.
var CharacterSheet = []ShotTemplate{
{
Label: "Neutral",
Direction: "front-facing, neutral expression, white background, full character visible head to toe, reference sheet style",
},
{
Label: "Expression",
Direction: "close-up on face, exaggerated emotion characteristic to the character, personality forward",
},
{
Label: "Action",
Direction: "mid-shot, dynamic pose, characteristic action or gesture, energy and movement",
},
{
Label: "Idle",
Direction: "relaxed casual pose, slight 3/4 angle, natural resting state, character at ease",
},
}
// ShotTemplateSets maps set names to their shot template slices.
// Used by HTTP handlers to populate albums from named template sets.
var ShotTemplateSets = map[string][]ShotTemplate{
"portrait": PortraitSession,
"product": ProductShoot,
"character": CharacterSheet,
}

View File

@ -0,0 +1,141 @@
// Package album provides types and job handlers for anchor-based image album generation.
//
// An album is a photography session: one subject, one anchor image, and N shots.
// The anchor image establishes visual identity (same face/product/character).
// Each shot has its own direction (pose, lighting, setting) but references the anchor.
//
// Usage (standalone mode):
//
// albumRepo := memory.NewAlbumRepository()
// memQueue.RegisterHandler("generate_anchor", album.AnchorHandler(mg, store, pub, albumRepo, logger))
// memQueue.RegisterHandler("generate_shot", album.ShotHandler(mg, store, pub, albumRepo, logger))
package album
import (
"context"
"time"
)
// AlbumID is the unique identifier for an album.
type AlbumID string
// Album represents a photography session: one subject, one anchor, N shots.
type Album struct {
ID AlbumID `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"` // Human label: "Jordan Headshots", "Product Launch Set"
SubjectDesc string `json:"subjectDesc"` // Natural language: what/who is being photographed
AnchorURL string `json:"anchorUrl"` // Stored anchor image — empty until generated
AnchorJobID string `json:"anchorJobId"` // Tracks the anchor generation job
Shots []Shot `json:"shots"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Shot is one image in an album with its own generation direction.
type Shot struct {
Index int `json:"index"` // 0-indexed position
Label string `json:"label"` // Human label: "Headshot", "Outdoor Casual"
Direction string `json:"direction"` // What makes this shot unique
ImageURL string `json:"imageUrl"` // Empty until generation completes
JobID string `json:"jobId"` // Tracks the async generation job
Status ShotStatus `json:"status"`
Error string `json:"error,omitempty"` // Set when Status == ShotFailed
GeneratedAt *time.Time `json:"generatedAt,omitempty"`
}
// ShotStatus represents the generation state of a shot.
type ShotStatus string
const (
ShotPending ShotStatus = "pending"
ShotGenerating ShotStatus = "generating"
ShotComplete ShotStatus = "complete"
ShotFailed ShotStatus = "failed"
)
// ShotTemplate defines a reusable shot direction.
// Built-in templates (PortraitSession, ProductShoot, CharacterSheet) ship with the skeleton.
// Teams replace them with domain-specific templates for their use case.
type ShotTemplate struct {
Label string // Human-readable name shown in the UI
Direction string // Added to the subject description to guide generation
}
// ---------------------------------------------------------------------------
// AlbumUpdater — minimal interface for job handlers
// ---------------------------------------------------------------------------
// AlbumUpdater is the minimal interface the job handlers need to persist generation results.
// The full AlbumRepository (in port package) extends this with CRUD operations.
type AlbumUpdater interface {
// UpdateAnchor stores the generated anchor URL for an album.
UpdateAnchor(ctx context.Context, albumID AlbumID, userID, anchorURL, anchorJobID string) error
// UpdateShot stores the generated image URL and status for a specific shot.
UpdateShot(ctx context.Context, albumID AlbumID, userID string, shotIndex int, imageURL string, status ShotStatus, shotError string) error
}
// ---------------------------------------------------------------------------
// Job payloads
// ---------------------------------------------------------------------------
// AnchorPayload is the job payload for "generate_anchor" jobs.
// The anchor is generated without a reference image — it IS the reference.
type AnchorPayload struct {
AlbumID string `json:"albumId"`
UserID string `json:"userId"`
SubjectDesc string `json:"subjectDesc"` // Natural language description of the subject
}
// ShotPayload is the job payload for "generate_shot" jobs.
// The worker fetches the anchor bytes from AnchorURL at execution time.
// Bytes are NOT stored in the queue payload (too large, and URLs are stable).
type ShotPayload struct {
AlbumID string `json:"albumId"`
UserID string `json:"userId"`
ShotIndex int `json:"shotIndex"`
AnchorURL string `json:"anchorUrl"` // Worker fetches this at execution time
SubjectDesc string `json:"subjectDesc"` // Combined with Direction for the final prompt
Direction string `json:"direction"` // Shot-specific direction
}
// ---------------------------------------------------------------------------
// SSE event type constants for album events
// ---------------------------------------------------------------------------
// Album-specific SSE event types.
// All delivered on the existing user:<userId> channel.
const (
EventAlbumAnchorComplete = "album_anchor_complete"
EventAlbumAnchorFailed = "album_anchor_failed"
EventAlbumShotGenerating = "album_shot_generating"
EventAlbumShotComplete = "album_shot_complete"
EventAlbumShotFailed = "album_shot_failed"
)
// AlbumAnchorCompleteData is the SSE payload for album_anchor_complete events.
type AlbumAnchorCompleteData struct {
AlbumID string `json:"albumId"`
AnchorURL string `json:"anchorUrl"`
}
// AlbumShotCompleteData is the SSE payload for album_shot_complete events.
type AlbumShotCompleteData struct {
AlbumID string `json:"albumId"`
ShotIndex int `json:"shotIndex"`
ImageURL string `json:"imageUrl"`
}
// AlbumShotFailedData is the SSE payload for album_shot_failed events.
type AlbumShotFailedData struct {
AlbumID string `json:"albumId"`
ShotIndex int `json:"shotIndex"`
Error string `json:"error"`
}
// AlbumAnchorFailedData is the SSE payload for album_anchor_failed events.
type AlbumAnchorFailedData struct {
AlbumID string `json:"albumId"`
Error string `json:"error"`
}

View File

@ -0,0 +1,345 @@
package persona
// ImageSpec defines a single position in the 20-image matrix.
// Each spec describes the camera setup, subject pose, clothing, scene, and generation status.
type ImageSpec struct {
// Position is the 1-indexed position number (120).
Position int `json:"position" yaml:"position"`
// Tier indicates which content tier (14) this position belongs to.
// Tier 1: Identity, Tier 2: Expressions, Tier 3: Angles, Tier 4: Context.
Tier int `json:"tier" yaml:"tier"`
// TierName is the human-readable tier label.
TierName string `json:"tier_name" yaml:"tier_name"`
// Distance is the camera distance from the subject: "close", "mid", "full".
Distance string `json:"distance" yaml:"distance"`
// Angle is the camera angle: "front", "3/4", "profile", "low", "high", "overhead".
Angle string `json:"angle" yaml:"angle"`
// SubjectPosition describes how the subject is positioned:
// "standing", "sitting", "lying", "leaning", "crouching", "kneeling".
SubjectPosition string `json:"subject_position" yaml:"subject_position"`
// Expression is the facial expression for this shot.
Expression string `json:"expression" yaml:"expression"`
// Pose describes additional body language and posing details.
Pose string `json:"pose" yaml:"pose"`
// ClothingState describes the neckline/coverage style for this shot
// (e.g., "off-shoulder", "casual", "athletic", "form-fitting").
ClothingState string `json:"clothing_state" yaml:"clothing_state"`
// Scene is the environment or background setting.
Scene string `json:"scene" yaml:"scene"`
// Outfit is the specific outfit description for this position.
// Populated during Stage 5 (PopulateImageMatrix) from the persona's Lifestyle.
Outfit string `json:"outfit,omitempty" yaml:"outfit,omitempty"`
// FashionContext is the fashion context name used for this shot.
// Populated during Stage 5 from the persona's FashionSense.
FashionContext string `json:"fashion_context,omitempty" yaml:"fashion_context,omitempty"`
// Prompt is the assembled HEIA generation prompt.
// Set by the imagegen pipeline before calling the image provider.
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"`
// URL is the storage URL of the generated image.
// Set after successful image generation and upload.
URL string `json:"url,omitempty" yaml:"url,omitempty"`
// Status is the current generation status for this position.
Status ImageStatus `json:"status" yaml:"status"`
}
// ImageStatus represents the generation status of an image position.
type ImageStatus string
const (
ImageStatusPending ImageStatus = "pending"
ImageStatusQueued ImageStatus = "queued"
ImageStatusComplete ImageStatus = "complete"
ImageStatusFailed ImageStatus = "failed"
)
// DefaultImageMatrix returns the 20-position image matrix with structural layout pre-filled.
// Outfit, FashionContext, and Prompt fields are empty — populated during specgen Stage 5.
func DefaultImageMatrix() []ImageSpec {
return []ImageSpec{
// ── Tier 1: Identity (positions 15) ──────────────────────────────────
// Establishes the persona's core visual identity. Position 1 is the anchor.
{
Position: 1,
Tier: 1,
TierName: "Identity",
Distance: "mid",
Angle: "3/4",
SubjectPosition: "standing",
Expression: "soft gaze",
Pose: "slight over-shoulder turn, relaxed arms",
ClothingState: "off-shoulder",
Scene: "neutral light background, subtle gradient",
Status: ImageStatusPending,
},
{
Position: 2,
Tier: 1,
TierName: "Identity",
Distance: "close",
Angle: "front",
SubjectPosition: "standing",
Expression: "natural smile",
Pose: "selfie angle, chin slightly down",
ClothingState: "casual",
Scene: "bright outdoor daylight, bokeh background",
Status: ImageStatusPending,
},
{
Position: 3,
Tier: 1,
TierName: "Identity",
Distance: "mid",
Angle: "front",
SubjectPosition: "standing",
Expression: "soft gaze",
Pose: "relaxed, hands at sides or lightly touching hair",
ClothingState: "casual",
Scene: "urban street or modern interior, warm light",
Status: ImageStatusPending,
},
{
Position: 4,
Tier: 1,
TierName: "Identity",
Distance: "mid",
Angle: "low",
SubjectPosition: "leaning",
Expression: "slight smirk",
Pose: "leaning against wall, one foot crossed",
ClothingState: "casual streetwear",
Scene: "textured urban wall or doorway",
Status: ImageStatusPending,
},
{
Position: 5,
Tier: 1,
TierName: "Identity",
Distance: "full",
Angle: "front",
SubjectPosition: "standing",
Expression: "confident",
Pose: "power stance, one hand on hip or relaxed",
ClothingState: "fashion outfit",
Scene: "clean studio or minimal architectural setting",
Status: ImageStatusPending,
},
// ── Tier 2: Expressions (positions 611) ──────────────────────────────
// Showcases personality through varied facial expressions and body language.
{
Position: 6,
Tier: 2,
TierName: "Expressions",
Distance: "close",
Angle: "front",
SubjectPosition: "lying",
Expression: "tongue-out playful",
Pose: "lying on stomach, propped on elbows, face close to camera",
ClothingState: "comfortable/cozy",
Scene: "plush surface, soft warm tones",
Status: ImageStatusPending,
},
{
Position: 7,
Tier: 2,
TierName: "Expressions",
Distance: "mid",
Angle: "3/4",
SubjectPosition: "standing",
Expression: "flirty",
Pose: "mirror selfie angle, one hand on hip or phone",
ClothingState: "casual",
Scene: "stylish bathroom or full-length mirror setting",
Status: ImageStatusPending,
},
{
Position: 8,
Tier: 2,
TierName: "Expressions",
Distance: "mid",
Angle: "front",
SubjectPosition: "standing",
Expression: "lip-bite",
Pose: "one or both arms raised overhead, stretching",
ClothingState: "form-fitting",
Scene: "neutral or warm-toned interior",
Status: ImageStatusPending,
},
{
Position: 9,
Tier: 2,
TierName: "Expressions",
Distance: "close",
Angle: "slight overhead",
SubjectPosition: "standing",
Expression: "beaming open smile",
Pose: "arms reaching upward or to the side",
ClothingState: "athletic wear",
Scene: "bright daylight, outdoor or gym setting",
Status: ImageStatusPending,
},
{
Position: 10,
Tier: 2,
TierName: "Expressions",
Distance: "mid",
Angle: "front",
SubjectPosition: "sitting",
Expression: "soft gaze",
Pose: "sitting with legs crossed or tucked, relaxed posture",
ClothingState: "cozy layered",
Scene: "couch or window seat, warm interior light",
Status: ImageStatusPending,
},
{
Position: 11,
Tier: 2,
TierName: "Expressions",
Distance: "close",
Angle: "slight side",
SubjectPosition: "standing",
Expression: "mischievous smile",
Pose: "adjusting hair or collar with one hand",
ClothingState: "stylish",
Scene: "textured background, dappled light",
Status: ImageStatusPending,
},
// ── Tier 3: Angles (positions 1216) ──────────────────────────────────
// Explores different camera angles to showcase the persona from multiple perspectives.
{
Position: 12,
Tier: 3,
TierName: "Angles",
Distance: "3/4 body",
Angle: "low",
SubjectPosition: "standing",
Expression: "confident",
Pose: "power pose, chin up, shoulders back",
ClothingState: "fashion outfit",
Scene: "dramatic lighting, architectural background",
Status: ImageStatusPending,
},
{
Position: 13,
Tier: 3,
TierName: "Angles",
Distance: "close",
Angle: "high",
SubjectPosition: "lying",
Expression: "coy",
Pose: "lying on back, looking up at camera through lashes",
ClothingState: "minimal/cozy",
Scene: "soft bedding or plush surface, diffused light",
Status: ImageStatusPending,
},
{
Position: 14,
Tier: 3,
TierName: "Angles",
Distance: "full",
Angle: "low",
SubjectPosition: "standing",
Expression: "slight smirk",
Pose: "walking toward camera or dynamic pose",
ClothingState: "fashion outfit",
Scene: "striking background — marble, street, or nature",
Status: ImageStatusPending,
},
{
Position: 15,
Tier: 3,
TierName: "Angles",
Distance: "close",
Angle: "slight low",
SubjectPosition: "standing",
Expression: "serious direct gaze",
Pose: "chin slightly raised, intense look into camera",
ClothingState: "strong/structured",
Scene: "minimal dark or moody background",
Status: ImageStatusPending,
},
{
Position: 16,
Tier: 3,
TierName: "Angles",
Distance: "mid",
Angle: "profile",
SubjectPosition: "standing",
Expression: "neutral serene",
Pose: "side view, looking toward natural light source",
ClothingState: "outfit showing silhouette",
Scene: "window light or golden hour outdoor",
Status: ImageStatusPending,
},
// ── Tier 4: Context (positions 1720) ─────────────────────────────────
// Situational shots showing the persona in everyday contexts and environments.
{
Position: 17,
Tier: 4,
TierName: "Context",
Distance: "mid",
Angle: "front",
SubjectPosition: "lounging",
Expression: "warm relaxed smile",
Pose: "reclining on sofa or chair, legs tucked",
ClothingState: "casual homewear",
Scene: "living room or bedroom, cozy ambient light",
Status: ImageStatusPending,
},
{
Position: 18,
Tier: 4,
TierName: "Context",
Distance: "mid",
Angle: "slight low",
SubjectPosition: "standing",
Expression: "neutral calm",
Pose: "over-shoulder glance back at camera",
ClothingState: "stylish",
Scene: "doorway, hallway, or outdoor path",
Status: ImageStatusPending,
},
{
Position: 19,
Tier: 4,
TierName: "Context",
Distance: "mid",
Angle: "front",
SubjectPosition: "sitting",
Expression: "flirty playful",
Pose: "knees pulled up, arms hugging knees or resting on them",
ClothingState: "casual",
Scene: "bench, steps, or outdoor surface, natural light",
Status: ImageStatusPending,
},
{
Position: 20,
Tier: 4,
TierName: "Context",
Distance: "full",
Angle: "front",
SubjectPosition: "standing",
Expression: "confident poised",
Pose: "full-length mirror shot, one hand on mirror edge",
ClothingState: "complete fashion outfit",
Scene: "full-length mirror, styled room or boutique setting",
Status: ImageStatusPending,
},
}
}

View File

@ -0,0 +1,227 @@
package persona
// Lifestyle contains the persona's interests, fashion sense, and vacation style.
// Generated in Stage 3 of the specgen pipeline using identity and psychology as inputs.
type Lifestyle struct {
// Interests captures the persona's hobbies and passions.
Interests Interests `json:"interests" yaml:"interests"`
// FashionSense describes the persona's clothing style preferences.
FashionSense FashionSense `json:"fashion_sense" yaml:"fashion_sense"`
// VacationStyle describes travel preferences.
VacationStyle VacationStyle `json:"vacation_style" yaml:"vacation_style"`
}
// Interests captures a persona's hobbies and passions across 5 categories.
type Interests struct {
// Creative interests: art, music, writing, photography, etc.
Creative []string `json:"creative" yaml:"creative"`
// Active interests: sports, fitness, dance, hiking, etc.
Active []string `json:"active" yaml:"active"`
// Social interests: dining, travel, nightlife, community, etc.
Social []string `json:"social" yaml:"social"`
// Intellectual interests: books, philosophy, science, tech, etc.
Intellectual []string `json:"intellectual" yaml:"intellectual"`
// Lifestyle interests: cooking, gardening, home decor, wellness, etc.
Lifestyle []string `json:"lifestyle" yaml:"lifestyle"`
}
// FashionSense describes a persona's clothing style.
type FashionSense struct {
// Primary is the dominant fashion context name.
Primary FashionContextName `json:"primary" yaml:"primary"`
// Secondary is a secondary style influence (optional).
Secondary FashionContextName `json:"secondary,omitempty" yaml:"secondary,omitempty"`
// SignatureDetails are specific personal styling touches (e.g., "always wears gold hoops").
SignatureDetails []string `json:"signature_details,omitempty" yaml:"signature_details,omitempty"`
}
// VacationStyle describes a persona's travel preferences.
type VacationStyle struct {
// Primary is the dominant vacation type: "beach", "city", "adventure", "luxury", "cultural".
Primary string `json:"primary" yaml:"primary"`
// Activities are preferred vacation activities.
Activities []string `json:"activities,omitempty" yaml:"activities,omitempty"`
// DreamDestinations are top travel destinations.
DreamDestinations []string `json:"dream_destinations,omitempty" yaml:"dream_destinations,omitempty"`
}
// FashionContextName identifies one of the 15 defined fashion contexts.
type FashionContextName string
const (
FashionClassicMinimalist FashionContextName = "classic_minimalist"
FashionStreetwearChic FashionContextName = "streetwear_chic"
FashionBohemianSpirit FashionContextName = "bohemian_free_spirit"
FashionAthleisurePro FashionContextName = "athleisure_pro"
FashionBusinessCasual FashionContextName = "business_casual"
FashionRomanticFeminine FashionContextName = "romantic_feminine"
FashionEdgyAlternative FashionContextName = "edgy_alternative"
FashionCoastalCasual FashionContextName = "coastal_casual"
FashionUrbanProfessional FashionContextName = "urban_professional"
FashionFestivalGlam FashionContextName = "festival_glam"
FashionPreppyClassic FashionContextName = "preppy_classic"
FashionDarkAcademia FashionContextName = "dark_academia"
FashionCottagecore FashionContextName = "cottagecore"
FashionY2KRevival FashionContextName = "y2k_revival"
FashionLuxeLoungewear FashionContextName = "luxe_loungewear"
)
// FashionContext describes a named fashion style with styling details.
type FashionContext struct {
// Name is the canonical identifier.
Name FashionContextName `json:"name" yaml:"name"`
// Description is a one-line style summary.
Description string `json:"description" yaml:"description"`
// KeyPieces are the defining wardrobe items.
KeyPieces []string `json:"key_pieces" yaml:"key_pieces"`
// Brands are representative brands for this aesthetic.
Brands []string `json:"brands" yaml:"brands"`
// Silhouette describes the overall shape/fit profile.
Silhouette string `json:"silhouette" yaml:"silhouette"`
}
// AllFashionContexts returns the catalog of all 15 defined fashion contexts.
func AllFashionContexts() []FashionContext {
return []FashionContext{
{
Name: FashionClassicMinimalist,
Description: "Clean lines, neutral palette, and timeless silhouettes",
KeyPieces: []string{"crisp white shirt", "tailored trousers", "structured blazer", "minimal gold jewelry"},
Brands: []string{"COS", "Everlane", "The Row", "A.P.C."},
Silhouette: "slim, structured, minimal layering",
},
{
Name: FashionStreetwearChic,
Description: "Urban-influenced with oversized fits, sneakers, and bold logos",
KeyPieces: []string{"oversized hoodie", "cargo pants", "chunky sneakers", "bucket hat"},
Brands: []string{"Off-White", "Supreme", "Stüssy", "PANGAIA"},
Silhouette: "oversized tops, tapered or wide bottoms",
},
{
Name: FashionBohemianSpirit,
Description: "Free-flowing fabrics, earthy tones, and global-inspired prints",
KeyPieces: []string{"flowy maxi dress", "embroidered blouse", "woven bag", "layered necklaces"},
Brands: []string{"Free People", "Spell", "Anthropologie", "Zimmermann"},
Silhouette: "loose, flowing, layered",
},
{
Name: FashionAthleisurePro,
Description: "Performance fabrics worn stylishly beyond the gym",
KeyPieces: []string{"seamless leggings", "sports bra", "zip-up hoodie", "white sneakers"},
Brands: []string{"Lululemon", "Alo Yoga", "Gymshark", "Vuori"},
Silhouette: "form-fitting, streamlined",
},
{
Name: FashionBusinessCasual,
Description: "Polished and professional with approachable comfort",
KeyPieces: []string{"fitted blazer", "straight-leg trousers", "silk blouse", "loafers"},
Brands: []string{"Banana Republic", "Theory", "J.Crew", "& Other Stories"},
Silhouette: "tailored, clean lines, moderate coverage",
},
{
Name: FashionRomanticFeminine,
Description: "Soft, delicate pieces with florals, lace, and pastel tones",
KeyPieces: []string{"floral midi dress", "lace cami", "ballet flats", "pearl accessories"},
Brands: []string{"Reformation", "Loveshackfancy", "Hill House Home", "Rixo"},
Silhouette: "fitted bodice with full or flowy skirts",
},
{
Name: FashionEdgyAlternative,
Description: "Dark palette, punk and rock influences, leather and hardware",
KeyPieces: []string{"leather jacket", "black skinny jeans", "combat boots", "silver chain jewelry"},
Brands: []string{"AllSaints", "Saint Laurent", "Helmut Lang", "Rick Owens"},
Silhouette: "slim, angular, layered with hardware",
},
{
Name: FashionCoastalCasual,
Description: "Relaxed, sun-kissed looks inspired by beach and ocean lifestyles",
KeyPieces: []string{"linen shorts", "striped tee", "espadrilles", "canvas tote"},
Brands: []string{"Outerknown", "Marine Layer", "Vilebrequin", "Solid & Striped"},
Silhouette: "relaxed, breezy, effortless",
},
{
Name: FashionUrbanProfessional,
Description: "Sharp city-dweller style blending fashion and function",
KeyPieces: []string{"tailored coat", "ankle boots", "structured handbag", "monochrome sets"},
Brands: []string{"Toteme", "Cos", "Sandro", "BOSS"},
Silhouette: "sleek, polished, statement outerwear",
},
{
Name: FashionFestivalGlam,
Description: "Bold prints, glitter, and maximalist looks built for events",
KeyPieces: []string{"crop top with sequins", "fringe jacket", "platform sandals", "body chains"},
Brands: []string{"House of CB", "PrettyLittleThing", "Fashion Nova", "Nasty Gal"},
Silhouette: "skin-baring, layered accessories, high visual impact",
},
{
Name: FashionPreppyClassic,
Description: "Collegiate-inspired clean aesthetics with a polished finish",
KeyPieces: []string{"polo shirt", "chino trousers", "boat shoes", "cable-knit sweater"},
Brands: []string{"Ralph Lauren", "Brooks Brothers", "Tommy Hilfiger", "Lacoste"},
Silhouette: "classic proportions, clean and put-together",
},
{
Name: FashionDarkAcademia,
Description: "Literary and scholarly aesthetic with earth tones and vintage pieces",
KeyPieces: []string{"tweed blazer", "turtleneck", "pleated skirt", "oxford shoes"},
Brands: []string{"Cordera", "Mango", "H&M Studio", "Massimo Dutti"},
Silhouette: "layered, traditional, intellectual",
},
{
Name: FashionCottagecore,
Description: "Pastoral romanticism with floral prints, ruffles, and natural textures",
KeyPieces: []string{"prairie dress", "puff-sleeve blouse", "wicker bag", "mary jane shoes"},
Brands: []string{"Doen", "For Love & Lemons", "Batsheva", "Emilia Wickstead"},
Silhouette: "loose, romantic, tiered or smocked",
},
{
Name: FashionY2KRevival,
Description: "Early 2000s nostalgia with low-rise, butterfly prints, and metallics",
KeyPieces: []string{"low-rise jeans", "halter top", "platform boots", "mini bag"},
Brands: []string{"Blumarine", "Diesel", "Juicy Couture", "Paris Hilton Collection"},
Silhouette: "crop tops with low-rise bottoms, skin-baring midriff",
},
{
Name: FashionLuxeLoungewear,
Description: "Elevated comfort dressing in premium fabrics and tonal sets",
KeyPieces: []string{"cashmere loungewear set", "silk slip", "fuzzy slides", "oversized cashmere cardigan"},
Brands: []string{"Skims", "Eberjey", "Brunello Cucinelli", "Leset"},
Silhouette: "relaxed but polished, tonal and monochromatic",
},
}
}
// FashionContextFor returns the FashionContext definition for the given name.
// Returns a zero-value FashionContext if the name is not found.
func FashionContextFor(name FashionContextName) FashionContext {
for _, fc := range AllFashionContexts() {
if fc.Name == name {
return fc
}
}
return FashionContext{}
}
// AllFashionContextNames returns all 15 fashion context names.
func AllFashionContextNames() []FashionContextName {
all := AllFashionContexts()
names := make([]FashionContextName, len(all))
for i, fc := range all {
names[i] = fc.Name
}
return names
}

View File

@ -0,0 +1,128 @@
package persona
import (
"time"
)
// PersonaSpec is the top-level container for a fully generated persona.
// It wraps all DNA, psychology, lifestyle, image matrix, and video specs
// produced by the personagen pipeline.
type PersonaSpec struct {
// ID is the unique identifier for this persona.
ID string `json:"id" yaml:"id"`
// CreatedAt is when the spec was generated.
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
// DNA contains the immutable biological characteristics.
DNA *DNA `json:"dna" yaml:"dna"`
// Name contains the persona's name.
Name NameSpec `json:"name" yaml:"name"`
// Psychology contains the HEXACO personality profile.
Psychology Psychology `json:"psychology" yaml:"psychology"`
// Lifestyle contains interests, fashion contexts, and vacation style.
Lifestyle Lifestyle `json:"lifestyle" yaml:"lifestyle"`
// ImageMatrix is the 20-position image generation spec.
ImageMatrix []ImageSpec `json:"image_matrix" yaml:"image_matrix"`
// Videos is the 4-motion-type video spec set.
Videos []VideoSpec `json:"videos" yaml:"videos"`
// AnchorImage is the PNG bytes for position 1 (the identity anchor).
// Not persisted inline — stored separately in object storage.
AnchorImage []byte `json:"-" yaml:"-"`
// Attractiveness describes the visual attractiveness tier.
Attractiveness AttractivenessTier `json:"attractiveness" yaml:"attractiveness"`
// GenerationTier describes the content generation tier.
GenerationTier GenerationTier `json:"generation_tier" yaml:"generation_tier"`
// DiversityProfile contains optional diversity metadata.
DiversityProfile *DiversityProfile `json:"diversity_profile,omitempty" yaml:"diversity_profile,omitempty"`
}
// AttractivenessTier describes the visual attractiveness level of a persona.
type AttractivenessTier string
const (
AttractivenessTierAverage AttractivenessTier = "average"
AttractivenessTierAboveAverage AttractivenessTier = "above_average"
AttractivenessTierAttractive AttractivenessTier = "attractive"
AttractivenessTierVery AttractivenessTier = "very_attractive"
AttractivenessTierStunning AttractivenessTier = "stunning"
)
// IsValid returns true if the tier is a recognized value.
func (t AttractivenessTier) IsValid() bool {
switch t {
case AttractivenessTierAverage, AttractivenessTierAboveAverage,
AttractivenessTierAttractive, AttractivenessTierVery, AttractivenessTierStunning:
return true
default:
return false
}
}
// GenerationTier controls the intended generation quality and persona profile depth.
type GenerationTier string
const (
GenerationTierEveryday GenerationTier = "everyday"
GenerationTierInfluencer GenerationTier = "influencer"
GenerationTierSupermodel GenerationTier = "supermodel"
)
// IsValid returns true if the tier is a recognized value.
func (t GenerationTier) IsValid() bool {
switch t {
case GenerationTierEveryday, GenerationTierInfluencer, GenerationTierSupermodel:
return true
default:
return false
}
}
// DiversityProfile contains optional diversity and representation metadata.
type DiversityProfile struct {
// RepresentationNotes describes diversity considerations for this persona.
RepresentationNotes string `json:"representation_notes,omitempty" yaml:"representation_notes,omitempty"`
// CulturalBackground describes cultural heritage highlights.
CulturalBackground string `json:"cultural_background,omitempty" yaml:"cultural_background,omitempty"`
}
// CoreIdentity contains the essential demographic identity data generated
// in Stage 1 of the specgen pipeline.
type CoreIdentity struct {
// Name is the persona's full name.
Name NameSpec `json:"name" yaml:"name"`
// Age in years.
Age int `json:"age" yaml:"age"`
// Gender is the gender identity.
Gender GenderIdentity `json:"gender" yaml:"gender"`
// Ethnicity is the primary ethnic background.
Ethnicity EthnicityCode `json:"ethnicity" yaml:"ethnicity"`
// Nationality is the country/cultural background.
Nationality string `json:"nationality" yaml:"nationality"`
// Sexuality describes the persona's sexuality (for backstory context only).
Sexuality string `json:"sexuality,omitempty" yaml:"sexuality,omitempty"`
// Occupation is the current job or role.
Occupation string `json:"occupation,omitempty" yaml:"occupation,omitempty"`
// BirthCity is where the persona was born.
BirthCity string `json:"birth_city,omitempty" yaml:"birth_city,omitempty"`
// CurrentCity is where the persona currently lives.
CurrentCity string `json:"current_city,omitempty" yaml:"current_city,omitempty"`
}

View File

@ -0,0 +1,92 @@
package persona
// VideoSpec defines a single video generation spec for one of the 4 motion types.
type VideoSpec struct {
// MotionType describes the scenario and movement style.
MotionType MotionType `json:"motion_type" yaml:"motion_type"`
// Prompt is the assembled Veo generation prompt.
// Set by the videogen pipeline before calling the video provider.
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"`
// URL is the storage URL of the generated video.
// Set after successful video generation and upload.
URL string `json:"url,omitempty" yaml:"url,omitempty"`
// Status is the current generation status.
Status VideoStatus `json:"status" yaml:"status"`
// Duration is the target video duration (e.g., "5s", "8s").
Duration string `json:"duration" yaml:"duration"`
// AspectRatio is the target aspect ratio ("9:16" vertical, "16:9" horizontal).
AspectRatio string `json:"aspect_ratio" yaml:"aspect_ratio"`
}
// MotionType represents the scenario and movement type for a persona video.
type MotionType string
const (
// MotionSmileReveal is a brief, warm smile moment — ideal for first impressions.
MotionSmileReveal MotionType = "smile_reveal"
// MotionPersonality is an expressive personality showcase moment.
MotionPersonality MotionType = "personality_moment"
// MotionLifestyle is a contextual lifestyle shot showing the persona in their world.
MotionLifestyle MotionType = "lifestyle"
// MotionInvitation is a direct-address invitation or call-to-action moment.
MotionInvitation MotionType = "invitation"
)
// IsValid returns true if the motion type is recognized.
func (m MotionType) IsValid() bool {
switch m {
case MotionSmileReveal, MotionPersonality, MotionLifestyle, MotionInvitation:
return true
default:
return false
}
}
// VideoStatus represents the generation status of a video.
type VideoStatus string
const (
VideoStatusPending VideoStatus = "pending"
VideoStatusQueued VideoStatus = "queued"
VideoStatusComplete VideoStatus = "complete"
VideoStatusFailed VideoStatus = "failed"
)
// DefaultVideoMatrix returns the 4 standard video specs with default durations and aspect ratios.
// Prompts are empty — populated by the videogen pipeline.
func DefaultVideoMatrix() []VideoSpec {
return []VideoSpec{
{
MotionType: MotionSmileReveal,
Duration: "5s",
AspectRatio: "9:16",
Status: VideoStatusPending,
},
{
MotionType: MotionPersonality,
Duration: "8s",
AspectRatio: "9:16",
Status: VideoStatusPending,
},
{
MotionType: MotionLifestyle,
Duration: "8s",
AspectRatio: "16:9",
Status: VideoStatusPending,
},
{
MotionType: MotionInvitation,
Duration: "5s",
AspectRatio: "9:16",
Status: VideoStatusPending,
},
}
}

View File

@ -0,0 +1,229 @@
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)
}
}

View File

@ -0,0 +1,432 @@
// 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"
"time"
"{{GO_MODULE}}/pkg/mediagen"
"{{GO_MODULE}}/pkg/persona"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/pkg/realtime"
"{{GO_MODULE}}/pkg/storage"
"{{GO_MODULE}}/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.
// 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
}
return generateVideo(ctx, s.mediagen, spec, motionType, s.anchor, s.logger)
}
// 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)
}
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},
})
// Generate all 20 image positions, publishing progress events.
for _, imgSpec := range spec.ImageMatrix {
pos := imgSpec.Position
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
url := ""
for _, is := range spec.ImageMatrix {
if is.Position == pos {
url = is.URL
break
}
}
sendEvent(&realtime.SSEEvent{
Type: "persona_image_progress",
JobID: job.ID,
Progress: progress,
Result: map[string]any{"position": pos, "url": 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.
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)
// Videos are best-effort; don't fail the entire job.
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.
func inferGenerationTier(description string) persona.GenerationTier {
for _, kw := range []string{"supermodel", "model", "editorial", "high fashion"} {
if contains(description, kw) {
return persona.GenerationTierSupermodel
}
}
for _, kw := range []string{"influencer", "content creator", "blogger", "social media"} {
if contains(description, 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
}
}
// contains checks if a string contains a substring (case-insensitive).
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
len(s) > 0 &&
(s == substr || len(s) > 0 && stringContainsFold(s, substr))
}
func stringContainsFold(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if equalFold(s[i:i+len(substr)], substr) {
return true
}
}
return false
}
func equalFold(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += 'a' - 'A'
}
if cb >= 'A' && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}
// now returns the current time. Useful for overriding in tests.
var now = func() time.Time { return time.Now() }

View File

@ -0,0 +1,610 @@
package personagen
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"{{GO_MODULE}}/pkg/persona"
"{{GO_MODULE}}/pkg/textgen"
)
// generatePersonaSpec runs the 5-stage LLM pipeline to produce a complete PersonaSpec.
// Each stage builds on the output of the previous one.
func generatePersonaSpec(ctx context.Context, tg *textgen.Manager, seed SeedParams, logger *slog.Logger) (*persona.PersonaSpec, error) {
logger = logger.With("op", "generatePersonaSpec")
// Stage 1: Core identity (name, demographics, occupation, locations)
logger.Info("specgen stage 1: generating core identity")
identity, err := genIdentity(ctx, tg, seed)
if err != nil {
return nil, fmt.Errorf("stage 1 identity: %w", err)
}
// Stage 2: Psychology (HEXACO, attachment, values)
logger.Info("specgen stage 2: generating psychology")
psych, err := genPsychology(ctx, tg, identity)
if err != nil {
return nil, fmt.Errorf("stage 2 psychology: %w", err)
}
// Stage 3: Lifestyle (interests, fashion context, vacation style)
logger.Info("specgen stage 3: generating lifestyle")
lifestyle, err := genLifestyle(ctx, tg, identity, psych)
if err != nil {
return nil, fmt.Errorf("stage 3 lifestyle: %w", err)
}
// Stage 4: Visual DNA (face, body, voice characteristics)
logger.Info("specgen stage 4: generating visual DNA")
dna, err := genDNA(ctx, tg, identity, lifestyle)
if err != nil {
return nil, fmt.Errorf("stage 4 visual DNA: %w", err)
}
// Stage 5: Populate image matrix with lifestyle-derived outfits and scenes.
logger.Info("specgen stage 5: populating image matrix")
imageMatrix := populateImageMatrix(persona.DefaultImageMatrix(), lifestyle)
tier := inferGenerationTier(seed.Description)
spec := &persona.PersonaSpec{
ID: generateID(),
CreatedAt: now(),
DNA: dna,
Name: identity.Name,
Psychology: *psych,
Lifestyle: *lifestyle,
ImageMatrix: imageMatrix,
Videos: persona.DefaultVideoMatrix(),
GenerationTier: tier,
Attractiveness: inferAttractiveness(tier),
}
logger.Info("specgen complete", "persona_id", spec.ID)
return spec, nil
}
// ── Internal LLM response structs ──────────────────────────────────────────
type identityLLMResponse struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Nickname string `json:"nickname,omitempty"`
Age int `json:"age"`
Gender string `json:"gender"`
Ethnicity string `json:"ethnicity"`
Nationality string `json:"nationality"`
Sexuality string `json:"sexuality,omitempty"`
Occupation string `json:"occupation,omitempty"`
BirthCity string `json:"birth_city,omitempty"`
CurrentCity string `json:"current_city,omitempty"`
}
type hexacoScoreLLM struct {
Score int `json:"score"`
BehavioralImplications string `json:"behavioral_implications,omitempty"`
}
type psychLLMResponse struct {
HonestyHumility hexacoScoreLLM `json:"honesty_humility"`
Emotionality hexacoScoreLLM `json:"emotionality"`
Extraversion hexacoScoreLLM `json:"extraversion"`
Agreeableness hexacoScoreLLM `json:"agreeableness"`
Conscientiousness hexacoScoreLLM `json:"conscientiousness"`
Openness hexacoScoreLLM `json:"openness"`
AttachmentPrimary string `json:"attachment_primary"`
AttachmentPattern string `json:"attachment_pattern,omitempty"`
CoreValues []string `json:"core_values,omitempty"`
LifePhilosophy string `json:"life_philosophy,omitempty"`
}
type lifestyleLLMResponse struct {
CreativeInterests []string `json:"creative_interests"`
ActiveInterests []string `json:"active_interests"`
SocialInterests []string `json:"social_interests"`
IntellectualInterests []string `json:"intellectual_interests"`
LifestyleInterests []string `json:"lifestyle_interests"`
FashionPrimary string `json:"fashion_primary"`
FashionSecondary string `json:"fashion_secondary,omitempty"`
FashionSignature []string `json:"fashion_signature_details,omitempty"`
VacationPrimary string `json:"vacation_primary"`
VacationActivities []string `json:"vacation_activities,omitempty"`
DreamDestinations []string `json:"dream_destinations,omitempty"`
}
type dnaLLMResponse struct {
// Face
FaceShape string `json:"face_shape"`
BoneStructure string `json:"bone_structure"`
Jawline string `json:"jawline"`
Cheekbones string `json:"cheekbones"`
EyeShape string `json:"eye_shape"`
EyeColor string `json:"eye_color"`
EyeSpacing string `json:"eye_spacing"`
EyeSize string `json:"eye_size"`
NoseShape string `json:"nose_shape"`
NoseBridge string `json:"nose_bridge"`
NoseTip string `json:"nose_tip"`
LipShape string `json:"lip_shape"`
LipFullness string `json:"lip_fullness"`
SmileType string `json:"smile_type"`
BrowShape string `json:"brow_shape"`
BrowThickness string `json:"brow_thickness"`
SkinTone string `json:"skin_tone"`
SkinUndertone string `json:"skin_undertone"`
SkinTexture string `json:"skin_texture"`
HairColor string `json:"hair_color"`
HairTexture string `json:"hair_texture"`
HairLength string `json:"hair_length"`
HairThickness string `json:"hair_thickness"`
// Body
HeightCM int `json:"height_cm"`
Build string `json:"build"`
BodyFatPercent int `json:"body_fat_percent"`
MuscleDefinition string `json:"muscle_definition"`
ShoulderWidth string `json:"shoulder_width"`
HipWidth string `json:"hip_width"`
WHRatio float64 `json:"wh_ratio"`
LegLength string `json:"leg_length"`
TorsoLength string `json:"torso_length"`
BustSize string `json:"bust_size,omitempty"`
Posture string `json:"posture"`
// Voice
Pitch string `json:"pitch"`
PitchRange string `json:"pitch_range"`
Tone string `json:"tone"`
Timbre string `json:"timbre"`
Accent string `json:"accent"`
AccentStrength string `json:"accent_strength"`
Cadence string `json:"cadence"`
RhythmPattern string `json:"rhythm_pattern"`
Volume string `json:"volume"`
Clarity string `json:"clarity"`
Expressiveness string `json:"expressiveness"`
}
// ── Stage implementations ──────────────────────────────────────────────────
func genIdentity(ctx context.Context, tg *textgen.Manager, seed SeedParams) (*persona.CoreIdentity, error) {
validGenders := `"woman", "man", "non_binary"`
validEthnicities := `"east_asian", "south_asian", "southeast_asian", "african", "hispanic", "middle_eastern", "caucasian", "mixed"`
prompt := fmt.Sprintf(`You are generating a synthetic persona profile. Given this seed:
Description: %q
Gender: %q
Name override (empty = generate one): %q
Return ONLY a JSON object with these exact fields:
{
"first_name": "string (culturally appropriate given ethnicity)",
"last_name": "string",
"nickname": "string or empty",
"age": number (18-35),
"gender": one of [%s],
"ethnicity": one of [%s],
"nationality": "string (country name)",
"sexuality": "string (e.g. heterosexual, bisexual)",
"occupation": "string (job title or role)",
"birth_city": "string",
"current_city": "string (where they live now)"
}
Ensure name fits the ethnicity and nationality. Return ONLY valid JSON.`,
seed.Description, seed.Gender, seed.Name, validGenders, validEthnicities)
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
Prompt: prompt,
MaxTokens: 400,
Temperature: 0.8,
Timeout: 30 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
var r identityLLMResponse
if err := parseLLMJSON(resp.Text, &r); err != nil {
return nil, fmt.Errorf("parsing identity response: %w", err)
}
// Apply name override from seed if provided.
firstName, lastName := r.FirstName, r.LastName
if seed.Name != "" {
parts := strings.Fields(seed.Name)
if len(parts) > 0 {
firstName = parts[0]
}
if len(parts) > 1 {
lastName = strings.Join(parts[1:], " ")
}
}
return &persona.CoreIdentity{
Name: persona.NameSpec{
First: firstName,
Last: lastName,
Nickname: r.Nickname,
DisplayName: firstName,
},
Age: r.Age,
Gender: persona.GenderIdentity(r.Gender),
Ethnicity: persona.EthnicityCode(r.Ethnicity),
Nationality: r.Nationality,
Sexuality: r.Sexuality,
Occupation: r.Occupation,
BirthCity: r.BirthCity,
CurrentCity: r.CurrentCity,
}, nil
}
func genPsychology(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity) (*persona.Psychology, error) {
prompt := fmt.Sprintf(`Given this persona:
Name: %s %s, Age: %d, Gender: %s, Occupation: %s
Generate a HEXACO personality profile. Score each dimension 1-10 (1=very low, 10=very high).
Valid attachment styles: "secure", "anxious", "avoidant", "disorganized"
Return ONLY a JSON object:
{
"honesty_humility": {"score": number, "behavioral_implications": "string"},
"emotionality": {"score": number, "behavioral_implications": "string"},
"extraversion": {"score": number, "behavioral_implications": "string"},
"agreeableness": {"score": number, "behavioral_implications": "string"},
"conscientiousness": {"score": number, "behavioral_implications": "string"},
"openness": {"score": number, "behavioral_implications": "string"},
"attachment_primary": "string",
"attachment_pattern": "string (1-2 sentences)",
"core_values": ["value1", "value2", "value3", "value4", "value5"],
"life_philosophy": "string (one guiding principle)"
}`,
identity.Name.First, identity.Name.Last, identity.Age, identity.Gender, identity.Occupation)
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
Prompt: prompt,
MaxTokens: 600,
Temperature: 0.7,
Timeout: 30 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
var r psychLLMResponse
if err := parseLLMJSON(resp.Text, &r); err != nil {
return nil, fmt.Errorf("parsing psychology response: %w", err)
}
toTraitScore := func(s hexacoScoreLLM) persona.TraitScore {
return persona.TraitScore{Score: s.Score, BehavioralImplications: s.BehavioralImplications}
}
return &persona.Psychology{
HEXACO: persona.HEXACOProfile{
HonestyHumility: toTraitScore(r.HonestyHumility),
Emotionality: toTraitScore(r.Emotionality),
Extraversion: toTraitScore(r.Extraversion),
Agreeableness: toTraitScore(r.Agreeableness),
Conscientiousness: toTraitScore(r.Conscientiousness),
Openness: toTraitScore(r.Openness),
},
Attachment: persona.AttachmentStyle{
Primary: r.AttachmentPrimary,
Pattern: r.AttachmentPattern,
},
Values: persona.Values{
Core: r.CoreValues,
LifePhilosophy: r.LifePhilosophy,
},
}, nil
}
func genLifestyle(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity, psych *persona.Psychology) (*persona.Lifestyle, error) {
fashionOptions := strings.Join(func() []string {
names := persona.AllFashionContextNames()
out := make([]string, len(names))
for i, n := range names {
out[i] = fmt.Sprintf("%q", n)
}
return out
}(), ", ")
prompt := fmt.Sprintf(`Given this persona:
Name: %s, Age: %d, Occupation: %s, Nationality: %s
Extraversion: %d/10, Openness: %d/10
Generate lifestyle profile. Valid fashion contexts: [%s]
Return ONLY a JSON object:
{
"creative_interests": ["interest1", "interest2"] (2-4 items),
"active_interests": ["interest1"] (1-3 items),
"social_interests": ["interest1", "interest2"] (2-4 items),
"intellectual_interests": ["interest1"] (1-3 items),
"lifestyle_interests": ["interest1", "interest2"] (2-3 items),
"fashion_primary": "one of the valid fashion context names",
"fashion_secondary": "one of the valid fashion context names (different from primary)",
"fashion_signature_details": ["detail1", "detail2"] (1-3 personal styling touches),
"vacation_primary": "beach|city|adventure|luxury|cultural",
"vacation_activities": ["activity1", "activity2"] (2-3 items),
"dream_destinations": ["destination1", "destination2", "destination3"]
}`,
identity.Name.First, identity.Age, identity.Occupation, identity.Nationality,
psych.HEXACO.Extraversion.Score, psych.HEXACO.Openness.Score,
fashionOptions)
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
Prompt: prompt,
MaxTokens: 500,
Temperature: 0.8,
Timeout: 30 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
var r lifestyleLLMResponse
if err := parseLLMJSON(resp.Text, &r); err != nil {
return nil, fmt.Errorf("parsing lifestyle response: %w", err)
}
return &persona.Lifestyle{
Interests: persona.Interests{
Creative: r.CreativeInterests,
Active: r.ActiveInterests,
Social: r.SocialInterests,
Intellectual: r.IntellectualInterests,
Lifestyle: r.LifestyleInterests,
},
FashionSense: persona.FashionSense{
Primary: persona.FashionContextName(r.FashionPrimary),
Secondary: persona.FashionContextName(r.FashionSecondary),
SignatureDetails: r.FashionSignature,
},
VacationStyle: persona.VacationStyle{
Primary: r.VacationPrimary,
Activities: r.VacationActivities,
DreamDestinations: r.DreamDestinations,
},
}, nil
}
func genDNA(ctx context.Context, tg *textgen.Manager, identity *persona.CoreIdentity, lifestyle *persona.Lifestyle) (*persona.DNA, error) {
fashionCtx := persona.FashionContextFor(lifestyle.FashionSense.Primary)
prompt := fmt.Sprintf(`Generate precise visual characteristics for:
Name: %s, Age: %d, Gender: %s, Ethnicity: %s, Nationality: %s
Fashion style: %s (%s)
All values MUST use exactly the allowed enum values listed below.
Return ONLY a JSON object (all fields required):
{
"face_shape": "oval|heart|square|round|diamond|oblong",
"bone_structure": "delicate|moderate|strong",
"jawline": "soft|rounded|defined|angular|square",
"cheekbones": "subtle|moderate|prominent|high",
"eye_shape": "almond|round|hooded|monolid|upturned|downturned|deep_set",
"eye_color": "dark_brown|brown|hazel|amber|green|blue|gray",
"eye_spacing": "close_set|average|wide_set",
"eye_size": "small|average|large",
"nose_shape": "button|straight|wide|roman|aquiline",
"nose_bridge": "low|moderate|high|bumped",
"nose_tip": "rounded|pointed|upturned|bulbous",
"lip_shape": "full|thin|bow|rounded|wide",
"lip_fullness": "subtle|moderate|plump|voluptuous",
"smile_type": "subtle|broad|asymmetric|gummy|closed",
"brow_shape": "natural|arched|straight|rounded|angled",
"brow_thickness": "thin|natural|thick|fluffy|bleached",
"skin_tone": "fair|light|medium|olive|tan|brown|dark_brown|deep",
"skin_undertone": "warm|cool|neutral",
"skin_texture": "smooth|normal|textured|mature",
"hair_color": "black|dark_brown|brown|light_brown|blonde|red|auburn|gray",
"hair_texture": "straight|wavy|curly|coily|kinky",
"hair_length": "pixie|short|chin|shoulder|mid_back|long|very_long",
"hair_thickness": "fine|medium|thick",
"height_cm": number (150-185),
"build": "slender|athletic|curvy|muscular|average|plus_curvy|petite",
"body_fat_percent": number (15-35),
"muscle_definition": "none|subtle|moderate|defined|ripped",
"shoulder_width": "narrow|average|broad",
"hip_width": "narrow|average|wide",
"wh_ratio": number (0.65-0.90),
"leg_length": "short|proportional|long",
"torso_length": "short|proportional|long",
"bust_size": "small|medium|large|very_large",
"posture": "upright|relaxed|confident|athletic",
"pitch": "very_low|low|medium|high|very_high",
"pitch_range": "narrow|moderate|wide",
"tone": "warm|cool|neutral|rich|bright",
"timbre": "clear|smooth|husky|breathy|rich|crisp",
"accent": "north_american|british|australian|global_english|non_native",
"accent_strength": "subtle|moderate|strong",
"cadence": "very_slow|slow|medium|fast|very_fast",
"rhythm_pattern": "steady|variable|dynamic",
"volume": "soft|moderate|loud",
"clarity": "crisp|natural|relaxed",
"expressiveness": "monotone|moderate|expressive|animated"
}`,
identity.Name.First, identity.Age, identity.Gender, identity.Ethnicity, identity.Nationality,
fashionCtx.Name, fashionCtx.Description)
resp, err := tg.GenerateText(ctx, textgen.TextRequest{
Prompt: prompt,
MaxTokens: 900,
Temperature: 0.6,
Timeout: 45 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("LLM call: %w", err)
}
var r dnaLLMResponse
if err := parseLLMJSON(resp.Text, &r); err != nil {
return nil, fmt.Errorf("parsing DNA response: %w", err)
}
return &persona.DNA{
Identity: persona.IdentityDNA{
Ethnicity: identity.Ethnicity,
Age: identity.Age,
Gender: identity.Gender,
Nationality: identity.Nationality,
PrimaryHeritage: identity.Ethnicity,
},
Face: persona.FaceDNA{
FaceShape: persona.FaceShapeCategory(r.FaceShape),
BoneStructure: persona.BoneStructureCategory(r.BoneStructure),
Jawline: persona.JawlineCategory(r.Jawline),
Cheekbones: persona.CheekbonesCategory(r.Cheekbones),
EyeShape: persona.EyeShapeCategory(r.EyeShape),
EyeColor: persona.EyeColorCategory(r.EyeColor),
EyeSpacing: persona.EyeSpacingCategory(r.EyeSpacing),
EyeSize: persona.EyeSizeCategory(r.EyeSize),
NoseShape: persona.NoseShapeCategory(r.NoseShape),
NoseBridge: persona.NoseBridgeCategory(r.NoseBridge),
NoseTip: persona.NoseTipCategory(r.NoseTip),
LipShape: persona.LipShapeCategory(r.LipShape),
LipFullness: persona.LipFullnessCategory(r.LipFullness),
SmileType: persona.SmileTypeCategory(r.SmileType),
BrowShape: persona.BrowShapeCategory(r.BrowShape),
BrowThickness: persona.BrowThicknessCategory(r.BrowThickness),
SkinTone: persona.SkinToneCategory(r.SkinTone),
SkinUndertone: persona.SkinUndertoneCategory(r.SkinUndertone),
SkinTexture: persona.SkinTextureCategory(r.SkinTexture),
HairColor: persona.HairColorCategory(r.HairColor),
HairTexture: persona.HairTextureCategory(r.HairTexture),
HairLength: persona.HairLengthCategory(r.HairLength),
HairThickness: persona.HairThicknessCategory(r.HairThickness),
},
Body: persona.BodyDNA{
Height: persona.HeightCategoryFromCM(r.HeightCM),
HeightCM: r.HeightCM,
Build: persona.BodyBuildCategory(r.Build),
BodyFatPercent: r.BodyFatPercent,
MuscleDefinition: persona.MuscleDefinitionCategory(r.MuscleDefinition),
ShoulderWidth: persona.ShoulderWidthCategory(r.ShoulderWidth),
HipWidth: persona.HipWidthCategory(r.HipWidth),
WHRatio: r.WHRatio,
LegLength: persona.LegLengthCategory(r.LegLength),
TorsoLength: persona.TorsoLengthCategory(r.TorsoLength),
BustSize: persona.BustSizeCategory(r.BustSize),
PostureType: persona.PostureCategory(r.Posture),
},
Voice: persona.VoiceDNA{
Pitch: persona.PitchCategory(r.Pitch),
PitchRange: persona.PitchRangeCategory(r.PitchRange),
Tone: persona.ToneCategory(r.Tone),
Timbre: persona.TimbreCategory(r.Timbre),
Accent: persona.AccentCategory(r.Accent),
AccentStrength: persona.AccentStrengthCategory(r.AccentStrength),
Cadence: persona.CadenceCategory(r.Cadence),
RhythmPattern: persona.RhythmPatternCategory(r.RhythmPattern),
Volume: persona.VolumeCategory(r.Volume),
Clarity: persona.ClarityCategory(r.Clarity),
Expressiveness: persona.ExpressivenessCategory(r.Expressiveness),
},
}, nil
}
// populateImageMatrix assigns outfit and fashion context details to each image spec
// based on the persona's lifestyle. This is Stage 5 of the pipeline.
func populateImageMatrix(matrix []persona.ImageSpec, lifestyle *persona.Lifestyle) []persona.ImageSpec {
if lifestyle == nil {
return matrix
}
primaryCtx := persona.FashionContextFor(lifestyle.FashionSense.Primary)
secondaryCtx := persona.FashionContextFor(lifestyle.FashionSense.Secondary)
for i := range matrix {
spec := &matrix[i]
switch spec.ClothingState {
case "fashion outfit", "complete fashion outfit", "full fashion outfit":
spec.FashionContext = string(primaryCtx.Name)
keyCount := min(3, len(primaryCtx.KeyPieces))
if keyCount > 0 {
spec.Outfit = strings.Join(primaryCtx.KeyPieces[:keyCount], ", ")
}
spec.Outfit += " — " + primaryCtx.Silhouette
case "casual", "casual homewear", "casual streetwear":
if secondaryCtx.Name != "" {
spec.FashionContext = string(secondaryCtx.Name)
if len(secondaryCtx.KeyPieces) > 0 {
spec.Outfit = secondaryCtx.KeyPieces[0] + " casual look"
}
} else {
spec.FashionContext = string(primaryCtx.Name)
spec.Outfit = "casual everyday look"
}
case "athletic wear":
spec.FashionContext = string(persona.FashionAthleisurePro)
spec.Outfit = "fitted athletic set, performance fabric"
case "cozy layered", "comfortable/cozy", "minimal/cozy":
spec.FashionContext = string(persona.FashionLuxeLoungewear)
spec.Outfit = "soft layered loungewear, neutral tones"
case "off-shoulder":
spec.FashionContext = string(primaryCtx.Name)
spec.Outfit = "off-shoulder " + firstKeyPiece(primaryCtx)
default:
spec.FashionContext = string(primaryCtx.Name)
spec.Outfit = firstKeyPiece(primaryCtx)
}
// Position 1 (anchor) gets the persona's signature styling detail.
if spec.Position == 1 && len(lifestyle.FashionSense.SignatureDetails) > 0 {
spec.Outfit += ", " + lifestyle.FashionSense.SignatureDetails[0]
}
}
return matrix
}
// firstKeyPiece returns the first key piece from a fashion context, or empty string.
func firstKeyPiece(fc persona.FashionContext) string {
if len(fc.KeyPieces) > 0 {
return fc.KeyPieces[0]
}
return ""
}
// parseLLMJSON extracts and unmarshals JSON from an LLM text response.
// Handles both raw JSON and JSON wrapped in markdown code blocks.
func parseLLMJSON(text string, dst any) error {
text = strings.TrimSpace(text)
// Strip markdown code fences if present.
if strings.HasPrefix(text, "```") {
lines := strings.Split(text, "\n")
if len(lines) >= 2 {
inner := lines[1:]
if len(inner) > 0 && strings.TrimSpace(inner[len(inner)-1]) == "```" {
inner = inner[:len(inner)-1]
}
text = strings.Join(inner, "\n")
}
}
// Extract outermost JSON object from potentially noisy LLM output.
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start >= 0 && end > start {
text = text[start : end+1]
}
if err := json.Unmarshal([]byte(text), dst); err != nil {
preview := text
if len(preview) > 120 {
preview = preview[:120] + "..."
}
return fmt.Errorf("JSON unmarshal: %w (preview: %s)", err, preview)
}
return nil
}

View File

@ -0,0 +1,253 @@
package personagen
import (
"context"
"fmt"
"log/slog"
"strings"
"{{GO_MODULE}}/pkg/mediagen"
"{{GO_MODULE}}/pkg/persona"
)
// generateVideo builds a Veo prompt for the given motion type and calls the mediagen provider.
// Requires anchor bytes (position 1 image) as the reference frame for identity consistency.
func generateVideo(
ctx context.Context,
mg *mediagen.Manager,
spec *persona.PersonaSpec,
motionType persona.MotionType,
anchor []byte,
logger *slog.Logger,
) (*persona.VideoSpec, error) {
if mg == nil {
return nil, fmt.Errorf("mediagen not configured")
}
// Find the matching VideoSpec in the spec's Videos slice.
var videoSpec *persona.VideoSpec
for i := range spec.Videos {
if spec.Videos[i].MotionType == motionType {
videoSpec = &spec.Videos[i]
break
}
}
if videoSpec == nil {
// Motion type not found in matrix; create an ephemeral spec.
vs := persona.DefaultVideoMatrix()
for i := range vs {
if vs[i].MotionType == motionType {
videoSpec = &vs[i]
break
}
}
}
if videoSpec == nil {
return nil, fmt.Errorf("unsupported motion type: %s", motionType)
}
prompt := buildVeoPrompt(spec, motionType)
videoSpec.Prompt = prompt
logger.Info("generating video", "motion_type", motionType, "duration", videoSpec.Duration)
resp, err := mg.GenerateVideo(ctx, mediagen.VideoRequest{
Prompt: prompt,
AspectRatio: videoSpec.AspectRatio,
Duration: videoSpec.Duration,
ReferenceImages: []mediagen.Image{{
Data: anchor,
MimeType: "image/png",
}},
})
if err != nil {
videoSpec.Status = persona.VideoStatusFailed
return nil, fmt.Errorf("video provider error: %w", err)
}
if len(resp.Videos) == 0 {
videoSpec.Status = persona.VideoStatusFailed
return nil, fmt.Errorf("no videos returned from provider for motion type %s", motionType)
}
// URL will be set by the caller after uploading to storage.
videoSpec.Status = persona.VideoStatusComplete
return videoSpec, nil
}
// buildVeoPrompt constructs a Veo video generation prompt for the given motion type.
// Each motion type produces a distinct narrative and action sequence.
func buildVeoPrompt(spec *persona.PersonaSpec, motionType persona.MotionType) string {
identity := buildIdentityLine(spec)
audio := buildAudioDescriptor(spec)
switch motionType {
case persona.MotionSmileReveal:
return buildSmileRevealPrompt(identity, audio)
case persona.MotionPersonality:
return buildPersonalityPrompt(spec, identity, audio)
case persona.MotionLifestyle:
return buildLifestylePrompt(spec, identity, audio)
case persona.MotionInvitation:
return buildInvitationPrompt(spec, identity, audio)
default:
return fmt.Sprintf("%s Natural, candid moment, warm natural lighting. %s", identity, audio)
}
}
// buildSmileRevealPrompt creates a warm, genuine smile reveal video prompt.
func buildSmileRevealPrompt(identity, audio string) string {
return fmt.Sprintf(
"%s She looks slightly away, then turns directly to camera with a warm, genuine smile — "+
"eyes lighting up, expression full of warmth and personality. "+
"Soft natural lighting, close-up framing, shallow depth of field. "+
"Slow motion for the smile reveal moment. %s",
identity, audio,
)
}
// buildPersonalityPrompt creates an expressive personality showcase video prompt.
func buildPersonalityPrompt(spec *persona.PersonaSpec, identity, audio string) string {
extraversion := "moderate"
if spec.DNA != nil {
// We don't have HEXACO in DNA; use voice expressiveness as a proxy.
switch spec.DNA.Voice.Expressiveness {
case persona.ExpressivenessAnimated:
extraversion = "highly expressive and animated"
case persona.ExpressivenessExpressive:
extraversion = "expressive and engaging"
default:
extraversion = "warm and natural"
}
}
return fmt.Sprintf(
"%s A candid personality moment — she is %s, laughing or reacting naturally, "+
"full of charisma. Dynamic handheld camera movement. "+
"Golden hour or warm studio lighting. "+
"Cut between close-up and mid-shot for rhythm. %s",
identity, extraversion, audio,
)
}
// buildLifestylePrompt creates a contextual lifestyle video prompt.
func buildLifestylePrompt(spec *persona.PersonaSpec, identity, audio string) string {
scene := "stylish urban environment"
activity := "going about her day"
if spec.Lifestyle.VacationStyle.Primary != "" {
switch spec.Lifestyle.VacationStyle.Primary {
case "beach", "coastal":
scene = "sunny beachside setting"
activity = "walking along the shoreline"
case "city":
scene = "vibrant city street"
activity = "exploring the city"
case "adventure":
scene = "scenic outdoor landscape"
activity = "enjoying the outdoors"
case "luxury":
scene = "luxurious upscale setting"
activity = "enjoying a refined moment"
case "cultural":
scene = "culturally rich environment"
activity = "immersed in her surroundings"
}
}
if len(spec.Lifestyle.Interests.Active) > 0 {
activity = spec.Lifestyle.Interests.Active[0]
}
return fmt.Sprintf(
"%s A natural lifestyle moment — she is %s in a %s. "+
"Wide establishing shot transitioning to mid-shot. "+
"Cinematic 16:9 composition, natural movement, vibrant color grading. %s",
identity, activity, scene, audio,
)
}
// buildInvitationPrompt creates a direct-address invitation video prompt.
func buildInvitationPrompt(spec *persona.PersonaSpec, identity, audio string) string {
name := spec.Name.First
return fmt.Sprintf(
"%s She looks directly into the camera with a warm, confident expression. "+
"%s gestures naturally as if personally inviting the viewer, "+
"making direct eye contact, with a knowing smile. "+
"Close-up to mid-shot. Clean, aspirational background. "+
"Cinematic vertical 9:16 framing. %s",
identity, name, audio,
)
}
// buildIdentityLine creates a one-line identity description for video prompts.
func buildIdentityLine(spec *persona.PersonaSpec) string {
if spec.DNA == nil {
return spec.Name.First
}
id := spec.DNA.Identity
body := spec.DNA.Body
return fmt.Sprintf(
"%s, a %d-year-old %s %s with %s %s hair,",
spec.Name.First,
id.Age,
ethnicitToAdj(id.Ethnicity),
strings.ToLower(string(id.Gender)),
string(spec.DNA.Face.HairColor),
string(spec.DNA.Face.HairTexture),
) + fmt.Sprintf(" %s build, %s skin.", string(body.Build), string(spec.DNA.Face.SkinTone))
}
// buildAudioDescriptor maps VoiceDNA fields to Veo audio generation descriptors.
func buildAudioDescriptor(spec *persona.PersonaSpec) string {
if spec.DNA == nil {
return "Natural ambient audio."
}
voice := spec.DNA.Voice
pitchDesc := voicePitchDesc(voice.Pitch)
timbreDesc := string(voice.Timbre)
cadenceDesc := voiceCadenceDesc(voice.Cadence)
expressDesc := voiceExpressivenessDesc(voice.Expressiveness)
return fmt.Sprintf(
"Audio: %s %s voice, %s delivery, %s.",
pitchDesc, timbreDesc, cadenceDesc, expressDesc,
)
}
func voicePitchDesc(p persona.PitchCategory) string {
switch p {
case persona.PitchVeryHigh, persona.PitchHigh:
return "higher-pitched"
case persona.PitchLow, persona.PitchVeryLow:
return "lower-pitched"
default:
return "medium-pitched"
}
}
func voiceCadenceDesc(c persona.CadenceCategory) string {
switch c {
case persona.CadenceFast, persona.CadenceVeryFast:
return "upbeat and quick"
case persona.CadenceSlow, persona.CadenceVerySlow:
return "measured and deliberate"
default:
return "natural and conversational"
}
}
func voiceExpressivenessDesc(e persona.ExpressivenessCategory) string {
switch e {
case persona.ExpressivenessAnimated:
return "highly animated with emotional range"
case persona.ExpressivenessExpressive:
return "warm and expressive"
case persona.ExpressivenessMonotone:
return "calm and even-toned"
default:
return "naturally expressive"
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
scalargo "github.com/bdpiprava/scalar-go"
@ -218,9 +219,35 @@ func (a *App) EnableDocs(spec *OpenAPISpec) {
a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json")
}
// opSummaryToID converts a human-readable summary to a camelCase operationId.
func opSummaryToID(summary string) string {
words := strings.Fields(summary)
var sb strings.Builder
first := true
for _, word := range words {
clean := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, word)
if clean == "" {
continue
}
if first {
sb.WriteString(strings.ToLower(clean[:1]) + clean[1:])
first = false
} else {
sb.WriteString(strings.ToUpper(clean[:1]) + clean[1:])
}
}
return sb.String()
}
// Op creates an OpenAPI operation helper.
func Op(summary, description string, tags ...string) map[string]any {
return map[string]any{
"operationId": opSummaryToID(summary),
"summary": summary,
"description": description,
"tags": tags,
@ -233,6 +260,7 @@ func Op(summary, description string, tags ...string) map[string]any {
// OpWithBody creates an OpenAPI operation with a request body.
func OpWithBody(summary, description string, tags ...string) map[string]any {
return map[string]any{
"operationId": opSummaryToID(summary),
"summary": summary,
"description": description,
"tags": tags,

49
scripts/generate-sdk.sh Executable file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# generate-sdk.sh — Generate the @orchard9/rdev-sdk TypeScript SDK from the embedded OpenAPI spec.
# No server, DB, or K8s needed. The spec is exported directly from the Go binary.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$SCRIPT_DIR/.."
SDK_DIR="$ROOT/sdk"
# 1. Check speakeasy is installed
if ! command -v speakeasy &>/dev/null; then
echo "speakeasy CLI not found. Installing..."
if command -v brew &>/dev/null; then
brew install speakeasy-api/speakeasy/speakeasy
else
# Install to ~/.local/bin (no sudo needed)
mkdir -p "$HOME/.local/bin"
curl -fsSL https://raw.githubusercontent.com/speakeasy-api/speakeasy/main/install.sh \
| INSTALL_DIR="$HOME/.local/bin" sh
export PATH="$HOME/.local/bin:$PATH"
fi
fi
# 2. Export the OpenAPI spec (no server needed — pure Go, no external deps)
echo "Exporting OpenAPI spec..."
go run "$ROOT/cmd/rdev-api" --export-openapi > "$SDK_DIR/openapi.json"
echo " -> $SDK_DIR/openapi.json"
# 3. Validate the spec
echo "Validating spec..."
speakeasy validate openapi -s "$SDK_DIR/openapi.json"
# 4. Generate the TypeScript SDK
echo "Generating TypeScript SDK..."
speakeasy generate sdk \
--schema "$SDK_DIR/openapi.json" \
--lang typescript \
--out "$SDK_DIR/typescript" \
--config "$SDK_DIR/.speakeasy/gen.yaml"
# 5. Install dependencies and build
echo "Building SDK..."
cd "$SDK_DIR/typescript"
npm install
npm run build
echo ""
echo "Done! SDK generated at sdk/typescript/"
echo "Test with: cd sdk/typescript && npm pack --dry-run"

12
sdk/.speakeasy/gen.yaml Normal file
View File

@ -0,0 +1,12 @@
configVersion: 2.0.0
generation:
sdkClassName: Rdev
maintainOpenAPIOrder: true
usageSnippets:
optionalPropertyRendering: withExample
typescript:
version: 0.1.0
author: orchard9
description: TypeScript SDK for the rdev Remote Developer API (threesix.ai)
packageName: "@orchard9/rdev-sdk"
clientServerStatusCodesAsErrors: true

82
sdk/README.md Normal file
View File

@ -0,0 +1,82 @@
# @orchard9/rdev-sdk
TypeScript SDK for the [rdev Remote Developer API](https://rdev.masq-ops.orchard9.ai/docs) — run Claude Code instances in isolated Kubernetes pods via REST.
## Prerequisites
- Go 1.25+ (to export the spec)
- [Speakeasy CLI](https://www.speakeasy.com/docs/speakeasy-cli/getting-started)
- Node.js 20+
## Regenerating the SDK
The SDK is generated from the live OpenAPI spec embedded in the rdev binary. No server required.
```bash
./scripts/generate-sdk.sh
```
This will:
1. Export the OpenAPI spec from the Go binary (pure, no DB/K8s needed)
2. Validate the spec with Speakeasy
3. Generate the TypeScript SDK into `sdk/typescript/`
4. Build and type-check the SDK
The generated `sdk/openapi.json` is gitignored (regenerated each time). The `sdk/typescript/` output is committed.
## Installation
Until published to npm, install directly from git:
```bash
npm install github:orchard9/rdev#main --workspace sdk/typescript
```
Or copy `sdk/typescript/` into your project.
## Usage
```typescript
import { Rdev } from "@orchard9/rdev-sdk";
const client = new Rdev({
apiKey: process.env.RDEV_API_KEY,
serverURL: "https://rdev.masq-ops.orchard9.ai",
});
// List projects
const projects = await client.projects.list();
console.log(projects);
// Run a Claude command
const cmd = await client.projects.runClaude("my-project", {
prompt: "fix the bug in auth handler",
});
console.log(cmd.streamUrl);
// Stream events
const events = new EventSource(
`https://rdev.masq-ops.orchard9.ai${cmd.streamUrl}`,
{ headers: { "X-API-Key": process.env.RDEV_API_KEY } }
);
events.addEventListener("complete", (e) => {
console.log("Done:", JSON.parse(e.data));
events.close();
});
```
## Authentication
All endpoints (except `/health`, `/ready`, `/docs`) require an API key.
```typescript
const client = new Rdev({
apiKey: "rdev_sk_xxxxxxxx_...",
});
```
Or set `RDEV_API_KEY` environment variable.
## API Reference
See the [API docs](https://rdev.masq-ops.orchard9.ai/docs) for full endpoint documentation.