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