The memo held one entry per picture ever rendered, for the life of the process. Correct for one author's laptop, wrong for what this engine is meant to be: a server that runs for months and serves a whole site to many readers. Least-recently-used rather than clearing when full, because a site has pages nobody opens for months and a front page opened every minute — discarding wholesale throws away exactly the entries about to be asked for again. A map into a recency-ordered list: reads promote, evictions take the back, both constant time. 1024 entries is a few hundred kilobytes. Generous enough that a normal site never evicts, bounded enough that no site can grow the process without limit. The number is a constant and not a setting, because a knob with one user is a knob nobody asked for. Eviction, replacement and the bound are tested, including under -race, since requests are concurrent and the store is shared. Latent item cleared. ext 1975/2000.
242 lines
8.0 KiB
Go
242 lines
8.0 KiB
Go
package shortcodes
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"image"
|
|
_ "image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/image/draw"
|
|
_ "golang.org/x/image/webp"
|
|
|
|
"khosra/internal/content"
|
|
"khosra/internal/render"
|
|
)
|
|
|
|
// widths are the derivative sizes offered to a browser. Three, spanning phone to desktop: enough for srcset
|
|
// to matter and few enough that the startup pass stays quick. They become a setting when someone wants a
|
|
// different set, not before.
|
|
var widths = []int{480, 960, 1440}
|
|
|
|
// Derive writes every missing derivative for the pictures under content/, returning how many it made.
|
|
//
|
|
// Ahead of the request, never during one: resampling takes long enough to be felt, and there is no page cache
|
|
// to hide it (ADR-0042). Idempotent, because a derivative is named after its source's content — so a rerun
|
|
// stats and skips, and an edited picture simply has a different name. Originals are only ever read.
|
|
func Derive(siteFS fs.FS, cacheDir string) (int, error) {
|
|
if siteFS == nil || cacheDir == "" {
|
|
return 0, nil
|
|
}
|
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
|
return 0, fmt.Errorf("derivative cache %s: %w", cacheDir, err)
|
|
}
|
|
made := 0
|
|
err := fs.WalkDir(siteFS, "content", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !derivable(p) {
|
|
return err
|
|
}
|
|
if strings.Contains(p, "/"+content.ExtrasDir+"/") {
|
|
// Extras are supporting material, not published pictures: deriving sizes nothing references would
|
|
// only slow startup down (content-model.md).
|
|
return nil
|
|
}
|
|
data, err := fs.ReadFile(siteFS, p)
|
|
if err != nil {
|
|
slog.Error("skipping unreadable picture", "path", p, "err", err)
|
|
return nil
|
|
}
|
|
n, err := derive(data, p, cacheDir)
|
|
if err != nil {
|
|
// One unreadable picture must not stop a site from starting (ADR-0029).
|
|
slog.Error("skipping picture", "path", p, "err", err)
|
|
return nil
|
|
}
|
|
made += n
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return made, fmt.Errorf("walk for pictures: %w", err)
|
|
}
|
|
return made, nil
|
|
}
|
|
|
|
// derive writes the derivatives one picture is missing.
|
|
func derive(data []byte, name, cacheDir string) (int, error) {
|
|
src, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
made := 0
|
|
for _, w := range widths {
|
|
if src.Bounds().Dx() <= w {
|
|
// Never upscale: a bigger file that looks worse is not a derivative worth having.
|
|
continue
|
|
}
|
|
file := filepath.Join(cacheDir, derivedName(data, name, w))
|
|
if _, err := os.Stat(file); err == nil {
|
|
continue
|
|
}
|
|
if err := write(file, scale(src, w), name); err != nil {
|
|
return made, err
|
|
}
|
|
made++
|
|
}
|
|
return made, nil
|
|
}
|
|
|
|
// scale resamples to a target width, keeping the aspect ratio.
|
|
//
|
|
// CatmullRom because the alternative in the standard library is nearest neighbour, which is visibly wrong on
|
|
// photographs — the reason ADR-0040 accepted a dependency at all.
|
|
func scale(src image.Image, width int) image.Image {
|
|
b := src.Bounds()
|
|
height := b.Dy() * width / b.Dx()
|
|
out := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
draw.CatmullRom.Scale(out, out.Bounds(), src, b, draw.Over, nil)
|
|
return out
|
|
}
|
|
|
|
// write encodes an image beside its siblings in the cache, atomically.
|
|
//
|
|
// Through a temporary file and a rename, so a derivative is either absent or complete: a half-written one
|
|
// would be served as a broken image and, being content-named, never regenerated.
|
|
func write(file string, img image.Image, source string) error {
|
|
tmp, err := os.CreateTemp(filepath.Dir(file), ".khosra-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if err := encode(tmp, img, source); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), file)
|
|
}
|
|
|
|
// encode writes PNG for sources that may carry transparency and JPEG for the rest, which is also what
|
|
// derivedName spells in the extension.
|
|
func encode(w *os.File, img image.Image, source string) error {
|
|
if lossless(source) {
|
|
return png.Encode(w, img)
|
|
}
|
|
return jpeg.Encode(w, img, &jpeg.Options{Quality: 82})
|
|
}
|
|
|
|
// inspected remembers what each picture is.
|
|
//
|
|
// Measured, not assumed: without this the same bytes were read, hashed and decoded on every request, costing
|
|
// ~102µs per picture — a twelve-picture gallery spent 1.2ms of its 1.24ms doing work it had already done
|
|
// (BenchmarkGalleryPage in internal/web). conventions.md allows a cache in the render path once a benchmark
|
|
// asks for one, and this is the smallest thing the benchmark asks for.
|
|
//
|
|
// Keyed by name, size and modification time, so an edited picture is inspected again rather than remembered
|
|
// wrongly, and bounded, so a long-running process serving a large site cannot grow without limit (ADR-0073).
|
|
var inspected = newMemo(remembered)
|
|
|
|
// Picture describes one image for a fragment: where to fetch it, what a browser may choose instead, and the
|
|
// intrinsic size, so a page reserves the right box before any bytes arrive.
|
|
//
|
|
// Src stays the original: a derivative is an optimisation, and a browser that ignores srcset still gets the
|
|
// picture the author put there (ADR-0042).
|
|
func picture(origin render.Origin, file string) (render.Picture, bool) {
|
|
if origin.Files == nil || !showable(file) {
|
|
return render.Picture{}, false
|
|
}
|
|
// The author's file is the picture, whatever the engine can make of it. An SVG has no decoder here and an
|
|
// AVIF has none anywhere, so both are rendered as they are, without a size or a srcset — dropping them
|
|
// would delete content because the engine cannot optimise it.
|
|
p := render.Picture{Src: file}
|
|
if !derivable(file) {
|
|
return p, true
|
|
}
|
|
name := path.Join(origin.Dir, file)
|
|
info, err := fs.Stat(origin.Files, name)
|
|
if err != nil {
|
|
slog.Error("picture unreadable", "path", name, "err", err)
|
|
return p, true
|
|
}
|
|
key := fmt.Sprintf("%s\x00%d\x00%d", name, info.Size(), info.ModTime().UnixNano())
|
|
if known, is := inspected.get(key); is {
|
|
return known, true
|
|
}
|
|
data, err := fs.ReadFile(origin.Files, name)
|
|
if err != nil {
|
|
slog.Error("picture unreadable", "path", name, "err", err)
|
|
return p, true
|
|
}
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
|
if err != nil {
|
|
slog.Warn("picture kept but not sized: cannot decode", "path", name, "err", err)
|
|
return p, true
|
|
}
|
|
p.Width, p.Height = cfg.Width, cfg.Height
|
|
var sources []string
|
|
for _, w := range widths {
|
|
if cfg.Width <= w {
|
|
continue
|
|
}
|
|
sources = append(sources, content.DerivedURL(derivedName(data, name, w))+" "+strconv.Itoa(w)+"w")
|
|
}
|
|
if len(sources) > 0 {
|
|
// The original closes the set at its own width, so a wide viewport still has the best file to pick.
|
|
sources = append(sources, file+" "+strconv.Itoa(cfg.Width)+"w")
|
|
p.Srcset = strings.Join(sources, ", ")
|
|
}
|
|
inspected.put(key, p)
|
|
return p, true
|
|
}
|
|
|
|
// derivedName is the cache filename for one source at one width: the source's content hash, so an edit
|
|
// changes the name and nothing stale is ever served, plus the width and the encoding's extension.
|
|
func derivedName(data []byte, source string, width int) string {
|
|
sum := sha256.Sum256(data)
|
|
ext := ".jpg"
|
|
if lossless(source) {
|
|
ext = ".png"
|
|
}
|
|
return hex.EncodeToString(sum[:8]) + "-w" + strconv.Itoa(width) + ext
|
|
}
|
|
|
|
// lossless reports whether a source may carry transparency, which decides the derivative's encoding.
|
|
func lossless(source string) bool {
|
|
switch strings.ToLower(path.Ext(source)) {
|
|
case ".png", ".gif", ".webp":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// showable reports whether a browser can display the file: what belongs in a gallery, whether or not this
|
|
// engine can do anything clever with it.
|
|
func showable(name string) bool {
|
|
switch strings.ToLower(path.Ext(name)) {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// derivable reports whether the file can be decoded and resampled here. SVG needs no resizing and AVIF has no
|
|
// decoder available, so neither gets derivatives (ADR-0040).
|
|
func derivable(name string) bool {
|
|
switch strings.ToLower(path.Ext(name)) {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
|
|
return true
|
|
}
|
|
return false
|
|
}
|