From 2bf618d105ca033315b83a43aa41e0ea74a79e6b Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Thu, 6 Aug 2026 12:37:57 +0300 Subject: [PATCH] =?UTF-8?q?media:=20=D0=BA=D0=BE=D0=B4=D0=B5=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20magick=20=D0=B2=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B5=20=D0=B8=20=D0=B4=D0=B8=D0=B0=D0=B3=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20=D1=80=D0=B5=D1=81=D0=B0?= =?UTF-8?q?=D0=B9=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Базовый пакет 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) --- Dockerfile | 10 ++++++++- internal/media/convert.go | 12 ++++++++++- internal/telegram/bot.go | 6 +++++- internal/web/images.go | 45 +++++++++++++++++++++++++++++++++++---- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index c90f9d0..37ac96e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,15 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/remembos ./cmd/rememb 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 diff --git a/internal/media/convert.go b/internal/media/convert.go index 175b589..90f5ad2 100644 --- a/internal/media/convert.go +++ b/internal/media/convert.go @@ -84,9 +84,19 @@ func runMagick(ctx context.Context, data []byte, args ...string) ([]byte, error) 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", 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 diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index b1c94fe..d929f9b 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -267,9 +267,13 @@ func (b *Bot) downloadAndCompressImages(ctx context.Context, attachments []memos continue } + srcBytes := len(data) + data, filename, err := media.CompressImage(ctx, data, att.Filename) 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 continue } diff --git a/internal/web/images.go b/internal/web/images.go index caa7da6..efe96dc 100644 --- a/internal/web/images.go +++ b/internal/web/images.go @@ -2,11 +2,13 @@ package web import ( "context" + "fmt" "net/http" "net/url" "strconv" "strings" "sync" + "time" "git.vakhrushev.me/av/remembos/internal/media" "git.vakhrushev.me/av/remembos/internal/memos" @@ -21,6 +23,9 @@ const imagePathPrefix = "/image/" // чтобы не пережимать их заново на каждую перезагрузку страницы. const imageCacheSize = 20 +// imageCacheMaxBytes — верхняя граница размера одной записи кэша. +const imageCacheMaxBytes = 4 * 1024 * 1024 + // attachmentUID returns the identifier used in image URLs ("attachments/{uid}" → "{uid}"). func attachmentUID(att memos.Attachment) string { 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) 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) return } @@ -76,26 +84,55 @@ func (h *Handler) findAttachment(ctx context.Context, uid string) (memos.Attachm return att, true } } + + // Обычно это устаревшая ссылка: страницу открыли до того, как воспоминание сменилось. + h.logger.Info("image not found in current memory", "uid", uid, "memo", mem.Memo.Name) return memos.Attachment{}, false } // imageBytes downloads and resizes the attachment, caching the result. func (h *Handler) imageBytes(ctx context.Context, att memos.Attachment) ([]byte, string, error) { 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 } + started := time.Now() + raw, err := h.client.DownloadAttachment(ctx, att) 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) 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 }