Image pipeline
framework/image is a chainable image pipeline: decode → transform →
encode. It is 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). A
source-byte cap runs before that guard: DecodeWithConfig buffers
through io.LimitReader(r, MaxSourceBytes+1), and a source that
delivers past the cap returns ErrSourceTooLarge before any pixel
buffer is allocated. Config.MaxSourceBytes defaults to
DefaultMaxSourceBytes (64 MiB); zero or negative means the default,
mirroring MaxPixels. 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 guard.
A JPEG is also refused with ErrDecompressionBomb when it carries more
than Config.MaxJPEGScans scans (default DefaultMaxJPEGScans = 64;
zero or negative means the default). The pixel cap bounds the frame,
not how many times the decoder walks it: a progressive scan of one
end-of-band run costs about ten bytes and revisits every block, so a
10 KB file of 100 scans took four seconds of CPU at 64 MP. Common
encoders write a few dozen scans at most. The count runs on the bytes,
before any pixel is decoded: it walks the marker segments from SOI to
the first EOI, skipping each segment by its length, so an APPn payload
(an ICC profile, a MakerNote preview) and a motion photo's video clip
after EOI are not counted as scans.
The scan cap is a flat ceiling; a budget applies as well. A JPEG is
refused when its scans × width × height exceed
Config.MaxJPEGScanPixels, the block walks its decode costs. Zero or
negative means ten times the effective MaxPixels
(DefaultMaxJPEGScanPixels = 640 M pixel-scans at the default 64 MP).
So a frame at the pixel cap gets ten scans, while anything up to 10 MP
keeps the flat cap's 64: at the flat cap alone, a 6.5 KB progressive
file of 64 scans over 64 MP took ~2.8 s of CPU. Raising MaxJPEGScans
admits more scans only on frames small enough for the budget; a host
that wants many scans on huge frames raises both.
A progressive JPEG has a memory budget too. Go's decoder keeps the
coefficients of a progressive frame for the whole decode: 256 bytes per
8×8 block of every component, on top of the pixels. A few-hundred-byte
progressive 3-component 8192×8192 file allocated ~1 GB, against ~192 MB
for the same frame baseline. Decode reads the frame header first and
refuses with ErrDecompressionBomb when that buffer would exceed
Config.MaxProgressiveJPEGBytes (default DefaultMaxProgressiveJPEGBytes
= 512 MiB; zero or negative means the default). At the default a
progressive 64 MP frame passes in grayscale (256 MiB) or 4:2:0 color
(384 MiB) and is refused at 4:4:4 (768 MiB). Baseline JPEGs keep no
coefficients and are not affected.
The coefficient walk follows the decoder's marker resynchronization: stray
bytes and top-level FF 00 stuffing do not disable the cap. An unreadable
frame header fails closed instead of falling through to an uncapped decode.
The same rule applies to file.StripMetadata when baking EXIF orientation.
file.StripMetadata's orientation bake counts the SOS segments its own
walk keeps and applies the same cap, scan budget and memory budget.
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, WithoutEnlargement img.Rotate(degrees) // 0 / 90 / 180 / 270 (clockwise) img.Flip() // mirror top↔bottom img.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
A resize target larger than the image's MaxPixels (the Config it
was decoded with, or DefaultMaxPixels for FromImage) is refused
before any pixel buffer is allocated. Resize has no error result, so
it panics with an error wrapping ErrTargetTooLarge; VariantSet.Process,
ProcessTo and Placeholder check first and return that error.
NewGradient returns it past DefaultMaxPixels.
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 distort img.Resize(800, 600, image.WithFit(image.FitInside)) // preserve aspect; fit within img.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:
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", })
Each variant's file name is <BaseName>-<Suffix or width>.<ext>. Two
variants that compose the same name would overwrite each other in
storage, so Process and ProcessTo refuse the set before encoding
anything, and CheckVariantNames reports the same error for a list
you want to check at startup. Same suffix, different Format is fine.
A name never passes image.MaxVariantNameBytes (255 bytes, the
filesystem name limit core/upload holds uploads to). A BaseName too
long for the set's longest -<suffix>.<ext> is cut from the front, by
the same amount for every variant, so the names stay distinct and keep
the end of the base, where upload.UniqueFilename puts its random
token. A suffix that leaves fewer than image.MinVariantBaseBytes (64)
bytes for the base is a CheckVariantNames error: a cut that short
would give two uploads the same rendition name.
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 URL durl, 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. Hashes longer than
166 characters (the 9×9 component limit) are rejected before decoding
their digits, so invalid stored values cannot grow the decoder's buffers.
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. VariantSet renders the raster
as decoded, so call AutoOrient() first when its outputs are for
display; the image-field pipeline (framework/imagefield) does.
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 / NewS3Storage set := 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 resize target | the image's MaxPixels (ErrTargetTooLarge past it) | Config.MaxPixels at decode |
| Max source bytes | 64 MiB (ErrSourceTooLarge past it) | Config.MaxSourceBytes |
| Max JPEG scans | 64 (ErrDecompressionBomb past it) | Config.MaxJPEGScans |
| Max JPEG scans × pixels | 10 × MaxPixels (ErrDecompressionBomb past it) | Config.MaxJPEGScanPixels |
| Max progressive JPEG coefficients | 512 MiB (ErrDecompressionBomb past it) | Config.MaxProgressiveJPEGBytes |
| 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, in scoreModeBlock and
chooseBlockModes, waiting on a proper Huffman cost model.
- Aliasing
*Imageacross goroutines. Chain methods return new*Imagevalues, but the underlying pixel buffer in animage.Imageis 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, and most thumbnail renderers don't.
Metadata on stored originals
Uploads keep their EXIF block (GPS and camera tags included) on the
stored original, and every later download serves it. Stripping metadata
at upload is planned for the upload pipeline and is tracked by the open
red probe framework/file/exif_red_test.go (make red-tests); until it
lands, strip on the client or in a BeforeCreate hook when the images are
user-supplied.