all repos — weimar @ 8dcf6e79e30fa40a7f7da17169314b250793210c

Unnamed repository; edit this file 'description' to name the repository.

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}