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

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). 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

FormatDecodeEncodeNotes
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

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

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

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:

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",
})

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:

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

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. 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: 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. 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:

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 / 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

KnobDefaultConfigurable via
Max decoded pixels64 MP (8192²)Config.MaxPixels
Max resize targetthe image's MaxPixels (ErrTargetTooLarge past it)Config.MaxPixels at decode
Max source bytes64 MiB (ErrSourceTooLarge past it)Config.MaxSourceBytes
Max JPEG scans64 (ErrDecompressionBomb past it)Config.MaxJPEGScans
Max JPEG scans × pixels10 × MaxPixels (ErrDecompressionBomb past it)Config.MaxJPEGScanPixels
Max progressive JPEG coefficients512 MiB (ErrDecompressionBomb past it)Config.MaxProgressiveJPEGBytes
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.go, in scoreModeBlock and 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, 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.