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

@@ -89,6 +89,44 @@ Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot
> [!WARNING]
> Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin.
## WebP variants
The `attach/webp` package writes a WebP copy of a thumbnail next to it, so a page can offer WebP with the original format as fallback. `webp.Variant` takes the same width, height and mode as `attach.File.ThumbKey`, makes the thumbnail first when it is missing, encodes it at quality 80 and stores it under `attach.VariantKey`: `thumb_42_480_0_0_0_auto.jpg` gets `thumb_42_480_0_0_0_auto.webp`, with the `image/webp` content type. A thumbnail of a `.webp` original already ends in `.webp`, so its variant is `thumb_42_480_0_0_0_auto.webp.webp`. Variants live under the file's thumbnail prefix, so `attach.DeleteKeys` removes them with the thumbnails.
```go src=modules/lagoon/attach/webp/webp_smoke_test.go#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
```
A variant that exists is returned without writing, so a job can run again safely. The encode happens in memory before anything is written, and a failed write deletes the key, so a failure never leaves a partial file. Encoding takes tens to hundreds of milliseconds per image, so run it in a background [job](../services/jobs.md), never inside an upload request. The original upload is never re-encoded.
The encoder is `github.com/gen2brain/webp`. It loads the system libwebp (`libwebp.so`, `libwebpdemux.so`) through purego when the library is installed, without cgo, and otherwise falls back to libwebp transpiled to Go, about five times slower. Only binaries that import `attach/webp` link it. Such a binary is dynamically linked against glibc, even with `CGO_ENABLED=0`, so it runs on Debian or Ubuntu hosts but not in a `scratch` or musl image. Build it with `-tags nodynamic` for a static binary that always uses the transpiled encoder.
## Storing an upload
`attach.Store` turns an upload into a `system_files` row. It takes an `attach.Upload` (the client's file name, the body and the public flag) and an `attach.Limits`, writes the bytes under the WinterCMS partition key and inserts an unattached row whose `sort_order` equals its id, as WinterCMS's Sortable trait does. The caller attaches the row later by setting `Field`, `AttachmentType` and `AttachmentID`, usually when the record's form is saved. When the row insert fails, the blob is deleted again.