~james/core

core/web/src/lib/images.ts -rw-r--r-- 2.7 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
export const MAX_IMAGE_BYTES = 1_000_000;

const KEPT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
const MAX_SIDE = 4096;
const VECTOR_SIDE = 1024;
const QUALITY = 0.85;
const MAX_ATTEMPTS = 8;

export class ImageError extends Error {}

const extensions: Record<string, string> = {
	"image/jpeg": "jpg",
	"image/png": "png",
	"image/webp": "webp"
};

const renamed = (name: string, type: string) =>
	`${name.replace(/\.[^.]*$/, "")}.${extensions[type] ?? "png"}`;

const decode = async (file: File, url: string): Promise<HTMLImageElement> => {
	const image = new Image();
	image.src = url;
	try {
		await image.decode();
	} catch {
		throw new ImageError(`"${file.name}" isn't an image this browser can read.`);
	}
	return image;
};

const render = (
	image: HTMLImageElement,
	width: number,
	height: number,
	type: string
): Promise<Blob> =>
	new Promise((resolve, reject) => {
		const canvas = document.createElement("canvas");
		canvas.width = width;
		canvas.height = height;
		const context = canvas.getContext("2d");
		if (!context) return reject(new ImageError("This browser can't resize images."));
		if (type === "image/jpeg") {
			context.fillStyle = "#fff";
			context.fillRect(0, 0, width, height);
		}
		context.drawImage(image, 0, 0, width, height);
		canvas.toBlob(
			(blob) =>
				blob ? resolve(blob) : reject(new ImageError("The image couldn't be encoded.")),
			type,
			QUALITY
		);
	});

export const prepareImage = async (file: File): Promise<File> => {
	if (KEPT_TYPES.has(file.type) && file.size <= MAX_IMAGE_BYTES) return file;
	if (file.type === "image/gif")
		throw new ImageError(
			`"${file.name}" is too large, and a gif can't be shrunk without losing its animation.`
		);
	const url = URL.createObjectURL(file);
	try {
		const image = await decode(file, url);
		const longest = Math.max(image.naturalWidth, image.naturalHeight) || VECTOR_SIDE;
		const vector = file.type === "image/svg+xml";
		const natural = image.naturalWidth || VECTOR_SIDE;
		const naturalHeight = image.naturalHeight || VECTOR_SIDE;
		let scale = vector ? VECTOR_SIDE / longest : Math.min(1, MAX_SIDE / longest);
		const type = file.type === "image/jpeg" ? "image/jpeg" : "image/webp";
		for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
			const blob = await render(
				image,
				Math.max(1, Math.round(natural * scale)),
				Math.max(1, Math.round(naturalHeight * scale)),
				type
			);
			if (blob.size <= MAX_IMAGE_BYTES)
				return new File([blob], renamed(file.name, blob.type), { type: blob.type });
			scale *= Math.min(0.9, Math.sqrt(MAX_IMAGE_BYTES / blob.size) * 0.95);
		}
		throw new ImageError(`"${file.name}" is too large to shrink under 1 MB.`);
	} finally {
		URL.revokeObjectURL(url);
	}
};