From efba1bbf2f03fcef3c3921f5037625528b2e56dd Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 14 Oct 2026 16:12:37 +0200 Subject: [PATCH] fix(photos): downscale pickup photos client-side before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/CollectModal.tsx | 10 ++- lib/image.ts | 123 ++++++++++++++++++++++++++++++++++++ lib/pocketbase.ts | 17 ++++- 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 lib/image.ts diff --git a/components/CollectModal.tsx b/components/CollectModal.tsx index 36bb149..b01ebdf 100644 --- a/components/CollectModal.tsx +++ b/components/CollectModal.tsx @@ -13,9 +13,13 @@ interface CollectModalProps { onConfirm: (item: OrderItem, receivedQty: number, notes: string, photo: File | null) => void; } -// Max photo size we'll accept in the UI (10MB on PB side too). -// Reject anything larger with an inline error so the driver sees -// the constraint before PB does — PB's error message is opaque. +// Reject absurd files at the UI so the driver sees the constraint +// immediately rather than after a long stalled upload. This is NOT the +// size that actually goes over the wire — `compressPhoto` (lib/image.ts) +// re-encodes to 1200px/q70 before upload, which lands in the low +// hundreds of KB. Raw camera files were multi-megabyte and every one of +// them was truncated in transit on 2026-10-12; the cap here just stops +// someone picking a 50MB file off a camera roll. const MAX_PHOTO_BYTES = 10 * 1024 * 1024; const ALLOWED_PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; diff --git a/lib/image.ts b/lib/image.ts new file mode 100644 index 0000000..9db7f12 --- /dev/null +++ b/lib/image.ts @@ -0,0 +1,123 @@ +/** + * 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 { + 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 { + 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((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); + } +} \ No newline at end of file diff --git a/lib/pocketbase.ts b/lib/pocketbase.ts index 19e56bd..52db308 100644 --- a/lib/pocketbase.ts +++ b/lib/pocketbase.ts @@ -10,6 +10,7 @@ import { PurchaseOrder, } from './types'; import { clearCache as clearItemsCache } from './itemsCache'; +import { compressPhoto } from './image'; const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io'; @@ -734,12 +735,19 @@ export const bulkProblemStock = async ( // Uses the `fieldName+` append syntax so each upload is preserved // alongside any previous files rather than replacing them. // +// The file is downscaled client-side first — see lib/image.ts. Uploading +// the raw camera file was what produced the run of `400 unexpected EOF` +// failures on 2026-10-12: the multi-megabyte body got truncated in +// transit on mobile data and PocketBase rejected the half-read +// multipart stream. Compressing to 1200px/q70 shrinks the same photo to +// a fraction of the size and the upload completes. +// // The source/notes params are accepted for interface compatibility // but cannot be stored per-file without a separate attachment collection. // Both are safe to drop here since the driver always uses // source=driver_proof and notes go into order_items.notes separately. // -// Throws on PB errors so the caller can roll back the status change. +// Throws on PB errors so the caller can report the failure. export const uploadOrderItemAttachment = async (params: { orderItemId: string; file: File; @@ -748,9 +756,14 @@ export const uploadOrderItemAttachment = async (params: { }): Promise => { const { orderItemId, file } = params; + // Never throws — hands back the original if it can't do better, so + // a decode failure degrades to today's behaviour instead of blocking + // the collection. + const uploadFile = await compressPhoto(file); + const formData = new FormData(); // fieldName+ = append (never overwrite) existing files on the row - formData.append('order_item_attachements+', file); + formData.append('order_item_attachements+', uploadFile); const updated = await pb.collection('order_items').update(orderItemId, formData); // PB returns the updated order_item; extract the last filename as the