diff --git a/next.config.ts b/next.config.ts index d626a76..da9f7c9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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. diff --git a/package-lock.json b/package-lock.json index 2b6a998..03c6f84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 50a9c92..50dde34 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/server/collection-ai-service.test.ts b/src/lib/server/collection-ai-service.test.ts index 2422840..e983663 100644 --- a/src/lib/server/collection-ai-service.test.ts +++ b/src/lib/server/collection-ai-service.test.ts @@ -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(); diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index fd218b5..3556213 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -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 => { 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 { 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"); diff --git a/src/lib/server/heic-to-jpeg.ts b/src/lib/server/heic-to-jpeg.ts new file mode 100644 index 0000000..f696346 --- /dev/null +++ b/src/lib/server/heic-to-jpeg.ts @@ -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 { + 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(); +}