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 | Chrome/Edge 89+, Safari 16.4+, Firefox 90+. |
| Bandwidth | ~5 MB one-off for the face tracker, fetched on the shopper's click. |
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 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.
03Quick start
<script type="module" src="https://your.cdn/eyewear-vto.js"></script>
<eyewear-tryon sku="ACME-52"></eyewear-tryon>
Importing the module registers the element. Nothing loads until the shopper presses start — the tracker is fetched on that click, never on page load, so the element costs a product page essentially nothing until it's used.
Try it in the playground — change the attributes, watch the snippet update, see the events your page would receive, and get a straight answer about whether this origin can open a camera at all.
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 | Bake ambient occlusion. Never bake highlights or reflections. |
| 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 only outbound requests are inbound asset fetches for the tracker, which self-hosting removes entirely.
Stored: the calibration only — six numbers in localStorage (head ratio, PD, distance, timestamp, capture resolution). No image, no landmarks, no embedding, nothing that identifies a person. It expires after 30 days and is rejected if the camera resolution changes. Surface clearCalibration() in your UI.
capture() returns a data URL to you. This package never uploads it. If your page does, that is a decision about biometric-derived data, and disclosing it is yours to make.
No face recognition, no identity matching, no embeddings — tracking only, by design and by invariant.