Vellum/internal/modules/photos/photos.go
mithral 0212d089ec Phase 6c: photo thumbnails and viewer
- photos: generates a downscaled JPEG thumbnail per image, because serving a
  folder of 12-megapixel originals to a grid is unusable on a TV box. Uses
  CatmullRom scaling via golang.org/x/image; decodes jpeg/png/gif/webp.
- an undecodable format returns no thumbnail rather than an error, so the grid
  falls back to a placeholder.
- images already smaller than the 512px ceiling are passed through untouched.
- library items endpoint now returns library_id, which the viewer needs to
  find its siblings for navigation.
- web: PhotoViewer with keyboard and button navigation within the library.

Verified: 900x1200 PNG thumbnailed to a 512-long-edge JPEG and served;
unit tests assert exact dimensions, aspect preservation, and that non-images
produce no thumbnail without erroring.
2026-10-09 21:22:30 -04:00

107 lines
2.5 KiB
Go

// Package photos generates thumbnails for image libraries.
//
// Photos reuse the generic item, cover, and file endpoints; the only
// photo-specific work is producing a small thumbnail, because serving a
// folder of 12-megapixel originals to a grid would be unusable on a TV box.
package photos
import (
"context"
"database/sql"
"fmt"
"image"
"os"
"path/filepath"
// Decoders registered for image.Decode.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/mithral/vellum/internal/media"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// Kind is the media kind this module owns.
const Kind = "photos"
// thumbMax is the longest edge of a generated thumbnail.
const thumbMax = 512
func init() {
media.RegisterCoverExtractor(Kind, ExtractCover)
}
// ExtractCover decodes the image and writes a downscaled JPEG thumbnail.
//
// A format the standard library cannot decode returns ("", nil): no thumbnail
// is a normal outcome, and the grid falls back to a placeholder rather than
// the request failing.
func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
f, err := os.Open(absPath)
if err != nil {
return "", nil
}
defer f.Close()
src, _, err := image.Decode(f)
if err != nil {
return "", nil
}
dst := scaleToFit(src, thumbMax)
dir, err := media.CoverCacheDir(cacheDir, Kind)
if err != nil {
return "", err
}
outPath := filepath.Join(dir, fmt.Sprintf("%d.jpg", item.ID))
tmp := outPath + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return "", fmt.Errorf("photos: create thumbnail: %w", err)
}
if err := encodeJPEG(out, dst); err != nil {
out.Close()
_ = os.Remove(tmp)
return "", err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return "", err
}
if err := os.Rename(tmp, outPath); err != nil {
_ = os.Remove(tmp)
return "", err
}
return outPath, nil
}
// scaleToFit scales src down so its longest edge is at most max. Images
// already smaller than max are returned unchanged.
func scaleToFit(src image.Image, max int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= max && h <= max {
return src
}
scale := float64(max) / float64(w)
if h > w {
scale = float64(max) / float64(h)
}
nw, nh := int(float64(w)*scale), int(float64(h)*scale)
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
// CatmullRom is a good quality/speed trade for thumbnails.
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Src, nil)
return dst
}