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 462222b..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__";
@@ -1583,20 +1585,41 @@ function setupCornerConfidenceSlider(scannerWorker = null) {
slider.addEventListener("input", update);
}
+const thresholdMeterPeaks = new Map();
+
function updateThresholdMeter(name, threshold, current, maximum) {
const fill = document.getElementById(`${name}-signal-fill`);
+ const peakMarker = document.getElementById(`${name}-signal-peak`);
const marker = document.getElementById(`${name}-signal-threshold`);
const value = document.getElementById(`${name}-signal-value`);
- if (!fill || !marker || !value) return;
+ if (!fill || !peakMarker || !marker || !value) return;
marker.style.left = `${(Math.min(1, Math.max(0, threshold / maximum)) * 100).toFixed(1)}%`;
- if (!Number.isFinite(current)) {
- fill.style.width = "0%";
- value.textContent = "Current —";
- return;
- }
- fill.style.width = `${(Math.min(1, Math.max(0, current / maximum)) * 100).toFixed(1)}%`;
- value.textContent = `Current ${current.toFixed(3)}`;
+ const now = performance.now();
+ const signal = Number.isFinite(current) ? Math.min(maximum, Math.max(0, current)) : 0;
+ const previous = thresholdMeterPeaks.get(name) ?? {
+ value: 0,
+ holdUntil: 0,
+ updatedAt: now,
+ };
+ let peak = previous.value;
+ let holdUntil = previous.holdUntil;
+ if (signal >= peak) {
+ peak = signal;
+ holdUntil = now + 1200;
+ } else if (now > holdUntil) {
+ peak = Math.max(signal, peak - maximum * ((now - previous.updatedAt) / 2200));
+ }
+ thresholdMeterPeaks.set(name, { value: peak, holdUntil, updatedAt: now });
+
+ fill.style.width = `${(signal / maximum * 100).toFixed(1)}%`;
+ peakMarker.style.left = `${(peak / maximum * 100).toFixed(1)}%`;
+ peakMarker.style.opacity = peak > 0 ? "1" : "0";
+ value.textContent = Number.isFinite(current)
+ ? `Current ${current.toFixed(3)} · peak ${peak.toFixed(3)}`
+ : peak > 0
+ ? `Current — · peak ${peak.toFixed(3)}`
+ : "Current —";
}
function setupMinMatchesSlider() {
@@ -1674,12 +1697,16 @@ class PerformanceOverlay {
const threads = this.captureState?.numThreads ?? "—";
const mode = this.captureState?.inferenceMode ?? "—";
const resultGap = data?.resultGapMs ? formatMs(data.resultGapMs) : "—";
- const resultFps = data?.resultGapMs ? `${(1000 / data.resultGapMs).toFixed(1)} FPS` : "— FPS";
+ const observedFps = data?.resultGapMs ? (1000 / data.resultGapMs).toFixed(1) : "—";
+ const pipelineFps = timing.totalMs > 0 ? (1000 / timing.totalMs).toFixed(1) : "—";
+ const intervalMs = getScanIntervalMs();
+ const intervalCeiling = intervalMs > 0 ? (1000 / intervalMs).toFixed(1) : "max";
const score = Number.isFinite(data?.score) ? data.score.toFixed(3) : "—";
const card = data?.cardPresent ? (data.cornersValid ? "card" : "bad-quad") : "no-card";
const orientation = data?.orientation ? ` ${data.orientation}` : "";
this.el.textContent = [
- `minimum interval ${getScanIntervalMs()}ms result ${resultGap} ${resultFps}`,
+ `FPS observed ${observedFps} pipeline ${pipelineFps} interval ceiling ${intervalCeiling}`,
+ `minimum interval ${intervalMs}ms result gap ${resultGap}`,
`total ${formatMs(timing.totalMs)} det ${formatMs(timing.detectMs)} (run ${formatMs(timing.detectorRunMs)})`,
`dew ${formatMs(timing.dewarpMs)} (warp ${formatMs(timing.dewarpWarpMs)}) emb ${formatMs(timing.embedMs)} (run ${formatMs(timing.embedRunMs)})`,
`prep det ${formatMs(timing.detectorInputMs)} prep emb ${formatMs(timing.embedInputMs)} lookup ${formatMs(timing.searchMs)}`,
@@ -2050,6 +2077,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 +2195,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();
@@ -2189,6 +2229,10 @@ async function boot() {
// Wire up init-phase progress messages before posting 'init'.
const scannerReady = new Promise((resolve, reject) => {
+ const bundledCatalogBytes = Object.values(manifest.catalog?.asset_sizes ?? {})
+ .reduce((total, size) => total + (Number.isSafeInteger(size) && size > 0 ? size : 0), 0);
+ const displayProgress = createProgressTracker({ catalogMode, bundledCatalogBytes });
+
function onInitMessage({ data }) {
if (data.type === "progress") {
recordBootTrace("worker:progress", data, { debugOnly: data.ratio < 1 });
@@ -2204,19 +2248,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", {
@@ -2253,7 +2302,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/applet_example.css b/examples/web_scanner/applet_example.css
index 123b990..96e6848 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;
@@ -226,6 +244,19 @@ tr:last-child td { border-bottom: 0; }
transform: translateX(-1px);
}
+.scan-settings-meter__peak {
+ position: absolute;
+ top: 0.05rem;
+ bottom: 0.05rem;
+ left: 0;
+ width: 2px;
+ background: #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.7);
+ opacity: 0;
+ transform: translateX(-1px);
+ transition: left 80ms linear, opacity 80ms linear;
+}
+
.scan-settings-meter-value {
font-size: 0.76rem;
color: #81796f;
diff --git a/examples/web_scanner/applet_example.html b/examples/web_scanner/applet_example.html
index 4318ca3..5ab4b0b 100644
--- a/examples/web_scanner/applet_example.html
+++ b/examples/web_scanner/applet_example.html
@@ -47,7 +47,13 @@
- Candidate API
+
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,10 +65,11 @@ Scanner Playground
Scan settings
- Corner threshold 0.02
+ Minimum corner sharpness 0.02
+
Current 0.00
@@ -72,6 +79,7 @@ Scanner Playground
Match score Current —
+
diff --git a/examples/web_scanner/applet_example.js b/examples/web_scanner/applet_example.js
index d59e537..ef804b0 100644
--- a/examples/web_scanner/applet_example.js
+++ b/examples/web_scanner/applet_example.js
@@ -30,6 +30,16 @@ function resolveAssetChannel() {
const assetChannel = resolveAssetChannel();
const assetBasePath = ASSET_CHANNELS[assetChannel];
+document.getElementById("asset-channel").textContent = `${assetChannel} assets`;
+for (const link of document.querySelectorAll("a[data-preserve-channel]")) {
+ const url = new URL(link.href, location.href);
+ if (assetChannel === "testing") {
+ url.searchParams.set("channel", assetChannel);
+ } else {
+ url.searchParams.delete("channel");
+ }
+ link.href = url.href;
+}
const PRESETS = [
{
@@ -116,9 +126,11 @@ const presetSelect = document.getElementById("preset-code");
const cornerThresholdInput = document.getElementById("scan-corner-threshold");
const cornerThresholdLabel = document.getElementById("scan-corner-threshold-label");
const cornerSignalFill = document.getElementById("scan-corner-signal-fill");
+const cornerSignalPeak = document.getElementById("scan-corner-signal-peak");
const cornerSignalThreshold = document.getElementById("scan-corner-signal-threshold");
const cornerSignalValue = document.getElementById("scan-corner-signal-value");
const matchSignalFill = document.getElementById("scan-match-signal-fill");
+const matchSignalPeak = document.getElementById("scan-match-signal-peak");
const matchSignalThreshold = document.getElementById("scan-match-signal-threshold");
const matchSignalValue = document.getElementById("scan-match-signal-value");
const thresholdInput = document.getElementById("scan-threshold");
@@ -199,27 +211,51 @@ function updateCornerThresholdUi(value) {
cornerSignalThreshold.style.left = `${(ratio * 100).toFixed(1)}%`;
}
+const signalPeaks = new Map();
+
+function updateSignalMeter(name, current, maximum, fill, peakMarker, value) {
+ const now = performance.now();
+ const signal = Number.isFinite(current) ? clamp(current, 0, maximum) : 0;
+ const previous = signalPeaks.get(name) ?? {
+ value: 0,
+ holdUntil: 0,
+ updatedAt: now,
+ };
+ let peak = previous.value;
+ let holdUntil = previous.holdUntil;
+ if (signal >= peak) {
+ peak = signal;
+ holdUntil = now + 1200;
+ } else if (now > holdUntil) {
+ peak = Math.max(signal, peak - maximum * ((now - previous.updatedAt) / 2200));
+ }
+ signalPeaks.set(name, { value: peak, holdUntil, updatedAt: now });
+
+ fill.style.width = `${(signal / maximum * 100).toFixed(1)}%`;
+ peakMarker.style.left = `${(peak / maximum * 100).toFixed(1)}%`;
+ peakMarker.style.opacity = peak > 0 ? "1" : "0";
+ value.textContent = Number.isFinite(current)
+ ? `Current ${current.toFixed(3)} · peak ${peak.toFixed(3)}`
+ : peak > 0
+ ? `Current — · peak ${peak.toFixed(3)}`
+ : "Current —";
+}
+
function updateCornerSignal(confidence) {
- const raw = Math.max(0, Number(confidence) || 0);
- const current = clamp(raw, 0, MAX_GUI_CORNER_CONFIDENCE);
- const ratio = current / MAX_GUI_CORNER_CONFIDENCE;
- cornerSignalFill.style.width = `${(ratio * 100).toFixed(1)}%`;
- cornerSignalValue.textContent = raw > MAX_GUI_CORNER_CONFIDENCE
- ? `Current ${MAX_GUI_CORNER_CONFIDENCE.toFixed(2)}+`
- : `Current ${current.toFixed(2)}`;
+ updateSignalMeter(
+ "corner",
+ Number(confidence),
+ MAX_GUI_CORNER_CONFIDENCE,
+ cornerSignalFill,
+ cornerSignalPeak,
+ cornerSignalValue,
+ );
}
function updateMatchSignal(score) {
const threshold = clamp(Number(thresholdInput.value), 0, 1);
matchSignalThreshold.style.left = `${(threshold * 100).toFixed(1)}%`;
- if (!Number.isFinite(score)) {
- matchSignalFill.style.width = "0%";
- matchSignalValue.textContent = "Current —";
- return;
- }
- const current = clamp(score, 0, 1);
- matchSignalFill.style.width = `${(current * 100).toFixed(1)}%`;
- matchSignalValue.textContent = `Current ${current.toFixed(3)}`;
+ updateSignalMeter("match", score, 1, matchSignalFill, matchSignalPeak, matchSignalValue);
}
function scanSettingsFromInputs() {
diff --git a/examples/web_scanner/index.html b/examples/web_scanner/index.html
index 7aee0b8..7c0384b 100644
--- a/examples/web_scanner/index.html
+++ b/examples/web_scanner/index.html
@@ -112,8 +112,8 @@ CollectorVision
Debug
-
- ⚙
+
+ ⚙ Settings
@@ -220,7 +220,7 @@ Performance Overlay
Show scan timing overlay
- 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.
@@ -249,8 +250,8 @@ Model Benchmark
@@ -262,6 +263,7 @@ Match Threshold
min="0.40" max="0.85" step="0.01" value="0.50" />
+
Current —
@@ -275,6 +277,7 @@ Match Threshold
min="0" max="0.10" step="0.01" value="0.02" />
+
Current —
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/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;
}
diff --git a/examples/web_scanner/screen_capture_monitor.css b/examples/web_scanner/screen_capture_monitor.css
index e90adad..a1e7d86 100644
--- a/examples/web_scanner/screen_capture_monitor.css
+++ b/examples/web_scanner/screen_capture_monitor.css
@@ -416,6 +416,19 @@ input[type="range"] {
transform: translateX(-1px);
}
+.threshold-meter__peak {
+ position: absolute;
+ top: 0.05rem;
+ bottom: 0.05rem;
+ left: 0;
+ width: 2px;
+ background: #fff;
+ box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
+ opacity: 0;
+ transform: translateX(-1px);
+ transition: left 100ms linear, opacity 100ms linear;
+}
+
.threshold-meter__value {
color: #c8bbab;
font-size: 0.76rem;
diff --git a/examples/web_scanner/screen_capture_monitor.html b/examples/web_scanner/screen_capture_monitor.html
index 5c86e7f..456401c 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
- Scanner
- Playground
+ Scanner
+ Playground
Overlay
@@ -137,6 +138,7 @@ Recognition settings
+
Current —
@@ -150,10 +152,11 @@ Recognition settings
- Corner threshold
+ Minimum corner sharpness
+
Current —
diff --git a/examples/web_scanner/screen_capture_monitor.js b/examples/web_scanner/screen_capture_monitor.js
index de50980..be836b2 100644
--- a/examples/web_scanner/screen_capture_monitor.js
+++ b/examples/web_scanner/screen_capture_monitor.js
@@ -39,6 +39,7 @@ const els = {
versionCornelius: document.getElementById("version-cornelius"),
versionMilo: document.getElementById("version-milo"),
versionCatalog: document.getElementById("version-catalog"),
+ versionChannel: document.getElementById("version-channel"),
roiBox: document.getElementById("roi-box"),
stageStatus: document.getElementById("stage-status"),
workerStatus: document.getElementById("worker-status"),
@@ -54,9 +55,11 @@ const els = {
scanIntervalValue: document.getElementById("scan-interval-value"),
cornerThreshold: document.getElementById("corner-threshold"),
matchSignalFill: document.getElementById("match-signal-fill"),
+ matchSignalPeak: document.getElementById("match-signal-peak"),
matchSignalThreshold: document.getElementById("match-signal-threshold"),
matchSignalValue: document.getElementById("match-signal-value"),
cornerSignalFill: document.getElementById("corner-signal-fill"),
+ cornerSignalPeak: document.getElementById("corner-signal-peak"),
cornerSignalThreshold: document.getElementById("corner-signal-threshold"),
cornerSignalValue: document.getElementById("corner-signal-value"),
groupSecondary: document.getElementById("group-secondary"),
@@ -107,6 +110,17 @@ function resolveAssetChannel() {
init();
async function init() {
+ const assetChannel = resolveAssetChannel();
+ els.versionChannel.textContent = assetChannel;
+ for (const link of document.querySelectorAll("a[data-preserve-channel]")) {
+ const url = new URL(link.href, location.href);
+ if (assetChannel === "testing") {
+ url.searchParams.set("channel", assetChannel);
+ } else {
+ url.searchParams.delete("channel");
+ }
+ link.href = url.href;
+ }
applySettingsToInputs();
applyRoiToBox();
bindUi();
@@ -453,20 +467,40 @@ function isAcceptedDetection(data) {
&& score >= settings.matchThreshold;
}
+const thresholdMeterPeaks = new Map();
+
function updateThresholdMeter(name, threshold, current, maximum) {
const fill = els[`${name}SignalFill`];
+ const peakMarker = els[`${name}SignalPeak`];
const marker = els[`${name}SignalThreshold`];
const value = els[`${name}SignalValue`];
const thresholdRatio = clamp(threshold / maximum, 0, 1);
marker.style.left = `${(thresholdRatio * 100).toFixed(1)}%`;
- if (!Number.isFinite(current)) {
- fill.style.width = "0%";
- value.textContent = "Current —";
- return;
+ const now = performance.now();
+ const signal = Number.isFinite(current) ? clamp(current, 0, maximum) : 0;
+ const previous = thresholdMeterPeaks.get(name) ?? {
+ value: 0,
+ holdUntil: 0,
+ updatedAt: now,
+ };
+ let peak = previous.value;
+ let holdUntil = previous.holdUntil;
+ if (signal >= peak) {
+ peak = signal;
+ holdUntil = now + 1200;
+ } else if (now > holdUntil) {
+ peak = Math.max(signal, peak - maximum * ((now - previous.updatedAt) / 2200));
}
- const currentRatio = clamp(current / maximum, 0, 1);
- fill.style.width = `${(currentRatio * 100).toFixed(1)}%`;
- value.textContent = `Current ${current.toFixed(3)}`;
+ thresholdMeterPeaks.set(name, { value: peak, holdUntil, updatedAt: now });
+
+ fill.style.width = `${(signal / maximum * 100).toFixed(1)}%`;
+ peakMarker.style.left = `${(peak / maximum * 100).toFixed(1)}%`;
+ peakMarker.style.opacity = peak > 0 ? "1" : "0";
+ value.textContent = Number.isFinite(current)
+ ? `Current ${current.toFixed(3)} · peak ${peak.toFixed(3)}`
+ : peak > 0
+ ? `Current — · peak ${peak.toFixed(3)}`
+ : "Current —";
}
function candidateFromResult(data) {
diff --git a/examples/web_scanner/style.css b/examples/web_scanner/style.css
index 66c0a19..796a7eb 100644
--- a/examples/web_scanner/style.css
+++ b/examples/web_scanner/style.css
@@ -192,9 +192,11 @@ body[data-loading="true"] .loading-screen {
}
.icon-button--gear {
- font-size: 1.25rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
line-height: 1;
- padding: 0.35rem 0.5rem;
+ padding: 0.5rem 0.65rem;
}
.camera-shell {
@@ -336,7 +338,7 @@ h2 {
top: 0.65rem;
right: 0.65rem;
z-index: 3;
- width: min(26rem, calc(100% - 1.3rem));
+ width: min(42rem, calc(100vw - 1.3rem));
padding: 0.55rem 0.65rem;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 0.75rem;
@@ -345,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);
}
@@ -690,6 +693,19 @@ dd {
transform: translateX(-1px);
}
+.threshold-meter__peak {
+ position: absolute;
+ top: 0.06rem;
+ bottom: 0.06rem;
+ left: 0;
+ width: 2px;
+ background: #fff;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.65);
+ opacity: 0;
+ transform: translateX(-1px);
+ transition: left 100ms linear, opacity 100ms linear;
+}
+
.threshold-meter__value {
margin: 0.3rem 0 0;
color: var(--muted, #665f56);
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");