package media import ( "bytes" "context" "fmt" "os/exec" "strings" ) const maxTelegramPhotoSize = 10 * 1024 * 1024 // 10 MB // webImageMaxSide — максимальная сторона картинки, отдаваемой на веб-страницу. // Экран всё равно не покажет больше, а исходники с телефона весят десятки мегабайт. const webImageMaxSide = 1600 // CompressImage compresses an image if it exceeds Telegram's 10 MB photo limit. // It uses ImageMagick's magick command to convert via stdin/stdout. // Returns the (possibly compressed) data, updated filename, and any error. func CompressImage(ctx context.Context, data []byte, filename string) (out []byte, outName string, err error) { if len(data) <= maxTelegramPhotoSize { return data, filename, nil } // First attempt: just re-encode as JPEG with quality 85 out, err = runMagick(ctx, data, "-quality", "85") if err != nil { return nil, "", fmt.Errorf("compress image: %w", err) } newFilename := replaceExt(filename, ".jpg") if len(out) <= maxTelegramPhotoSize { return out, newFilename, nil } // Second attempt: resize to 50% and quality 85 out, err = runMagick(ctx, data, "-resize", "50%", "-quality", "85") if err != nil { return nil, "", fmt.Errorf("compress image with resize: %w", err) } if len(out) > maxTelegramPhotoSize { return nil, "", fmt.Errorf("image still too large after compression (%d bytes)", len(out)) } return out, newFilename, nil } // ResizeForWeb downscales an image to webImageMaxSide and re-encodes it as JPEG. // Images smaller than the limit are only re-encoded (ImageMagick's ">" modifier // never enlarges). Returns the data and the resulting MIME type. func ResizeForWeb(ctx context.Context, data []byte, mimeType string) ([]byte, string, error) { // GIF (анимация) и SVG (вектор) при конверсии в JPEG теряют смысл — отдаём как есть. if !resizableMIME(mimeType) { return data, mimeType, nil } out, err := runMagick(ctx, data, "-auto-orient", "-resize", fmt.Sprintf("%dx%d>", webImageMaxSide, webImageMaxSide), "-quality", "85", "-strip", ) if err != nil { return nil, "", fmt.Errorf("resize image: %w", err) } return out, "image/jpeg", nil } func resizableMIME(mimeType string) bool { switch mimeType { case "image/gif", "image/svg+xml": return false } return true } func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) { cmdArgs := append([]string{"-"}, args...) cmdArgs = append(cmdArgs, "jpeg:-") cmd := exec.CommandContext(ctx, "magick", cmdArgs...) cmd.Stdin = bytes.NewReader(data) // Без stderr от magick ошибка выглядит как голое "exit status 1" // и не подсказывает, чего не хватает (обычно — кодека). var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("magick: %w: %s", err, strings.TrimSpace(stderr.String())) } // Без кодека magick иногда завершается успешно, но не пишет ничего. if len(out) == 0 { return nil, fmt.Errorf("magick produced empty output: %s", strings.TrimSpace(stderr.String())) } return out, nil } func replaceExt(filename, newExt string) string { if i := strings.LastIndex(filename, "."); i >= 0 { return filename[:i] + newExt } return filename + newExt }