feat(lagoon): WebP variants of attachment thumbnails

- attach.VariantKey: a thumbnail's copy in another format next to it
- attach/webp (separate package, so only binaries that import it link
  the encoder): Encode at quality 80 and Variant, which writes the WebP
  copy of a thumbnail once and leaves no file on a failed encode
- github.com/gen2brain/webp v0.6.4, decided in
  .planning/notes/2026-10-28-webp-gen2brain.md
- lagoon README and docs/database/attachments.md
This commit is contained in:
Jakub Zych
2026-11-04 16:52:59 +01:00
parent 1bb2ecf9cc
commit 5c0722429b
8 changed files with 401 additions and 2 deletions

View File

@@ -0,0 +1,28 @@
package attach
import (
"path"
"strings"
)
// VariantKey is the blob key of a thumbnail's copy in another image format,
// stored next to the thumbnail: thumbKey with its extension replaced by
// format, so thumb_42_480_0_0_0_auto.jpg becomes
// thumb_42_480_0_0_0_auto.webp. When the thumbnail already carries that
// extension (a thumbnail of a .webp original holds JPEG bytes under the
// original's name, see ThumbKey), format is appended instead
// (thumb_42_480_0_0_0_auto.webp.webp), so the variant never overwrites the
// thumbnail. format follows the token rule of ThumbFilename ([a-z0-9]+,
// lower-cased first); an empty thumbKey or a format outside the rule
// answers "".
func VariantKey(thumbKey, format string) string {
format = strings.ToLower(format)
if thumbKey == "" || !thumbToken.MatchString(format) {
return ""
}
ext := path.Ext(thumbKey)
if strings.EqualFold(strings.TrimPrefix(ext, "."), format) {
return thumbKey + "." + format
}
return strings.TrimSuffix(thumbKey, ext) + "." + format
}

View File

@@ -0,0 +1,13 @@
package webp
import (
"image"
"io"
)
// SetEncoderForTest replaces the encoder Variant uses and returns the
// restore function (tests only).
func SetEncoderForTest(fn func(io.Writer, image.Image) error) func() {
encodeImage = fn
return func() { encodeImage = Encode }
}

View File

@@ -0,0 +1,106 @@
// Package webp encodes WebP images and writes WebP variants of attachment
// thumbnails next to them, through github.com/gen2brain/webp (no cgo: the
// system libwebp through purego when it is installed, else libwebp
// transpiled to Go). It is a separate package so that only binaries that
// import it link the encoder.
package webp
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
encoder "github.com/gen2brain/webp"
"gocloud.dev/blob"
"gocloud.dev/gcerrors"
)
// Quality is the encoder quality of Encode, on libwebp's 0 to 100 scale.
const Quality = 80
// Format is the extension of a WebP variant (attach.VariantKey).
const Format = "webp"
// ContentType is the content type a variant is stored with.
const ContentType = "image/webp"
// maxThumbBytes caps the thumbnail Variant reads, like attach caps an
// original.
const maxThumbBytes = 32 << 20
// Encode writes img to w as a lossy WebP at Quality.
func Encode(w io.Writer, img image.Image) error {
if img == nil {
return errors.New("webp: image is nil")
}
return encoder.Encode(w, img, encoder.Options{Quality: Quality})
}
// encodeImage is Encode; tests replace it to fail an encode.
var encodeImage = Encode
// Variant writes the WebP variant of f's thumbnail at w by h in mode (see
// attach.File.ThumbKey, which makes the thumbnail first when it is missing)
// next to that thumbnail, under attach.VariantKey(thumbKey, "webp"), and
// returns the variant's key. A variant that already exists is returned as
// it is, so a second call writes nothing. The thumbnail is encoded in
// memory before anything is written, and the key is deleted when the write
// or its close fails, so a failed encode leaves no partial file. Errors of
// ThumbKey are returned as they are.
func Variant(ctx context.Context, bucket *blob.Bucket, f *attach.File, w, h int, mode string) (string, error) {
thumbKey, err := f.ThumbKey(ctx, bucket, w, h, mode)
if err != nil {
return "", err
}
key := attach.VariantKey(thumbKey, Format)
if key == "" {
return "", fmt.Errorf("webp: no variant key for %q", thumbKey)
}
exists, err := bucket.Exists(ctx, key)
if err != nil {
return "", fmt.Errorf("webp: variant exists: %w", err)
}
if exists {
return key, nil
}
r, err := bucket.NewReader(ctx, thumbKey, nil)
if err != nil {
return "", fmt.Errorf("webp: read thumbnail: %w", err)
}
raw, err := io.ReadAll(io.LimitReader(r, maxThumbBytes))
closeErr := r.Close()
if err != nil {
return "", fmt.Errorf("webp: read thumbnail: %w", err)
}
if closeErr != nil {
return "", fmt.Errorf("webp: read thumbnail: %w", closeErr)
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return "", fmt.Errorf("webp: decode thumbnail %q: %w", thumbKey, err)
}
var buf bytes.Buffer
if err := encodeImage(&buf, img); err != nil {
return "", fmt.Errorf("webp: encode %q: %w", thumbKey, err)
}
wr, err := bucket.NewWriter(ctx, key, &blob.WriterOptions{ContentType: ContentType})
if err != nil {
return "", fmt.Errorf("webp: variant writer: %w", err)
}
_, writeErr := wr.Write(buf.Bytes())
closeErr = wr.Close()
if writeErr != nil || closeErr != nil {
if delErr := bucket.Delete(ctx, key); delErr != nil && gcerrors.Code(delErr) != gcerrors.NotFound {
return "", fmt.Errorf("webp: delete partial variant %q: %w", key, delErr)
}
if writeErr != nil {
return "", fmt.Errorf("webp: write variant: %w", writeErr)
}
return "", fmt.Errorf("webp: write variant: %w", closeErr)
}
return key, nil
}

View File

@@ -0,0 +1,203 @@
package webp_test
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/jpeg"
"io"
"testing"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/lagoon/attach/webp"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
)
// gradient is a w by h test picture.
func gradient(w, h int) *image.RGBA {
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 * 255 / w), uint8(y * 255 / h), 128, 255})
}
}
return img
}
// isWebP reports whether raw starts with a RIFF/WEBP header.
func isWebP(raw []byte) bool {
return len(raw) > 12 && string(raw[0:4]) == "RIFF" && string(raw[8:12]) == "WEBP"
}
// putOriginal stores a JPEG original for f in bucket.
func putOriginal(t *testing.T, bucket *blob.Bucket, f *attach.File, w, h int) {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, gradient(w, h), nil); err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(t.Context(), attach.BlobKey(f.DiskName), buf.Bytes(), nil); err != nil {
t.Fatal(err)
}
}
func TestEncodeWritesWebP(t *testing.T) {
var buf bytes.Buffer
if err := webp.Encode(&buf, gradient(64, 40)); err != nil {
t.Fatal(err)
}
if !isWebP(buf.Bytes()) {
t.Fatalf("Encode wrote % x, want a RIFF/WEBP header", buf.Bytes()[:16])
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(buf.Bytes()))
if err != nil || format != "webp" || cfg.Width != 64 || cfg.Height != 40 {
t.Fatalf("decoded config %+v %q err=%v, want a 64x40 webp", cfg, format, err)
}
if err := webp.Encode(&buf, nil); err == nil {
t.Fatal("webp.Encode(nil) must fail")
}
}
func TestVariantKey(t *testing.T) {
for _, tc := range []struct{ thumb, format, want string }{
{"abc/123/xyz/thumb_42_480_0_0_0_auto.jpg", "webp", "abc/123/xyz/thumb_42_480_0_0_0_auto.webp"},
{"abc/123/xyz/thumb_42_480_0_0_0_auto.png", "WEBP", "abc/123/xyz/thumb_42_480_0_0_0_auto.webp"},
{"abc/123/xyz/thumb_42_480_0_0_0_auto.webp", "webp", "abc/123/xyz/thumb_42_480_0_0_0_auto.webp.webp"},
{"abc/123/xyz/thumb_42_480_0_0_0_auto.jpg", "../x", ""},
{"abc/123/xyz/thumb_42_480_0_0_0_auto.jpg", "", ""},
{"", "webp", ""},
} {
if got := attach.VariantKey(tc.thumb, tc.format); got != tc.want {
t.Errorf("VariantKey(%q, %q) = %q, want %q", tc.thumb, tc.format, got, tc.want)
}
}
}
func TestVariantWritesOnceNextToTheThumbnail(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
f := &attach.File{ID: 5, DiskName: "abc123xyz.jpg", FileName: "cover.jpg"}
putOriginal(t, bucket, f, 400, 300)
key, err := webp.Variant(ctx, bucket, f, 120, 90, "auto")
if err != nil {
t.Fatal(err)
}
if want := "abc/123/xyz/thumb_5_120_90_0_0_auto.webp"; key != want {
t.Fatalf("variant key = %q, want %q", key, want)
}
if ok, err := bucket.Exists(ctx, "abc/123/xyz/thumb_5_120_90_0_0_auto.jpg"); err != nil || !ok {
t.Fatalf("the thumbnail was not made first: %v %v", ok, err)
}
raw, err := bucket.ReadAll(ctx, key)
if err != nil || !isWebP(raw) {
t.Fatalf("variant bytes = % x err=%v, want WebP", raw[:min(16, len(raw))], err)
}
attrs, err := bucket.Attributes(ctx, key)
if err != nil || attrs.ContentType != webp.ContentType {
t.Fatalf("variant content type = %v err=%v", attrs, err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil || cfg.Width != 120 || cfg.Height != 90 {
t.Fatalf("variant is %dx%d err=%v, want the thumbnail's 120x90", cfg.Width, cfg.Height, err)
}
// A second call finds the variant and writes nothing.
if err := bucket.WriteAll(ctx, key, []byte("kept"), nil); err != nil {
t.Fatal(err)
}
again, err := webp.Variant(ctx, bucket, f, 120, 90, "auto")
if err != nil || again != key {
t.Fatalf("second Variant = %q err=%v", again, err)
}
if raw, _ := bucket.ReadAll(ctx, key); string(raw) != "kept" {
t.Fatalf("second Variant rewrote the variant: % x", raw[:min(16, len(raw))])
}
}
func TestVariantOfAWebPOriginalKeepsItsThumbnail(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
f := &attach.File{ID: 9, DiskName: "def456uvw.webp"}
var buf bytes.Buffer
if err := webp.Encode(&buf, gradient(200, 100)); err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), buf.Bytes(), nil); err != nil {
t.Fatal(err)
}
key, err := webp.Variant(ctx, bucket, f, 100, 50, "auto")
if err != nil {
t.Fatal(err)
}
thumb := "def/456/uvw/thumb_9_100_50_0_0_auto.webp"
if key != thumb+".webp" {
t.Fatalf("variant key = %q, want %q", key, thumb+".webp")
}
if raw, _ := bucket.ReadAll(ctx, thumb); isWebP(raw) {
t.Fatal("the thumbnail of a webp original was overwritten with the variant")
}
if raw, _ := bucket.ReadAll(ctx, key); !isWebP(raw) {
t.Fatal("the variant is not WebP")
}
}
func TestVariantEncodeFailureLeavesNoFile(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
f := &attach.File{ID: 7, DiskName: "ghi789rst.jpg"}
putOriginal(t, bucket, f, 300, 200)
boom := errors.New("boom")
restore := webp.SetEncoderForTest(func(w io.Writer, _ image.Image) error {
_, _ = w.Write([]byte("RIFF partial"))
return boom
})
t.Cleanup(restore)
if _, err := webp.Variant(ctx, bucket, f, 60, 40, "auto"); !errors.Is(err, boom) {
t.Fatalf("Variant err = %v, want the encode failure", err)
}
if ok, err := bucket.Exists(ctx, "ghi/789/rst/thumb_7_60_40_0_0_auto.webp"); err != nil || ok {
t.Fatalf("a failed encode left a variant: %v %v", ok, err)
}
restore()
if _, err := webp.Variant(ctx, bucket, f, 60, 40, "auto"); err != nil {
t.Fatalf("the next run after a failure: %v", err)
}
}
func ExampleVariant() {
ctx := context.Background()
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
// The system_files row of a cover image and its stored original.
f := &attach.File{ID: 42, DiskName: "5f1d0c2e9a7b4c3d8e6f.jpg", FileName: "cover.jpg"}
var img bytes.Buffer
if err := jpeg.Encode(&img, image.NewRGBA(image.Rect(0, 0, 1600, 1200)), nil); err != nil {
fmt.Println(err)
return
}
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), img.Bytes(), nil); err != nil {
fmt.Println(err)
return
}
// In a background job: the 800 px thumbnail and its WebP copy.
key, err := webp.Variant(ctx, bucket, f, 800, 800, "auto")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(key)
fmt.Println(attach.PublicURL(key))
// Output:
// 5f1/d0c/2e9/thumb_42_800_800_0_0_auto.webp
// /storage/uploads/5f1/d0c/2e9/thumb_42_800_800_0_0_auto.webp
}