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:
parent
1b11867efe
commit
0212d089ec
10 changed files with 332 additions and 8 deletions
|
|
@ -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
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
internal/modules/photos/encode.go
Normal file
12
internal/modules/photos/encode.go
Normal 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})
|
||||||
|
}
|
||||||
107
internal/modules/photos/photos.go
Normal file
107
internal/modules/photos/photos.go
Normal 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
|
||||||
|
}
|
||||||
112
internal/modules/photos/photos_test.go
Normal file
112
internal/modules/photos/photos_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
84
web/src/lib/components/PhotoViewer.svelte
Normal file
84
web/src/lib/components/PhotoViewer.svelte
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue