WFDASHDRIVER-APP/lib/image.ts
Dave efba1bbf2f fix(photos): downscale pickup photos client-side before upload
Every pickup-photo upload on 2026-10-12 failed: 86 of 86 attempts
came back `400 unexpected EOF`, median 7.6s, max 15.6s. The old
comment here blamed a PocketBase `image/*` mime-detection bug, which
was wrong on both counts — `order_item_attachements` has
mimeTypes=null, so nothing was checking the format. Go's multipart
parser was hitting end-of-body before the declared Content-Length
arrived: a 3-8MB Android camera file truncated in transit on mobile
data.

The office side never hit this because WFDASH re-encodes to
1200px/q70 in attachment-compressor.ts before storing. This adds the
same treatment on the driver side so both produce comparable images
(~150-300KB, sub-second transfer).

compressPhoto() never throws and never returns null — on any decode
failure it hands back the original file. Compression here is an
optimisation, not a correctness requirement, so it must never turn a
photo that might have uploaded fine into a hard error.
2026-10-14 16:12:37 +02:00

123 lines
No EOL
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}
}