Просмотр исходного кода

feat: add HEIC/HEIF image format support for Gemini channel (#4049)

* 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.

* fix: handle ISOBMFF extended sizes in HEIF dimension parser

parseHEIFDimensions now correctly handles boxSize==1 (64-bit extended
size) and boxSize==0 (box-to-EOF), preventing the parser from breaking
out of the loop when encountering these valid ISOBMFF box headers
before reaching the meta box.
Calcium-Ion 1 месяц назад
Родитель
Сommit
9816ad87e3
4 измененных файлов с 155 добавлено и 13 удалено
  1. 2 0
      relay/channel/gemini/relay-gemini.go
  2. 9 0
      service/file_decoder.go
  3. 115 0
      service/file_service.go
  4. 29 13
      service/image.go

+ 2 - 0
relay/channel/gemini/relay-gemini.go

@@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
 	"image/jpeg":      true,
 	"image/jpeg":      true,
 	"image/jpg":       true, // support old image/jpeg
 	"image/jpg":       true, // support old image/jpeg
 	"image/webp":      true,
 	"image/webp":      true,
+	"image/heic":      true,
+	"image/heif":      true,
 	"text/plain":      true,
 	"text/plain":      true,
 	"video/mov":       true,
 	"video/mov":       true,
 	"video/mpeg":      true,
 	"video/mpeg":      true,

+ 9 - 0
service/file_decoder.go

@@ -104,6 +104,11 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
 			return sniffed, nil
 			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 {
 		if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil {
 			switch strings.ToLower(format) {
 			switch strings.ToLower(format) {
 			case "jpeg", "jpg":
 			case "jpeg", "jpg":
@@ -168,6 +173,10 @@ func GetMimeTypeByExtension(ext string) string {
 		return "image/gif"
 		return "image/gif"
 	case "jfif":
 	case "jfif":
 		return "image/jpeg"
 		return "image/jpeg"
+	case "heic":
+		return "image/heic"
+	case "heif":
+		return "image/heif"
 
 
 	// Audio files
 	// Audio files
 	case "mp3":
 	case "mp3":

+ 115 - 0
service/file_service.go

@@ -3,6 +3,7 @@ package service
 import (
 import (
 	"bytes"
 	"bytes"
 	"encoding/base64"
 	"encoding/base64"
+	"encoding/binary"
 	"fmt"
 	"fmt"
 	"image"
 	"image"
 	_ "image/gif"
 	_ "image/gif"
@@ -275,6 +276,11 @@ func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) stri
 			}
 			}
 			return sniffed
 			return sniffed
 		}
 		}
+
+		// 4.5 尝试 HEIF/HEIC 检测(Go 标准库不识别)
+		if heifMime := detectHEIF(fileBytes); heifMime != "" {
+			return heifMime
+		}
 	}
 	}
 
 
 	// 5. 尝试作为图片解码获取格式
 	// 5. 尝试作为图片解码获取格式
@@ -449,9 +455,118 @@ func decodeImageConfig(data []byte) (image.Config, string, error) {
 		return config, "webp", nil
 		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")
 	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])
+		headerLen := 8
+
+		if boxSize == 1 {
+			// 64-bit extended size
+			if offset+16 > size {
+				break
+			}
+			boxSize = int(binary.BigEndian.Uint64(data[offset+8 : offset+16]))
+			headerLen = 16
+		} else if boxSize == 0 {
+			// box extends to end of data
+			boxSize = size - offset
+		}
+
+		if boxSize < headerLen || offset+boxSize > size {
+			break
+		}
+
+		if boxType == "meta" {
+			// meta is a full box: 4 bytes version/flags after header
+			metaData := data[offset+headerLen : 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 类型
 // guessMimeTypeFromURL 从 URL 猜测 MIME 类型
 func guessMimeTypeFromURL(url string) string {
 func guessMimeTypeFromURL(url string) string {
 	cleanedURL := url
 	cleanedURL := url

+ 29 - 13
service/image.go

@@ -159,20 +159,36 @@ func DecodeUrlImageData(imageUrl string) (image.Config, string, error) {
 }
 }
 
 
 func getImageConfig(reader io.Reader) (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())
-		}
-		format = "webp"
+	config, format, err := image.DecodeConfig(bytes.NewReader(data))
+	if err == nil {
+		return config, format, nil
 	}
 	}
-	if err != nil {
-		return image.Config{}, "", err
+	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"
+		}
+		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 config, format, nil
+
+	return image.Config{}, "", err
 }
 }