Alvin3y1 commited on
Commit
b7e88e3
·
verified ·
1 Parent(s): b1044a6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +75 -166
app.py CHANGED
@@ -5,43 +5,34 @@ import shutil
5
  import subprocess
6
  import time
7
  import logging
8
- import concurrent.futures
9
  import threading
10
- import numpy as np
11
  import uuid
 
 
 
 
 
12
  from aiohttp import web
13
- from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack, RTCIceServer, RTCConfiguration
14
- from av import VideoFrame
15
- import mss
16
 
17
  # --- Configuration ---
18
  HOST = "0.0.0.0"
19
  PORT = 7860
20
  DISPLAY_NUM = ":99"
21
 
22
- # Xvfb buffer limits
23
- MAX_WIDTH = 3840
24
- MAX_HEIGHT = 2160
25
-
26
- # Initial Resolution
27
- DEFAULT_WIDTH = 1280
28
- DEFAULT_HEIGHT = 720
29
 
30
  # Cloudflare TURN Credentials
31
  TURN_USER = "g08abe68c81a07f098bb5f0914549bb32440e5aad0b216c7fba2b61e76fd62c6"
32
  TURN_PASS = "aed1a10dd10eba9401ad9d99e5c66036d8a970eab5ba8e6dc9845ab57c771a7d"
33
 
34
- logging.basicConfig(level=logging.WARNING)
35
- logger = logging.getLogger("WebRTC-Chrome")
36
-
37
- # Increase worker threads for faster I/O
38
- executor = concurrent.futures.ThreadPoolExecutor(max_workers=16)
39
- thread_local_storage = threading.local()
40
-
41
- config = {
42
- "width": DEFAULT_WIDTH,
43
- "height": DEFAULT_HEIGHT
44
- }
45
 
46
  # --- High Performance Input Manager ---
47
  class InputManager:
@@ -51,11 +42,9 @@ class InputManager:
51
  self.scroll_accum = 0
52
 
53
  def start_process(self):
54
- # Only start if DISPLAY var is set
55
- if not os.environ.get("DISPLAY"):
56
- return
57
-
58
  try:
 
59
  self.process = subprocess.Popen(
60
  ['xdotool', '-'],
61
  stdin=subprocess.PIPE,
@@ -66,7 +55,6 @@ class InputManager:
66
  logger.error(f"Failed to start xdotool: {e}")
67
 
68
  def _send_raw(self, command):
69
- # Lazy initialization / Restart if dead
70
  if self.process is None or self.process.poll() is not None:
71
  self.start_process()
72
 
@@ -75,7 +63,6 @@ class InputManager:
75
  self.process.stdin.write(command + "\n")
76
  self.process.stdin.flush()
77
  except Exception:
78
- # If write fails, force restart next time
79
  try: self.process.kill()
80
  except: pass
81
  self.process = None
@@ -98,41 +85,35 @@ class InputManager:
98
  def mouse_move(self, x, y): self.send(f"mousemove {x} {y}")
99
  def mouse_down(self, btn): self.send(f"mousedown {btn}")
100
  def mouse_up(self, btn): self.send(f"mouseup {btn}")
101
- def click(self, btn, repeat=1): self.send(f"click --repeat {repeat} {btn}")
102
  def key_down(self, key): self.send(f"keydown {key}")
103
  def key_up(self, key): self.send(f"keyup {key}")
104
 
105
  input_manager = InputManager()
106
 
107
  # --- System Management ---
108
-
109
  def start_system():
110
- # 1. Setup Environment FIRST
111
  os.environ["DISPLAY"] = DISPLAY_NUM
112
 
113
  if not shutil.which("Xvfb"): raise FileNotFoundError("Xvfb missing")
114
 
115
  logger.warning(f"Starting Xvfb on {DISPLAY_NUM}...")
 
116
  subprocess.Popen([
117
  "Xvfb", DISPLAY_NUM,
118
- "-screen", "0", f"{MAX_WIDTH}x{MAX_HEIGHT}x24",
119
  "-ac", "-noreset"
120
  ])
121
 
122
- # 2. Wait for Xvfb to initialize
123
- time.sleep(3)
124
-
125
- # 3. Now start xdotool (InputManager)
126
- input_manager.start_process()
127
-
128
- # 4. Initialize Resolution
129
- set_resolution(DEFAULT_WIDTH, DEFAULT_HEIGHT)
130
 
131
- # 5. Start Window Manager
132
  if shutil.which("matchbox-window-manager"):
133
  subprocess.Popen("matchbox-window-manager -use_titlebar no", shell=True)
134
 
135
- # 6. Start Browser
 
 
 
136
  threading.Thread(target=keep_chrome_alive, daemon=True).start()
137
 
138
  def keep_chrome_alive():
@@ -143,11 +124,11 @@ def keep_chrome_alive():
143
  "--user-data-dir=/home/user/chrome-data "
144
  "--disable-infobars "
145
  "--disable-dev-shm-usage "
146
- "--disable-gpu "
147
  "--no-first-run "
148
  "--no-default-browser-check "
149
  "--window-position=0,0 "
150
- f"--window-size={MAX_WIDTH},{MAX_HEIGHT}"
151
  )
152
  while True:
153
  try:
@@ -155,106 +136,6 @@ def keep_chrome_alive():
155
  time.sleep(1)
156
  except: time.sleep(2)
157
 
158
- def get_xrandr_output_name():
159
- try:
160
- out = subprocess.check_output(["xrandr"]).decode()
161
- for line in out.splitlines():
162
- if " connected" in line:
163
- return line.split()[0]
164
- except: pass
165
- return "screen"
166
-
167
- def get_cvt_modeline(width, height, rate=60):
168
- H_BLANK = 160
169
- H_SYNC = 32
170
- H_FRONT_PORCH = 48
171
- V_FRONT_PORCH = 3
172
- V_SYNC = 5
173
- MIN_V_BLANK = 460
174
-
175
- frame_time_us = 1000000.0 / rate
176
- active_time_us = frame_time_us - MIN_V_BLANK
177
- if active_time_us <= 0: return None
178
-
179
- h_period_us = active_time_us / height
180
- v_blank_lines = int(MIN_V_BLANK / h_period_us) + 1
181
- v_total = height + v_blank_lines
182
- h_total = width + H_BLANK
183
- pclk = (h_total * v_total * rate) / 1000000.0
184
-
185
- h_sync_start = width + H_FRONT_PORCH
186
- h_sync_end = h_sync_start + H_SYNC
187
- v_sync_start = height + V_FRONT_PORCH
188
- v_sync_end = v_sync_start + V_SYNC
189
-
190
- return f'"{width}x{height}_60.00" {pclk:.2f} {width} {h_sync_start} {h_sync_end} {h_total} {height} {v_sync_start} {v_sync_end} {v_total} +hsync -vsync'
191
-
192
- def set_resolution(w, h):
193
- try:
194
- if w % 2 != 0: w += 1
195
- if h % 2 != 0: h += 1
196
-
197
- output = get_xrandr_output_name()
198
- mode_name = f"WEB_{w}x{h}_{str(uuid.uuid4())[:4]}"
199
- modeline_str = get_cvt_modeline(w, h)
200
- if not modeline_str: return
201
-
202
- parts = modeline_str.split()
203
- mode_params = parts[1:]
204
-
205
- subprocess.run(["xrandr", "--newmode", mode_name] + mode_params, check=True)
206
- subprocess.run(["xrandr", "--addmode", output, mode_name], check=True)
207
- subprocess.run(["xrandr", "--output", output, "--mode", mode_name], check=True)
208
-
209
- config["width"] = w
210
- config["height"] = h
211
- except Exception as e:
212
- logger.error(f"Resolution setup failed: {e}")
213
-
214
- # --- Video Capture ---
215
- class VirtualScreenTrack(VideoStreamTrack):
216
- kind = "video"
217
- def __init__(self):
218
- super().__init__()
219
- self.last_frame_time = 0
220
- self.frame_count = 0
221
-
222
- def _capture(self):
223
- try:
224
- if not hasattr(thread_local_storage, "sct"):
225
- thread_local_storage.sct = mss.mss()
226
-
227
- monitor = {"top": 0, "left": 0, "width": config["width"], "height": config["height"]}
228
- sct_img = thread_local_storage.sct.grab(monitor)
229
- img = np.array(sct_img)
230
- return img[..., :3]
231
- except: return None
232
-
233
- async def recv(self):
234
- FPS = 30
235
- FRAME_TIME = 1.0 / FPS
236
-
237
- pts, time_base = await self.next_timestamp()
238
-
239
- current_time = time.time()
240
- wait = FRAME_TIME - (current_time - self.last_frame_time)
241
- if wait > 0:
242
- await asyncio.sleep(wait)
243
-
244
- self.last_frame_time = time.time()
245
-
246
- frame = await asyncio.get_event_loop().run_in_executor(executor, self._capture)
247
-
248
- if frame is None:
249
- blank = np.zeros((config["height"], config["width"], 3), dtype=np.uint8)
250
- av_frame = VideoFrame.from_ndarray(blank, format="bgr24")
251
- else:
252
- av_frame = VideoFrame.from_ndarray(frame, format="bgr24")
253
-
254
- av_frame.pts = pts
255
- av_frame.time_base = time_base
256
- return av_frame
257
-
258
  # --- Input Mapping ---
259
  def map_key(key):
260
  if key == " ": return "space"
@@ -263,10 +144,7 @@ def map_key(key):
263
  "control": "ctrl", "shift": "shift", "alt": "alt", "meta": "super", "cmd": "super",
264
  "enter": "Return", "backspace": "BackSpace", "tab": "Tab", "escape": "Escape",
265
  "arrowup": "Up", "arrowdown": "Down", "arrowleft": "Left", "arrowright": "Right",
266
- "home": "Home", "end": "End", "pageup": "Page_Up", "pagedown": "Page_Down",
267
  "delete": "Delete", "insert": "Insert",
268
- "f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
269
- "f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
270
  "!": "exclam", "@": "at", "#": "numbersign", "$": "dollar", "%": "percent",
271
  "^": "asciicircum", "&": "ampersand", "*": "asterisk", "(": "parenleft",
272
  ")": "parenright", "-": "minus", "_": "underscore", "=": "equal", "+": "plus",
@@ -283,16 +161,9 @@ def process_input(data):
283
  msg = json.loads(data)
284
  t = msg.get("type")
285
 
286
- current_w = config["width"]
287
- current_h = config["height"]
288
-
289
- if t == "resize":
290
- target_w = int(msg.get("width"))
291
- target_h = int(msg.get("height"))
292
- set_resolution(target_w, target_h)
293
-
294
- elif t == "mousemove":
295
- input_manager.mouse_move(int(msg["x"] * current_w), int(msg["y"] * current_h))
296
  elif t == "mousedown":
297
  input_manager.mouse_down({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
298
  elif t == "mouseup":
@@ -305,52 +176,90 @@ def process_input(data):
305
  elif t == "keyup":
306
  k = map_key(msg.get("key"))
307
  if k: input_manager.key_up(k)
 
308
 
309
- except Exception:
310
- pass
311
 
312
- # --- Routes ---
313
  async def offer(request):
314
  try:
315
  params = await request.json()
316
  offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
317
  except: return web.Response(status=400)
318
 
 
319
  pc = RTCPeerConnection(RTCConfiguration(iceServers=[
320
  RTCIceServer(urls=["turns:turn.cloudflare.com:443?transport=tcp", "turn:turn.cloudflare.com:3478?transport=udp"], username=TURN_USER, credential=TURN_PASS),
321
  RTCIceServer(urls=["stun:stun.l.google.com:19302"])
322
  ]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  pcs.add(pc)
324
 
 
325
  @pc.on("connectionstatechange")
326
  async def on_state():
327
  if pc.connectionState in ["failed", "closed"]:
328
  await pc.close()
 
 
 
329
  pcs.discard(pc)
330
 
331
  @pc.on("datachannel")
332
  def on_dc(channel):
333
- channel.on("message", lambda m: asyncio.get_event_loop().run_in_executor(executor, process_input, m))
 
334
 
335
- pc.addTrack(VirtualScreenTrack())
336
  await pc.setRemoteDescription(offer)
337
  answer = await pc.createAnswer()
338
  await pc.setLocalDescription(answer)
339
 
 
340
  sdp = "\r\n".join([l for l in pc.localDescription.sdp.splitlines() if "a=candidate" not in l or "typ relay" in l]) + "\r\n"
341
- return web.Response(content_type="application/json", text=json.dumps({"sdp": sdp, "type": pc.localDescription.type}), headers={"Access-Control-Allow-Origin": "*"})
 
 
 
 
 
342
 
343
- async def index(r): return web.Response(text="helloworld")
344
  async def options(r): return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type"})
345
 
 
346
  pcs = set()
347
- async def on_shutdown(app): await asyncio.gather(*[pc.close() for pc in pcs])
 
 
348
 
 
349
  if __name__ == "__main__":
350
  start_system()
 
351
  app = web.Application()
352
  app.on_shutdown.append(on_shutdown)
353
  app.router.add_get("/", index)
354
  app.router.add_post("/offer", offer)
355
  app.router.add_options("/offer", options)
 
 
356
  web.run_app(app, host=HOST, port=PORT)
 
5
  import subprocess
6
  import time
7
  import logging
 
8
  import threading
 
9
  import uuid
10
+ import uvloop
11
+
12
+ # Enable high-performance event loop
13
+ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
14
+
15
  from aiohttp import web
16
+ from aiortc import RTCPeerConnection, RTCSessionDescription, RTCIceServer, RTCConfiguration
17
+ from aiortc.contrib.media import MediaPlayer
 
18
 
19
  # --- Configuration ---
20
  HOST = "0.0.0.0"
21
  PORT = 7860
22
  DISPLAY_NUM = ":99"
23
 
24
+ # Resolution Config
25
+ # Note: Changing resolution dynamically with x11grab is complex.
26
+ # We lock capture to these dimensions for stability.
27
+ WIDTH = 1280
28
+ HEIGHT = 720
 
 
29
 
30
  # Cloudflare TURN Credentials
31
  TURN_USER = "g08abe68c81a07f098bb5f0914549bb32440e5aad0b216c7fba2b61e76fd62c6"
32
  TURN_PASS = "aed1a10dd10eba9401ad9d99e5c66036d8a970eab5ba8e6dc9845ab57c771a7d"
33
 
34
+ logging.basicConfig(level=logging.WARNING)
35
+ logger = logging.getLogger("WebRTC-Fast")
 
 
 
 
 
 
 
 
 
36
 
37
  # --- High Performance Input Manager ---
38
  class InputManager:
 
42
  self.scroll_accum = 0
43
 
44
  def start_process(self):
45
+ if not os.environ.get("DISPLAY"): return
 
 
 
46
  try:
47
+ # - is stdin
48
  self.process = subprocess.Popen(
49
  ['xdotool', '-'],
50
  stdin=subprocess.PIPE,
 
55
  logger.error(f"Failed to start xdotool: {e}")
56
 
57
  def _send_raw(self, command):
 
58
  if self.process is None or self.process.poll() is not None:
59
  self.start_process()
60
 
 
63
  self.process.stdin.write(command + "\n")
64
  self.process.stdin.flush()
65
  except Exception:
 
66
  try: self.process.kill()
67
  except: pass
68
  self.process = None
 
85
  def mouse_move(self, x, y): self.send(f"mousemove {x} {y}")
86
  def mouse_down(self, btn): self.send(f"mousedown {btn}")
87
  def mouse_up(self, btn): self.send(f"mouseup {btn}")
 
88
  def key_down(self, key): self.send(f"keydown {key}")
89
  def key_up(self, key): self.send(f"keyup {key}")
90
 
91
  input_manager = InputManager()
92
 
93
  # --- System Management ---
 
94
  def start_system():
 
95
  os.environ["DISPLAY"] = DISPLAY_NUM
96
 
97
  if not shutil.which("Xvfb"): raise FileNotFoundError("Xvfb missing")
98
 
99
  logger.warning(f"Starting Xvfb on {DISPLAY_NUM}...")
100
+ # Start Xvfb
101
  subprocess.Popen([
102
  "Xvfb", DISPLAY_NUM,
103
+ "-screen", "0", f"{WIDTH}x{HEIGHT}x24",
104
  "-ac", "-noreset"
105
  ])
106
 
107
+ time.sleep(2) # Wait for X11
 
 
 
 
 
 
 
108
 
109
+ # Start Window Manager
110
  if shutil.which("matchbox-window-manager"):
111
  subprocess.Popen("matchbox-window-manager -use_titlebar no", shell=True)
112
 
113
+ # Initialize Input
114
+ input_manager.start_process()
115
+
116
+ # Start Browser
117
  threading.Thread(target=keep_chrome_alive, daemon=True).start()
118
 
119
  def keep_chrome_alive():
 
124
  "--user-data-dir=/home/user/chrome-data "
125
  "--disable-infobars "
126
  "--disable-dev-shm-usage "
127
+ "--disable-gpu " # Software rendering is often more stable for pure Xvfb capture
128
  "--no-first-run "
129
  "--no-default-browser-check "
130
  "--window-position=0,0 "
131
+ f"--window-size={WIDTH},{HEIGHT}"
132
  )
133
  while True:
134
  try:
 
136
  time.sleep(1)
137
  except: time.sleep(2)
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  # --- Input Mapping ---
140
  def map_key(key):
141
  if key == " ": return "space"
 
144
  "control": "ctrl", "shift": "shift", "alt": "alt", "meta": "super", "cmd": "super",
145
  "enter": "Return", "backspace": "BackSpace", "tab": "Tab", "escape": "Escape",
146
  "arrowup": "Up", "arrowdown": "Down", "arrowleft": "Left", "arrowright": "Right",
 
147
  "delete": "Delete", "insert": "Insert",
 
 
148
  "!": "exclam", "@": "at", "#": "numbersign", "$": "dollar", "%": "percent",
149
  "^": "asciicircum", "&": "ampersand", "*": "asterisk", "(": "parenleft",
150
  ")": "parenright", "-": "minus", "_": "underscore", "=": "equal", "+": "plus",
 
161
  msg = json.loads(data)
162
  t = msg.get("type")
163
 
164
+ # Mapping normalized coordinates (0.0 - 1.0) back to pixels
165
+ if t == "mousemove":
166
+ input_manager.mouse_move(int(msg["x"] * WIDTH), int(msg["y"] * HEIGHT))
 
 
 
 
 
 
 
167
  elif t == "mousedown":
168
  input_manager.mouse_down({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
169
  elif t == "mouseup":
 
176
  elif t == "keyup":
177
  k = map_key(msg.get("key"))
178
  if k: input_manager.key_up(k)
179
+ except Exception: pass
180
 
181
+ # --- WebRTC Routes ---
 
182
 
 
183
  async def offer(request):
184
  try:
185
  params = await request.json()
186
  offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
187
  except: return web.Response(status=400)
188
 
189
+ # 1. Create Peer Connection
190
  pc = RTCPeerConnection(RTCConfiguration(iceServers=[
191
  RTCIceServer(urls=["turns:turn.cloudflare.com:443?transport=tcp", "turn:turn.cloudflare.com:3478?transport=udp"], username=TURN_USER, credential=TURN_PASS),
192
  RTCIceServer(urls=["stun:stun.l.google.com:19302"])
193
  ]))
194
+
195
+ # 2. Configure FFmpeg Options for Low Latency
196
+ # These flags tell FFmpeg to capture X11 directly and encode immediately
197
+ options = {
198
+ "framerate": "30",
199
+ "video_size": f"{WIDTH}x{HEIGHT}",
200
+ "probesize": "32", # Fast startup
201
+ "analyzeduration": "0", # No delay for analysis
202
+ "fflags": "nobuffer", # Reduce buffer latency
203
+ "preset": "ultrafast", # Fastest encoding (trades CPU for bitrate, but low lag)
204
+ "tune": "zerolatency", # Critical for realtime
205
+ "threads": "4" # Use multi-threading
206
+ }
207
+
208
+ # 3. Create MediaPlayer (Replaces manual VideoTrack)
209
+ # This runs in a separate C-thread, bypassing Python GIL issues
210
+ player = MediaPlayer(DISPLAY_NUM, format="x11grab", options=options)
211
+
212
+ # Add track to PC
213
+ pc.addTrack(player.video)
214
  pcs.add(pc)
215
 
216
+ # Lifecycle management
217
  @pc.on("connectionstatechange")
218
  async def on_state():
219
  if pc.connectionState in ["failed", "closed"]:
220
  await pc.close()
221
+ # Ensure ffmpeg process is killed
222
+ if player:
223
+ player.video.stop()
224
  pcs.discard(pc)
225
 
226
  @pc.on("datachannel")
227
  def on_dc(channel):
228
+ # Offload input processing to default thread executor to avoid blocking async loop
229
+ channel.on("message", lambda m: asyncio.get_event_loop().run_in_executor(None, process_input, m))
230
 
231
+ # SDP Negotiation
232
  await pc.setRemoteDescription(offer)
233
  answer = await pc.createAnswer()
234
  await pc.setLocalDescription(answer)
235
 
236
+ # Clean SDP for faster connection
237
  sdp = "\r\n".join([l for l in pc.localDescription.sdp.splitlines() if "a=candidate" not in l or "typ relay" in l]) + "\r\n"
238
+
239
+ return web.Response(
240
+ content_type="application/json",
241
+ text=json.dumps({"sdp": sdp, "type": pc.localDescription.type}),
242
+ headers={"Access-Control-Allow-Origin": "*"}
243
+ )
244
 
245
+ async def index(r): return web.Response(text="WebRTC X11 Streamer Ready")
246
  async def options(r): return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type"})
247
 
248
+ # --- Cleanup ---
249
  pcs = set()
250
+ async def on_shutdown(app):
251
+ coros = [pc.close() for pc in pcs]
252
+ await asyncio.gather(*coros)
253
 
254
+ # --- Entry Point ---
255
  if __name__ == "__main__":
256
  start_system()
257
+
258
  app = web.Application()
259
  app.on_shutdown.append(on_shutdown)
260
  app.router.add_get("/", index)
261
  app.router.add_post("/offer", offer)
262
  app.router.add_options("/offer", options)
263
+
264
+ logger.warning(f"Server starting on {HOST}:{PORT}")
265
  web.run_app(app, host=HOST, port=PORT)