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