import type { ExportFormat, ExportRequest, ExportResult, PixelRect, WorkerInbound, WorkerOutbound } from "../types"; import { buildFilter } from "../lib/filter"; import { loadVips } from "../lib/vips"; const MIME: Record = { jpeg: "image/jpeg", webp: "image/webp", png: "image/png", avif: "image/avif", }; /** Bounding box of an image after rotating it by `deg` degrees. */ function rotateSize(width: number, height: number, deg: number): { width: number; height: number } { const rad = (deg * Math.PI) / 180; const sin = Math.abs(Math.sin(rad)); const cos = Math.abs(Math.cos(rad)); return { width: cos * width + sin * height, height: sin * width + cos * height, }; } /** * Encode pixels to the requested format. Prefers the jSquash WASM codecs for * real quality control, and falls back to the browser's native canvas encoder * if a codec fails to load. */ async function encode( data: ImageData, canvas: OffscreenCanvas, format: ExportFormat, quality: number, ): Promise<{ blob: Blob; encoder: "jsquash" | "canvas" }> { try { switch (format) { case "jpeg": { const m: any = await import("@jsquash/jpeg"); const buf = await m.encode(data, { quality }); return { blob: new Blob([buf], { type: MIME.jpeg }), encoder: "jsquash" }; } case "webp": { const m: any = await import("@jsquash/webp"); const buf = await m.encode(data, { quality }); return { blob: new Blob([buf], { type: MIME.webp }), encoder: "jsquash" }; } case "avif": { const m: any = await import("@jsquash/avif"); const buf = await m.encode(data, { quality }); return { blob: new Blob([buf], { type: MIME.avif }), encoder: "jsquash" }; } case "png": { const m: any = await import("@jsquash/png"); const buf = await m.encode(data); return { blob: new Blob([buf], { type: MIME.png }), encoder: "jsquash" }; } } } catch { const blob = await canvas.convertToBlob({ type: MIME[format], quality: quality / 100 }); return { blob, encoder: "canvas" }; } } /** * Resample the crop region to the target size with libvips (lanczos3). Color * adjustments are baked in first. Returns RGBA ImageData at the target size, or * null if anything goes wrong (caller falls back to canvas). vips objects live * in WASM memory and are freed explicitly. */ async function resampleWithVips( vips: any, full: OffscreenCanvas, crop: PixelRect, outW: number, outH: number, filter: string, ): Promise { const cw = Math.max(1, Math.round(crop.width)); const ch = Math.max(1, Math.round(crop.height)); let source: any; let resized: any; try { const cropCanvas = new OffscreenCanvas(cw, ch); const cctx = cropCanvas.getContext("2d"); if (!cctx) return null; if (filter) cctx.filter = filter; cctx.drawImage(full, crop.x, crop.y, crop.width, crop.height, 0, 0, cw, ch); cctx.filter = "none"; const data = cctx.getImageData(0, 0, cw, ch); source = vips.Image.newFromMemory( new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.byteLength), cw, ch, 4, "uchar", ); resized = source.resize(outW / cw, { vscale: outH / ch, kernel: "lanczos3" }); const rw: number = resized.width; const rh: number = resized.height; const bytes: Uint8Array = resized.writeToMemory(); if (rw * rh * 4 !== bytes.length) return null; // Copy out of WASM-backed memory before the vips objects are freed. return new ImageData(new Uint8ClampedArray(bytes), rw, rh); } catch (err) { console.warn("[wimp] vips resample failed; using canvas.", err); return null; } finally { source?.delete?.(); resized?.delete?.(); } } async function runExport(req: ExportRequest): Promise { const srcBlob = new Blob([req.bytes], { type: req.mime || "application/octet-stream" }); // Respect EXIF orientation so the export matches the cropper's , which // applies it automatically (phone photos are commonly stored rotated). const bitmap = await createImageBitmap(srcBlob, { imageOrientation: "from-image" }); // Stage 1 — paint the rotated + flipped full image into its bounding box. // This mirrors react-easy-crop's coordinate space so the crop rect lines up. const bbox = rotateSize(bitmap.width, bitmap.height, req.rotation); const bw = Math.round(bbox.width); const bh = Math.round(bbox.height); const full = new OffscreenCanvas(bw, bh); const fctx = full.getContext("2d"); if (!fctx) throw new Error("Could not acquire a 2D drawing context."); fctx.translate(bw / 2, bh / 2); fctx.rotate((req.rotation * Math.PI) / 180); fctx.scale(req.flipH ? -1 : 1, req.flipV ? -1 : 1); fctx.translate(-bitmap.width / 2, -bitmap.height / 2); fctx.drawImage(bitmap, 0, 0); bitmap.close(); // Stage 2 — crop the chosen region and resample to the target size. const crop = req.crop; let outW = Math.max(1, Math.round(req.output?.width ?? crop.width)); let outH = Math.max(1, Math.round(req.output?.height ?? crop.height)); // Optional cap on the longest edge, scaled proportionally. if (req.maxEdge && Math.max(outW, outH) > req.maxEdge) { const scale = req.maxEdge / Math.max(outW, outH); outW = Math.max(1, Math.round(outW * scale)); outH = Math.max(1, Math.round(outH * scale)); } const hasAlpha = req.format !== "jpeg"; const filter = buildFilter(req.adjustments); // Prefer libvips for meaningful downscales; otherwise the canvas resampler. const downscaling = outW < Math.round(crop.width) * 0.999; const vips = downscaling ? await loadVips() : null; const vipsData = vips ? await resampleWithVips(vips, full, crop, outW, outH, filter) : null; const out = new OffscreenCanvas(outW, outH); const octx = out.getContext("2d", { alpha: hasAlpha }); if (!octx) throw new Error("Could not acquire a 2D drawing context."); let engine: "vips" | "canvas"; let imageData: ImageData; if (vipsData) { engine = "vips"; const tmp = new OffscreenCanvas(vipsData.width, vipsData.height); const tctx = tmp.getContext("2d"); if (!tctx) throw new Error("Could not acquire a 2D drawing context."); tctx.putImageData(vipsData, 0, 0); if (!hasAlpha) { // JPEG has no alpha; flatten onto white so transparent areas aren't black. octx.fillStyle = "#ffffff"; octx.fillRect(0, 0, outW, outH); } octx.drawImage(tmp, 0, 0, outW, outH); imageData = octx.getImageData(0, 0, outW, outH); } else { engine = "canvas"; octx.imageSmoothingEnabled = true; octx.imageSmoothingQuality = "high"; if (!hasAlpha) { octx.fillStyle = "#ffffff"; octx.fillRect(0, 0, outW, outH); } if (filter) octx.filter = filter; octx.drawImage(full, crop.x, crop.y, crop.width, crop.height, 0, 0, outW, outH); octx.filter = "none"; imageData = octx.getImageData(0, 0, outW, outH); } // Stage 3 — encode to the requested format. const { blob, encoder } = await encode(imageData, out, req.format, req.quality); return { ok: true, blob, width: outW, height: outH, bytes: blob.size, format: req.format, engine, encoder, }; } self.onmessage = async (e: MessageEvent) => { const { id, request } = e.data; try { const result = await runExport(request); const message: WorkerOutbound = { id, response: result }; postMessage(message); } catch (err) { const message: WorkerOutbound = { id, response: { ok: false, error: err instanceof Error ? err.message : String(err) }, }; postMessage(message); } };