Building a compressor you give a file size, not a quality slider
Every image compressor has a quality slider from 1 to 100. Nobody wants a quality of 63. They want a photo under 100 KB because a form refuses anything larger. This tool inverts the control: you state the outcome, and the engine searches for the parameters. That search is the single most reused piece of code on the site, and it knows nothing about images.
What it is built with
- Canvas 2D and `createImageBitmap` for decoding and rescaling
- `canvas.convertToBlob` / `toBlob` for JPEG and WebP encoding
- OffscreenCanvas in a Web Worker where the browser supports it
- No third-party image library: the browser already has the codecs
Step 1: decide what the user is actually asking for
A quality parameter is the encoder’s unit, not the user’s. The relationship between quality and file size depends on the image: a flat screenshot at quality 80 might be 40 KB while a detailed photograph at the same setting is 900 KB. Handing someone a slider is handing them a guessing game with a slow feedback loop.
So the interface takes a budget, and the code solves for the rest. The consequence is that the compressor has to encode the image several times, which is only tolerable because it happens locally with no upload between attempts.
Step 2: make the search know nothing about images
The engine is handed an `encode(params)` function and a byte budget, and returns the largest output that still fits. It has no idea whether it is driving a JPEG encoder or ffmpeg. That is what lets the video tool reuse it.
export interface TargetSizeOptions {
/** Codec bridge. Must be pure w.r.t. its params and honour `signal`. */
encode: (params: EncodeParams, signal: AbortSignal) => Promise<Uint8Array>;
/** Hard byte budget the output must fit under (e.g. 100 * 1024). */
budgetBytes: number;
searchSpace: {
quality: Range;
/** Omitted means never rescale. */
scale?: Range;
};
strategy: 'binary' | 'analytic-then-verify';
maxIterations?: number;
}
Step 3: binary search, with the boundaries probed first
Output size is monotonic in quality: raising quality never makes the file smaller. That is what makes binary search valid, and it is worth writing down because the whole approach collapses without it.
The subtlety is where to probe. An implementation that only bisects the interior can miss a fit that lives exactly at the minimum quality, and then reports failure for an image it could have handled. So each scale level probes the cheapest possible output first: if even that overshoots, no amount of quality reduction will help and only rescaling can, which tells the loop what to do next.
Every fitting probe is kept, so the engine can never return something worse than it already found, even if it runs out of iterations mid-search.
for (;;) {
// Cheapest possible output at this scale. If even this overshoots,
// quality can't save us here — only scaling down can.
const low = await probe(qRange.min);
best = pickBest(best, low);
if (low.withinBudget) {
// Best possible output at this scale. If it fits, we're done.
const high = await probe(qRange.max);
best = pickBest(best, high);
if (high.withinBudget) return high;
// Otherwise the answer is between qMin and qMax: bisect.
}
// ... drop scale and repeat, if scaling is allowed
}
Step 4: report what it had to give up
A compressor that silently halves the dimensions of a photo to hit a budget has done something the user might not have accepted. The result carries a record of what was sacrificed, so the interface can say "scaled to 78%" instead of quietly handing back a smaller picture.
The same structure covers honest failure. If nothing in the search space fits, the engine returns the smallest output it managed with `withinBudget: false`, rather than throwing. The user gets the best available answer and an accurate statement that it did not fit.
Step 5: the payoff, on a completely different codec
Video cannot binary-search. A single encode of a two-minute clip takes long enough that a dozen attempts is not an option. But the required bitrate is calculable: a byte budget divided by a duration is a bitrate, so you can compute a first guess analytically and then verify it with one corrective re-encode.
That is a second strategy inside the same engine, selected by a string, sharing all the budget accounting and result reporting. Because the search was written without any knowledge of images, adding video meant adding a strategy rather than writing a second engine.
What went wrong
The most useful part of any build write-up, and the part usually left out.
- Binary searching only the interior of the quality range silently fails for images whose only fit is at the minimum. Probe the boundaries first.
- The search must be abortable. Each iteration is a full encode, and a user who changes the target halfway through should not wait for the old search to finish; every probe checks the abort signal before starting.
- A batch of large photos will exhaust memory if every decode is held at once. Decoding, searching and releasing one image at a time is slower to write and is the difference between working and crashing on a phone.
- Canvas silently returns a PNG when asked for a format it cannot encode. The output extension is therefore not evidence of the output format, and the tool checks the magic bytes rather than trusting the request.
Use the tool: Compress an image to an exact size · How YappyKit works