130 lines
3.4 KiB
Go
130 lines
3.4 KiB
Go
// Package media compresses images (stdlib + x/image) and videos (ffmpeg) for bot pipelines.
|
|
// Deploy note: video compression requires the `ffmpeg` binary on PATH (or Options.FFmpegPath).
|
|
package media
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif" // register GIF decoder
|
|
"image/jpeg"
|
|
"image/png"
|
|
"strings"
|
|
|
|
"golang.org/x/image/draw"
|
|
|
|
pkgmedia "spark-server/internal/pkg/media"
|
|
)
|
|
|
|
// Options configures compressors.
|
|
type Options struct {
|
|
Limits pkgmedia.Limits
|
|
JPEGQuality int
|
|
VideoCRF int
|
|
FFmpegPath string // default "ffmpeg"
|
|
}
|
|
|
|
func (o *Options) normalize() {
|
|
o.Limits = o.Limits.Normalize()
|
|
if o.JPEGQuality <= 0 || o.JPEGQuality > 100 {
|
|
o.JPEGQuality = pkgmedia.DefaultJPEGQuality
|
|
}
|
|
if o.VideoCRF <= 0 {
|
|
o.VideoCRF = pkgmedia.DefaultVideoCRF
|
|
}
|
|
if strings.TrimSpace(o.FFmpegPath) == "" {
|
|
o.FFmpegPath = "ffmpeg"
|
|
}
|
|
}
|
|
|
|
// ImageResult is compressed image output.
|
|
type ImageResult struct {
|
|
Data []byte
|
|
Width int
|
|
Height int
|
|
MIME string
|
|
}
|
|
|
|
// CompressImage scales longest edge to ≤ Limits.ImageMaxEdge and re-encodes as JPEG (PNG in → JPEG out unless already small PNG kept as PNG when no resize needed and source is PNG).
|
|
func CompressImage(src []byte, opt *Options) (*ImageResult, error) {
|
|
if opt == nil {
|
|
return nil, fmt.Errorf("media: nil options")
|
|
}
|
|
opt.normalize()
|
|
if len(src) == 0 {
|
|
return nil, fmt.Errorf("media: empty image")
|
|
}
|
|
img, format, err := image.Decode(bytes.NewReader(src))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("media: decode image: %w", err)
|
|
}
|
|
b := img.Bounds()
|
|
w, h := b.Dx(), b.Dy()
|
|
maxEdge := opt.Limits.ImageMaxEdge
|
|
if !pkgmedia.NeedsResize(w, h, maxEdge) && int64(len(src)) <= opt.Limits.ImageMaxBytes {
|
|
mime := mimeForFormat(format)
|
|
return &ImageResult{Data: src, Width: w, Height: h, MIME: mime}, nil
|
|
}
|
|
nw, nh := scaleToMax(w, h, maxEdge)
|
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: opt.JPEGQuality}); err != nil {
|
|
return nil, fmt.Errorf("media: encode jpeg: %w", err)
|
|
}
|
|
out := buf.Bytes()
|
|
if int64(len(out)) > opt.Limits.ImageMaxBytes {
|
|
return nil, fmt.Errorf("media: compressed image still too large (%d > %d)", len(out), opt.Limits.ImageMaxBytes)
|
|
}
|
|
return &ImageResult{Data: out, Width: nw, Height: nh, MIME: "image/jpeg"}, nil
|
|
}
|
|
|
|
// CompressAvatar scales to ≤ AvatarMaxEdge and JPEG-encodes.
|
|
func CompressAvatar(src []byte, opt *Options) (*ImageResult, error) {
|
|
if opt == nil {
|
|
return nil, fmt.Errorf("media: nil options")
|
|
}
|
|
opt.normalize()
|
|
lim := opt.Limits
|
|
lim.ImageMaxEdge = lim.AvatarMaxEdge
|
|
lim.ImageMaxBytes = lim.AvatarMaxBytes
|
|
opt.Limits = lim
|
|
return CompressImage(src, opt)
|
|
}
|
|
|
|
func scaleToMax(w, h, maxEdge int) (int, int) {
|
|
if maxEdge <= 0 || !pkgmedia.NeedsResize(w, h, maxEdge) {
|
|
return w, h
|
|
}
|
|
edge := pkgmedia.LongestEdge(w, h)
|
|
nw := w * maxEdge / edge
|
|
nh := h * maxEdge / edge
|
|
if nw < 1 {
|
|
nw = 1
|
|
}
|
|
if nh < 1 {
|
|
nh = 1
|
|
}
|
|
return nw, nh
|
|
}
|
|
|
|
func mimeForFormat(format string) string {
|
|
switch strings.ToLower(format) {
|
|
case "png":
|
|
return "image/png"
|
|
case "gif":
|
|
return "image/gif"
|
|
default:
|
|
return "image/jpeg"
|
|
}
|
|
}
|
|
|
|
// EncodePNG is a test helper / bot helper for synthetic images.
|
|
func EncodePNG(img image.Image) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, img); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|