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/Workspace.tsx
// The Images workspace in the landing hero: drop images, pick what to do, see every result, download.
// Images are opened and saved on this device; nothing is uploaded.
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode } from "react";
import { canSaveAvif, FileProblem, make, onModelProgress, open, removeBackground } from "./lib/engine";
import { BLUR, EXT, fromQuery, LABEL, MIME, outType, sizeProblem, stemOf, target, toQuery, WHITE, type Fit, type Kind, type OutType, type Settings } from "./lib/formats";
import { MODEL_BYTES } from "./lib/model";
import { canShareFiles, save, share, size, toast } from "./ui";

type Result = { key: string; blob: Blob; thumb: string; width: number; height: number; type: OutType; kept: boolean };
type Item = {
  id: string;
  name: string;
  kind: Kind;
  /** The original file, or a decoded copy of a HEIC photo this browser cannot open. */
  src: Blob;
  bytes: number;
  width: number;
  height: number;
  thumb: string;
  /** Position in the batch it was added with, so rows come in one after another. */
  order: number;
  mask?: Uint8ClampedArray;
  maskError?: string;
  result?: Result;
  error?: { key: string; message: string };
};

const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const LOSSY: OutType[] = ["jpeg", "webp", "avif"];
const MB = (n: number) => Math.round(n / 1024 / 1024);
const BACKGROUNDS = [["Clear", null], ["White", WHITE], ["Color", "color"], ["Blur", BLUR]] as const;
const INLINE_MAX = "min(62vh, 640px)";
const DIALOG_MAX = "min(88dvh - 200px, 700px)";

/** Everything that changes what one image becomes. A result is current while its key matches. */
function keyOf(item: Item, s: Settings): string {
  const out = outType(item.kind, s, canSaveAvif);
  return JSON.stringify([
    out,
    target(item.width, item.height, s),
    LOSSY.includes(out) ? s.quality : null,
    out === "png" ? s.pngColors : null,
    s.cutout ? s.background ?? "clear" : null,
    s.strip,
  ]);
}

function outName(item: Item, type: OutType) {
  return `${stemOf(item.name)}.${EXT[type]}`;
}

function Saving({ from, to }: { from: number; to: number }) {
  const pct = to < from ? Math.max(1, Math.floor((1 - to / from) * 100)) : Math.ceil((to / from - 1) * 100);
  return (
    <span className={`saving${to < from ? " good" : pct > 0 ? " bad" : ""}`}>
      {to < from ? `${pct}% smaller` : pct > 0 ? `${pct}% larger` : "Same size"}
    </span>
  );
}

export function Workspace() {
  const [items, setItems] = useState<Item[]>([]);
  // A link such as /?bg=remove or /?to=jpg&width=1600 opens with those settings.
  const [settings, setSettings] = useState<Settings>(() => {
    const s = fromQuery(window.location.search);
    return s.format === "avif" && !canSaveAvif ? { ...s, format: "same" } : s;
  });
  const [applied, setApplied] = useState<Settings>(settings);
  const [sizeText, setSizeText] = useState(String(settings.size));
  const [color, setColor] = useState(settings.background?.startsWith("#") && settings.background !== WHITE ? settings.background : "#d9e4f5");
  const [opening, setOpening] = useState<{ name: string; left: number } | null>(null);
  const [problems, setProblems] = useState<string[]>([]);
  const [making, setMaking] = useState<string | null>(null);
  const [cutting, setCutting] = useState<string | null>(null);
  const [model, setModel] = useState<{ loaded: number; total: number } | null>(null);
  const [zipping, setZipping] = useState(false);
  const [preview, setPreview] = useState<string | null>(null);
  const [leaving, setLeaving] = useState<string[]>([]);
  const [over, setOver] = useState(false);
  const input = useRef<HTMLInputElement>(null);
  const ids = useRef(0);
  const pasted = useRef(0);
  const itemsRef = useRef(items);
  itemsRef.current = items;
  const appliedRef = useRef(applied);
  appliedRef.current = applied;
  const busy = useRef({ make: false, cut: false });

  const started = items.length > 0 || opening !== null;

  useEffect(() => {
    document.body.classList.toggle("app-mode", started);
    if (started) window.scrollTo(0, 0);
  }, [started]);

  useEffect(() => onModelProgress(setModel), []);

  // Sliders and typing settle for a moment before every image is made again.
  useEffect(() => {
    const t = window.setTimeout(() => setApplied(settings), 250);
    return () => window.clearTimeout(t);
  }, [settings]);

  // The address keeps the settings, so the page can be bookmarked or sent as a preset.
  useEffect(() => {
    const q = toQuery(applied);
    if (q !== window.location.search) window.history.replaceState(window.history.state, "", `${window.location.pathname}${q}${window.location.hash}`);
  }, [applied]);

  const change = (patch: Partial<Settings>) => setSettings((s) => ({ ...s, ...patch }));
  const update = (id: string, patch: Partial<Item>) => setItems((list) => list.map((i) => (i.id === id ? { ...i, ...patch } : i)));

  // One image at a time in each worker, in list order, always with the latest settings.
  const pumpMake = () => {
    if (busy.current.make) return;
    const s = appliedRef.current;
    const next = itemsRef.current.find((i) => {
      if (s.cutout && !i.mask) return false;
      const key = keyOf(i, s);
      return i.result?.key !== key && i.error?.key !== key;
    });
    if (!next) return;
    const key = keyOf(next, s);
    const out = outType(next.kind, s, canSaveAvif);
    const [width, height] = target(next.width, next.height, s);
    busy.current.make = true;
    setMaking(next.id);
    make({
      src: next.src, kind: next.kind, out, width, height, quality: s.quality, pngColors: s.pngColors,
      mask: s.cutout ? next.mask! : null, background: s.background, strip: s.strip,
    })
      .then((made) => {
        const item = itemsRef.current.find((i) => i.id === next.id);
        if (!item) return; // removed while it was being made
        const result: Result = {
          key, type: out, kept: made.kept, width: made.width, height: made.height,
          blob: new Blob([made.data], { type: MIME[out] }), thumb: URL.createObjectURL(made.thumb),
        };
        update(next.id, { result, error: undefined });
        if (item.result) URL.revokeObjectURL(item.result.thumb);
      }, (err: Error) => update(next.id, { error: { key, message: err.message } }))
      .finally(() => {
        busy.current.make = false;
        setMaking(null);
      });
  };

  const pumpCut = () => {
    if (busy.current.cut || !appliedRef.current.cutout) return;
    const next = itemsRef.current.find((i) => !i.mask && !i.maskError);
    if (!next) return;
    busy.current.cut = true;
    setCutting(next.id);
    removeBackground(next.src)
      .then((mask) => update(next.id, { mask }), (err: Error) => update(next.id, { maskError: err.message }))
      .finally(() => {
        busy.current.cut = false;
        setCutting(null);
      });
  };

  // After every change, start the next job if a worker is free.
  useEffect(() => {
    pumpCut();
    pumpMake();
  });

  const addFiles = async (list: File[]) => {
    if (!list.length) return;
    setProblems([]);
    let left = list.length;
    let order = 0;
    for (const file of list) {
      setOpening({ name: file.name, left });
      try {
        const got = await open(file);
        const item: Item = {
          id: `i${ids.current++}`, name: file.name, kind: got.kind, src: got.src, bytes: file.size,
          width: got.width, height: got.height, thumb: URL.createObjectURL(got.thumb), order: order++,
        };
        setItems((l) => [...l, item]);
      } catch (err) {
        setProblems((p) => [...p, err instanceof FileProblem ? err.message : `${file.name} could not be opened.`]);
      }
      left--;
    }
    setOpening(null);
  };
  const addRef = useRef(addFiles);
  addRef.current = addFiles;

  // Images dropped anywhere on the page are added (and the browser does not open them instead).
  useEffect(() => {
    let depth = 0;
    const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes("Files");
    const enter = (e: DragEvent) => { if (hasFiles(e)) { e.preventDefault(); depth++; setOver(true); } };
    const dragover = (e: DragEvent) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer!.dropEffect = "copy"; } };
    const leave = (e: DragEvent) => { if (hasFiles(e) && --depth <= 0) { depth = 0; setOver(false); } };
    const drop = (e: DragEvent) => {
      if (!hasFiles(e)) return;
      e.preventDefault();
      depth = 0;
      setOver(false);
      void addRef.current(Array.from(e.dataTransfer!.files));
    };
    // Pasted images (a screenshot, a copied photo) are added too; pasted text stays text.
    const paste = (e: ClipboardEvent) => {
      const files = Array.from(e.clipboardData?.files || []).filter((f) => f.type.startsWith("image/"));
      if (!files.length) return;
      e.preventDefault();
      void addRef.current(files.map((f) => new File([f], `pasted-${++pasted.current}.${f.type.split("/")[1] || "png"}`, { type: f.type })));
    };
    window.addEventListener("dragenter", enter);
    window.addEventListener("dragover", dragover);
    window.addEventListener("dragleave", leave);
    window.addEventListener("drop", drop);
    window.addEventListener("paste", paste);
    return () => {
      window.removeEventListener("dragenter", enter);
      window.removeEventListener("dragover", dragover);
      window.removeEventListener("dragleave", leave);
      window.removeEventListener("drop", drop);
      window.removeEventListener("paste", paste);
    };
  }, []);

  const pick = () => input.current?.click();
  const onPick = (e: ChangeEvent<HTMLInputElement>) => {
    const list = Array.from(e.target.files || []);
    e.target.value = "";
    void addFiles(list);
  };

  const forget = (list: Item[]) => {
    for (const i of list) {
      URL.revokeObjectURL(i.thumb);
      if (i.result) URL.revokeObjectURL(i.result.thumb);
    }
  };
  const gone = (id: string) => {
    forget(itemsRef.current.filter((i) => i.id === id));
    setItems((l) => l.filter((i) => i.id !== id));
    setLeaving((l) => l.filter((x) => x !== id));
  };
  const startOver = () => {
    forget(items);
    setItems([]);
    setProblems([]);
    setLeaving([]);
    setPreview(null);
  };
  const toggleCutout = (on: boolean) => {
    // Turning it on again retries images whose background could not be removed.
    if (on) setItems((l) => l.map((i) => (i.maskError ? { ...i, maskError: undefined } : i)));
    change({ cutout: on });
  };

  // Results count only when they match the latest settings, so nothing stale is ever downloaded.
  const s = settings;
  const current = (i: Item) => (i.result?.key === keyOf(i, s) ? i.result : null);
  const failure = (i: Item): string | null => {
    if (s.cutout && i.maskError) return i.maskError.startsWith("The background model") ? i.maskError : `The background could not be removed: ${i.maskError}`;
    return i.error?.key === keyOf(i, s) ? i.error.message : null;
  };
  const retry = (i: Item) => update(i.id, s.cutout && i.maskError ? { maskError: undefined } : { error: undefined });
  const modelPct = s.cutout && model && model.loaded < model.total ? Math.round((model.loaded / model.total) * 100) : null;
  const work = (i: Item) => s.cutout && !i.mask
    ? cutting === i.id ? (modelPct !== null ? `Getting the model, ${modelPct}%` : "Removing background") : "Waiting"
    : making === i.id ? "Working" : "Waiting";

  const done = items.filter((i) => current(i));
  const failed = items.filter((i) => !current(i) && failure(i));
  const settled = !opening && items.length > 0 && done.length + failed.length === items.length;
  const before = done.reduce((n, i) => n + i.bytes, 0);
  const after = done.reduce((n, i) => n + current(i)!.blob.size, 0);
  const outs = items.map((i) => outType(i.kind, settings, canSaveAvif));
  const anyLossy = outs.some((o) => LOSSY.includes(o));
  const anyPng = outs.some((o) => o === "png");
  const sizeError = sizeProblem(settings);
  const isColor = !!settings.background && settings.background !== WHITE && settings.background !== BLUR;
  const single = items.length + (opening?.left ?? 0) === 1;

  const download = (i: Item) => {
    const r = current(i);
    if (r) save(r.blob, outName(i, r.type));
  };
  const downloadAll = async () => {
    if (!settled || zipping || !done.length) return;
    if (done.length === 1) return download(done[0]);
    setZipping(true);
    try {
      const used = new Map<string, number>();
      const files = done.map((i) => {
        const r = current(i)!;
        let name = outName(i, r.type);
        const n = used.get(name) ?? 0;
        used.set(name, n + 1);
        if (n) name = name.replace(/(\.[^.]+)$/, ` (${n + 1})$1`);
        return { name, input: r.blob, lastModified: new Date() };
      });
      const { downloadZip } = await import("client-zip");
      const blob = await downloadZip(files).blob();
      save(new Blob([blob], { type: "application/zip" }), "images.zip");
    } catch (err) {
      toast(`The zip could not be made: ${(err as Error).message}`);
    } finally {
      setZipping(false);
    }
  };
  const shareAll = () => {
    if (!settled || !done.length) return;
    void share(done.map((i) => {
      const r = current(i)!;
      return new File([r.blob], outName(i, r.type), { type: r.blob.type });
    }));
  };

  const problemList = problems.length > 0 && (
    <div className="problems" role="alert">
      {problems.map((p, i) => (
        <p className="notice notice-error enter" key={i}>
          <span>{p}</span>
          <button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label="Dismiss" onClick={() => setProblems((list) => list.filter((_, j) => j !== i))}>
            <Icon name="x" />
          </button>
        </p>
      ))}
    </div>
  );

  const fileInput = <input ref={input} id="file-input" type="file" multiple accept="image/*,.heic,.heif,.avif" hidden aria-label="Choose images" onChange={onPick} />;

  if (!started) {
    return (
      // The whole card opens the file picker, like the button in it.
      <div className={`drop card${over ? " is-over" : ""}`} onClick={(e) => { if (!(e.target as HTMLElement).closest("button, label, input")) pick(); }}>
        {fileInput}
        <img className="art drop-art" src="/art/card-images.webp" alt="" width="220" height="181" />
        <p className="drop-title">Drop images here</p>
        <button className="btn btn-lg" type="button" onClick={pick}>Choose images</button>
        <label className="check drop-check">
          <input type="checkbox" checked={settings.cutout} onChange={(e) => toggleCutout(e.target.checked)} />
          <span>Remove the background</span>
        </label>
        <p className="hint">JPG, PNG, WebP, AVIF or HEIC. <span className="paste-hint">You can paste them too. </span>Nothing is uploaded.</p>
        {problemList}
      </div>
    );
  }

  let status: ReactNode;
  if (!settled) {
    const cutLeft = s.cutout ? items.filter((i) => !i.mask && !i.maskError).length : 0;
    const text = opening ? `Opening ${opening.name}`
      : modelPct !== null ? `Getting the model, ${modelPct}%. Only the first time.`
      : cutLeft ? (items.length === 1 ? "Removing the background" : `Removing backgrounds, ${items.length - cutLeft} of ${items.length} done`)
      : items.length === 1 ? "Making your image" : `Making images, ${done.length} of ${items.length} done`;
    status = <><span className="spinner" aria-hidden="true" /><span className="status-text">{text}</span></>;
  } else if (done.length) {
    status = (
      <>
        <span>{size(before)} to {size(after)}</span> <Saving from={before} to={after} />
        {failed.length > 0 && <span className="status-failed">{plural(failed.length, "image")} not made</span>}
      </>
    );
  } else {
    status = <span className="status-failed">{items.length === 1 ? "This image could not be made." : "None of these images could be made."}</span>;
  }

  // One file downloads as itself; several as a zip of the ones that were made.
  const label = items.length === 1 ? `Download ${LABEL[outType(items[0].kind, settings, canSaveAvif)]}`
    : settled && done.length === 1 ? `Download ${LABEL[current(done[0])!.type]}`
    : settled && failed.length && done.length ? `Download ${plural(done.length, "image")} (.zip)`
    : "Download all (.zip)";
  const downloadButton = (
    <button className="btn btn-lg btn-block" type="button" onClick={downloadAll}
      aria-busy={!settled || zipping} disabled={settled && !done.length}>
      <span>{label}</span>
    </button>
  );
  const shown = items.find((i) => i.id === preview);

  return (
    <div className="editor">
      {fileInput}
      <div className="editor-grid">
        <div className="editor-main">
          <div className="editor-head">
            <div className="editor-title-wrap">
              <p className="eyebrow">On this device only. Nothing is uploaded.</p>
              <h2 className="editor-title">{items.length === 1 ? items[0].name : items.length ? plural(items.length, "image") : "Opening your images"}</h2>
              <p className="editor-status" aria-live="polite">{status}</p>
            </div>
            <div className="row editor-actions">
              <button className="btn btn-line btn-sm" type="button" onClick={pick}><Icon name="plus" /> Add images</button>
              <button className="btn btn-quiet btn-sm" type="button" onClick={startOver}>Start over</button>
            </div>
          </div>

          {problemList}

          {single ? (
            items[0] ? (
              <Single item={items[0]} result={current(items[0])} failure={failure(items[0])} work={work(items[0])}
                strip={s.strip} onRetry={() => retry(items[0])} />
            ) : (
              <div className="single" aria-busy="true">
                <div className="stage skeleton" style={{ aspectRatio: "4 / 3", width: `min(100%, calc(${INLINE_MAX} * 4 / 3))` }} />
                <span className="skeleton skeleton-text" style={{ width: 220 }} />
              </div>
            )
          ) : (
            <ul className="items">
              {items.map((i) => (
                <Row key={i.id} item={i} result={current(i)} failure={failure(i)} work={work(i)} strip={s.strip}
                  leaving={leaving.includes(i.id)}
                  onPreview={() => setPreview(i.id)} onDownload={() => download(i)} onRetry={() => retry(i)}
                  onRemove={() => setLeaving((l) => [...l, i.id])} onGone={() => gone(i.id)} />
              ))}
              {opening && Array.from({ length: Math.min(opening.left, 3) }, (_, k) => (
                <li className="item item-skeleton" key={`s${k}`} aria-hidden="true">
                  <span className="item-thumb skeleton" />
                  <span className="item-body"><span className="skeleton skeleton-text" /><span className="skeleton skeleton-text" /></span>
                </li>
              ))}
            </ul>
          )}
        </div>

        <aside className="editor-side card" aria-label="What to do with the images">
          <div className="side-body">
            <div className="field">
              <label className="check check-strong">
                <input type="checkbox" checked={settings.cutout} onChange={(e) => toggleCutout(e.target.checked)} />
                <span>Remove background</span>
              </label>
              {settings.cutout && (
                <div className="segmented segmented-4 enter" role="radiogroup" aria-label="New background">
                  {BACKGROUNDS.map(([label, value]) => (
                    <button key={label} type="button" role="radio"
                      aria-checked={value === "color" ? isColor : settings.background === value}
                      onClick={() => change({ background: value === "color" ? color : value })}>
                      {label}
                    </button>
                  ))}
                </div>
              )}
              {settings.cutout && isColor && (
                <label className="color-pick">
                  <input type="color" value={color} aria-label="Background color" onChange={(e) => { setColor(e.target.value); change({ background: e.target.value }); }} />
                  <span>{color}</span>
                </label>
              )}
              <ModelNote on={settings.cutout} model={model} />
            </div>

            <label className="field">
              <span>Format</span>
              <select className="select" value={settings.format} onChange={(e) => change({ format: e.target.value as Settings["format"] })}>
                <option value="same">Keep each image's format</option>
                <option value="jpeg">JPG</option>
                <option value="png">PNG</option>
                <option value="webp">WebP</option>
                {canSaveAvif && <option value="avif">AVIF</option>}
              </select>
              {settings.format === "jpeg" && settings.cutout && !settings.background && <small>JPG cannot be see-through, so cut-outs go on white.</small>}
            </label>

            {anyLossy && (
              <div className="field">
                <span className="spread"><span>Quality</span><span className="value">{settings.quality}</span></span>
                <input className="range" type="range" min={1} max={100} value={settings.quality} aria-label="Quality"
                  onChange={(e) => change({ quality: Number(e.target.value) })} />
                <small>Lower is smaller. 75 looks the same to most eyes.</small>
              </div>
            )}
            {anyPng && (
              <div className="field">
                <span>PNG</span>
                <div className="segmented" role="radiogroup" aria-label="PNG colors">
                  <button type="button" role="radio" aria-checked={settings.pngColors === "fewer"} onClick={() => change({ pngColors: "fewer" })}>256 colors</button>
                  <button type="button" role="radio" aria-checked={settings.pngColors === "all"} onClick={() => change({ pngColors: "all" })}>Lossless</button>
                </div>
                <small>{settings.pngColors === "fewer" ? "Like TinyPNG. Usually 50 to 80% smaller." : "Every pixel stays exactly the same."}</small>
              </div>
            )}

            <div className="field">
              <span>Size</span>
              <div className="segmented segmented-4" role="radiogroup" aria-label="Size">
                {(["none", "width", "height", "percent"] as Fit[]).map((f) => (
                  <button key={f} type="button" role="radio" aria-checked={settings.fit === f}
                    onClick={() => {
                      if (f === "none") return change({ fit: f });
                      const next = f === "percent" ? 50 : settings.fit === "width" || settings.fit === "height" ? settings.size : 1600;
                      setSizeText(String(next));
                      change({ fit: f, size: next });
                    }}>
                    {{ none: "Original", width: "Width", height: "Height", percent: "Percent" }[f]}
                  </button>
                ))}
              </div>
              {settings.fit !== "none" && (
                <>
                  <div className="with-unit">
                    <input className="input" inputMode="numeric" value={sizeText} aria-invalid={!!sizeError}
                      aria-label={settings.fit === "percent" ? "Percent" : `${settings.fit === "width" ? "Width" : "Height"} in pixels`}
                      onChange={(e) => { setSizeText(e.target.value); change({ size: Number(e.target.value.trim() || NaN) }); }} />
                    <span>{settings.fit === "percent" ? "%" : "px"}</span>
                  </div>
                  {sizeError
                    ? <small className="field-error">{sizeError}</small>
                    : <small>{settings.fit === "percent" ? "Both sides change by this much." : `The ${settings.fit === "width" ? "height" : "width"} follows each image's shape.`}</small>}
                </>
              )}
            </div>

            <div className="field">
              <label className="check">
                <input type="checkbox" checked={settings.strip} onChange={(e) => change({ strip: e.target.checked })} />
                <span>Remove photo details</span>
              </label>
              <small>{settings.strip ? "Location, camera and date are taken out." : "Kept only when a JPG is saved as JPG."}</small>
            </div>
          </div>
          <div className="side-foot">{downloadButton}</div>
        </aside>
      </div>

      <div className="action-bar editor-bar">
        {canShareFiles && (
          <button className="btn btn-line btn-lg bar-share" type="button" onClick={shareAll} disabled={!settled || !done.length}>
            <Icon name="share" /> Share
          </button>
        )}
        {downloadButton}
      </div>
      {over && <div className="drop-overlay" aria-hidden="true"><span>Drop to add images</span></div>}
      {shown && <Compare item={shown} result={current(shown) ?? shown.result ?? null} ready={!!current(shown)}
        onDownload={() => download(shown)} onClose={() => setPreview(null)} />}
    </div>
  );
}

type RowProps = {
  item: Item; result: Result | null; failure: string | null; work: string; strip: boolean; leaving: boolean;
  onPreview: () => void; onDownload: () => void; onRetry: () => void; onRemove: () => void; onGone: () => void;
};

function Row({ item, result, failure, work, strip, leaving, onPreview, onDownload, onRetry, onRemove, onGone }: RowProps) {
  // While an image is made again, its last result stays in place, dimmed, so nothing jumps.
  const shown = result ?? item.result;
  return (
    <li className={`item enter${leaving ? " leaving" : ""}`} style={{ "--i": item.order } as CSSProperties}
      onAnimationEnd={(e) => { if (leaving && e.target === e.currentTarget) onGone(); }}>
      <button className="item-thumb press" type="button" onClick={onPreview} aria-label={`Compare ${item.name} before and after`}>
        <img className={`checker${result || !shown ? "" : " is-stale"}`} src={shown?.thumb ?? item.thumb} alt="" />
      </button>
      <div className="item-body">
        <p className="item-name" title={item.name}>{item.name}</p>
        <p className="item-meta">{LABEL[item.kind]} · {item.width} × {item.height} · {size(item.bytes)}{item.kind === "gif" ? " · first frame only" : ""}</p>
        <Outcome item={item} result={result} failure={failure} work={work} onRetry={onRetry} />
        {result?.kept && <p className="kept">Already compact, so the original was kept{strip ? " without its photo details" : ""}.</p>}
      </div>
      <div className="item-tools">
        <button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Download ${item.name}`} disabled={!result} onClick={onDownload}><Icon name="download" /></button>
        <button className="btn btn-quiet btn-icon btn-sm" type="button" aria-label={`Remove ${item.name}`} onClick={onRemove}><Icon name="x" /></button>
      </div>
    </li>
  );
}

/** What an image becomes: the result, the job in progress (over the last result), or why it failed. */
function Outcome({ item, result, failure, work, quiet, onRetry }: { item: Item; result: Result | null; failure: string | null; work: string; quiet?: boolean; onRetry: () => void }) {
  const shown = result ?? item.result;
  if (failure) {
    return (
      <p className="item-result">
        <span className="item-error">{failure}</span>{" "}
        <button className="link-btn" type="button" onClick={onRetry}>Try again</button>
      </p>
    );
  }
  // Quiet: the image above already says what is happening, so only the space is kept.
  if (!shown) return quiet ? <p className="item-result" aria-hidden="true">&nbsp;</p> : <p className="item-result working"><span className="spinner" aria-hidden="true" /> {work}</p>;
  return (
    <p className="item-result">
      <span className={result ? undefined : "is-stale"}>to {LABEL[shown.type]} · {shown.width} × {shown.height} · <strong>{size(shown.blob.size)}</strong></span>{" "}
      {result ? <Saving from={item.bytes} to={result.blob.size} /> : !quiet && <span className="saving"><span className="spinner" aria-hidden="true" />{work}</span>}
    </p>
  );
}

function Single({ item, result, failure, work, strip, onRetry }: { item: Item; result: Result | null; failure: string | null; work: string; strip: boolean; onRetry: () => void }) {
  return (
    <section className="single" aria-label={`${item.name}, before and after`}>
      <Viewer item={item} result={result ?? item.result ?? null} note={result || failure ? null : work} maxHeight={INLINE_MAX} />
      <div className="single-meta">
        <p className="item-meta">{LABEL[item.kind]} · {item.width} × {item.height} · {size(item.bytes)}{item.kind === "gif" ? " · first frame only" : ""}</p>
        <Outcome item={item} result={result} failure={failure} work={work} quiet onRetry={onRetry} />
        {result?.kept && <p className="kept">Already compact, so the original was kept{strip ? " without its photo details" : ""}.</p>}
      </div>
    </section>
  );
}

/** The image at full size; drag across it (or use the arrow keys) to compare the original with the new version. */
function Viewer({ item, result, note, maxHeight }: { item: Item; result: Result | null; note: string | null; maxHeight: string }) {
  const [split, setSplit] = useState(50);
  const stage = useRef<HTMLDivElement>(null);
  const before = useMemo(() => URL.createObjectURL(item.src), [item.src]);
  const after = useMemo(() => (result ? URL.createObjectURL(result.blob) : null), [result]);
  useEffect(() => () => URL.revokeObjectURL(before), [before]);
  useEffect(() => () => { if (after) URL.revokeObjectURL(after); }, [after]);
  const move = (x: number) => {
    const r = stage.current!.getBoundingClientRect();
    setSplit(Math.round(Math.min(100, Math.max(0, ((x - r.left) / r.width) * 100))));
  };
  const ratio = item.width / item.height;
  return (
    <div ref={stage} className={`stage checker${after ? " can-compare" : ""}`}
      style={{ aspectRatio: `${item.width} / ${item.height}`, width: `min(100%, calc(${maxHeight} * ${ratio}))` }}
      {...(after ? {
        role: "slider", tabIndex: 0, "aria-label": "Compare the original with the new version",
        "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": split, "aria-valuetext": `${split}% original`,
      } : {})}
      onPointerDown={(e) => { if (!after) return; e.currentTarget.setPointerCapture(e.pointerId); move(e.clientX); }}
      onPointerMove={(e) => { if (e.currentTarget.hasPointerCapture(e.pointerId)) move(e.clientX); }}
      onKeyDown={(e) => {
        const step = ({ ArrowLeft: -5, ArrowRight: 5, Home: -100, End: 100 } as Record<string, number>)[e.key];
        if (!after || step === undefined) return;
        e.preventDefault();
        setSplit((v) => Math.min(100, Math.max(0, v + step)));
      }}>
      <img src={after ?? before} alt="" draggable={false} />
      {after && <img className="stage-before" src={before} alt="" draggable={false} style={{ clipPath: `inset(0 ${100 - split}% 0 0)` }} />}
      {after && <span className="stage-line" style={{ left: `${split}%` }} aria-hidden="true"><span className="stage-knob"><Icon name="drag" /></span></span>}
      {after && split > 14 && <span className="stage-tag stage-tag-before" aria-hidden="true">Original</span>}
      {after && split < 86 && <span className="stage-tag stage-tag-after" aria-hidden="true">New</span>}
      {note && <span className="stage-note"><span className="spinner" aria-hidden="true" />{note}</span>}
    </div>
  );
}

function ModelNote({ on, model }: { on: boolean; model: { loaded: number; total: number } | null }) {
  if (on && model && model.loaded < model.total) {
    return (
      <div className="model" role="status">
        <small>Getting the background model: {MB(model.loaded)} of {MB(model.total)} MB. Only the first time.</small>
        <span className="bar"><span style={{ transform: `scaleX(${model.loaded / model.total})` }} /></span>
      </div>
    );
  }
  if (model) return null;
  return <small>Runs on this device. The first time, your browser downloads a {MB(MODEL_BYTES)} MB model and keeps it.</small>;
}

function Compare({ item, result, ready, onDownload, onClose }: { item: Item; result: Result | null; ready: boolean; onDownload: () => void; onClose: () => void }) {
  const [closing, setClosing] = useState(false);
  // Read while rendering, before the close button takes focus, so focus goes back to what opened it.
  const [opener] = useState(() => document.activeElement as HTMLElement | null);
  useEffect(() => {
    const key = (e: KeyboardEvent) => { if (e.key === "Escape") setClosing(true); };
    window.addEventListener("keydown", key);
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", key);
      document.body.style.overflow = "";
      opener?.focus();
    };
  }, [opener]);
  return (
    <div className={`dialog-backdrop${closing ? " leaving" : ""}`} onClick={() => setClosing(true)}
      onAnimationEnd={(e) => { if (closing && e.target === e.currentTarget) onClose(); }}>
      <div className="dialog compare-dialog" role="dialog" aria-modal="true" aria-labelledby="compare-title" onClick={(e) => e.stopPropagation()}>
        <div className="dialog-head">
          <h2 className="dialog-title" id="compare-title">{item.name}</h2>
          <button className="btn btn-quiet btn-icon" type="button" aria-label="Close" onClick={() => setClosing(true)} autoFocus><Icon name="x" /></button>
        </div>
        <div className="dialog-body">
          <Viewer item={item} result={result} note={ready ? null : "Being made"} maxHeight={DIALOG_MAX} />
        </div>
        <div className="dialog-foot">
          <p className="compare-sizes">{result ? <>{size(item.bytes)} to {size(result.blob.size)} <Saving from={item.bytes} to={result.blob.size} /></> : size(item.bytes)}</p>
          <button className="btn" type="button" disabled={!ready} onClick={onDownload}>
            <Icon name="download" /> Download{result ? ` ${LABEL[result.type]}` : ""}
          </button>
        </div>
      </div>
    </div>
  );
}

const PATHS: Record<string, ReactNode> = {
  x: <path d="M5 5l10 10M15 5 5 15" />,
  plus: <path d="M10 4v12M4 10h12" />,
  download: <><path d="M10 3v10" /><path d="m6 9 4 4 4-4" /><path d="M4 16h12" /></>,
  share: <><path d="M10 13V3" /><path d="m6.5 6.5 3.5-3.5 3.5 3.5" /><path d="M5 10v6h10v-6" /></>,
  drag: <><path d="m7 6-4 4 4 4" /><path d="m13 6 4 4-4 4" /></>,
};

function Icon({ name }: { name: keyof typeof PATHS }) {
  return (
    <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      {PATHS[name]}
    </svg>
  );
}

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