From 79527c0ab16836f93326d062230c9d58c39a4cfa Mon Sep 17 00:00:00 2001 From: RedwindA Date: Thu, 2 Apr 2026 16:40:45 +0800 Subject: [PATCH] feat: add HEIC/HEIF image format support Add detection, MIME type mapping, and dimension parsing for HEIC/HEIF images via ISOBMFF ftyp brand inspection and ispe box parsing. Update Gemini relay to accept these formats and refactor getImageConfig to properly retry decoders using buffered data. --- relay/channel/gemini/relay-gemini.go | 2 + service/file_decoder.go | 9 +++ service/file_service.go | 100 +++++++++++++++++++++++++++ service/image.go | 42 +++++++---- 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index 1b92e4ff..2f1e7ecb 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{ "image/jpeg": true, "image/jpg": true, // support old image/jpeg "image/webp": true, + "image/heic": true, + "image/heif": true, "text/plain": true, "video/mov": true, "video/mpeg": true, diff --git a/service/file_decoder.go b/service/file_decoder.go index d5831d8c..57898af7 100644 --- a/service/file_decoder.go +++ b/service/file_decoder.go @@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e return sniffed, nil } + // Try HEIF/HEIC detection (Go standard library doesn't recognize it) + if heifMime := detectHEIF(readData); heifMime != "" { + return heifMime, nil + } + if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil { switch strings.ToLower(format) { case "jpeg", "jpg": @@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string { return "image/gif" case "jfif": return "image/jpeg" + case "heic": + return "image/heic" + case "heif": + return "image/heif" // Audio files case "mp3": diff --git a/service/file_service.go b/service/file_service.go index c592aa47..6d1f5cfc 100644 --- a/service/file_service.go +++ b/service/file_service.go @@ -3,6 +3,7 @@ package service import ( "bytes" "encoding/base64" + "encoding/binary" "fmt" "image" _ "image/gif" @@ -275,6 +276,11 @@ func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) stri } return sniffed } + + // 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别) + if heifMime := detectHEIF(fileBytes); heifMime != "" { + return heifMime + } } // 5. 尝试作为图片解码获取格式 @@ -449,9 +455,103 @@ func decodeImageConfig(data []byte) (image.Config, string, error) { return config, "webp", nil } + // Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions + if heifMime := detectHEIF(data); heifMime != "" { + formatName := "heif" + if heifMime == "image/heic" { + formatName = "heic" + } + if w, h, ok := parseHEIFDimensions(data); ok { + return image.Config{Width: w, Height: h}, formatName, nil + } + return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions") + } + return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format") } +// detectHEIF checks ISOBMFF magic bytes to detect HEIC/HEIF files. +// Returns "image/heic", "image/heif", or "" if not recognized. +func detectHEIF(data []byte) string { + if len(data) < 12 { + return "" + } + // ISOBMFF: bytes[4:8] must be "ftyp" + if string(data[4:8]) != "ftyp" { + return "" + } + brand := string(data[8:12]) + switch brand { + case "heic", "heix", "hevc", "hevx", "heim", "heis": + return "image/heic" + case "mif1", "msf1": + return "image/heif" + default: + return "" + } +} + +// parseHEIFDimensions parses ISOBMFF box tree to find the ispe box +// and extract image width/height. Returns (width, height, ok). +func parseHEIFDimensions(data []byte) (int, int, bool) { + size := len(data) + if size < 12 { + return 0, 0, false + } + + // Walk top-level boxes to find "meta" + offset := 0 + for offset+8 <= size { + boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4])) + boxType := string(data[offset+4 : offset+8]) + if boxSize < 8 || offset+boxSize > size { + break + } + if boxType == "meta" { + // meta is a full box: 4 bytes version/flags after header + metaData := data[offset+8 : offset+boxSize] + if len(metaData) < 4 { + return 0, 0, false + } + return findISPE(metaData[4:]) + } + offset += boxSize + } + return 0, 0, false +} + +// findISPE recursively searches for the ispe box within container boxes. +// Path: meta -> iprp -> ipco -> ispe +func findISPE(data []byte) (int, int, bool) { + offset := 0 + size := len(data) + for offset+8 <= size { + boxSize := int(binary.BigEndian.Uint32(data[offset : offset+4])) + boxType := string(data[offset+4 : offset+8]) + if boxSize < 8 || offset+boxSize > size { + break + } + content := data[offset+8 : offset+boxSize] + switch boxType { + case "iprp", "ipco": + if w, h, ok := findISPE(content); ok { + return w, h, true + } + case "ispe": + // ispe is a full box: 4 bytes version/flags, then 4 bytes width, 4 bytes height + if len(content) >= 12 { + w := int(binary.BigEndian.Uint32(content[4:8])) + h := int(binary.BigEndian.Uint32(content[8:12])) + if w > 0 && h > 0 { + return w, h, true + } + } + } + offset += boxSize + } + return 0, 0, false +} + // guessMimeTypeFromURL 从 URL 猜测 MIME 类型 func guessMimeTypeFromURL(url string) string { cleanedURL := url diff --git a/service/image.go b/service/image.go index fa5c175b..66181f6c 100644 --- a/service/image.go +++ b/service/image.go @@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) { } func getImageConfig(reader io.Reader) (image.Config, string, error) { + // Read all data so we can retry with different decoders + data, readErr := io.ReadAll(reader) + if readErr != nil { + return image.Config{}, "", fmt.Errorf("failed to read image data: %w", readErr) + } + // 读取图片的头部信息来获取图片尺寸 - config, format, err := image.DecodeConfig(reader) - if err != nil { - err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error())) - common.SysLog(err.Error()) - config, err = webp.DecodeConfig(reader) - if err != nil { - err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error())) - common.SysLog(err.Error()) + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err == nil { + return config, format, nil + } + common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error())) + + config, err = webp.DecodeConfig(bytes.NewReader(data)) + if err == nil { + return config, "webp", nil + } + common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error())) + + // Try HEIF/HEIC: parse ISOBMFF ispe box for dimensions + if heifMime := detectHEIF(data); heifMime != "" { + formatName := "heif" + if heifMime == "image/heic" { + formatName = "heic" } - format = "webp" + if w, h, ok := parseHEIFDimensions(data); ok { + return image.Config{Width: w, Height: h}, formatName, nil + } + return image.Config{}, "", fmt.Errorf("failed to decode HEIF/HEIC image dimensions") } - if err != nil { - return image.Config{}, "", err - } - return config, format, nil + + return image.Config{}, "", err }