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.