diff --git a/.gitignore b/.gitignore index 96e4c1f..ef5fb55 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 1a6bef3..e59c595 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -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) diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go index e2d18d3..60e540d 100644 --- a/cmd/rdev-api/openapi.go +++ b/cmd/rdev-api/openapi.go @@ -198,7 +198,8 @@ func registerProjectPaths(spec *api.OpenAPISpec) { )) spec.AddPath("/projects/cleanup", "delete", map[string]any{ - "summary": "Cleanup test projects", + "operationId": "cleanupProjects", + "summary": "Cleanup test projects", "description": `Deletes test projects matching the given name patterns that are older than a specified age. This is useful for cleaning up orphaned test projects created by cookbook scripts. @@ -339,7 +340,8 @@ func registerCommandPaths(spec *api.OpenAPISpec) { func registerEventPaths(spec *api.OpenAPISpec) { spec.AddPath("/projects/{id}/events", "get", map[string]any{ - "summary": "Stream events", + "operationId": "streamProjectEvents", + "summary": "Stream events", "description": `Server-Sent Events stream for real-time command output. Requires projects:read scope. @@ -487,7 +489,8 @@ func registerConfigTypePaths(spec *api.OpenAPISpec, typePlural, typeSingular, ty func registerAuditPaths(spec *api.OpenAPISpec) { spec.AddPath("/audit-log", "get", map[string]any{ - "summary": "List audit log entries", + "operationId": "listAuditLogEntries", + "summary": "List audit log entries", "description": `Returns audit log entries with optional filtering. **Required scope**: ` + "`audit:read`" + ` diff --git a/cmd/rdev-api/openapi_ext.go b/cmd/rdev-api/openapi_ext.go index 77ed54a..33cff9a 100644 --- a/cmd/rdev-api/openapi_ext.go +++ b/cmd/rdev-api/openapi_ext.go @@ -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,7 +810,8 @@ 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{ - "summary": "List credentials", + "operationId": "listCredentials", + "summary": "List credentials", "description": `Returns all infrastructure credentials with values masked. Optionally filter by category using ?category= query parameter. @@ -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,7 +984,8 @@ Includes screenshot URLs, video URL (if recorded), and duration.`, )) spec.AddPath("/verify/{taskId}/stream", "get", map[string]any{ - "summary": "Stream verification events", + "operationId": "streamVerifyEvents", + "summary": "Stream verification events", "description": `Streams real-time verification task events via Server-Sent Events (SSE). Events include task started, screenshot captured, video recorded, task completed/failed. @@ -1001,7 +1040,8 @@ Stops Playwright browser and marks task as cancelled.`, )) spec.AddPath("/projects/{id}/verify", "get", map[string]any{ - "summary": "List project verifications", + "operationId": "listProjectVerifications", + "summary": "List project verifications", "description": `Returns verification tasks for a project with pagination. **Required scope**: ` + "`verify:read`", @@ -1139,7 +1179,8 @@ Useful for applying configuration changes or recovering from errors.`, )) spec.AddPath("/projects/{id}/deploy/logs", "get", map[string]any{ - "summary": "Get deployment logs", + "operationId": "getDeploymentLogs", + "summary": "Get deployment logs", "description": `Returns recent logs from a project's deployment pods. **Required scope**: ` + "`projects:read`", @@ -1190,7 +1231,8 @@ 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{ - "summary": "Remove domain", + "operationId": "removeProjectDomain", + "summary": "Remove domain", "description": `Removes a custom domain from a project. Deletes DNS A record if domain was a managed subdomain. @@ -1306,7 +1348,8 @@ Part of the enterprise resilience architecture for handling transient failures.` func registerWebhookPaths(spec *api.OpenAPISpec) { spec.AddPath("/webhooks/woodpecker", "post", map[string]any{ - "summary": "Woodpecker CI webhook", + "operationId": "receiveWoodpeckerWebhook", + "summary": "Woodpecker CI webhook", "description": `Receives build event webhooks from Woodpecker CI. **Authentication**: Uses HMAC-SHA256 signature verification (X-Woodpecker-Signature header), not API key auth. @@ -1406,7 +1449,8 @@ Sagas are distributed workflows with automatic compensation on failure. Useful f )) spec.AddPath("/sagas", "get", map[string]any{ - "summary": "List sagas", + "operationId": "listSagas", + "summary": "List sagas", "description": `Returns sagas with optional filtering. **Query parameters**: ?name=&status= diff --git a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl index a6d7b8a..8195c85 100644 --- a/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl +++ b/internal/adapter/templates/templates/components/service/cmd/server/main.go.tmpl @@ -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 } diff --git a/internal/adapter/templates/templates/components/service/internal/adapter/memory/album.go.tmpl b/internal/adapter/templates/templates/components/service/internal/adapter/memory/album.go.tmpl new file mode 100644 index 0000000..66af19e --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/adapter/memory/album.go.tmpl @@ -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] = © + 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 ©, 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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/album.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/album.go.tmpl new file mode 100644 index 0000000..4f8be6e --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/album.go.tmpl @@ -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: 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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/handlers/persona.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/handlers/persona.go.tmpl new file mode 100644 index 0000000..404d136 --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/api/handlers/persona.go.tmpl @@ -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:` 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:` 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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index b179276..2a13644 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -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 diff --git a/internal/adapter/templates/templates/components/service/internal/port/album.go.tmpl b/internal/adapter/templates/templates/components/service/internal/port/album.go.tmpl new file mode 100644 index 0000000..b8652af --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/port/album.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/components/service/internal/service/album.go.tmpl b/internal/adapter/templates/templates/components/service/internal/service/album.go.tmpl new file mode 100644 index 0000000..04f633a --- /dev/null +++ b/internal/adapter/templates/templates/components/service/internal/service/album.go.tmpl @@ -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: 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 +} + diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/album.md b/internal/adapter/templates/templates/skeleton/.claude/guides/album.md new file mode 100644 index 0000000..5191b63 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/guides/album.md @@ -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:` 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 Component + +```tsx +import { AlbumGrid } from '@example-project/ui'; + + regenerateShot(index)} + onResetShot={(index) => resetShot(index)} + onImageClick={(indexOrAnchor) => openLightbox(indexOrAnchor)} +/> +``` + +### Individual Components + +```tsx +import { AnchorPreview, ShotCard } from '@example-project/ui'; + +// Anchor card + + +// Individual shot card + 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 " \ + -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//anchor \ + -H "Authorization: Bearer " + +# Watch SSE for album_anchor_complete +curl -N "http://localhost:8001/api/example-api/events?channel=user:" \ + -H "Authorization: Bearer " +``` diff --git a/internal/adapter/templates/templates/skeleton/.claude/guides/personagen.md b/internal/adapter/templates/templates/skeleton/.claude/guides/personagen.md new file mode 100644 index 0000000..7433807 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/guides/personagen.md @@ -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: +``` + +## 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 | 1–5 | Core look, position 1 is the anchor | +| 2 – Expressions | 6–11 | Personality through facial expressions | +| 3 – Angles | 12–16 | Camera angle variety | +| 4 – Context | 17–20 | 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:` 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 | diff --git a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl index 22a843b..ca652dd 100644 --- a/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl +++ b/internal/adapter/templates/templates/skeleton/CLAUDE.md.tmpl @@ -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 diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts index f539f60..780f0fd 100644 --- a/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts +++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/index.ts @@ -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'; diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts index 3b68f16..1d3dde4 100644 --- a/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts +++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/types.ts @@ -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. diff --git a/internal/adapter/templates/templates/skeleton/packages/realtime/src/useAlbumGeneration.ts b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useAlbumGeneration.ts new file mode 100644 index 0000000..54857d3 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/realtime/src/useAlbumGeneration.ts @@ -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; + /** Enqueue anchor generation for the album */ + generateAnchor: () => Promise; + /** Enqueue all pending shots (requires anchor to exist) */ + generateAllShots: () => Promise; + /** Enqueue a single shot by index (for regeneration) */ + regenerateShot: (index: number) => Promise; + /** Reset a shot to pending so it can be regenerated */ + resetShot: (index: number) => Promise; +} + +/** + * 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 ( + * + * ); + * ``` + */ +export function useAlbumGeneration( + config: UseAlbumGenerationConfig +): UseAlbumGenerationResult { + const { apiBase, userId, albumId, sseEndpoint, authToken } = config; + + const [album, setAlbum] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Keep album in a ref for event handler closure (avoids stale state). + const albumRef = useRef(null); + albumRef.current = album; + + const effectiveSseEndpoint = sseEndpoint ?? `${apiBase}/events`; + + // Build auth headers. + const headers = useCallback((): Record => { + const h: Record = { '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 | 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, + }; +} diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/AlbumGrid.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/AlbumGrid.tsx new file mode 100644 index 0000000..d6eb569 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/AlbumGrid.tsx @@ -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 { + /** 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 + * + * ``` + */ +const AlbumGrid = React.forwardRef( + ( + { + 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 ( +
+ {/* Header row with name + action buttons */} + {(name || onGenerateAllShots) && ( +
+ {name && ( +

{name}

+ )} +
+ {onGenerateAllShots && anchorReady && pendingShots.length > 0 && ( + + )} + {allComplete && ( + + ✓ All complete + + )} +
+
+ )} + + {/* Grid */} +
+ {/* Anchor slot — always first */} + onImageClick('anchor') : undefined} + /> + + {/* Shot slots */} + {shots.map((shot) => ( + onRegenerateShot(shot.index) : undefined} + onRegenerate={onRegenerateShot ? () => onRegenerateShot(shot.index) : undefined} + onReset={onResetShot ? () => onResetShot(shot.index) : undefined} + onImageClick={onImageClick ? () => onImageClick(shot.index) : undefined} + /> + ))} +
+
+ ); + } +); +AlbumGrid.displayName = 'AlbumGrid'; + +export { AlbumGrid }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/AnchorPreview.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/AnchorPreview.tsx new file mode 100644 index 0000000..d193a84 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/AnchorPreview.tsx @@ -0,0 +1,164 @@ +import * as React from 'react'; +import { cn } from '../utils/cn'; + +export interface AnchorPreviewProps extends React.HTMLAttributes { + /** 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 + * + * ``` + */ +const AnchorPreview = React.forwardRef( + ( + { + anchorUrl, + isGenerating = false, + label = 'Reference', + onGenerate, + onRegenerate, + onImageClick, + className, + ...props + }, + ref + ) => { + return ( +
+ {/* GENERATING state — shimmer */} + {isGenerating && !anchorUrl && ( + <> +
+ +
+ + Generating anchor... + +
+ + )} + + {/* NO ANCHOR state — CTA */} + {!anchorUrl && !isGenerating && ( +
+ {/* Camera/anchor icon */} +
+ + + + +
+
+

{label}

+

+ Generate first +

+
+ {onGenerate && ( + + )} +
+ )} + + {/* HAS ANCHOR state */} + {anchorUrl && ( + <> + {label} + {/* Accent border indicator */} +
+ {/* Hover overlay */} +
+ + {label} + +
+ {onRegenerate && ( + + )} +
+
+ + )} +
+ ); + } +); +AnchorPreview.displayName = 'AnchorPreview'; + +export { AnchorPreview }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ShotCard.tsx b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ShotCard.tsx new file mode 100644 index 0000000..7f1109b --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/components/ShotCard.tsx @@ -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 { + /** 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 + * regenerateShot(shot.index)} + * /> + * ``` + */ +const ShotCard = React.forwardRef( + ( + { + label, + status, + imageUrl, + error, + anchorReady = false, + onGenerate, + onRegenerate, + onReset, + onImageClick, + className, + ...props + }, + ref + ) => { + return ( +
+ {/* PENDING state */} + {status === 'pending' && ( +
+ + {label} + + {anchorReady && onGenerate && ( + + )} + {!anchorReady && ( + + Generate anchor first + + )} +
+ )} + + {/* GENERATING state — shimmer */} + {status === 'generating' && ( +
+
+ + + {label} + +
+ )} + + {/* COMPLETE state */} + {status === 'complete' && imageUrl && ( + <> + {label} + {/* Hover overlay with label + controls */} +
+ {label} +
+ {onRegenerate && ( + + )} + {onReset && ( + + )} +
+
+ + )} + + {/* FAILED state */} + {status === 'failed' && ( +
+ + {label} + + {error && ( + + {error} + + )} + {onRegenerate && ( + + )} +
+ )} +
+ ); + } +); +ShotCard.displayName = 'ShotCard'; + +export { ShotCard }; diff --git a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts index 9cb9900..54ef4ce 100644 --- a/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts +++ b/internal/adapter/templates/templates/skeleton/packages/ui/src/index.ts @@ -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, diff --git a/internal/adapter/templates/templates/skeleton/pkg/album/handler.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/album/handler.go.tmpl new file mode 100644 index 0000000..f875d78 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/album/handler.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/album/templates.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/album/templates.go.tmpl new file mode 100644 index 0000000..6a46f16 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/album/templates.go.tmpl @@ -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, +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/album/types.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/album/types.go.tmpl new file mode 100644 index 0000000..1ca259e --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/album/types.go.tmpl @@ -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: 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"` +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/image_matrix.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/image_matrix.go.tmpl new file mode 100644 index 0000000..794de17 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/persona/image_matrix.go.tmpl @@ -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 (1–20). + Position int `json:"position" yaml:"position"` + + // Tier indicates which content tier (1–4) 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 1–5) ────────────────────────────────── + // 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 6–11) ────────────────────────────── + // 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 12–16) ────────────────────────────────── + // 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 17–20) ───────────────────────────────── + // 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, + }, + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/lifestyle.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/lifestyle.go.tmpl new file mode 100644 index 0000000..b4af768 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/persona/lifestyle.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/spec.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/spec.go.tmpl new file mode 100644 index 0000000..6fc2b83 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/persona/spec.go.tmpl @@ -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"` +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/persona/video_spec.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/persona/video_spec.go.tmpl new file mode 100644 index 0000000..302d0c4 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/persona/video_spec.go.tmpl @@ -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, + }, + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/personagen/imagegen.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/personagen/imagegen.go.tmpl new file mode 100644 index 0000000..d80bba7 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/personagen/imagegen.go.tmpl @@ -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) + } +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/personagen/service.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/personagen/service.go.tmpl new file mode 100644 index 0000000..e76d541 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/personagen/service.go.tmpl @@ -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() } diff --git a/internal/adapter/templates/templates/skeleton/pkg/personagen/specgen.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/personagen/specgen.go.tmpl new file mode 100644 index 0000000..acf66a1 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/personagen/specgen.go.tmpl @@ -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 +} diff --git a/internal/adapter/templates/templates/skeleton/pkg/personagen/videogen.go.tmpl b/internal/adapter/templates/templates/skeleton/pkg/personagen/videogen.go.tmpl new file mode 100644 index 0000000..16e17fd --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/pkg/personagen/videogen.go.tmpl @@ -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" + } +} diff --git a/pkg/api/openapi.go b/pkg/api/openapi.go index a341eb2..a87d036 100644 --- a/pkg/api/openapi.go +++ b/pkg/api/openapi.go @@ -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, diff --git a/scripts/generate-sdk.sh b/scripts/generate-sdk.sh new file mode 100755 index 0000000..833ab1d --- /dev/null +++ b/scripts/generate-sdk.sh @@ -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" diff --git a/sdk/.speakeasy/gen.yaml b/sdk/.speakeasy/gen.yaml new file mode 100644 index 0000000..2c89025 --- /dev/null +++ b/sdk/.speakeasy/gen.yaml @@ -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 diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..2b38580 --- /dev/null +++ b/sdk/README.md @@ -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.