// Package imaging produces the two derived variants from an original: // thumb (512px long edge, JPEG q78) for the grid and preview (2048px long // edge, JPEG q85) for the lightbox. Originals are never modified. // Re-encoding strips EXIF (including GPS) from variants; orientation is // applied to pixels first. package imaging import ( "fmt" "image" "image/jpeg" "io" "os" "time" _ "image/gif" _ "image/png" "github.com/rwcarlsen/goexif/exif" "golang.org/x/image/draw" _ "golang.org/x/image/webp" ) const ( ThumbLongEdge = 512 ThumbQuality = 78 PreviewLongEdge = 2048 PreviewQuality = 85 ) type Result struct { Width int // of the original, after orientation Height int TakenAt time.Time // zero when EXIF has no usable timestamp } // ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be // converted to JPEG first), applies EXIF orientation, and writes both // variants as JPEG to thumbPath and previewPath. func ProcessFile(path, thumbPath, previewPath string) (Result, error) { f, err := os.Open(path) if err != nil { return Result{}, err } orientation, takenAt := readExif(f) if _, err := f.Seek(0, io.SeekStart); err != nil { f.Close() return Result{}, err } img, _, err := image.Decode(f) f.Close() if err != nil { return Result{}, fmt.Errorf("decode: %w", err) } img = applyOrientation(img, orientation) bounds := img.Bounds() res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt} if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil { return res, fmt.Errorf("preview: %w", err) } if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil { return res, fmt.Errorf("thumb: %w", err) } return res, nil } func writeVariant(img image.Image, path string, longEdge, quality int) error { out, err := os.Create(path) if err != nil { return err } defer out.Close() return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality}) } // resize scales so the long edge is at most longEdge, never upscaling. func resize(img image.Image, longEdge int) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() long := max(w, h) if long <= longEdge { return img } scale := float64(longEdge) / float64(long) nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)) dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) return dst } func readExif(r io.Reader) (orientation int, takenAt time.Time) { orientation = 1 x, err := exif.Decode(r) if err != nil { return orientation, takenAt } if tag, err := x.Get(exif.Orientation); err == nil { if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 { orientation = v } } if t, err := x.DateTime(); err == nil { takenAt = t } return orientation, takenAt } // applyOrientation bakes the EXIF orientation into pixels (values 2-8 per // the EXIF spec; 1 is identity). func applyOrientation(img image.Image, orientation int) image.Image { switch orientation { case 2: return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y }) case 3: return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y }) case 4: return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y }) case 5: return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x }) case 6: return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x }) case 7: return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x }) case 8: return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x }) default: return img } } // transform maps destination (x,y) to source coordinates, same dimensions. func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() dst := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { sx, sy := srcAt(x, y, w, h) dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) } } return dst } // transformSwap is for orientations that swap width and height. func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() dst := image.NewRGBA(image.Rect(0, 0, h, w)) for y := 0; y < w; y++ { for x := 0; x < h; x++ { sx, sy := srcAt(x, y, w, h) dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy)) } } return dst }