fix: convert iPhone HEIC photos to JPEG before upload

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>
This commit is contained in:
Dave 2026-10-20 07:07:12 +02:00
parent ebe88d40e7
commit 48e081a080

View file

@ -34,13 +34,17 @@ export const PHOTO_QUALITY = 0.8;
export const SKIP_BELOW_BYTES = 150 * 1024; export const SKIP_BELOW_BYTES = 150 * 1024;
/** /**
* Formats a canvas can decode and re-encode for us. HEIC/HEIF is * Formats we try to re-encode. HEIC/HEIF is included (since 2026-10-20):
* excluded deliberately: iPhone-origin files land here via `accept` * iPhones send HEIC, Safari on iOS/macOS can decode it into a canvas, and
* when a driver picks from the library rather than the camera, and * WFDASH's AI and most desktop browsers can't read HEIC at all — the first
* Chrome cannot decode them into a canvas. They fall through to * HEIC invoice photos went unread. Where the browser can't decode HEIC
* `compressPhoto` returning the original untouched. * (Chrome), loadImage fails and the original is uploaded as before.
*/ */
const REENCODABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); 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> { function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -62,8 +66,10 @@ function loadImage(url: string): Promise<HTMLImageElement> {
* "the best file we can produce", so the happy path stays simple. * "the best file we can produce", so the happy path stays simple.
*/ */
export async function compressPhoto(file: File): Promise<File> { export async function compressPhoto(file: File): Promise<File> {
if (file.size <= SKIP_BELOW_BYTES) return file; const heic = isHeic(file);
if (!REENCODABLE_TYPES.has(file.type)) return 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 // Not available during SSR. Only ever called from a click handler in
// the browser, but a guard is cheaper than a confusing crash. // the browser, but a guard is cheaper than a confusing crash.
@ -81,10 +87,11 @@ export async function compressPhoto(file: File): Promise<File> {
if (img.naturalWidth > 20000 || img.naturalHeight > 20000) return file; if (img.naturalWidth > 20000 || img.naturalHeight > 20000) return file;
const longest = Math.max(img.naturalWidth, img.naturalHeight); const longest = Math.max(img.naturalWidth, img.naturalHeight);
// Only shrink. Upscaling a small photo wastes bytes and detail. // Only shrink. Upscaling a small photo wastes bytes and detail. A
if (longest <= MAX_PHOTO_EDGE) return file; // HEIC is still re-encoded (at its own size) to get a JPEG.
if (longest <= MAX_PHOTO_EDGE && !heic) return file;
const scale = MAX_PHOTO_EDGE / longest; const scale = Math.min(1, MAX_PHOTO_EDGE / longest);
const width = Math.max(1, Math.round(img.naturalWidth * scale)); const width = Math.max(1, Math.round(img.naturalWidth * scale));
const height = Math.max(1, Math.round(img.naturalHeight * scale)); const height = Math.max(1, Math.round(img.naturalHeight * scale));
@ -106,8 +113,9 @@ export async function compressPhoto(file: File): Promise<File> {
if (!blob) return file; if (!blob) return file;
// If re-encoding somehow produced something bigger, keep the // If re-encoding somehow produced something bigger, keep the
// original rather than making the upload worse. // original rather than making the upload worse — except for HEIC,
if (blob.size >= file.size) return file; // where a readable JPEG beats a smaller unreadable file.
if (!heic && blob.size >= file.size) return file;
const stem = file.name.replace(/\.[^.]+$/, '') || 'pickup'; const stem = file.name.replace(/\.[^.]+$/, '') || 'pickup';
return new File([blob], `${stem}.jpg`, { return new File([blob], `${stem}.jpg`, {