compressPhoto skipped HEIC on purpose (Chrome can't decode it), so an iPhone driver's photos went up as HEIC. WFDASH's AI check can't read HEIC and most desktop browsers can't show it — Thabo's first two invoice photos on 2026-10-20 went unread. Now HEIC/HEIF is always re-encoded to JPEG (downscaled to 2000px) when the browser can decode it (Safari on iOS/macOS can). Where it can't, the original is uploaded as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
No EOL
5.4 KiB
TypeScript
132 lines
No EOL
5.4 KiB
TypeScript
/**
|
||
* 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.
|
||
*
|
||
* Size choice (2026-10-17): these photos are mostly supplier invoices
|
||
* that the WFDASH AI reads on Collection Confirm. At 1200px / q70 a
|
||
* landscape invoice shot sideways left stock codes too blurry to read
|
||
* (the AI turned "051315" into "0515315"). 2000px / q80 keeps small
|
||
* print legible and still lands around 400–600 KB — an order of
|
||
* magnitude below the multi-MB camera files that failed in transit.
|
||
*/
|
||
|
||
/** Longest edge, in pixels, of the uploaded image. */
|
||
export const MAX_PHOTO_EDGE = 2000;
|
||
|
||
/** JPEG encoder quality, 0–1. */
|
||
export const PHOTO_QUALITY = 0.8;
|
||
|
||
/**
|
||
* 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 we try to re-encode. HEIC/HEIF is included (since 2026-10-20):
|
||
* iPhones send HEIC, Safari on iOS/macOS can decode it into a canvas, and
|
||
* WFDASH's AI and most desktop browsers can't read HEIC at all — the first
|
||
* HEIC invoice photos went unread. Where the browser can't decode HEIC
|
||
* (Chrome), loadImage fails and the original is uploaded as before.
|
||
*/
|
||
const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']);
|
||
|
||
function isHeic(file: File): boolean {
|
||
return /^image\/hei[cf]/i.test(file.type) || /\.(heic|heif)$/i.test(file.name);
|
||
}
|
||
|
||
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> {
|
||
const heic = isHeic(file);
|
||
// A HEIC is always converted, however small: the point is the format.
|
||
if (!heic && file.size <= SKIP_BELOW_BYTES) return file;
|
||
if (!heic && !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. A
|
||
// HEIC is still re-encoded (at its own size) to get a JPEG.
|
||
if (longest <= MAX_PHOTO_EDGE && !heic) return file;
|
||
|
||
const scale = Math.min(1, 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 — except for HEIC,
|
||
// where a readable JPEG beats a smaller unreadable file.
|
||
if (!heic && 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);
|
||
}
|
||
} |