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/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"> </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
Bounded source sync
Bounded source sync
Bounded source sync