all repos — weimar @ 3d9ddf2add48689bb513ed4f59a427849dfe0805

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	"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}