WFDASHDRIVER-APP/lib/image.ts

123 lines
4.9 KiB
TypeScript
Raw Normal View History

/**
* Client-side photo downscaling for pickup uploads.
*
* Why this exists: the driver app used to PUT the raw camera file
* straight at PocketBase. PocketBase's request logs show what that
* cost us — on 2026-10-12 every single attempt came back `400
* unexpected EOF` (86 of 86, median 7.6s, max 15.6s). That error is
* Go's multipart parser hitting the end of the request body before
* the declared Content-Length arrived: a modern Android camera
* produces a 3–8 MB file, and on mobile data the transfer is getting
* cut off partway through. Not a PocketBase mime or schema problem —
* `order_item_attachements` has `mimeTypes=null`, so nothing was
* rejecting the format.
*
* The office side never hit this because `attachment-compressor.ts`
* in WFDASH re-encodes to 1200px / quality 70 before storing. These
* constants deliberately mirror `JPG_MAX_WIDTH` and `JPG_QUALITY`
* there so both sides produce comparable images. A 1200px q70 JPEG
* lands around 150–300 KB — a sub-second transfer that finishes.
*/
/** Longest edge, in pixels, of the uploaded image. Mirrors WFDASH's JPG_MAX_WIDTH. */
export const MAX_PHOTO_EDGE = 1200;
/** JPEG encoder quality, 0–1. Mirrors WFDASH's JPG_QUALITY (70). */
export const PHOTO_QUALITY = 0.7;
/**
* Below this, the file is already small enough that re-encoding costs
* the driver time (and a visible spinner) for no meaningful saving.
* 150 KB is comfortably inside a reliable mobile upload.
*/
export const SKIP_BELOW_BYTES = 150 * 1024;
/**
* Formats a canvas can decode and re-encode for us. HEIC/HEIF is
* excluded deliberately: iPhone-origin files land here via `accept`
* when a driver picks from the library rather than the camera, and
* Chrome cannot decode them into a canvas. They fall through to
* `compressPhoto` returning the original untouched.
*/
const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('decode failed'));
img.src = url;
});
}
/**
* Downscale + re-encode `file` for upload.
*
* Never throws and never returns null: on any failure it hands back the
* original file. Compression here is an optimisation, not a
* correctness requirement — turning a photo that might have uploaded
* fine into an error would be strictly worse than trying and failing
* the way we do today. Callers can treat the return value as
* "the best file we can produce", so the happy path stays simple.
*/
export async function compressPhoto(file: File): Promise<File> {
if (file.size <= SKIP_BELOW_BYTES) return file;
if (!REENCODABLE_TYPES.has(file.type)) return file;
// Not available during SSR. Only ever called from a click handler in
// the browser, but a guard is cheaper than a confusing crash.
if (typeof document === 'undefined') return file;
let objectUrl: string | null = null;
try {
objectUrl = URL.createObjectURL(file);
const img = await loadImage(objectUrl);
// Guard against absurd intrinsic sizes before allocating a canvas;
// some devices report bogus dimensions for corrupt files and a
// canvas at 99999px would take the tab down with it.
if (!img.naturalWidth || !img.naturalHeight) return file;
if (img.naturalWidth > 20000 || img.naturalHeight > 20000) return file;
const longest = Math.max(img.naturalWidth, img.naturalHeight);
// Only shrink. Upscaling a small photo wastes bytes and detail.
if (longest <= MAX_PHOTO_EDGE) return file;
const scale = MAX_PHOTO_EDGE / longest;
const width = Math.max(1, Math.round(img.naturalWidth * scale));
const height = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
// JPEG has no alpha channel; without this a transparent PNG comes
// out with black backgrounds where the alpha was.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', PHOTO_QUALITY),
);
if (!blob) return file;
// If re-encoding somehow produced something bigger, keep the
// original rather than making the upload worse.
if (blob.size >= file.size) return file;
const stem = file.name.replace(/\.[^.]+$/, '') || 'pickup';
return new File([blob], `${stem}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
} catch {
// Decode failure, canvas unavailable, out of memory — all handled
// the same way: try anyway with what we were given.
return file;
} finally {
if (objectUrl) URL.revokeObjectURL(objectUrl);
}
}