diff --git a/web/calibrate/Light9Camera.ts b/web/calibrate/Light9Camera.ts
new file mode 100644
--- /dev/null
+++ b/web/calibrate/Light9Camera.ts
@@ -0,0 +1,227 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValueMap, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+
+@customElement("light9-camera")
+export class Light9Camera extends LitElement {
+ static styles = [
+ css`
+ :host {
+ display: flex;
+ }
+ #video {
+ display: none;
+ }
+ #stack {
+ position: relative;
+ width: 640px;
+ height: 480px;
+ }
+ #stack > * {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+ #stack > :first-child {
+ position: static;
+ }
+ #stack > img {
+ opacity: 0;
+ animation: fadeIn 1s 1s ease-in-out forwards;
+ }
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+ `,
+ ];
+ videoEl!: HTMLVideoElement;
+ canvas!: HTMLCanvasElement;
+ ctx!: CanvasRenderingContext2D;
+ @state()
+ vtrack: MediaStreamTrack | undefined;
+
+ @property() saturatedPixelCount = 0;
+ @property() saturatedPixelFraction = 0;
+
+ @property() videoSettings: MediaTrackSettings & any = {};
+
+ render() {
+ const saturatedCountDisplay = `${this.saturatedPixelCount} (${(this.saturatedPixelFraction * 100).toFixed(2)}%)`;
+
+ return html`
+
+
+
+
+
+
+
+
saturated pixels: ${saturatedCountDisplay}
+
+
+ `;
+ }
+
+ protected async firstUpdated(_changedProperties: PropertyValueMap | Map) {
+ this.videoEl = this.shadowRoot!.getElementById("video") as HTMLVideoElement;
+ this.canvas = this.shadowRoot!.getElementById("canvas") as HTMLCanvasElement;
+ this.ctx = this.canvas.getContext("2d", { willReadFrequently: true })!;
+
+ const constraints: MediaStreamConstraints = {
+ video: {
+ facingMode: { ideal: "environment" },
+ frameRate: { max: 10 },
+ },
+ };
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
+ const t = stream.getVideoTracks()[0];
+ await t.applyConstraints({
+ brightness: 0,
+ contrast: 32,
+ colorTemperature: 6600,
+ exposureMode: "manual",
+ exposureTime: 250,
+ whiteBalanceMode: "manual",
+ focusMode: "manual",
+ focusDistance: 235,
+ } as MediaTrackConstraints);
+
+ this.vtrack = t;
+ this.videoEl.srcObject = stream;
+ this.videoEl.play();
+ this.videoSettings = this.vtrack.getSettings();
+
+ this.redrawLoop();
+ }
+
+ redrawLoop() {
+ if (this.videoEl.videoWidth !== 0 && this.videoEl.videoHeight !== 0) {
+ this.redraw();
+ }
+ // todo: video frames come slower than raf is waiting
+ requestAnimationFrame(this.redrawLoop.bind(this));
+ }
+
+ public async set(k: string, v: any) {
+ if (!this.vtrack) {
+ throw new Error("vtrack");
+ }
+ await this.vtrack.applyConstraints({ [k]: v });
+ this.videoSettings = this.vtrack.getSettings();
+ }
+
+ private redraw() {
+ this.canvas.width = this.videoEl.videoWidth;
+ this.canvas.height = this.videoEl.videoHeight;
+ this.ctx.drawImage(this.videoEl, 0, 0);
+ this.makeSaturatedPixelsTransparent();
+ }
+
+ private makeSaturatedPixelsTransparent() {
+ const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
+ const data = imageData.data;
+ this.saturatedPixelCount = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i] === 255 || data[i + 1] === 255 || data[i + 2] === 255) {
+ this.saturatedPixelCount += 1;
+
+ data[i + 3] = 0;
+ }
+ }
+ this.saturatedPixelFraction = this.saturatedPixelCount / (data.length / 4);
+ this.ctx.putImageData(imageData, 0, 0);
+ }
+}
+
+@customElement("light9-camera-settings-table")
+export class Light9CameraSettingsTable extends LitElement {
+ static styles = [
+ css`
+ table {
+ border-collapse: collapse;
+ }
+ td {
+ border: 1px solid gray;
+ padding: 1px 6px;
+ }
+ `,
+ ];
+
+ boring = [
+ "aspectRatio",
+ "backgroundBlur",
+ "channelCount",
+ "deviceId",
+ "displaySurface",
+ "echoCancellation",
+ "eyeGazeCorrection",
+ "faceFraming",
+ "groupId",
+ "latency",
+ "noiseSuppression",
+ "pointsOfInterest",
+ "resizeMode",
+ "sampleRate",
+ "sampleSize",
+ "suppressLocalAudioPlayback",
+ "torch",
+ "voiceIsolation",
+ ];
+
+ adjustable: Record = {
+ focusDistance: { min: "0", max: "1023" },
+ brightness: { min: "0", max: "64" },
+ colorTemperature: { min: "2800", max: "6500" },
+ exposureTime: { min: "0", max: "400" },
+ };
+
+ @property() cam!: Light9Camera;
+ @property() videoSettings: MediaTrackSettings & any = {};
+ supportedByBrowser: MediaTrackSupportedConstraints;
+ constructor() {
+ super();
+ this.supportedByBrowser = navigator.mediaDevices.getSupportedConstraints();
+ }
+ render() {
+ const rows: TemplateResult<1>[] = [];
+ for (const key of Object.keys(this.supportedByBrowser)) {
+ if (!this.boring.includes(key)) {
+ this.renderRow(key, rows);
+ }
+ }
+ return html``;
+ }
+
+ private renderRow(key: string, rows: any[]) {
+ let valueDisplay = "";
+ if (this.videoSettings[key] !== undefined) {
+ valueDisplay = JSON.stringify(this.videoSettings[key]);
+ }
+ let adjuster = html``;
+ let conf = this.adjustable[key];
+ if (conf !== undefined) {
+ adjuster = html`
+
+ `;
+ }
+ rows.push(
+ html`
+ ${key} |
+ ${valueDisplay} |
+ ${adjuster} |
+
`
+ );
+ }
+
+ async setFromSlider(ev: InputEvent) {
+ const el = ev.target as HTMLInputElement;
+ await this.cam.set(el.dataset.param as string, parseFloat(el.value));
+ }
+}