556 lines
15 KiB
Go
556 lines
15 KiB
Go
package img
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/disintegration/imaging"
|
|
)
|
|
|
|
// ResizeMode controls how the source image is mapped to the target WxH.
|
|
//
|
|
// - ResizeModeFit: contain (keep aspect), no cropping; resulting image may be smaller than WxH
|
|
// - ResizeModeFill: cover + crop (center crop) to exactly WxH
|
|
// - ResizeModePad: keep aspect + pad (letterbox) to exactly WxH (no crop, no stretch)
|
|
//
|
|
// If you only pass a single dimension (e.g. W>0, H=0), Fit will keep aspect.
|
|
// Fill/Pad requires both W and H.
|
|
//
|
|
// Note: GIF input will be processed as a single frame (first frame).
|
|
// If you need animated GIF support, we can extend this later.
|
|
type ResizeMode string
|
|
|
|
const (
|
|
ResizeModeFit ResizeMode = "fit"
|
|
ResizeModeFill ResizeMode = "fill"
|
|
// ResizeModePad keeps aspect ratio (no crop, no stretch) and pads to exactly WxH.
|
|
// Typical use: 16:9 -> 1024x1024 with white/black bars.
|
|
ResizeModePad ResizeMode = "pad"
|
|
)
|
|
|
|
type Options struct {
|
|
TargetWidth int
|
|
TargetHeight int
|
|
MaxSizeMB float64
|
|
|
|
Mode ResizeMode
|
|
|
|
// PadColor is used only when Mode == ResizeModePad.
|
|
// Default: white.
|
|
PadColor color.Color
|
|
|
|
// OutputDir is where the processed image will be written.
|
|
// Default: ./runtime/tmp/images/processed
|
|
OutputDir string
|
|
|
|
// Timeout controls total download time.
|
|
// Default: 30s.
|
|
Timeout time.Duration
|
|
|
|
// MaxDownloadMB caps download size to protect memory/disk.
|
|
// Default: max( maxSizeMB*6, 30MB ).
|
|
MaxDownloadMB float64
|
|
|
|
// MinQuality is the minimum JPEG quality to try (1-100).
|
|
// Default: 10. If compression still can't meet MaxSizeMB, returns error.
|
|
MinQuality int
|
|
|
|
// MaxRetries controls how many quality adjustments to attempt.
|
|
// Default: 15.
|
|
MaxRetries int
|
|
|
|
// AllowResolutionReduction allows reducing resolution further if compression fails.
|
|
// Default: false.
|
|
AllowResolutionReduction bool
|
|
|
|
// ForceJPEG forces output to JPEG format even if input is PNG/GIF/etc.
|
|
// If image has alpha channel, it will be flattened to white background.
|
|
// Default: false.
|
|
ForceJPEG bool
|
|
}
|
|
|
|
// ProcessFromURL downloads an image from imgURL, resizes it, compresses it to <= MaxSizeMB,
|
|
// and returns the local output path.
|
|
//
|
|
// Contract:
|
|
// - imgURL: http/https
|
|
// - TargetWidth/TargetHeight: pixels, >=0
|
|
// - MaxSizeMB: >0
|
|
// - returns: absolute or relative file path (depending on OutputDir), and error
|
|
func ProcessFromURL(imgURL string, opt Options) (string, error) {
|
|
if strings.TrimSpace(imgURL) == "" {
|
|
return "", errors.New("imgURL is empty")
|
|
}
|
|
u, err := url.Parse(imgURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return "", errors.New("imgURL must be http/https")
|
|
}
|
|
if opt.MaxSizeMB <= 0 {
|
|
return "", errors.New("MaxSizeMB must be > 0")
|
|
}
|
|
if opt.TargetWidth < 0 || opt.TargetHeight < 0 {
|
|
return "", errors.New("TargetWidth/TargetHeight must be >= 0")
|
|
}
|
|
if opt.TargetWidth == 0 && opt.TargetHeight == 0 {
|
|
return "", errors.New("TargetWidth and TargetHeight cannot both be 0")
|
|
}
|
|
if opt.Mode == "" {
|
|
opt.Mode = ResizeModeFit
|
|
}
|
|
if opt.Mode != ResizeModeFit && opt.Mode != ResizeModeFill && opt.Mode != ResizeModePad {
|
|
return "", errors.New("invalid resize Mode")
|
|
}
|
|
if (opt.Mode == ResizeModeFill || opt.Mode == ResizeModePad) && (opt.TargetWidth == 0 || opt.TargetHeight == 0) {
|
|
return "", errors.New("fill/pad mode requires both TargetWidth and TargetHeight")
|
|
}
|
|
if opt.Timeout <= 0 {
|
|
opt.Timeout = 30 * time.Second
|
|
}
|
|
if opt.OutputDir == "" {
|
|
opt.OutputDir = filepath.FromSlash("./runtime/tmp/images/processed")
|
|
}
|
|
if opt.MaxDownloadMB <= 0 {
|
|
opt.MaxDownloadMB = opt.MaxSizeMB * 6
|
|
if opt.MaxDownloadMB < 30 {
|
|
opt.MaxDownloadMB = 30
|
|
}
|
|
if opt.MaxDownloadMB > 200 {
|
|
opt.MaxDownloadMB = 200
|
|
}
|
|
}
|
|
if opt.MinQuality <= 0 {
|
|
opt.MinQuality = 10
|
|
}
|
|
if opt.MinQuality > 100 {
|
|
opt.MinQuality = 100
|
|
}
|
|
if opt.MaxRetries <= 0 {
|
|
opt.MaxRetries = 15
|
|
}
|
|
|
|
// Ensure output dir exists.
|
|
if err := os.MkdirAll(opt.OutputDir, os.ModePerm); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), opt.Timeout)
|
|
defer cancel()
|
|
|
|
data, contentType, err := downloadWithLimit(ctx, imgURL, int64(opt.MaxDownloadMB*1024*1024))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
img, fmtName, err := decodeImage(data, contentType)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Check if image already meets requirements
|
|
if alreadyMeetsRequirements(img, data, opt) {
|
|
return "", nil
|
|
}
|
|
|
|
img = applyResize(img, opt)
|
|
|
|
// Determine output format
|
|
outExt := ".jpg"
|
|
outFormat := "jpeg"
|
|
|
|
if opt.ForceJPEG {
|
|
// Force JPEG output, flatten alpha if needed
|
|
outFormat = "jpeg"
|
|
outExt = ".jpg"
|
|
if imageHasAlpha(img) {
|
|
img = flattenToWhite(img)
|
|
}
|
|
} else {
|
|
// Auto-detect: if image has alpha, output PNG; otherwise JPEG
|
|
hasAlpha := imageHasAlpha(img)
|
|
if hasAlpha {
|
|
outExt = ".png"
|
|
outFormat = "png"
|
|
}
|
|
}
|
|
|
|
// Create a stable file name based on url + options + content signature.
|
|
h := sha256.New()
|
|
h.Write([]byte(imgURL))
|
|
h.Write([]byte(fmt.Sprintf("|%d|%d|%f|%s|%s", opt.TargetWidth, opt.TargetHeight, opt.MaxSizeMB, opt.Mode, outFormat)))
|
|
h.Write(data[:minInt(len(data), 1024)])
|
|
name := hex.EncodeToString(h.Sum(nil))[:32] + outExt
|
|
outPath := filepath.Join(opt.OutputDir, name)
|
|
|
|
maxBytes := int64(opt.MaxSizeMB * 1024 * 1024)
|
|
encoded, err := encodeUnderSize(img, outFormat, maxBytes, opt.MinQuality, opt.MaxRetries)
|
|
if err != nil {
|
|
// Fallback: if PNG can't fit, try JPEG (dropping alpha with white background).
|
|
if outFormat == "png" {
|
|
flattened := flattenToWhite(img)
|
|
encoded, err = encodeUnderSize(flattened, "jpeg", maxBytes, opt.MinQuality, opt.MaxRetries)
|
|
if err == nil {
|
|
outPath = strings.TrimSuffix(outPath, filepath.Ext(outPath)) + ".jpg"
|
|
} else {
|
|
// If AllowResolutionReduction is true, try reducing resolution
|
|
if opt.AllowResolutionReduction {
|
|
return tryWithReducedResolution(img, opt, outPath, maxBytes)
|
|
}
|
|
return "", err
|
|
}
|
|
} else {
|
|
// If AllowResolutionReduction is true, try reducing resolution
|
|
if opt.AllowResolutionReduction {
|
|
return tryWithReducedResolution(img, opt, outPath, maxBytes)
|
|
}
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
tmpPath := outPath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, encoded, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Rename(tmpPath, outPath); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return "", err
|
|
}
|
|
|
|
_ = fmtName // reserved for future behavior
|
|
return outPath, nil
|
|
}
|
|
|
|
func downloadWithLimit(ctx context.Context, imgURL string, maxBytes int64) ([]byte, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imgURL, nil)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
// Reasonable defaults; could be overridden later.
|
|
req.Header.Set("User-Agent", "fonchain-tools/1.0")
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, "", fmt.Errorf("download failed: %s", resp.Status)
|
|
}
|
|
if resp.ContentLength > 0 && resp.ContentLength > maxBytes {
|
|
return nil, "", fmt.Errorf("remote file too large: %d > %d", resp.ContentLength, maxBytes)
|
|
}
|
|
|
|
lr := &io.LimitedReader{R: resp.Body, N: maxBytes + 1}
|
|
b, err := io.ReadAll(lr)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if int64(len(b)) > maxBytes {
|
|
return nil, "", fmt.Errorf("download exceeded limit: %d > %d", len(b), maxBytes)
|
|
}
|
|
return b, resp.Header.Get("Content-Type"), nil
|
|
}
|
|
|
|
func decodeImage(data []byte, contentType string) (image.Image, string, error) {
|
|
// Register gif (jpeg/png already registered) for Decode.
|
|
_ = gif.GIF{}
|
|
reader := bytes.NewReader(data)
|
|
img, fmtName, err := image.Decode(reader)
|
|
if err == nil {
|
|
return img, fmtName, nil
|
|
}
|
|
|
|
// Some servers set odd content-type; try simple sniff.
|
|
_ = contentType
|
|
return nil, "", fmt.Errorf("decode image failed: %w", err)
|
|
}
|
|
|
|
func applyResize(img image.Image, opt Options) image.Image {
|
|
w := opt.TargetWidth
|
|
h := opt.TargetHeight
|
|
|
|
srcW := img.Bounds().Dx()
|
|
srcH := img.Bounds().Dy()
|
|
if srcW <= 0 || srcH <= 0 {
|
|
return img
|
|
}
|
|
|
|
switch opt.Mode {
|
|
case ResizeModeFill:
|
|
return imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
|
|
case ResizeModeFit:
|
|
// If only one dimension is provided, keep aspect.
|
|
if w == 0 {
|
|
w = int(float64(srcW) * (float64(h) / float64(srcH)))
|
|
if w < 1 {
|
|
w = 1
|
|
}
|
|
}
|
|
if h == 0 {
|
|
h = int(float64(srcH) * (float64(w) / float64(srcW)))
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
}
|
|
// imaging.Fit ensures the result fits within w,h without cropping.
|
|
return imaging.Fit(img, w, h, imaging.Lanczos)
|
|
case ResizeModePad:
|
|
bg := opt.PadColor
|
|
if bg == nil {
|
|
bg = color.White
|
|
}
|
|
// First, fit within WxH (no crop).
|
|
fitted := imaging.Fit(img, w, h, imaging.Lanczos)
|
|
// Then, paste centered onto WxH canvas.
|
|
canvas := imaging.New(w, h, bg)
|
|
x := (w - fitted.Bounds().Dx()) / 2
|
|
y := (h - fitted.Bounds().Dy()) / 2
|
|
return imaging.Paste(canvas, fitted, image.Pt(x, y))
|
|
default:
|
|
return img
|
|
}
|
|
}
|
|
|
|
func encodeUnderSize(img image.Image, format string, maxBytes int64, minQuality, maxRetries int) ([]byte, error) {
|
|
if maxBytes <= 0 {
|
|
return nil, errors.New("maxBytes must be > 0")
|
|
}
|
|
|
|
switch format {
|
|
case "jpeg":
|
|
// Binary search quality.
|
|
lo, hi := minQuality, 92
|
|
var best []byte
|
|
iterations := 0
|
|
maxIterations := maxInt(8, maxRetries/2)
|
|
|
|
for i := 0; i < maxIterations && lo <= hi; i++ {
|
|
iterations++
|
|
q := (lo + hi) / 2
|
|
b, err := encodeJPEG(img, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(b)) <= maxBytes {
|
|
best = b
|
|
lo = q + 1
|
|
} else {
|
|
hi = q - 1
|
|
}
|
|
}
|
|
|
|
if best != nil {
|
|
return best, nil
|
|
}
|
|
|
|
// Try more aggressive qualities from minQuality down to 1
|
|
qualities := []int{}
|
|
for q := minQuality; q >= 1; q -= 5 {
|
|
qualities = append(qualities, q)
|
|
}
|
|
// Ensure we try the lowest qualities
|
|
if minQuality > 3 {
|
|
qualities = append(qualities, 3, 1)
|
|
}
|
|
|
|
var lastSize int64
|
|
for _, q := range qualities {
|
|
if iterations >= maxRetries {
|
|
break
|
|
}
|
|
iterations++
|
|
b, err := encodeJPEG(img, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lastSize = int64(len(b))
|
|
if lastSize <= maxBytes {
|
|
return b, nil
|
|
}
|
|
}
|
|
|
|
sizeMB := float64(lastSize) / (1024 * 1024)
|
|
targetMB := float64(maxBytes) / (1024 * 1024)
|
|
return nil, fmt.Errorf("cannot compress to %.2fMB (best: %.2fMB at quality %d after %d attempts). Consider increasing MaxSizeMB or enabling AllowResolutionReduction",
|
|
targetMB, sizeMB, minQuality, iterations)
|
|
|
|
case "png":
|
|
// PNG size depends heavily on content; try best compression.
|
|
b, err := encodePNG(img, png.BestCompression)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(b)) <= maxBytes {
|
|
return b, nil
|
|
}
|
|
sizeMB := float64(len(b)) / (1024 * 1024)
|
|
targetMB := float64(maxBytes) / (1024 * 1024)
|
|
return nil, fmt.Errorf("PNG cannot compress to %.2fMB (got %.2fMB). Consider using JPEG format or enabling AllowResolutionReduction",
|
|
targetMB, sizeMB)
|
|
|
|
default:
|
|
return nil, errors.New("unsupported output format")
|
|
}
|
|
}
|
|
|
|
func encodeJPEG(img image.Image, quality int) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
// Use 4:2:0 subsampling (default) for smaller size.
|
|
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func encodePNG(img image.Image, level png.CompressionLevel) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := png.Encoder{CompressionLevel: level}
|
|
if err := enc.Encode(&buf, img); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func imageHasAlpha(img image.Image) bool {
|
|
switch img.(type) {
|
|
case *image.NRGBA, *image.NRGBA64, *image.RGBA, *image.RGBA64, *image.Alpha, *image.Alpha16, *image.Paletted:
|
|
// Need to inspect pixels if we want exactness; but this is good enough and stable.
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func flattenToWhite(img image.Image) image.Image {
|
|
b := img.Bounds()
|
|
dst := image.NewRGBA(b)
|
|
|
|
// Fill with white.
|
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
|
for x := b.Min.X; x < b.Max.X; x++ {
|
|
dst.Set(x, y, image.White)
|
|
}
|
|
}
|
|
|
|
// Draw original image over white background.
|
|
return imaging.Overlay(dst, img, image.Pt(0, 0), 1.0)
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// alreadyMeetsRequirements checks if the image already satisfies the size and dimension requirements
|
|
func alreadyMeetsRequirements(img image.Image, data []byte, opt Options) bool {
|
|
// Check file size
|
|
maxBytes := int64(opt.MaxSizeMB * 1024 * 1024)
|
|
if int64(len(data)) > maxBytes {
|
|
return false
|
|
}
|
|
|
|
// Check dimensions based on mode
|
|
srcW := img.Bounds().Dx()
|
|
srcH := img.Bounds().Dy()
|
|
targetW := opt.TargetWidth
|
|
targetH := opt.TargetHeight
|
|
|
|
switch opt.Mode {
|
|
case ResizeModeFit:
|
|
// For fit mode, check if image is already within or equal to target dimensions
|
|
if targetW > 0 && srcW > targetW {
|
|
return false
|
|
}
|
|
if targetH > 0 && srcH > targetH {
|
|
return false
|
|
}
|
|
// If only one dimension is specified, check aspect ratio compatibility
|
|
if targetW > 0 && targetH == 0 && srcW <= targetW {
|
|
return true
|
|
}
|
|
if targetH > 0 && targetW == 0 && srcH <= targetH {
|
|
return true
|
|
}
|
|
if targetW > 0 && targetH > 0 && srcW <= targetW && srcH <= targetH {
|
|
return true
|
|
}
|
|
return false
|
|
|
|
case ResizeModeFill, ResizeModePad:
|
|
// For fill/pad mode, image must be exactly the target dimensions
|
|
if srcW != targetW || srcH != targetH {
|
|
return false
|
|
}
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// tryWithReducedResolution attempts to compress by progressively reducing resolution
|
|
func tryWithReducedResolution(img image.Image, opt Options, outPath string, maxBytes int64) (string, error) {
|
|
bounds := img.Bounds()
|
|
w := bounds.Dx()
|
|
h := bounds.Dy()
|
|
|
|
// Try reducing resolution in steps: 90%, 80%, 70%, 60%, 50%
|
|
reductions := []float64{0.9, 0.8, 0.7, 0.6, 0.5}
|
|
|
|
for _, factor := range reductions {
|
|
newW := int(float64(w) * factor)
|
|
newH := int(float64(h) * factor)
|
|
|
|
if newW < 100 || newH < 100 {
|
|
break // Don't reduce too much
|
|
}
|
|
|
|
resized := imaging.Resize(img, newW, newH, imaging.Lanczos)
|
|
|
|
// Try to encode with reduced resolution
|
|
encoded, err := encodeUnderSize(resized, "jpeg", maxBytes, opt.MinQuality, opt.MaxRetries/2)
|
|
if err == nil {
|
|
// Success! Save it
|
|
tmpPath := outPath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, encoded, 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.Rename(tmpPath, outPath); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return "", err
|
|
}
|
|
return outPath, nil
|
|
}
|
|
}
|
|
|
|
targetMB := float64(maxBytes) / (1024 * 1024)
|
|
return "", fmt.Errorf("cannot compress to %.2fMB even after reducing resolution to 50%%. Original size too large", targetMB)
|
|
}
|