autoglm-gui 0.1.9__tar.gz → 0.1.10__tar.gz

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 (19) hide show
  1. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/.gitignore +1 -0
  2. autoglm_gui-0.1.10/AutoGLM_GUI/scrcpy_stream.py +499 -0
  3. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/server.py +113 -4
  4. autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/about-CG66VMpe.js → autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/about-uuv-AkSr.js +1 -1
  5. autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/chat-Bl1mU48-.js +4 -0
  6. autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/index-CW22sfnV.js → autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-B6TfcGH7.js +1 -1
  7. autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-BCzw2xc6.css +1 -0
  8. autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/index-Bw-ojnVn.js → autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-BhEqSAe_.js +5 -5
  9. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/static/index.html +2 -2
  10. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/PKG-INFO +2 -2
  11. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/pyproject.toml +2 -2
  12. autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/chat-DiGLXxmX.js +0 -1
  13. autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/index-DJf9qMan.css +0 -1
  14. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/__init__.py +0 -0
  15. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/__main__.py +0 -0
  16. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/adb_plus/__init__.py +0 -0
  17. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/adb_plus/screenshot.py +0 -0
  18. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/LICENSE +0 -0
  19. {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/README.md +0 -0
@@ -16,3 +16,4 @@ AutoGLM_GUI/static/
16
16
  # Frontend
17
17
  frontend/node_modules/
18
18
  frontend/dist/
19
+ .mcp.json
@@ -0,0 +1,499 @@
1
+ """scrcpy video streaming implementation."""
2
+
3
+ import asyncio
4
+ import os
5
+ import socket
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ class ScrcpyStreamer:
11
+ """Manages scrcpy server lifecycle and H.264 video streaming."""
12
+
13
+ def __init__(
14
+ self,
15
+ device_id: str | None = None,
16
+ max_size: int = 1280,
17
+ bit_rate: int = 1_000_000,
18
+ port: int = 27183,
19
+ ):
20
+ """Initialize ScrcpyStreamer.
21
+
22
+ Args:
23
+ device_id: ADB device serial (None for default device)
24
+ max_size: Maximum video dimension
25
+ bit_rate: Video bitrate in bps
26
+ port: TCP port for scrcpy socket
27
+ """
28
+ self.device_id = device_id
29
+ self.max_size = max_size
30
+ self.bit_rate = bit_rate
31
+ self.port = port
32
+
33
+ self.scrcpy_process: subprocess.Popen | None = None
34
+ self.tcp_socket: socket.socket | None = None
35
+ self.forward_cleanup_needed = False
36
+
37
+ # H.264 parameter sets cache (for new connections to join mid-stream)
38
+ # IMPORTANT: Only cache INITIAL complete SPS/PPS from stream start
39
+ # Later SPS/PPS may be truncated across chunks
40
+ self.cached_sps: bytes | None = None
41
+ self.cached_pps: bytes | None = None
42
+ self.cached_idr: bytes | None = None # Last IDR frame for immediate playback
43
+ self.sps_pps_locked = False # Lock SPS/PPS after initial complete capture
44
+ # Note: IDR is NOT locked - we keep updating to the latest frame
45
+
46
+ # Find scrcpy-server location
47
+ self.scrcpy_server_path = self._find_scrcpy_server()
48
+
49
+ def _find_scrcpy_server(self) -> str:
50
+ """Find scrcpy-server binary path."""
51
+ # Priority 1: Project root directory (for repository version)
52
+ project_root = Path(__file__).parent.parent
53
+ project_server = project_root / "scrcpy-server-v3.3.3"
54
+ if project_server.exists():
55
+ print(f"[ScrcpyStreamer] Using project scrcpy-server: {project_server}")
56
+ return str(project_server)
57
+
58
+ # Priority 2: Environment variable
59
+ scrcpy_server = os.getenv("SCRCPY_SERVER_PATH")
60
+ if scrcpy_server and os.path.exists(scrcpy_server):
61
+ print(f"[ScrcpyStreamer] Using env scrcpy-server: {scrcpy_server}")
62
+ return scrcpy_server
63
+
64
+ # Priority 3: Common system locations
65
+ paths = [
66
+ "/opt/homebrew/Cellar/scrcpy/3.3.3/share/scrcpy/scrcpy-server",
67
+ "/usr/local/share/scrcpy/scrcpy-server",
68
+ "/usr/share/scrcpy/scrcpy-server",
69
+ ]
70
+
71
+ for path in paths:
72
+ if os.path.exists(path):
73
+ print(f"[ScrcpyStreamer] Using system scrcpy-server: {path}")
74
+ return path
75
+
76
+ raise FileNotFoundError(
77
+ "scrcpy-server not found. Please put scrcpy-server-v3.3.3 in project root or set SCRCPY_SERVER_PATH."
78
+ )
79
+
80
+ async def start(self) -> None:
81
+ """Start scrcpy server and establish connection."""
82
+ try:
83
+ # 0. Kill existing scrcpy server processes on device
84
+ print("[ScrcpyStreamer] Cleaning up existing scrcpy processes...")
85
+ await self._cleanup_existing_server()
86
+
87
+ # 1. Push scrcpy-server to device
88
+ print("[ScrcpyStreamer] Pushing server to device...")
89
+ await self._push_server()
90
+
91
+ # 2. Setup port forwarding
92
+ print(f"[ScrcpyStreamer] Setting up port forwarding on port {self.port}...")
93
+ await self._setup_port_forward()
94
+
95
+ # 3. Start scrcpy server
96
+ print("[ScrcpyStreamer] Starting scrcpy server...")
97
+ await self._start_server()
98
+
99
+ # 4. Connect TCP socket
100
+ print("[ScrcpyStreamer] Connecting to TCP socket...")
101
+ await self._connect_socket()
102
+ print("[ScrcpyStreamer] Successfully connected!")
103
+
104
+ except Exception as e:
105
+ print(f"[ScrcpyStreamer] Failed to start: {e}")
106
+ import traceback
107
+ traceback.print_exc()
108
+ self.stop()
109
+ raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
110
+
111
+ async def _cleanup_existing_server(self) -> None:
112
+ """Kill existing scrcpy server processes on device."""
113
+ cmd_base = ["adb"]
114
+ if self.device_id:
115
+ cmd_base.extend(["-s", self.device_id])
116
+
117
+ # Method 1: Try pkill
118
+ cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
119
+ process = await asyncio.create_subprocess_exec(
120
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
121
+ )
122
+ await process.wait()
123
+
124
+ # Method 2: Find and kill by PID (more reliable)
125
+ cmd = cmd_base + [
126
+ "shell",
127
+ "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9"
128
+ ]
129
+ process = await asyncio.create_subprocess_exec(
130
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
131
+ )
132
+ await process.wait()
133
+
134
+ # Method 3: Remove port forward if exists
135
+ cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
136
+ process = await asyncio.create_subprocess_exec(
137
+ *cmd_remove_forward, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
138
+ )
139
+ await process.wait()
140
+
141
+ # Wait longer for resources to be released
142
+ print("[ScrcpyStreamer] Waiting for cleanup to complete...")
143
+ await asyncio.sleep(2)
144
+
145
+ async def _push_server(self) -> None:
146
+ """Push scrcpy-server to device."""
147
+ cmd = ["adb"]
148
+ if self.device_id:
149
+ cmd.extend(["-s", self.device_id])
150
+ cmd.extend(["push", self.scrcpy_server_path, "/data/local/tmp/scrcpy-server"])
151
+
152
+ process = await asyncio.create_subprocess_exec(
153
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
154
+ )
155
+ await process.wait()
156
+
157
+ async def _setup_port_forward(self) -> None:
158
+ """Setup ADB port forwarding."""
159
+ cmd = ["adb"]
160
+ if self.device_id:
161
+ cmd.extend(["-s", self.device_id])
162
+ cmd.extend(["forward", f"tcp:{self.port}", "localabstract:scrcpy"])
163
+
164
+ process = await asyncio.create_subprocess_exec(
165
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
166
+ )
167
+ await process.wait()
168
+ self.forward_cleanup_needed = True
169
+
170
+ async def _start_server(self) -> None:
171
+ """Start scrcpy server on device with retry on address conflict."""
172
+ max_retries = 3
173
+ retry_delay = 2
174
+
175
+ for attempt in range(max_retries):
176
+ cmd = ["adb"]
177
+ if self.device_id:
178
+ cmd.extend(["-s", self.device_id])
179
+
180
+ # Build server command
181
+ # Note: scrcpy 3.3+ uses different parameter format
182
+ server_args = [
183
+ "shell",
184
+ "CLASSPATH=/data/local/tmp/scrcpy-server",
185
+ "app_process",
186
+ "/",
187
+ "com.genymobile.scrcpy.Server",
188
+ "3.3.3", # scrcpy version - must match installed version
189
+ f"max_size={self.max_size}",
190
+ f"video_bit_rate={self.bit_rate}",
191
+ "max_fps=20", # ✅ Limit to 20fps to reduce data volume
192
+ "tunnel_forward=true",
193
+ "audio=false",
194
+ "control=false",
195
+ "cleanup=false",
196
+ # Force I-frame (IDR) every 1 second for reliable reconnection
197
+ "video_codec_options=i-frame-interval=1",
198
+ ]
199
+ cmd.extend(server_args)
200
+
201
+ # Capture stderr to see error messages
202
+ self.scrcpy_process = await asyncio.create_subprocess_exec(
203
+ *cmd,
204
+ stdout=subprocess.PIPE,
205
+ stderr=subprocess.PIPE
206
+ )
207
+
208
+ # Wait for server to start
209
+ await asyncio.sleep(2)
210
+
211
+ # Check if process is still running
212
+ if self.scrcpy_process.returncode is not None:
213
+ # Process has exited
214
+ stdout, stderr = await self.scrcpy_process.communicate()
215
+ error_msg = stderr.decode() if stderr else stdout.decode()
216
+
217
+ # Check if it's an "Address already in use" error
218
+ if "Address already in use" in error_msg:
219
+ if attempt < max_retries - 1:
220
+ print(f"[ScrcpyStreamer] Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...")
221
+ await self._cleanup_existing_server()
222
+ await asyncio.sleep(retry_delay)
223
+ continue
224
+ else:
225
+ raise RuntimeError(f"scrcpy server failed after {max_retries} attempts: {error_msg}")
226
+ else:
227
+ raise RuntimeError(f"scrcpy server exited immediately: {error_msg}")
228
+
229
+ # Server started successfully
230
+ return
231
+
232
+ raise RuntimeError("Failed to start scrcpy server after maximum retries")
233
+
234
+ async def _connect_socket(self) -> None:
235
+ """Connect to scrcpy TCP socket."""
236
+ self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
237
+ self.tcp_socket.settimeout(5)
238
+
239
+ # Increase socket buffer size for high-resolution video
240
+ # Default is often 64KB, but complex frames can be 200-500KB
241
+ try:
242
+ self.tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024) # 2MB
243
+ print("[ScrcpyStreamer] Set socket receive buffer to 2MB")
244
+ except OSError as e:
245
+ print(f"[ScrcpyStreamer] Warning: Failed to set socket buffer size: {e}")
246
+
247
+ # Retry connection
248
+ for _ in range(5):
249
+ try:
250
+ self.tcp_socket.connect(("localhost", self.port))
251
+ self.tcp_socket.settimeout(None) # Non-blocking for async
252
+ return
253
+ except (ConnectionRefusedError, OSError):
254
+ await asyncio.sleep(0.5)
255
+
256
+ raise ConnectionError("Failed to connect to scrcpy server")
257
+
258
+ def _find_nal_units(self, data: bytes) -> list[tuple[int, int, int]]:
259
+ """Find NAL units in H.264 data.
260
+
261
+ Returns:
262
+ List of (start_pos, nal_type, nal_size) tuples
263
+ """
264
+ nal_units = []
265
+ i = 0
266
+ data_len = len(data)
267
+
268
+ while i < data_len - 4:
269
+ # Look for start codes: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
270
+ if data[i:i+4] == b'\x00\x00\x00\x01':
271
+ start_code_len = 4
272
+ elif data[i:i+3] == b'\x00\x00\x01':
273
+ start_code_len = 3
274
+ else:
275
+ i += 1
276
+ continue
277
+
278
+ # NAL unit type is in lower 5 bits of first byte after start code
279
+ nal_start = i + start_code_len
280
+ if nal_start >= data_len:
281
+ break
282
+
283
+ nal_type = data[nal_start] & 0x1F
284
+
285
+ # Find next start code to determine NAL unit size
286
+ next_start = nal_start + 1
287
+ while next_start < data_len - 3:
288
+ if (data[next_start:next_start+4] == b'\x00\x00\x00\x01' or
289
+ data[next_start:next_start+3] == b'\x00\x00\x01'):
290
+ break
291
+ next_start += 1
292
+ else:
293
+ next_start = data_len
294
+
295
+ nal_size = next_start - i
296
+ nal_units.append((i, nal_type, nal_size))
297
+
298
+ i = next_start
299
+
300
+ return nal_units
301
+
302
+ def _cache_nal_units(self, data: bytes) -> None:
303
+ """Parse and cache INITIAL complete NAL units (SPS, PPS, IDR).
304
+
305
+ IMPORTANT: Only caches complete SPS/PPS from stream start.
306
+ NAL units may be truncated across chunks, so we validate minimum sizes
307
+ and lock the cache after getting complete initial parameters.
308
+ """
309
+ nal_units = self._find_nal_units(data)
310
+
311
+ for start, nal_type, size in nal_units:
312
+ nal_data = data[start:start+size]
313
+
314
+ if nal_type == 7: # SPS
315
+ # Only cache SPS if not yet locked
316
+ if not self.sps_pps_locked:
317
+ # Validate: SPS should be at least 10 bytes
318
+ if size >= 10 and not self.cached_sps:
319
+ self.cached_sps = nal_data
320
+ hex_preview = ' '.join(f'{b:02x}' for b in nal_data[:min(12, len(nal_data))])
321
+ print(f"[ScrcpyStreamer] ✓ Cached complete SPS ({size} bytes): {hex_preview}...")
322
+ elif size < 10:
323
+ print(f"[ScrcpyStreamer] ✗ Skipped truncated SPS ({size} bytes, too short)")
324
+
325
+ elif nal_type == 8: # PPS
326
+ # Only cache PPS if not yet locked
327
+ if not self.sps_pps_locked:
328
+ # Validate: PPS should be at least 6 bytes
329
+ if size >= 6 and not self.cached_pps:
330
+ self.cached_pps = nal_data
331
+ hex_preview = ' '.join(f'{b:02x}' for b in nal_data[:min(12, len(nal_data))])
332
+ print(f"[ScrcpyStreamer] ✓ Cached complete PPS ({size} bytes): {hex_preview}...")
333
+ elif size < 6:
334
+ print(f"[ScrcpyStreamer] ✗ Skipped truncated PPS ({size} bytes, too short)")
335
+
336
+ elif nal_type == 5: # IDR frame
337
+ # ✅ ALWAYS update IDR to keep the LATEST frame
338
+ # This gives better UX on reconnect (recent content, not stale startup frame)
339
+ if self.cached_sps and self.cached_pps:
340
+ is_first = self.cached_idr is None
341
+ self.cached_idr = nal_data
342
+ if is_first:
343
+ print(f"[ScrcpyStreamer] ✓ Cached initial IDR frame ({size} bytes)")
344
+ # Don't log every IDR update (too verbose)
345
+
346
+ # Lock SPS/PPS once we have complete initial parameters
347
+ if self.cached_sps and self.cached_pps and not self.sps_pps_locked:
348
+ self.sps_pps_locked = True
349
+ print("[ScrcpyStreamer] 🔒 SPS/PPS locked (IDR will continue updating)")
350
+
351
+ def _prepend_sps_pps_to_idr(self, data: bytes) -> bytes:
352
+ """Prepend SPS/PPS before EVERY IDR frame unconditionally.
353
+
354
+ This ensures that clients can start decoding from any IDR frame,
355
+ even if they join mid-stream. We always prepend to guarantee
356
+ that every IDR is self-contained.
357
+
358
+ Returns:
359
+ Modified data with SPS/PPS prepended to all IDR frames
360
+ """
361
+ if not self.cached_sps or not self.cached_pps:
362
+ return data
363
+
364
+ nal_units = self._find_nal_units(data)
365
+ if not nal_units:
366
+ return data
367
+
368
+ # Find all IDR frames
369
+ idr_positions = [(start, size) for start, nal_type, size in nal_units if nal_type == 5]
370
+
371
+ if not idr_positions:
372
+ return data
373
+
374
+ # Build modified data by prepending SPS/PPS before each IDR
375
+ result = bytearray()
376
+ last_pos = 0
377
+ sps_pps = self.cached_sps + self.cached_pps
378
+
379
+ for idr_start, idr_size in idr_positions:
380
+ # Add data before this IDR
381
+ result.extend(data[last_pos:idr_start])
382
+
383
+ # Check if SPS/PPS already exists right before this IDR
384
+ # (to avoid duplicating if scrcpy already sent them)
385
+ prepend_offset = max(0, idr_start - len(sps_pps))
386
+ if data[prepend_offset:idr_start] != sps_pps:
387
+ # Prepend SPS/PPS before this IDR
388
+ result.extend(sps_pps)
389
+ print(f"[ScrcpyStreamer] Prepended SPS/PPS before IDR at position {idr_start}")
390
+
391
+ # Update position to start of IDR
392
+ last_pos = idr_start
393
+
394
+ # Add remaining data (including all IDR frames and data after)
395
+ result.extend(data[last_pos:])
396
+
397
+ return bytes(result)
398
+
399
+ def get_initialization_data(self) -> bytes | None:
400
+ """Get cached SPS/PPS/IDR for initializing new connections.
401
+
402
+ Returns:
403
+ Concatenated SPS + PPS + IDR, or None if not available
404
+ """
405
+ if self.cached_sps and self.cached_pps:
406
+ # Return SPS + PPS (+ IDR if available)
407
+ init_data = self.cached_sps + self.cached_pps
408
+ if self.cached_idr:
409
+ init_data += self.cached_idr
410
+
411
+ # Validate data integrity
412
+ print(f"[ScrcpyStreamer] Returning init data:")
413
+ print(f" - SPS: {len(self.cached_sps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_sps[:8])}")
414
+ print(f" - PPS: {len(self.cached_pps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_pps[:8])}")
415
+ if self.cached_idr:
416
+ print(f" - IDR: {len(self.cached_idr)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_idr[:8])}")
417
+ print(f" - Total: {len(init_data)} bytes")
418
+
419
+ return init_data
420
+ return None
421
+
422
+ async def read_h264_chunk(self) -> bytes:
423
+ """Read H.264 data chunk from socket.
424
+
425
+ Returns:
426
+ bytes: Raw H.264 data with SPS/PPS prepended to IDR frames
427
+
428
+ Raises:
429
+ ConnectionError: If socket is closed or error occurs
430
+ """
431
+ if not self.tcp_socket:
432
+ raise ConnectionError("Socket not connected")
433
+
434
+ try:
435
+ # Use asyncio to make socket read non-blocking
436
+ # Read up to 512KB at once for high-quality frames
437
+ loop = asyncio.get_event_loop()
438
+ data = await loop.run_in_executor(None, self.tcp_socket.recv, 512 * 1024)
439
+
440
+ if not data:
441
+ raise ConnectionError("Socket closed by remote")
442
+
443
+ # Log large chunks (might indicate complex frames)
444
+ if len(data) > 200 * 1024: # > 200KB
445
+ print(f"[ScrcpyStreamer] Large chunk received: {len(data) / 1024:.1f} KB")
446
+
447
+ # Cache INITIAL complete SPS/PPS/IDR for future use
448
+ # (Later chunks may have truncated NAL units, so we only cache once)
449
+ self._cache_nal_units(data)
450
+
451
+ # NOTE: We don't automatically prepend SPS/PPS here because:
452
+ # 1. NAL units may be truncated across chunks
453
+ # 2. Prepending truncated SPS/PPS causes decoding errors
454
+ # 3. Instead, we send cached complete SPS/PPS when new connections join
455
+
456
+ return data
457
+ except ConnectionError:
458
+ raise
459
+ except Exception as e:
460
+ print(f"[ScrcpyStreamer] Unexpected error in read_h264_chunk: {type(e).__name__}: {e}")
461
+ raise ConnectionError(f"Failed to read from socket: {e}") from e
462
+
463
+ def stop(self) -> None:
464
+ """Stop scrcpy server and cleanup resources."""
465
+ # Close socket
466
+ if self.tcp_socket:
467
+ try:
468
+ self.tcp_socket.close()
469
+ except Exception:
470
+ pass
471
+ self.tcp_socket = None
472
+
473
+ # Kill server process
474
+ if self.scrcpy_process:
475
+ try:
476
+ self.scrcpy_process.terminate()
477
+ self.scrcpy_process.wait(timeout=2)
478
+ except Exception:
479
+ try:
480
+ self.scrcpy_process.kill()
481
+ except Exception:
482
+ pass
483
+ self.scrcpy_process = None
484
+
485
+ # Remove port forwarding
486
+ if self.forward_cleanup_needed:
487
+ try:
488
+ cmd = ["adb"]
489
+ if self.device_id:
490
+ cmd.extend(["-s", self.device_id])
491
+ cmd.extend(["forward", "--remove", f"tcp:{self.port}"])
492
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2)
493
+ except Exception:
494
+ pass
495
+ self.forward_cleanup_needed = False
496
+
497
+ def __del__(self):
498
+ """Cleanup on destruction."""
499
+ self.stop()
@@ -1,12 +1,13 @@
1
1
  """AutoGLM-GUI Backend API Server."""
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  import os
5
6
  from importlib.metadata import version as get_version
6
7
  from importlib.resources import files
7
8
  from pathlib import Path
8
9
 
9
- from fastapi import FastAPI, HTTPException
10
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
10
11
  from fastapi.middleware.cors import CORSMiddleware
11
12
  from fastapi.responses import FileResponse, StreamingResponse
12
13
  from fastapi.staticfiles import StaticFiles
@@ -16,6 +17,11 @@ from phone_agent.model import ModelConfig
16
17
  from pydantic import BaseModel, Field
17
18
 
18
19
  from AutoGLM_GUI.adb_plus import capture_screenshot
20
+ from AutoGLM_GUI.scrcpy_stream import ScrcpyStreamer
21
+
22
+ # 全局 scrcpy streamer 实例和锁
23
+ scrcpy_streamer: ScrcpyStreamer | None = None
24
+ scrcpy_lock = asyncio.Lock()
19
25
 
20
26
  # 获取包版本号
21
27
  try:
@@ -105,7 +111,7 @@ class ScreenshotResponse(BaseModel):
105
111
 
106
112
  # API 端点
107
113
  @app.post("/api/init")
108
- async def init_agent(request: InitRequest) -> dict:
114
+ def init_agent(request: InitRequest) -> dict:
109
115
  """初始化 PhoneAgent。"""
110
116
  global agent, last_model_config, last_agent_config
111
117
 
@@ -251,7 +257,7 @@ def chat_stream(request: ChatRequest):
251
257
 
252
258
 
253
259
  @app.get("/api/status", response_model=StatusResponse)
254
- async def get_status() -> StatusResponse:
260
+ def get_status() -> StatusResponse:
255
261
  """获取 Agent 状态和版本信息。"""
256
262
  global agent
257
263
 
@@ -263,7 +269,7 @@ async def get_status() -> StatusResponse:
263
269
 
264
270
 
265
271
  @app.post("/api/reset")
266
- async def reset_agent() -> dict:
272
+ def reset_agent() -> dict:
267
273
  """重置 Agent 状态。"""
268
274
  global agent, last_model_config, last_agent_config
269
275
 
@@ -291,6 +297,22 @@ async def reset_agent() -> dict:
291
297
  }
292
298
 
293
299
 
300
+ @app.post("/api/video/reset")
301
+ async def reset_video_stream() -> dict:
302
+ """Reset video stream (cleanup scrcpy server)."""
303
+ global scrcpy_streamer
304
+
305
+ async with scrcpy_lock:
306
+ if scrcpy_streamer is not None:
307
+ print("[video/reset] Stopping existing streamer...")
308
+ scrcpy_streamer.stop()
309
+ scrcpy_streamer = None
310
+ print("[video/reset] Streamer reset complete")
311
+ return {"success": True, "message": "Video stream reset"}
312
+ else:
313
+ return {"success": True, "message": "No active video stream"}
314
+
315
+
294
316
  @app.post("/api/screenshot", response_model=ScreenshotResponse)
295
317
  def take_screenshot(request: ScreenshotRequest) -> ScreenshotResponse:
296
318
  """获取设备截图。此操作无副作用,不影响 PhoneAgent 运行。"""
@@ -314,6 +336,93 @@ def take_screenshot(request: ScreenshotRequest) -> ScreenshotResponse:
314
336
  )
315
337
 
316
338
 
339
+ @app.websocket("/api/video/stream")
340
+ async def video_stream_ws(websocket: WebSocket):
341
+ """Stream real-time H.264 video from scrcpy server via WebSocket."""
342
+ global scrcpy_streamer
343
+
344
+ await websocket.accept()
345
+ print("[video/stream] WebSocket connection accepted")
346
+
347
+ # Use global lock to prevent concurrent streamer initialization
348
+ async with scrcpy_lock:
349
+ # Reuse existing streamer if available
350
+ if scrcpy_streamer is None:
351
+ print("[video/stream] Creating new streamer instance...")
352
+ scrcpy_streamer = ScrcpyStreamer(max_size=1280, bit_rate=4_000_000)
353
+
354
+ try:
355
+ print("[video/stream] Starting scrcpy server...")
356
+ await scrcpy_streamer.start()
357
+ print("[video/stream] Scrcpy server started successfully")
358
+ except Exception as e:
359
+ import traceback
360
+ print(f"[video/stream] Failed to start streamer: {e}")
361
+ print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
362
+ scrcpy_streamer.stop()
363
+ scrcpy_streamer = None
364
+ try:
365
+ await websocket.send_json({"error": str(e)})
366
+ except Exception:
367
+ pass
368
+ return
369
+ else:
370
+ print("[video/stream] Reusing existing streamer instance")
371
+
372
+ # Send ONLY SPS/PPS (not IDR) to initialize decoder
373
+ # Client will then wait for next live IDR frame (max 1s with i-frame-interval=1)
374
+ # This avoids issues with potentially corrupted cached IDR frames
375
+ if scrcpy_streamer.cached_sps and scrcpy_streamer.cached_pps:
376
+ init_data = scrcpy_streamer.cached_sps + scrcpy_streamer.cached_pps
377
+ await websocket.send_bytes(init_data)
378
+ print(f"[video/stream] ✓ Sent SPS/PPS ({len(init_data)} bytes), client will wait for live IDR")
379
+ else:
380
+ print("[video/stream] ⚠ Warning: No cached SPS/PPS available")
381
+
382
+ # Stream H.264 data to client
383
+ stream_failed = False
384
+ try:
385
+ chunk_count = 0
386
+ while True:
387
+ try:
388
+ h264_chunk = await scrcpy_streamer.read_h264_chunk()
389
+ await websocket.send_bytes(h264_chunk)
390
+ chunk_count += 1
391
+ if chunk_count % 100 == 0:
392
+ print(f"[video/stream] Sent {chunk_count} chunks")
393
+ except ConnectionError as e:
394
+ print(f"[video/stream] Connection error after {chunk_count} chunks: {e}")
395
+ stream_failed = True
396
+ # Don't send error if WebSocket already disconnected
397
+ try:
398
+ await websocket.send_json({"error": f"Stream error: {str(e)}"})
399
+ except Exception:
400
+ pass
401
+ break
402
+
403
+ except WebSocketDisconnect:
404
+ print("[video/stream] Client disconnected")
405
+ except Exception as e:
406
+ import traceback
407
+ print(f"[video/stream] Error: {e}")
408
+ print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
409
+ stream_failed = True
410
+ try:
411
+ await websocket.send_json({"error": str(e)})
412
+ except Exception:
413
+ pass
414
+
415
+ # Reset global streamer if stream failed
416
+ if stream_failed:
417
+ async with scrcpy_lock:
418
+ print("[video/stream] Stream failed, resetting global streamer...")
419
+ if scrcpy_streamer is not None:
420
+ scrcpy_streamer.stop()
421
+ scrcpy_streamer = None
422
+
423
+ print("[video/stream] Client stream ended")
424
+
425
+
317
426
  # 静态文件托管 - 使用包内资源定位
318
427
  def _get_static_dir() -> Path | None:
319
428
  """获取静态文件目录路径。"""
@@ -1 +1 @@
1
- import{j as o}from"./index-Bw-ojnVn.js";function t(){return o.jsx("div",{className:"p-2",children:o.jsx("h3",{children:"About"})})}export{t as component};
1
+ import{j as o}from"./index-BhEqSAe_.js";function t(){return o.jsx("div",{className:"p-2",children:o.jsx("h3",{children:"About"})})}export{t as component};