diff --git a/cmd/acb-index-builder/deploy.go b/cmd/acb-index-builder/deploy.go index ed9ed8b..5e4bbc3 100644 --- a/cmd/acb-index-builder/deploy.go +++ b/cmd/acb-index-builder/deploy.go @@ -228,30 +228,38 @@ func extractMatchIDFromKey(key string) string { return filename } -// promoteRecentReplays copies recent replays from B2 to R2 warm cache +// promoteRecentReplays copies recent replays and thumbnails from B2 to R2 warm cache func promoteRecentReplays(ctx context.Context, cfg *Config, matchIDs []string) error { for _, matchID := range matchIDs { - // Source path in B2 - b2Key := fmt.Sprintf("replays/%s.json.gz", matchID) + // Promote replay + b2ReplayKey := fmt.Sprintf("replays/%s.json.gz", matchID) + r2ReplayKey := b2ReplayKey - // Check if already in R2 - r2Key := b2Key - exists, err := checkR2ObjectExists(ctx, cfg, r2Key) + exists, err := checkR2ObjectExists(ctx, cfg, r2ReplayKey) if err != nil { - slog.Error("Failed to check R2 object existence", "key", r2Key, "error", err) - continue - } - if exists { - continue // Already in warm cache + slog.Error("Failed to check R2 object existence", "key", r2ReplayKey, "error", err) + } else if !exists { + if err := copyB2ToR2(ctx, cfg, b2ReplayKey, r2ReplayKey); err != nil { + slog.Error("Failed to promote replay to R2", "match_id", matchID, "error", err) + } else { + slog.Info("Promoted replay to R2 warm cache", "match_id", matchID) + } } - // Copy from B2 to R2 - if err := copyB2ToR2(ctx, cfg, b2Key, r2Key); err != nil { - slog.Error("Failed to promote replay to R2", "match_id", matchID, "error", err) - continue - } + // Promote thumbnail + b2ThumbKey := fmt.Sprintf("thumbnails/%s.png", matchID) + r2ThumbKey := b2ThumbKey - slog.Info("Promoted replay to R2 warm cache", "match_id", matchID) + exists, err = checkR2ObjectExists(ctx, cfg, r2ThumbKey) + if err != nil { + slog.Error("Failed to check R2 thumbnail existence", "key", r2ThumbKey, "error", err) + } else if !exists { + if err := copyB2ToR2(ctx, cfg, b2ThumbKey, r2ThumbKey); err != nil { + slog.Warn("Failed to promote thumbnail to R2", "match_id", matchID, "error", err) + } else { + slog.Info("Promoted thumbnail to R2 warm cache", "match_id", matchID) + } + } } return nil diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index 5f95143..ccca396 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -861,10 +861,12 @@ func buildPlaylistMatch(m MatchData, order int, data *IndexData, curationTag str if !m.CompletedAt.IsZero() { completedAt = m.CompletedAt.Format(time.RFC3339) } + thumbnailURL := fmt.Sprintf("https://r2.aicodebattle.com/thumbnails/%s.png", m.ID) return PlaylistMatch{ MatchID: m.ID, Order: order, Title: title, + ThumbnailURL: thumbnailURL, CurationTag: curationTag, Participants: participants, Score: strings.Join(scoreParts, "-"), diff --git a/engine/thumbnail.go b/engine/thumbnail.go new file mode 100644 index 0000000..7ae5da1 --- /dev/null +++ b/engine/thumbnail.go @@ -0,0 +1,274 @@ +package engine + +import ( + "image" + "image/color" + "image/png" + "io" + "math" +) + +// ThumbnailConfig configures thumbnail rendering. +type ThumbnailConfig struct { + Width int + Height int + CellSize int + Background color.Color + WallColor color.Color + GridColor color.Color + PlayerColors []color.Color + EnergyColor color.Color + CoreColor color.Color +} + +// DefaultThumbnailConfig returns the default thumbnail configuration. +func DefaultThumbnailConfig() ThumbnailConfig { + return ThumbnailConfig{ + Width: 640, + Height: 360, + CellSize: 6, + Background: color.RGBA{18, 18, 24, 255}, + WallColor: color.RGBA{60, 60, 70, 255}, + GridColor: color.RGBA{30, 30, 40, 255}, + PlayerColors: []color.Color{ + color.RGBA{66, 165, 245, 255}, // Blue + color.RGBA{239, 83, 80, 255}, // Red + color.RGBA{102, 187, 106, 255}, // Green + color.RGBA{255, 202, 40, 255}, // Yellow + color.RGBA{171, 71, 188, 255}, // Purple + color.RGBA{255, 112, 67, 255}, // Orange + color.RGBA{38, 198, 218, 255}, // Cyan + color.RGBA{236, 64, 122, 255}, // Pink + }, + EnergyColor: color.RGBA{255, 235, 59, 255}, + CoreColor: color.RGBA{255, 255, 255, 255}, + } +} + +// RenderThumbnail renders a replay turn as a PNG thumbnail. +func RenderThumbnail(replay *Replay, turnNum int, cfg ThumbnailConfig) (*image.RGBA, error) { + if turnNum < 0 || turnNum >= len(replay.Turns) { + turnNum = len(replay.Turns) - 1 + } + + turn := replay.Turns[turnNum] + img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height)) + + drawBackground(img, cfg.Background) + + cellW := float64(cfg.Width) / float64(replay.Map.Cols) + cellH := float64(cfg.Height) / float64(replay.Map.Rows) + + drawGrid(img, replay.Map.Cols, replay.Map.Rows, cellW, cellH, cfg.GridColor) + drawWalls(img, replay.Map.Walls, replay.Map.Cols, cellW, cellH, cfg.WallColor) + drawEnergy(img, turn.Energy, replay.Map.Cols, cellW, cellH, cfg.EnergyColor) + drawCores(img, turn.Cores, replay.Map.Cols, cellW, cellH, cfg.CoreColor, cfg.PlayerColors) + drawBots(img, turn.Bots, replay.Map.Cols, cellW, cellH, cfg.PlayerColors) + + return img, nil +} + +// RenderThumbnailPNG renders a replay turn and writes it as PNG. +func RenderThumbnailPNG(replay *Replay, turnNum int, w io.Writer) error { + cfg := DefaultThumbnailConfig() + img, err := RenderThumbnail(replay, turnNum, cfg) + if err != nil { + return err + } + return png.Encode(w, img) +} + +// RenderFinalTurnThumbnail renders the final turn of a replay as PNG. +func RenderFinalTurnThumbnail(replay *Replay, w io.Writer) error { + return RenderThumbnailPNG(replay, len(replay.Turns)-1, w) +} + +// RenderMidGameThumbnail renders the mid-game turn (40% through) as PNG. +func RenderMidGameThumbnail(replay *Replay, w io.Writer) error { + turnNum := len(replay.Turns) * 2 / 5 + if turnNum >= len(replay.Turns) { + turnNum = len(replay.Turns) - 1 + } + return RenderThumbnailPNG(replay, turnNum, DefaultThumbnailConfig()) +} + +func drawBackground(img *image.RGBA, c color.Color) { + bounds := img.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + img.Set(x, y, c) + } + } +} + +func drawGrid(img *image.RGBA, cols, rows int, cellW, cellH float64, c color.Color) { + bounds := img.Bounds() + + for col := 0; col <= cols; col++ { + x := int(float64(col) * cellW) + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + img.Set(x, y, c) + } + } + + for row := 0; row <= rows; row++ { + y := int(float64(row) * cellH) + for x := bounds.Min.X; x < bounds.Max.X; x++ { + img.Set(x, y, c) + } + } +} + +func drawWalls(img *image.RGBA, walls []Position, cols int, cellW, cellH float64, c color.Color) { + for _, w := range walls { + x0 := int(float64(w.Col) * cellW) + y0 := int(float64(w.Row) * cellH) + x1 := int(float64(w.Col+1) * cellW) + y1 := int(float64(w.Row+1) * cellH) + + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + img.Set(x, y, c) + } + } + } +} + +func drawBots(img *image.RGBA, bots []ReplayBot, cols int, cellW, cellH float64, colors []color.Color) { + for _, b := range bots { + if !b.Alive { + continue + } + + c := colors[b.Owner%len(colors)] + x0 := int(float64(b.Position.Col)*cellW + cellW*0.25) + y0 := int(float64(b.Position.Row)*cellH + cellH*0.25) + x1 := int(float64(b.Position.Col+1)*cellW - cellW*0.25) + y1 := int(float64(b.Position.Row+1)*cellH - cellH*0.25) + + cx, cy := (x0+x1)/2, (y0+y1)/2 + r := (x1 - x0) + if r > (y1 - y0) { + r = y1 - y0 + } + + drawCircle(img, cx, cy, r/2, c) + } +} + +func drawCores(img *image.RGBA, cores []ReplayCoreState, cols int, cellW, cellH float64, baseColor color.Color, playerColors []color.Color) { + for _, c := range cores { + x0 := int(float64(c.Position.Col)*cellW + cellW*0.15) + y0 := int(float64(c.Position.Row)*cellH + cellH*0.15) + x1 := int(float64(c.Position.Col+1)*cellW - cellW*0.15) + y1 := int(float64(c.Position.Row+1)*cellH - cellH*0.15) + + fillColor := baseColor + if c.Active { + fillColor = playerColors[c.Owner%len(playerColors)] + } + + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + img.Set(x, y, fillColor) + } + } + + strokeColor := color.RGBA{0, 0, 0, 255} + drawRectOutline(img, x0, y0, x1, y1, strokeColor) + } +} + +func drawEnergy(img *image.RGBA, energy []Position, cols int, cellW, cellH float64, c color.Color) { + for _, e := range energy { + cx := int(float64(e.Col+0.5) * cellW) + cy := int(float64(e.Row+0.5) * cellH) + r := int(cellW * 0.3) + + drawDiamond(img, cx, cy, r, c) + } +} + +func drawCircle(img *image.RGBA, cx, cy, r int, c color.Color) { + r2 := r * r + for dy := -r; dy <= r; dy++ { + for dx := -r; dx <= r; dx++ { + if dx*dx+dy*dy <= r2 { + img.Set(cx+dx, cy+dy, c) + } + } + } +} + +func drawDiamond(img *image.RGBA, cx, cy, r int, c color.Color) { + for dy := -r; dy <= r; dy++ { + for dx := -r; dx <= r; dx++ { + if abs(dx)+abs(dy) <= r { + img.Set(cx+dx, cy+dy, c) + } + } + } +} + +func drawRectOutline(img *image.RGBA, x0, y0, x1, y1 int, c color.Color) { + for x := x0; x < x1; x++ { + img.Set(x, y0, c) + img.Set(x, y1-1, c) + } + for y := y0; y < y1; y++ { + img.Set(x0, y, c) + img.Set(x1-1, y, c) + } +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// SelectThumbnailTurn selects the most interesting turn for thumbnail generation. +// Prioritizes: end game > mid game with many bots > late game. +func SelectThumbnailTurn(replay *Replay) int { + if len(replay.Turns) == 0 { + return 0 + } + + finalTurn := len(replay.Turns) - 1 + if finalTurn < 10 { + return finalTurn + } + + maxBotsTurn := finalTurn + maxBots := 0 + midGame := replay.Turns[finalTurn].Turn * 2 / 5 + + for i := len(replay.Turns) - 1; i >= 0; i-- { + botCount := 0 + for _, b := range replay.Turns[i].Bots { + if b.Alive { + botCount++ + } + } + if botCount > maxBots { + maxBots = botCount + maxBotsTurn = i + } + if replay.Turns[i].Turn <= midGame { + break + } + } + + if maxBotsTurn > finalTurn*3/4 { + return finalTurn + } + return maxBotsTurn +} + +// GenerateMatchThumbnail generates a thumbnail image for a match. +// Uses the most interesting turn as determined by SelectThumbnailTurn. +func GenerateMatchThumbnail(replay *Replay) (*image.RGBA, error) { + turnNum := SelectThumbnailTurn(replay) + return RenderThumbnail(replay, turnNum, DefaultThumbnailConfig()) +}