2417
|
1 import debug from "debug";
|
|
2 import { css, html, LitElement, PropertyValueMap, TemplateResult } from "lit";
|
|
3 import { customElement, property, state } from "lit/decorators.js";
|
|
4
|
|
5 @customElement("light9-camera")
|
|
6 export class Light9Camera extends LitElement {
|
|
7 static styles = [
|
|
8 css`
|
|
9 :host {
|
|
10 display: flex;
|
|
11 }
|
|
12 #video {
|
|
13 display: none;
|
|
14 }
|
|
15 #stack {
|
|
16 position: relative;
|
|
17 width: 640px;
|
|
18 height: 480px;
|
|
19 }
|
|
20 #stack > * {
|
|
21 position: absolute;
|
|
22 left: 0;
|
|
23 top: 0;
|
|
24 }
|
|
25 #stack > :first-child {
|
|
26 position: static;
|
|
27 }
|
|
28 #stack > img {
|
|
29 opacity: 0;
|
|
30 animation: fadeIn 1s 1s ease-in-out forwards;
|
|
31 }
|
|
32 @keyframes fadeIn {
|
|
33 from {
|
|
34 opacity: 0;
|
|
35 }
|
|
36 to {
|
|
37 opacity: 1;
|
|
38 }
|
|
39 }
|
|
40 `,
|
|
41 ];
|
|
42 videoEl!: HTMLVideoElement;
|
|
43 canvas!: HTMLCanvasElement;
|
|
44 ctx!: CanvasRenderingContext2D;
|
|
45 @state()
|
|
46 vtrack: MediaStreamTrack | undefined;
|
|
47
|
|
48 @property() saturatedPixelCount = 0;
|
|
49 @property() saturatedPixelFraction = 0;
|
|
50
|
|
51 @property() videoSettings: MediaTrackSettings & any = {};
|
|
52
|
|
53 render() {
|
|
54 const saturatedCountDisplay = `${this.saturatedPixelCount} (${(this.saturatedPixelFraction * 100).toFixed(2)}%)`;
|
|
55
|
|
56 return html`
|
|
57 <video id="video"></video>
|
|
58
|
|
59 <div id="stack">
|
|
60 <img src="zebra.png" />
|
|
61 <canvas id="canvas"></canvas>
|
|
62 </div>
|
|
63 <div id="controls">
|
|
64 <p>saturated pixels: ${saturatedCountDisplay}</p>
|
|
65 <light9-camera-settings-table .cam=${this} .videoSettings=${this.videoSettings}></light9-camera-settings-table>
|
|
66 </div>
|
|
67 `;
|
|
68 }
|
|
69
|
|
70 protected async firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
|
|
71 this.videoEl = this.shadowRoot!.getElementById("video") as HTMLVideoElement;
|
|
72 this.canvas = this.shadowRoot!.getElementById("canvas") as HTMLCanvasElement;
|
|
73 this.ctx = this.canvas.getContext("2d", { willReadFrequently: true })!;
|
|
74
|
|
75 const constraints: MediaStreamConstraints = {
|
|
76 video: {
|
|
77 facingMode: { ideal: "environment" },
|
|
78 frameRate: { max: 10 },
|
|
79 },
|
|
80 };
|
|
81 const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
82 const t = stream.getVideoTracks()[0];
|
|
83 await t.applyConstraints({
|
|
84 brightness: 0,
|
|
85 contrast: 32,
|
|
86 colorTemperature: 6600,
|
|
87 exposureMode: "manual",
|
|
88 exposureTime: 250,
|
|
89 whiteBalanceMode: "manual",
|
|
90 focusMode: "manual",
|
|
91 focusDistance: 235,
|
|
92 } as MediaTrackConstraints);
|
|
93
|
|
94 this.vtrack = t;
|
|
95 this.videoEl.srcObject = stream;
|
|
96 this.videoEl.play();
|
|
97 this.videoSettings = this.vtrack.getSettings();
|
|
98
|
|
99 this.redrawLoop();
|
|
100 }
|
|
101
|
|
102 redrawLoop() {
|
|
103 if (this.videoEl.videoWidth !== 0 && this.videoEl.videoHeight !== 0) {
|
|
104 this.redraw();
|
|
105 }
|
|
106 // todo: video frames come slower than raf is waiting
|
|
107 requestAnimationFrame(this.redrawLoop.bind(this));
|
|
108 }
|
|
109
|
|
110 public async set(k: string, v: any) {
|
|
111 if (!this.vtrack) {
|
|
112 throw new Error("vtrack");
|
|
113 }
|
|
114 await this.vtrack.applyConstraints({ [k]: v });
|
|
115 this.videoSettings = this.vtrack.getSettings();
|
|
116 }
|
|
117
|
|
118 private redraw() {
|
|
119 this.canvas.width = this.videoEl.videoWidth;
|
|
120 this.canvas.height = this.videoEl.videoHeight;
|
|
121 this.ctx.drawImage(this.videoEl, 0, 0);
|
|
122 this.makeSaturatedPixelsTransparent();
|
|
123 }
|
|
124
|
|
125 private makeSaturatedPixelsTransparent() {
|
|
126 const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
|
127 const data = imageData.data;
|
|
128 this.saturatedPixelCount = 0;
|
|
129 for (let i = 0; i < data.length; i += 4) {
|
|
130 if (data[i] === 255 || data[i + 1] === 255 || data[i + 2] === 255) {
|
|
131 this.saturatedPixelCount += 1;
|
|
132
|
|
133 data[i + 3] = 0;
|
|
134 }
|
|
135 }
|
|
136 this.saturatedPixelFraction = this.saturatedPixelCount / (data.length / 4);
|
|
137 this.ctx.putImageData(imageData, 0, 0);
|
|
138 }
|
|
139 }
|
|
140
|
|
141 @customElement("light9-camera-settings-table")
|
|
142 export class Light9CameraSettingsTable extends LitElement {
|
|
143 static styles = [
|
|
144 css`
|
|
145 table {
|
|
146 border-collapse: collapse;
|
|
147 }
|
|
148 td {
|
|
149 border: 1px solid gray;
|
|
150 padding: 1px 6px;
|
|
151 }
|
|
152 `,
|
|
153 ];
|
|
154
|
|
155 boring = [
|
|
156 "aspectRatio",
|
|
157 "backgroundBlur",
|
|
158 "channelCount",
|
|
159 "deviceId",
|
|
160 "displaySurface",
|
|
161 "echoCancellation",
|
|
162 "eyeGazeCorrection",
|
|
163 "faceFraming",
|
|
164 "groupId",
|
|
165 "latency",
|
|
166 "noiseSuppression",
|
|
167 "pointsOfInterest",
|
|
168 "resizeMode",
|
|
169 "sampleRate",
|
|
170 "sampleSize",
|
|
171 "suppressLocalAudioPlayback",
|
|
172 "torch",
|
|
173 "voiceIsolation",
|
|
174 ];
|
|
175
|
|
176 adjustable: Record<string, { min: string; max: string }> = {
|
|
177 focusDistance: { min: "0", max: "1023" },
|
|
178 brightness: { min: "0", max: "64" },
|
|
179 colorTemperature: { min: "2800", max: "6500" },
|
|
180 exposureTime: { min: "0", max: "400" },
|
|
181 };
|
|
182
|
|
183 @property() cam!: Light9Camera;
|
|
184 @property() videoSettings: MediaTrackSettings & any = {};
|
|
185 supportedByBrowser: MediaTrackSupportedConstraints;
|
|
186 constructor() {
|
|
187 super();
|
|
188 this.supportedByBrowser = navigator.mediaDevices.getSupportedConstraints();
|
|
189 }
|
|
190 render() {
|
|
191 const rows: TemplateResult<1>[] = [];
|
|
192 for (const key of Object.keys(this.supportedByBrowser)) {
|
|
193 if (!this.boring.includes(key)) {
|
|
194 this.renderRow(key, rows);
|
|
195 }
|
|
196 }
|
|
197 return html`<table>
|
|
198 ${rows}
|
|
199 </table>`;
|
|
200 }
|
|
201
|
|
202 private renderRow(key: string, rows: any[]) {
|
|
203 let valueDisplay = "";
|
|
204 if (this.videoSettings[key] !== undefined) {
|
|
205 valueDisplay = JSON.stringify(this.videoSettings[key]);
|
|
206 }
|
|
207 let adjuster = html``;
|
|
208 let conf = this.adjustable[key];
|
|
209 if (conf !== undefined) {
|
|
210 adjuster = html`
|
|
211 <input type="range" min="${conf.min}" max="${conf.max}" value="${this.videoSettings[key]}" data-param="${key}" @input=${this.setFromSlider} />
|
|
212 `;
|
|
213 }
|
|
214 rows.push(
|
|
215 html`<tr>
|
|
216 <td>${key}</td>
|
|
217 <td>${valueDisplay}</td>
|
|
218 <td>${adjuster}</td>
|
|
219 </tr>`
|
|
220 );
|
|
221 }
|
|
222
|
|
223 async setFromSlider(ev: InputEvent) {
|
|
224 const el = ev.target as HTMLInputElement;
|
|
225 await this.cam.set(el.dataset.param as string, parseFloat(el.value));
|
|
226 }
|
|
227 }
|