diff --git a/src/lib/convert-image-to-webp.ts b/src/lib/convert-image-to-webp.ts index ff1eb3f2..fcf9480e 100644 --- a/src/lib/convert-image-to-webp.ts +++ b/src/lib/convert-image-to-webp.ts @@ -1,42 +1,94 @@ +import { convertHeicBlobToJpeg } from "./convert-heic"; + +function isHeicFile(file: File): boolean { + const name = file.name.toLowerCase(); + const type = (file.type ?? "").toLowerCase(); + return ( + name.endsWith(".heic") || + name.endsWith(".heif") || + type === "image/heic" || + type === "image/heif" + ); +} + +function makeOutputFile(blob: Blob, baseName: string, ext: string): File { + return new File([blob], `${baseName}.${ext}`, { + type: blob.type, + lastModified: Date.now(), + }); +} + /** - * Converts an image File to WebP and returns a new File. - * Used so receipt uploads are stored only as WebP. + * Converts an image File to WebP (or JPEG on Safari where WebP isn't supported). + * HEIC from iPhone is handled: if the image fails to load, we decode with heic2any first. */ export function convertImageToWebP(file: File, quality = 0.85): Promise { return new Promise((resolve, reject) => { - const img = new Image(); + const baseName = file.name.replace(/\.[^.]+$/i, "") || "image"; + + const tryCanvas = (sourceBlob: Blob) => { + const url = URL.createObjectURL(sourceBlob); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Could not get canvas context")); + return; + } + ctx.drawImage(img, 0, 0); + // Safari doesn't support WebP in toBlob; try WebP first, fall back to JPEG + canvas.toBlob( + (blob) => { + if (blob && blob.type === "image/webp") { + resolve(makeOutputFile(blob, baseName, "webp")); + return; + } + canvas.toBlob( + (jpegBlob) => { + if (!jpegBlob) { + reject(new Error("Failed to encode image")); + return; + } + resolve(makeOutputFile(jpegBlob, baseName, "jpg")); + }, + "image/jpeg", + quality + ); + }, + "image/webp", + quality + ); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load image")); + }; + img.src = url; + }; + const url = URL.createObjectURL(file); + const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext("2d"); - if (!ctx) { - reject(new Error("Could not get canvas context")); - return; - } - ctx.drawImage(img, 0, 0); - canvas.toBlob( - (blob) => { - if (!blob) { - reject(new Error("Failed to encode WebP")); - return; - } - const baseName = file.name.replace(/\.[^.]+$/i, "") || "image"; - const webpFile = new File([blob], `${baseName}.webp`, { - type: "image/webp", - lastModified: Date.now(), - }); - resolve(webpFile); - }, - "image/webp", - quality - ); + tryCanvas(file); }; - img.onerror = () => { + img.onerror = async () => { URL.revokeObjectURL(url); - reject(new Error("Failed to load image")); + // HEIC often fails to load in Image() on iOS; decode with heic2any then run canvas + if (isHeicFile(file)) { + try { + const jpegBlob = await convertHeicBlobToJpeg(file); + tryCanvas(jpegBlob); + } catch (e) { + reject(e instanceof Error ? e : new Error("Failed to convert HEIC")); + } + } else { + reject(new Error("Failed to load image")); + } }; img.src = url; }); diff --git a/src/routes/receipts/ReceiptForm.svelte b/src/routes/receipts/ReceiptForm.svelte index d8d2e016..b19901a0 100644 --- a/src/routes/receipts/ReceiptForm.svelte +++ b/src/routes/receipts/ReceiptForm.svelte @@ -7,8 +7,8 @@ const TYPE_OPTIONS = ["Purchase", "Gas"] as const; - const BUTTON_OUTLINE_CLASS = - "border-input bg-background hover:bg-accent hover:text-accent-foreground inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors"; + const SCAN_BUTTON_CLASS = + "border-input bg-background hover:bg-accent hover:text-accent-foreground flex h-9 w-full cursor-pointer items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium shadow-xs transition-colors"; let { action, @@ -24,69 +24,9 @@ defaultType?: string; } = $props(); - let imageInputEl = $state(null); let imageFileName = $state(null); let imageConverting = $state(false); - let showCamera = $state(false); - let cameraError = $state(null); - let videoEl = $state(undefined); - let stream: MediaStream | null = null; - - async function startCamera() { - cameraError = null; - showCamera = true; - try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, - }); - } catch (e) { - cameraError = e instanceof Error ? e.message : "Could not access camera."; - } - } - - $effect(() => { - if (!showCamera || !stream || !videoEl) return; - videoEl.srcObject = stream; - videoEl.play().catch(() => {}); - return () => { - if (videoEl?.srcObject) videoEl.srcObject = null; - }; - }); - - function stopCamera() { - showCamera = false; - cameraError = null; - if (stream) { - stream.getTracks().forEach((t) => t.stop()); - stream = null; - } - if (videoEl?.srcObject) videoEl.srcObject = null; - } - - function capturePhoto() { - if (!videoEl || videoEl.readyState < 2 || !imageInputEl) return; - const canvas = document.createElement("canvas"); - canvas.width = videoEl.videoWidth; - canvas.height = videoEl.videoHeight; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.drawImage(videoEl, 0, 0); - canvas.toBlob( - (blob) => { - if (!blob) return; - const file = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() }); - const dt = new DataTransfer(); - dt.items.add(file); - imageInputEl.files = dt.files; - imageFileName = file.name; - stopCamera(); - }, - "image/webp", - 0.85 - ); - } - async function onImageChange(e: Event) { const input = e.currentTarget as HTMLInputElement; const file = input.files?.[0]; @@ -94,12 +34,9 @@ imageFileName = null; return; } - if (file.type === "image/webp") { - imageFileName = file.name; - return; - } imageConverting = true; imageFileName = null; + // Let the UI paint "Processing image…" before we block the main thread await new Promise((r) => setTimeout(r, 0)); try { const webpFile = await convertImageToWebP(file); @@ -146,19 +83,19 @@
{#if imageConverting}
@@ -166,56 +103,20 @@ Processing image…
{:else} -
- - -
+ {/if} {#if imageConverting}

Converting to WebP…

{:else if imageFileName}

Selected: {imageFileName}

{:else} -

Optional. Take a photo (saved as WebP) or choose from gallery.

+

Optional. Images are converted to WebP before upload.

{/if}
- {#if showCamera} - - {/if} - {#if error}

{error}

{/if} diff --git a/src/routes/receipts/[id]/+page.svelte b/src/routes/receipts/[id]/+page.svelte index b5999a8d..0a949fb0 100644 --- a/src/routes/receipts/[id]/+page.svelte +++ b/src/routes/receipts/[id]/+page.svelte @@ -125,11 +125,6 @@ let imageError = $state(false); let imageConverting = $state(false); - let showCamera = $state(false); - let cameraError = $state(null); - let videoEl = $state(undefined); - let stream: MediaStream | null = null; - /** On small screens image is collapsed by default; on lg+ expanded. */ let imageOpen = $state(false); let imageOpenInitialized = $state(false); @@ -140,57 +135,6 @@ imageOpen = mq.matches; }); - async function startCamera() { - cameraError = null; - showCamera = true; - try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 } }, - }); - } catch (e) { - cameraError = e instanceof Error ? e.message : "Could not access camera."; - } - } - - $effect(() => { - if (!showCamera || !stream || !videoEl) return; - videoEl.srcObject = stream; - videoEl.play().catch(() => {}); - return () => { - if (videoEl?.srcObject) videoEl.srcObject = null; - }; - }); - - function stopCamera() { - showCamera = false; - cameraError = null; - if (stream) { - stream.getTracks().forEach((t) => t.stop()); - stream = null; - } - if (videoEl?.srcObject) videoEl.srcObject = null; - } - - function capturePhoto() { - if (!videoEl || videoEl.readyState < 2) return; - const canvas = document.createElement("canvas"); - canvas.width = videoEl.videoWidth; - canvas.height = videoEl.videoHeight; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.drawImage(videoEl, 0, 0); - canvas.toBlob( - (blob) => { - if (!blob) return; - selectedImageFile = new File([blob], "receipt.webp", { type: "image/webp", lastModified: Date.now() }); - imageFileName = "receipt.webp"; - stopCamera(); - }, - "image/webp", - 0.85 - ); - } - async function onImageChange() { const input = imageInputEl; const file = input?.files?.[0] ?? null; @@ -199,11 +143,6 @@ imageFileName = null; return; } - if (file.type === "image/webp") { - selectedImageFile = file; - imageFileName = file.name; - return; - } imageConverting = true; selectedImageFile = null; imageFileName = null; @@ -233,37 +172,6 @@
- {#if showCamera} - - {/if} -
Back

Receipt

@@ -334,17 +242,18 @@ class="flex flex-col gap-4" > -
+
{#if imageConverting}
Processing image…
{:else} -