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