From 5c2813279d60b033e8234ffcb47fb6036b1d86bc Mon Sep 17 00:00:00 2001 From: Fernando Paes Date: Mon, 15 Jun 2026 00:19:27 +0000 Subject: [PATCH] fix(send): generate JPEGThumbnail for images in /send/media Images sent through SendMediaFile and SendMediaUrl were delivered without a JPEGThumbnail, so clients (notably iOS) showed no inline preview until the full image finished downloading. This adds a reusable makeJPEGThumbnail helper that decodes the source bytes, resizes to a 72px-wide preview preserving aspect ratio (never upscaling), and JPEG-encodes it at quality 50. The helper is wired into the image branch of both SendMediaFile and SendMediaUrl (newsletter and normal paths). The existing inline thumbnail logic in SendCarousel is refactored to reuse the same helper. Thumbnail generation is best-effort: if decoding or encoding fails the helper returns nil and the message is sent without a preview rather than failing the request. Co-Authored-By: Claude Sonnet 4.6 --- pkg/sendMessage/service/send_service.go | 107 +++++++++++++++------- pkg/sendMessage/service/thumbnail_test.go | 95 +++++++++++++++++++ 2 files changed, 167 insertions(+), 35 deletions(-) create mode 100644 pkg/sendMessage/service/thumbnail_test.go diff --git a/pkg/sendMessage/service/send_service.go b/pkg/sendMessage/service/send_service.go index c6ecdcdd..51bd4d80 100644 --- a/pkg/sendMessage/service/send_service.go +++ b/pkg/sendMessage/service/send_service.go @@ -1028,15 +1028,20 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, switch data.Type { case "image": + // Generate a JPEG preview thumbnail for better client UX (iOS in + // particular). On failure jpegThumb is nil and the message is sent + // without a preview rather than failing the request. + jpegThumb := makeJPEGThumbnail(fileData, 72) if isNewsletter { // Newsletter: SEM MediaKey e FileEncSHA256 media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { // Normal: COM MediaKey e FileEncSHA256 @@ -1049,6 +1054,7 @@ func (s *sendService) sendMediaFileWithRetry(data *MediaStruct, fileData []byte, FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } mediaType = "ImageMessage" @@ -1312,15 +1318,20 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc switch data.Type { case "image": + // Generate a JPEG preview thumbnail for better client UX (iOS in + // particular). On failure jpegThumb is nil and the message is sent + // without a preview rather than failing the request. + jpegThumb := makeJPEGThumbnail(fileData, 72) if isNewsletter { // Newsletter: sem criptografia (sem MediaKey e FileEncSHA256) media = &waE2E.Message{ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(data.Caption), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - Mimetype: proto.String(mimeType), - FileSHA256: uploaded.FileSHA256, - FileLength: &uploaded.FileLength, + Caption: proto.String(data.Caption), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + Mimetype: proto.String(mimeType), + FileSHA256: uploaded.FileSHA256, + FileLength: &uploaded.FileLength, + JPEGThumbnail: jpegThumb, }} } else { // Normal: com criptografia @@ -1333,6 +1344,7 @@ func (s *sendService) sendMediaUrlWithRetry(data *MediaStruct, instance *instanc FileEncSHA256: uploaded.FileEncSHA256, FileSHA256: uploaded.FileSHA256, FileLength: proto.Uint64(uint64(len(fileData))), + JPEGThumbnail: jpegThumb, }} } mediaType = "ImageMessage" @@ -1875,6 +1887,53 @@ func stringPointer(s string) *string { return &s } +// makeJPEGThumbnail decodes raw image bytes and produces a small JPEG +// thumbnail suitable for the JPEGThumbnail field of WhatsApp media messages. +// The thumbnail keeps the original aspect ratio and is capped at maxWidth +// pixels wide. It returns nil if the image cannot be decoded so callers can +// fall back to sending the message without a preview thumbnail. +func makeJPEGThumbnail(fileData []byte, maxWidth int) []byte { + if maxWidth < 1 { + maxWidth = 72 + } + + img, _, err := image.Decode(bytes.NewReader(fileData)) + if err != nil { + return nil + } + + bounds := img.Bounds() + srcWidth := bounds.Dx() + srcHeight := bounds.Dy() + if srcWidth < 1 || srcHeight < 1 { + return nil + } + + thumbWidth := maxWidth + if srcWidth < thumbWidth { + thumbWidth = srcWidth + } + thumbHeight := int(float64(srcHeight) * float64(thumbWidth) / float64(srcWidth)) + if thumbHeight < 1 { + thumbHeight = 1 + } + + thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight)) + for y := 0; y < thumbHeight; y++ { + for x := 0; x < thumbWidth; x++ { + srcX := x * srcWidth / thumbWidth + srcY := y * srcHeight / thumbHeight + thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) + } + } + + var thumbBuf bytes.Buffer + if err := jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}); err != nil { + return nil + } + return thumbBuf.Bytes() +} + func sectionsToString(data *ListStruct) (string, error) { type row struct { Header string `json:"header"` @@ -2515,29 +2574,7 @@ func (s *sendService) SendCarousel(data *CarouselStruct, instance *instance_mode uploaded, err := client.Upload(context.Background(), fileData, whatsmeow.MediaImage) if err == nil { // Generate JPEG thumbnail for iOS compatibility - var jpegThumb []byte - img, _, decErr := image.Decode(bytes.NewReader(fileData)) - if decErr == nil { - // Resize to 72px thumbnail - bounds := img.Bounds() - thumbWidth := 72 - thumbHeight := int(float64(bounds.Dy()) * float64(thumbWidth) / float64(bounds.Dx())) - if thumbHeight < 1 { - thumbHeight = 1 - } - thumbImg := image.NewRGBA(image.Rect(0, 0, thumbWidth, thumbHeight)) - for y := 0; y < thumbHeight; y++ { - for x := 0; x < thumbWidth; x++ { - srcX := x * bounds.Dx() / thumbWidth - srcY := y * bounds.Dy() / thumbHeight - thumbImg.Set(x, y, img.At(srcX+bounds.Min.X, srcY+bounds.Min.Y)) - } - } - var thumbBuf bytes.Buffer - if jpeg.Encode(&thumbBuf, thumbImg, &jpeg.Options{Quality: 50}) == nil { - jpegThumb = thumbBuf.Bytes() - } - } + jpegThumb := makeJPEGThumbnail(fileData, 72) header.HasMediaAttachment = proto.Bool(true) header.Media = &waE2E.InteractiveMessage_Header_ImageMessage{ diff --git a/pkg/sendMessage/service/thumbnail_test.go b/pkg/sendMessage/service/thumbnail_test.go new file mode 100644 index 00000000..088cc754 --- /dev/null +++ b/pkg/sendMessage/service/thumbnail_test.go @@ -0,0 +1,95 @@ +package send_service + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +// encodePNG builds a simple solid-color PNG of the given size for testing. +func encodePNG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 10, G: 120, B: 200, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("failed to encode test PNG: %v", err) + } + return buf.Bytes() +} + +func TestMakeJPEGThumbnail_ResizesLandscape(t *testing.T) { + src := encodePNG(t, 800, 400) + + thumb := makeJPEGThumbnail(src, 72) + if thumb == nil { + t.Fatal("expected a thumbnail, got nil") + } + + cfg, format, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("thumbnail is not a decodable image: %v", err) + } + if format != "jpeg" { + t.Fatalf("expected jpeg thumbnail, got %q", format) + } + if cfg.Width != 72 { + t.Fatalf("expected width 72, got %d", cfg.Width) + } + // Aspect ratio (2:1) must be preserved: 72 wide -> 36 tall. + if cfg.Height != 36 { + t.Fatalf("expected height 36 to preserve aspect ratio, got %d", cfg.Height) + } +} + +func TestMakeJPEGThumbnail_DoesNotUpscaleSmallImages(t *testing.T) { + src := encodePNG(t, 40, 40) + + thumb := makeJPEGThumbnail(src, 72) + if thumb == nil { + t.Fatal("expected a thumbnail, got nil") + } + + cfg, _, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("thumbnail is not a decodable image: %v", err) + } + if cfg.Width != 40 || cfg.Height != 40 { + t.Fatalf("expected the thumbnail to keep the original 40x40 size, got %dx%d", cfg.Width, cfg.Height) + } +} + +func TestMakeJPEGThumbnail_InvalidInputReturnsNil(t *testing.T) { + if thumb := makeJPEGThumbnail([]byte("not an image"), 72); thumb != nil { + t.Fatalf("expected nil for non-image input, got %d bytes", len(thumb)) + } + if thumb := makeJPEGThumbnail(nil, 72); thumb != nil { + t.Fatalf("expected nil for nil input, got %d bytes", len(thumb)) + } +} + +func TestMakeJPEGThumbnail_DefaultsInvalidMaxWidth(t *testing.T) { + src := encodePNG(t, 800, 800) + + thumb := makeJPEGThumbnail(src, 0) + if thumb == nil { + t.Fatal("expected a thumbnail with defaulted maxWidth, got nil") + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(thumb)) + if err != nil { + t.Fatalf("thumbnail is not a decodable image: %v", err) + } + if cfg.Width != 72 { + t.Fatalf("expected defaulted width 72, got %d", cfg.Width) + } +} + +// ensure the jpeg encoder import stays referenced even if the helper changes. +var _ = jpeg.DefaultQuality