112 lines
3.2 KiB
Go
112 lines
3.2 KiB
Go
// Package collage composes poster collage images from multiple source images.
|
||
package collage
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"image"
|
||
"image/color"
|
||
"image/jpeg"
|
||
_ "image/png"
|
||
|
||
"golang.org/x/image/draw"
|
||
_ "golang.org/x/image/webp"
|
||
)
|
||
|
||
// ErrNotEnoughImages is returned when no source images are usable.
|
||
var ErrNotEnoughImages = errors.New("collage: no usable source images")
|
||
|
||
const (
|
||
posterWidth = 600
|
||
posterHeight = 900
|
||
jpegQuality = 92
|
||
)
|
||
|
||
// ComposePoster builds a poster collage from raw image bytes.
|
||
// Returns JPEG bytes suitable for feeding into processCollectionImage.
|
||
//
|
||
// Layout:
|
||
// - 1 image: scale-fill the full canvas
|
||
// - 2-3 images: 2 side-by-side columns
|
||
// - 4+ images: 2×2 grid (first 4 used)
|
||
func ComposePoster(images [][]byte) ([]byte, error) {
|
||
decoded := make([]image.Image, 0, len(images))
|
||
for _, data := range images {
|
||
img, _, err := image.Decode(bytes.NewReader(data))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
decoded = append(decoded, img)
|
||
}
|
||
if len(decoded) == 0 {
|
||
return nil, ErrNotEnoughImages
|
||
}
|
||
if len(decoded) > 4 {
|
||
decoded = decoded[:4]
|
||
}
|
||
|
||
canvas := image.NewNRGBA(image.Rect(0, 0, posterWidth, posterHeight))
|
||
|
||
// Fill with dark background in case images don't cover fully.
|
||
for y := range posterHeight {
|
||
for x := range posterWidth {
|
||
canvas.SetNRGBA(x, y, color.NRGBA{R: 24, G: 24, B: 27, A: 255})
|
||
}
|
||
}
|
||
|
||
switch len(decoded) {
|
||
case 1:
|
||
scaleFill(canvas, decoded[0], image.Rect(0, 0, posterWidth, posterHeight))
|
||
case 2, 3:
|
||
halfW := posterWidth / 2
|
||
scaleFill(canvas, decoded[0], image.Rect(0, 0, halfW, posterHeight))
|
||
scaleFill(canvas, decoded[1], image.Rect(halfW, 0, posterWidth, posterHeight))
|
||
default:
|
||
halfW := posterWidth / 2
|
||
halfH := posterHeight / 2
|
||
scaleFill(canvas, decoded[0], image.Rect(0, 0, halfW, halfH))
|
||
scaleFill(canvas, decoded[1], image.Rect(halfW, 0, posterWidth, halfH))
|
||
scaleFill(canvas, decoded[2], image.Rect(0, halfH, halfW, posterHeight))
|
||
scaleFill(canvas, decoded[3], image.Rect(halfW, halfH, posterWidth, posterHeight))
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
// scaleFill scales src to cover dst completely (crop-to-fill), centering the
|
||
// source image so excess is cropped equally from both sides.
|
||
func scaleFill(dst *image.NRGBA, src image.Image, rect image.Rectangle) {
|
||
dstW := rect.Dx()
|
||
dstH := rect.Dy()
|
||
srcBounds := src.Bounds()
|
||
srcW := srcBounds.Dx()
|
||
srcH := srcBounds.Dy()
|
||
|
||
// Compute scale factor to cover the destination rectangle.
|
||
scaleX := float64(dstW) / float64(srcW)
|
||
scaleY := float64(dstH) / float64(srcH)
|
||
scale := scaleX
|
||
if scaleY > scaleX {
|
||
scale = scaleY
|
||
}
|
||
|
||
// Scaled source dimensions.
|
||
scaledW := int(float64(srcW) * scale)
|
||
scaledH := int(float64(srcH) * scale)
|
||
|
||
// Compute crop offset to center.
|
||
offsetX := (scaledW - dstW) / 2
|
||
offsetY := (scaledH - dstH) / 2
|
||
|
||
// Scale the full source into a temporary image, then copy the centered region.
|
||
tmp := image.NewNRGBA(image.Rect(0, 0, scaledW, scaledH))
|
||
draw.BiLinear.Scale(tmp, tmp.Bounds(), src, srcBounds, draw.Over, nil)
|
||
|
||
// Copy the centered crop into the destination rectangle.
|
||
draw.Copy(dst, rect.Min, tmp, image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH), draw.Over, nil)
|
||
}
|