Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 604467e480f8468389092a3b
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/codec.worker.ts
// Opens, resizes and saves images, off the page's main thread. Everything stays on this device.
import UPNG from "upng-js";
import { BLUR, type Kind, type OutType } from "./formats";
import { copyExif, stripJpeg, stripPng, stripWebp } from "./meta";
export type Probe = { width: number; height: number; thumb: Blob };
export type MakeJob = {
src: Blob;
kind: Kind;
out: OutType;
width: number;
height: number;
quality: number;
pngColors: "fewer" | "all";
/** 1024 x 1024 alpha values from background removal, or null. */
mask: Uint8ClampedArray | null;
/** Behind a cut-out: null for clear, "blur", or a color. */
background: string | null;
strip: boolean;
};
export type Made = { data: Uint8Array; thumb: Blob; width: number; height: number; kept: boolean };
const THUMB = 240;
async function probe(src: Blob): Promise<Probe> {
const bmp = await createImageBitmap(src);
const thumb = await thumbOf(bmp);
const { width, height } = bmp;
bmp.close();
return { width, height, thumb };
}
function thumbOf(img: CanvasImageSource & { width: number; height: number }): Promise<Blob> {
const k = Math.min(1, THUMB / Math.max(img.width, img.height));
const c = new OffscreenCanvas(Math.max(1, Math.round(img.width * k)), Math.max(1, Math.round(img.height * k)));
const ctx = c.getContext("2d")!;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, c.width, c.height);
return c.convertToBlob({ type: "image/webp", quality: 0.85 });
}
async function make(job: MakeJob): Promise<Made> {
const { width: w, height: h, out } = job;
const bmp = await createImageBitmap(job.src);
const sameSize = bmp.width === w && bmp.height === h;
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("This image is too large for this browser.");
ctx.imageSmoothingQuality = "high";
ctx.drawImage(bmp, 0, 0, w, h);
if (job.mask) {
const m = new OffscreenCanvas(1024, 1024);
const alpha = new ImageData(1024, 1024);
for (let i = 0; i < job.mask.length; i++) alpha.data[i * 4 + 3] = job.mask[i];
m.getContext("2d")!.putImageData(alpha, 0, 0);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(m, 0, 0, w, h);
if (job.background === BLUR) {
ctx.globalCompositeOperation = "destination-over";
drawBlurred(ctx, bmp, m, w, h);
}
}
bmp.close();
// JPG cannot be see-through, so anything transparent goes on white unless a color was picked.
const fill = job.mask && job.background && job.background !== BLUR ? job.background : out === "jpeg" ? "#ffffff" : null;
if (fill) {
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = fill;
ctx.fillRect(0, 0, w, h);
}
ctx.globalCompositeOperation = "source-over";
let data = await encode(canvas, ctx, job);
let kept = false;
const source = async () => new Uint8Array(await job.src.arrayBuffer());
// Never hand back a bigger file than the person gave: if a fresh copy of the same kind and size
// comes out larger, keep the original pixels and only remove the photo details.
if (out === job.kind && sameSize && !job.mask && data.length >= job.src.size && (out === "jpeg" || out === "png" || out === "webp")) {
const original = await source();
data = !job.strip ? original : out === "jpeg" ? stripJpeg(original) : out === "png" ? stripPng(original) : stripWebp(original);
kept = true;
} else if (!job.strip && out === "jpeg" && job.kind === "jpeg") {
data = copyExif(data, await source());
}
return { data, thumb: await thumbOf(canvas), width: w, height: h, kept };
}
/** A blurred copy of the background, drawn under the cut-out subject like a portrait mode background. */
function drawBlurred(ctx: OffscreenCanvasRenderingContext2D, bmp: ImageBitmap, mask: OffscreenCanvas, w: number, h: number) {
const r = Math.max(4, Math.round(Math.max(w, h) / 60));
// The background alone, with the subject taken out, so the subject's colors do not spread into the blur as a halo.
const bg = new OffscreenCanvas(w, h);
const bctx = bg.getContext("2d")!;
bctx.drawImage(bmp, 0, 0, w, h);
bctx.globalCompositeOperation = "destination-out";
bctx.drawImage(mask, 0, 0, w, h);
// Each wider blur goes underneath and fills more of the gap the subject left; the photo itself fills the rest.
for (const k of [1, 3, 9]) soften(ctx, bg, r * k, r, w, h);
ctx.drawImage(bmp, 0, 0, w, h);
}
function soften(ctx: OffscreenCanvasRenderingContext2D, src: OffscreenCanvas, r: number, edge: number, w: number, h: number) {
if ("filter" in OffscreenCanvasRenderingContext2D.prototype) {
ctx.filter = `blur(${r}px)`;
// Drawn a little larger than the frame, so the blur does not fade out at the edges.
ctx.drawImage(src, -2 * edge, -2 * edge, w + 4 * edge, h + 4 * edge);
ctx.filter = "none";
return;
}
// Browsers without canvas filters: shrink it and draw it back at full size, which softens it.
const small = new OffscreenCanvas(Math.max(1, Math.round(w / r)), Math.max(1, Math.round(h / r)));
const sctx = small.getContext("2d")!;
sctx.imageSmoothingQuality = "high";
sctx.drawImage(src, 0, 0, small.width, small.height);
ctx.drawImage(small, 0, 0, w, h);
}
async function encode(canvas: OffscreenCanvas, ctx: OffscreenCanvasRenderingContext2D, job: MakeJob): Promise<Uint8Array> {
const pixels = () => ctx.getImageData(0, 0, canvas.width, canvas.height);
switch (job.out) {
case "jpeg": {
const { default: encode } = await import("@jsquash/jpeg/encode");
return new Uint8Array(await encode(pixels(), { quality: job.quality }));
}
case "webp": {
const { default: encode } = await import("@jsquash/webp/encode");
return new Uint8Array(await encode(pixels(), { quality: job.quality }));
}
case "png": {
const { default: optimise } = await import("@jsquash/oxipng/optimise");
const png = job.pngColors === "fewer"
? UPNG.encode([pixels().data.buffer], canvas.width, canvas.height, 256)
: await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer();
return new Uint8Array(await optimise(png, { level: 2 }));
}
case "avif": {
const blob = await canvas.convertToBlob({ type: "image/avif", quality: job.quality / 100 });
if (blob.type !== "image/avif") throw new Error("This browser cannot save AVIF.");
return new Uint8Array(await blob.arrayBuffer());
}
}
}
type Request = { id: number } & ({ op: "probe"; src: Blob } | { op: "make"; job: MakeJob });
self.onmessage = async (e: MessageEvent<Request>) => {
const req = e.data;
try {
if (req.op === "probe") {
self.postMessage({ id: req.id, ok: true, value: await probe(req.src) });
} else {
const made = await make(req.job);
self.postMessage({ id: req.id, ok: true, value: made }, { transfer: [made.data.buffer] });
}
} catch (err) {
self.postMessage({ id: req.id, ok: false, error: (err as Error)?.message || String(err) });
}
};
Change history
Quality pass: background removal, blur background, paste images
Bounded source sync
Bounded source sync
Bounded source sync