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 },
|
2418
|
79 width: 640,
|
|
80 height: 480,
|
2417
|
81 },
|
|
82 };
|
|
83 const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
84 const t = stream.getVideoTracks()[0];
|
|
85 await t.applyConstraints({
|
|
86 brightness: 0,
|
|
87 contrast: 32,
|
|
88 colorTemperature: 6600,
|
|
89 exposureMode: "manual",
|
|
90 exposureTime: 250,
|
|
91 whiteBalanceMode: "manual",
|
|
92 focusMode: "manual",
|
|
93 focusDistance: 235,
|
|
94 } as MediaTrackConstraints);
|
|
95
|
|
96 this.vtrack = t;
|
|
97 this.videoEl.srcObject = stream;
|
|
98 this.videoEl.play();
|
|
99 this.videoSettings = this.vtrack.getSettings();
|
|
100
|
|
101 this.redrawLoop();
|
|
102 }
|
|
103
|
|
104 redrawLoop() {
|
|
105 if (this.videoEl.videoWidth !== 0 && this.videoEl.videoHeight !== 0) {
|
|
106 this.redraw();
|
|
107 }
|
|
108 // todo: video frames come slower than raf is waiting
|
|
109 requestAnimationFrame(this.redrawLoop.bind(this));
|
|
110 }
|
|
111
|
|
112 public async set(k: string, v: any) {
|
|
113 if (!this.vtrack) {
|
|
114 throw new Error("vtrack");
|
|
115 }
|
|
116 await this.vtrack.applyConstraints({ [k]: v });
|
|
117 this.videoSettings = this.vtrack.getSettings();
|
|
118 }
|
|
119
|
|
120 private redraw() {
|
|
121 this.canvas.width = this.videoEl.videoWidth;
|
|
122 this.canvas.height = this.videoEl.videoHeight;
|
|
123 this.ctx.drawImage(this.videoEl, 0, 0);
|
|
124 this.makeSaturatedPixelsTransparent();
|
|
125 }
|
|
126
|
|
127 private makeSaturatedPixelsTransparent() {
|
|
128 const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
|
129 const data = imageData.data;
|
|
130 this.saturatedPixelCount = 0;
|
|
131 for (let i = 0; i < data.length; i += 4) {
|
|
132 if (data[i] === 255 || data[i + 1] === 255 || data[i + 2] === 255) {
|
|
133 this.saturatedPixelCount += 1;
|
|
134
|
|
135 data[i + 3] = 0;
|
|
136 }
|
|
137 }
|
|
138 this.saturatedPixelFraction = this.saturatedPixelCount / (data.length / 4);
|
|
139 this.ctx.putImageData(imageData, 0, 0);
|
|
140 }
|
|
141 }
|
|
142
|
|
143 @customElement("light9-camera-settings-table")
|
|
144 export class Light9CameraSettingsTable extends LitElement {
|
|
145 static styles = [
|
|
146 css`
|
|
147 table {
|
|
148 border-collapse: collapse;
|
|
149 }
|
|
150 td {
|
|
151 border: 1px solid gray;
|
|
152 padding: 1px 6px;
|
|
153 }
|
|
154 `,
|
|
155 ];
|
|
156
|
|
157 boring = [
|
|
158 "aspectRatio",
|
|
159 "backgroundBlur",
|
|
160 "channelCount",
|
|
161 "deviceId",
|
|
162 "displaySurface",
|
|
163 "echoCancellation",
|
|
164 "eyeGazeCorrection",
|
|
165 "faceFraming",
|
|
166 "groupId",
|
|
167 "latency",
|
|
168 "noiseSuppression",
|
|
169 "pointsOfInterest",
|
|
170 "resizeMode",
|
|
171 "sampleRate",
|
|
172 "sampleSize",
|
|
173 "suppressLocalAudioPlayback",
|
|
174 "torch",
|
|
175 "voiceIsolation",
|
|
176 ];
|
|
177
|
|
178 adjustable: Record<string, { min: string; max: string }> = {
|
|
179 focusDistance: { min: "0", max: "1023" },
|
|
180 brightness: { min: "0", max: "64" },
|
|
181 colorTemperature: { min: "2800", max: "6500" },
|
|
182 exposureTime: { min: "0", max: "400" },
|
|
183 };
|
|
184
|
|
185 @property() cam!: Light9Camera;
|
|
186 @property() videoSettings: MediaTrackSettings & any = {};
|
|
187 supportedByBrowser: MediaTrackSupportedConstraints;
|
|
188 constructor() {
|
|
189 super();
|
|
190 this.supportedByBrowser = navigator.mediaDevices.getSupportedConstraints();
|
|
191 }
|
|
192 render() {
|
|
193 const rows: TemplateResult<1>[] = [];
|
|
194 for (const key of Object.keys(this.supportedByBrowser)) {
|
|
195 if (!this.boring.includes(key)) {
|
|
196 this.renderRow(key, rows);
|
|
197 }
|
|
198 }
|
|
199 return html`<table>
|
|
200 ${rows}
|
|
201 </table>`;
|
|
202 }
|
|
203
|
|
204 private renderRow(key: string, rows: any[]) {
|
|
205 let valueDisplay = "";
|
|
206 if (this.videoSettings[key] !== undefined) {
|
|
207 valueDisplay = JSON.stringify(this.videoSettings[key]);
|
|
208 }
|
|
209 let adjuster = html``;
|
|
210 let conf = this.adjustable[key];
|
|
211 if (conf !== undefined) {
|
|
212 adjuster = html`
|
|
213 <input type="range" min="${conf.min}" max="${conf.max}" value="${this.videoSettings[key]}" data-param="${key}" @input=${this.setFromSlider} />
|
|
214 `;
|
|
215 }
|
|
216 rows.push(
|
|
217 html`<tr>
|
|
218 <td>${key}</td>
|
|
219 <td>${valueDisplay}</td>
|
|
220 <td>${adjuster}</td>
|
|
221 </tr>`
|
|
222 );
|
|
223 }
|
|
224
|
|
225 async setFromSlider(ev: InputEvent) {
|
|
226 const el = ev.target as HTMLInputElement;
|
|
227 await this.cam.set(el.dataset.param as string, parseFloat(el.value));
|
|
228 }
|
|
229 }
|