media.go raw
1 package blossom
2
3 import (
4 "bytes"
5 "image"
6 "image/jpeg"
7 "strings"
8
9 "github.com/disintegration/imaging"
10 )
11
12 // ThumbnailSize defines the maximum dimension for thumbnails (used for list display)
13 const ThumbnailSize = 128
14
15 // ImageVariant represents a responsive image size category per NIP-XX
16 // Selection rule: pick smallest variant >= target width (next-larger)
17 type ImageVariant string
18
19 const (
20 VariantThumb ImageVariant = "thumb" // 128px - list display, previews
21 VariantMobileSm ImageVariant = "mobile-sm" // 512px - small mobile
22 VariantMobileLg ImageVariant = "mobile-lg" // 1024px - large mobile, small tablets
23 VariantDesktopSm ImageVariant = "desktop-sm" // 1536px - laptops
24 VariantDesktopMd ImageVariant = "desktop-md" // 2048px - standard desktops
25 VariantDesktopLg ImageVariant = "desktop-lg" // 2560px - large/HiDPI displays
26 VariantOriginal ImageVariant = "original" // unchanged size, EXIF stripped
27 )
28
29 // VariantSpec defines the target width and JPEG quality for a variant
30 type VariantSpec struct {
31 Width int
32 Quality int
33 }
34
35 // VariantSpecs maps variant types to their specifications
36 var VariantSpecs = map[ImageVariant]VariantSpec{
37 VariantThumb: {Width: 128, Quality: 70},
38 VariantMobileSm: {Width: 512, Quality: 75},
39 VariantMobileLg: {Width: 1024, Quality: 80},
40 VariantDesktopSm: {Width: 1536, Quality: 85},
41 VariantDesktopMd: {Width: 2048, Quality: 88},
42 VariantDesktopLg: {Width: 2560, Quality: 90},
43 VariantOriginal: {Width: 0, Quality: 92}, // Width 0 means keep original
44 }
45
46 // ScaledImage represents a generated image variant
47 type ScaledImage struct {
48 Variant ImageVariant
49 Data []byte
50 Width int
51 Height int
52 MimeType string
53 }
54
55 // OptimizeMedia optimizes media content (BUD-05)
56 // This is a placeholder implementation - actual optimization would use
57 // libraries like image processing, video encoding, etc.
58 func OptimizeMedia(data []byte, mimeType string) (optimizedData []byte, optimizedMimeType string) {
59 // For now, just return the original data unchanged
60 // In a real implementation, this would:
61 // - Resize images to optimal dimensions
62 // - Compress images (JPEG quality, PNG optimization)
63 // - Convert formats if beneficial
64 // - Optimize video encoding
65 // - etc.
66
67 optimizedData = data
68 optimizedMimeType = mimeType
69 return
70 }
71
72 // GenerateThumbnail creates a thumbnail from image data using Lanczos resampling.
73 // Returns the thumbnail data, MIME type, and any error.
74 // Thumbnails are always JPEG for smaller file sizes.
75 func GenerateThumbnail(data []byte, mimeType string, maxSize int) ([]byte, string, error) {
76 if maxSize <= 0 {
77 maxSize = ThumbnailSize
78 }
79
80 // Decode using imaging library (automatically strips EXIF)
81 reader := bytes.NewReader(data)
82 img, err := imaging.Decode(reader, imaging.AutoOrientation(true))
83 if err != nil {
84 return nil, "", err
85 }
86
87 bounds := img.Bounds()
88 origWidth := bounds.Dx()
89 origHeight := bounds.Dy()
90
91 // Don't upscale small images
92 if origWidth <= maxSize && origHeight <= maxSize {
93 // Return a compressed version of the original
94 var buf bytes.Buffer
95 if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
96 return nil, "", err
97 }
98 return buf.Bytes(), "image/jpeg", nil
99 }
100
101 // Calculate new dimensions maintaining aspect ratio
102 var newWidth, newHeight int
103 if origWidth > origHeight {
104 newWidth = maxSize
105 newHeight = (origHeight * maxSize) / origWidth
106 } else {
107 newHeight = maxSize
108 newWidth = (origWidth * maxSize) / origHeight
109 }
110
111 // Ensure minimum dimensions
112 if newWidth < 1 {
113 newWidth = 1
114 }
115 if newHeight < 1 {
116 newHeight = 1
117 }
118
119 // Resize using Lanczos (sinc-based) for highest quality
120 thumb := imaging.Resize(img, newWidth, newHeight, imaging.Lanczos)
121
122 // Encode as JPEG for smaller file size
123 var buf bytes.Buffer
124 if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil {
125 return nil, "", err
126 }
127
128 return buf.Bytes(), "image/jpeg", nil
129 }
130
131 // GenerateResponsiveVariants creates multiple resolution variants of an image.
132 // Uses Lanczos resampling for high-quality downscaling.
133 // Returns variants from smallest to largest: thumb, mobile, desktop, original.
134 // EXIF metadata is automatically stripped from all variants.
135 func GenerateResponsiveVariants(data []byte, mimeType string) ([]ScaledImage, error) {
136 // Decode using imaging library (automatically strips EXIF and handles orientation)
137 reader := bytes.NewReader(data)
138 img, err := imaging.Decode(reader, imaging.AutoOrientation(true))
139 if err != nil {
140 return nil, err
141 }
142
143 bounds := img.Bounds()
144 origWidth := bounds.Dx()
145 origHeight := bounds.Dy()
146
147 // Order of variants from smallest to largest
148 variantOrder := []ImageVariant{
149 VariantThumb,
150 VariantMobileSm,
151 VariantMobileLg,
152 VariantDesktopSm,
153 VariantDesktopMd,
154 VariantDesktopLg,
155 VariantOriginal,
156 }
157 variants := make([]ScaledImage, 0, len(variantOrder))
158
159 for _, variant := range variantOrder {
160 spec := VariantSpecs[variant]
161
162 var resized image.Image
163 var newWidth, newHeight int
164
165 if spec.Width == 0 || origWidth <= spec.Width {
166 // Original variant or image smaller than target - just compress
167 resized = img
168 newWidth = origWidth
169 newHeight = origHeight
170 } else {
171 // Calculate height maintaining aspect ratio
172 newWidth = spec.Width
173 newHeight = (origHeight * spec.Width) / origWidth
174 if newHeight < 1 {
175 newHeight = 1
176 }
177 // Resize using Lanczos for highest quality
178 resized = imaging.Resize(img, newWidth, newHeight, imaging.Lanczos)
179 }
180
181 // Encode as JPEG
182 var buf bytes.Buffer
183 if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: spec.Quality}); err != nil {
184 return nil, err
185 }
186
187 variants = append(variants, ScaledImage{
188 Variant: variant,
189 Data: buf.Bytes(),
190 Width: newWidth,
191 Height: newHeight,
192 MimeType: "image/jpeg",
193 })
194 }
195
196 return variants, nil
197 }
198
199 // IsImageMimeType returns true if the MIME type is a supported image format
200 func IsImageMimeType(mimeType string) bool {
201 switch {
202 case strings.HasPrefix(mimeType, "image/jpeg"),
203 strings.HasPrefix(mimeType, "image/png"),
204 strings.HasPrefix(mimeType, "image/gif"),
205 strings.HasPrefix(mimeType, "image/webp"):
206 return true
207 default:
208 return false
209 }
210 }
211