fix(collection-ai): read iPhone HEIC photos (they were skipped)

The first iPhone driver photos (Thabo, 2026-10-20, PO202610003TEL and
PO26100702STK) arrived as HEIC. The AI only takes JPEG/PNG/WebP/GIF, so
the reads were saved as "skipped — Not an image (.heic)" and Check with
AI kept answering 0 files read, 0 lines checked; skipped reads were never
retried.

- heic-to-jpeg.ts: HEIC/HEIF → JPEG with heic-convert (pure JS/WASM;
  sharp's prebuilt binaries can't decode HEVC HEIC), downscaled to 1600px
  like the PB thumb. defaultFetchFile uses it (PB has no HEIC thumbs).
- HEIC/HEIF count as readable images; a read an older version "skipped"
  for a now-readable type is re-read, and the tick treats it as unread.
- next.config: heic-convert / heic-decode / libheif-js are server
  externals (libheif's WASM loader uses a dynamic require).

Verified on the live photo: 1 MB HEIC → 315 KB JPEG in ~1 s, readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-20 07:12:29 +02:00
parent d3132ab7f6
commit 9a28e99520
6 changed files with 145 additions and 6 deletions

View file

@ -9,7 +9,9 @@ const nextConfig: NextConfig = {
// means Node resolves them at runtime from node_modules instead of going
// through webpack's loader — so pdfjs-dist's init runs in a clean CJS
// context, the way its author expects.
serverExternalPackages: ["pdf-parse", "pdfjs-dist", "@napi-rs/canvas"],
// heic-convert → heic-decode → libheif-js loads a WASM bundle with a
// dynamic require webpack can't follow; load it from node_modules too.
serverExternalPackages: ["pdf-parse", "pdfjs-dist", "@napi-rs/canvas", "heic-convert", "heic-decode", "libheif-js"],
// Dev server blocks cross-origin requests to its own dev resources by
// default in Next 16, which shows up as a page stuck on the loading
// spinner when you open the app from your phone on the LAN.

59
package-lock.json generated
View file

@ -19,6 +19,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"csv-parse": "^7.0.2",
"heic-convert": "^2.1.0",
"jspdf": "^2.5.2",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
@ -49,6 +50,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/heic-convert": "^2.1.1",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
@ -3705,6 +3707,13 @@
"@types/unist": "*"
}
},
"node_modules/@types/heic-convert": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/heic-convert/-/heic-convert-2.1.1.tgz",
"integrity": "sha512-+s14762Nf62z9zziIs7ItvAkSUCS3ls4Z5XPT9BlVve3Q3S4DAnf6qekffMTvEWycSUF+kulkGaSN65fK6eKvg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"dev": true,
@ -7404,6 +7413,41 @@
"set-cookie-parser": "^3.0.1"
}
},
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-convert/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"dev": true,
@ -8209,6 +8253,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
@ -8451,6 +8501,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/libheif-js": {
"version": "1.23.5",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.5.tgz",
"integrity": "sha512-umXZPthnWZtF3iG/5mQG+AZ2V6i0UeYpo3z2CkggTEgL8dKmIIwJT6TDuAMuabXa1yTW8uJ1ZAgR+CxNTklYYw==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",

View file

@ -83,6 +83,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"csv-parse": "^7.0.2",
"heic-convert": "^2.1.0",
"jspdf": "^2.5.2",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
@ -113,6 +114,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/heic-convert": "^2.1.1",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",

View file

@ -891,6 +891,30 @@ describe("checkCollectionCard", () => {
expect(c1.same_item).toBe("sure");
});
it("reads an iPhone HEIC photo instead of skipping it", async () => {
seedNss({ attachments: ["img_3958_aek6efjkk6.HEIC"] });
const { deps } = makeDeps();
const r = await checkCollectionCard(PO_CEM, { deps });
expect(deps.fetchFile).toHaveBeenCalledWith(expect.anything(), "img_3958_aek6efjkk6.HEIC");
expect(r.files_read).toBe(1);
expect(reads()[0]).toMatchObject({ filename: "img_3958_aek6efjkk6.HEIC", status: "read" });
expect(aiCheck("order_items", "l1")).not.toBeNull();
});
it("re-reads a HEIC photo that an older version skipped as 'not an image'", async () => {
seedNss({ attachments: ["img_3958_aek6efjkk6.HEIC"] });
addRead({ source_id: "l1", filename: "img_3958_aek6efjkk6.HEIC", status: "skipped", notes: ["Not an image (.heic)"], lines: [] });
const { deps } = makeDeps();
const r = await checkCollectionCard(PO_CEM, { deps });
expect(r.files_read).toBe(1);
expect(reads().filter((x) => x.filename === "img_3958_aek6efjkk6.HEIC")).toHaveLength(1);
expect(reads()[0]).toMatchObject({ status: "read" });
});
it("stock rows: reads the parent purchase's attachments and fills the SKU through updateStockPurchaseItem", async () => {
addStockPurchase("sp1", "PO26101501STK", ["stock_inv.jpg"]);
addStockLine("s1", "sp1", { description: "M6 nylock nut" });
@ -1183,6 +1207,18 @@ describe("runCollectionAiTick", () => {
expect(deps.readDocument).not.toHaveBeenCalled();
});
it("picks up a card whose HEIC photo was skipped by an older version", async () => {
addPo("po1", "PO202610001XX");
addOrderLine("c1", "po1", { order_item_attachements: ["img_1.HEIC"] });
addRead({ source_id: "c1", filename: "img_1.HEIC", status: "skipped", notes: ["Not an image (.heic)"], lines: [], po_refs: [] });
const { deps } = makeDeps();
const r = await runCollectionAiTick(deps);
expect(r.ran).toBe(true);
expect(deps.fetchFile).toHaveBeenCalledWith(expect.anything(), "img_1.HEIC");
});
it("does not overlap a run still inside its lease", async () => {
seedCards(1);
settings.collection_ai_last_run_at = new Date(NOW.getTime() - 60_000).toISOString();

View file

@ -32,6 +32,7 @@ import {
import { docPriceLabel, roundCents } from "@/lib/collection-ai-ticks";
import { updateStockPurchaseItem } from "@/lib/server/stock-purchase-items-service";
import { normalizePoReference } from "@/lib/server/stock-purchase-invoice-matching-service";
import { heicToJpeg, isHeic } from "@/lib/server/heic-to-jpeg";
import { readDocumentImage, type DocumentReadResult } from "@/lib/server/collection-ai-read";
import {
isRealPair,
@ -89,7 +90,8 @@ const MAX_SUMMARY_ERRORS = 20;
const MAX_SUMMARY_ERROR_CHARS = 300;
const CHUNK = 50;
const PDF_NOTE = "PDF not read yet";
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif"]);
/** Photo types the AI can read (HEIC/HEIF are converted to JPEG first). */
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif", "heic", "heif"]);
/** Same cap as `updateOrderItemSku`. */
const MAX_SKU_LENGTH = 64;
/** Item statuses that mean the line was physically collected. */
@ -383,12 +385,19 @@ async function defaultFetchFile(
): Promise<{ base64: string; contentType: string; bytes: Buffer }> {
const pb = await getAdminPocketBase();
const token = await pb.files.getToken();
const heic = isHeic(filename);
// PB makes thumbs for JPEG/PNG/GIF/WebP only; a HEIC comes back whole
// and is converted (and downscaled) here.
const url = pb.files.getURL({ id: rec.id, collectionName: rec.collection }, filename, {
token,
thumb: "1600x0",
...(heic ? {} : { thumb: "1600x0" }),
});
const res = await fetch(url);
if (!res.ok) throw new Error(`file fetch failed (${res.status})`);
if (heic) {
const jpeg = await heicToJpeg(Buffer.from(await res.arrayBuffer()));
return { base64: jpeg.toString("base64"), contentType: "image/jpeg", bytes: jpeg };
}
const bytes = Buffer.from(await res.arrayBuffer());
let contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
if (contentType === "image/jpg") contentType = "image/jpeg";
@ -605,9 +614,12 @@ async function checkCard(
const readOneFile = async (file: FileRef): Promise<void> => {
const existing = readsByFile.get(fileKey(file));
if (existing && !(opts.retryFailed && existing.status === "failed")) return;
const replaceId = existing?.id ?? null;
const extension = ext(file.filename);
// A photo "skipped" by an older version (e.g. HEIC before 2026-10-20)
// is re-read now that its type is readable.
const staleSkip = existing?.status === "skipped" && IMAGE_EXT.has(extension);
if (existing && !staleSkip && !(opts.retryFailed && existing.status === "failed")) return;
const replaceId = existing?.id ?? null;
if (!IMAGE_EXT.has(extension)) {
const fields = emptyRead(file, now);
fields.notes = [extension === "pdf" ? PDF_NOTE : `Not an image (.${extension || "?"})`];
@ -1033,7 +1045,12 @@ async function cardsNeedingWork(pb: AdminPb): Promise<string[]> {
const out: string[] = [];
for (const [poNumber, cardLines] of byCard) {
const files = filesForLines(cardLines, stockPurchases);
const unread = files.some((f) => IMAGE_EXT.has(ext(f.filename)) && !reads.has(fileKey(f)));
// Unread, or skipped by an older version that couldn't read its type.
const unread = files.some((f) => {
if (!IMAGE_EXT.has(ext(f.filename))) return false;
const r = reads.get(fileKey(f));
return !r || r.status === "skipped";
});
const hasDocument = files.some((f) => {
const r = reads.get(fileKey(f));
return r?.status === "read" && (r.doc_type === "tax_invoice" || r.doc_type === "delivery_note");

View file

@ -0,0 +1,23 @@
/**
* iPhone photos arrive as HEIC (the driver app on iOS sends them as-is
* when the browser can't re-encode; first seen 2026-10-20). The AI only
* accepts JPEG/PNG/WebP/GIF, and `sharp`'s prebuilt binaries can't decode
* HEIC (HEVC) — only AVIF. `heic-convert` is pure JS (libheif in WASM),
* so it works in the Docker image without native builds. The result is
* downscaled with sharp to the same 1600px the PB thumb gives other photos.
*/
const MAX_EDGE = 1600;
export function isHeic(filename: string, contentType = ""): boolean {
return /\.(heic|heif)$/i.test(filename) || /^image\/hei[cf]/i.test(contentType);
}
export async function heicToJpeg(bytes: Buffer): Promise<Buffer> {
const { default: convert } = await import("heic-convert");
const jpeg = Buffer.from(await convert({ buffer: bytes, format: "JPEG", quality: 0.9 }));
const { default: sharp } = await import("sharp");
return sharp(jpeg)
.resize({ width: MAX_EDGE, height: MAX_EDGE, fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 88 })
.toBuffer();
}