Start with the Mreyti SDK setup guide → — prepare your SDK, publish or host it, configure a customer website, and download a complete HTML example. This page is the detailed API reference.
Version 0.1.0. The API below ships and is verified to install and typecheck against a clean project. The geometry it renders — where frames sit, how temple arms are hidden behind the head, the metric scale — has never been checked against a recorded fixture. Ship it as a preview, not as a fitting tool.
01Requirements
| HTTPS | Mandatory. getUserMedia refuses any insecure origin. localhost is exempt for development. |
|---|---|
| WebGL2 | For lens refraction. Without it the lens degrades to a tinted surface rather than vanishing. |
| Browsers | Use a current browser with camera access and WebGL2; verify your target devices before release. |
| Bandwidth | ~6.4–6.5 MiB gzipped for the external tracker assets, fetched on Start. The standalone script plus tracker is approximately 7.13 MiB; the project’s 5 MiB release budget is currently exceeded. |
On a plain HTTP origin — or behind a certificate the browser doesn't trust — the page loads, the button works, and the camera request is silently rejected. It looks like a broken widget rather than a configuration problem. See Deploying.
02Install
npm install @pykero/eyewear-vto@preview three @mediapipe/tasks-vision
three and @mediapipe/tasks-vision are peer dependencies, so a host already using three.js doesn't ship a second copy of it — three alone is ~170 kB gzipped.
03Script quick start
Use the standalone script (IIFE) for an HTML page, CMS, or website builder that permits custom JavaScript. No framework or npm installation is needed on the receiving website. The SDK package is still named @pykero/eyewear-vto.
1. Get the SDK file
To use this repository’s current fixes, build the SDK from the repository root:
npm ci
npm run build:lib
Upload dist-lib/eyewear-vto.iife.js to your website as /vendor/mreyti/eyewear-vto.iife.js. This is a file you must deploy; the path below is not a hosted Mreyti service. Published package previews may be older than the repository.
2. Paste this into your page
<!-- Place this where the mirror should appear. -->
<eyewear-tryon sku="DEV-48" style="display:block; width:100%; max-width:640px"></eyewear-tryon>
<script defer src="/vendor/mreyti/eyewear-vto.iife.js"></script>
Load the script once per page. DEV-48 is an included sample frame, so this example works without a catalogue. Serve the page over HTTPS, or localhost for development. The widget shows its own Start button and asks for camera access only after it is pressed.
The widget JavaScript downloads when the script loads; the external tracker model and WASM download on Start. The .iife.js file includes its JavaScript dependencies. Do not substitute eyewear-vto.js: that ESM build expects a bundler or an import map resolving its dependencies.
3. Verify the embed
Check that the SDK request returns JavaScript with HTTP 200, the Start button appears, and your camera opens after permission. Close or remove the widget and check that the camera indicator turns off. Test on your target phone as well: controls at 320 px are a known unresolved layout issue.
For your products, register frame specifications and model URLs through setFrames() before starting, then select a registered SKU. Set up both tracker asset paths if you want them served from your own domain.
Where to put it
| Website | Integration |
|---|---|
| Plain HTML | Paste the element into the page body and load the script once. |
| WordPress / WooCommerce | Load the SDK through your theme’s script setup and place the element in a product template or custom HTML block. Your editor must allow the custom element. |
| Shopify | Upload the SDK as a theme asset, load it once in the layout, and place the element in the product section. Register your catalogue before selecting the product’s SKU. |
| Webflow / other builders | Use a custom HTML embed for the element and a custom-code area for the script. A plan and editor that permit JavaScript are required; verify on the published page. |
| React / Vue / other bundled apps | Use the npm/ESM route below. Import only in the browser for server-rendered apps. |
If a builder runs your embed inside an iframe, the parent must permit camera use in its Permissions Policy and iframe allow="camera" attribute. A script cannot override a host that blocks cameras or strips custom HTML. See camera security requirements.
npm / ESM SDK
import '@pykero/eyewear-vto'
await customElements.whenDefined('eyewear-tryon')
const el = document.querySelector('eyewear-tryon')
// Set your catalogue before selecting a product SKU or starting.
// el.setFrames(yourFrames)
The element markup is the same for both approaches. When calling methods from a separate script, wait for customElements.whenDefined('eyewear-tryon'); a deferred script may not have registered the element yet. Call el.stop() when hiding a modal; removing the element also releases the camera.
Open the integration playground to inspect attributes, events and catalogue selection, or try the Mreyti mirror.
React
import { useEffect, useRef } from 'react'
import '@pykero/eyewear-vto'
export function TryOn({ frames, sku }) {
const ref = useRef(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.setFrames(frames) // property, not attribute
const onCal = (e) => console.log('PD', e.detail.pdMm)
el.addEventListener('vto-calibrated', onCal)
return () => el.removeEventListener('vto-calibrated', onCal)
}, [frames])
return <eyewear-tryon ref={ref} sku={sku} />
}
Arrays and objects must be set as properties. React 18 and below serialise unknown attributes to strings, so the ref approach is required there.
Vue
// vite.config.js — otherwise Vue warns on every render
vue({ template: { compilerOptions: { isCustomElement: (t) => t === 'eyewear-tryon' } } })
Next.js and other SSR
The component touches window and customElements at import, so load it client-side only:
useEffect(() => { import('@pykero/eyewear-vto') }, [])
04Your catalogue
el.setFrames([
{
spec: {
sku: 'ACME-52',
name: 'Acme Round',
lensWidthMm: 52, // the "52" in 52□18 145
bridgeMm: 18, // the "18"
templeLengthMm: 145, // the "145"
lensHeightMm: 42,
rimThicknessMm: 3,
frontWidthMm: 138, // hinge to hinge across the front
},
modelUrl: '/frames/acme-52.glb',
modelUrlLow: '/frames/acme-52-low.glb', // optional, used past ~1.1 m
},
// No model yet? Still listable — renders procedurally from its millimetres.
{ sku: 'ACME-58', name: 'Acme Wide', lensWidthMm: 58, bridgeMm: 18,
templeLengthMm: 145, lensHeightMm: 44, rimThicknessMm: 3, frontWidthMm: 148 },
])
The frame is drawn at the size you declare, against a head measured in the same units. Get frontWidthMm wrong and the frame is wrong on every face — consistently and invisibly, because a wrongly-sized frame still looks like a plausible pair of glasses. Take the numbers off the temple arm, not off a marketing page.
Frames are never scaled to fit the detected face. That is the whole product: a 48 mm frame that looks too narrow on a wide face is the system telling the truth.
05Preparing models
| Format | glTF 2.0 — KHR_materials_transmission, Meshopt, KTX2/Basis |
|---|---|
| Scale | Millimetres. A 138 mm frame spans 138 units. |
| Origin | Centre of the bridge, rear face of the front |
| Axes | +X model's left, +Y up, +Z out of the face |
| Lighting | Runtime lighting only. Do not bake lighting into frame textures. |
| Lenses | Remove them — the renderer supplies lenses at CR-39 index |
| Budget | ≤40k triangles high LOD, ≤8k low |
Your model is measured, and refused if it disagrees
| Check | Tolerance |
|---|---|
Front width vs frontWidthMm | ±1.5 mm |
| Unit ratio — 1000×, 100×, 10× and inverses | reported as a unit error, with the fix |
Height vs lensHeightMm + 2·rimThicknessMm | −2.5 mm |
Depth vs templeLengthMm | ≥ 50% |
A failing model falls back to the procedural frame and fires an event. It is never silently rescaled — a wrongly-scaled model is internally consistent, so rescaling would hide the fault instead of fixing it.
el.addEventListener('vto-model-rejected', (e) => {
console.warn(e.detail.sku, e.detail.reason)
// "Model measures 138000.0 mm across, 1000x its declared 138 mm. This is a
// unit error in the export, not a bad scan — glTF is metres by convention
// and this project's scene is millimetres."
})
Wire that into your logging. It's the difference between noticing a bad export and shipping a catalogue where one SKU is quietly the wrong size.
06Self-hosting the tracker
By default the face tracker's runtime and model come from public CDNs. Fine for evaluation, wrong for production: your try-on then depends on a third party staying up, and fires a cross-origin request the moment a shopper opens it.
mkdir -p public/vendor/mediapipe
cp -r node_modules/@mediapipe/tasks-vision/wasm public/vendor/mediapipe/
curl -o public/vendor/mediapipe/face_landmarker.task \
https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
<eyewear-tryon
wasm-base="/vendor/mediapipe/wasm"
model-url="/vendor/mediapipe/face_landmarker.task"
></eyewear-tryon>
A half-configured pair silently mixes a self-hosted runtime with a CDN model — the worst of both. The wasm directory holds SIMD and non-SIMD builds; the browser downloads only the variant it needs. Serve it with long-lived cache headers.
07Measurement
el.calibrate() // shows the card outline
const pd = el.measure() // null if the solve was rejected
el.pdMm // card-referenced PD in mm, or null
el.clearCalibration() // deletes the stored measurement
The shopper holds any bank card — ISO/IEC 7810 ID-1, 85.60 × 53.98 mm — flat against their forehead and moves until it fills the on-screen outline. That gives one known length in frame, which is what makes true scale solvable at all.
Until they do it, frames are drawn against a canonical head: the right shape at an average size. Say so in your UI rather than presenting an uncalibrated session as a measurement.
08API
Methods
start() | Loads the tracker and opens the camera. Returns a promise. |
stop() | Releases the camera and tears down the scene. |
setFrames(frames) | Replace the catalogue. Specs and model entries, mixed freely. |
calibrate() | Show the card guide. |
measure() | Take the measurement. Returns PD in mm, or null. |
clearCalibration() | Remove the stored measurement from this device. |
capture(type?) | A still of the try-on as a data URL. |
Properties and attributes
sku | Current SKU. Also an attribute. |
frames | The catalogue, read-only. |
pdMm | Card-referenced PD, or null. |
assetUrls | Self-hosted tracker assets. Also wasm-base / model-url. |
auto-start | Attribute. Starts without a click — a bad default on a product page. |
Events
| Event | Fires when | detail |
|---|---|---|
vto-ready | Camera open, tracking running | { fixture } |
vto-error | Camera denied, or tracker failed to load | { message } |
vto-tracking-changed | Face found or lost | { tracking } |
vto-calibrated | Measurement accepted | { pdMm, metricScale } |
vto-calibration-rejected | Solve outside plausible bounds | {} |
vto-model-rejected | A model failed QA; stand-in showing | { sku, reason, qa } |
vto-frame-changed | SKU changed | { sku } |
vto-capture | capture() produced a still | { dataUrl, type } |
Styling
Everything lives in a shadow root, closed to your CSS on purpose — product pages have opinionated resets. One hook is exposed:
eyewear-tryon::part(controls) { background: none; }
09Deploying
Any static host works — the build is plain files. What matters is TLS.
The repo ships a Dockerfile: a two-stage build serving dist/ from non-root nginx on port 8080, 25 MB, with a healthcheck, running on a read-only filesystem.
Behind a reverse proxy
- Route to container port 8080. The image runs as uid 101, and unprivileged processes cannot bind ports below 1024.
- Terminate TLS with a publicly trusted certificate. Self-signed is not enough — browsers block the camera.
- Let's Encrypt HTTP-01 validation needs port 80 open, even though the site serves on 443.
Behind Cloudflare
A proxied (orange-cloud) record intercepts the ACME challenge, so your origin can never obtain a Let's Encrypt certificate. Set the record to DNS only while issuing, then re-proxy if you want.
With SSL/TLS mode Full (strict) against an untrusted origin certificate, Cloudflare returns 502 without ever fetching your working page. Give the origin a real certificate, or use mode Full. Never Flexible — it shows a padlock while the Cloudflare-to-origin hop is plain HTTP.
10Troubleshooting
| Symptom | Cause |
|---|---|
| Page loads, camera never starts | Not a secure context — HTTP, an untrusted cert, or an IP/:port URL |
NotAllowedError | Permission denied, or a Permissions-Policy header blocks camera. In an iframe you need allow="camera". |
| Frames look right but too small or large | Uncalibrated — drawn against a canonical head |
| One SKU is the wrong size | Failed dimensional QA and fell back. Listen for vto-model-rejected. |
| Frames render black | No environment map, or WebGL2 unavailable — metal has no diffuse term |
| Two pairs of glasses visible | Shopper is wearing their own. Known and accepted. |
| Frames drift past ~45° of yaw | Tracking degrades there by design; pose is damped rather than allowed to swim |
| 502 from a CDN | Origin certificate untrusted — see Deploying |
11Privacy
No video frame, landmark, or derived measurement leaves the browser. No network call in this package carries anything derived from the camera. The preview downloads software and model assets; self-hosting moves those downloads to your own origin. Never send camera captures, landmarks or measurements to analytics, logging or external services.
Calibration stores a versioned scalar solve and camera intrinsics in local storage, with no video or landmarks. Reuse additionally requires the exact same live camera stream, proven only in memory; a reload or a new stream requires calibration again. Offer clearCalibration() to remove it.
capture() returns a data URL to the calling page. Use it for a local preview or download only. The project prohibits uploading camera-derived data.
No face recognition, no identity matching, no embeddings — tracking only, by design and by invariant.