images-free.openapps.xyz

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.
Owned app · rev b674d6e71b51
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.
← All files
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
b674d6e71b · 5 hours ago
Bounded source sync
c6622bcbd1 · 1 day ago
Bounded source sync
92ecc89502 · 1 day ago
Bounded source sync
ae2f8b8be0 · 1 day ago