package storage import ( "bytes" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "os/exec" "strings" "golang.org/x/image/draw" _ "golang.org/x/image/bmp" "golang.org/x/image/webp" ) // DetectContentType identifies the MIME type from magic bytes. func DetectContentType(data []byte) string { if len(data) < 2 { return "application/octet-stream" } // Check magic bytes (most specific first). switch { case len(data) > 3 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47: return "image/png" case len(data) > 2 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF: return "image/jpeg" case len(data) > 2 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46: return "image/gif" case len(data) > 1 && data[0] == 0x42 && data[1] == 0x4D: return "image/bmp" case len(data) > 3 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46: // RIFF — could be WebP or AVI if len(data) > 11 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { return "image/webp" } case len(data) > 7 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70: // ftyp box (MP4, QuickTime) return "video/mp4" case len(data) > 3 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3: return "video/webm" } return "application/octet-stream" } // ExtensionFromMIME returns the file extension (without dot) for common MIME types. func ExtensionFromMIME(mimeType string) string { switch mimeType { case "image/jpeg": return "jpg" case "image/png": return "png" case "image/gif": return "gif" case "image/webp": return "webp" case "image/bmp": return "bmp" case "video/mp4": return "mp4" case "video/webm": return "webm" default: return "bin" } } // IsImage returns true if the MIME type is a supported image format. func IsImage(mimeType string) bool { switch mimeType { case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp": return true } return false } // IsVideo returns true if the MIME type is a supported video format. func IsVideo(mimeType string) bool { switch mimeType { case "video/mp4", "video/webm": return true } return false } // StripEXIF removes EXIF (APP1) segments from JPEG data without re-compression. // Non-JPEG data is returned unchanged. func StripEXIF(data []byte) ([]byte, error) { if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 { return data, nil } var buf bytes.Buffer i := 0 for i < len(data) { if data[i] != 0xFF { buf.Write(data[i:]) break } marker := data[i+1] // SOI — no segment length if marker == 0xD8 { buf.Write(data[i : i+2]) i += 2 continue } // EOI — end of image if marker == 0xD9 { buf.Write(data[i:]) break } // Standalone markers (no segment data) if marker == 0x00 || (marker >= 0xD0 && marker <= 0xD7) { buf.Write(data[i : i+2]) i += 2 continue } // Need length bytes if i+3 >= len(data) { buf.Write(data[i:]) break } length := int(data[i+2])<<8 | int(data[i+3]) // APP1 — EXIF or XMP; skip it if marker == 0xE1 { i += 2 + length continue } // Copy all other segments verbatim end := i + 2 + length if end > len(data) { end = len(data) } buf.Write(data[i:end]) i = end } return buf.Bytes(), nil } // ThumbnailMaxDimension is the default maximum dimension for generated thumbnails. const ThumbnailMaxDimension = 300 // GenerateThumbnail decodes the image data (JPEG, PNG, GIF, WebP, BMP), resizes // it to fit within maxDimension×maxDimension (preserving aspect ratio), and // re-encodes as JPEG. Returns the thumbnail bytes. func GenerateThumbnail(data []byte, maxDimension int) ([]byte, error) { src, _, err := decodeImage(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("decode image: %w", err) } bounds := src.Bounds() w := bounds.Dx() h := bounds.Dy() if w <= maxDimension && h <= maxDimension { // Image is already small enough — encode as JPEG directly. return encodeJPEG(src) } // Compute new dimensions preserving aspect ratio. if w > h { h = h * maxDimension / w w = maxDimension } else { w = w * maxDimension / h h = maxDimension } dst := image.NewRGBA(image.Rect(0, 0, w, h)) draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) return encodeJPEG(dst) } // FFmpegAvailable returns true if ffmpeg is found on PATH. func FFmpegAvailable() bool { _, err := exec.LookPath("ffmpeg") return err == nil } // GenerateVideoThumbnail extracts a single frame from a video file using ffmpeg // and writes it to outputPath as JPEG. func GenerateVideoThumbnail(videoPath, outputPath string) error { cmd := exec.Command("ffmpeg", "-i", videoPath, "-vframes", "1", "-s", fmt.Sprintf("%dx%d", ThumbnailMaxDimension, ThumbnailMaxDimension), "-y", outputPath, ) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("ffmpeg thumbnail: %w\n%s", err, string(out)) } return nil } // ThumbnailPath returns the relative thumbnail path for a given storage path. // Convention: replace extension with "_thumb.jpg". // // "ab/cd/ef/a1b2c3d4e5f6.jpg" → "ab/cd/ef/a1b2c3d4e5f6_thumb.jpg" func ThumbnailPath(storagePath string) string { dot := strings.LastIndex(storagePath, ".") if dot < 0 { return storagePath + "_thumb.jpg" } return storagePath[:dot] + "_thumb.jpg" } // DecodeImage decodes a supported image format from raw bytes. // Returns nil if the format is not recognised. func DecodeImage(data []byte) (image.Image, string, error) { return decodeImage(bytes.NewReader(data)) } func decodeImage(r *bytes.Reader) (image.Image, string, error) { // Try standard formats first. img, format, err := image.Decode(r) if err == nil { return img, format, nil } // Reset and try WebP. r.Seek(0, 0) img, err = webp.Decode(r) if err == nil { return img, "webp", nil } return nil, "", fmt.Errorf("unsupported image format") } func encodeJPEG(img image.Image) ([]byte, error) { var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil { return nil, fmt.Errorf("encode JPEG: %w", err) } return buf.Bytes(), nil }