- 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.
112 lines
2.5 KiB
Go
112 lines
2.5 KiB
Go
package photos
|
|
|
|
import (
|
|
"context"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/internal/media"
|
|
)
|
|
|
|
func writePNG(t *testing.T, w, h int) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "photo.png")
|
|
f, err := os.Create(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
|
|
}
|
|
}
|
|
if err := png.Encode(f, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func decode(t *testing.T, path string) image.Image {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
img, _, err := image.Decode(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return img
|
|
}
|
|
|
|
func TestExtractCoverDownscales(t *testing.T) {
|
|
src := writePNG(t, 1000, 1500)
|
|
cacheDir := t.TempDir()
|
|
item := media.Item{ID: 7, Kind: Kind}
|
|
|
|
out, err := ExtractCover(context.Background(), nil, item, src, cacheDir)
|
|
if err != nil {
|
|
t.Fatalf("ExtractCover: %v", err)
|
|
}
|
|
if out == "" {
|
|
t.Fatal("no thumbnail produced")
|
|
}
|
|
|
|
img := decode(t, out)
|
|
b := img.Bounds()
|
|
if b.Dx() > thumbMax || b.Dy() > thumbMax {
|
|
t.Errorf("thumbnail is %dx%d, exceeds %d", b.Dx(), b.Dy(), thumbMax)
|
|
}
|
|
// Aspect ratio must be preserved: 1000x1500 -> 341x512.
|
|
if b.Dy() != thumbMax {
|
|
t.Errorf("long edge = %d, want %d", b.Dy(), thumbMax)
|
|
}
|
|
if b.Dx() != 341 {
|
|
t.Errorf("short edge = %d, want 341", b.Dx())
|
|
}
|
|
|
|
// It must actually be a JPEG.
|
|
f, err := os.Open(out)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := jpeg.Decode(f); err != nil {
|
|
t.Errorf("output is not a JPEG: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractCoverLeavesSmallImagesAlone(t *testing.T) {
|
|
src := writePNG(t, 100, 80)
|
|
out, err := ExtractCover(context.Background(), nil, media.Item{ID: 1, Kind: Kind}, src, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("ExtractCover: %v", err)
|
|
}
|
|
img := decode(t, out)
|
|
if img.Bounds().Dx() != 100 || img.Bounds().Dy() != 80 {
|
|
t.Errorf("small image was resized to %v", img.Bounds())
|
|
}
|
|
}
|
|
|
|
func TestExtractCoverIgnoresNonImage(t *testing.T) {
|
|
p := filepath.Join(t.TempDir(), "not-an-image.jpg")
|
|
if err := os.WriteFile(p, []byte("definitely not an image"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := ExtractCover(context.Background(), nil, media.Item{ID: 1, Kind: Kind}, p, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("a non-image must not error: %v", err)
|
|
}
|
|
if out != "" {
|
|
t.Errorf("expected no thumbnail, got %q", out)
|
|
}
|
|
}
|