From 36f4f77e76e198db1bfde89ec30dc5585d33d042 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Wed, 15 Jul 2026 11:52:40 +0200 Subject: [PATCH] feat(web): zxing-wasm photo decode plumbing --- web/src/at_record_web/photo_scan.gleam | 118 +++++++++++++++++++++++ web/src/at_record_web/photo_scan_ffi.mjs | 111 +++++++++++++++++++++ web/test/photo_scan_test.gleam | 37 +++++++ 3 files changed, 266 insertions(+) create mode 100644 web/src/at_record_web/photo_scan.gleam create mode 100644 web/src/at_record_web/photo_scan_ffi.mjs create mode 100644 web/test/photo_scan_test.gleam diff --git a/web/src/at_record_web/photo_scan.gleam b/web/src/at_record_web/photo_scan.gleam new file mode 100644 index 0000000..737e08d --- /dev/null +++ b/web/src/at_record_web/photo_scan.gleam @@ -0,0 +1,118 @@ +//// Photo-mode barcode decode: capture a still frame from the live camera +//// video and decode it once, off the continuous-scan loop. Reuses the +//// zxing-wasm@3.1.0 BarcodeDetector ponyfill already vendored at +//// web/priv/static/vendor/{barcode-detector.js,zxing_reader.wasm} for the +//// Firefox/Safari live-scan fallback (see ffi.mjs) instead of shipping a +//// second copy of the wasm: same dynamic-import-on-first-use lazy load, +//// same Docker/bun build path (bun is pinned there), same BFF static +//// handler already serving that directory. No new npm dependency or +//// build-config change was needed to ship this. + +import gleam/list +import lustre/effect.{type Effect} + +/// An opaque handle to a captured still frame (a JS `Blob` under the hood); +/// only meaningful to `decode_barcodes`. +pub type Frame + +pub type DecodeError { + Unsupported(String) + CaptureFailed(String) + DecodeFailed(String) +} + +@external(javascript, "./photo_scan_ffi.mjs", "supportsLiveDetection") +pub fn supports_live_detection() -> Bool + +@external(javascript, "./photo_scan_ffi.mjs", "supportsPhotoCapture") +pub fn supports_photo_capture() -> Bool + +@external(javascript, "./photo_scan_ffi.mjs", "captureFrameEffect") +fn capture_frame_ffi( + video_id: String, + on_result: fn(Frame) -> Nil, + on_error: fn(String, String) -> Nil, +) -> Nil + +@external(javascript, "./photo_scan_ffi.mjs", "decodeBarcodesEffect") +fn decode_barcodes_ffi( + frame: Frame, + on_result: fn(List(String)) -> Nil, + on_error: fn(String, String) -> Nil, +) -> Nil + +@external(javascript, "./photo_scan_ffi.mjs", "captureAndDecode") +fn capture_and_decode_ffi( + video_id: String, + on_result: fn(List(String)) -> Nil, + on_error: fn(String, String) -> Nil, +) -> Nil + +/// Maps the ffi's `(code, message)` pair to a typed error; split out so the +/// mapping is testable without a browser. +pub fn decode_error_from_code(code: String, message: String) -> DecodeError { + case code { + "unsupported" -> Unsupported(message) + "capture_failed" -> CaptureFailed(message) + _ -> DecodeFailed(message) + } +} + +/// Grab a still frame from the `video_id` element without decoding it yet, +/// for a UI that wants to show a captured-photo preview before committing +/// to a decode. +pub fn capture_frame( + video_id: String, + on_result: fn(Result(Frame, DecodeError)) -> msg, +) -> Effect(msg) { + effect.from(fn(dispatch) { + capture_frame_ffi( + video_id, + fn(frame) { dispatch(on_result(Ok(frame))) }, + fn(code, message) { + dispatch(on_result(Error(decode_error_from_code(code, message)))) + }, + ) + }) +} + +/// Decode barcodes out of an already-captured frame. An empty list means +/// the frame decoded cleanly but held no recognisable barcode; that's not +/// an error, the user just missed the shot. +pub fn decode_barcodes( + frame: Frame, + on_result: fn(Result(List(String), DecodeError)) -> msg, +) -> Effect(msg) { + effect.from(fn(dispatch) { + decode_barcodes_ffi( + frame, + fn(codes) { dispatch(on_result(Ok(codes))) }, + fn(code, message) { + dispatch(on_result(Error(decode_error_from_code(code, message)))) + }, + ) + }) +} + +/// Capture-then-decode in one round trip: the common case for a photo-mode +/// shutter button, skipping the separate preview step. +pub fn capture_and_decode( + video_id: String, + on_result: fn(Result(List(String), DecodeError)) -> msg, +) -> Effect(msg) { + effect.from(fn(dispatch) { + capture_and_decode_ffi( + video_id, + fn(codes) { dispatch(on_result(Ok(codes))) }, + fn(code, message) { + dispatch(on_result(Error(decode_error_from_code(code, message)))) + }, + ) + }) +} + +/// The first decoded barcode, if any; a capture only ever acts on one code, +/// mirroring the live scanner's `codes[0]` behaviour. +pub fn first_barcode(codes: List(String)) -> Result(String, Nil) { + list.first(codes) +} diff --git a/web/src/at_record_web/photo_scan_ffi.mjs b/web/src/at_record_web/photo_scan_ffi.mjs new file mode 100644 index 0000000..2b5aeba --- /dev/null +++ b/web/src/at_record_web/photo_scan_ffi.mjs @@ -0,0 +1,111 @@ +// Same vendored zxing-wasm ponyfill the live scanner's ffi.mjs lazily loads +// (see web/priv/static/vendor/); kept as a separate module-local promise +// rather than sharing state with ffi.mjs, since both dynamic-import the +// same URL and the browser module cache dedupes the actual fetch anyway. +let ponyfillPromise = null; + +function getBarcodeDetector() { + if (!ponyfillPromise) { + const vendorBase = `${window.location.origin}/static/vendor`; + ponyfillPromise = import(`${vendorBase}/barcode-detector.js`).then((mod) => { + mod.setZXingModuleOverrides({ + locateFile: (path) => `${vendorBase}/${path}`, + }); + return mod.BarcodeDetector; + }); + } + return ponyfillPromise; +} + +export function supportsPhotoCapture() { + return !!( + navigator.mediaDevices && + navigator.mediaDevices.getUserMedia && + document.createElement("canvas").toBlob + ); +} + +export function supportsLiveDetection() { + return "BarcodeDetector" in window; +} + +function captureFrame(videoId) { + return new Promise((resolve, reject) => { + const video = document.getElementById(videoId); + if (!video || !video.videoWidth || !video.videoHeight) { + reject({ code: "capture_failed", message: "The camera has no frame ready yet." }); + return; + } + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject({ code: "capture_failed", message: "Canvas isn't available in this browser." }); + return; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject({ code: "capture_failed", message: "Couldn't capture a still frame." }); + } + }, + "image/jpeg", + 0.92, + ); + }); +} + +function decodeBarcodes(blob) { + return getBarcodeDetector() + .then((BarcodeDetector) => { + const detector = new BarcodeDetector({ formats: ["ean_13", "upc_a"] }); + return detector.detect(blob); + }) + .then((codes) => codes.map((c) => c.rawValue)) + .catch((err) => { + throw { + code: "decode_failed", + message: err && err.message ? err.message : "Barcode decoding failed.", + }; + }); +} + +function errorParts(err) { + return [err && err.code ? err.code : "decode_failed", err && err.message ? err.message : String(err)]; +} + +export function captureFrameEffect(videoId, onResult, onError) { + captureFrame(videoId) + .then((blob) => onResult(blob)) + .catch((err) => { + const [code, message] = errorParts(err); + onError(code, message); + }); +} + +export function decodeBarcodesEffect(blob, onResult, onError) { + decodeBarcodes(blob) + .then((codes) => onResult(codes)) + .catch((err) => { + const [code, message] = errorParts(err); + onError(code, message); + }); +} + +export function captureAndDecode(videoId, onResult, onError) { + if (!supportsPhotoCapture()) { + onError("unsupported", "Photo capture isn't supported in this browser."); + return; + } + captureFrame(videoId) + .then((blob) => decodeBarcodes(blob)) + .then((codes) => onResult(codes)) + .catch((err) => { + const [code, message] = errorParts(err); + onError(code, message); + }); +} diff --git a/web/test/photo_scan_test.gleam b/web/test/photo_scan_test.gleam new file mode 100644 index 0000000..5c427e3 --- /dev/null +++ b/web/test/photo_scan_test.gleam @@ -0,0 +1,37 @@ +//// Pure-logic coverage only: capture/decode themselves need a browser +//// (camera, canvas, wasm), so this exercises the parts of photo_scan that +//// don't cross the ffi boundary, the error-code mapping and the +//// first-barcode pick. + +import at_record_web/photo_scan.{ + CaptureFailed, DecodeFailed, Unsupported, decode_error_from_code, + first_barcode, +} + +pub fn maps_unsupported_code_test() { + assert decode_error_from_code("unsupported", "no getUserMedia") + == Unsupported("no getUserMedia") +} + +pub fn maps_capture_failed_code_test() { + assert decode_error_from_code("capture_failed", "no frame yet") + == CaptureFailed("no frame yet") +} + +pub fn maps_unknown_code_to_decode_failed_test() { + assert decode_error_from_code("boom", "wasm init failed") + == DecodeFailed("wasm init failed") +} + +pub fn maps_decode_failed_code_test() { + assert decode_error_from_code("decode_failed", "zxing threw") + == DecodeFailed("zxing threw") +} + +pub fn first_barcode_picks_the_first_of_several_test() { + assert first_barcode(["012345678905", "099999999994"]) == Ok("012345678905") +} + +pub fn first_barcode_is_an_error_on_no_codes_test() { + assert first_barcode([]) == Error(Nil) +} -- 2.51.2