From e99251b54b41b4eb5d4aea935a0ea30353cc3cdc Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 25 Oct 2026 19:07:49 +0900 Subject: [PATCH 1/4] media: send and receive GIFs like the LINE Chrome Extension Match what the LINE Chrome Extension 3.7.2 sends for images, as captured on 2026-10-25 (beeper/line#90): - MEDIA_CONTENT_INFO carries the image's real width and height, the original file size, and animated true/false for GIFs. - FILE_SIZE is the original size, not the encrypted upload size. - Images no longer send FILE_NAME or a contentType metadata key. - Plain chats send MEDIA_CONTENT_INFO only and upload no thumbnail, which also stops the failing (400) plain thumbnail upload. - Thumbnails are at most 384 px on the longest side, never upscaled, with transparent areas on white instead of black. - Detect animated GIFs by walking the GIF blocks; the old byte scan marked some single-frame GIFs as animated. Advertise fi.mau.gif with LINE's 20 MB GIF limit, convert GIF-picker videos (MP4/WebM) to GIF, and refuse GIFs over 20 MB as too large. Receive LINE images with their real type and size, and mark animated GIFs fi.mau.gif, instead of labelling every image image.jpg. Co-authored-by: formeo14 Co-Authored-By: Claude Opus 5.5 --- pkg/connector/gif.go | 149 ++++++++++++++++++++++ pkg/connector/gif_test.go | 149 ++++++++++++++++++++++ pkg/connector/handlers/image.go | 69 +++++++++- pkg/connector/handlers/image_info_test.go | 49 +++++++ pkg/connector/media.go | 79 ++++++++---- pkg/connector/send_message.go | 92 +++++-------- pkg/connector/userinfo.go | 13 +- 7 files changed, 507 insertions(+), 93 deletions(-) create mode 100644 pkg/connector/gif.go create mode 100644 pkg/connector/gif_test.go create mode 100644 pkg/connector/handlers/image_info_test.go diff --git a/pkg/connector/gif.go b/pkg/connector/gif.go new file mode 100644 index 00000000..86879c1d --- /dev/null +++ b/pkg/connector/gif.go @@ -0,0 +1,149 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// maxGIFSize is the largest GIF LINE sends as an image. The LINE Chrome +// Extension sends bigger ones as files ("Photos larger than 20 MB can't be sent +// in their original resolution"), so the bridge refuses them as too large. +const maxGIFSize = 20 * 1000 * 1000 + +// imageContentInfo is MEDIA_CONTENT_INFO as the LINE Chrome Extension sends it +// for images: the original file's size and pixel dimensions, and for GIFs +// whether they have more than one frame. +type imageContentInfo struct { + Category string `json:"category"` + FileSize int `json:"fileSize"` + Extension string `json:"extension"` + Animated *bool `json:"animated,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +func originalImageInfo(data []byte, extension string) imageContentInfo { + info := imageContentInfo{ + Category: "original", + FileSize: len(data), + Extension: extension, + } + if extension == "gif" { + animated := isAnimatedGif(data) + info.Animated = &animated + } + if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + info.Width, info.Height = config.Width, config.Height + } + return info +} + +// imageMediaMetadata builds the content metadata the LINE Chrome Extension +// sends with an image. Encrypted sends add OID, SID, FILE_SIZE and the +// thumbnail info; plain sends carry MEDIA_CONTENT_INFO only. Neither sends +// FILE_NAME or a contentType key. +func imageMediaMetadata(data []byte, extension string, plain bool) map[string]string { + metadata := map[string]string{} + if infoJSON, err := json.Marshal(originalImageInfo(data, extension)); err == nil { + metadata["MEDIA_CONTENT_INFO"] = string(infoJSON) + } + if !plain { + metadata["FILE_SIZE"] = strconv.Itoa(len(data)) + } + return metadata +} + +const maxConvertedGIFSize = 100 * 1024 * 1024 + +var gifConversionSlot = make(chan struct{}, 1) + +type gifOutputBuffer struct { + buffer bytes.Buffer + limit int +} + +func (b *gifOutputBuffer) Write(p []byte) (int, error) { + if len(p) > b.limit-b.buffer.Len() { + return 0, fmt.Errorf("converted GIF exceeds %d byte limit", b.limit) + } + return b.buffer.Write(p) +} + +// convertVideoToGIF turns the MP4 or WebM that Matrix GIF pickers send +// (fi.mau.gif) into a real GIF, because LINE only receives GIFs as images. +func convertVideoToGIF(ctx context.Context, data []byte) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + if err := ctx.Err(); err != nil { + return nil, err + } + if len(data) > maxConvertedGIFSize { + return nil, fmt.Errorf("GIF video exceeds 100 MiB input limit") + } + select { + case gifConversionSlot <- struct{}{}: + defer func() { <-gifConversionSlot }() + case <-ctx.Done(): + return nil, ctx.Err() + } + dir, err := os.MkdirTemp("", "line-gif-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + input := filepath.Join(dir, "input") + palette := filepath.Join(dir, "palette.png") + if err = os.WriteFile(input, data, 0600); err != nil { + return nil, err + } + probe := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", input) + durationData, err := probe.Output() + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("failed to inspect GIF video (ffprobe must be installed): %w", err) + } + duration, err := strconv.ParseFloat(strings.TrimSpace(string(durationData)), 64) + if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 || duration > 60 { + return nil, fmt.Errorf("GIF video must have a known duration of at most 60 seconds") + } + filter := "fps=15,scale=w='min(640,iw)':h='min(640,ih)':force_original_aspect_ratio=decrease:flags=lanczos" + + makePalette := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", + "-threads", "1", "-i", input, "-an", "-vf", filter+",palettegen", + "-frames:v", "1", "-threads", "1", "-update", "1", palette) + if err = makePalette.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("ffmpeg GIF palette generation failed: %w", err) + } + output := gifOutputBuffer{limit: maxConvertedGIFSize} + cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", + "-threads", "1", "-i", input, "-i", palette, "-an", + "-filter_complex", "[0:v]"+filter+"[v];[v][1:v]paletteuse", + "-loop", "0", "-f", "gif", "pipe:1") + cmd.Stdout = &output + if err = cmd.Run(); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("ffmpeg GIF conversion failed: %w", err) + } + converted := output.buffer.Bytes() + if _, format, err := image.DecodeConfig(bytes.NewReader(converted)); err != nil || format != "gif" { + return nil, fmt.Errorf("ffmpeg produced invalid GIF output") + } + return converted, nil +} diff --git a/pkg/connector/gif_test.go b/pkg/connector/gif_test.go new file mode 100644 index 00000000..0d1d963b --- /dev/null +++ b/pkg/connector/gif_test.go @@ -0,0 +1,149 @@ +package connector + +import ( + "bytes" + "encoding/json" + "image" + "image/color" + "image/gif" + "image/jpeg" + "testing" + + "maunium.net/go/mautrix/event" +) + +func testGIF(t *testing.T, width, height, frames int) []byte { + t.Helper() + palette := color.Palette{color.Transparent, color.RGBA{R: 6, G: 166, B: 74, A: 255}} + anim := &gif.GIF{} + for i := 0; i < frames; i++ { + frame := image.NewPaletted(image.Rect(0, 0, width, height), palette) + frame.SetColorIndex(i%width, 0, 1) + anim.Image = append(anim.Image, frame) + anim.Delay = append(anim.Delay, 10) + } + var buf bytes.Buffer + if err := gif.EncodeAll(&buf, anim); err != nil { + t.Fatalf("encode test GIF: %v", err) + } + return buf.Bytes() +} + +func decodeContentInfo(t *testing.T, metadata map[string]string) map[string]any { + t.Helper() + var info map[string]any + if err := json.Unmarshal([]byte(metadata["MEDIA_CONTENT_INFO"]), &info); err != nil { + t.Fatalf("MEDIA_CONTENT_INFO is not JSON: %v", err) + } + return info +} + +// The LINE Chrome Extension sends a GIF's real pixel size and original file +// size in MEDIA_CONTENT_INFO (capture D2, D8, D9). +func TestImageMediaMetadataEncryptedMatchesChrome(t *testing.T) { + data := testGIF(t, 960, 360, 3) + metadata := imageMediaMetadata(data, "gif", false) + + info := decodeContentInfo(t, metadata) + if info["width"] != float64(960) || info["height"] != float64(360) { + t.Errorf("width/height = %v/%v, want 960/360", info["width"], info["height"]) + } + if info["fileSize"] != float64(len(data)) { + t.Errorf("fileSize = %v, want the original size %d", info["fileSize"], len(data)) + } + if info["animated"] != true || info["extension"] != "gif" || info["category"] != "original" { + t.Errorf("unexpected MEDIA_CONTENT_INFO %v", info) + } + if metadata["FILE_SIZE"] != itoa(len(data)) { + t.Errorf("FILE_SIZE = %q, want the original size %d", metadata["FILE_SIZE"], len(data)) + } + for _, key := range []string{"FILE_NAME", "contentType"} { + if _, ok := metadata[key]; ok { + t.Errorf("metadata has %s, which the Chrome extension does not send", key) + } + } +} + +// In plain chats the Chrome extension sends MEDIA_CONTENT_INFO only (capture D7). +func TestImageMediaMetadataPlainIsContentInfoOnly(t *testing.T) { + metadata := imageMediaMetadata(testGIF(t, 480, 480, 2), "gif", true) + if len(metadata) != 1 || metadata["MEDIA_CONTENT_INFO"] == "" { + t.Fatalf("plain metadata = %v, want MEDIA_CONTENT_INFO only", metadata) + } +} + +// A single-frame GIF is sent with animated: false (capture D3); non-GIF images +// carry no animated flag. +func TestOriginalImageInfoAnimatedFlag(t *testing.T) { + still := originalImageInfo(testGIF(t, 800, 800, 1), "gif") + if still.Animated == nil || *still.Animated { + t.Errorf("single-frame GIF animated = %v, want false", still.Animated) + } + moving := originalImageInfo(testGIF(t, 32, 32, 4), "gif") + if moving.Animated == nil || !*moving.Animated { + t.Errorf("multi-frame GIF animated = %v, want true", moving.Animated) + } + if moving.Width != 32 || moving.Height != 32 { + t.Errorf("tiny GIF size = %dx%d, want 32x32", moving.Width, moving.Height) + } + if jpg := originalImageInfo([]byte("not an image"), "jpg"); jpg.Animated != nil { + t.Errorf("JPEG has animated flag %v, want none", *jpg.Animated) + } +} + +// Thumbnails keep the aspect ratio with the longest side at most 384 and are +// never scaled up (capture D8: 384x144, D9: 144x384, E6: 32x32). +func TestGenerateThumbnailMatchesChromeSizes(t *testing.T) { + cases := []struct{ w, h, wantW, wantH int }{ + {960, 360, 384, 144}, + {360, 960, 144, 384}, + {480, 480, 384, 384}, + {32, 32, 32, 32}, + } + for _, c := range cases { + _, w, h, err := generateThumbnail(testGIF(t, c.w, c.h, 2)) + if err != nil { + t.Fatalf("%dx%d: %v", c.w, c.h, err) + } + if w != c.wantW || h != c.wantH { + t.Errorf("%dx%d thumbnail = %dx%d, want %dx%d", c.w, c.h, w, h, c.wantW, c.wantH) + } + } +} + +// Transparent areas become white, not black, in the JPEG thumbnail LINE shows +// before a GIF plays. +func TestGenerateThumbnailTransparentIsWhite(t *testing.T) { + thumb, _, _, err := generateThumbnail(testGIF(t, 64, 64, 2)) + if err != nil { + t.Fatal(err) + } + img, err := jpeg.Decode(bytes.NewReader(thumb)) + if err != nil { + t.Fatal(err) + } + r, g, b, _ := img.At(32, 40).RGBA() + if r>>8 < 240 || g>>8 < 240 || b>>8 < 240 { + t.Errorf("transparent pixel = (%d,%d,%d), want white", r>>8, g>>8, b>>8) + } +} + +// GIF pickers send fi.mau.gif; LINE takes GIFs up to 20 MB (capture E1–E3). +func TestCapabilitiesAdvertiseGIFs(t *testing.T) { + caps := (&LineClient{}).GetCapabilities(t.Context(), nil) + gifCaps, ok := caps.File[event.CapMsgGIF] + if !ok { + t.Fatal("no fi.mau.gif capability") + } + if gifCaps.MaxSize != maxGIFSize { + t.Errorf("GIF MaxSize = %d, want %d", gifCaps.MaxSize, maxGIFSize) + } + if gifCaps.MimeTypes["image/gif"] != event.CapLevelFullySupported { + t.Errorf("image/gif support = %v, want fully supported", gifCaps.MimeTypes["image/gif"]) + } +} + +func itoa(n int) string { + b, _ := json.Marshal(n) + return string(b) +} diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 7c3ccdbd..80e7be38 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "net/http" + "strings" "time" "maunium.net/go/mautrix/bridgev2" @@ -40,8 +42,9 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int if err != nil { return nil, err } + info, body := lineImageInfo(data.ContentMetadata, nil) if converted, err := h.generateDirectMedia(ctx, data.ID, directMedia, &event.MessageEventContent{ - MsgType: event.MsgImage, RelatesTo: relatesTo, + MsgType: event.MsgImage, Body: body, Info: info, RelatesTo: relatesTo, }); converted != nil || err != nil { return converted, err } @@ -111,9 +114,10 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int return oversized, nil } - // Upload to Matrix + // Upload to Matrix with the image's real type, so GIFs stay GIFs. + info, body := lineImageInfo(data.ContentMetadata, imgData) uploadStart := time.Now() - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, body, info.MimeType) uploadDuration := time.Since(uploadStart) if err != nil { h.Log.Error(). @@ -145,9 +149,10 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgImage, - Body: "image.jpg", + Body: body, URL: mxc, File: file, + Info: info, RelatesTo: relatesTo, }, }, @@ -175,6 +180,62 @@ func lineImageDownloadSource(data line.Message) imageDownloadSource { return imageDownloadSource{oid: data.ID, isPlainMedia: true} } +// lineImageInfo describes an incoming LINE image for Matrix: its real MIME +// type, size and dimensions from MEDIA_CONTENT_INFO, and fi.mau.gif for +// animated GIFs so clients play them as GIFs. data is the downloaded image, or +// nil when only the metadata is known. +func lineImageInfo(metadata map[string]string, data []byte) (*event.FileInfo, string) { + var contentInfo struct { + Extension string `json:"extension"` + Animated bool `json:"animated"` + Width int `json:"width"` + Height int `json:"height"` + FileSize int `json:"fileSize"` + } + if raw := metadata["MEDIA_CONTENT_INFO"]; raw != "" { + _ = json.Unmarshal([]byte(raw), &contentInfo) + } + + mimeType := "" + switch strings.ToLower(contentInfo.Extension) { + case "gif": + mimeType = "image/gif" + case "png": + mimeType = "image/png" + case "webp": + mimeType = "image/webp" + case "jpg", "jpeg": + mimeType = "image/jpeg" + } + if data != nil { + if detected := http.DetectContentType(data); strings.HasPrefix(detected, "image/") { + mimeType = detected + } + } + if mimeType == "" { + mimeType = "image/jpeg" + } + + info := &event.FileInfo{ + MimeType: mimeType, + Width: contentInfo.Width, + Height: contentInfo.Height, + Size: contentInfo.FileSize, + } + if data != nil { + info.Size = len(data) + } + if mimeType == "image/gif" && contentInfo.Animated { + info.MauGIF = true + } + + extension := strings.TrimPrefix(mimeType, "image/") + if extension == "jpeg" { + extension = "jpg" + } + return info, "image." + extension +} + func lineMediaCategory(metadata map[string]string) string { if metadata == nil || metadata["MEDIA_CONTENT_INFO"] == "" { return "" diff --git a/pkg/connector/handlers/image_info_test.go b/pkg/connector/handlers/image_info_test.go new file mode 100644 index 00000000..da9efb50 --- /dev/null +++ b/pkg/connector/handlers/image_info_test.go @@ -0,0 +1,49 @@ +package handlers + +import "testing" + +// GIFs from LINE arrive with MEDIA_CONTENT_INFO (capture F1–F3); Matrix should +// get them as animated GIFs, not as image.jpg (test C1, C6). +func TestLineImageInfoAnimatedGIF(t *testing.T) { + metadata := map[string]string{ + "MEDIA_CONTENT_INFO": `{"width":480,"height":400,"animated":true,"attribute":{},"category":"original","fileSize":3151649,"extension":"gif"}`, + } + info, body := lineImageInfo(metadata, nil) + if info.MimeType != "image/gif" || body != "image.gif" { + t.Errorf("mime/body = %q/%q, want image/gif/image.gif", info.MimeType, body) + } + if !info.MauGIF { + t.Error("animated GIF is not marked fi.mau.gif") + } + if info.Width != 480 || info.Height != 400 || info.Size != 3151649 { + t.Errorf("info = %dx%d %d bytes, want 480x400 3151649", info.Width, info.Height, info.Size) + } +} + +func TestLineImageInfoStillGIFAndJPEG(t *testing.T) { + still, _ := lineImageInfo(map[string]string{ + "MEDIA_CONTENT_INFO": `{"extension":"gif","animated":false,"width":800,"height":800}`, + }, nil) + if still.MimeType != "image/gif" || still.MauGIF { + t.Errorf("still GIF = %q mauGIF=%v, want image/gif not fi.mau.gif", still.MimeType, still.MauGIF) + } + + jpg, body := lineImageInfo(nil, nil) + if jpg.MimeType != "image/jpeg" || body != "image.jpg" { + t.Errorf("no metadata = %q/%q, want image/jpeg/image.jpg", jpg.MimeType, body) + } +} + +// The downloaded bytes decide the type when they are a known image. +func TestLineImageInfoDetectsFromData(t *testing.T) { + gifHeader := []byte("GIF89a\x01\x00\x01\x00\x00\x00\x00;") + info, body := lineImageInfo(map[string]string{ + "MEDIA_CONTENT_INFO": `{"extension":"jpg"}`, + }, gifHeader) + if info.MimeType != "image/gif" || body != "image.gif" { + t.Errorf("detected = %q/%q, want image/gif/image.gif", info.MimeType, body) + } + if info.Size != len(gifHeader) { + t.Errorf("size = %d, want %d", info.Size, len(gifHeader)) + } +} diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 482236d0..5759c470 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -35,13 +35,6 @@ func isHEICImage(mimeType, fileName string) bool { return extension == ".heic" || extension == ".heif" } -func jpegFileName(fileName string) string { - if fileName == "" { - return "image.jpg" - } - return strings.TrimSuffix(fileName, filepath.Ext(fileName)) + ".jpg" -} - func convertHEICToJPEG(ctx context.Context, imageData []byte) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err @@ -281,27 +274,27 @@ func generateThumbnail(imageData []byte) ([]byte, int, int, error) { width := bounds.Dx() height := bounds.Dy() - maxDim := 1280 + // The LINE Chrome Extension scales image thumbnails so the longest side is + // at most 384 pixels, keeps the aspect ratio and never scales up. + maxDim := 384 newWidth := width newHeight := height if width > maxDim || height > maxDim { if width > height { newWidth = maxDim - newHeight = (height * maxDim) / width + newHeight = max(1, (height*maxDim)/width) } else { newHeight = maxDim - newWidth = (width * maxDim) / height + newWidth = max(1, (width*maxDim)/height) } } - var thumbnail image.Image - if newWidth != width || newHeight != height { - thumbnail = image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) - draw.CatmullRom.Scale(thumbnail.(draw.Image), thumbnail.Bounds(), img, bounds, draw.Over, nil) - } else { - thumbnail = img - } + // JPEG has no alpha channel: draw on white so transparent areas don't turn + // black in the preview LINE shows before a GIF plays. + thumbnail := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) + draw.Draw(thumbnail, thumbnail.Bounds(), image.White, image.Point{}, draw.Src) + draw.CatmullRom.Scale(thumbnail, thumbnail.Bounds(), img, bounds, draw.Over, nil) var buf bytes.Buffer if err := jpeg.Encode(&buf, thumbnail, &jpeg.Options{Quality: 60}); err != nil { @@ -346,27 +339,57 @@ func encryptThumbnail(thumbnailData []byte, keyMaterialB64 string) ([]byte, erro return append(encrypted, h.Sum(nil)...), nil } +// isAnimatedGif reports whether a GIF has more than one frame. It walks the +// GIF block structure instead of scanning for the 0x2C image separator byte, +// which also appears inside compressed pixel data and made single-frame GIFs +// look animated. func isAnimatedGif(data []byte) bool { - // GIF header: "GIF89a" or "GIF87a" - if len(data) < 6 { + // Header ("GIF87a"/"GIF89a") and logical screen descriptor. + if len(data) < 13 || string(data[0:3]) != "GIF" { return false } + pos := 13 + if flags := data[10]; flags&0x80 != 0 { + pos += 3 << ((flags & 0x07) + 1) // global color table + } - if string(data[0:3]) != "GIF" { - return false + // skipSubBlocks skips data sub-blocks up to and including the terminator. + skipSubBlocks := func(p int) int { + for p < len(data) { + size := int(data[p]) + p++ + if size == 0 { + return p + } + p += size + } + return -1 } - // Count image descriptors (0x2C) which indicate frames - frameCount := 0 - for i := 0; i < len(data)-1; i++ { - if data[i] == 0x2C { // Image descriptor separator - frameCount++ - if frameCount > 1 { + frames := 0 + for pos >= 0 && pos < len(data) { + switch data[pos] { + case 0x2C: // image descriptor + frames++ + if frames > 1 { return true } + if pos+10 > len(data) { + return false + } + flags := data[pos+9] + pos += 10 + if flags&0x80 != 0 { + pos += 3 << ((flags & 0x07) + 1) // local color table + } + pos++ // LZW minimum code size + pos = skipSubBlocks(pos) + case 0x21: // extension: label, then sub-blocks + pos = skipSubBlocks(pos + 2) + default: // 0x3B trailer, or data we cannot parse + return false } } - return false } diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index fa2754ef..fa0c8e49 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -9,6 +9,7 @@ import ( "fmt" "image" "image/jpeg" + "maps" "regexp" "strconv" "strings" @@ -142,6 +143,13 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } } + // GIF pickers in Matrix clients send GIFs as short videos flagged fi.mau.gif. + // LINE only receives GIFs as images, so convert those and send them as one. + videoGIF := effectiveMsgType == event.MsgVideo && msg.Content.Info != nil && msg.Content.Info.MauGIF + if videoGIF { + effectiveMsgType = event.MsgImage + } + // For non-text messages, check with the server whether to use E2EE or plain media upload. // This must happen before media processing since it affects the upload path. useE2EEMedia := !plainText @@ -193,18 +201,29 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaDownloadFailed, err) } - mimeType := msg.Content.Info.MimeType + mimeType := "" + if msg.Content.Info != nil { + mimeType = msg.Content.Info.MimeType + } fileName := msg.Content.GetFileName() + if videoGIF { + data, err = convertVideoToGIF(ctx, data) + if err != nil { + return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaConvertFailed, err) + } + mimeType = "image/gif" + } if isHEICImage(mimeType, fileName) { data, err = convertHEICToJPEG(ctx, data) if err != nil { return nil, fmt.Errorf("%w: %w", bridgev2.ErrMediaConvertFailed, err) } mimeType = "image/jpeg" - fileName = jpegFileName(fileName) } isGif := mimeType == "image/gif" - isAnimated := isGif && isAnimatedGif(data) + if isGif && len(data) > maxGIFSize { + return nil, fmt.Errorf("%w: %.1f MB is larger than LINE's %d MB limit for GIFs", bridgev2.ErrMediaTooLarge, float64(len(data))/1000/1000, maxGIFSize/1000/1000) + } extension := "jpg" if isGif { @@ -216,50 +235,15 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat contentType = int(ContentImage) if plainText { - // Plain media: save data for post-send upload to r/talk/m/{msgId} + // Plain media: send MEDIA_CONTENT_INFO only, then upload the file to + // r/talk/m/{msgId}. The LINE Chrome Extension uploads no thumbnail here. plainMediaData = data - - thumbnailData, thumbWidth, thumbHeight, err := generateThumbnail(data) - if err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to generate thumbnail, continuing without it") - } else { - plainThumbData = thumbnailData - mediaThumbInfo := map[string]interface{}{ - "width": thumbWidth, - "height": thumbHeight, - } - if thumbInfoJSON, err := json.Marshal(mediaThumbInfo); err == nil { - contentMetadata["MEDIA_THUMB_INFO"] = string(thumbInfoJSON) - } - } - - contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(data)) - contentMetadata["contentType"] = fmt.Sprintf("%d", ContentImage) - - if fileName == "" { - fileName = "image." + extension - } - contentMetadata["FILE_NAME"] = fileName - - mediaContentInfo := map[string]interface{}{ - "category": "original", - "fileSize": len(data), - "extension": extension, - } - if isAnimated { - mediaContentInfo["animated"] = true - } - if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { - contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) - } + maps.Copy(contentMetadata, imageMediaMetadata(data, extension, true)) } else { // E2EE: encrypt, upload to OBS first, send with OID // Save original data for potential group E2EE fallback if isGroup { originalMediaData = data - if thumbData, _, _, tErr := generateThumbnail(data); tErr == nil { - originalThumbData = thumbData - } } uploadData, keyMaterialB64, err := lc.encryptFileData(data) @@ -305,26 +289,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat contentMetadata["OID"] = oid contentMetadata["SID"] = "emi" - contentMetadata["FILE_SIZE"] = fmt.Sprintf("%d", len(uploadData)) - contentMetadata["contentType"] = fmt.Sprintf("%d", ContentImage) contentMetadata["ENC_KM"] = keyMaterialB64 - - if fileName == "" { - fileName = "image." + extension - } - contentMetadata["FILE_NAME"] = fileName - - mediaContentInfo := map[string]interface{}{ - "category": "original", - "fileSize": len(uploadData), - "extension": extension, - } - if isAnimated { - mediaContentInfo["animated"] = true - } - if mediaInfoJSON, err := json.Marshal(mediaContentInfo); err == nil { - contentMetadata["MEDIA_CONTENT_INFO"] = string(mediaInfoJSON) - } + // Sizes describe the original file, not the encrypted upload. + maps.Copy(contentMetadata, imageMediaMetadata(data, extension, false)) imgPayload := map[string]string{"keyMaterial": keyMaterialB64} payload, _ = json.Marshal(imgPayload) @@ -656,6 +623,11 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat delete(contentMetadata, "ENC_KM") plainMediaData = originalMediaData plainThumbData = originalThumbData + if contentType == int(ContentImage) { + // Plain images carry MEDIA_CONTENT_INFO only, like the LINE Chrome Extension. + delete(contentMetadata, "FILE_SIZE") + delete(contentMetadata, "MEDIA_THUMB_INFO") + } } } } diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 15d8fdbf..48bea4df 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -46,7 +46,7 @@ func (lc *LineClient) HandleMatrixReadReceipt(ctx context.Context, read *bridgev func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { caps := &event.RoomFeatures{ - ID: "dev.highest.matrix.line.capabilities.2026_10_21", + ID: "dev.highest.matrix.line.capabilities.2026_10_25", MaxTextLength: 5000, Reply: event.CapLevelFullySupported, Reaction: event.CapLevelPartialSupport, @@ -91,6 +91,17 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port "image/jpeg": event.CapLevelFullySupported, }, }, + // GIF pickers send fi.mau.gif. LINE receives every GIF as an image up to + // 20 MB, so videos are converted to GIF before sending. + event.CapMsgGIF: { + Caption: event.CapLevelRejected, + MaxSize: maxGIFSize, + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + "video/mp4": event.CapLevelPartialSupport, + "video/webm": event.CapLevelPartialSupport, + }, + }, event.MsgFile: { Caption: event.CapLevelRejected, MaxSize: handlers.BeeperMaxFileSize, From 392e9ab8cef01df307baa9eaed0461b459e0ed78 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 25 Oct 2026 19:40:45 +0900 Subject: [PATCH 2/4] media: upload plain images as the original file In chats without end-to-end encryption (for example with a LINE business account), the message says MEDIA_CONTENT_INFO category "original", but the image was uploaded without cat=original. LINE iOS then showed the preview frame and "Unable to load this photo as it's no longer available". Upload plain images with cat=original and the file name, as the LINE Chrome Extension does: {"ver":"2.0","name":"","type":"image","cat":"original"}. Co-authored-by: formeo14 Co-Authored-By: Claude Opus 5.5 --- pkg/connector/send_message.go | 9 +++++++++ pkg/line/client.go | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index fa0c8e49..efeb1b6a 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -10,6 +10,7 @@ import ( "image" "image/jpeg" "maps" + "path/filepath" "regexp" "strconv" "strings" @@ -173,6 +174,7 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat // For plain media: we send the message first, then upload media to r/talk/m/{msgId}. var plainMediaData []byte // raw media data to upload after sending var plainThumbData []byte // raw thumbnail data to upload after sending + var plainImageName string // file name for plain image uploads (cat=original) // For group chats, save original data in case E2EE encryption fails and we fall back to plain. var originalMediaData []byte @@ -233,6 +235,10 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } contentType = int(ContentImage) + plainImageName = "image." + extension + if fileName != "" { + plainImageName = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + "." + extension + } if plainText { // Plain media: send MEDIA_CONTENT_INFO only, then upload the file to @@ -836,6 +842,9 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat } if err := callLineErr(func(client *line.Client) error { + if contentType == int(ContentImage) { + return client.UploadOBSPlainOriginalImage(plainMediaData, sentMsg.ID, plainImageName) + } return client.UploadOBSPlain(plainMediaData, sentMsg.ID, obsType) }); err != nil { return nil, bridgev2.WrapErrorInStatus(fmt.Errorf("failed to upload plain media to OBS: %w", err)). diff --git a/pkg/line/client.go b/pkg/line/client.go index 6740f1e3..3f8ac711 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -628,6 +628,18 @@ func obsTypeFromSID(sid string) string { // UploadOBSPlain uploads plain (non-E2EE) media to a specific OID via the "m" endpoint. // obsType should be "image", "video", "audio", or "file". func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { + return c.uploadOBSPlain(data, oid, obsType, "", "") +} + +// UploadOBSPlainOriginalImage uploads a plain image as its original file. The +// LINE Chrome Extension sends images with MEDIA_CONTENT_INFO category +// "original" and uploads them with cat=original and the file name; without the +// category LINE clients show the preview but cannot load the photo. +func (c *Client) UploadOBSPlainOriginalImage(data []byte, oid, fileName string) error { + return c.uploadOBSPlain(data, oid, "image", fileName, "original") +} + +func (c *Client) uploadOBSPlain(data []byte, oid, obsType, fileName, category string) error { obsToken, err := c.AcquireEncryptedAccessToken() if err != nil { return fmt.Errorf("failed to acquire OBS token: %w", err) @@ -645,6 +657,12 @@ func (c *Client) UploadOBSPlain(data []byte, oid string, obsType string) error { "name": fmt.Sprintf("%d", time.Now().UnixMilli()), "type": obsType, } + if fileName != "" { + obsParams["name"] = fileName + } + if category != "" { + obsParams["cat"] = category + } obsParamsJSON, _ := json.Marshal(obsParams) obsParamsB64 := base64.StdEncoding.EncodeToString(obsParamsJSON) From f568aa923459c630915a4fbdc63b0338ada946b0 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 25 Oct 2026 20:38:20 +0900 Subject: [PATCH 3/4] media: keep the GIF type on direct media images With direct media on, generateDirectMedia replaced the type and file name from MEDIA_CONTENT_INFO with image/jpeg and image.jpg. The saved metadata also made the download response say image/jpeg, so GIFs from LINE still arrived as JPEGs on that path. Set the type, file name and (when FILE_SIZE is missing) the size from lineImageInfo on the direct media metadata before the URI is made. Answers the Greptile review comment "Direct GIFs still claim JPEG". Co-authored-by: formeo14 Co-Authored-By: Claude Opus 5.5 --- pkg/connector/handlers/image.go | 7 ++++ pkg/connector/handlers/image_info_test.go | 39 ++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 80e7be38..a58d5d85 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -42,7 +42,14 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int if err != nil { return nil, err } + // Keep the type that LINE reports (for example GIF) in the event and in + // the saved metadata that the download response uses. info, body := lineImageInfo(data.ContentMetadata, nil) + directMedia.ContentType = info.MimeType + directMedia.FileName = body + if directMedia.Size == 0 { + directMedia.Size = info.Size + } if converted, err := h.generateDirectMedia(ctx, data.ID, directMedia, &event.MessageEventContent{ MsgType: event.MsgImage, Body: body, Info: info, RelatesTo: relatesTo, }); converted != nil || err != nil { diff --git a/pkg/connector/handlers/image_info_test.go b/pkg/connector/handlers/image_info_test.go index da9efb50..2e5bc2d7 100644 --- a/pkg/connector/handlers/image_info_test.go +++ b/pkg/connector/handlers/image_info_test.go @@ -1,6 +1,43 @@ package handlers -import "testing" +import ( + "context" + "testing" + + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/line" +) + +// With direct media, the event and the saved metadata (which sets the +// download's Content-Type) must keep the GIF type too. +func TestConvertImageDirectMediaKeepsGIFType(t *testing.T) { + var saved *DirectMedia + h := &Handler{GenerateDirectMediaURI: func(_ context.Context, _ string, dm *DirectMedia) (id.ContentURIString, any, error) { + saved = dm + return "mxc://example.com/gif", nil, nil + }} + converted, err := h.ConvertImage(context.Background(), nil, nil, line.Message{ + ID: "1", + ContentMetadata: map[string]string{ + "OID": "oid", + "MEDIA_CONTENT_INFO": `{"width":480,"height":400,"animated":true,"category":"original","fileSize":3151649,"extension":"gif"}`, + }, + }, "", nil) + if err != nil || converted == nil { + t.Fatalf("ConvertImage = %v, %v", converted, err) + } + content := converted.Parts[0].Content + if content.Body != "image.gif" || content.Info.MimeType != "image/gif" || !content.Info.MauGIF { + t.Errorf("event = %q %q mauGIF=%v, want image.gif image/gif fi.mau.gif", content.Body, content.Info.MimeType, content.Info.MauGIF) + } + if content.Info.Size != 3151649 { + t.Errorf("event size = %d, want 3151649", content.Info.Size) + } + if saved == nil || saved.ContentType != "image/gif" || saved.FileName != "image.gif" { + t.Errorf("saved metadata = %+v, want image/gif image.gif", saved) + } +} // GIFs from LINE arrive with MEDIA_CONTENT_INFO (capture F1–F3); Matrix should // get them as animated GIFs, not as image.jpg (test C1, C6). From 91660965c9b1bc76559dfc7d12e9eb523eaf6927 Mon Sep 17 00:00:00 2001 From: Caio Lins Date: Sun, 25 Oct 2026 20:38:20 +0900 Subject: [PATCH 4/4] media: offer GIF picker videos only when ffmpeg is installed Picker videos are converted to GIF with ffprobe and ffmpeg. A native install without them still offered video/mp4 and video/webm for fi.mau.gif, so every picker GIF failed instead of sending. Offer those types only when both programs are on PATH, add "+no_gif_video" to the capability ID when they are not, and list FFmpeg as an optional native prerequisite in the README. Answers the Greptile review comment "Picker support ignores missing programs". Co-authored-by: formeo14 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ pkg/connector/gif.go | 23 +++++++++++++++++++++++ pkg/connector/userinfo.go | 13 ++++++------- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 732232fe..59bf0d3b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ pacman -Syu mingw-w64-x86_64-gcc cmake ``` - [Go](https://go.dev/dl/) +- Optional: [FFmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on `PATH`) to send GIFs from GIF pickers, which send videos. Without it, the bridge sends only GIF files as GIFs. - [olm](https://gitlab.matrix.org/matrix-org/olm) library: ```bash @@ -241,6 +242,7 @@ cd data - [Go](https://go.dev/dl/) and build tools (`gcc`, `make`) - [olm](https://gitlab.matrix.org/matrix-org/olm) library (install via your package manager or build from source) +- Optional: [FFmpeg](https://ffmpeg.org/) (`ffmpeg` and `ffprobe` on `PATH`) to send GIFs from GIF pickers, which send videos. Without it, the bridge sends only GIF files as GIFs. - If using Beeper, compile [bbctl](https://github.com/beeper/bridge-manager) from source ### Linux / macOS build diff --git a/pkg/connector/gif.go b/pkg/connector/gif.go index 86879c1d..39e1eb6e 100644 --- a/pkg/connector/gif.go +++ b/pkg/connector/gif.go @@ -13,6 +13,10 @@ import ( "strconv" "strings" "time" + + "go.mau.fi/util/ffmpeg" + + "maunium.net/go/mautrix/event" ) // maxGIFSize is the largest GIF LINE sends as an image. The LINE Chrome @@ -63,6 +67,25 @@ func imageMediaMetadata(data []byte, extension string, plain bool) map[string]st return metadata } +// canConvertVideoToGIF reports whether the ffprobe and ffmpeg programs that +// convertVideoToGIF runs are installed. +func canConvertVideoToGIF() bool { + return ffmpeg.Supported() && ffmpeg.ProbeSupported() +} + +// gifMimeTypes lists the fi.mau.gif types the bridge can send. Picker videos +// are offered only when they can be converted to GIF. +func gifMimeTypes() map[string]event.CapabilitySupportLevel { + mimeTypes := map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelFullySupported, + } + if canConvertVideoToGIF() { + mimeTypes["video/mp4"] = event.CapLevelPartialSupport + mimeTypes["video/webm"] = event.CapLevelPartialSupport + } + return mimeTypes +} + const maxConvertedGIFSize = 100 * 1024 * 1024 var gifConversionSlot = make(chan struct{}, 1) diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 48bea4df..7e969fa9 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -94,13 +94,9 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port // GIF pickers send fi.mau.gif. LINE receives every GIF as an image up to // 20 MB, so videos are converted to GIF before sending. event.CapMsgGIF: { - Caption: event.CapLevelRejected, - MaxSize: maxGIFSize, - MimeTypes: map[string]event.CapabilitySupportLevel{ - "image/gif": event.CapLevelFullySupported, - "video/mp4": event.CapLevelPartialSupport, - "video/webm": event.CapLevelPartialSupport, - }, + Caption: event.CapLevelRejected, + MaxSize: maxGIFSize, + MimeTypes: gifMimeTypes(), }, event.MsgFile: { Caption: event.CapLevelRejected, @@ -162,6 +158,9 @@ func (lc *LineClient) GetCapabilities(ctx context.Context, portal *bridgev2.Port }, }, } + if !canConvertVideoToGIF() { + caps.ID += "+no_gif_video" + } if portal != nil && portal.RoomType == database.RoomTypeDM && !lc.isOwnMID(string(portal.ID)) { caps.ID += "+dm" caps.BlockUser = true