Add thumbnailing for photo grids
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
.claude/
|
.claude/
|
||||||
wiki/
|
wiki/
|
||||||
|
cache/
|
||||||
|
|
||||||
# Binaries
|
# Binaries
|
||||||
datascape
|
datascape
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{if .Photos}}
|
{{if .Photos}}
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
{{range .Photos}}
|
{{range .Photos}}
|
||||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{{if .Photos}}
|
{{if .Photos}}
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
{{range .Photos}}
|
{{range .Photos}}
|
||||||
<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
{{range .Months}}
|
{{range .Months}}
|
||||||
<h3 id="{{.ID}}">
|
<h3 id="{{.ID}}">
|
||||||
<a href="{{.URL}}">{{.Name}}</a>
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
{{if .PhotoCount}}<span class="muted">({{.PhotoCount}} photos)</span>{{end}}
|
|
||||||
</h3>
|
</h3>
|
||||||
|
{{if .Photos}}
|
||||||
|
<div class="photo-grid">
|
||||||
|
{{range .Photos}}
|
||||||
|
<a href="{{.URL}}" target="_blank"><img src="{{.ThumbURL}}" alt="{{.Name}}" loading="lazy"></a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -283,16 +283,17 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
|
|||||||
|
|
||||||
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
|
||||||
type diaryPhoto struct {
|
type diaryPhoto struct {
|
||||||
Date time.Time
|
Date time.Time
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
|
ThumbURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type diaryMonthSummary struct {
|
type diaryMonthSummary struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
PhotoCount int
|
Photos []diaryPhoto
|
||||||
}
|
}
|
||||||
|
|
||||||
type diaryDaySection struct {
|
type diaryDaySection struct {
|
||||||
@@ -376,10 +377,16 @@ func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
photoURL := path.Join(yearURLPath, url.PathEscape(name))
|
||||||
|
thumb := photoURL
|
||||||
|
if hasThumbnail(name) {
|
||||||
|
thumb = thumbURL(photoURL, 300)
|
||||||
|
}
|
||||||
photos = append(photos, diaryPhoto{
|
photos = append(photos, diaryPhoto{
|
||||||
Date: t,
|
Date: t,
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: path.Join(yearURLPath, url.PathEscape(name)),
|
URL: photoURL,
|
||||||
|
ThumbURL: thumb,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return photos
|
return photos
|
||||||
@@ -408,18 +415,18 @@ func renderDiaryYear(fsPath, urlPath string) template.HTML {
|
|||||||
if err != nil || monthNum < 1 || monthNum > 12 {
|
if err != nil || monthNum < 1 || monthNum > 12 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
count := 0
|
var monthPhotos []diaryPhoto
|
||||||
for _, p := range photos {
|
for _, p := range photos {
|
||||||
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
|
||||||
count++
|
monthPhotos = append(monthPhotos, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
|
||||||
months = append(months, diaryMonthSummary{
|
months = append(months, diaryMonthSummary{
|
||||||
ID: monthDate.Format("2006-01"),
|
ID: monthDate.Format("2006-01"),
|
||||||
Name: monthDate.Format("January 2006"),
|
Name: fmt.Sprintf("%s %d", germanMonths[monthDate.Month()], year),
|
||||||
URL: path.Join(urlPath, e.Name()) + "/",
|
URL: path.Join(urlPath, e.Name()) + "/",
|
||||||
PhotoCount: count,
|
Photos: monthPhotos,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ var pageTypeHandlers []pageTypeHandler
|
|||||||
func main() {
|
func main() {
|
||||||
addr := flag.String("addr", ":8080", "listen address")
|
addr := flag.String("addr", ":8080", "listen address")
|
||||||
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
||||||
|
cacheDir := flag.String("cache", "./cache", "thumbnail cache directory")
|
||||||
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
||||||
pass := flag.String("pass", "", "basic auth password")
|
pass := flag.String("pass", "", "basic auth password")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -53,6 +54,14 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thumbCacheDir, err = filepath.Abs(*cacheDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(thumbCacheDir, 0755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
initMarkdown(root)
|
initMarkdown(root)
|
||||||
|
|
||||||
authKey, err := loadOrCreateAuthKey(root)
|
authKey, err := loadOrCreateAuthKey(root)
|
||||||
@@ -86,6 +95,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(r.URL.Path, thumbURLPrefix+"/") {
|
||||||
|
h.handleThumb(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
urlPath := path.Clean("/" + r.URL.Path)
|
urlPath := path.Clean("/" + r.URL.Path)
|
||||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Thumbnailer produces a thumbnail of a source file. Implementations register
|
||||||
|
// themselves in init() by appending to thumbnailers. The first registered
|
||||||
|
// handler whose CanHandle returns true is used.
|
||||||
|
type Thumbnailer interface {
|
||||||
|
CanHandle(ext string) bool
|
||||||
|
Generate(src io.Reader, dst io.Writer, width int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumbnailers []Thumbnailer
|
||||||
|
|
||||||
|
// thumbCacheDir is set from the -cache flag at startup.
|
||||||
|
var thumbCacheDir string
|
||||||
|
|
||||||
|
const thumbURLPrefix = "/_thumb"
|
||||||
|
|
||||||
|
// Cache is content-addressed: the cache path is derived from the SHA-256 of
|
||||||
|
// the source file. Renames and moves reuse the same cache entry; overwriting
|
||||||
|
// a file with new content produces a new digest and regenerates.
|
||||||
|
var (
|
||||||
|
thumbLocks = map[string]*sync.Mutex{}
|
||||||
|
thumbLocksMu sync.Mutex
|
||||||
|
|
||||||
|
digestCache = map[string]digestEntry{}
|
||||||
|
digestCacheMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// digestEntry remembers the digest of a source file so repeated requests do
|
||||||
|
// not re-hash the whole file. The (mtime, size) pair invalidates the cache
|
||||||
|
// when the file is overwritten in place.
|
||||||
|
type digestEntry struct {
|
||||||
|
mtime time.Time
|
||||||
|
size int64
|
||||||
|
hex string
|
||||||
|
}
|
||||||
|
|
||||||
|
func findThumbnailer(name string) Thumbnailer {
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
if ext == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, t := range thumbnailers {
|
||||||
|
if t.CanHandle(ext) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasThumbnail reports whether a file name has a registered thumbnailer.
|
||||||
|
func hasThumbnail(name string) bool {
|
||||||
|
return findThumbnailer(name) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// thumbURL builds a thumbnail URL for a wiki file. filePath must be URL-style
|
||||||
|
// (slash-separated, leading slash), as already used on page links.
|
||||||
|
func thumbURL(filePath string, width int) string {
|
||||||
|
return thumbURLPrefix + filePath + "?w=" + strconv.Itoa(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw := strings.TrimPrefix(r.URL.Path, thumbURLPrefix)
|
||||||
|
if raw == "" || raw == "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decoded, err := url.PathUnescape(raw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanPath := path.Clean(decoded)
|
||||||
|
|
||||||
|
srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
|
||||||
|
rel, err := filepath.Rel(h.root, srcFS)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srcInfo, err := os.Stat(srcFS)
|
||||||
|
if err != nil || srcInfo.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := findThumbnailer(srcFS)
|
||||||
|
if t == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
width := 300
|
||||||
|
if s := r.URL.Query().Get("w"); s != "" {
|
||||||
|
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
|
||||||
|
width = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
digest, data, err := sourceDigest(srcFS, srcInfo)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("thumb digest %s: %v", rel, err)
|
||||||
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheFS := filepath.Join(thumbCacheDir, digest[:2], fmt.Sprintf("%s.%d.jpg", digest, width))
|
||||||
|
if _, err := os.Stat(cacheFS); err == nil {
|
||||||
|
serveThumb(w, r, cacheFS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lock := thumbLock(cacheFS)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
|
if _, err := os.Stat(cacheFS); err == nil {
|
||||||
|
serveThumb(w, r, cacheFS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var src io.Reader
|
||||||
|
if data != nil {
|
||||||
|
src = bytes.NewReader(data)
|
||||||
|
} else {
|
||||||
|
f, err := os.Open(srcFS)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("thumb open %s: %v", rel, err)
|
||||||
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
src = f
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := generateThumb(t, src, cacheFS, width); err != nil {
|
||||||
|
log.Printf("thumb %s: %v", rel, err)
|
||||||
|
http.Error(w, "thumbnail failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveThumb(w, r, cacheFS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceDigest returns the SHA-256 hex digest of a source file's content.
|
||||||
|
// On a cache hit (path + mtime + size unchanged) the returned data is nil,
|
||||||
|
// so the caller knows to open the file itself. On a miss the file is read
|
||||||
|
// once and the contents are returned for the caller to reuse.
|
||||||
|
func sourceDigest(srcFS string, info os.FileInfo) (string, []byte, error) {
|
||||||
|
digestCacheMu.Lock()
|
||||||
|
d, ok := digestCache[srcFS]
|
||||||
|
digestCacheMu.Unlock()
|
||||||
|
if ok && d.mtime.Equal(info.ModTime()) && d.size == info.Size() {
|
||||||
|
return d.hex, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(srcFS)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
h := hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
digestCacheMu.Lock()
|
||||||
|
digestCache[srcFS] = digestEntry{mtime: info.ModTime(), size: info.Size(), hex: h}
|
||||||
|
digestCacheMu.Unlock()
|
||||||
|
|
||||||
|
return h, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveThumb(w http.ResponseWriter, r *http.Request, cacheFS string) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
http.ServeFile(w, r, cacheFS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateThumb(t Thumbnailer, src io.Reader, cacheFS string, width int) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cacheFS), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(cacheFS), ".thumb-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
if err := t.Generate(src, tmp, width); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, cacheFS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thumbLock(key string) *sync.Mutex {
|
||||||
|
thumbLocksMu.Lock()
|
||||||
|
defer thumbLocksMu.Unlock()
|
||||||
|
m, ok := thumbLocks[key]
|
||||||
|
if !ok {
|
||||||
|
m = &sync.Mutex{}
|
||||||
|
thumbLocks[key] = m
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
thumbnailers = append(thumbnailers, &imageThumbnailer{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageThumbnailer struct{}
|
||||||
|
|
||||||
|
func (it *imageThumbnailer) CanHandle(ext string) bool {
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *imageThumbnailer) Generate(src io.Reader, dst io.Writer, width int) error {
|
||||||
|
img, _, err := image.Decode(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return jpeg.Encode(dst, resizeBox(img, width), &jpeg.Options{Quality: 80})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resizeBox downsamples src to the requested width using a box filter.
|
||||||
|
// Aspect ratio is preserved. Upscaling is a no-op (returns src unchanged).
|
||||||
|
// Each source pixel is visited exactly once; alpha is discarded.
|
||||||
|
func resizeBox(src image.Image, width int) image.Image {
|
||||||
|
b := src.Bounds()
|
||||||
|
srcW, srcH := b.Dx(), b.Dy()
|
||||||
|
if srcW <= width {
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
dstW := width
|
||||||
|
dstH := srcH * width / srcW
|
||||||
|
if dstH < 1 {
|
||||||
|
dstH = 1
|
||||||
|
}
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||||
|
|
||||||
|
for y := 0; y < dstH; y++ {
|
||||||
|
sy0 := y * srcH / dstH
|
||||||
|
sy1 := (y + 1) * srcH / dstH
|
||||||
|
if sy1 == sy0 {
|
||||||
|
sy1 = sy0 + 1
|
||||||
|
}
|
||||||
|
for x := 0; x < dstW; x++ {
|
||||||
|
sx0 := x * srcW / dstW
|
||||||
|
sx1 := (x + 1) * srcW / dstW
|
||||||
|
if sx1 == sx0 {
|
||||||
|
sx1 = sx0 + 1
|
||||||
|
}
|
||||||
|
var r, g, bl, n uint64
|
||||||
|
for sy := sy0; sy < sy1; sy++ {
|
||||||
|
for sx := sx0; sx < sx1; sx++ {
|
||||||
|
sr, sg, sb, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
|
||||||
|
r += uint64(sr >> 8)
|
||||||
|
g += uint64(sg >> 8)
|
||||||
|
bl += uint64(sb >> 8)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: uint8(r / n),
|
||||||
|
G: uint8(g / n),
|
||||||
|
B: uint8(bl / n),
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user