Skip to content
File

Blob: src/workers/export.worker.ts

typescript215 lines
1import type { ExportFormat, ExportRequest, ExportResult, PixelRect, WorkerInbound, WorkerOutbound } from "../types";
2import { buildFilter } from "../lib/filter";
3import { loadVips } from "../lib/vips";
4 
5const MIME: Record<ExportFormat, string> = {
6 jpeg: "image/jpeg",
7 webp: "image/webp",
8 png: "image/png",
9 avif: "image/avif",
10};
11 
12/** Bounding box of an image after rotating it by `deg` degrees. */
13function rotateSize(width: number, height: number, deg: number): { width: number; height: number } {
14 const rad = (deg * Math.PI) / 180;
15 const sin = Math.abs(Math.sin(rad));
16 const cos = Math.abs(Math.cos(rad));
17 return {
18 width: cos * width + sin * height,
19 height: sin * width + cos * height,
20 };
21}
22 
23/**
24 * Encode pixels to the requested format. Prefers the jSquash WASM codecs for
25 * real quality control, and falls back to the browser's native canvas encoder
26 * if a codec fails to load.
27 */
28async function encode(
29 data: ImageData,
30 canvas: OffscreenCanvas,
31 format: ExportFormat,
32 quality: number,
33): Promise<{ blob: Blob; encoder: "jsquash" | "canvas" }> {
34 try {
35 switch (format) {
36 case "jpeg": {
37 const m: any = await import("@jsquash/jpeg");
38 const buf = await m.encode(data, { quality });
39 return { blob: new Blob([buf], { type: MIME.jpeg }), encoder: "jsquash" };
40 }
41 case "webp": {
42 const m: any = await import("@jsquash/webp");
43 const buf = await m.encode(data, { quality });
44 return { blob: new Blob([buf], { type: MIME.webp }), encoder: "jsquash" };
45 }
46 case "avif": {
47 const m: any = await import("@jsquash/avif");
48 const buf = await m.encode(data, { quality });
49 return { blob: new Blob([buf], { type: MIME.avif }), encoder: "jsquash" };
50 }
51 case "png": {
52 const m: any = await import("@jsquash/png");
53 const buf = await m.encode(data);
54 return { blob: new Blob([buf], { type: MIME.png }), encoder: "jsquash" };
55 }
56 }
57 } catch {
58 const blob = await canvas.convertToBlob({ type: MIME[format], quality: quality / 100 });
59 return { blob, encoder: "canvas" };
60 }
61}
62 
63/**
64 * Resample the crop region to the target size with libvips (lanczos3). Color
65 * adjustments are baked in first. Returns RGBA ImageData at the target size, or
66 * null if anything goes wrong (caller falls back to canvas). vips objects live
67 * in WASM memory and are freed explicitly.
68 */
69async function resampleWithVips(
70 vips: any,
71 full: OffscreenCanvas,
72 crop: PixelRect,
73 outW: number,
74 outH: number,
75 filter: string,
76): Promise<ImageData | null> {
77 const cw = Math.max(1, Math.round(crop.width));
78 const ch = Math.max(1, Math.round(crop.height));
79 let source: any;
80 let resized: any;
81 try {
82 const cropCanvas = new OffscreenCanvas(cw, ch);
83 const cctx = cropCanvas.getContext("2d");
84 if (!cctx) return null;
85 if (filter) cctx.filter = filter;
86 cctx.drawImage(full, crop.x, crop.y, crop.width, crop.height, 0, 0, cw, ch);
87 cctx.filter = "none";
88 const data = cctx.getImageData(0, 0, cw, ch);
89 
90 source = vips.Image.newFromMemory(
91 new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.byteLength),
92 cw,
93 ch,
94 4,
95 "uchar",
96 );
97 resized = source.resize(outW / cw, { vscale: outH / ch, kernel: "lanczos3" });
98 const rw: number = resized.width;
99 const rh: number = resized.height;
100 const bytes: Uint8Array = resized.writeToMemory();
101 if (rw * rh * 4 !== bytes.length) return null;
102 // Copy out of WASM-backed memory before the vips objects are freed.
103 return new ImageData(new Uint8ClampedArray(bytes), rw, rh);
104 } catch (err) {
105 console.warn("[wimp] vips resample failed; using canvas.", err);
106 return null;
107 } finally {
108 source?.delete?.();
109 resized?.delete?.();
110 }
111}
112 
113async function runExport(req: ExportRequest): Promise<ExportResult> {
114 const srcBlob = new Blob([req.bytes], { type: req.mime || "application/octet-stream" });
115 // Respect EXIF orientation so the export matches the cropper's <img>, which
116 // applies it automatically (phone photos are commonly stored rotated).
117 const bitmap = await createImageBitmap(srcBlob, { imageOrientation: "from-image" });
118 
119 // Stage 1 โ€” paint the rotated + flipped full image into its bounding box.
120 // This mirrors react-easy-crop's coordinate space so the crop rect lines up.
121 const bbox = rotateSize(bitmap.width, bitmap.height, req.rotation);
122 const bw = Math.round(bbox.width);
123 const bh = Math.round(bbox.height);
124 const full = new OffscreenCanvas(bw, bh);
125 const fctx = full.getContext("2d");
126 if (!fctx) throw new Error("Could not acquire a 2D drawing context.");
127 fctx.translate(bw / 2, bh / 2);
128 fctx.rotate((req.rotation * Math.PI) / 180);
129 fctx.scale(req.flipH ? -1 : 1, req.flipV ? -1 : 1);
130 fctx.translate(-bitmap.width / 2, -bitmap.height / 2);
131 fctx.drawImage(bitmap, 0, 0);
132 bitmap.close();
133 
134 // Stage 2 โ€” crop the chosen region and resample to the target size.
135 const crop = req.crop;
136 let outW = Math.max(1, Math.round(req.output?.width ?? crop.width));
137 let outH = Math.max(1, Math.round(req.output?.height ?? crop.height));
138 // Optional cap on the longest edge, scaled proportionally.
139 if (req.maxEdge && Math.max(outW, outH) > req.maxEdge) {
140 const scale = req.maxEdge / Math.max(outW, outH);
141 outW = Math.max(1, Math.round(outW * scale));
142 outH = Math.max(1, Math.round(outH * scale));
143 }
144 const hasAlpha = req.format !== "jpeg";
145 const filter = buildFilter(req.adjustments);
146 
147 // Prefer libvips for meaningful downscales; otherwise the canvas resampler.
148 const downscaling = outW < Math.round(crop.width) * 0.999;
149 const vips = downscaling ? await loadVips() : null;
150 const vipsData = vips ? await resampleWithVips(vips, full, crop, outW, outH, filter) : null;
151 
152 const out = new OffscreenCanvas(outW, outH);
153 const octx = out.getContext("2d", { alpha: hasAlpha });
154 if (!octx) throw new Error("Could not acquire a 2D drawing context.");
155 
156 let engine: "vips" | "canvas";
157 let imageData: ImageData;
158 
159 if (vipsData) {
160 engine = "vips";
161 const tmp = new OffscreenCanvas(vipsData.width, vipsData.height);
162 const tctx = tmp.getContext("2d");
163 if (!tctx) throw new Error("Could not acquire a 2D drawing context.");
164 tctx.putImageData(vipsData, 0, 0);
165 if (!hasAlpha) {
166 // JPEG has no alpha; flatten onto white so transparent areas aren't black.
167 octx.fillStyle = "#ffffff";
168 octx.fillRect(0, 0, outW, outH);
169 }
170 octx.drawImage(tmp, 0, 0, outW, outH);
171 imageData = octx.getImageData(0, 0, outW, outH);
172 } else {
173 engine = "canvas";
174 octx.imageSmoothingEnabled = true;
175 octx.imageSmoothingQuality = "high";
176 if (!hasAlpha) {
177 octx.fillStyle = "#ffffff";
178 octx.fillRect(0, 0, outW, outH);
179 }
180 if (filter) octx.filter = filter;
181 octx.drawImage(full, crop.x, crop.y, crop.width, crop.height, 0, 0, outW, outH);
182 octx.filter = "none";
183 imageData = octx.getImageData(0, 0, outW, outH);
184 }
185 
186 // Stage 3 โ€” encode to the requested format.
187 const { blob, encoder } = await encode(imageData, out, req.format, req.quality);
188 
189 return {
190 ok: true,
191 blob,
192 width: outW,
193 height: outH,
194 bytes: blob.size,
195 format: req.format,
196 engine,
197 encoder,
198 };
199}
200 
201self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
202 const { id, request } = e.data;
203 try {
204 const result = await runExport(request);
205 const message: WorkerOutbound = { id, response: result };
206 postMessage(message);
207 } catch (err) {
208 const message: WorkerOutbound = {
209 id,
210 response: { ok: false, error: err instanceof Error ? err.message : String(err) },
211 };
212 postMessage(message);
213 }
214};