persona-community-5/pkg/album/handler.go
jordan bd2f591b98
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-24 07:39:46 +00:00

263 lines
8.9 KiB
Go

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
}
}