Image pipeline
framework/image is a chainable image pipeline — decode → transform →
encode — implemented in pure Go on top of image/jpeg, image/png,
image/gif, and golang.org/x/image. No CGo, no system libraries, no
native build step. The API is inspired by Bun.Image; the
implementation is independent.
Quickstart
import "github.com/DonaldMurillo/gofastr/framework/image"img, err := image.Open("photo.jpg")if err != nil { return err}thumb, err := img. AutoOrient(). Resize(800, 0, image.WithFit(image.FitInside)). JPEG(image.JPEGOptions{Quality: 80}). Bytes()
Chain methods return a new *Image, so the same source can be branched
into independent pipelines without aliasing:
big, _ := img.Resize(1600, 0).JPEG().Bytes()small, _ := img.Resize(320, 0).WebP().Bytes() // zero-value = lossless
Construction
| Function | Use when |
|---|---|
Decode(io.Reader) | Network or file stream input |
DecodeBytes([]byte) | Already-loaded buffer |
Open(path) | Filesystem path |
OpenFS(fs.FS, name) | embed.FS or virtual filesystem |
FromImage(image.Image, Format) | Pixels you generated in-process |
All decoders sniff the format from magic bytes and reject inputs whose
reported width × height exceeds Config.MaxPixels (default
DefaultMaxPixels = 64 MP, equivalent to an 8192×8192 square). Tune
via DecodeBytesWithConfig or DecodeWithConfig. The default was
intentionally tightened from Bun.Image's 268 MP after a security
review found a 45-byte crafted PNG declaring 16383×16383 trip the
old guard and trigger ~1 GiB of decoder allocation.
Open(path) rejects paths containing .. segments that escape the
working directory (e.g. ../etc/passwd). Callers handling user input
should validate at their layer too; prefer OpenFS rooted in an
embed.FS or constrained tree for stronger guarantees.
Supported formats
| Format | Decode | Encode | Notes |
|---|---|---|---|
| JPEG | ✅ | ✅ | EXIF orientation parsed for AutoOrient |
| PNG | ✅ | ✅ | Compression level configurable |
| GIF | ✅ | ✅ | First frame on animated input; 1..256 palette colours |
| BMP | ✅ | ✅ | — |
| TIFF | ✅ | ✅ | Compression + predictor configurable |
| WebP | ✅ | ✅ | Lossless (VP8L). WebPOptions{Lossy: true} returns ErrFormatUnsupported. Encode dimension cap: 16384×16384. |
| HEIC / AVIF | ❌ | ❌ | Out of scope (no pure-Go codec exists) |
Animated input, ICC profiles, and EXIF data beyond orientation are
intentionally out of scope.
Transformations
img.Resize(width, height, opts...) // ResizeOption: WithFilter, WithFit, WithoutEnlargementimg.Rotate(degrees) // 0 / 90 / 180 / 270 (clockwise)img.Flip() // mirror top↔bottomimg.Flop() // mirror left↔right// Modulation fields are *float64 so the zero-value Modulation{}// unambiguously means "no change", and a literal Saturation: 0// unambiguously means grayscale. Use image.Float64 to construct.img.Modulate(image.Modulation{ Brightness: image.Float64(1.2), // 1.0 = identity; nil = unchanged Saturation: image.Float64(0.8), // 0.0 = grayscale; nil = unchanged})img.AutoOrient() // apply EXIF orientation, then clear it
Resize filters
x/image/draw ships four kernels; this package exposes them with the
familiar Bun.Image / Sharp naming where it applies:
| Filter | Backed by | When to use |
|---|---|---|
Lanczos3 | draw.CatmullRom | Default. Highest quality available pure-Go. |
Lanczos2 | draw.BiLinear | Faster, mildly softer. |
CatmullRom | draw.CatmullRom | Same as Lanczos3. |
BiLinear | draw.BiLinear | Fast, soft. |
ApproxBiLinear | draw.ApproxBiLinear | Fastest; visible aliasing. |
Nearest | draw.NearestNeighbor | Pixel art, exact down-sampling. |
There is no native Lanczos kernel because the Go team's x/image does
not ship one. Lanczos3 is an alias for CatmullRom — visually similar
at typical photo content.
Fit modes
img.Resize(800, 600, image.WithFit(image.FitFill)) // default; may distortimg.Resize(800, 600, image.WithFit(image.FitInside)) // preserve aspect; fit withinimg.Resize(800, 600, image.WithFit(image.FitOutside)) // preserve aspect; overflow
WithoutEnlargement() skips the resize entirely when the target box
would scale up the source on either axis.
Encoders
Terminal methods on *Image return a configured *Encoder. Materialise
the output with Bytes, Write(io.Writer), Base64, or DataURL.
data, err := img.JPEG(image.JPEGOptions{Quality: 80}).Bytes()err = img.PNG().Write(httpRespWriter)b64, err := img.GIF(image.GIFOptions{NumColors: 64}).Base64()durl, err := img.BMP().DataURL()
Per-format option structs:
| Method | Options |
|---|---|
JPEG(JPEGOptions{Quality: 1..100}) | Quality default 80 |
PNG(PNGOptions{Compression}) | image/png.CompressionLevel |
GIF(GIFOptions{NumColors}) | 1..256, default 256 |
BMP() | — |
TIFF(TIFFOptions{Compression, Predictor}) | from x/image/tiff |
WebP(WebPOptions{}) | Zero-value lossless; Lossy: true errors |
Inspect output before materialising via Encoder.MIME() and
Encoder.Format().
Already have uploads? Skip the wiring
If the images arrive as CRUD uploads on a schema.Image field, you do not
need to call any of this by hand. One app option makes every upload produce
renditions plus a BlurHash and writes them to sibling columns:
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework"
import "github.com/DonaldMurillo/gofastr/framework/imagefield"
import "github.com/DonaldMurillo/gofastr/framework/image"
-->
framework.WithImagePipeline(imagefield.MustNew(imagefield.Config{ Variants: []image.Variant{{Width: 960, Format: image.FormatWebP, Suffix: "md"}}, BlurHashX: 4, BlurHashY: 3,}))
See uploads.md → "Automatic renditions and placeholders".
The rest of this page is the pipeline underneath, for images that do not
come in through an upload — generated covers, imported batches, one-off
scripts.
Plug-and-play: VariantSet → PipelineImage
For the common case — "take this upload, produce three sizes plus a
placeholder, hand it to the UI" — there's a declarative helper. The
VariantSet is headless (no UI/HTTP dependency); pair it with the
ui.PipelineImage component to render.
result, err := image.VariantSet{ BaseName: "hero", Variants: []image.Variant{ {Width: 320, Format: image.FormatJPEG, Quality: 80, Suffix: "sm"}, {Width: 800, Format: image.FormatJPEG, Quality: 82, Suffix: "md"}, {Width: 1600, Format: image.FormatJPEG, Quality: 85, Suffix: "lg"}, {Width: 320, Format: image.FormatWebP, Suffix: "sm"}, // VP8L {Width: 800, Format: image.FormatWebP, Suffix: "md"}, {Width: 1600, Format: image.FormatWebP, Suffix: "lg"}, }, Placeholder: &image.PlaceholderOptions{Width: 24}, BlurHashX: 4, BlurHashY: 3,}.Process(img)// core/upload.Storage is io.Reader-shaped: Save(ctx, key, r).for _, v := range result.Variants { _ = store.Save(ctx, v.Name, bytes.NewReader(v.Bytes))}saveBlurHashColumn(entityID, result.BlurHash) // → entity column
For high-throughput uploads, use ProcessTo so only one variant
sits in memory at a time:
sr, err := image.VariantSet{ /* same fields */ }.ProcessTo(img, func(h image.VariantHeader, r io.Reader) error { return store.Save(ctx, h.Name, r) })// sr.Placeholder and sr.BlurHash carry the metadata; no Bytes buffered.
Render the result via framework/ui:
ui.PipelineImage(ui.PipelineImageConfig{ Fallback: "/uploads/hero-md.jpg", Alt: "Sunset over the ocean", Width: 800, Height: 600, Sources: []ui.PipelineSource{ {URL: "/uploads/hero-sm.webp", Width: 320, Type: "image/webp"}, {URL: "/uploads/hero-md.webp", Width: 800, Type: "image/webp"}, {URL: "/uploads/hero-lg.webp", Width: 1600, Type: "image/webp"}, {URL: "/uploads/hero-sm.jpg", Width: 320, Type: "image/jpeg"}, {URL: "/uploads/hero-md.jpg", Width: 800, Type: "image/jpeg"}, {URL: "/uploads/hero-lg.jpg", Width: 1600, Type: "image/jpeg"}, }, Placeholder: result.BlurHash, Sizes: "(min-width: 1024px) 1024px, 100vw",})
PipelineImage emits one <source type="…" srcset="…"> per distinct
Type in input order — put the modern format first so legacy browsers
fall through to the <img> fallback.
Placeholders
A placeholder is a cheap stand-in that paints before the real image
arrives. Placeholder on ui.OptimizedImage and ui.PipelineImage
takes an inline raster data: URI and renders it as an image stacked
behind the real one. No JavaScript is involved and nothing needs
hydrating — the placeholder is server-rendered and simply stays behind
the loaded image.
Two ways to produce one.
LQIP — encode a tiny copy of the source. Simplest when you hold the
image at the moment you need the placeholder:
durl, err := img.Placeholder() // ~500-byte JPEG data URLdurl, err := img.Placeholder(image.PlaceholderOptions{ Width: 24, // px (height computed from aspect) Quality: 50,})
BlurHash — store ~28 characters, render later. Compute the hash once
at upload time, keep it in a column, and turn it back into pixels on
render:
hash, err := img.BlurHash(4, 3) // "LEHV6nWB2yk8…" → store this// …later, rendering a row that has only the hash:durl, err := image.BlurHashDataURL(hash, image.BlurHashRenderConfig{})
Pick BlurHash when a 28-byte column beats a ~500-byte one, or when
hashes already reach you from another client. Pick LQIP when you control
the upload and want no decode step at render time. Either way the UI
component takes the same data: URI:
ui.PipelineImage(ui.PipelineImageConfig{ Fallback: "/uploads/hero-md.jpg", Alt: "Sunset over the ocean", Width: 800, Height: 600, Placeholder: durl,})
BlurHashDataURL memoises its output, since the same handful of hashes
recur on every request for a given page. SetBlurHashCacheSize(n) caps
the table (n <= 0 disables it) and FlushBlurHashCache() empties it.
BlurHashRenderConfig knobs: Width/Height default to
DefaultBlurHashRenderSize (20 px) and are capped at
MaxBlurHashRenderSize (128 px) — a hash holds at most 9×9 cosine
components, so rendering larger cannot recover detail it never carried.
Punch raises contrast (default 1.0). Format defaults to
FormatJPEG, whose size barely moves with dimensions (~840–940 bytes
from 16 px to 48 px, because the quantisation and Huffman tables
dominate a payload this small); FormatPNG is smaller at or below 20 px
(~430–880 bytes) but grows to ~2.4 KB by 48 px.
DecodeBlurHash is available directly when you want the pixels rather
than a data URL — it returns a pipeline *Image, so it chains with the
encoders like any other source.
Both directions follow the [blurha.sh][bh-spec] reference. When
encoding, note that cost scales with width × height × components;
BlurHash auto-downscales to 64 px internally so you do not have to
resize first.
A malformed hash returns an error rather than pixels. Treat a
placeholder as optional when rendering user data — the UI components
already drop an unusable Placeholder and render the image without one,
so a bad legacy row degrades instead of failing the page.
Two behaviors worth knowing about, both consequences of the placeholder
being a real element that is never removed:
- With
Fit: ImageFitContainthe placeholder letterboxes exactly like
the image in front of it, so the empty bars show the component's
resting surface color rather than blur. - A real image with transparent regions lets the placeholder show
through them permanently. If that is unwanted, skip the placeholder
for transparent art.
[bh-spec]: https://blurha.sh
Decompression-bomb guard
Inputs whose reported width × height exceed Config.MaxPixels
(default 64 MP — an 8192×8192 square) return ErrDecompressionBomb
before any pixel decoding is attempted. The check uses the format's
header dimensions (stdimage.DecodeConfig) so no pixel buffer is
allocated for a rejected input. Note the WebP-lossless encoder has
a per-dimension cap of 16384 (so a 16384×16384 square is encode-
rejected even with MaxPixels raised). Override the guard per-call:
img, err := image.DecodeBytesWithConfig(data, image.Config{ MaxPixels: 64 * 1024 * 1024, // 64 MP})
EXIF orientation
Decoding a JPEG records the EXIF orientation tag (1..8) on the
*Image. Metadata().Orientation exposes it; AutoOrient() applies it
and resets the tag. Only the orientation tag is parsed — full EXIF
support is intentionally out of scope.
Caveat: an *Image built via FromImage(...) carries
Orientation = 0, so AutoOrient() is a no-op on it. For EXIF
handling, route through Decode/Open/OpenFS.
Importing alongside the stdlib image package
The package name image collides with std/image. Files inside
this package use stdimage "image". Callers that need both should
alias one side, typically the framework one:
import ( "image" // stdlib fwimage "github.com/DonaldMurillo/gofastr/framework/image")
Avatar upload recipe
The typical "user uploads a photo → produce variants → store → render"
flow is goroutine-safe end-to-end (every Encoder caches its output via
sync.Once; every ProcessTo reader is one-shot). A complete sketch:
import ( "github.com/DonaldMurillo/gofastr/battery/storage" // in-memory or S3 storage "github.com/DonaldMurillo/gofastr/framework/image" "github.com/DonaldMurillo/gofastr/framework/ui")// 1. Decode + orient. Reject animated GIFs (avatar = still image).img, err := image.Decode(r)if err != nil { /* ... */ }img = img.AutoOrient()// 2. Generate variants and stream to storage. ProcessTo streams one// encoded buffer at a time and clamps to source width so a tiny// upload doesn't fanout into 16× upscaled storage waste.store := storage.NewMemoryStorage() // or NewLocalStorage / NewS3Storageset := image.VariantSet{ RejectAnimated: true, // ErrAnimatedSource if FrameCount > 1 BaseName: userID, Variants: []image.Variant{ {Width: 320, Format: image.FormatJPEG, Quality: 80, Suffix: "sm"}, {Width: 800, Format: image.FormatJPEG, Quality: 82, Suffix: "md"}, {Width: 1600, Format: image.FormatWebP, Suffix: "lg"}, }, Placeholder: &image.PlaceholderOptions{Width: 24}, BlurHashX: 4, BlurHashY: 3,}headers := []ui.HeaderInfo{}sr, err := set.ProcessTo(img, func(h image.VariantHeader, r io.Reader) error { if err := store.Save(ctx, h.Name, r); err != nil { return err } headers = append(headers, ui.HeaderInfo{ Name: h.Name, Width: h.Width, Height: h.Height, MIME: h.MIME, }) return nil})// 3. Render with PipelineImage. PipelineSourcesFromHeaders turns the// typed headers into the responsive <source> list.picture := ui.PipelineImage(ui.PipelineImageConfig{ Fallback: "/uploads/" + headers[1].Name, Alt: "Avatar", Width: headers[1].Width, Height: headers[1].Height, Sources: ui.PipelineSourcesFromHeaders(headers, func(name string) string { return "/uploads/" + name }), // sr.Placeholder is already a data: URL. To use the stored hash // instead, decode it: image.BlurHashDataURL(sr.BlurHash, …). Placeholder: sr.Placeholder,})
VariantSink's r is one-shot — stash it for a later goroutine and
the next read returns ErrReaderClosed. Drain inside the sink (e.g.,
hand it directly to storage.Save).
Limits at a glance
| Knob | Default | Configurable via |
|---|---|---|
| Max decoded pixels | 64 MP (8192²) | Config.MaxPixels |
| Max encoded WebP dim | 16384 per axis | hard cap (spec) |
VariantSet.Variants | 64 entries | MaxVariantsPerSet const |
BlurHash working size | 64 px longest side | hard cap (perf) |
| BlurHash render size | 20 px square | BlurHashRenderConfig.Width/Height |
| Max BlurHash render size | 128 px per axis | MaxBlurHashRenderSize const |
| Placeholder memo table | 512 entries | SetBlurHashCacheSize |
Path traversal | rejected on Open | none (use OpenFS) |
Performance notes
- 5-pass VP8L encode:
WebP().Bytes()runs every uniform predictor
mode (1, 2, 11, 12, 13) and ships the smallest. On a 256² photo
that's ~20 ms / 29 MB of allocations — ~6× slower than a single-mode
pass. For high-volume hot paths, prefer JPEG (~1 ms) and reserve
WebP-lossless for low-throughput admin / dashboard flows. isUniformshort-circuit: solid-color inputs encode in one
pass instead of five (~10 ms vs ~50 ms for 1024²). Near-uniform
inputs with one off-pixel still pay the full 5-pass.- BlurHash auto-resizes to 64 px on the longest side internally;
callers do not need to pre-Resize. BlurHashDataURLmemoises decode+encode per
(hash, size, punch, format, quality). Without the cache a list view
re-decodes the same handful of hashes on every request; a decode is
cheap but not free, and it multiplies by the number of images
on the page.ProcessToreleases resize intermediates between variants so
peak heap stays near one variant's worth, not all variants summed.Modulatefast-paths*image.NRGBAand*image.RGBA; for
other concrete types the slow per-pixelAt()path applies.
Common mistakes
- Calling
BlurHashon the original size. The algorithm is
O(W × H × xComp × yComp). AlwaysResizeto a small box (e.g.
32 × 24) first. - Expecting WebP-lossy / HEIC / AVIF to work. They return
ErrFormatUnsupported. There is no pure-Go encoder for AV1, HEVC,
or VP8 quality-competitive with libvpx — those formats need CGo and
are out of scope for this package. - Expecting WebP-lossless to match
cwebpfile sizes. The pure-Go
encoder tries five uniform predictor modes (1, 2, 11, 12, 13) per
image and emits the smallest output, plus subtract-green and LZ77
+ an 8-bit color cache. Compared againstcwebp -z 9
(libwebp 1.6) andpng.BestCompression:
| Content (256×256) | PNG-best | Ours WebP-LL | cwebp -z 9 | ours vs PNG | ours vs cwebp |
|---|---|---|---|---|---|
| smooth gradient | 579 | 282 | 76 | 0.49× | 3.71× |
| repeating patches | ~800 | 352 | 148 | 0.44× | 2.38× |
| natural photo | ~110k | 125.5k | 111.7k | 1.14× | 1.12× |
| white noise | ~197k | 196.8k | 196.7k | 1.00× | 1.00× |
In short: we beat PNG on smooth and structured content by
2-3×; cwebp beats us by another 2-4× on the same content (its
per-block adaptive mode + cross-color + palette path) but only by
~12% on natural photos and ~0% on noise. For PNG-replacement
delivery in the framework's UI pipeline, our output is good enough;
for "smallest-possible WebP" you'd still go to cwebp.
The encoder infrastructure for per-block mode evaluation is in
framework/image/internal/vp8l/predictor.go — scoreModeBlock +
chooseBlockModes — waiting on a proper Huffman cost model.
- Aliasing
*Imageacross goroutines. Chain methods return new
*Imagevalues, but the underlying pixel buffer in animage.Image
is shared. If you mutate viaGoImage(), clone first. - Forgetting
AutoOrienton user-uploaded photos. Phone cameras
store rotated sensors with an orientation tag, not rotated pixels.
Saving the JPEG verbatim leaves the rotation only correct in viewers
that honour EXIF — most thumbnail renderers don't.