import { useEffect, useState } from "react"; declare global { interface Window { // oxlint-disable-next-line typescript/no-explicit-any -- OpenCV.js has no bundled types cv: any; } } /** * OpenCV.js builds to try, in order. Both are single-file builds with the WASM embedded, * so no companion `.wasm` request is needed. * * Note: opencv.org only publishes rolling branch builds — pinned version paths such as * `/4.10.0/opencv.js` were removed upstream and now return 404. `5.x` is the current * development line and `4.x` the stable line; the 4.x entry is a fallback for the case * where the 5.x build is temporarily broken or unavailable. */ export const DEFAULT_OPENCV_URLS = ["https://docs.opencv.org/5.x/opencv.js", "https://docs.opencv.org/4.x/opencv.js"]; /** Milliseconds to wait for the WASM runtime to initialise before giving up on a build. */ const INIT_TIMEOUT_MS = 60_000; let scriptUrls = [...DEFAULT_OPENCV_URLS]; let loadPromise: Promise | null = null; /** * Overrides which OpenCV.js build(s) to load — e.g. to self-host instead of using the CDN. * Must be called before anything triggers a load; ignored once loading has started. */ export function configureOpenCV(urls: string | string[]) { if (loadPromise) { console.warn("[OpenCV] configureOpenCV() ignored — OpenCV is already loading or loaded"); return; } scriptUrls = Array.isArray(urls) ? [...urls] : [urls]; } function isReady() { return typeof window.cv?.Mat !== "undefined"; } /** Polls for cv.Mat, which signals that the WASM runtime has fully initialised. */ function waitForCvMat(timeoutMs: number): Promise { return new Promise((resolve, reject) => { let elapsed = 0; let interval = setInterval(() => { if (isReady()) { clearInterval(interval); resolve(); return; } elapsed += 100; if (elapsed >= timeoutMs) { clearInterval(interval); reject(new Error("OpenCV.js initialisation timed out")); } }, 100); }); } /** Loads a single OpenCV.js build, handling both the promise-based and polling Emscripten builds. */ function loadScript(url: string): Promise { return new Promise((resolve, reject) => { // A previous failed attempt may have left a half-initialised global behind. if (!isReady()) window.cv = undefined; let script = document.createElement("script"); script.src = url; script.async = true; let settle = (err?: Error) => { if (err) { script.remove(); reject(err); } else { resolve(); } }; script.onload = () => { if (typeof window.cv?.then === "function") { // Emscripten promise-based build: cv itself is the promise. // IMPORTANT: never resolve *with* the instance — if the resolved value is // itself thenable, the promise chain follows it forever and never settles. // Instead mutate window.cv and signal readiness with undefined. (window.cv as Promise).then( (instance) => { window.cv = instance; settle(); }, (err: Error) => settle(err instanceof Error ? err : new Error(String(err))), ); } else if (isReady()) { settle(); } else { // Polling build: the WASM initialises asynchronously after the script loads. waitForCvMat(INIT_TIMEOUT_MS).then(() => settle(), settle); } }; script.onerror = () => settle(new Error(`Failed to load OpenCV.js from ${url}`)); document.head.appendChild(script); }); } /** * Lazily loads OpenCV.js, trying each configured build in order. * Rejects only when every candidate fails. Callers read `window.cv`. * * IMPORTANT: this promise must never resolve *with* the OpenCV module. Emscripten's * module object has its own `.then` method, so it is thenable — resolving a promise * with it makes the promise machinery try to adopt it and call `.then` forever, which * wedges the whole tab in an infinite microtask loop. Always resolve with `undefined` * and let callers read `window.cv`. */ export function loadOpenCV(): Promise { if (loadPromise) return loadPromise; // Already initialised (e.g. hot-reload, or loaded by the host page) if (isReady()) { loadPromise = Promise.resolve(); return loadPromise; } loadPromise = (async () => { let errors: Error[] = []; for (let url of scriptUrls) { try { await loadScript(url); if (!isReady()) throw new Error(`OpenCV.js loaded from ${url} but exposes no cv.Mat`); return; // never `return window.cv` — see the note above } catch (err) { let error = err instanceof Error ? err : new Error(String(err)); errors.push(error); console.warn(`[OpenCV] Could not load ${url}:`, error.message); } } // Allow a later retry (e.g. after the network comes back) rather than caching the failure. loadPromise = null; throw new Error(`Failed to load OpenCV.js from: ${scriptUrls.join(", ")}`, { cause: errors }); })(); return loadPromise; } interface UseOpenCVResult { // oxlint-disable-next-line typescript/no-explicit-any -- OpenCV.js has no bundled types cv: any | null; ready: boolean; error: Error | null; } /** Lazily loads OpenCV.js from CDN. Starts loading only when `enabled` is true. */ export function useOpenCV(enabled = true): UseOpenCVResult { // oxlint-disable-next-line typescript/no-explicit-any -- OpenCV.js has no bundled types let [cv, setCv] = useState(null); let [ready, setReady] = useState(false); let [error, setError] = useState(null); useEffect(() => { if (!enabled) return; let cancelled = false; loadOpenCV() .then(() => { if (cancelled) return; setCv(window.cv); setReady(true); }) .catch((err: Error) => { if (cancelled) return; // OpenCV only powers optional features (border suggestion, perspective warp), // so a load failure must never break the tools that depend on it. console.warn("[useOpenCV] OpenCV failed to load — continuing without it:", err.message); setError(err); }); return () => { cancelled = true; }; }, [enabled]); return { cv, ready, error }; }