- 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.
107 lines
2.5 KiB
Go
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
|
|
}
|