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.
This commit is contained in:
mithral 2026-10-09 21:22:30 -04:00
parent 1b11867efe
commit 0212d089ec
10 changed files with 332 additions and 8 deletions

View file

@ -21,6 +21,7 @@ import (
"github.com/mithral/vellum/internal/metadata" "github.com/mithral/vellum/internal/metadata"
"github.com/mithral/vellum/internal/modules/comics" "github.com/mithral/vellum/internal/modules/comics"
"github.com/mithral/vellum/internal/modules/ebooks" "github.com/mithral/vellum/internal/modules/ebooks"
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
"github.com/mithral/vellum/internal/search" "github.com/mithral/vellum/internal/search"
"github.com/mithral/vellum/migrations" "github.com/mithral/vellum/migrations"
) )

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.26.8
require ( require (
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
golang.org/x/image v0.47.0
modernc.org/sqlite v1.60.1 modernc.org/sqlite v1.60.1
) )

2
go.sum
View file

@ -14,6 +14,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=

View file

@ -178,7 +178,7 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
} }
rows, err := h.db.QueryContext(r.Context(), ` rows, err := h.db.QueryContext(r.Context(), `
SELECT id, kind, path, title, COALESCE(year, 0), added_at SELECT id, library_id, kind, path, title, COALESCE(year, 0), added_at
FROM media_items FROM media_items
WHERE library_id = ? AND missing = 0 WHERE library_id = ? AND missing = 0
ORDER BY sort_title ORDER BY sort_title
@ -191,6 +191,7 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
type itemDTO struct { type itemDTO struct {
ID int64 `json:"id"` ID int64 `json:"id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Path string `json:"path"` Path string `json:"path"`
Title string `json:"title"` Title string `json:"title"`
@ -200,7 +201,7 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
items := []itemDTO{} items := []itemDTO{}
for rows.Next() { for rows.Next() {
var it itemDTO var it itemDTO
if err := rows.Scan(&it.ID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.AddedAt); err != nil { if err := rows.Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.AddedAt); err != nil {
h.fail(w, "scan items", err) h.fail(w, "scan items", err)
return return
} }

View file

@ -0,0 +1,12 @@
package photos
import (
"image"
"image/jpeg"
"io"
)
// encodeJPEG writes an image as JPEG at a quality suited to thumbnails.
func encodeJPEG(w io.Writer, img image.Image) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: 82})
}

View file

@ -0,0 +1,107 @@
// 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
}

View file

@ -0,0 +1,112 @@
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)
}
}

View file

@ -25,6 +25,7 @@ export interface MediaType {
export interface Item { export interface Item {
id: number id: number
library_id: number
kind: string kind: string
path: string path: string
title: string title: string

View file

@ -0,0 +1,84 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item } from '../api'
import { go } from '../nav'
let { itemId }: { itemId: number } = $props()
let item = $state<Item | null>(null)
let siblings = $state<Item[]>([])
let error = $state<string | null>(null)
let loading = $state(true)
const index = $derived(siblings.findIndex((i) => i.id === itemId))
onMount(async () => {
try {
const detail = await api.item(itemId)
item = detail.item
// The viewer navigates within its library, so it needs the siblings.
siblings = (await api.items(detail.item.library_id)).items.filter((i) => i.kind === 'photos')
window.addEventListener('keydown', onKey)
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this photo'
} finally {
loading = false
}
})
onDestroy(() => window.removeEventListener('keydown', onKey))
function onKey(event: KeyboardEvent) {
if (event.key === 'ArrowRight') step(1)
if (event.key === 'ArrowLeft') step(-1)
}
function step(delta: number) {
const next = siblings[index + delta]
if (next) go({ name: 'reader', itemId: next.id, title: next.title })
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if loading}
<div class="stage center"><div class="spinner"></div></div>
{:else}
<div class="stage">
<img src={api.fileUrl(itemId)} alt={item?.title ?? ''} />
</div>
<footer class="bar">
<button class="btn btn-ghost" onclick={() => step(-1)} disabled={index <= 0}>←</button>
<span class="muted mono">{index >= 0 ? index + 1 : '?'} / {siblings.length}</span>
<button class="btn btn-ghost" onclick={() => step(1)} disabled={index < 0 || index >= siblings.length - 1}>
→
</button>
</footer>
{/if}
<style>
.stage {
display: grid;
place-items: center;
height: calc(100vh - 52px - 96px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: #000;
overflow: hidden;
}
.stage img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.center {
place-items: center;
}
.bar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
height: 56px;
}
</style>

View file

@ -4,6 +4,7 @@
import { api, ApiError, type Item, type EbookMetadata } from '../api' import { api, ApiError, type Item, type EbookMetadata } from '../api'
import ComicReader from './ComicReader.svelte' import ComicReader from './ComicReader.svelte'
import AudiobookPlayer from './AudiobookPlayer.svelte' import AudiobookPlayer from './AudiobookPlayer.svelte'
import PhotoViewer from './PhotoViewer.svelte'
let { itemId }: { itemId: number } = $props() let { itemId }: { itemId: number } = $props()
@ -95,6 +96,8 @@
<ComicReader {itemId} /> <ComicReader {itemId} />
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')} {:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}
<AudiobookPlayer {itemId} /> <AudiobookPlayer {itemId} />
{:else if item && item.kind === 'photos'}
<PhotoViewer {itemId} />
{:else if item && item.kind !== 'ebooks'} {:else if item && item.kind !== 'ebooks'}
<div class="empty"> <div class="empty">
<h3>{item.title}</h3> <h3>{item.title}</h3>