RGBMatrixEmulator 0.12.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. RGBMatrixEmulator/__init__.py +5 -0
  2. RGBMatrixEmulator/adapters/__init__.py +72 -0
  3. RGBMatrixEmulator/adapters/base.py +109 -0
  4. RGBMatrixEmulator/adapters/browser_adapter/README.md +89 -0
  5. RGBMatrixEmulator/adapters/browser_adapter/__init__.py +0 -0
  6. RGBMatrixEmulator/adapters/browser_adapter/adapter.py +51 -0
  7. RGBMatrixEmulator/adapters/browser_adapter/request_handlers/__init__.py +7 -0
  8. RGBMatrixEmulator/adapters/browser_adapter/request_handlers/image.py +12 -0
  9. RGBMatrixEmulator/adapters/browser_adapter/request_handlers/image_web_socket.py +34 -0
  10. RGBMatrixEmulator/adapters/browser_adapter/request_handlers/main.py +9 -0
  11. RGBMatrixEmulator/adapters/browser_adapter/server.py +86 -0
  12. RGBMatrixEmulator/adapters/browser_adapter/static/assets/client.js +91 -0
  13. RGBMatrixEmulator/adapters/browser_adapter/static/assets/icon.ico +0 -0
  14. RGBMatrixEmulator/adapters/browser_adapter/static/assets/styles.css +25 -0
  15. RGBMatrixEmulator/adapters/browser_adapter/static/index.html +144 -0
  16. RGBMatrixEmulator/adapters/pygame_adapter.py +59 -0
  17. RGBMatrixEmulator/adapters/sixel_adapter.py +90 -0
  18. RGBMatrixEmulator/adapters/terminal_adapter.py +34 -0
  19. RGBMatrixEmulator/adapters/tkinter_adapter.py +83 -0
  20. RGBMatrixEmulator/adapters/turtle_adapter.py +97 -0
  21. RGBMatrixEmulator/emulation/__init__.py +0 -0
  22. RGBMatrixEmulator/emulation/canvas.py +71 -0
  23. RGBMatrixEmulator/emulation/matrix.py +62 -0
  24. RGBMatrixEmulator/emulation/options.py +213 -0
  25. RGBMatrixEmulator/graphics/__init__.py +169 -0
  26. RGBMatrixEmulator/graphics/color.py +32 -0
  27. RGBMatrixEmulator/graphics/font.py +36 -0
  28. RGBMatrixEmulator/icon.ico +0 -0
  29. RGBMatrixEmulator/icon.png +0 -0
  30. RGBMatrixEmulator/logger.py +28 -0
  31. RGBMatrixEmulator/version.py +5 -0
  32. rgbmatrixemulator-0.12.0.data/data/RGBMatrixEmulator/client.js +91 -0
  33. rgbmatrixemulator-0.12.0.data/data/RGBMatrixEmulator/icon.ico +0 -0
  34. rgbmatrixemulator-0.12.0.data/data/RGBMatrixEmulator/icon.png +0 -0
  35. rgbmatrixemulator-0.12.0.data/data/RGBMatrixEmulator/index.html +144 -0
  36. rgbmatrixemulator-0.12.0.data/data/RGBMatrixEmulator/styles.css +25 -0
  37. rgbmatrixemulator-0.12.0.data/data/docs/LICENSE +9 -0
  38. rgbmatrixemulator-0.12.0.data/data/docs/README.md +160 -0
  39. rgbmatrixemulator-0.12.0.dist-info/METADATA +186 -0
  40. rgbmatrixemulator-0.12.0.dist-info/RECORD +43 -0
  41. rgbmatrixemulator-0.12.0.dist-info/WHEEL +5 -0
  42. rgbmatrixemulator-0.12.0.dist-info/licenses/LICENSE +9 -0
@@ -0,0 +1,213 @@
1
+ import json, os, pprint, sys
2
+
3
+ from RGBMatrixEmulator.adapters import ADAPTER_TYPES
4
+ from RGBMatrixEmulator.logger import Logger
5
+
6
+
7
+ class RGBMatrixOptions:
8
+ def __init__(self):
9
+ self.hardware_mapping = "EMULATED"
10
+ self.rows = 32
11
+ self.cols = 32
12
+ self.chain_length = 1
13
+ self.parallel = 1
14
+ self.row_address_type = 0
15
+ self.multiplexing = 0
16
+ self.pwm_bits = 0
17
+ self.brightness = 100
18
+ self.pwm_lsb_nanoseconds = 130
19
+ self.led_rgb_sequence = "RGB-EMULATED"
20
+ self.show_refresh_rate = 0
21
+ self.gpio_slowdown = None
22
+ self.disable_hardware_pulsing = False
23
+
24
+ emulator_config = RGBMatrixEmulatorConfig()
25
+
26
+ if emulator_config.display_adapter.lower() in ADAPTER_TYPES:
27
+ self.display_adapter = ADAPTER_TYPES[
28
+ emulator_config.display_adapter.lower()
29
+ ]
30
+ elif len(ADAPTER_TYPES.keys()) > 0:
31
+ adapter_types = ", ".join(
32
+ '"{}"'.format(key) for key in ADAPTER_TYPES.keys()
33
+ )
34
+
35
+ # Try to set it to the emulator default, but if it failed to load, pick the first one that did.
36
+ if emulator_config.DEFAULT_CONFIG.get("display_adapter") in ADAPTER_TYPES:
37
+ default_adapter = emulator_config.DEFAULT_CONFIG.get("display_adapter")
38
+ else:
39
+ default_adapter = list(ADAPTER_TYPES.keys())[0]
40
+
41
+ Logger.warning(
42
+ '"{}" display adapter option not recognized. Valid adapters are {}. Defaulting to "{}"...'.format(
43
+ emulator_config.display_adapter, adapter_types, default_adapter
44
+ )
45
+ )
46
+ self.display_adapter = ADAPTER_TYPES[default_adapter]
47
+ else:
48
+ Logger.critical(
49
+ "Failed to find a valid display adapter to load! Check that you have installed dependencies required for your configured adapter."
50
+ )
51
+
52
+ sys.exit(1)
53
+
54
+ self.pixel_style = emulator_config.DEFAULT_CONFIG.get("pixel_style")
55
+ config_pixel_style = emulator_config.pixel_style.lower()
56
+
57
+ if config_pixel_style in emulator_config.VALID_PIXEL_STYLES:
58
+ if config_pixel_style != self.pixel_style:
59
+ if self.display_adapter.SUPPORTS_ALTERNATE_PIXEL_STYLE:
60
+ self.pixel_style = emulator_config.pixel_style
61
+ else:
62
+ Logger.warning(
63
+ '"{}" pixel style option is not supported by adapter "{}". Defaulting to "square"...'.format(
64
+ config_pixel_style, emulator_config.display_adapter.lower()
65
+ )
66
+ )
67
+ else:
68
+ Logger.warning(
69
+ '"{}" pixel style option not recognized. Valid options are "square", "circle". Defaulting to "square"...'.format(
70
+ config_pixel_style
71
+ )
72
+ )
73
+
74
+ self.pixel_size = emulator_config.pixel_size
75
+ self.pixel_outline = emulator_config.DEFAULT_CONFIG["pixel_outline"]
76
+ self.pixel_outline = emulator_config.pixel_outline
77
+ self.browser = emulator_config.browser
78
+
79
+ if emulator_config.suppress_font_warnings:
80
+ import bdfparser
81
+
82
+ bdfparser.warnings.simplefilter("ignore")
83
+
84
+ def window_size(self):
85
+ return (
86
+ self.cols * self.pixel_size * self.chain_length,
87
+ self.rows * self.pixel_size * self.parallel,
88
+ )
89
+
90
+ def window_size_str(self, pixel_text=""):
91
+ width, height = self.window_size()
92
+
93
+ return f"{width} x {height} {pixel_text}"
94
+
95
+
96
+ class RGBMatrixEmulatorConfig:
97
+ __CONFIG_PATH = "emulator_config.json"
98
+
99
+ VALID_PIXEL_STYLES = ["square", "circle"]
100
+ DEFAULT_CONFIG = {
101
+ "pixel_outline": 0,
102
+ "pixel_size": 16,
103
+ "pixel_style": "square",
104
+ "display_adapter": "browser",
105
+ "suppress_font_warnings": False,
106
+ "suppress_adapter_load_errors": False,
107
+ "browser": {
108
+ "_comment": "For use with the browser adapter only.",
109
+ "port": 8888,
110
+ "target_fps": 24,
111
+ "fps_display": False,
112
+ "quality": 70,
113
+ "image_border": True,
114
+ "debug_text": False,
115
+ "image_format": "JPEG",
116
+ },
117
+ "log_level": "info",
118
+ }
119
+
120
+ def __init__(self):
121
+ self.config = self.__load_config()
122
+ self.default_config = self.DEFAULT_CONFIG
123
+
124
+ RGBMatrixEmulatorConfig.Utils.set_attributes(self)
125
+
126
+ def __load_config(self):
127
+ if os.path.exists(self.__CONFIG_PATH):
128
+ with open(self.__CONFIG_PATH) as f:
129
+ config = json.load(f)
130
+
131
+ return config
132
+
133
+ with open(self.__CONFIG_PATH, "w") as f:
134
+ json.dump(self.DEFAULT_CONFIG, f, indent=4)
135
+
136
+ return self.DEFAULT_CONFIG
137
+
138
+ def __str__(self):
139
+ return RGBMatrixEmulatorConfig.Utils.to_str(self)
140
+
141
+ class ChildConfig:
142
+ def __init__(self, config, default_config):
143
+ self.config = config
144
+ self.default_config = default_config
145
+
146
+ RGBMatrixEmulatorConfig.Utils.set_attributes(self)
147
+
148
+ def __str__(self):
149
+ return RGBMatrixEmulatorConfig.Utils.to_str(self)
150
+
151
+ class Utils:
152
+ def to_str(obj):
153
+ """
154
+ Pretty prints the config object from dict.
155
+ """
156
+ printer = pprint.PrettyPrinter(sort_dicts=False)
157
+ return "\n".join(
158
+ [
159
+ obj.__repr__(),
160
+ printer.pformat(RGBMatrixEmulatorConfig.Utils.to_dict(obj)),
161
+ ]
162
+ )
163
+
164
+ def to_dict(obj):
165
+ """
166
+ Recursively recreates the config dict from child config objects.
167
+ """
168
+ config = {}
169
+ for key in obj.__dict__.keys():
170
+ if key in ["config", "default_config"] or key[0] == "_":
171
+ continue
172
+
173
+ value = obj.__dict__.get(key)
174
+
175
+ if isinstance(value, RGBMatrixEmulatorConfig.ChildConfig):
176
+ value = RGBMatrixEmulatorConfig.Utils.to_dict(value)
177
+
178
+ config[key] = value
179
+
180
+ return config
181
+
182
+ def set_attributes(obj):
183
+ """
184
+ Dynamically set attributes loaded into config and default config variables.
185
+
186
+ Numbers, strings, and arrays are stored natively. Nested dicts are parsed into RGBMatrixEmulatorChildConfig objects recursively.
187
+ """
188
+ for key in obj.default_config.keys():
189
+ if key in obj.config:
190
+ value = obj.config.get(key)
191
+ default = obj.default_config.get(key)
192
+ else:
193
+ value = obj.default_config.get(key)
194
+ default = value
195
+
196
+ Logger.warning(
197
+ "Emulator config is missing key '{}', falling back to default '{}'. Consider adding this to your emulator config file.".format(
198
+ key, value
199
+ )
200
+ )
201
+
202
+ RGBMatrixEmulatorConfig.Utils.set_attribute(obj, key, value, default)
203
+
204
+ def set_attribute(obj, key, value, default):
205
+ """
206
+ Store the value as an attribute or delegate to the RGBMatrixEmulatorChildConfig to parse into a new node.
207
+ """
208
+ if isinstance(value, dict):
209
+ obj.__setattr__(
210
+ key, RGBMatrixEmulatorConfig.ChildConfig(value, default)
211
+ )
212
+ else:
213
+ obj.__setattr__(key, value)
@@ -0,0 +1,169 @@
1
+ from RGBMatrixEmulator.graphics.color import Color
2
+ from RGBMatrixEmulator.graphics.font import Font
3
+
4
+
5
+ def DrawText(canvas, font, x, y, color, text):
6
+ # Early return for empty string prevents bugs in bdfparser library
7
+ # and makes good sense anyway
8
+ if len(text) == 0:
9
+ return
10
+
11
+ # Support multiple spacings based on device width
12
+ character_widths = [__actual_width(font, letter) for letter in text]
13
+ first_char_width = character_widths[0]
14
+ max_char_width = max(character_widths)
15
+ total_width = sum(character_widths)
16
+
17
+ # Offscreen to the left, adjust by first character width
18
+ if x < 0:
19
+ adjustment = abs(x + first_char_width) // first_char_width
20
+ text = text[adjustment:]
21
+ if adjustment:
22
+ x += first_char_width * adjustment
23
+
24
+ # Offscreen to the right, rough adjustment by max width
25
+ if (total_width + x) > canvas.width:
26
+ text = text[: ((canvas.width + 1) // max_char_width) + 2]
27
+
28
+ # Draw the text!
29
+ if len(text) != 0:
30
+ # Ensure text doesn't get drawn as multiple lines
31
+ linelimit = len(text) * (font.headers["fbbx"] + 1)
32
+
33
+ text_map = font.bdf_font.draw(
34
+ text, linelimit, missing=font.default_character
35
+ ).todata(2)
36
+ font_y_offset = -(font.headers["fbby"] + font.headers["fbbyoff"])
37
+
38
+ for y2, row in enumerate(text_map):
39
+ for x2, value in enumerate(row):
40
+ if value == 1:
41
+ if isinstance(color, tuple):
42
+ canvas.SetPixel(x + x2, y + y2 + font_y_offset, *color)
43
+ else:
44
+ canvas.SetPixel(
45
+ x + x2,
46
+ y + y2 + font_y_offset,
47
+ color.red,
48
+ color.green,
49
+ color.blue,
50
+ )
51
+
52
+ return total_width
53
+
54
+
55
+ def DrawLine(canvas, x1, y1, x2, y2, color):
56
+ int_points = __coerce_int(x1, y1, x2, y2)
57
+ rows, cols = __line(*int_points)
58
+
59
+ for point in zip(rows, cols):
60
+ if isinstance(color, tuple):
61
+ canvas.SetPixel(*point, *color)
62
+ else:
63
+ canvas.SetPixel(*point, color.red, color.green, color.blue)
64
+
65
+
66
+ def DrawCircle(canvas, x, y, r, color):
67
+ int_points = __coerce_int(x, y)
68
+ rows, cols = __circle_perimeter(*int_points, r)
69
+
70
+ for point in zip(rows, cols):
71
+ if isinstance(color, tuple):
72
+ canvas.SetPixel(*point, *color)
73
+ else:
74
+ canvas.SetPixel(*point, color.red, color.green, color.blue)
75
+
76
+
77
+ def __actual_width(font, letter):
78
+ """
79
+ Returns the actual width of the letter in the font. If the font doesn't contain a glyph for this letter, it falls back to
80
+ the width of the default character (?) to prevent division by 0.
81
+ """
82
+ width = font.CharacterWidth(ord(letter))
83
+
84
+ if width > 0:
85
+ return width
86
+
87
+ return font.CharacterWidth(font.default_character.cp())
88
+
89
+
90
+ def __coerce_int(*values):
91
+ return [int(value) for value in values]
92
+
93
+
94
+ def __line(x1, y1, x2, y2):
95
+ """
96
+ Line drawing algorithm
97
+
98
+ Extracted from scikit-image:
99
+ https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/draw/_draw.pyx#L44
100
+ """
101
+ steep = 0
102
+ r = x1
103
+ c = y1
104
+ dr = abs(x2 - x1)
105
+ dc = abs(y2 - y1)
106
+
107
+ rr = [0] * (max(dc, dr) + 1)
108
+ cc = [0] * (max(dc, dr) + 1)
109
+
110
+ if (y2 - c) > 0:
111
+ sc = 1
112
+ else:
113
+ sc = -1
114
+ if (x2 - r) > 0:
115
+ sr = 1
116
+ else:
117
+ sr = -1
118
+ if dr > dc:
119
+ steep = 1
120
+ c, r = r, c
121
+ dc, dr = dr, dc
122
+ sc, sr = sr, sc
123
+ d = (2 * dr) - dc
124
+
125
+ for i in range(dc):
126
+ if steep:
127
+ rr[i] = c
128
+ cc[i] = r
129
+ else:
130
+ rr[i] = r
131
+ cc[i] = c
132
+ while d >= 0:
133
+ r = r + sr
134
+ d = d - (2 * dc)
135
+ c = c + sc
136
+ d = d + (2 * dr)
137
+
138
+ rr[dc] = x2
139
+ cc[dc] = y2
140
+
141
+ return (rr, cc)
142
+
143
+
144
+ def __circle_perimeter(x, y, radius):
145
+ """
146
+ Bresenham circle algorithm
147
+
148
+ Extracted from scikit-image
149
+ https://github.com/scikit-image/scikit-image/blob/00177e14097237ef20ed3141ed454bc81b308f82/skimage/draw/_draw.pyx#L248
150
+ """
151
+ rr = list()
152
+ cc = list()
153
+
154
+ c = 0
155
+ r = radius
156
+ d = 3 - 2 * radius
157
+
158
+ while r >= c:
159
+ rr.extend([_ + x for _ in [r, -r, r, -r, c, -c, c, -c]])
160
+ cc.extend([_ + y for _ in [c, c, -c, -c, r, r, -r, -r]])
161
+
162
+ if d < 0:
163
+ d += 4 * c + 6
164
+ else:
165
+ d += 4 * (c - r) + 10
166
+ r -= 1
167
+ c += 1
168
+
169
+ return (rr, cc)
@@ -0,0 +1,32 @@
1
+ class Color:
2
+ def __init__(self, r=0, g=0, b=0):
3
+ self.red = r
4
+ self.green = g
5
+ self.blue = b
6
+
7
+ @classmethod
8
+ def adjust_brightness(cls, pixel, alpha, to_int=False):
9
+ if to_int:
10
+ return tuple(int(channel) for channel in pixel)
11
+
12
+ return tuple(channel * alpha for channel in pixel)
13
+
14
+ @classmethod
15
+ def to_hex(cls, pixel):
16
+ return "#%02x%02x%02x" % pixel
17
+
18
+ @classmethod
19
+ def BLACK(cls):
20
+ return (0, 0, 0)
21
+
22
+ @classmethod
23
+ def RED(cls):
24
+ return (255, 0, 0)
25
+
26
+ @classmethod
27
+ def GREEN(cls):
28
+ return (0, 255, 0)
29
+
30
+ @classmethod
31
+ def BLUE(cls):
32
+ return (0, 0, 255)
@@ -0,0 +1,36 @@
1
+ import bdfparser
2
+
3
+
4
+ class Font:
5
+ def __init__(self):
6
+ self.bdf_font = None
7
+ self.headers = {}
8
+ self.spacing = {}
9
+
10
+ def LoadFont(self, path):
11
+ self.bdf_font = bdfparser.Font(path)
12
+ self.headers = self.bdf_font.headers
13
+ self.props = self.bdf_font.props
14
+
15
+ # All rpi-rgb-led-matrix fonts have a character at 0xFFFD to represent a missing character
16
+ # Cache this for use later so we don't have to constantly look it up
17
+ self.default_character = self.bdf_font.glyphbycp(0xFFFD)
18
+
19
+ def CharacterWidth(self, char):
20
+ # Missing glyphs return 0 width in rpi-rgb-led-matrix
21
+ if self.bdf_font == None or not self.bdf_font.glyphbycp(char):
22
+ return 0
23
+
24
+ return self.bdf_font.glyphbycp(char).meta["dwx0"]
25
+
26
+ @property
27
+ def height(self):
28
+ if self.bdf_font is None:
29
+ return -1
30
+ return self.headers["fbby"]
31
+
32
+ @property
33
+ def baseline(self):
34
+ if self.bdf_font is None:
35
+ return 0
36
+ return self.headers["fbby"] + self.headers["fbbyoff"]
Binary file
Binary file
@@ -0,0 +1,28 @@
1
+ import json, logging
2
+
3
+ # Try to load the config from file. (Default: INFO)
4
+ try:
5
+ with open("emulator_config.json") as config_file:
6
+ log_level_name = json.load(config_file).get("log_level", "INFO").upper()
7
+ log_level = getattr(logging, log_level_name)
8
+ except:
9
+ log_level = logging.INFO
10
+
11
+ # Create a Logger
12
+ Logger = logging.getLogger("RGBME")
13
+ Logger.setLevel(log_level)
14
+
15
+ # Create console handler and set the log level
16
+ ch = logging.StreamHandler()
17
+ ch.setLevel(log_level)
18
+
19
+ # Create formatter
20
+ formatter = logging.Formatter(
21
+ "[%(asctime)s] [%(name)s] [%(levelname)s]: %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
22
+ )
23
+
24
+ # Add formatter to console handler
25
+ ch.setFormatter(formatter)
26
+
27
+ # Add console handler to Logger
28
+ Logger.addHandler(ch)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env python
2
+
3
+ # package version
4
+ __version__ = "0.12.0"
5
+ """Installed version of RGBMatrixEmulator."""
@@ -0,0 +1,91 @@
1
+ function init() {
2
+ const WS_RETRY_DELAY = 2000;
3
+ const FPS_DEFAULT = 24;
4
+
5
+ let img = document.getElementById("liveImg");
6
+ let fpsText = document.getElementById("fps");
7
+ let fpsTarget = parseInt(document.getElementById("targetFps").value) || FPS_DEFAULT;
8
+
9
+ let requestStartTime = performance.now();
10
+ let startTime = performance.now();
11
+ let time = 0;
12
+ let requestTime = 0;
13
+ let timeSmoothing = 0.9; // larger=more smoothing
14
+ let requestTimeSmoothing = 0.2; // larger=more smoothing
15
+ let targetTime = 1000 / fpsTarget;
16
+
17
+ let socket = generateSocket();
18
+
19
+ function requestImage() {
20
+ requestStartTime = performance.now();
21
+ socket.send('more');
22
+ }
23
+
24
+ function generateSocket() {
25
+ let path = location.pathname;
26
+
27
+ if (path.endsWith("index.html")) {
28
+ path = path.substring(0, path.length - "index.html".length);
29
+ }
30
+
31
+ if(!path.endsWith("/")) {
32
+ path = path + "/";
33
+ }
34
+
35
+ let wsProtocol = (location.protocol === "https:") ? "wss://" : "ws://";
36
+ let ws = new WebSocket(wsProtocol + location.host + path + "websocket");
37
+
38
+ ws.binaryType = 'arraybuffer';
39
+
40
+ ws.onopen = function() {
41
+ console.log("RGBME WebSocket connection established!");
42
+ startTime = performance.now();
43
+ requestImage();
44
+ };
45
+
46
+ ws.onclose = function() {
47
+ // Handle retries by recreating the connection to websocket.
48
+ console.warn(`RGBME WebSocket connection lost. Retrying in ${WS_RETRY_DELAY / 1000}s.`)
49
+ setTimeout(function() {
50
+ // We generate socket with a timeout to make sure server has time to recover.
51
+ socket = generateSocket();
52
+ }, WS_RETRY_DELAY);
53
+ }
54
+
55
+ ws.onerror = function() {
56
+ ws.close();
57
+ }
58
+
59
+ ws.onmessage = function(evt) {
60
+ let arrayBuffer = evt.data;
61
+ let blob = new Blob([new Uint8Array(arrayBuffer)], {type: "image/jpeg"});
62
+ let old_img = img.src.slice()
63
+ img.src = window.URL.createObjectURL(blob);
64
+ window.URL.revokeObjectURL(old_img);
65
+
66
+ let endTime = performance.now();
67
+ let currentTime = endTime - startTime;
68
+ // smooth with moving average
69
+ time = (time * timeSmoothing) + (currentTime * (1.0 - timeSmoothing));
70
+ startTime = endTime;
71
+ let fps = Math.round(1000 / time);
72
+
73
+ if (fpsText) {
74
+ fpsText.textContent = fps;
75
+ }
76
+
77
+ let currentRequestTime = performance.now() - requestStartTime;
78
+ // smooth with moving average
79
+ requestTime = (requestTime * requestTimeSmoothing) + (currentRequestTime * (1.0 - requestTimeSmoothing));
80
+ let timeout = Math.max(0, targetTime - requestTime);
81
+
82
+ setTimeout(requestImage, timeout);
83
+ };
84
+
85
+ return ws;
86
+ }
87
+
88
+ console.log(`TARGET FPS: ${fpsTarget}`);
89
+ };
90
+
91
+ init();