Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user