package album import ( "context" "fmt" "log/slog" "net/http" "time" "git.threesix.ai/jordan/persona-community-5/pkg/logging" "git.threesix.ai/jordan/persona-community-5/pkg/mediagen" "git.threesix.ai/jordan/persona-community-5/pkg/queue" "git.threesix.ai/jordan/persona-community-5/pkg/realtime" "git.threesix.ai/jordan/persona-community-5/pkg/storage" ) // 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 { if 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 } } // Save caption alongside the image regardless of whether bytes were uploaded. // This ensures we always record what generated the image (URL-only providers included). if anchorURL != "" { captionPath := fmt.Sprintf("albums/%s/%s/anchor.caption", userID, albumID) if _, captionErr := store.Upload(ctx, captionPath, []byte(subjectDesc), "text/plain"); captionErr != nil { logger.Warn("failed to persist anchor caption", "error", captionErr, "job_id", job.ID) } } } 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. const anchorMaxBytes = 20 << 20 // 20 MB — anchor images should be small PNGs var anchorBytes []byte var anchorMime string if anchorURL != "" { data, err := storage.FetchURL(ctx, httpClient, anchorURL, anchorMaxBytes) 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 { if 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 } } // Save caption alongside the image regardless of whether bytes were uploaded. if imageURL != "" { captionPath := fmt.Sprintf("albums/%s/%s/shots/%d.caption", userID, albumID, shotIndex) if _, captionErr := store.Upload(ctx, captionPath, []byte(prompt), "text/plain"); captionErr != nil { logger.Warn("failed to persist shot caption", "error", captionErr, "job_id", job.ID) } } } // 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 } }