feat: add album generation system to skeleton
Adds anchor-based image album generation across docs, skeleton, and rendered
full-monorepo. One subject description + one anchor image + N directed shots,
covering personas, products, characters, and brand assets out of the box.
## What ships
**Skeleton packages:**
- pkg/album/types.go — Album, Shot, ShotStatus, ShotTemplate, AlbumUpdater
- pkg/album/templates.go — PortraitSession, ProductShoot, CharacterSheet built-ins
- pkg/album/handler.go — AnchorHandler + ShotHandler queue job handlers
- packages/realtime/src/useAlbumGeneration.ts — SSE hook owning all album state
- packages/ui/src/components/AlbumGrid.tsx — responsive shot grid with shimmer
- packages/ui/src/components/ShotCard.tsx — pending/generating/complete/failed states
- packages/ui/src/components/AnchorPreview.tsx — anchor CTA + image with controls
**Component service template:**
- internal/port/album.go — AlbumRepository interface
- internal/adapter/memory/album.go — in-memory repo for standalone dev
- internal/service/album.go — create, list, get, generateAnchor, generateAllShots
- internal/api/handlers/album.go — HTTP handlers (CRUD + 202 generation endpoints)
- Routes: GET/POST /albums, GET/DELETE /albums/{id}, POST /albums/{id}/anchor,
POST/DELETE /albums/{id}/shots, POST /albums/{id}/shots/{index}
**Documentation:**
- .claude/guides/album.md — full guide with API, SSE events, frontend usage
**Key architecture decisions:**
- Anchor bytes never stored in queue payload — workers fetch AnchorURL at runtime
- Generation order enforced: POST /shots returns 422 if no anchor exists
- All album SSE events on existing user:<userId> channel (no new channel)
- AlbumUpdater interface lets job handlers update repo from inside queue workers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4603402b84
commit
002c32aedb
3
.gitignore
vendored
3
.gitignore
vendored
@ -47,3 +47,6 @@ tmp/
|
|||||||
|
|
||||||
# Rendered example monorepo (regenerated from templates)
|
# Rendered example monorepo (regenerated from templates)
|
||||||
examples/full-monorepo/
|
examples/full-monorepo/
|
||||||
|
|
||||||
|
# SDK: spec is generated at build time, not committed
|
||||||
|
sdk/openapi.json
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -44,6 +46,21 @@ import (
|
|||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
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
|
// Initialize structured logging from environment configuration
|
||||||
logCfg := logging.ConfigFromEnv()
|
logCfg := logging.ConfigFromEnv()
|
||||||
appLogger := logging.New(logCfg)
|
appLogger := logging.New(logCfg)
|
||||||
|
|||||||
@ -198,7 +198,8 @@ func registerProjectPaths(spec *api.OpenAPISpec) {
|
|||||||
))
|
))
|
||||||
|
|
||||||
spec.AddPath("/projects/cleanup", "delete", map[string]any{
|
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.
|
"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.
|
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) {
|
func registerEventPaths(spec *api.OpenAPISpec) {
|
||||||
spec.AddPath("/projects/{id}/events", "get", map[string]any{
|
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.
|
"description": `Server-Sent Events stream for real-time command output.
|
||||||
|
|
||||||
Requires projects:read scope.
|
Requires projects:read scope.
|
||||||
@ -487,7 +489,8 @@ func registerConfigTypePaths(spec *api.OpenAPISpec, typePlural, typeSingular, ty
|
|||||||
|
|
||||||
func registerAuditPaths(spec *api.OpenAPISpec) {
|
func registerAuditPaths(spec *api.OpenAPISpec) {
|
||||||
spec.AddPath("/audit-log", "get", map[string]any{
|
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.
|
"description": `Returns audit log entries with optional filtering.
|
||||||
|
|
||||||
**Required scope**: ` + "`audit:read`" + `
|
**Required scope**: ` + "`audit:read`" + `
|
||||||
|
|||||||
@ -1,6 +1,36 @@
|
|||||||
package main
|
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) {
|
func registerAgentPaths(spec *api.OpenAPISpec) {
|
||||||
spec.AddPath("/agents", "get", withAuth(
|
spec.AddPath("/agents", "get", withAuth(
|
||||||
@ -104,6 +134,7 @@ type param struct {
|
|||||||
// withAuth creates an operation that requires authentication.
|
// withAuth creates an operation that requires authentication.
|
||||||
func withAuth(summary, description, tag, scope, example string) map[string]any {
|
func withAuth(summary, description, tag, scope, example string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
"operationId": summaryToOperationID(summary),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
"tags": []string{tag},
|
"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.
|
// withAuthAndBody creates an operation with auth and request body.
|
||||||
func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any {
|
func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
"operationId": summaryToOperationID(summary),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
"tags": []string{tag},
|
"tags": []string{tag},
|
||||||
@ -171,6 +203,7 @@ func withAuthAndParams(summary, description, tag, scope string, params []param)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
"operationId": summaryToOperationID(summary),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
"tags": []string{tag},
|
"tags": []string{tag},
|
||||||
@ -200,6 +233,7 @@ func withAuthBodyAndParams(summary, description, tag, scope string, params []par
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
"operationId": summaryToOperationID(summary),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
"tags": []string{tag},
|
"tags": []string{tag},
|
||||||
@ -282,6 +316,7 @@ func registerSDLCPaths(spec *api.OpenAPISpec) {
|
|||||||
))
|
))
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/sdlc/next", "get", map[string]any{
|
spec.AddPath("/projects/{id}/sdlc/next", "get", map[string]any{
|
||||||
|
"operationId": "getSdlcNextAction",
|
||||||
"summary": "Get next action",
|
"summary": "Get next action",
|
||||||
"description": "Returns the classifier's recommended next action.\n\n**Required scope**: `projects:read`",
|
"description": "Returns the classifier's recommended next action.\n\n**Required scope**: `projects:read`",
|
||||||
"tags": []string{"SDLC"},
|
"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
|
// Use wildcard for dynamic component path
|
||||||
spec.AddPath("/projects/{id}/components/{path}", "delete", map[string]any{
|
spec.AddPath("/projects/{id}/components/{path}", "delete", map[string]any{
|
||||||
|
"operationId": "removeComponent",
|
||||||
"summary": "Remove component",
|
"summary": "Remove component",
|
||||||
"description": "Removes a component from the project's monorepo.\n\n**Required scope**: `projects:execute`",
|
"description": "Removes a component from the project's monorepo.\n\n**Required scope**: `projects:execute`",
|
||||||
"tags": []string{"Components"},
|
"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) {
|
func registerCredentialPaths(spec *api.OpenAPISpec) {
|
||||||
spec.AddPath("/credentials", "get", map[string]any{
|
spec.AddPath("/credentials", "get", map[string]any{
|
||||||
"summary": "List credentials",
|
"operationId": "listCredentials",
|
||||||
|
"summary": "List credentials",
|
||||||
"description": `Returns all infrastructure credentials with values masked.
|
"description": `Returns all infrastructure credentials with values masked.
|
||||||
|
|
||||||
Optionally filter by category using ?category=<name> query parameter.
|
Optionally filter by category using ?category=<name> query parameter.
|
||||||
@ -867,6 +904,7 @@ Useful for bulk credential loading from configuration files.`,
|
|||||||
))
|
))
|
||||||
|
|
||||||
spec.AddPath("/credentials/{key}", "delete", map[string]any{
|
spec.AddPath("/credentials/{key}", "delete", map[string]any{
|
||||||
|
"operationId": "deleteCredential",
|
||||||
"summary": "Delete credential",
|
"summary": "Delete credential",
|
||||||
"description": "Removes a credential permanently.\n\n**Required scope**: `admin`",
|
"description": "Removes a credential permanently.\n\n**Required scope**: `admin`",
|
||||||
"tags": []string{"Credentials"},
|
"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{
|
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).
|
"description": `Streams real-time verification task events via Server-Sent Events (SSE).
|
||||||
|
|
||||||
Events include task started, screenshot captured, video recorded, task completed/failed.
|
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{
|
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.
|
"description": `Returns verification tasks for a project with pagination.
|
||||||
|
|
||||||
**Required scope**: ` + "`verify:read`",
|
**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{
|
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.
|
"description": `Returns recent logs from a project's deployment pods.
|
||||||
|
|
||||||
**Required scope**: ` + "`projects:read`",
|
**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{
|
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.
|
"description": `Removes a custom domain from a project.
|
||||||
|
|
||||||
Deletes DNS A record if domain was a managed subdomain.
|
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) {
|
func registerWebhookPaths(spec *api.OpenAPISpec) {
|
||||||
spec.AddPath("/webhooks/woodpecker", "post", map[string]any{
|
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.
|
"description": `Receives build event webhooks from Woodpecker CI.
|
||||||
|
|
||||||
**Authentication**: Uses HMAC-SHA256 signature verification (X-Woodpecker-Signature header), not API key auth.
|
**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{
|
spec.AddPath("/sagas", "get", map[string]any{
|
||||||
"summary": "List sagas",
|
"operationId": "listSagas",
|
||||||
|
"summary": "List sagas",
|
||||||
"description": `Returns sagas with optional filtering.
|
"description": `Returns sagas with optional filtering.
|
||||||
|
|
||||||
**Query parameters**: ?name=<name>&status=<status>
|
**Query parameters**: ?name=<name>&status=<status>
|
||||||
|
|||||||
@ -11,7 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"{{GO_MODULE}}/pkg/album"
|
||||||
"{{GO_MODULE}}/pkg/app"
|
"{{GO_MODULE}}/pkg/app"
|
||||||
|
"{{GO_MODULE}}/pkg/personagen"
|
||||||
"{{GO_MODULE}}/pkg/database"
|
"{{GO_MODULE}}/pkg/database"
|
||||||
"{{GO_MODULE}}/pkg/gemini"
|
"{{GO_MODULE}}/pkg/gemini"
|
||||||
"{{GO_MODULE}}/pkg/laozhang"
|
"{{GO_MODULE}}/pkg/laozhang"
|
||||||
@ -91,6 +93,7 @@ func main() {
|
|||||||
// With DATABASE_URL: Postgres repos + DB queue (production)
|
// With DATABASE_URL: Postgres repos + DB queue (production)
|
||||||
// Without DATABASE_URL: in-memory repos + in-process AI (development)
|
// Without DATABASE_URL: in-memory repos + in-process AI (development)
|
||||||
exampleRepo := memory.NewExampleRepository()
|
exampleRepo := memory.NewExampleRepository()
|
||||||
|
albumRepo := memory.NewAlbumRepository()
|
||||||
var userRepo port.UserRepository
|
var userRepo port.UserRepository
|
||||||
var sessionRepo port.SessionRepository
|
var sessionRepo port.SessionRepository
|
||||||
var authCodeRepo port.AuthCodeRepository
|
var authCodeRepo port.AuthCodeRepository
|
||||||
@ -139,7 +142,7 @@ func main() {
|
|||||||
sessionRepo = memory.NewSessionRepository()
|
sessionRepo = memory.NewSessionRepository()
|
||||||
authCodeRepo = memory.NewAuthCodeRepository()
|
authCodeRepo = memory.NewAuthCodeRepository()
|
||||||
mediaRepo = memory.NewMediaRepository()
|
mediaRepo = memory.NewMediaRepository()
|
||||||
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, sseHub, logger)
|
jobQueue, jobReader = setupStandaloneQueue(ctx, mediaStore, albumRepo, sseHub, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required config.
|
// Validate required config.
|
||||||
@ -183,6 +186,7 @@ func main() {
|
|||||||
|
|
||||||
// Create services (business logic)
|
// Create services (business logic)
|
||||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||||
|
albumService := service.NewAlbumService(albumRepo, jobQueue, logger)
|
||||||
authService := service.NewAuthService(
|
authService := service.NewAuthService(
|
||||||
userRepo, sessionRepo, authCodeRepo, emailSender,
|
userRepo, sessionRepo, authCodeRepo, emailSender,
|
||||||
cfg.JWTSecret, cfg.RegistrationEnabled, logger,
|
cfg.JWTSecret, cfg.RegistrationEnabled, logger,
|
||||||
@ -200,6 +204,7 @@ func main() {
|
|||||||
api.RegisterRoutes(application, &api.Dependencies{
|
api.RegisterRoutes(application, &api.Dependencies{
|
||||||
ExampleService: exampleService,
|
ExampleService: exampleService,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
|
AlbumService: albumService,
|
||||||
Queue: jobQueue,
|
Queue: jobQueue,
|
||||||
JobReader: jobReader,
|
JobReader: jobReader,
|
||||||
SSEHub: sseHub,
|
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.
|
// setupStandaloneQueue initializes an in-memory queue with in-process AI handlers.
|
||||||
// This mode requires no database or Redis — everything runs in a single process.
|
// This mode requires no database or Redis — everything runs in a single process.
|
||||||
// Returns both Producer (for enqueue) and JobReader (for status polling).
|
// 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)
|
memQueue := queue.NewMemoryQueue(logger.Logger)
|
||||||
|
|
||||||
// LocalPublisher delivers events directly to the SSE hub (no Redis needed).
|
// 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 {
|
if mediagenManager != nil {
|
||||||
memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
|
memQueue.RegisterHandler("generate_image", generation.ImageHandler(mediagenManager, store, pub, logger))
|
||||||
memQueue.RegisterHandler("generate_video", generation.VideoHandler(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 {
|
if textgenManager != nil {
|
||||||
memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
|
memQueue.RegisterHandler("generate_text", generation.TextHandler(textgenManager, pub, logger))
|
||||||
memQueue.RegisterHandler("ai_chat_response", generation.ChatResponseHandler(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
|
return memQueue, memQueue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,281 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"{{GO_MODULE}}/pkg/album"
|
||||||
|
"{{GO_MODULE}}/pkg/app"
|
||||||
|
"{{GO_MODULE}}/pkg/auth"
|
||||||
|
"{{GO_MODULE}}/pkg/httperror"
|
||||||
|
"{{GO_MODULE}}/pkg/httpresponse"
|
||||||
|
"{{GO_MODULE}}/pkg/logging"
|
||||||
|
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Album handles HTTP requests for album CRUD and generation endpoints.
|
||||||
|
// All generation endpoints are async: they enqueue a job and return 202.
|
||||||
|
// Results arrive via SSE events on the user:<userId> channel.
|
||||||
|
type Album struct {
|
||||||
|
albums *service.AlbumService
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlbum creates a new Album handler.
|
||||||
|
func NewAlbum(albums *service.AlbumService, logger *logging.Logger) *Album {
|
||||||
|
return &Album{
|
||||||
|
albums: albums,
|
||||||
|
logger: logger.WithComponent("AlbumHandler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request/response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateAlbumRequest is the request body for POST /albums.
|
||||||
|
type CreateAlbumRequest struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||||
|
SubjectDesc string `json:"subjectDesc" validate:"required,min=1,max=500"`
|
||||||
|
Shots []ShotTemplateBody `json:"shots" validate:"required,min=1,max=20"`
|
||||||
|
TemplateSet string `json:"templateSet"` // Optional: "portrait", "product", "character"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShotTemplateBody is a single shot spec in the create request.
|
||||||
|
type ShotTemplateBody struct {
|
||||||
|
Label string `json:"label" validate:"required"`
|
||||||
|
Direction string `json:"direction" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumJobResponse is the response for generation enqueue endpoints.
|
||||||
|
type AlbumJobResponse struct {
|
||||||
|
JobID string `json:"jobId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumJobsResponse is the response for bulk generation enqueue.
|
||||||
|
type AlbumJobsResponse struct {
|
||||||
|
JobIDs []string `json:"jobIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Create handles POST /albums — creates a new album with shot specs.
|
||||||
|
func (h *Album) Create(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateAlbumRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a template set was provided, use it (overrides explicit shots).
|
||||||
|
var shots []album.ShotTemplate
|
||||||
|
if req.TemplateSet != "" {
|
||||||
|
set, ok := album.ShotTemplateSets[req.TemplateSet]
|
||||||
|
if !ok {
|
||||||
|
return httperror.BadRequest("unknown template set: " + req.TemplateSet)
|
||||||
|
}
|
||||||
|
shots = set
|
||||||
|
} else {
|
||||||
|
// Convert body shots to ShotTemplate.
|
||||||
|
shots = make([]album.ShotTemplate, len(req.Shots))
|
||||||
|
for i, s := range req.Shots {
|
||||||
|
shots[i] = album.ShotTemplate{Label: s.Label, Direction: s.Direction}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := h.albums.Create(r.Context(), user.ID, req.Name, req.SubjectDesc, shots)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to create album", "error", err, "user_id", user.ID)
|
||||||
|
return httperror.BadRequest(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.Created(w, r, a)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /albums — returns all albums for the authenticated user.
|
||||||
|
func (h *Album) List(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, err := h.albums.List(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to list albums", "error", err, "user_id", user.ID)
|
||||||
|
return httperror.Internal("failed to list albums")
|
||||||
|
}
|
||||||
|
|
||||||
|
if albums == nil {
|
||||||
|
albums = []album.Album{}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, albums)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /albums/{id} — returns a single album with all shot statuses.
|
||||||
|
func (h *Album) Get(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := h.albums.Get(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.OK(w, r, a)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /albums/{id} — deletes an album.
|
||||||
|
func (h *Album) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.albums.Delete(r.Context(), id, user.ID); err != nil {
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.NoContent(w)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generation (async — returns 202)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GenerateAnchor handles POST /albums/{id}/anchor — enqueues anchor generation.
|
||||||
|
// Returns 202 with job ID. Result arrives via album_anchor_complete SSE event.
|
||||||
|
func (h *Album) GenerateAnchor(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.albums.GenerateAnchor(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue anchor job", "error", err, "album_id", string(id))
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
|
||||||
|
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllShots handles POST /albums/{id}/shots — enqueues all pending shots.
|
||||||
|
// Returns 422 if the album has no anchor yet.
|
||||||
|
// Returns 202 with job IDs for all enqueued shots.
|
||||||
|
func (h *Album) GenerateAllShots(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobIDs, err := h.albums.GenerateAllShots(r.Context(), id, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "anchor must be generated before shots" {
|
||||||
|
return httperror.UnprocessableEntity("anchor must be generated before shots")
|
||||||
|
}
|
||||||
|
return httperror.NotFound("album not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if jobIDs == nil {
|
||||||
|
jobIDs = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
|
||||||
|
httpresponse.Accepted(w, r, AlbumJobsResponse{JobIDs: jobIDs})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateShot handles POST /albums/{id}/shots/{index} — enqueues a single shot (for regeneration).
|
||||||
|
func (h *Album) GenerateShot(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
shotIndex := 0
|
||||||
|
if idx := chi.URLParam(r, "index"); idx != "" {
|
||||||
|
if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil {
|
||||||
|
return httperror.BadRequest("invalid shot index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.albums.GenerateShot(r.Context(), id, user.ID, shotIndex)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "anchor must be generated before shots" {
|
||||||
|
return httperror.UnprocessableEntity("anchor must be generated before shots")
|
||||||
|
}
|
||||||
|
return httperror.NotFound("album or shot not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.Accepted(w, r, AlbumJobResponse{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetShot handles DELETE /albums/{id}/shots/{index} — resets a shot to pending.
|
||||||
|
func (h *Album) ResetShot(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := album.AlbumID(chi.URLParam(r, "id"))
|
||||||
|
if id == "" {
|
||||||
|
return httperror.BadRequest("album ID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
shotIndex := 0
|
||||||
|
if idx := chi.URLParam(r, "index"); idx != "" {
|
||||||
|
if _, err := fmt.Sscanf(idx, "%d", &shotIndex); err != nil {
|
||||||
|
return httperror.BadRequest("invalid shot index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.albums.ResetShot(r.Context(), id, user.ID, shotIndex); err != nil {
|
||||||
|
return httperror.NotFound("album or shot not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpresponse.NoContent(w)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"{{GO_MODULE}}/pkg/app"
|
||||||
|
"{{GO_MODULE}}/pkg/auth"
|
||||||
|
"{{GO_MODULE}}/pkg/httperror"
|
||||||
|
"{{GO_MODULE}}/pkg/httpresponse"
|
||||||
|
"{{GO_MODULE}}/pkg/logging"
|
||||||
|
"{{GO_MODULE}}/pkg/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Persona handles HTTP requests for persona generation.
|
||||||
|
// All generation is async: validate request, enqueue job, return 202 with job ID.
|
||||||
|
// Results are delivered via SSE events to the user's `user:<userId>` channel:
|
||||||
|
//
|
||||||
|
// - persona_spec_started: LLM pipeline started
|
||||||
|
// - persona_spec_complete: Persona profile generated
|
||||||
|
// - persona_image_started: Starting a specific image position
|
||||||
|
// - persona_image_progress: Image position complete with URL
|
||||||
|
// - persona_image_complete: All 20 images generated
|
||||||
|
// - persona_video_started: Starting a video motion type
|
||||||
|
// - persona_video_complete: Video complete with URL
|
||||||
|
// - persona_failed: Generation failed (check error field)
|
||||||
|
type Persona struct {
|
||||||
|
queue queue.Producer
|
||||||
|
jobReader queue.JobReader
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPersona creates a new Persona handler with injected dependencies.
|
||||||
|
func NewPersona(q queue.Producer, jr queue.JobReader, logger *logging.Logger) *Persona {
|
||||||
|
return &Persona{
|
||||||
|
queue: q,
|
||||||
|
jobReader: jr,
|
||||||
|
logger: logger.WithComponent("PersonaHandler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePersonaRequest is the request body for persona generation.
|
||||||
|
type GeneratePersonaRequest struct {
|
||||||
|
// Description is a natural-language persona concept (required).
|
||||||
|
// Example: "mysterious woman with dark hair who loves poetry"
|
||||||
|
Description string `json:"description" validate:"required,min=3,max=1000"`
|
||||||
|
|
||||||
|
// Gender is the gender identity: "woman", "man", or "non_binary" (required).
|
||||||
|
Gender string `json:"gender" validate:"required,oneof=woman man non_binary"`
|
||||||
|
|
||||||
|
// Name is an optional name override for the generated persona.
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePersona queues a persona generation job.
|
||||||
|
// Returns immediately with job ID. Full lifecycle results come via SSE.
|
||||||
|
//
|
||||||
|
// Subscribe to SSE channel `user:<userId>` at /api/{{COMPONENT_NAME}}/events before calling.
|
||||||
|
// Poll job status at GET /generate/jobs/{id} as a fallback to SSE.
|
||||||
|
func (h *Persona) GeneratePersona(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
var req GeneratePersonaRequest
|
||||||
|
if err := app.BindAndValidate(r, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.GetUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
return httperror.Unauthorized("authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.queue.Enqueue(r.Context(), "persona_generate", map[string]any{
|
||||||
|
"description": req.Description,
|
||||||
|
"gender": req.Gender,
|
||||||
|
"name": req.Name,
|
||||||
|
"userID": user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to enqueue persona job", "error", err)
|
||||||
|
return httperror.Internal("failed to queue persona generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("persona generation queued", "jobId", jobID, "userID", user.ID)
|
||||||
|
|
||||||
|
httpresponse.Accepted(w, r, GenerateAccepted{JobID: jobID})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -34,6 +34,8 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
|
|||||||
generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger)
|
generateHandler := handlers.NewGenerate(deps.Queue, deps.JobReader, deps.SSEHub, logger)
|
||||||
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
|
chatHandler := handlers.NewChat(deps.Queue, deps.SSEHub, logger)
|
||||||
mediaHandler := handlers.NewMedia(deps.Store, deps.MediaRepo, 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
|
// Build and mount OpenAPI spec
|
||||||
spec := NewServiceSpec()
|
spec := NewServiceSpec()
|
||||||
@ -151,6 +153,19 @@ func RegisterRoutes(application *app.App, deps *Dependencies) {
|
|||||||
|
|
||||||
// Media library (upload, list, delete)
|
// Media library (upload, list, delete)
|
||||||
r.Mount("/media", mediaHandler.Routes())
|
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 {
|
type Dependencies struct {
|
||||||
ExampleService *service.ExampleService
|
ExampleService *service.ExampleService
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
|
AlbumService *service.AlbumService
|
||||||
Queue queue.Producer
|
Queue queue.Producer
|
||||||
JobReader queue.JobReader
|
JobReader queue.JobReader
|
||||||
SSEHub *realtime.SSEHub
|
SSEHub *realtime.SSEHub
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"{{GO_MODULE}}/pkg/album"
|
||||||
|
"{{GO_MODULE}}/pkg/logging"
|
||||||
|
"{{GO_MODULE}}/pkg/queue"
|
||||||
|
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbumService handles album creation, retrieval, and generation orchestration.
|
||||||
|
// All generation is async: service enqueues jobs and returns immediately.
|
||||||
|
// Results arrive via SSE on the user:<userId> channel.
|
||||||
|
type AlbumService struct {
|
||||||
|
albums port.AlbumRepository
|
||||||
|
queue queue.Producer
|
||||||
|
logger *logging.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlbumService creates a new AlbumService.
|
||||||
|
func NewAlbumService(albums port.AlbumRepository, q queue.Producer, logger *logging.Logger) *AlbumService {
|
||||||
|
return &AlbumService{
|
||||||
|
albums: albums,
|
||||||
|
queue: q,
|
||||||
|
logger: logger.WithComponent("AlbumService"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new album with the given shots and persists it.
|
||||||
|
// Shots are provided as ShotTemplate slices (Label + Direction).
|
||||||
|
func (s *AlbumService) Create(ctx context.Context, userID, name, subjectDesc string, shots []album.ShotTemplate) (*album.Album, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("album name is required")
|
||||||
|
}
|
||||||
|
if subjectDesc == "" {
|
||||||
|
return nil, fmt.Errorf("subject description is required")
|
||||||
|
}
|
||||||
|
if len(shots) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one shot is required")
|
||||||
|
}
|
||||||
|
if len(shots) > 20 {
|
||||||
|
return nil, fmt.Errorf("maximum 20 shots per album")
|
||||||
|
}
|
||||||
|
|
||||||
|
shotList := make([]album.Shot, len(shots))
|
||||||
|
for i, tmpl := range shots {
|
||||||
|
shotList[i] = album.Shot{
|
||||||
|
Index: i,
|
||||||
|
Label: tmpl.Label,
|
||||||
|
Direction: tmpl.Direction,
|
||||||
|
Status: album.ShotPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a := &album.Album{
|
||||||
|
ID: album.AlbumID("alb_" + generateID()),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
SubjectDesc: subjectDesc,
|
||||||
|
Shots: shotList,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.albums.Create(ctx, a); err != nil {
|
||||||
|
return nil, fmt.Errorf("create album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("album created", "album_id", string(a.ID), "user_id", userID, "shots", len(shotList))
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns an album by ID, enforcing user ownership.
|
||||||
|
func (s *AlbumService) Get(ctx context.Context, id album.AlbumID, userID string) (*album.Album, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all albums for a user.
|
||||||
|
func (s *AlbumService) List(ctx context.Context, userID string) ([]album.Album, error) {
|
||||||
|
return s.albums.List(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes an album. Does NOT delete stored images.
|
||||||
|
func (s *AlbumService) Delete(ctx context.Context, id album.AlbumID, userID string) error {
|
||||||
|
return s.albums.Delete(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAnchor enqueues an anchor generation job for an album.
|
||||||
|
// Returns the job ID. Result arrives via album_anchor_complete SSE event.
|
||||||
|
func (s *AlbumService) GenerateAnchor(ctx context.Context, id album.AlbumID, userID string) (string, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := s.queue.Enqueue(ctx, "generate_anchor", map[string]any{
|
||||||
|
"albumId": string(a.ID),
|
||||||
|
"userId": userID,
|
||||||
|
"subjectDesc": a.SubjectDesc,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("enqueue anchor job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.albums.UpdateAnchorJobID(ctx, id, userID, jobID); err != nil {
|
||||||
|
s.logger.Warn("failed to persist anchor job ID", "error", err, "album_id", string(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("anchor generation enqueued", "album_id", string(id), "job_id", jobID)
|
||||||
|
return jobID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAllShots enqueues generation jobs for all pending shots.
|
||||||
|
// Returns 422 if the album has no anchor yet (shots require an anchor reference).
|
||||||
|
func (s *AlbumService) GenerateAllShots(ctx context.Context, id album.AlbumID, userID string) ([]string, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
if a.AnchorURL == "" {
|
||||||
|
return nil, fmt.Errorf("anchor must be generated before shots")
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobIDs []string
|
||||||
|
for _, shot := range a.Shots {
|
||||||
|
if shot.Status != album.ShotPending && shot.Status != album.ShotFailed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobID, err := s.enqueueShotJob(ctx, a, shot.Index)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to enqueue shot", "error", err, "album_id", string(id), "shot_index", shot.Index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
jobIDs = append(jobIDs, jobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("shots enqueued", "album_id", string(id), "count", len(jobIDs))
|
||||||
|
return jobIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateShot enqueues a generation job for a single shot (for regeneration).
|
||||||
|
func (s *AlbumService) GenerateShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) (string, error) {
|
||||||
|
a, err := s.albums.Get(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("album not found: %w", err)
|
||||||
|
}
|
||||||
|
if a.AnchorURL == "" {
|
||||||
|
return "", fmt.Errorf("anchor must be generated before shots")
|
||||||
|
}
|
||||||
|
if shotIndex < 0 || shotIndex >= len(a.Shots) {
|
||||||
|
return "", fmt.Errorf("shot index out of range: %d", shotIndex)
|
||||||
|
}
|
||||||
|
return s.enqueueShotJob(ctx, a, shotIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetShot clears a shot back to pending so it can be regenerated.
|
||||||
|
func (s *AlbumService) ResetShot(ctx context.Context, id album.AlbumID, userID string, shotIndex int) error {
|
||||||
|
return s.albums.ResetShot(ctx, id, userID, shotIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueueShotJob is the internal helper that enqueues a single shot generation job.
|
||||||
|
func (s *AlbumService) enqueueShotJob(ctx context.Context, a *album.Album, shotIndex int) (string, error) {
|
||||||
|
shot := a.Shots[shotIndex]
|
||||||
|
jobID, err := s.queue.Enqueue(ctx, "generate_shot", map[string]any{
|
||||||
|
"albumId": string(a.ID),
|
||||||
|
"userId": a.UserID,
|
||||||
|
"shotIndex": shotIndex,
|
||||||
|
"anchorUrl": a.AnchorURL,
|
||||||
|
"subjectDesc": a.SubjectDesc,
|
||||||
|
"direction": shot.Direction,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("enqueue shot job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.albums.UpdateShotJobID(ctx, a.ID, a.UserID, shotIndex, jobID); err != nil {
|
||||||
|
s.logger.Warn("failed to persist shot job ID", "error", err, "shot_index", shotIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobID, nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,231 @@
|
|||||||
|
# Album Generation Guide
|
||||||
|
|
||||||
|
Albums are the right abstraction for generating multiple images of the same subject with visual consistency. One anchor image + N directed shots.
|
||||||
|
|
||||||
|
## Mental Model: Photography Session
|
||||||
|
|
||||||
|
| Term | Meaning | Example |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **Subject** | What's being photographed | "Woman, dark curly hair, early 30s, artistic style" |
|
||||||
|
| **Anchor** | The reference image that ties all shots together | Generated from subject description |
|
||||||
|
| **Shot** | One image with a specific direction | "Headshot, direct eye contact, studio lighting" |
|
||||||
|
| **Album** | The full session: subject + anchor + shots | "Jordan Headshots" |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
The album abstraction covers persona headshots, product photography, character sheets, and brand assets — same mechanism, different subject descriptions.
|
||||||
|
|
||||||
|
```
|
||||||
|
Personas: subject="Woman, dark hair, 30s" + shots=[Headshot, Casual, Professional]
|
||||||
|
Products: subject="Titanium water bottle, brushed finish" + shots=[Hero, Lifestyle, Detail]
|
||||||
|
Characters: subject="Cartoon raccoon mascot, mischievous" + shots=[Neutral, Expression, Action]
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All generation endpoints return **202 Accepted** with a job ID. Results arrive via SSE.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/example-api/albums Create album with shots
|
||||||
|
GET /api/example-api/albums List user's albums
|
||||||
|
GET /api/example-api/albums/{id} Get album with shot statuses
|
||||||
|
DELETE /api/example-api/albums/{id} Delete album
|
||||||
|
|
||||||
|
POST /api/example-api/albums/{id}/anchor Enqueue anchor generation → {jobId}
|
||||||
|
POST /api/example-api/albums/{id}/shots Enqueue all pending shots → {jobIds:[...]}
|
||||||
|
POST /api/example-api/albums/{id}/shots/{i} Regenerate one shot → {jobId}
|
||||||
|
DELETE /api/example-api/albums/{id}/shots/{i} Reset shot to pending
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Album
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /albums
|
||||||
|
{
|
||||||
|
"name": "Jordan Headshots",
|
||||||
|
"subjectDesc": "Professional woman, dark curly hair, early 30s, warm smile",
|
||||||
|
"shots": [
|
||||||
|
{"label": "Headshot", "direction": "close-up, direct eye contact, studio lighting"},
|
||||||
|
{"label": "Casual", "direction": "relaxed smile, natural light, outdoor setting"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a built-in template set:
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /albums
|
||||||
|
{
|
||||||
|
"name": "Jordan Headshots",
|
||||||
|
"subjectDesc": "Professional woman, dark curly hair, early 30s, warm smile",
|
||||||
|
"templateSet": "portrait"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Available template sets: `portrait` (6 shots), `product` (4 shots), `character` (4 shots).
|
||||||
|
|
||||||
|
## Generation Order
|
||||||
|
|
||||||
|
1. **Generate anchor first** — POST `/albums/{id}/anchor`
|
||||||
|
2. **Wait for `album_anchor_complete` SSE** — anchor URL arrives
|
||||||
|
3. **Generate shots** — POST `/albums/{id}/shots` (returns 422 if no anchor)
|
||||||
|
4. **Shots complete** — `album_shot_complete` events arrive per shot
|
||||||
|
|
||||||
|
**Enforcement:** The service returns 422 Unprocessable Entity if shots are requested before the anchor exists. The frontend disables "Generate All Shots" until the anchor is ready.
|
||||||
|
|
||||||
|
## SSE Events
|
||||||
|
|
||||||
|
All events arrive on the `user:<userId>` channel (existing subscription).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Anchor events
|
||||||
|
{ type: "album_anchor_complete", result: { albumId, anchorUrl } }
|
||||||
|
{ type: "album_anchor_failed", result: { albumId, error } }
|
||||||
|
|
||||||
|
// Shot events
|
||||||
|
{ type: "album_shot_generating", result: { albumId, shotIndex } }
|
||||||
|
{ type: "album_shot_complete", result: { albumId, shotIndex, imageUrl } }
|
||||||
|
{ type: "album_shot_failed", result: { albumId, shotIndex, error } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Usage
|
||||||
|
|
||||||
|
### useAlbumGeneration Hook
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAlbumGeneration } from '@example-project/realtime';
|
||||||
|
|
||||||
|
function AlbumPage({ albumId }: { albumId: string }) {
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
|
||||||
|
const {
|
||||||
|
album,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadAlbum,
|
||||||
|
generateAnchor,
|
||||||
|
generateAllShots,
|
||||||
|
regenerateShot,
|
||||||
|
resetShot,
|
||||||
|
} = useAlbumGeneration({
|
||||||
|
apiBase: '/api/example-api',
|
||||||
|
userId: user.id,
|
||||||
|
albumId,
|
||||||
|
authToken: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
useEffect(() => { void loadAlbum(); }, [loadAlbum]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlbumGrid
|
||||||
|
name={album?.name}
|
||||||
|
anchorUrl={album?.anchorUrl}
|
||||||
|
shots={album?.shots ?? []}
|
||||||
|
onGenerateAnchor={generateAnchor}
|
||||||
|
onGenerateAllShots={generateAllShots}
|
||||||
|
onRegenerateShot={regenerateShot}
|
||||||
|
onResetShot={resetShot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AlbumGrid Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AlbumGrid } from '@example-project/ui';
|
||||||
|
|
||||||
|
<AlbumGrid
|
||||||
|
name="Jordan Headshots"
|
||||||
|
anchorUrl={album.anchorUrl}
|
||||||
|
anchorGenerating={isAnchorGenerating}
|
||||||
|
shots={album.shots}
|
||||||
|
onGenerateAnchor={generateAnchor}
|
||||||
|
onRegenerateAnchor={generateAnchor}
|
||||||
|
onGenerateAllShots={generateAllShots}
|
||||||
|
onRegenerateShot={(index) => regenerateShot(index)}
|
||||||
|
onResetShot={(index) => resetShot(index)}
|
||||||
|
onImageClick={(indexOrAnchor) => openLightbox(indexOrAnchor)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Individual Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AnchorPreview, ShotCard } from '@example-project/ui';
|
||||||
|
|
||||||
|
// Anchor card
|
||||||
|
<AnchorPreview
|
||||||
|
anchorUrl={album.anchorUrl}
|
||||||
|
isGenerating={!!album.anchorJobId && !album.anchorUrl}
|
||||||
|
onGenerate={generateAnchor}
|
||||||
|
onRegenerate={generateAnchor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Individual shot card
|
||||||
|
<ShotCard
|
||||||
|
label="Headshot"
|
||||||
|
status={shot.status} // 'pending' | 'generating' | 'complete' | 'failed'
|
||||||
|
imageUrl={shot.imageUrl}
|
||||||
|
error={shot.error}
|
||||||
|
anchorReady={!!album.anchorUrl}
|
||||||
|
onGenerate={() => regenerateShot(shot.index)}
|
||||||
|
onRegenerate={() => regenerateShot(shot.index)}
|
||||||
|
onReset={() => resetShot(shot.index)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend Architecture
|
||||||
|
|
||||||
|
### Skeleton Package (`pkg/album/`)
|
||||||
|
|
||||||
|
Ships in every project. Provides:
|
||||||
|
- `Album`, `Shot`, `ShotStatus`, `ShotTemplate` types
|
||||||
|
- `AlbumUpdater` interface (minimal interface for job handlers)
|
||||||
|
- `AnchorHandler(mg, store, pub, updater, logger)` — job handler for `generate_anchor`
|
||||||
|
- `ShotHandler(mg, store, pub, updater, logger)` — job handler for `generate_shot`
|
||||||
|
- Built-in template sets: `PortraitSession`, `ProductShoot`, `CharacterSheet`
|
||||||
|
|
||||||
|
### Job Handler Architecture
|
||||||
|
|
||||||
|
Anchor bytes are **NOT stored in the job payload** (megabytes in DB = bad).
|
||||||
|
Instead:
|
||||||
|
1. Anchor is generated and stored at `albums/{userId}/{albumId}/anchor.png`
|
||||||
|
2. The anchor URL is stored in the album record
|
||||||
|
3. Shot jobs carry `anchorUrl` in their payload
|
||||||
|
4. The `ShotHandler` fetches anchor bytes at execution time via HTTP
|
||||||
|
5. Bytes are passed as `ReferenceImage` to the mediagen provider
|
||||||
|
|
||||||
|
### Component Service Layer
|
||||||
|
|
||||||
|
```
|
||||||
|
port.AlbumRepository — CRUD + AlbumUpdater
|
||||||
|
memory.AlbumRepository — in-memory (standalone dev mode)
|
||||||
|
postgres.AlbumRepository — postgres (production, not yet implemented)
|
||||||
|
service.AlbumService — business logic (create, list, get, generateAnchor, generateShots)
|
||||||
|
handlers.Album — HTTP handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev Mode
|
||||||
|
|
||||||
|
In standalone mode (`DATABASE_URL` not set), albums persist in memory until the server restarts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the backend
|
||||||
|
go run ./services/example-api/cmd/server
|
||||||
|
|
||||||
|
# Create an album
|
||||||
|
curl -X POST http://localhost:8001/api/example-api/albums \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"Test","subjectDesc":"A cat","templateSet":"portrait"}'
|
||||||
|
|
||||||
|
# Generate anchor
|
||||||
|
curl -X POST http://localhost:8001/api/example-api/albums/<id>/anchor \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
|
# Watch SSE for album_anchor_complete
|
||||||
|
curl -N "http://localhost:8001/api/example-api/events?channel=user:<userId>" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
# Persona Generation Guide
|
||||||
|
|
||||||
|
`pkg/personagen` provides a complete pipeline for generating synthetic persona profiles with
|
||||||
|
biological DNA, personality psychology, lifestyle preferences, a 20-position image matrix,
|
||||||
|
and 4-motion-type video specs.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A `PersonaSpec` is the top-level output — it contains:
|
||||||
|
- **CoreIdentity**: name, age, gender, ethnicity, occupation, location
|
||||||
|
- **DNA**: immutable biological characteristics (face, body, voice)
|
||||||
|
- **Psychology**: HEXACO personality profile, attachment style, values
|
||||||
|
- **Lifestyle**: interests (5 categories), fashion sense (15 contexts), vacation style
|
||||||
|
- **ImageMatrix**: 20-position image generation specs (4 tiers: Identity, Expressions, Angles, Context)
|
||||||
|
- **Videos**: 4 video specs (smile_reveal, personality_moment, lifestyle, invitation)
|
||||||
|
|
||||||
|
## Generation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/{service}/persona/generate
|
||||||
|
→ "persona_generate" job enqueued → 202 {jobId}
|
||||||
|
→ Worker picks up job
|
||||||
|
→ personagen.Service.GenerateSpec() → 5-stage LLM pipeline
|
||||||
|
→ personagen.Service.GenerateImages() → 20 image positions (position 1 = anchor first)
|
||||||
|
→ personagen.Service.GenerateVideo() → 4 video motion types
|
||||||
|
→ SSE events delivered to user:<userId>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5-Stage Spec Pipeline
|
||||||
|
|
||||||
|
| Stage | What it does | LLM calls |
|
||||||
|
|-------|-------------|-----------|
|
||||||
|
| 1 | Identity: name, age, ethnicity, occupation, location | 1 |
|
||||||
|
| 2 | Psychology: HEXACO scores, attachment, values | 1 |
|
||||||
|
| 3 | Lifestyle: interests, fashion context, vacation style | 1 |
|
||||||
|
| 4 | Visual DNA: all face/body/voice characteristics | 1 |
|
||||||
|
| 5 | Populate image matrix from lifestyle (no LLM needed) | 0 |
|
||||||
|
|
||||||
|
Total: **4 LLM calls** per persona spec.
|
||||||
|
|
||||||
|
## Image Matrix Tiers
|
||||||
|
|
||||||
|
| Tier | Positions | Focus |
|
||||||
|
|------|-----------|-------|
|
||||||
|
| 1 – Identity | 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:<userId>` channel before calling the generate endpoint:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "persona_spec_started", "jobId": "...", "message": "Generating persona profile..."}
|
||||||
|
{"type": "persona_spec_complete", "jobId": "...", "result": {"personaId": "ps_abc123"}}
|
||||||
|
{"type": "persona_image_started", "jobId": "...", "result": {"position": 1}}
|
||||||
|
{"type": "persona_image_progress", "jobId": "...", "progress": 45, "result": {"position": 9, "url": "..."}}
|
||||||
|
{"type": "persona_image_complete", "jobId": "...", "progress": 100, "result": {"personaId": "..."}}
|
||||||
|
{"type": "persona_video_started", "jobId": "...", "result": {"motionType": "smile_reveal"}}
|
||||||
|
{"type": "persona_video_complete", "jobId": "...", "result": {"motionType": "smile_reveal", "url": "..."}}
|
||||||
|
{"type": "persona_failed", "jobId": "...", "error": "Spec generation failed: ..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Requirements
|
||||||
|
|
||||||
|
Both AI providers must be configured for persona generation to work:
|
||||||
|
|
||||||
|
| Env Var | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `LAOZHANG_API_KEY` | Image and video generation (Flux, Kling) |
|
||||||
|
| `GEMINI_API_KEY` | Text generation (Gemini) + additional media (Imagen, Veo) |
|
||||||
|
|
||||||
|
These are auto-injected by rdev into every deployed service. Locally, source from `.secrets`.
|
||||||
|
|
||||||
|
## Using the Service Directly
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create the service
|
||||||
|
svc := personagen.New(textgenMgr, mediagenMgr, store, logger.Logger)
|
||||||
|
|
||||||
|
// Generate a full persona spec (5-stage LLM pipeline)
|
||||||
|
spec, err := svc.GenerateSpec(ctx, personagen.SeedParams{
|
||||||
|
Description: "confident Latina entrepreneur who loves fashion and travel",
|
||||||
|
Gender: "woman",
|
||||||
|
Name: "Sofia Reyes", // optional
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate all 20 images (position 1 first, sets anchor automatically)
|
||||||
|
if err := svc.GenerateImages(ctx, spec, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a specific video
|
||||||
|
videoSpec, err := svc.GenerateVideo(ctx, spec, persona.MotionSmileReveal)
|
||||||
|
|
||||||
|
// Generate utility images
|
||||||
|
avatarBytes, err := svc.GenerateAvatar(ctx, spec)
|
||||||
|
bannerBytes, err := svc.GenerateBanner(ctx, spec, "lifestyle")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fashion Contexts
|
||||||
|
|
||||||
|
15 named fashion contexts are available in `pkg/persona`:
|
||||||
|
|
||||||
|
| Context Name | Style |
|
||||||
|
|-------------|-------|
|
||||||
|
| `classic_minimalist` | Clean lines, neutral palette |
|
||||||
|
| `streetwear_chic` | Urban oversized fits, sneakers |
|
||||||
|
| `bohemian_free_spirit` | Flowing fabrics, earth tones |
|
||||||
|
| `athleisure_pro` | Performance fabrics, gym-to-street |
|
||||||
|
| `business_casual` | Polished professional with comfort |
|
||||||
|
| `romantic_feminine` | Florals, lace, pastels |
|
||||||
|
| `edgy_alternative` | Dark palette, leather, hardware |
|
||||||
|
| `coastal_casual` | Relaxed, beachy, linen |
|
||||||
|
| `urban_professional` | Sharp city-dweller aesthetic |
|
||||||
|
| `festival_glam` | Bold prints, glitter, maximalist |
|
||||||
|
| `preppy_classic` | Collegiate, polo shirts, chinos |
|
||||||
|
| `dark_academia` | Literary, tweed, earth tones |
|
||||||
|
| `cottagecore` | Prairie dresses, pastoral romance |
|
||||||
|
| `y2k_revival` | Low-rise, metallics, early 2000s |
|
||||||
|
| `luxe_loungewear` | Elevated comfort in premium fabrics |
|
||||||
|
|
||||||
|
Use `persona.AllFashionContexts()` for the full catalog or `persona.FashionContextFor(name)` for a single context.
|
||||||
|
|
||||||
|
## Motion Types
|
||||||
|
|
||||||
|
| Motion Type | Description | Duration | Aspect |
|
||||||
|
|------------|-------------|----------|--------|
|
||||||
|
| `smile_reveal` | Warm genuine smile moment | 5s | 9:16 |
|
||||||
|
| `personality_moment` | Expressive personality showcase | 8s | 9:16 |
|
||||||
|
| `lifestyle` | Contextual lifestyle shot | 8s | 16:9 |
|
||||||
|
| `invitation` | Direct-address to viewer | 5s | 9:16 |
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
| Error | Cause |
|
||||||
|
|-------|-------|
|
||||||
|
| `ErrAnchorNotSet` | `GenerateVideo()` called before position 1 image was generated |
|
||||||
|
| `"mediagen not configured"` | No AI provider keys set |
|
||||||
|
| `"stage N X: ..."` | LLM call or JSON parse failure in specgen pipeline |
|
||||||
@ -13,6 +13,8 @@
|
|||||||
| **Auth & user management** | [auth.md](.claude/guides/auth.md) |
|
| **Auth & user management** | [auth.md](.claude/guides/auth.md) |
|
||||||
| **Event channels** | [events.md](.claude/guides/events.md) |
|
| **Event channels** | [events.md](.claude/guides/events.md) |
|
||||||
| **Media pipeline** | [media.md](.claude/guides/media.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) |
|
| **Deploy** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|||||||
@ -3,3 +3,4 @@ export { useEventChannel, type ChannelEvent, type SSEState, type UseEventChannel
|
|||||||
export { useMediaGeneration, type GenerationStatus, type UseMediaGenerationConfig, type UseMediaGenerationResult } from './useMediaGeneration';
|
export { useMediaGeneration, type GenerationStatus, type UseMediaGenerationConfig, type UseMediaGenerationResult } from './useMediaGeneration';
|
||||||
export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat';
|
export { useChat, type UseChatConfig, type UseChatResult, type ChatMessage } from './useChat';
|
||||||
export { useMediaUpload, type UploadProgress, type UploadResult, type UseMediaUploadConfig, type UseMediaUploadResult } from './useMediaUpload';
|
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';
|
||||||
|
|||||||
@ -16,7 +16,13 @@ export type EventType =
|
|||||||
| 'upload_started'
|
| 'upload_started'
|
||||||
| 'upload_progress'
|
| 'upload_progress'
|
||||||
| 'upload_complete'
|
| '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.
|
* Chat message data payload.
|
||||||
|
|||||||
@ -0,0 +1,317 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { useEventChannel, type ChannelEvent } from './useEventChannel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shot status values matching Go ShotStatus constants.
|
||||||
|
*/
|
||||||
|
export type ShotStatus = 'pending' | 'generating' | 'complete' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single shot in an album.
|
||||||
|
*/
|
||||||
|
export interface Shot {
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
direction: string;
|
||||||
|
imageUrl: string;
|
||||||
|
jobId: string;
|
||||||
|
status: ShotStatus;
|
||||||
|
error?: string;
|
||||||
|
generatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An album: one subject, one anchor, N shots.
|
||||||
|
*/
|
||||||
|
export interface Album {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
subjectDesc: string;
|
||||||
|
anchorUrl: string;
|
||||||
|
anchorJobId: string;
|
||||||
|
shots: Shot[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the useAlbumGeneration hook.
|
||||||
|
*/
|
||||||
|
export interface UseAlbumGenerationConfig {
|
||||||
|
/** API base path for album endpoints (default: '/api/example-api') */
|
||||||
|
apiBase: string;
|
||||||
|
/** User ID for subscribing to user channel */
|
||||||
|
userId: string;
|
||||||
|
/** Album to track. Pass null to manage without an active album. */
|
||||||
|
albumId: string | null;
|
||||||
|
/** SSE endpoint (default: '/api/example-api/events') */
|
||||||
|
sseEndpoint?: string;
|
||||||
|
/** Auth token for API requests */
|
||||||
|
authToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the useAlbumGeneration hook.
|
||||||
|
*/
|
||||||
|
export interface UseAlbumGenerationResult {
|
||||||
|
/** The currently loaded album (null if not yet loaded) */
|
||||||
|
album: Album | null;
|
||||||
|
/** Whether the album is being loaded */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error message */
|
||||||
|
error: string | null;
|
||||||
|
/** Load (or refresh) the album from the API */
|
||||||
|
loadAlbum: () => Promise<void>;
|
||||||
|
/** Enqueue anchor generation for the album */
|
||||||
|
generateAnchor: () => Promise<void>;
|
||||||
|
/** Enqueue all pending shots (requires anchor to exist) */
|
||||||
|
generateAllShots: () => Promise<void>;
|
||||||
|
/** Enqueue a single shot by index (for regeneration) */
|
||||||
|
regenerateShot: (index: number) => Promise<void>;
|
||||||
|
/** Reset a shot to pending so it can be regenerated */
|
||||||
|
resetShot: (index: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing album generation state with real-time progress via SSE.
|
||||||
|
*
|
||||||
|
* Subscribe to SSE events for the user channel and update album shot states
|
||||||
|
* as generation_complete and anchor_complete events arrive.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { album, generateAnchor, generateAllShots, regenerateShot } =
|
||||||
|
* useAlbumGeneration({
|
||||||
|
* apiBase: '/api/example-api',
|
||||||
|
* userId: currentUser.id,
|
||||||
|
* albumId: album.id,
|
||||||
|
* authToken: token,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <AlbumGrid album={album} onRegenerateShot={regenerateShot} />
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useAlbumGeneration(
|
||||||
|
config: UseAlbumGenerationConfig
|
||||||
|
): UseAlbumGenerationResult {
|
||||||
|
const { apiBase, userId, albumId, sseEndpoint, authToken } = config;
|
||||||
|
|
||||||
|
const [album, setAlbum] = useState<Album | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Keep album in a ref for event handler closure (avoids stale state).
|
||||||
|
const albumRef = useRef<Album | null>(null);
|
||||||
|
albumRef.current = album;
|
||||||
|
|
||||||
|
const effectiveSseEndpoint = sseEndpoint ?? `${apiBase}/events`;
|
||||||
|
|
||||||
|
// Build auth headers.
|
||||||
|
const headers = useCallback((): Record<string, string> => {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (authToken) h['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
return h;
|
||||||
|
}, [authToken]);
|
||||||
|
|
||||||
|
const loadAlbum = useCallback(async () => {
|
||||||
|
if (!albumId) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/albums/${albumId}`, {
|
||||||
|
headers: headers(),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to load album: ${res.status}`);
|
||||||
|
const json = (await res.json()) as { data: Album };
|
||||||
|
const loaded = json.data ?? (json as unknown as Album);
|
||||||
|
setAlbum(loaded);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load album');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [albumId, apiBase, headers]);
|
||||||
|
|
||||||
|
// Handle incoming SSE events for this album.
|
||||||
|
const handleEvent = useCallback((event: ChannelEvent) => {
|
||||||
|
const current = albumRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
// Extract albumId from the event result payload.
|
||||||
|
const result = event.result as Record<string, unknown> | undefined;
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const eventAlbumId = result['albumId'] as string | undefined;
|
||||||
|
if (eventAlbumId && eventAlbumId !== current.id) return;
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'album_anchor_complete': {
|
||||||
|
const anchorUrl = result['anchorUrl'] as string;
|
||||||
|
setAlbum((prev) => prev ? { ...prev, anchorUrl } : prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'album_anchor_failed': {
|
||||||
|
// Reload to get consistent state.
|
||||||
|
void loadAlbum();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'album_shot_generating': {
|
||||||
|
const shotIndex = result['shotIndex'] as number;
|
||||||
|
setAlbum((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const shots = prev.shots.map((s) =>
|
||||||
|
s.index === shotIndex ? { ...s, status: 'generating' as ShotStatus } : s
|
||||||
|
);
|
||||||
|
return { ...prev, shots };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'album_shot_complete': {
|
||||||
|
const shotIndex = result['shotIndex'] as number;
|
||||||
|
const imageUrl = result['imageUrl'] as string;
|
||||||
|
setAlbum((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const shots = prev.shots.map((s) =>
|
||||||
|
s.index === shotIndex
|
||||||
|
? { ...s, imageUrl, status: 'complete' as ShotStatus, error: undefined }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
return { ...prev, shots };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'album_shot_failed': {
|
||||||
|
const shotIndex = result['shotIndex'] as number;
|
||||||
|
const errMsg = result['error'] as string;
|
||||||
|
setAlbum((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const shots = prev.shots.map((s) =>
|
||||||
|
s.index === shotIndex
|
||||||
|
? { ...s, status: 'failed' as ShotStatus, error: errMsg }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
return { ...prev, shots };
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadAlbum]);
|
||||||
|
|
||||||
|
// Subscribe to the user SSE channel.
|
||||||
|
useEventChannel({
|
||||||
|
endpoint: effectiveSseEndpoint,
|
||||||
|
channel: `user:${userId}`,
|
||||||
|
onEvent: handleEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateAnchor = useCallback(async () => {
|
||||||
|
if (!albumId) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/albums/${albumId}/anchor`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||||
|
throw new Error(body.message ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start anchor generation');
|
||||||
|
}
|
||||||
|
}, [albumId, apiBase, headers]);
|
||||||
|
|
||||||
|
const generateAllShots = useCallback(async () => {
|
||||||
|
if (!albumId) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/albums/${albumId}/shots`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||||
|
throw new Error(body.message ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start shot generation');
|
||||||
|
}
|
||||||
|
}, [albumId, apiBase, headers]);
|
||||||
|
|
||||||
|
const regenerateShot = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
if (!albumId) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/albums/${albumId}/shots/${index}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||||
|
throw new Error(body.message ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
// Optimistically set shot to generating.
|
||||||
|
setAlbum((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const shots = prev.shots.map((s) =>
|
||||||
|
s.index === index ? { ...s, status: 'generating' as ShotStatus } : s
|
||||||
|
);
|
||||||
|
return { ...prev, shots };
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to regenerate shot');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[albumId, apiBase, headers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetShot = useCallback(
|
||||||
|
async (index: number) => {
|
||||||
|
if (!albumId) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/albums/${albumId}/shots/${index}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
// Update shot to pending locally.
|
||||||
|
setAlbum((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const shots = prev.shots.map((s) =>
|
||||||
|
s.index === index
|
||||||
|
? { ...s, status: 'pending' as ShotStatus, imageUrl: '', error: undefined }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
return { ...prev, shots };
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to reset shot');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[albumId, apiBase, headers]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
album,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadAlbum,
|
||||||
|
generateAnchor,
|
||||||
|
generateAllShots,
|
||||||
|
regenerateShot,
|
||||||
|
resetShot,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
import { AnchorPreview } from './AnchorPreview';
|
||||||
|
import { ShotCard } from './ShotCard';
|
||||||
|
|
||||||
|
export type ShotStatus = 'pending' | 'generating' | 'complete' | 'failed';
|
||||||
|
|
||||||
|
export interface AlbumShot {
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
direction: string;
|
||||||
|
imageUrl: string;
|
||||||
|
status: ShotStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlbumGridProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Album name */
|
||||||
|
name?: string;
|
||||||
|
/** Anchor image URL — empty string if not yet generated */
|
||||||
|
anchorUrl?: string;
|
||||||
|
/** Whether the anchor is currently being generated */
|
||||||
|
anchorGenerating?: boolean;
|
||||||
|
/** The list of shots in the album */
|
||||||
|
shots: AlbumShot[];
|
||||||
|
/** Number of columns (default: auto 3-4 based on screen) */
|
||||||
|
columns?: 3 | 4 | 5;
|
||||||
|
/** Called when the user requests anchor generation */
|
||||||
|
onGenerateAnchor?: () => void;
|
||||||
|
/** Called when the user requests anchor regeneration */
|
||||||
|
onRegenerateAnchor?: () => void;
|
||||||
|
/** Called when the user requests generation of all pending shots */
|
||||||
|
onGenerateAllShots?: () => void;
|
||||||
|
/** Called when the user requests regeneration of a specific shot */
|
||||||
|
onRegenerateShot?: (index: number) => void;
|
||||||
|
/** Called when the user resets a shot back to pending */
|
||||||
|
onResetShot?: (index: number) => void;
|
||||||
|
/** Called when the user clicks an image (e.g., to open lightbox) */
|
||||||
|
onImageClick?: (index: number | 'anchor') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumGrid displays an album as a responsive image grid.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* ┌──────────────┬──────────────┬──────────────┬──────────────┐
|
||||||
|
* │ [Anchor] │ Headshot │ Casual │ Professional │
|
||||||
|
* │ Reference │ [shimmer] │ ✓ [image] │ ✓ [image] │
|
||||||
|
* ├──────────────┼──────────────┼──────────────┼──────────────┤
|
||||||
|
* │ Candid │ Outdoor │ Editorial │ │
|
||||||
|
* │ [pending] │ [pending] │ [pending] │ │
|
||||||
|
* └──────────────┴──────────────┴──────────────┴──────────────┘
|
||||||
|
*
|
||||||
|
* - Anchor occupies the first slot with accent styling
|
||||||
|
* - Pending shots show the label + "Generate" button (disabled until anchor ready)
|
||||||
|
* - Generating shots show a shimmer animation
|
||||||
|
* - Complete shots show the image with hover controls
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AlbumGrid
|
||||||
|
* anchorUrl={album.anchorUrl}
|
||||||
|
* shots={album.shots}
|
||||||
|
* onGenerateAnchor={generateAnchor}
|
||||||
|
* onGenerateAllShots={generateAllShots}
|
||||||
|
* onRegenerateShot={regenerateShot}
|
||||||
|
* onResetShot={resetShot}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const AlbumGrid = React.forwardRef<HTMLDivElement, AlbumGridProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
anchorUrl,
|
||||||
|
anchorGenerating = false,
|
||||||
|
shots,
|
||||||
|
columns = 4,
|
||||||
|
onGenerateAnchor,
|
||||||
|
onRegenerateAnchor,
|
||||||
|
onGenerateAllShots,
|
||||||
|
onRegenerateShot,
|
||||||
|
onResetShot,
|
||||||
|
onImageClick,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const anchorReady = !!anchorUrl;
|
||||||
|
const pendingShots = shots.filter((s) => s.status === 'pending' || s.status === 'failed');
|
||||||
|
const allComplete = shots.every((s) => s.status === 'complete');
|
||||||
|
|
||||||
|
const colClass =
|
||||||
|
columns === 3
|
||||||
|
? 'grid-cols-3'
|
||||||
|
: columns === 5
|
||||||
|
? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5'
|
||||||
|
: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('space-y-3', className)} {...props}>
|
||||||
|
{/* Header row with name + action buttons */}
|
||||||
|
{(name || onGenerateAllShots) && (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{name && (
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{name}</h3>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{onGenerateAllShots && anchorReady && pendingShots.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onGenerateAllShots}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md bg-[var(--accent)] text-white hover:opacity-80 transition-opacity font-medium"
|
||||||
|
>
|
||||||
|
Generate All ({pendingShots.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{allComplete && (
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
✓ All complete
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className={cn('grid gap-2', colClass)}>
|
||||||
|
{/* Anchor slot — always first */}
|
||||||
|
<AnchorPreview
|
||||||
|
anchorUrl={anchorUrl}
|
||||||
|
isGenerating={anchorGenerating}
|
||||||
|
label="Reference"
|
||||||
|
onGenerate={onGenerateAnchor}
|
||||||
|
onRegenerate={onRegenerateAnchor}
|
||||||
|
onImageClick={onImageClick ? () => onImageClick('anchor') : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Shot slots */}
|
||||||
|
{shots.map((shot) => (
|
||||||
|
<ShotCard
|
||||||
|
key={shot.index}
|
||||||
|
label={shot.label}
|
||||||
|
status={shot.status}
|
||||||
|
imageUrl={shot.imageUrl}
|
||||||
|
error={shot.error}
|
||||||
|
anchorReady={anchorReady}
|
||||||
|
onGenerate={onRegenerateShot ? () => onRegenerateShot(shot.index) : undefined}
|
||||||
|
onRegenerate={onRegenerateShot ? () => onRegenerateShot(shot.index) : undefined}
|
||||||
|
onReset={onResetShot ? () => onResetShot(shot.index) : undefined}
|
||||||
|
onImageClick={onImageClick ? () => onImageClick(shot.index) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AlbumGrid.displayName = 'AlbumGrid';
|
||||||
|
|
||||||
|
export { AlbumGrid };
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
export interface AnchorPreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** URL of the anchor image — if empty, shows the "Generate Anchor First" CTA */
|
||||||
|
anchorUrl?: string;
|
||||||
|
/** Whether anchor generation is currently in progress */
|
||||||
|
isGenerating?: boolean;
|
||||||
|
/** Label shown above the anchor image */
|
||||||
|
label?: string;
|
||||||
|
/** Called when the user clicks the "Generate Anchor" button */
|
||||||
|
onGenerate?: () => void;
|
||||||
|
/** Called when the user clicks the "Regenerate" button on an existing anchor */
|
||||||
|
onRegenerate?: () => void;
|
||||||
|
/** Called when the user clicks the anchor image (e.g., to open a lightbox) */
|
||||||
|
onImageClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnchorPreview displays the album's reference/anchor image.
|
||||||
|
*
|
||||||
|
* The anchor is the first card in every album — it defines the visual identity
|
||||||
|
* that all shots reference. When missing, it shows a prominent CTA because
|
||||||
|
* no shots can be generated until the anchor exists.
|
||||||
|
*
|
||||||
|
* Three states:
|
||||||
|
* - No anchor, not generating: Show "Generate Anchor" CTA
|
||||||
|
* - Generating: Show shimmer animation
|
||||||
|
* - Has anchor: Show image with Regenerate hover overlay
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AnchorPreview
|
||||||
|
* anchorUrl={album.anchorUrl}
|
||||||
|
* isGenerating={album.anchorJobId !== '' && !album.anchorUrl}
|
||||||
|
* onGenerate={generateAnchor}
|
||||||
|
* onRegenerate={generateAnchor}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const AnchorPreview = React.forwardRef<HTMLDivElement, AnchorPreviewProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
anchorUrl,
|
||||||
|
isGenerating = false,
|
||||||
|
label = 'Reference',
|
||||||
|
onGenerate,
|
||||||
|
onRegenerate,
|
||||||
|
onImageClick,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative aspect-square rounded-lg overflow-hidden group',
|
||||||
|
'border-2 border-dashed border-[var(--accent)]/40',
|
||||||
|
anchorUrl && 'border-solid border-[var(--accent)]/60',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* GENERATING state — shimmer */}
|
||||||
|
{isGenerating && !anchorUrl && (
|
||||||
|
<>
|
||||||
|
<div className="w-full h-full bg-gradient-to-r from-[var(--surface-100)] via-[var(--surface-200)] to-[var(--surface-100)] animate-[shimmer_1.5s_ease-in-out_infinite] bg-[length:200%_100%]" />
|
||||||
|
<style>{`
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="absolute bottom-2 inset-x-2 text-center">
|
||||||
|
<span className="text-xs font-medium text-[var(--text-secondary)]">
|
||||||
|
Generating anchor...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NO ANCHOR state — CTA */}
|
||||||
|
{!anchorUrl && !isGenerating && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 p-4 bg-[var(--surface-50)]">
|
||||||
|
{/* Camera/anchor icon */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-[var(--accent)]/10 flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-[var(--accent)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM18.75 10.5h.008v.008h-.008V10.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-semibold text-[var(--text-primary)]">{label}</p>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)] mt-0.5">
|
||||||
|
Generate first
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onGenerate && (
|
||||||
|
<button
|
||||||
|
onClick={onGenerate}
|
||||||
|
className="px-3 py-1 text-xs rounded-md bg-[var(--accent)] text-white hover:opacity-80 transition-opacity font-medium"
|
||||||
|
>
|
||||||
|
Generate Anchor
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HAS ANCHOR state */}
|
||||||
|
{anchorUrl && (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={anchorUrl}
|
||||||
|
alt={label}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full object-cover',
|
||||||
|
onImageClick && 'cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={onImageClick}
|
||||||
|
/>
|
||||||
|
{/* Accent border indicator */}
|
||||||
|
<div className="absolute inset-0 ring-2 ring-[var(--accent)]/30 rounded-lg pointer-events-none" />
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex flex-col justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="text-xs font-semibold text-white drop-shadow">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{onRegenerate && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRegenerate(); }}
|
||||||
|
className="px-2 py-0.5 text-xs rounded bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AnchorPreview.displayName = 'AnchorPreview';
|
||||||
|
|
||||||
|
export { AnchorPreview };
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '../utils/cn';
|
||||||
|
|
||||||
|
export type ShotStatus = 'pending' | 'generating' | 'complete' | 'failed';
|
||||||
|
|
||||||
|
export interface ShotCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** Human-readable label for this shot (e.g., "Headshot", "Casual") */
|
||||||
|
label: string;
|
||||||
|
/** Current generation status */
|
||||||
|
status: ShotStatus;
|
||||||
|
/** Image URL — shown when status is 'complete' */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Error message — shown when status is 'failed' */
|
||||||
|
error?: string;
|
||||||
|
/** Whether the anchor exists (controls if generate button is enabled) */
|
||||||
|
anchorReady?: boolean;
|
||||||
|
/** Called when the user clicks Generate on a pending shot */
|
||||||
|
onGenerate?: () => void;
|
||||||
|
/** Called when the user clicks Regenerate on a complete/failed shot */
|
||||||
|
onRegenerate?: () => void;
|
||||||
|
/** Called when the user clicks Reset (removes the image, back to pending) */
|
||||||
|
onReset?: () => void;
|
||||||
|
/** Called when the user clicks the image (e.g., to open a lightbox) */
|
||||||
|
onImageClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShotCard displays a single shot in an album grid.
|
||||||
|
*
|
||||||
|
* Three visual states:
|
||||||
|
* - pending: Gray placeholder with label and optional Generate button
|
||||||
|
* - generating: Shimmer animation with label
|
||||||
|
* - complete: The generated image with hover controls (Regenerate / Reset)
|
||||||
|
* - failed: Red border with error message and Regenerate button
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ShotCard
|
||||||
|
* label="Headshot"
|
||||||
|
* status={shot.status}
|
||||||
|
* imageUrl={shot.imageUrl}
|
||||||
|
* anchorReady={!!album.anchorUrl}
|
||||||
|
* onGenerate={() => regenerateShot(shot.index)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const ShotCard = React.forwardRef<HTMLDivElement, ShotCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
status,
|
||||||
|
imageUrl,
|
||||||
|
error,
|
||||||
|
anchorReady = false,
|
||||||
|
onGenerate,
|
||||||
|
onRegenerate,
|
||||||
|
onReset,
|
||||||
|
onImageClick,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative aspect-square rounded-lg overflow-hidden group',
|
||||||
|
'border border-[var(--border)]',
|
||||||
|
status === 'failed' && 'border-red-500/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* PENDING state */}
|
||||||
|
{status === 'pending' && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2 bg-[var(--surface-100)] p-3">
|
||||||
|
<span className="text-xs font-medium text-[var(--text-secondary)] text-center leading-tight">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{anchorReady && onGenerate && (
|
||||||
|
<button
|
||||||
|
onClick={onGenerate}
|
||||||
|
className="mt-1 px-2 py-0.5 text-xs rounded bg-[var(--accent)] text-white hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!anchorReady && (
|
||||||
|
<span className="text-[10px] text-[var(--text-tertiary)] text-center">
|
||||||
|
Generate anchor first
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GENERATING state — shimmer */}
|
||||||
|
{status === 'generating' && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2 bg-[var(--surface-100)] p-3">
|
||||||
|
<div className="w-full h-full absolute inset-0 bg-gradient-to-r from-[var(--surface-100)] via-[var(--surface-200)] to-[var(--surface-100)] animate-[shimmer_1.5s_ease-in-out_infinite] bg-[length:200%_100%]" />
|
||||||
|
<style>{`
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<span className="relative z-10 text-xs font-medium text-[var(--text-secondary)] text-center">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* COMPLETE state */}
|
||||||
|
{status === 'complete' && imageUrl && (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={label}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full object-cover',
|
||||||
|
onImageClick && 'cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={onImageClick}
|
||||||
|
/>
|
||||||
|
{/* Hover overlay with label + controls */}
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex flex-col justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="text-xs font-medium text-white drop-shadow">{label}</span>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
{onRegenerate && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRegenerate(); }}
|
||||||
|
className="px-2 py-0.5 text-xs rounded bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onReset && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||||
|
className="px-2 py-0.5 text-xs rounded bg-white/20 text-white hover:bg-white/30 backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAILED state */}
|
||||||
|
{status === 'failed' && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-2 bg-red-50 dark:bg-red-950/20 p-3">
|
||||||
|
<span className="text-xs font-medium text-[var(--text-secondary)] text-center">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{error && (
|
||||||
|
<span className="text-[10px] text-red-500 text-center line-clamp-2" title={error}>
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{onRegenerate && (
|
||||||
|
<button
|
||||||
|
onClick={onRegenerate}
|
||||||
|
className="mt-1 px-2 py-0.5 text-xs rounded bg-red-500 text-white hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
ShotCard.displayName = 'ShotCard';
|
||||||
|
|
||||||
|
export { ShotCard };
|
||||||
@ -36,6 +36,11 @@ export { VideoPlayer, VideoGrid, type VideoPlayerProps, type VideoGridProps } fr
|
|||||||
export { MediaUploader, type MediaUploaderProps } from './components/MediaUploader';
|
export { MediaUploader, type MediaUploaderProps } from './components/MediaUploader';
|
||||||
export { MediaLibrary, type MediaLibraryProps, type MediaItem } from './components/MediaLibrary';
|
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)
|
// Icons (re-export commonly used ones)
|
||||||
export {
|
export {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
// Package album provides types and job handlers for anchor-based image album generation.
|
||||||
|
//
|
||||||
|
// An album is a photography session: one subject, one anchor image, and N shots.
|
||||||
|
// The anchor image establishes visual identity (same face/product/character).
|
||||||
|
// Each shot has its own direction (pose, lighting, setting) but references the anchor.
|
||||||
|
//
|
||||||
|
// Usage (standalone mode):
|
||||||
|
//
|
||||||
|
// albumRepo := memory.NewAlbumRepository()
|
||||||
|
// memQueue.RegisterHandler("generate_anchor", album.AnchorHandler(mg, store, pub, albumRepo, logger))
|
||||||
|
// memQueue.RegisterHandler("generate_shot", album.ShotHandler(mg, store, pub, albumRepo, logger))
|
||||||
|
package album
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbumID is the unique identifier for an album.
|
||||||
|
type AlbumID string
|
||||||
|
|
||||||
|
// Album represents a photography session: one subject, one anchor, N shots.
|
||||||
|
type Album struct {
|
||||||
|
ID AlbumID `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"` // Human label: "Jordan Headshots", "Product Launch Set"
|
||||||
|
SubjectDesc string `json:"subjectDesc"` // Natural language: what/who is being photographed
|
||||||
|
AnchorURL string `json:"anchorUrl"` // Stored anchor image — empty until generated
|
||||||
|
AnchorJobID string `json:"anchorJobId"` // Tracks the anchor generation job
|
||||||
|
Shots []Shot `json:"shots"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shot is one image in an album with its own generation direction.
|
||||||
|
type Shot struct {
|
||||||
|
Index int `json:"index"` // 0-indexed position
|
||||||
|
Label string `json:"label"` // Human label: "Headshot", "Outdoor Casual"
|
||||||
|
Direction string `json:"direction"` // What makes this shot unique
|
||||||
|
ImageURL string `json:"imageUrl"` // Empty until generation completes
|
||||||
|
JobID string `json:"jobId"` // Tracks the async generation job
|
||||||
|
Status ShotStatus `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"` // Set when Status == ShotFailed
|
||||||
|
GeneratedAt *time.Time `json:"generatedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShotStatus represents the generation state of a shot.
|
||||||
|
type ShotStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ShotPending ShotStatus = "pending"
|
||||||
|
ShotGenerating ShotStatus = "generating"
|
||||||
|
ShotComplete ShotStatus = "complete"
|
||||||
|
ShotFailed ShotStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShotTemplate defines a reusable shot direction.
|
||||||
|
// Built-in templates (PortraitSession, ProductShoot, CharacterSheet) ship with the skeleton.
|
||||||
|
// Teams replace them with domain-specific templates for their use case.
|
||||||
|
type ShotTemplate struct {
|
||||||
|
Label string // Human-readable name shown in the UI
|
||||||
|
Direction string // Added to the subject description to guide generation
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AlbumUpdater — minimal interface for job handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// AlbumUpdater is the minimal interface the job handlers need to persist generation results.
|
||||||
|
// The full AlbumRepository (in port package) extends this with CRUD operations.
|
||||||
|
type AlbumUpdater interface {
|
||||||
|
// UpdateAnchor stores the generated anchor URL for an album.
|
||||||
|
UpdateAnchor(ctx context.Context, albumID AlbumID, userID, anchorURL, anchorJobID string) error
|
||||||
|
|
||||||
|
// UpdateShot stores the generated image URL and status for a specific shot.
|
||||||
|
UpdateShot(ctx context.Context, albumID AlbumID, userID string, shotIndex int, imageURL string, status ShotStatus, shotError string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Job payloads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// AnchorPayload is the job payload for "generate_anchor" jobs.
|
||||||
|
// The anchor is generated without a reference image — it IS the reference.
|
||||||
|
type AnchorPayload struct {
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
SubjectDesc string `json:"subjectDesc"` // Natural language description of the subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShotPayload is the job payload for "generate_shot" jobs.
|
||||||
|
// The worker fetches the anchor bytes from AnchorURL at execution time.
|
||||||
|
// Bytes are NOT stored in the queue payload (too large, and URLs are stable).
|
||||||
|
type ShotPayload struct {
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
ShotIndex int `json:"shotIndex"`
|
||||||
|
AnchorURL string `json:"anchorUrl"` // Worker fetches this at execution time
|
||||||
|
SubjectDesc string `json:"subjectDesc"` // Combined with Direction for the final prompt
|
||||||
|
Direction string `json:"direction"` // Shot-specific direction
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSE event type constants for album events
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Album-specific SSE event types.
|
||||||
|
// All delivered on the existing user:<userId> channel.
|
||||||
|
const (
|
||||||
|
EventAlbumAnchorComplete = "album_anchor_complete"
|
||||||
|
EventAlbumAnchorFailed = "album_anchor_failed"
|
||||||
|
EventAlbumShotGenerating = "album_shot_generating"
|
||||||
|
EventAlbumShotComplete = "album_shot_complete"
|
||||||
|
EventAlbumShotFailed = "album_shot_failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlbumAnchorCompleteData is the SSE payload for album_anchor_complete events.
|
||||||
|
type AlbumAnchorCompleteData struct {
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
AnchorURL string `json:"anchorUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumShotCompleteData is the SSE payload for album_shot_complete events.
|
||||||
|
type AlbumShotCompleteData struct {
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
ShotIndex int `json:"shotIndex"`
|
||||||
|
ImageURL string `json:"imageUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumShotFailedData is the SSE payload for album_shot_failed events.
|
||||||
|
type AlbumShotFailedData struct {
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
ShotIndex int `json:"shotIndex"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumAnchorFailedData is the SSE payload for album_anchor_failed events.
|
||||||
|
type AlbumAnchorFailedData struct {
|
||||||
|
AlbumID string `json:"albumId"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
scalargo "github.com/bdpiprava/scalar-go"
|
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")
|
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.
|
// Op creates an OpenAPI operation helper.
|
||||||
func Op(summary, description string, tags ...string) map[string]any {
|
func Op(summary, description string, tags ...string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
"operationId": opSummaryToID(summary),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description,
|
"description": description,
|
||||||
"tags": tags,
|
"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.
|
// OpWithBody creates an OpenAPI operation with a request body.
|
||||||
func OpWithBody(summary, description string, tags ...string) map[string]any {
|
func OpWithBody(summary, description string, tags ...string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
|
"operationId": opSummaryToID(summary),
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description,
|
"description": description,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
|||||||
49
scripts/generate-sdk.sh
Executable file
49
scripts/generate-sdk.sh
Executable file
@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# generate-sdk.sh — Generate the @orchard9/rdev-sdk TypeScript SDK from the embedded OpenAPI spec.
|
||||||
|
# No server, DB, or K8s needed. The spec is exported directly from the Go binary.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$SCRIPT_DIR/.."
|
||||||
|
SDK_DIR="$ROOT/sdk"
|
||||||
|
|
||||||
|
# 1. Check speakeasy is installed
|
||||||
|
if ! command -v speakeasy &>/dev/null; then
|
||||||
|
echo "speakeasy CLI not found. Installing..."
|
||||||
|
if command -v brew &>/dev/null; then
|
||||||
|
brew install speakeasy-api/speakeasy/speakeasy
|
||||||
|
else
|
||||||
|
# Install to ~/.local/bin (no sudo needed)
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/speakeasy-api/speakeasy/main/install.sh \
|
||||||
|
| INSTALL_DIR="$HOME/.local/bin" sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Export the OpenAPI spec (no server needed — pure Go, no external deps)
|
||||||
|
echo "Exporting OpenAPI spec..."
|
||||||
|
go run "$ROOT/cmd/rdev-api" --export-openapi > "$SDK_DIR/openapi.json"
|
||||||
|
echo " -> $SDK_DIR/openapi.json"
|
||||||
|
|
||||||
|
# 3. Validate the spec
|
||||||
|
echo "Validating spec..."
|
||||||
|
speakeasy validate openapi -s "$SDK_DIR/openapi.json"
|
||||||
|
|
||||||
|
# 4. Generate the TypeScript SDK
|
||||||
|
echo "Generating TypeScript SDK..."
|
||||||
|
speakeasy generate sdk \
|
||||||
|
--schema "$SDK_DIR/openapi.json" \
|
||||||
|
--lang typescript \
|
||||||
|
--out "$SDK_DIR/typescript" \
|
||||||
|
--config "$SDK_DIR/.speakeasy/gen.yaml"
|
||||||
|
|
||||||
|
# 5. Install dependencies and build
|
||||||
|
echo "Building SDK..."
|
||||||
|
cd "$SDK_DIR/typescript"
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! SDK generated at sdk/typescript/"
|
||||||
|
echo "Test with: cd sdk/typescript && npm pack --dry-run"
|
||||||
12
sdk/.speakeasy/gen.yaml
Normal file
12
sdk/.speakeasy/gen.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
configVersion: 2.0.0
|
||||||
|
generation:
|
||||||
|
sdkClassName: Rdev
|
||||||
|
maintainOpenAPIOrder: true
|
||||||
|
usageSnippets:
|
||||||
|
optionalPropertyRendering: withExample
|
||||||
|
typescript:
|
||||||
|
version: 0.1.0
|
||||||
|
author: orchard9
|
||||||
|
description: TypeScript SDK for the rdev Remote Developer API (threesix.ai)
|
||||||
|
packageName: "@orchard9/rdev-sdk"
|
||||||
|
clientServerStatusCodesAsErrors: true
|
||||||
82
sdk/README.md
Normal file
82
sdk/README.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# @orchard9/rdev-sdk
|
||||||
|
|
||||||
|
TypeScript SDK for the [rdev Remote Developer API](https://rdev.masq-ops.orchard9.ai/docs) — run Claude Code instances in isolated Kubernetes pods via REST.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.25+ (to export the spec)
|
||||||
|
- [Speakeasy CLI](https://www.speakeasy.com/docs/speakeasy-cli/getting-started)
|
||||||
|
- Node.js 20+
|
||||||
|
|
||||||
|
## Regenerating the SDK
|
||||||
|
|
||||||
|
The SDK is generated from the live OpenAPI spec embedded in the rdev binary. No server required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/generate-sdk.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Export the OpenAPI spec from the Go binary (pure, no DB/K8s needed)
|
||||||
|
2. Validate the spec with Speakeasy
|
||||||
|
3. Generate the TypeScript SDK into `sdk/typescript/`
|
||||||
|
4. Build and type-check the SDK
|
||||||
|
|
||||||
|
The generated `sdk/openapi.json` is gitignored (regenerated each time). The `sdk/typescript/` output is committed.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Until published to npm, install directly from git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install github:orchard9/rdev#main --workspace sdk/typescript
|
||||||
|
```
|
||||||
|
|
||||||
|
Or copy `sdk/typescript/` into your project.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Rdev } from "@orchard9/rdev-sdk";
|
||||||
|
|
||||||
|
const client = new Rdev({
|
||||||
|
apiKey: process.env.RDEV_API_KEY,
|
||||||
|
serverURL: "https://rdev.masq-ops.orchard9.ai",
|
||||||
|
});
|
||||||
|
|
||||||
|
// List projects
|
||||||
|
const projects = await client.projects.list();
|
||||||
|
console.log(projects);
|
||||||
|
|
||||||
|
// Run a Claude command
|
||||||
|
const cmd = await client.projects.runClaude("my-project", {
|
||||||
|
prompt: "fix the bug in auth handler",
|
||||||
|
});
|
||||||
|
console.log(cmd.streamUrl);
|
||||||
|
|
||||||
|
// Stream events
|
||||||
|
const events = new EventSource(
|
||||||
|
`https://rdev.masq-ops.orchard9.ai${cmd.streamUrl}`,
|
||||||
|
{ headers: { "X-API-Key": process.env.RDEV_API_KEY } }
|
||||||
|
);
|
||||||
|
events.addEventListener("complete", (e) => {
|
||||||
|
console.log("Done:", JSON.parse(e.data));
|
||||||
|
events.close();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
All endpoints (except `/health`, `/ready`, `/docs`) require an API key.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = new Rdev({
|
||||||
|
apiKey: "rdev_sk_xxxxxxxx_...",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set `RDEV_API_KEY` environment variable.
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
See the [API docs](https://rdev.masq-ops.orchard9.ai/docs) for full endpoint documentation.
|
||||||
Loading…
Reference in New Issue
Block a user