Files
remembos/internal/media/convert.go
T
avandClaude Opus 5 39f4a4dff9 web: картинки заметки отдаются через ресайз
Раньше страница ссылалась прямо на файлы Memos, и в браузер уезжали
исходники с телефона на десятки мегабайт. Теперь вложения идут через
эндпоинт /image/{uid}: скачиваем тем же клиентом, что и телеграм-часть,
ужимаем ImageMagick до 1600px по большей стороне и кэшируем результат
в памяти (FIFO на 20 картинок) плюс Cache-Control на час.

GIF и SVG отдаются без конверсии: анимация и вектор в JPEG теряются.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:26:33 +03:00

101 lines
2.9 KiB
Go

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)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("magick: %w", err)
}
return out, nil
}
func replaceExt(filename, newExt string) string {
if i := strings.LastIndex(filename, "."); i >= 0 {
return filename[:i] + newExt
}
return filename + newExt
}