image-budget

API

One function, and a result that does not lie

compress(input, options?)

compress(input: Blob, options?: Options): Promise<Result>

Throws only when there is no result at all - a file that cannot be decoded, a missing codec, an aborted signal. A budget it could not meet is a Result, not an exception: forcing a try/catch around a perfectly usable image would be the wrong shape.

Options

optiondefault
maxBytesnoneThe budget. Without it a single encode runs at top quality.
maxDimensionnoneLongest-edge cap, applied before encoding.
format'auto''auto', or an explicit image/jpeg | png | webp | avif.
preserveExiffalseAsk to keep metadata. Reported as degraded if the engine cannot.
maxAttempts6Ceiling on encodes across the whole search.
tolerance0.85Stop once a result lands in [tolerance * maxBytes, maxBytes].
signalnoneChecked between attempts.
enginecanvasPass jsquashEngine for AVIF.

format: 'auto'

Prefers WebP, and never flattens an image that might be transparent. Only JPEG input is provably opaque; anything else could carry alpha, and finding out costs a full pixel scan to answer a question that can be answered conservatively for free. So a PNG never auto-converts to JPEG - it would paint the transparent areas black, silently.

Asking for image/jpeg explicitly overrides that. Then flattening is your decision rather than the library's accident.

tolerance

The last few percent of quality costs whole encodes. When one AVIF attempt is seconds, landing at 88% of the budget immediately beats landing at 99% three attempts later. Set it to 0.99 if you would rather spend the time.

Result

interface Result {
  blob: Blob
  format: string // read off the blob, never echoed from the request
  bytes: number
  width: number
  height: number
  engine: string
  attempts: number // how many encodes the search actually ran
  degraded: Degradation[]
}

Degradation

An empty degraded array is the only assurance that the output is what you asked for. Each entry is something that would otherwise have happened in silence.

type Degradation =
  | { kind: 'format'; want: string; got: string }
  | { kind: 'exif'; reason: 'engine-cannot-preserve' }
  | { kind: 'overshoot'; maxBytes: number; got: number }
  | {
      kind: 'dimension'
      reason: 'engine-pixel-ceiling' | 'budget'
      from: readonly [number, number]
      to: readonly [number, number]
    }

format — either the engine cannot emit what you asked for (known from the probe, before encoding) or it claimed to and didn't (caught by reading blob.type after). Both surface the same way.

exif — you passed preserveExif and the chosen engine holds pixels, not metadata. Note that orientation is not lost: it is baked into the pixels during decode via imageOrientation: 'from-image', so the image is never sideways even though the EXIF tag is gone.

overshoot — no quality at any attempted size fit the budget. blob is the smallest output produced, so you can still upload it, warn, or refuse.

dimension — the image was scaled down. reason: 'budget' means you asked for it, or the search ran out of quality and started shrinking. 'engine-pixel-ceiling' means the engine protected itself: Safari returns a blank canvas past roughly 16.7M pixels instead of throwing, so this cap is about correctness, not speed.

Engine

The seam. Single-shot by design - an engine encodes once at the quality it is handed and has no opinion about budgets, because the search is the only place that can report honestly on itself.

interface Engine {
  id: string
  preservesExif: boolean
  safeMaxPixels: number
  probe: () => Promise<ReadonlySet<Format>>
  attempt: (source: ImageBitmap, plan: Plan) => Promise<Attempt>
}

Two ship in the box - see Engines.

Also exported

decode, decodeNative, decodeHeic, isHeic, canvasEngine. Useful if you are building your own pipeline; not needed for compress.

On this page