internal/storage/processing.go (view raw)
1package storage
2
3import (
4 "bytes"
5 "fmt"
6 "image"
7 _ "image/gif"
8 "image/jpeg"
9 _ "image/png"
10 "os/exec"
11 "strings"
12
13 "golang.org/x/image/draw"
14 _ "golang.org/x/image/bmp"
15 "golang.org/x/image/webp"
16)
17
18// DetectContentType identifies the MIME type from magic bytes.
19func DetectContentType(data []byte) string {
20 if len(data) < 2 {
21 return "application/octet-stream"
22 }
23
24 // Check magic bytes (most specific first).
25 switch {
26 case len(data) > 3 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47:
27 return "image/png"
28 case len(data) > 2 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
29 return "image/jpeg"
30 case len(data) > 2 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46:
31 return "image/gif"
32 case len(data) > 1 && data[0] == 0x42 && data[1] == 0x4D:
33 return "image/bmp"
34 case len(data) > 3 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46:
35 // RIFF — could be WebP or AVI
36 if len(data) > 11 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
37 return "image/webp"
38 }
39 case len(data) > 7 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70:
40 // ftyp box (MP4, QuickTime)
41 return "video/mp4"
42 case len(data) > 3 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
43 return "video/webm"
44 }
45
46 return "application/octet-stream"
47}
48
49// ExtensionFromMIME returns the file extension (without dot) for common MIME types.
50func ExtensionFromMIME(mimeType string) string {
51 switch mimeType {
52 case "image/jpeg":
53 return "jpg"
54 case "image/png":
55 return "png"
56 case "image/gif":
57 return "gif"
58 case "image/webp":
59 return "webp"
60 case "image/bmp":
61 return "bmp"
62 case "video/mp4":
63 return "mp4"
64 case "video/webm":
65 return "webm"
66 default:
67 return "bin"
68 }
69}
70
71// IsImage returns true if the MIME type is a supported image format.
72func IsImage(mimeType string) bool {
73 switch mimeType {
74 case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp":
75 return true
76 }
77 return false
78}
79
80// IsVideo returns true if the MIME type is a supported video format.
81func IsVideo(mimeType string) bool {
82 switch mimeType {
83 case "video/mp4", "video/webm":
84 return true
85 }
86 return false
87}
88
89// StripEXIF removes EXIF (APP1) segments from JPEG data without re-compression.
90// Non-JPEG data is returned unchanged.
91func StripEXIF(data []byte) ([]byte, error) {
92 if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 {
93 return data, nil
94 }
95
96 var buf bytes.Buffer
97 i := 0
98 for i < len(data) {
99 if data[i] != 0xFF {
100 buf.Write(data[i:])
101 break
102 }
103
104 marker := data[i+1]
105
106 // SOI — no segment length
107 if marker == 0xD8 {
108 buf.Write(data[i : i+2])
109 i += 2
110 continue
111 }
112
113 // EOI — end of image
114 if marker == 0xD9 {
115 buf.Write(data[i:])
116 break
117 }
118
119 // Standalone markers (no segment data)
120 if marker == 0x00 || (marker >= 0xD0 && marker <= 0xD7) {
121 buf.Write(data[i : i+2])
122 i += 2
123 continue
124 }
125
126 // Need length bytes
127 if i+3 >= len(data) {
128 buf.Write(data[i:])
129 break
130 }
131
132 length := int(data[i+2])<<8 | int(data[i+3])
133
134 // APP1 — EXIF or XMP; skip it
135 if marker == 0xE1 {
136 i += 2 + length
137 continue
138 }
139
140 // Copy all other segments verbatim
141 end := i + 2 + length
142 if end > len(data) {
143 end = len(data)
144 }
145 buf.Write(data[i:end])
146 i = end
147 }
148
149 return buf.Bytes(), nil
150}
151
152// ThumbnailMaxDimension is the default maximum dimension for generated thumbnails.
153const ThumbnailMaxDimension = 300
154
155// GenerateThumbnail decodes the image data (JPEG, PNG, GIF, WebP, BMP), resizes
156// it to fit within maxDimension×maxDimension (preserving aspect ratio), and
157// re-encodes as JPEG. Returns the thumbnail bytes.
158func GenerateThumbnail(data []byte, maxDimension int) ([]byte, error) {
159 src, _, err := decodeImage(bytes.NewReader(data))
160 if err != nil {
161 return nil, fmt.Errorf("decode image: %w", err)
162 }
163
164 bounds := src.Bounds()
165 w := bounds.Dx()
166 h := bounds.Dy()
167
168 if w <= maxDimension && h <= maxDimension {
169 // Image is already small enough — encode as JPEG directly.
170 return encodeJPEG(src)
171 }
172
173 // Compute new dimensions preserving aspect ratio.
174 if w > h {
175 h = h * maxDimension / w
176 w = maxDimension
177 } else {
178 w = w * maxDimension / h
179 h = maxDimension
180 }
181
182 dst := image.NewRGBA(image.Rect(0, 0, w, h))
183 draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
184
185 return encodeJPEG(dst)
186}
187
188// FFmpegAvailable returns true if ffmpeg is found on PATH.
189func FFmpegAvailable() bool {
190 _, err := exec.LookPath("ffmpeg")
191 return err == nil
192}
193
194// GenerateVideoThumbnail extracts a single frame from a video file using ffmpeg
195// and writes it to outputPath as JPEG.
196func GenerateVideoThumbnail(videoPath, outputPath string) error {
197 cmd := exec.Command("ffmpeg",
198 "-i", videoPath,
199 "-vframes", "1",
200 "-s", fmt.Sprintf("%dx%d", ThumbnailMaxDimension, ThumbnailMaxDimension),
201 "-y",
202 outputPath,
203 )
204 if out, err := cmd.CombinedOutput(); err != nil {
205 return fmt.Errorf("ffmpeg thumbnail: %w\n%s", err, string(out))
206 }
207 return nil
208}
209
210// ThumbnailPath returns the relative thumbnail path for a given storage path.
211// Convention: replace extension with "_thumb.jpg".
212//
213// "ab/cd/ef/a1b2c3d4e5f6.jpg" → "ab/cd/ef/a1b2c3d4e5f6_thumb.jpg"
214func ThumbnailPath(storagePath string) string {
215 dot := strings.LastIndex(storagePath, ".")
216 if dot < 0 {
217 return storagePath + "_thumb.jpg"
218 }
219 return storagePath[:dot] + "_thumb.jpg"
220}
221
222// DecodeImage decodes a supported image format from raw bytes.
223// Returns nil if the format is not recognised.
224func DecodeImage(data []byte) (image.Image, string, error) {
225 return decodeImage(bytes.NewReader(data))
226}
227
228func decodeImage(r *bytes.Reader) (image.Image, string, error) {
229 // Try standard formats first.
230 img, format, err := image.Decode(r)
231 if err == nil {
232 return img, format, nil
233 }
234
235 // Reset and try WebP.
236 r.Seek(0, 0)
237 img, err = webp.Decode(r)
238 if err == nil {
239 return img, "webp", nil
240 }
241
242 return nil, "", fmt.Errorf("unsupported image format")
243}
244
245func encodeJPEG(img image.Image) ([]byte, error) {
246 var buf bytes.Buffer
247 if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
248 return nil, fmt.Errorf("encode JPEG: %w", err)
249 }
250 return buf.Bytes(), nil
251}