From 22960fa5f334048f4b84e65d5a8b71008113b3cd Mon Sep 17 00:00:00 2001 From: GitHub Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:36:56 +0000 Subject: [PATCH 1/8] Parallelize web scanner startup Start Catalog v2 reconstruction alongside model preparation and fetch detector and embedder weights concurrently, avoiding idle network time during first load. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/web_scanner/app.js | 6 ++++- examples/web_scanner/scanner.worker.mjs | 35 ++++++++++++++++--------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/examples/web_scanner/app.js b/examples/web_scanner/app.js index 462222b..74f8dd2 100644 --- a/examples/web_scanner/app.js +++ b/examples/web_scanner/app.js @@ -2253,7 +2253,11 @@ async function boot() { loadingScreen.step("dewarp", "active", "Queued"); loadingScreen.step("detector", "active", "Queued"); loadingScreen.step("embedder", "active", "Queued"); - loadingScreen.step("catalog", "active", "Waiting for models"); + loadingScreen.step( + "catalog", + "active", + catalogMode === "v2" ? "Downloading and indexing" : "Waiting for models", + ); setText("models-status", "Loading models"); scannerWorker.postMessage({ diff --git a/examples/web_scanner/scanner.worker.mjs b/examples/web_scanner/scanner.worker.mjs index b755a1b..b4cf047 100644 --- a/examples/web_scanner/scanner.worker.mjs +++ b/examples/web_scanner/scanner.worker.mjs @@ -601,6 +601,12 @@ class WorkerRuntime { async load(onStage) { const version = this.manifest.version; + const catalogPromise = this.catalogMode === "v2" + ? this.loadCatalogV2(onStage).then( + () => null, + (error) => error, + ) + : null; // Use per-model content hashes as cache keys when available so that a new // model weight file (same filename, different content) always busts the // IndexedDB entry, even if the bundle version string hasn't changed. @@ -608,18 +614,20 @@ class WorkerRuntime { const modelSizes = this.manifest.model_sizes ?? {}; const detectorVersion = hashes[this.detectorConfig.modelKey] ?? version; const embedderVersion = hashes.milo ?? version; - const detectorBuffer = await fetchBufferCached( - `${this.assetBasePath}/${this.manifest.models[this.detectorConfig.modelKey]}`, - detectorVersion, - modelSizes[this.detectorConfig.modelKey], - (ratio, loaded, total, cached) => onStage?.("detector", ratio, loaded, total, cached), - ); - const embedderBuffer = await fetchBufferCached( - `${this.assetBasePath}/${this.manifest.models.milo}`, - embedderVersion, - modelSizes.milo, - (ratio, loaded, total, cached) => onStage?.("embedder", ratio, loaded, total, cached), - ); + const [detectorBuffer, embedderBuffer] = await Promise.all([ + fetchBufferCached( + `${this.assetBasePath}/${this.manifest.models[this.detectorConfig.modelKey]}`, + detectorVersion, + modelSizes[this.detectorConfig.modelKey], + (ratio, loaded, total, cached) => onStage?.("detector", ratio, loaded, total, cached), + ), + fetchBufferCached( + `${this.assetBasePath}/${this.manifest.models.milo}`, + embedderVersion, + modelSizes.milo, + (ratio, loaded, total, cached) => onStage?.("embedder", ratio, loaded, total, cached), + ), + ]); // Use as many threads as the device has cores, capped at 4. // NOTE: multi-threaded WASM requires SharedArrayBuffer / COOP+COEP headers. @@ -648,7 +656,8 @@ class WorkerRuntime { this.inputNames.embedder = this.embedder.inputNames[0]; if (this.catalogMode === "v2") { - await this.loadCatalogV2(onStage); + const catalogError = await catalogPromise; + if (catalogError) throw catalogError; return; } From 49e35316a67feb702bd77d1ab3ed9955baa4e4b4 Mon Sep 17 00:00:00 2001 From: GitHub Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:43:05 +0000 Subject: [PATCH 2/8] Keep scanner download progress monotonic Aggregate multipart legacy catalog transfers in the loading UI and retain meaningful byte counts when completion events omit them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/web_scanner/app.js | 57 +++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/examples/web_scanner/app.js b/examples/web_scanner/app.js index 74f8dd2..31436b6 100644 --- a/examples/web_scanner/app.js +++ b/examples/web_scanner/app.js @@ -2189,6 +2189,48 @@ async function boot() { // Wire up init-phase progress messages before posting 'init'. const scannerReady = new Promise((resolve, reject) => { + const stageProgress = new Map(); + const bundledCatalogBytes = Object.values(manifest.catalog?.asset_sizes ?? {}) + .reduce((total, size) => total + (Number.isSafeInteger(size) && size > 0 ? size : 0), 0); + + function displayProgress(data) { + const previous = stageProgress.get(data.stage); + const rawLoaded = Math.max(0, Number(data.loaded) || 0); + const rawTotal = Math.max(0, Number(data.total) || 0); + let loaded = rawLoaded; + let total = rawTotal; + let ratio = Math.max(0, Math.min(1, Number(data.ratio) || 0)); + let offset = previous?.offset ?? 0; + + // Older workers report each bundled catalog file independently. Combine + // those reports so the display does not jump from the embeddings size + // back down to the card-ID file size. + if (data.stage === "catalog" && catalogMode === "v1" && bundledCatalogBytes > 0) { + if (rawTotal === bundledCatalogBytes) { + offset = 0; + } else if (previous && rawLoaded < previous.rawLoaded) { + offset = previous.loaded; + } + loaded = Math.min(bundledCatalogBytes, offset + rawLoaded); + total = bundledCatalogBytes; + ratio = loaded / total; + } + + // Completion-only events (Catalog v2 currently emits one) do not carry + // byte counts. Keep the last useful byte detail rather than flashing 0 B. + if (previous && loaded < previous.loaded) { + loaded = previous.loaded; + total = previous.total; + } + if (previous) { + ratio = Math.max(ratio, previous.ratio); + } + + const progress = { loaded, total, ratio, rawLoaded, offset }; + stageProgress.set(data.stage, progress); + return progress; + } + function onInitMessage({ data }) { if (data.type === "progress") { recordBootTrace("worker:progress", data, { debugOnly: data.ratio < 1 }); @@ -2204,19 +2246,24 @@ async function boot() { debugLog.info("dewarp ready"); } else { const ranges = { detector: [24, 44], embedder: [44, 60], catalog: [60, 96] }; + const progress = displayProgress(data); const [start, end] = ranges[data.stage] ?? [0, 0]; - const percent = start + (end - start) * data.ratio; + const percent = start + (end - start) * progress.ratio; const note = data.cached ? "Cached" - : data.total > 0 - ? `${formatBytes(data.loaded)} / ${formatBytes(data.total)}` - : `${formatBytes(data.loaded)} downloaded`; + : progress.total > 0 + ? `${formatBytes(progress.loaded)} / ${formatBytes(progress.total)}` + : progress.loaded > 0 + ? `${formatBytes(progress.loaded)} downloaded` + : data.ratio >= 1 + ? "Ready" + : "Starting"; const label = { detector: "Loading corner detector", embedder: "Loading embedder", catalog: "Loading card catalog", }[data.stage]; - setPhase(data.stage, percent, label, note, data.ratio >= 1 ? "done" : "active"); + setPhase(data.stage, percent, label, note, progress.ratio >= 1 ? "done" : "active"); } } else if (data.type === "ready") { recordBootTrace("worker:ready", { From 418272976166e58a5afd111d244170d78f73743c Mon Sep 17 00:00:00 2001 From: GitHub Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:45:58 +0000 Subject: [PATCH 3/8] Preserve asset channels across web tools Expose stable/testing state in the playground and screen capture monitor, retain the selected channel while navigating, and make the scanner sharpness settings easier to find. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/web_scanner/README.md | 3 ++- examples/web_scanner/app.js | 13 +++++++++++++ examples/web_scanner/applet_example.css | 18 ++++++++++++++++++ examples/web_scanner/applet_example.html | 10 ++++++++-- examples/web_scanner/applet_example.js | 10 ++++++++++ examples/web_scanner/index.html | 7 ++++--- .../web_scanner/screen_capture_monitor.html | 7 ++++--- examples/web_scanner/screen_capture_monitor.js | 12 ++++++++++++ examples/web_scanner/style.css | 6 ++++-- 9 files changed, 75 insertions(+), 11 deletions(-) diff --git a/examples/web_scanner/README.md b/examples/web_scanner/README.md index 55f5e4a..889495e 100644 --- a/examples/web_scanner/README.md +++ b/examples/web_scanner/README.md @@ -18,7 +18,8 @@ Catalog v2 is the default: Append `?catalog=v1` to use the prepared static catalog bundle. This is the compatibility path, not the default. `?channel=testing` selects the separately -published testing model bundle. +published testing model bundle on the scanner, playground, and screen capture +monitor. Links between those pages preserve the selected channel. The standalone [`catalog_v2_example.html`](./catalog_v2_example.html) loads any published game catalog and displays its first record. diff --git a/examples/web_scanner/app.js b/examples/web_scanner/app.js index 31436b6..68e91ac 100644 --- a/examples/web_scanner/app.js +++ b/examples/web_scanner/app.js @@ -2050,6 +2050,18 @@ function resolveAssetChannel() { return Object.hasOwn(ASSET_CHANNELS, requested) ? requested : "stable"; } +function preserveAssetChannel(channel) { + for (const link of document.querySelectorAll("a[data-preserve-channel]")) { + const url = new URL(link.href, location.href); + if (channel === "testing") { + url.searchParams.set("channel", channel); + } else { + url.searchParams.delete("channel"); + } + link.href = url.href; + } +} + function resolveCatalogMode() { const requested = new URLSearchParams(location.search).get("catalog") ?? "v2"; if (requested !== "v1" && requested !== "v2") { @@ -2156,6 +2168,7 @@ async function boot() { // Load the manifest on the main thread first — it drives both the loading // screen text and the worker init message. const channel = resolveAssetChannel(); + preserveAssetChannel(channel); const catalogMode = resolveCatalogMode(); const { assetBasePath, manifest } = await loadManifest(channel); const catalogLimit = getCatalogLimitFromQuery(); diff --git a/examples/web_scanner/applet_example.css b/examples/web_scanner/applet_example.css index 123b990..faaee9f 100644 --- a/examples/web_scanner/applet_example.css +++ b/examples/web_scanner/applet_example.css @@ -32,6 +32,24 @@ h2 { p { line-height: 1.55; } +.playground-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.playground-heading nav { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.playground-heading nav a { + color: inherit; + font-weight: 700; +} + code { border-radius: 0.35rem; padding: 0.1rem 0.3rem; diff --git a/examples/web_scanner/applet_example.html b/examples/web_scanner/applet_example.html index 4318ca3..d6401bf 100644 --- a/examples/web_scanner/applet_example.html +++ b/examples/web_scanner/applet_example.html @@ -47,7 +47,13 @@
-

Candidate API

+
+

Candidate API · stable assets

+ +

Scanner Playground

Try preset card handlers, edit the JavaScript, and see how the applet can fit into your own page. This playground keeps everything local in your browser: apply a handler, then scan a card. Need to disable cross-origin isolation for debugging? Open with ?coi=0.

@@ -59,7 +65,7 @@

Scanner Playground

Scan settings
@@ -231,7 +231,8 @@

Screen Capture Monitor

Watch a shared tab, window, or screen, crop to a region of interest, and emit card events for overlays or CSV/JSONL export.

- ▶ Open Screen Capture Monitor + ▶ Open Screen Capture Monitor + ▶ Open Scanner Playground
diff --git a/examples/web_scanner/screen_capture_monitor.html b/examples/web_scanner/screen_capture_monitor.html index 5c86e7f..bb0907c 100644 --- a/examples/web_scanner/screen_capture_monitor.html +++ b/examples/web_scanner/screen_capture_monitor.html @@ -57,11 +57,12 @@

Screen Capture Monitor

Detector
loading…
Milo
loading…
Catalog
loading…
+
Channel
stable
@@ -150,7 +151,7 @@

Recognition settings

-

Shows detector, dewarp, embed, lookup, thread, and memory estimates in the camera corner. You can also enable it with ?fps=1.

+

Shows observed and pipeline FPS plus detector, dewarp, embed, lookup, thread, and memory estimates in the camera corner. You can also enable it with ?fps=1.

diff --git a/examples/web_scanner/style.css b/examples/web_scanner/style.css index d0fc8fa..7edd483 100644 --- a/examples/web_scanner/style.css +++ b/examples/web_scanner/style.css @@ -338,7 +338,7 @@ h2 { top: 0.65rem; right: 0.65rem; z-index: 3; - width: min(26rem, calc(100% - 1.3rem)); + width: min(34rem, calc(100% - 1.3rem)); padding: 0.55rem 0.65rem; border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 0.75rem; @@ -347,7 +347,8 @@ h2 { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.7rem; line-height: 1.35; - white-space: pre-line; + overflow-x: auto; + white-space: pre; pointer-events: none; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); } From 32d5741078e831445b60f1fb195764e550ec1f5d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:54:46 +0000 Subject: [PATCH 6/8] Test monotonic catalog progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/web_scanner/app.js | 42 ++-------------- examples/web_scanner/lib/progress.mjs | 40 +++++++++++++++ tests/js/package.json | 2 +- tests/js/test_progress.mjs | 71 +++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 40 deletions(-) create mode 100644 examples/web_scanner/lib/progress.mjs create mode 100644 tests/js/test_progress.mjs diff --git a/examples/web_scanner/app.js b/examples/web_scanner/app.js index 566e42e..c1ac3bc 100644 --- a/examples/web_scanner/app.js +++ b/examples/web_scanner/app.js @@ -1,3 +1,5 @@ +import { createProgressTracker } from "./lib/progress.mjs"; + // Replaced by the deploy-pages CI workflow with the actual short commit SHA. const BUILD_ID = "__BUILD_ID__"; @@ -2227,47 +2229,9 @@ async function boot() { // Wire up init-phase progress messages before posting 'init'. const scannerReady = new Promise((resolve, reject) => { - const stageProgress = new Map(); const bundledCatalogBytes = Object.values(manifest.catalog?.asset_sizes ?? {}) .reduce((total, size) => total + (Number.isSafeInteger(size) && size > 0 ? size : 0), 0); - - function displayProgress(data) { - const previous = stageProgress.get(data.stage); - const rawLoaded = Math.max(0, Number(data.loaded) || 0); - const rawTotal = Math.max(0, Number(data.total) || 0); - let loaded = rawLoaded; - let total = rawTotal; - let ratio = Math.max(0, Math.min(1, Number(data.ratio) || 0)); - let offset = previous?.offset ?? 0; - - // Older workers report each bundled catalog file independently. Combine - // those reports so the display does not jump from the embeddings size - // back down to the card-ID file size. - if (data.stage === "catalog" && catalogMode === "v1" && bundledCatalogBytes > 0) { - if (rawTotal === bundledCatalogBytes) { - offset = 0; - } else if (previous && rawLoaded < previous.rawLoaded) { - offset = previous.loaded; - } - loaded = Math.min(bundledCatalogBytes, offset + rawLoaded); - total = bundledCatalogBytes; - ratio = loaded / total; - } - - // Completion-only events (Catalog v2 currently emits one) do not carry - // byte counts. Keep the last useful byte detail rather than flashing 0 B. - if (previous && loaded < previous.loaded) { - loaded = previous.loaded; - total = previous.total; - } - if (previous) { - ratio = Math.max(ratio, previous.ratio); - } - - const progress = { loaded, total, ratio, rawLoaded, offset }; - stageProgress.set(data.stage, progress); - return progress; - } + const displayProgress = createProgressTracker({ catalogMode, bundledCatalogBytes }); function onInitMessage({ data }) { if (data.type === "progress") { diff --git a/examples/web_scanner/lib/progress.mjs b/examples/web_scanner/lib/progress.mjs new file mode 100644 index 0000000..dfdf4b0 --- /dev/null +++ b/examples/web_scanner/lib/progress.mjs @@ -0,0 +1,40 @@ +export function createProgressTracker({ catalogMode, bundledCatalogBytes = 0 } = {}) { + const stages = new Map(); + + return function trackProgress(data) { + const previous = stages.get(data.stage); + const rawLoaded = Math.max(0, Number(data.loaded) || 0); + const rawTotal = Math.max(0, Number(data.total) || 0); + let loaded = rawLoaded; + let total = rawTotal; + let ratio = Math.max(0, Math.min(1, Number(data.ratio) || 0)); + let offset = previous?.offset ?? 0; + + // Older workers report each bundled catalog file independently. Combine + // those reports into the manifest's authoritative aggregate size. + if (data.stage === "catalog" && catalogMode === "v1" && bundledCatalogBytes > 0) { + if (rawTotal === bundledCatalogBytes) { + offset = 0; + } else if (previous && rawLoaded < previous.rawLoaded) { + offset = previous.loaded; + } + loaded = Math.min(bundledCatalogBytes, offset + rawLoaded); + total = bundledCatalogBytes; + ratio = loaded / total; + } + + // Keep completion-only events from erasing useful byte details, and keep + // every stage monotonic if messages arrive late or restart at zero. + if (previous && loaded < previous.loaded) { + loaded = previous.loaded; + total = previous.total; + } + if (previous) { + ratio = Math.max(ratio, previous.ratio); + } + + const progress = { loaded, total, ratio, rawLoaded, offset }; + stages.set(data.stage, progress); + return progress; + }; +} diff --git a/tests/js/package.json b/tests/js/package.json index ede09e7..201cbc0 100644 --- a/tests/js/package.json +++ b/tests/js/package.json @@ -8,7 +8,7 @@ }, "private": true, "scripts": { - "test": "node --input-type=module --check < ../../examples/web_scanner/app.js && node --check ../../examples/web_scanner/scanner.worker.mjs && node --check ../../examples/web_scanner/lib/collectorvision-catalog-v2.mjs && node test_catalog_v2.mjs && node test_pipeline.mjs" + "test": "node --input-type=module --check < ../../examples/web_scanner/app.js && node --check ../../examples/web_scanner/scanner.worker.mjs && node --check ../../examples/web_scanner/lib/collectorvision-catalog-v2.mjs && node test_progress.mjs && node test_catalog_v2.mjs && node test_pipeline.mjs" }, "dependencies": { "canvas": "^2.11.2", diff --git a/tests/js/test_progress.mjs b/tests/js/test_progress.mjs new file mode 100644 index 0000000..2ff14fc --- /dev/null +++ b/tests/js/test_progress.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; + +import { createProgressTracker } from "../../examples/web_scanner/lib/progress.mjs"; + +const EMBEDDINGS_BYTES = 28_086_016; +const CARD_IDS_BYTES = 4_408_130; +const ORACLE_IDS_BYTES = 4_388_440; +const CATALOG_BYTES = EMBEDDINGS_BYTES + CARD_IDS_BYTES + ORACLE_IDS_BYTES; + +function progress(stage, loaded, total, ratio = total > 0 ? loaded / total : 0) { + return { stage, loaded, total, ratio }; +} + +{ + const track = createProgressTracker({ + catalogMode: "v1", + bundledCatalogBytes: CATALOG_BYTES, + }); + const updates = [ + progress("catalog", 0, EMBEDDINGS_BYTES), + progress("catalog", EMBEDDINGS_BYTES, EMBEDDINGS_BYTES), + progress("catalog", 0, CARD_IDS_BYTES), + progress("catalog", CARD_IDS_BYTES, CARD_IDS_BYTES), + progress("catalog", 0, ORACLE_IDS_BYTES), + progress("catalog", ORACLE_IDS_BYTES, ORACLE_IDS_BYTES), + ].map(track); + + assert.deepEqual( + updates.map(({ loaded }) => loaded), + [ + 0, + EMBEDDINGS_BYTES, + EMBEDDINGS_BYTES, + EMBEDDINGS_BYTES + CARD_IDS_BYTES, + EMBEDDINGS_BYTES + CARD_IDS_BYTES, + CATALOG_BYTES, + ], + ); + assert.ok(updates.every(({ total }) => total === CATALOG_BYTES)); + assert.ok(updates.every((update, index) => index === 0 || update.ratio >= updates[index - 1].ratio)); +} + +{ + const track = createProgressTracker({ + catalogMode: "v1", + bundledCatalogBytes: CATALOG_BYTES, + }); + const updates = [ + progress("catalog", EMBEDDINGS_BYTES, CATALOG_BYTES), + progress("catalog", EMBEDDINGS_BYTES + CARD_IDS_BYTES, CATALOG_BYTES), + progress("catalog", CATALOG_BYTES, CATALOG_BYTES), + ].map(track); + + assert.deepEqual(updates.map(({ loaded }) => loaded), [ + EMBEDDINGS_BYTES, + EMBEDDINGS_BYTES + CARD_IDS_BYTES, + CATALOG_BYTES, + ]); +} + +{ + const track = createProgressTracker(); + track(progress("catalog", 12_000, 20_000, 0.6)); + const completed = track(progress("catalog", 0, 0, 1)); + + assert.equal(completed.loaded, 12_000); + assert.equal(completed.total, 20_000); + assert.equal(completed.ratio, 1); +} + +console.log("Catalog progress regression tests passed"); From 49284a37938c3824b600467a30e2458079efd980 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:57:10 +0000 Subject: [PATCH 7/8] Clarify scanner threshold settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/web_scanner/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/web_scanner/index.html b/examples/web_scanner/index.html index a6a30f2..7c0384b 100644 --- a/examples/web_scanner/index.html +++ b/examples/web_scanner/index.html @@ -250,8 +250,8 @@

Model Benchmark

-

Recognition

-

Match Threshold

+

Detector and recognition

+

Detection Thresholds