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:
parent
d3132ab7f6
commit
9a28e99520
6 changed files with 145 additions and 6 deletions
|
|
@ -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
59
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
23
src/lib/server/heic-to-jpeg.ts
Normal file
23
src/lib/server/heic-to-jpeg.ts
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue