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

12 lines
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:

2 lines
big, _   := img.Resize(1600, 0).JPEG().Bytes()small, _ := img.Resize(320, 0).WebP().Bytes() // zero-value = lossless

Construction

FunctionUse 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

FormatDecodeEncodeNotes
JPEGEXIF orientation parsed for AutoOrient
PNGCompression level configurable
GIFFirst frame on animated input; 1..256 palette colours
BMP
TIFFCompression + predictor configurable
WebPLossless (VP8L). WebPOptions{Lossy: true} returns ErrFormatUnsupported. Encode dimension cap: 16384×16384.
HEIC / AVIFOut of scope (no pure-Go codec exists)

Animated input, ICC profiles, and EXIF data beyond orientation are
intentionally out of scope.

Transformations

12 lines
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:

FilterBacked byWhen to use
Lanczos3draw.CatmullRomDefault. Highest quality available pure-Go.
Lanczos2draw.BiLinearFaster, mildly softer.
CatmullRomdraw.CatmullRomSame as Lanczos3.
BiLineardraw.BiLinearFast, soft.
ApproxBiLineardraw.ApproxBiLinearFastest; visible aliasing.
Nearestdraw.NearestNeighborPixel 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

3 lines
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.

4 lines
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:

MethodOptions
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"
-->

4 lines
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.

19 lines
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:

5 lines
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:

15 lines
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:

6 lines
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:

4 lines
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:

6 lines
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: ImageFitContain the 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:

3 lines
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:

4 lines
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:

48 lines
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

KnobDefaultConfigurable via
Max decoded pixels64 MP (8192²)Config.MaxPixels
Max encoded WebP dim16384 per axishard cap (spec)
VariantSet.Variants64 entriesMaxVariantsPerSet const
BlurHash working size64 px longest sidehard cap (perf)
BlurHash render size20 px squareBlurHashRenderConfig.Width/Height
Max BlurHash render size128 px per axisMaxBlurHashRenderSize const
Placeholder memo table512 entriesSetBlurHashCacheSize
Path traversalrejected on Opennone (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.
  • isUniform short-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.
  • BlurHashDataURL memoises 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.
  • ProcessTo releases resize intermediates between variants so
    peak heap stays near one variant's worth, not all variants summed.
  • Modulate fast-paths *image.NRGBA and *image.RGBA; for
    other concrete types the slow per-pixel At() path applies.

Common mistakes

  • Calling BlurHash on the original size. The algorithm is
    O(W × H × xComp × yComp). Always Resize to 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 cwebp file 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 against cwebp -z 9
    (libwebp 1.6) and png.BestCompression:
Content (256×256)PNG-bestOurs WebP-LLcwebp -z 9ours vs PNGours vs cwebp
smooth gradient579282760.49×3.71×
repeating patches~8003521480.44×2.38×
natural photo~110k125.5k111.7k1.14×1.12×
white noise~197k196.8k196.7k1.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.goscoreModeBlock +
chooseBlockModes — waiting on a proper Huffman cost model.

  • Aliasing *Image across goroutines. Chain methods return new
    *Image values, but the underlying pixel buffer in an image.Image
    is shared. If you mutate via GoImage(), clone first.
  • Forgetting AutoOrient on 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.