Skip to content

feat(inference): add browser point suggestions to the editor - #875

Merged
JArmandoAnaya merged 42 commits into
mainfrom
feat/browser-suggestion-integration
Sep 17, 2026
Merged

JArmandoAnaya merged 42 commits into
mainfrom
feat/browser-suggestion-integration

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Summary

  • Browser-side point suggestion (EfficientSAM-Ti, via @visionset/browser-inference) now runs end to end in the OSS editor, alongside the unchanged server suggestion path. A person chooses "Server" or "This device" per project.
  • A ready browser target is answerable even with no server connections configured, and vice versa — target-aware blockers, not connection-aware ones.
  • frontend/app is the only package that knows about models.robomous.ai, ONNX Runtime, or SHA-256 verification; ui-core and @visionset/annotator stay vendor- and runtime-free, machine-enforced by two boundary-test files (ui_core_boundary.test.mjs, cdn_vendor_boundary.test.mjs).
  • Model weights are fetched only after an explicit "Download to this browser" action, byte-count- and SHA-256-verified against the pinned EfficientSAM-Ti release before any target is exposed — never against the manifest's own self-reported hashes.
  • A negative-point ask on a browser target refuses deterministically: no HTTP call, no silent fallback to Server.
  • One image encode per displayed asset regardless of refinement clicks; an asset switch mid-flight can never let a stale embedding answer for the wrong asset (race-safety proven by dedicated tests, independently mutation-verified).
  • model_ref provenance is immutable and survives acceptance unchanged, regardless of execution provider.
  • The chooser UI uses the real @robomous/ui-core design system (Tabs, Badge) — no ad hoc controls, and no component the design system doesn't actually ship (it has no radio-group/checkbox, so Server/This-device is a real segmented Tabs, not radio buttons).
  • Explicitly out of scope (deferred to a future phase): a general model registry/discovery UI, browser-storage persistence of downloaded weights across reloads, revision garbage collection, reactive multi-model acquisition state.

Real-world verification (not just mocked tests)

This branch required an unusually long real-world validation pass, because several defects only manifested outside of mocks:

  • Real CDN acquisition, verified against the live models.robomous.ai release: the actual deployed manifest schema didn't match this code's first assumption (nested artifacts.{encoder,decoder}.path, not a flat shape) — found via a live smoke test, fixed, and re-verified against the real CDN twice more through follow-up hardening (manifest shape validation, path-traversal guard, a decisive test proving verification never trusts the manifest's own hashes).
  • Real ONNX Runtime in a real browser: the hermetic Playwright suite (frontend/app/e2e/browserSuggestion.spec.ts) was run for the first time against a real, exported EfficientSAM-Ti model. This surfaced a pre-existing bug in @visionset/browser-inference's worker (a bundler asset-path issue only visible when served through Vite's dev server, as the real app does) and a test-fixture bug (a 1×1 placeholder image, invisible until the model started validating real pixel bounds). Both fixed; suite is now genuinely 7/7 against a real model, independently reproduced twice.
  • Real production build: fixing the browser bug above surfaced that a real vite build of frontend/app didn't ship the ONNX WASM assets at all — the feature would have 404'd in any real deployment despite passing every dev-mode test. Fixed with a small Vite plugin that derives the assets from @visionset/browser-inference's own build output at build time; proven by actually building the app and serving the output over HTTP.
  • Real wheel packaging: closing the gap above surfaced that the wheel's existing size ceiling was already silently blown by the new WASM payload. Given a dedicated, bounded budget (separate from the general "nothing megabyte-sized snuck in" guard) plus a new regression test in tests/packaging/test_wheel.py, proven by an actual delete/rebuild/fail, restore/rebuild/pass cycle.

Known trade-offs for human review

  • ~6.4 MB (compressed) / ~25.8 MB (installed) of ONNX Runtime now ships in every pip install visionset wheel, for a feature most users won't invoke. The model weights themselves are already fetched on demand from the CDN, so bundling the runtime is a choice, not a network-independence requirement — serving it from the same CDN is a viable alternative if wheel size becomes a concern. Not blocking this PR.
  • Browser and server can now legitimately produce different masks for the same asset and click. Browser-canvas image decoding and the server's Pillow decoding diverge on EXIF auto-rotation, ICC color management, and alpha-premultiplication rounding — previously an unreconciled but inert difference (Phase E), now live because the browser executor is the first real consumer of decoded pixels for inference. Documented in docs/content/architecture/frontend/ui-core.md.
  • MAX_WHEEL_BYTES's headroom is down to ~1.45× after this branch (from the app bundle's own growth, not this feature) and will likely need raising within a release or two.
  • Downloaded model bytes do not persist across a page reload (by design, deferred to a future phase) — every reload requires re-downloading before "This device" works again, and a stale "This device" preference silently falls back to Server on reload rather than erroring.
  • A few narrow, low-traffic refusal paths (a stale-lease read, a >6-point refinement past EfficientSAM-Ti's cap) still surface slightly-off wording rather than the smooth deterministic Server/browser distinction the more common refusal paths now have. Not blocking.

Test plan

  • All 10 named mutation classes (browser-click-sends-HTTP, skip-SHA256, reuse-stale-PreparedImage, re-encode-every-click, dropped-tolerance, wrong-model_ref, silent-fallback, auto-download, server-click-enters-browser-executor, server-path-regression) planted and independently killed; a whole-branch mutation audit re-ran two of them from scratch and confirmed identical results.
  • pnpm test:scripts, all four frontend package suites (ui-core 1424, annotator 1960, browser-inference 129, app 100), and all typechecks green.
  • bash scripts/check.sh and bash scripts/check.sh docs green.
  • frontend/browser-inference's existing real-model Playwright suite: 17/17, real ONNX Runtime, mask outputs SHA-256-identical to the Python reference.
  • New browserSuggestion.spec.ts hermetic suite: 7/7 against a real, exported EfficientSAM-Ti model (independently reproduced twice).
  • Real CDN acquisition smoke-tested against the live models.robomous.ai release (manifest, encoder, decoder — byte counts and SHA-256 hashes all confirmed).
  • Real vite build + real wheel build verified to actually ship and correctly serve the ONNX assets.
  • Final whole-branch review (two passes: findings, then a scoped re-review of the fixes) — both critical/important findings independently mutation-verified by the reviewer itself, not taken on trust.

… target

A stored suggest-target preference naming a browser target that listTargets()
no longer reports is the ordinary shape of every reload for someone who
picked "This device" last time, since acquired model bytes are never
persisted across page loads. It must revert to Server without a visible
error. An explicit in-session choice that later drops out is different and
must keep surfacing through blocker/refusal, never auto-switch back.
… state

Also adds the missing @visionset/browser-inference workspace dependency
to frontend/app/package.json (and the resulting pnpm-lock.yaml update),
without which BrowserInferenceRuntime.ts's import could never resolve.
annotate.spec.ts's stubbed API route table was module-private, so a second
Playwright spec had no way to reuse it without duplicating ~440 lines.
Moved verbatim into frontend/app/e2e/_wireApiStub.ts, exporting the pieces
other specs and annotate.spec.ts's own remaining tests need (serveApi,
openJob, PROJECT/BATCH/JOB, progressStore, openedWorld); annotate.spec.ts
now imports them instead of defining its own copy.
- Test 7 asserted aria-selected on suggest-target-browser while the refused
  status has TargetChooser unmounted; press Escape first to drop back to idle,
  which remounts the chooser without touching activeTarget.
- Split acquireAndSelectBrowserTarget into an arming step and a non-arming
  acquire/select step, since toggleSuggest() clears the whole session on a
  second press with the tool already armed (test 3 was double-arming it).
- Registered the model-request listener before navigating in the "never
  fetched before Download" test, and added a mid-test assertion that merely
  switching to the device tab is also zero requests.
- SHA-256 mismatch test now uses a correctly-sized but wrong-content buffer,
  so the size check passes and the SHA-256 check is what actually fails.
- Added a catch-all abort route for the model CDN host ahead of the specific
  fixture routes, so anything unmatched hard-fails instead of reaching the
  real network.
- Removed the routing-paragraph duplicated between annotate.spec.ts and
  _wireApiStub.ts, keeping it only where the route table now lives.
…gression test discriminate

The executor guard re-derived "is this target in browserTargets" a second
time, independently of computeSuggestBlocker's own answer to the same
question - the same shape of bug this file just fixed once already. Derive
it from blocker instead, so the two can no longer silently drift apart.

The regression test's "no fallthrough to the server" assertion also could
not actually fail: it checked synchronously right after the click, before
an async mutateAsync dispatch would have reached the sent log, and nothing
in the test setup guaranteed a genuinely usable server connection existed
to fall through to in the first place. Wait for the server tab to resolve
ready, and flush a tick before asserting.
…evision

Mutation testing found this gap: hardcoding MODEL_REF to drop
EFFICIENT_SAM_TI_REVISION left all existing tests green, silently
corrupting the provenance carried into accepted annotations. The new
test imports EFFICIENT_SAM_TI_REVISION independently from manifest.js
and checks the exact expected string, so it fails if either the
constant or the implementation drifts.
… shape

A real-CDN smoke test found the live manifest nests artifacts under
"artifacts" with bare relative filenames, not the flat shape this suite's
mockCdn assumed (manifest.ts/acquireEfficientSam.ts already fixed upstream).
Updates the manifest stub and the encoder/decoder route patterns, including
the SHA-mismatch test's own re-route, to match.
…rd artifact paths

- Extract EFFICIENT_SAM_TI_BASE_URL so the manifest and artifact URLs share one
  source for the /models/efficient-sam-ti/<revision>/ prefix instead of restating it.
- Strip a trailing slash from MODEL_CDN_BASE_URL to avoid a double slash.
- Validate the parsed manifest's artifacts.{encoder,decoder}.path before use, so a
  future CDN schema drift throws a diagnosable error instead of a bare TypeError.
- Reject a manifest artifact path containing a slash before building its URL.
- Add tests proving verification never reads the manifest's own bytes/sha256, the
  path-traversal guard fails closed, and the manifest-shape validation works.
…URL rewriting

Vite's `import.meta.url`-relative asset transform rewrites the literal argument of
`new URL("./ort/", import.meta.url)` and drops the trailing slash, so `wasmPaths`
arrived as `.../dist/browser/ort`. ORT then resolved its glue module as a *sibling*
of `ort` rather than a file inside it; a dev server answers that 404 with its SPA
fallback, so the dynamic import received HTML, both execution providers failed to
initialize and every session load reported `graph-load-failed`.

The trailing slash is now re-applied to the resolved string instead of being
trusted to the literal, which holds for any bundler and any host.
The suite resolves `@visionset/browser-inference` through its `dist/`, exactly as it
does the three packages already built here, so an unbuilt change there was invisible
in the browser and the run silently depended on that `dist/` happening to be current.
_wireApiStub.ts's /content route always serves a real-but-1x1 PNG, which is
fine for annotate.spec.ts's ~150 tests (AnnotatorCanvas lays the picture out
at the asset's declared width/height, never its own naturalWidth) but breaks
this suite: the browser executor validates click coordinates against the
image's actual decoded dimensions, so every click landed "outside" a 1x1
image and was correctly refused.

Adds a small dependency-free PNG encoder and overrides just this file's
/content route with a real 640x480 image, matching the stub's asset
metadata and the coordinate frame the existing click math already assumes.
Scoped to this file rather than changing _wireApiStub.ts's shared default.

First real green run against the model fixture: 7/7 passed.
…function it describes

It had drifted above `withTrailingSlash`, the helper it only mentions in its
last paragraph, and away from `assetsAt`, which is what actually decides where
the ORT artifacts are fetched from.
A production `vite build` emitted no `ort/` directory at all. The worker's
default resolution — `./ort/` against its own module URL — is right for the
package as installed, but rolldown hashes the worker into `assets/`, so the
built bundle asked for `/app/assets/ort/ort-wasm-simd-threaded.asyncify.mjs`,
which no build writes. The SPA fallback answers that with HTML, the dynamic
import of the glue fails, and every execution provider fails to initialize —
a feature that works under the dev server and 404s in every real deployment.

A vite plugin now copies `@visionset/browser-inference`'s own
`dist/browser/ort/` into the build output at `ort/`, and serves the same URL
from a dev middleware, so both modes answer the one address the app states
through `assetBaseUrl`: `<base>ort/`, absolute against the document, since
`BASE_URL` is the only thing that knows the wheel mounts the bundle at `/app/`.
The bytes are read from the dependency's build output at build time, so an
ONNX Runtime bump needs nothing here.
The production fix shipped with nothing that would notice its removal: delete
the `ortAssets()` plugin and tsc, lint, 96 app tests, 129 browser-inference
tests and the dev e2e suite all stay green while `/app/` serves an app whose
first "this device" suggestion fails inside a worker. The wheel suite already
builds the frontend, so the guard is one assertion against output that exists.

Counting the wheel's size had to change with it. `_static/ort/` is ~6.4 MB
deflated, against a 2 MB ceiling whose stated job is catching the day a
`node_modules/` or a fixture video gets swept in. Raising that ceiling past
8 MB would have retired the guard to admit one file, so the ORT runtime is
excluded from it and bounded separately instead: the rest of the wheel is
still held to 2 MB, and a version bump that doubles ONNX Runtime now reads as
a sentence about ONNX Runtime.

Also in the plugin, two ways it could fail unhelpfully: an unhandled `error`
on the dev middleware's read stream took down the whole dev server, which
`tsup`'s `clean: true` makes reachable by rebuilding browser-inference while
the server is up; and a subdirectory under `dist/browser/ort/` would have
surfaced as an opaque EISDIR from `readFile` rather than as the missing-assets
message written for it.
The comment still described a ~570 KB wheel with a ~640 KB bundle and "room for
the UI to roughly triple", which was stale twice over: the constant now counts
everything but the ORT runtime, and the app bundle has since grown to ~1.19 MB
uncompressed on its own. The counted part is ~1.45 MB compressed, so the real
headroom is about 1.45x — worth saying plainly, because a reader who trusts
"roughly triple" will misjudge how close the next UI addition puts them to a
ceiling they would then raise without noticing they were the reason.

No threshold or behaviour change.
…ming ready

Three things the composition root was getting wrong about its own lifecycle.

**One encode per asset.** `createBrowserSuggestionExecutor`'s one-embedding slot is
the only encode cache there is, and it lives in that closure. `executorFor` built a
fresh executor per call, `AnnotationPage` calls it during render, and it re-renders
on every click — so every refinement click re-ran the full encoder pass, silently
defeating the design's "one encode per asset, N decodes per N refinements". The
executor is now memoized beside the runtime it wraps, which keeps the invariant true
whatever ui-core's render behaviour does.

**Ready means the session exists.** `createRuntime` returns before the worker has
loaded a graph; `ready()` is what waits for that. Claiming ready on the constructor
alone listed a target whose every click then refused — and a listed target hides the
Download control, so the retry path went with it. `ready()` is now awaited, a
rejection leaves the state unacquired and retryable, and the failed runtime is
disposed rather than leaked.

**No download that can only fail.** `listAcquisitions()` consults the package's own
capability check first: a browser with no Worker or no WebAssembly is offered
nothing, which is the port's own rule one level down.
…not be reached"

Every refusal this executor raised besides the negative-point one was a bare
`Error`, and `refusalProse` routes a non-`ApiError` through `asApiError`, which
stamps it `NETWORK_ERROR` — whose prose blames a server that was never asked.

Three codes, each with prose that says what actually happened on this device:
`BROWSER_ASSET_CHANGED` for the missing/mismatched source and both staleness
checks, `BROWSER_INFERENCE_UNAVAILABLE` for a runtime that could not start, and
`BROWSER_INFERENCE_FAILED` for a run that did not answer. The runtime's own errors
are mapped through an exhaustive table over `InferenceRuntimeErrorCode`, so a code
added to that package stops compiling here until somebody decides what a person
should be told about it.

`refusals.test.ts` holds the "never says the server" assertion for all three, which
is the regression that would otherwise reopen silently.
`browser-models` named only `browserSuggestion.spec.ts`, not the code that suite
guards. A change to the composition root, the vite or playwright config, or the
ui-core inference seam and panels woke only the `frontend` job — where that suite
self-skips, having neither the ONNX artifacts nor `VISIONSET_REQUIRE_BROWSER_MODELS`.
So the PRs most in need of real-model coverage were the ones least likely to get it.

`ci_path_filters.test.mjs` now asserts each of those paths wakes both groups.
… a dead click

`computeSuggestBlocker` was exported from the package root and consumed by nobody
outside it — `AnnotationPage` imports it relatively. An internal helper on the
public surface is a contract nobody asked for.

And with "This device" selected before any download, the idle card still read
"Click the thing you want" over a click that is a silent no-op, since there is no
executor for an unready browser target. That one state now points at the Download
button instead. Every other state's copy is untouched.
Four passages in `architecture/frontend/ui-core.md` had gone false. No host supplied
a runtime (`frontend/app` now does, unconditionally); the port was two members wide
(it is four — two required, two optional and additive); no capability let a host ask
for local inference (one does); and no consumer of the pixel lease had needed to
reconcile browser-canvas-vs-Pillow decode divergence.

That last one is the important one, and it is now stated as a live limitation rather
than a future concern: the browser suggestion executor feeds `readRgb` straight to a
model, so Server and "This device" can legitimately return different masks for the
same asset and the same click — and for an asset carrying EXIF orientation or an
AdobeRGB profile the difference is not marginal.

`ui.md`'s suggest section described only the server path. It now covers the two
tabs, the explicit ~41 MB download, that the download does not survive a reload, and
that Alt-click needs Server.
frontend/app now imports @visionset/browser-inference and its /browser
subpath, both resolved through its generated dist/. The cycle suite's
webServer build sequence built every other workspace dependency first
but never this one, so app's build failed with TS2307 on a clean clone
(GitHub Actions run 1261) even though it passed locally wherever an
earlier, unrelated command had already built browser-inference.
…he decode's

`onImageReady` built the `BrowserSuggestionAssetSource` from the decoded
`<img>`'s `naturalWidth`/`naturalHeight`. Everything that source meets is in
the *descriptor's* pixels instead: the click points `suggestAt` sends, the
geometry `shapesFromMask` returns, and the annotations already on the frame.
`AnnotatorCanvas` has always laid the picture out that way — this is the same
rule one layer on.

A decode that disagrees — EXIF orientation transposing the axes, a preview
served in place of the original — therefore had the executor bound-check
clicks and extract pixels against a second, private frame, and answer with
suggestions that are individually plausible and uniformly wrong. Nothing
crashes and nothing is logged, which is what makes it worth a test.

The regression test makes the two frames disagree by a transposition, because
a fixture where they coincide cannot tell them apart — which is exactly how
this survived the suite that shipped it. It claims the extent the executor
bound-checks against *and* what `readRgb` asks the decoder for; mutating
either one alone fails it.

The e2e header's account of why it serves a real, correctly-sized asset image
was written against the old behaviour and is corrected here: the bounds now
agree either way, and what a 1x1 `/content` still gets wrong is the pixels.
@JArmandoAnaya
JArmandoAnaya force-pushed the feat/browser-suggestion-integration branch from b1b8f0f to c49ea85 Compare September 17, 2026 18:06
@JArmandoAnaya
JArmandoAnaya merged commit b3b4796 into main Sep 17, 2026
32 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/browser-suggestion-integration branch September 17, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant