media: кодеки для magick в образе и диагностика ресайза

Базовый пакет imagemagick в alpine идёт без кодеков, поэтому magick не мог
прочитать jpeg-вложение и падал с "exit status 1". Ставим imagemagick-jpeg,
-heic, -webp, -tiff.

Чтобы такое было видно из логов: runMagick подставляет stderr в ошибку и
отдельно ловит "код выхода 0, но пустой вывод" (так magick ведёт себя без
кодека на запись), а веб логирует формат, размеры до и после, время
скачивания и ресайза.

Если ресайз всё же не удался, отдаём оригинал вместо 502; в кэш не кладём
записи больше 4 МБ, чтобы неужатые оригиналы не съели память.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-06 12:37:57 +03:00
co-authored by Claude Opus 5
parent 39f4a4dff9
commit 2bf618d105
4 changed files with 66 additions and 7 deletions
+9 -1
View File
@@ -14,7 +14,15 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/rememb
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache imagemagick tzdata # Базовый пакет imagemagick идёт без кодеков: без них magick не читает и не
# пишет JPEG (а с телефонов приходят ещё HEIC и WebP).
RUN apk add --no-cache \
imagemagick \
imagemagick-jpeg \
imagemagick-heic \
imagemagick-webp \
imagemagick-tiff \
tzdata
COPY --from=build /out/remembos /remembos COPY --from=build /out/remembos /remembos
+11 -1
View File
@@ -84,9 +84,19 @@ func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error)
cmd := exec.CommandContext(ctx, "magick", cmdArgs...) cmd := exec.CommandContext(ctx, "magick", cmdArgs...)
cmd.Stdin = bytes.NewReader(data) cmd.Stdin = bytes.NewReader(data)
// Без stderr от magick ошибка выглядит как голое "exit status 1"
// и не подсказывает, чего не хватает (обычно — кодека).
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
return nil, fmt.Errorf("magick: %w", err) 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 return out, nil
+5 -1
View File
@@ -267,9 +267,13 @@ func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos
continue continue
} }
srcBytes := len(data)
data, filename, err := media.CompressImage(ctx, data, att.Filename) data, filename, err := media.CompressImage(ctx, data, att.Filename)
if err != nil { if err != nil {
b.logger.Warn("failed to compress image, skipping", "name", att.Name, "error", err) b.logger.Warn("failed to compress image, skipping",
"name", att.Name, "filename", att.Filename, "mime", att.Type,
"src_bytes", srcBytes, "error", err)
skipped = true skipped = true
continue continue
} }
+41 -4
View File
@@ -2,11 +2,13 @@ package web
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time"
"git.vakhrushev.me/av/remembos/internal/media" "git.vakhrushev.me/av/remembos/internal/media"
"git.vakhrushev.me/av/remembos/internal/memos" "git.vakhrushev.me/av/remembos/internal/memos"
@@ -21,6 +23,9 @@ const imagePathPrefix = "/image/"
// чтобы не пережимать их заново на каждую перезагрузку страницы. // чтобы не пережимать их заново на каждую перезагрузку страницы.
const imageCacheSize = 20 const imageCacheSize = 20
// imageCacheMaxBytes — верхняя граница размера одной записи кэша.
const imageCacheMaxBytes = 4 * 1024 * 1024
// attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}"). // attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}").
func attachmentUID(att memos.Attachment) string { func attachmentUID(att memos.Attachment) string {
return strings.TrimPrefix(att.Name, "attachments/") return strings.TrimPrefix(att.Name, "attachments/")
@@ -42,7 +47,10 @@ func (h *Handler) handleImage(w http.ResponseWriter, r *http.Request) {
data, contentType, err := h.imageBytes(r.Context(), att) data, contentType, err := h.imageBytes(r.Context(), att)
if err != nil { if err != nil {
h.logger.Error("failed to prepare image", "name", att.Name, "error", err) h.logger.Error("failed to prepare image",
"name", att.Name, "filename", att.Filename, "mime", att.Type,
"declared_bytes", att.Size, "external", att.ExternalLink != "",
"error", err)
http.Error(w, "не удалось загрузить изображение", http.StatusBadGateway) http.Error(w, "не удалось загрузить изображение", http.StatusBadGateway)
return return
} }
@@ -76,26 +84,55 @@ func (h *Handler) findAttachment(ctx context.Context, uid string) (memos.Attachm
return att, true return att, true
} }
} }
// Обычно это устаревшая ссылка: страницу открыли до того, как воспоминание сменилось.
h.logger.Info("image not found in current memory", "uid", uid, "memo", mem.Memo.Name)
return memos.Attachment{}, false return memos.Attachment{}, false
} }
// imageBytes downloads and resizes the attachment, caching the result. // imageBytes downloads and resizes the attachment, caching the result.
func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) { func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) {
if img, ok := h.images.get(att.Name); ok { if img, ok := h.images.get(att.Name); ok {
h.logger.Debug("image served from cache",
"name", att.Name, "mime", img.contentType, "bytes", len(img.data))
return img.data, img.contentType, nil return img.data, img.contentType, nil
} }
started := time.Now()
raw, err := h.client.DownloadAttachment(ctx, att) raw, err := h.client.DownloadAttachment(ctx, att)
if err != nil { if err != nil {
return nil, "", err return nil, "", fmt.Errorf("download %s (%s): %w", att.Filename, att.Type, err)
} }
downloaded := time.Since(started)
resizeStart := time.Now()
data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type) data, contentType, err := media.ResizeForWeb(ctx, raw, att.Type)
if err != nil { if err != nil {
return nil, "", err // Отдать картинку неужатой лучше, чем не отдать вовсе.
h.logger.Warn("failed to resize image, serving original",
"name", att.Name, "filename", att.Filename, "mime", att.Type,
"src_bytes", len(raw), "error", err)
data, contentType = raw, att.Type
}
if contentType == "" {
contentType = "application/octet-stream"
} }
h.images.put(att.Name, cachedImage{data: data, contentType: contentType}) // Оригиналы бывают на десятки мегабайт — такие в кэш не кладём.
cached := len(data) <= imageCacheMaxBytes
if cached {
h.images.put(att.Name, cachedImage{data: data, contentType: contentType})
}
h.logger.Info("image prepared",
"name", att.Name, "filename", att.Filename,
"src_mime", att.Type, "src_bytes", len(raw),
"out_mime", contentType, "out_bytes", len(data),
"download", downloaded.Round(time.Millisecond).String(),
"resize", time.Since(resizeStart).Round(time.Millisecond).String(),
"cached", cached)
return data, contentType, nil return data, contentType, nil
} }