File
Blob: src/workers/export.worker.ts
| 1 | import type { ExportFormat, ExportRequest, ExportResult, PixelRect, WorkerInbound, WorkerOutbound } from "../types"; |
| 2 | import { buildFilter } from "../lib/filter"; |
| 3 | import { loadVips } from "../lib/vips"; |
| 4 | |
| 5 | const 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. */ |
| 13 | function 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 | */ |
| 28 | async 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 | */ |
| 69 | async 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 | |
| 113 | async 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 | |
| 201 | self.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 | }; |