52 □ 18  145  ·  v0.1.0

Eyewear Try-On SDK

A Web Component that puts glasses on a shopper's face through their webcam, rendered at true physical size. Everything runs on their device. This is what you need to integrate it.

Preview — read before shipping

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

HTTPSMandatory. getUserMedia refuses any insecure origin. localhost is exempt for development.
WebGL2For lens refraction. Without it the lens degrades to a tinted surface rather than vanishing.
BrowsersChrome/Edge 89+, Safari 16.4+, Firefox 90+.
Bandwidth~5 MB one-off for the face tracker, fetched on the shopper's click.
The most common deployment failure

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.

ESM build32 kBgzipped · deps external
Script build628 kBgzipped · fully bundled

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 millimetres are the render, not metadata

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

FormatglTF 2.0 — KHR_materials_transmission, Meshopt, KTX2/Basis
ScaleMillimetres. A 138 mm frame spans 138 units.
OriginCentre of the bridge, rear face of the front
Axes+X model's left, +Y up, +Z out of the face
LightingBake ambient occlusion. Never bake highlights or reflections.
LensesRemove 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

CheckTolerance
Front width vs frontWidthMm±1.5 mm
Unit ratio — 1000×, 100×, 10× and inversesreported 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>
Both or neither

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

skuCurrent SKU. Also an attribute.
framesThe catalogue, read-only.
pdMmCard-referenced PD, or null.
assetUrlsSelf-hosted tracker assets. Also wasm-base / model-url.
auto-startAttribute. Starts without a click — a bad default on a product page.

Events

EventFires whendetail
vto-readyCamera open, tracking running{ fixture }
vto-errorCamera denied, or tracker failed to load{ message }
vto-tracking-changedFace found or lost{ tracking }
vto-calibratedMeasurement accepted{ pdMm, metricScale }
vto-calibration-rejectedSolve outside plausible bounds{}
vto-model-rejectedA model failed QA; stand-in showing{ sku, reason, qa }
vto-frame-changedSKU changed{ sku }
vto-capturecapture() 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

Two traps that cost hours

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

SymptomCause
Page loads, camera never startsNot a secure context — HTTP, an untrusted cert, or an IP/:port URL
NotAllowedErrorPermission denied, or a Permissions-Policy header blocks camera. In an iframe you need allow="camera".
Frames look right but too small or largeUncalibrated — drawn against a canonical head
One SKU is the wrong sizeFailed dimensional QA and fell back. Listen for vto-model-rejected.
Frames render blackNo environment map, or WebGL2 unavailable — metal has no diffuse term
Two pairs of glasses visibleShopper is wearing their own. Known and accepted.
Frames drift past ~45° of yawTracking degrades there by design; pose is damped rather than allowed to swim
502 from a CDNOrigin 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.

One thing that becomes your decision

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.