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 "strconv"
12 "strings"
13
14 "golang.org/x/image/draw"
15 _ "golang.org/x/image/bmp"
16 "golang.org/x/image/webp"
17)
18
19// DetectContentType identifies the MIME type from magic bytes.
20func DetectContentType(data []byte) string {
21 if len(data) < 2 {
22 return "application/octet-stream"
23 }
24
25 // Check magic bytes (most specific first).
26 switch {
27 case len(data) > 3 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47:
28 return "image/png"
29 case len(data) > 2 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
30 return "image/jpeg"
31 case len(data) > 2 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46:
32 return "image/gif"
33 case len(data) > 1 && data[0] == 0x42 && data[1] == 0x4D:
34 return "image/bmp"
35 case len(data) > 3 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46:
36 // RIFF — could be WebP or AVI
37 if len(data) > 11 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
38 return "image/webp"
39 }
40 case len(data) > 7 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70:
41 // ftyp box (MP4, QuickTime)
42 return "video/mp4"
43 case len(data) > 3 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
44 return "video/webm"
45 }
46
47 return "application/octet-stream"
48}
49
50// ExtensionFromMIME returns the file extension (without dot) for common MIME types.
51func ExtensionFromMIME(mimeType string) string {
52 switch mimeType {
53 case "image/jpeg":
54 return "jpg"
55 case "image/png":
56 return "png"
57 case "image/gif":
58 return "gif"
59 case "image/webp":
60 return "webp"
61 case "image/bmp":
62 return "bmp"
63 case "video/mp4":
64 return "mp4"
65 case "video/webm":
66 return "webm"
67 default:
68 return "bin"
69 }
70}
71
72// IsImage returns true if the MIME type is a supported image format.
73func IsImage(mimeType string) bool {
74 switch mimeType {
75 case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp":
76 return true
77 }
78 return false
79}
80
81// IsVideo returns true if the MIME type is a supported video format.
82func IsVideo(mimeType string) bool {
83 switch mimeType {
84 case "video/mp4", "video/webm":
85 return true
86 }
87 return false
88}
89
90// StripEXIF removes EXIF (APP1) segments from JPEG data without re-compression.
91// Non-JPEG data is returned unchanged.
92func StripEXIF(data []byte) ([]byte, error) {
93 if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 {
94 return data, nil
95 }
96
97 var buf bytes.Buffer
98 i := 0
99 for i < len(data) {
100 if data[i] != 0xFF {
101 buf.Write(data[i:])
102 break
103 }
104
105 marker := data[i+1]
106
107 // SOI — no segment length
108 if marker == 0xD8 {
109 buf.Write(data[i : i+2])
110 i += 2
111 continue
112 }
113
114 // EOI — end of image
115 if marker == 0xD9 {
116 buf.Write(data[i:])
117 break
118 }
119
120 // Standalone markers (no segment data)
121 if marker == 0x00 || (marker >= 0xD0 && marker <= 0xD7) {
122 buf.Write(data[i : i+2])
123 i += 2
124 continue
125 }
126
127 // Need length bytes
128 if i+3 >= len(data) {
129 buf.Write(data[i:])
130 break
131 }
132
133 length := int(data[i+2])<<8 | int(data[i+3])
134
135 // APP1 — EXIF or XMP; skip it
136 if marker == 0xE1 {
137 i += 2 + length
138 continue
139 }
140
141 // Copy all other segments verbatim
142 end := i + 2 + length
143 if end > len(data) {
144 end = len(data)
145 }
146 buf.Write(data[i:end])
147 i = end
148 }
149
150 return buf.Bytes(), nil
151}
152
153// ThumbnailMaxDimension is the default maximum dimension for generated thumbnails.
154const ThumbnailMaxDimension = 300
155
156// GenerateThumbnail decodes the image data (JPEG, PNG, GIF, WebP, BMP), resizes
157// it to fit within maxDimension×maxDimension (preserving aspect ratio), and
158// re-encodes as JPEG. Returns the thumbnail bytes.
159func GenerateThumbnail(data []byte, maxDimension int) ([]byte, error) {
160 src, _, err := decodeImage(bytes.NewReader(data))
161 if err != nil {
162 return nil, fmt.Errorf("decode image: %w", err)
163 }
164
165 bounds := src.Bounds()
166 w := bounds.Dx()
167 h := bounds.Dy()
168
169 if w <= maxDimension && h <= maxDimension {
170 // Image is already small enough — encode as JPEG directly.
171 return encodeJPEG(src)
172 }
173
174 // Compute new dimensions preserving aspect ratio.
175 if w > h {
176 h = h * maxDimension / w
177 w = maxDimension
178 } else {
179 w = w * maxDimension / h
180 h = maxDimension
181 }
182
183 dst := image.NewRGBA(image.Rect(0, 0, w, h))
184 draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
185
186 return encodeJPEG(dst)
187}
188
189// FFmpegAvailable returns true if ffmpeg is found on PATH.
190func FFmpegAvailable() bool {
191 _, err := exec.LookPath("ffmpeg")
192 return err == nil
193}
194
195// GenerateVideoThumbnail extracts a single frame from a video file using ffmpeg
196// and writes it to outputPath as JPEG.
197func GenerateVideoThumbnail(videoPath, outputPath string) error {
198 cmd := exec.Command("ffmpeg",
199 "-i", videoPath,
200 "-vframes", "1",
201 "-s", fmt.Sprintf("%dx%d", ThumbnailMaxDimension, ThumbnailMaxDimension),
202 "-y",
203 outputPath,
204 )
205 if out, err := cmd.CombinedOutput(); err != nil {
206 return fmt.Errorf("ffmpeg thumbnail: %w\n%s", err, string(out))
207 }
208 return nil
209}
210
211// ThumbnailPath returns the relative thumbnail path for a given storage path.
212// Convention: replace extension with "_thumb.jpg".
213//
214// "ab/cd/ef/a1b2c3d4e5f6.jpg" → "ab/cd/ef/a1b2c3d4e5f6_thumb.jpg"
215func ThumbnailPath(storagePath string) string {
216 dot := strings.LastIndex(storagePath, ".")
217 if dot < 0 {
218 return storagePath + "_thumb.jpg"
219 }
220 return storagePath[:dot] + "_thumb.jpg"
221}
222
223// DecodeImage decodes a supported image format from raw bytes.
224// Returns nil if the format is not recognised.
225func DecodeImage(data []byte) (image.Image, string, error) {
226 return decodeImage(bytes.NewReader(data))
227}
228
229func decodeImage(r *bytes.Reader) (image.Image, string, error) {
230 // Try standard formats first.
231 img, format, err := image.Decode(r)
232 if err == nil {
233 return img, format, nil
234 }
235
236 // Reset and try WebP.
237 r.Seek(0, 0)
238 img, err = webp.Decode(r)
239 if err == nil {
240 return img, "webp", nil
241 }
242
243 return nil, "", fmt.Errorf("unsupported image format")
244}
245
246func encodeJPEG(img image.Image) ([]byte, error) {
247 var buf bytes.Buffer
248 if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
249 return nil, fmt.Errorf("encode JPEG: %w", err)
250 }
251 return buf.Bytes(), nil
252}
253
254// getVideoDuration is the implementation used by ComputeInnateTags.
255// It is a variable so tests can override it.
256var getVideoDuration = func(videoPath string) float64 {
257 cmd := exec.Command("ffprobe",
258 "-v", "error",
259 "-show_entries", "format=duration",
260 "-of", "csv=p=0",
261 videoPath,
262 )
263 out, err := cmd.Output()
264 if err != nil {
265 return 0
266 }
267 dur, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
268 if err != nil {
269 return 0
270 }
271 return dur
272}
273
274// GetVideoDuration returns the duration of a video file in seconds using ffprobe.
275// Returns 0 if ffprobe is unavailable or the probe fails.
276func GetVideoDuration(videoPath string) float64 {
277 return getVideoDuration(videoPath)
278}
279
280// ComputeInnateTags determines the set of automatically-assigned tags for a
281// piece of media based on its MIME type, dimensions, and (for videos) duration.
282// The videoPath argument is used only for video duration probing; pass an empty
283// string for images.
284func ComputeInnateTags(mimeType string, width, height int, videoPath string) []string {
285 tags := make([]string, 0, 4)
286
287 isImage := IsImage(mimeType)
288 isVideo := IsVideo(mimeType)
289
290 if isImage {
291 tags = append(tags, "image")
292 }
293 if isVideo {
294 tags = append(tags, "video")
295 }
296 if mimeType == "image/gif" {
297 tags = append(tags, "gif")
298 }
299
300 // Resolution-based tags (images only).
301 if isImage {
302 if width < 256 && height < 256 {
303 tags = append(tags, "lowres")
304 }
305 if width > 1920 || height > 1920 {
306 tags = append(tags, "highres")
307 }
308 }
309
310 // Duration-based tags (videos only).
311 if isVideo && videoPath != "" {
312 duration := GetVideoDuration(videoPath)
313 if duration > 10 {
314 tags = append(tags, ">10 sec")
315 } else if duration > 0 && duration < 10 {
316 tags = append(tags, "<10 sec")
317 }
318 }
319
320 return tags
321}