2026-10-14 14:12:37 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*
|
2026-10-17 15:43:35 +00:00
|
|
|
|
* 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.
|
2026-10-14 14:12:37 +00:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-10-17 15:43:35 +00:00
|
|
|
|
/** Longest edge, in pixels, of the uploaded image. */
|
|
|
|
|
|
export const MAX_PHOTO_EDGE = 2000;
|
2026-10-14 14:12:37 +00:00
|
|
|
|
|
2026-10-17 15:43:35 +00:00
|
|
|
|
/** JPEG encoder quality, 0–1. */
|
|
|
|
|
|
export const PHOTO_QUALITY = 0.8;
|
2026-10-14 14:12:37 +00:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-20 05:07:12 +00:00
|
|
|
|
* 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.
|
2026-10-14 14:12:37 +00:00
|
|
|
|
*/
|
2026-10-20 05:07:12 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-14 14:12:37 +00:00
|
|
|
|
|
|
|
|
|
|
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> {
|
2026-10-20 05:07:12 +00:00
|
|
|
|
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;
|
2026-10-14 14:12:37 +00:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-10-20 05:07:12 +00:00
|
|
|
|
// 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;
|
2026-10-14 14:12:37 +00:00
|
|
|
|
|
2026-10-20 05:07:12 +00:00
|
|
|
|
const scale = Math.min(1, MAX_PHOTO_EDGE / longest);
|
2026-10-14 14:12:37 +00:00
|
|
|
|
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
|
2026-10-20 05:07:12 +00:00
|
|
|
|
// 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;
|
2026-10-14 14:12:37 +00:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|