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.
This commit is contained in:
parent
68d1a6ae6d
commit
efba1bbf2f
3 changed files with 145 additions and 5 deletions
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
123
lib/image.ts
Normal file
123
lib/image.ts
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<OrderItemAttachment> => {
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue