autoglm-gui 0.4.7__tar.gz → 0.4.9__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 (48) hide show
  1. autoglm_gui-0.4.9/AutoGLM_GUI/__init__.py +52 -0
  2. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/api/media.py +58 -44
  3. autoglm_gui-0.4.9/AutoGLM_GUI/platform_utils.py +37 -0
  4. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/scrcpy_stream.py +117 -143
  5. autoglm_gui-0.4.7/AutoGLM_GUI/static/assets/about-DIdU3ZqP.js → autoglm_gui-0.4.9/AutoGLM_GUI/static/assets/about-BI6OV6gm.js +1 -1
  6. autoglm_gui-0.4.9/AutoGLM_GUI/static/assets/chat-C_2Cot0q.js +25 -0
  7. autoglm_gui-0.4.7/AutoGLM_GUI/static/assets/index--ElIPD22.js → autoglm_gui-0.4.9/AutoGLM_GUI/static/assets/index-Dn3vR6uV.js +1 -1
  8. autoglm_gui-0.4.7/AutoGLM_GUI/static/assets/index-BuFMN8G5.js → autoglm_gui-0.4.9/AutoGLM_GUI/static/assets/index-Do7ha9Kf.js +1 -1
  9. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/static/index.html +1 -1
  10. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/PKG-INFO +4 -1
  11. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/README.md +3 -0
  12. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/pyproject.toml +1 -1
  13. autoglm_gui-0.4.7/AutoGLM_GUI/__init__.py +0 -9
  14. autoglm_gui-0.4.7/AutoGLM_GUI/static/assets/chat-_-u1G4Ee.js +0 -25
  15. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/.gitignore +0 -0
  16. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/__main__.py +0 -0
  17. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/adb_plus/__init__.py +0 -0
  18. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/adb_plus/screenshot.py +0 -0
  19. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/adb_plus/touch.py +0 -0
  20. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/api/__init__.py +0 -0
  21. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/api/agents.py +0 -0
  22. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/api/control.py +0 -0
  23. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/api/devices.py +0 -0
  24. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/config.py +0 -0
  25. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/schemas.py +0 -0
  26. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/server.py +0 -0
  27. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/state.py +0 -0
  28. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/static/assets/index-DCrxTz-A.css +0 -0
  29. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/AutoGLM_GUI/version.py +0 -0
  30. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/LICENSE +0 -0
  31. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/__init__.py +0 -0
  32. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/actions/__init__.py +0 -0
  33. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/actions/handler.py +0 -0
  34. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/adb/__init__.py +0 -0
  35. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/adb/connection.py +0 -0
  36. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/adb/device.py +0 -0
  37. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/adb/input.py +0 -0
  38. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/adb/screenshot.py +0 -0
  39. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/agent.py +0 -0
  40. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/config/__init__.py +0 -0
  41. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/config/apps.py +0 -0
  42. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/config/i18n.py +0 -0
  43. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/config/prompts.py +0 -0
  44. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/config/prompts_en.py +0 -0
  45. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/config/prompts_zh.py +0 -0
  46. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/model/__init__.py +0 -0
  47. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/phone_agent/model/client.py +0 -0
  48. {autoglm_gui-0.4.7 → autoglm_gui-0.4.9}/scrcpy-server-v3.3.3 +0 -0
@@ -0,0 +1,52 @@
1
+ """AutoGLM-GUI package metadata."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from functools import wraps
6
+ from importlib import metadata
7
+
8
+ # ============================================================================
9
+ # Fix Windows encoding issue: Force UTF-8 for all subprocess calls
10
+ # ============================================================================
11
+ # On Windows, subprocess defaults to GBK encoding which fails when ADB/scrcpy
12
+ # output UTF-8 characters. This monkey patch ensures all subprocess calls
13
+ # use UTF-8 encoding by default.
14
+
15
+ _original_run = subprocess.run
16
+ _original_popen = subprocess.Popen
17
+
18
+
19
+ @wraps(_original_run)
20
+ def _patched_run(*args, **kwargs):
21
+ """Patched subprocess.run that defaults to UTF-8 encoding on Windows."""
22
+ if sys.platform == "win32":
23
+ # Add encoding='utf-8' if text=True is set but encoding is not specified
24
+ if kwargs.get("text") or kwargs.get("universal_newlines"):
25
+ if "encoding" not in kwargs:
26
+ kwargs["encoding"] = "utf-8"
27
+ return _original_run(*args, **kwargs)
28
+
29
+
30
+ class _PatchedPopen(_original_popen):
31
+ """Patched subprocess.Popen that defaults to UTF-8 encoding on Windows."""
32
+
33
+ def __init__(self, *args, **kwargs):
34
+ if sys.platform == "win32":
35
+ # Add encoding='utf-8' if text=True is set but encoding is not specified
36
+ if kwargs.get("text") or kwargs.get("universal_newlines"):
37
+ if "encoding" not in kwargs:
38
+ kwargs["encoding"] = "utf-8"
39
+ super().__init__(*args, **kwargs)
40
+
41
+
42
+ # Apply the patches globally
43
+ subprocess.run = _patched_run
44
+ subprocess.Popen = _PatchedPopen
45
+
46
+ # ============================================================================
47
+
48
+ # Expose package version at runtime; fall back to "unknown" during editable/dev runs
49
+ try:
50
+ __version__ = metadata.version("autoglm-gui")
51
+ except metadata.PackageNotFoundError:
52
+ __version__ = "unknown"
@@ -1,6 +1,8 @@
1
1
  """Media routes: screenshot, video stream, stream reset."""
2
2
 
3
3
  import asyncio
4
+ import os
5
+ from pathlib import Path
4
6
 
5
7
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
8
 
@@ -11,6 +13,9 @@ from AutoGLM_GUI.state import scrcpy_locks, scrcpy_streamers
11
13
 
12
14
  router = APIRouter()
13
15
 
16
+ # Debug configuration: Set DEBUG_SAVE_VIDEO_STREAM=1 to save streams to debug_streams/
17
+ DEBUG_SAVE_STREAM = os.getenv("DEBUG_SAVE_VIDEO_STREAM", "0") == "1"
18
+
14
19
 
15
20
  @router.post("/api/video/reset")
16
21
  async def reset_video_stream(device_id: str | None = None) -> dict:
@@ -81,17 +86,14 @@ async def video_stream_ws(
81
86
 
82
87
  print(f"[video/stream] WebSocket connection for device {device_id}")
83
88
 
84
- # Debug: Save stream to file for analysis
85
- # Set to True for debugging (default: False)
86
- debug_save = False
89
+ # Debug: Save stream to file for analysis (controlled by DEBUG_SAVE_VIDEO_STREAM env var)
87
90
  debug_file = None
88
- if debug_save:
89
- import os
90
- from pathlib import Path
91
-
91
+ if DEBUG_SAVE_STREAM:
92
92
  debug_dir = Path("debug_streams")
93
93
  debug_dir.mkdir(exist_ok=True)
94
- debug_file_path = debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
94
+ debug_file_path = (
95
+ debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
96
+ )
95
97
  debug_file = open(debug_file_path, "wb")
96
98
  print(f"[video/stream] DEBUG: Saving stream to {debug_file_path}")
97
99
 
@@ -110,46 +112,45 @@ async def video_stream_ws(
110
112
  await scrcpy_streamers[device_id].start()
111
113
  print(f"[video/stream] Scrcpy server started for device {device_id}")
112
114
 
113
- # Read initial chunks and accumulate into a single buffer
114
- # Then parse the entire buffer to find complete NAL units
115
+ # Read NAL units until we have SPS, PPS, and IDR
115
116
  streamer = scrcpy_streamers[device_id]
116
- accumulated_buffer = bytearray()
117
- target_size = 50 * 1024 # Accumulate at least 50KB
118
117
 
119
- print(f"[video/stream] Accumulating initial data (target: {target_size} bytes)...")
120
- for attempt in range(10):
118
+ print("[video/stream] Reading NAL units for initialization...")
119
+ for attempt in range(20): # Max 20 NAL units for initialization
121
120
  try:
122
- # Disable auto-caching - we'll parse the entire buffer at once
123
- chunk = await streamer.read_h264_chunk(auto_cache=False)
124
- accumulated_buffer.extend(chunk)
121
+ nal_unit = await streamer.read_nal_unit(auto_cache=True)
122
+ nal_type = nal_unit[4] & 0x1F if len(nal_unit) > 4 else -1
123
+ nal_type_names = {5: "IDR", 7: "SPS", 8: "PPS"}
125
124
  print(
126
- f"[video/stream] Read chunk ({len(chunk)} bytes, total: {len(accumulated_buffer)} bytes)"
125
+ f"[video/stream] Read NAL unit: type={nal_type_names.get(nal_type, nal_type)}, size={len(nal_unit)} bytes"
127
126
  )
127
+
128
+ # Check if we have all required parameter sets
129
+ if (
130
+ streamer.cached_sps
131
+ and streamer.cached_pps
132
+ and streamer.cached_idr
133
+ ):
134
+ print(
135
+ f"[video/stream] ✓ Initialization complete: SPS={len(streamer.cached_sps)}B, PPS={len(streamer.cached_pps)}B, IDR={len(streamer.cached_idr)}B"
136
+ )
137
+ break
128
138
  except Exception as e:
129
- print(f"[video/stream] Failed to read chunk: {e}")
139
+ print(f"[video/stream] Failed to read NAL unit: {e}")
130
140
  await asyncio.sleep(0.5)
131
141
  continue
132
142
 
133
- # Check if we have enough data
134
- if len(accumulated_buffer) >= target_size:
135
- break
136
-
137
- # Now parse the entire accumulated buffer at once
138
- # This ensures NAL units spanning multiple chunks are detected as complete
139
- print(f"[video/stream] Parsing accumulated buffer ({len(accumulated_buffer)} bytes)...")
140
- streamer._cache_nal_units(bytes(accumulated_buffer))
141
-
142
- # Get initialization data
143
+ # Get initialization data (SPS + PPS + IDR)
143
144
  init_data = streamer.get_initialization_data()
144
145
  if not init_data:
145
146
  raise RuntimeError(
146
- f"Failed to find complete SPS/PPS/IDR in {len(accumulated_buffer)} bytes"
147
+ "Failed to get initialization data (missing SPS/PPS/IDR)"
147
148
  )
148
149
 
149
- # Send initialization data to first client
150
+ # Send initialization data as ONE message (SPS+PPS+IDR combined)
150
151
  await websocket.send_bytes(init_data)
151
152
  print(
152
- f"[video/stream] Sent initial data ({len(init_data)} bytes) to first client"
153
+ f"[video/stream] Sent initialization data to first client: {len(init_data)} bytes total"
153
154
  )
154
155
 
155
156
  # Debug: Save to file
@@ -188,10 +189,23 @@ async def video_stream_ws(
188
189
  await asyncio.sleep(0.5)
189
190
 
190
191
  if init_data:
191
- await websocket.send_bytes(init_data)
192
+ # Log what we're sending
192
193
  print(
193
- f"[video/stream] Sent initialization data (SPS+PPS+IDR, {len(init_data)} bytes) for device {device_id}"
194
+ f"[video/stream] Sending cached initialization data for device {device_id}:"
194
195
  )
196
+ print(
197
+ f" - SPS: {len(streamer.cached_sps) if streamer.cached_sps else 0}B"
198
+ )
199
+ print(
200
+ f" - PPS: {len(streamer.cached_pps) if streamer.cached_pps else 0}B"
201
+ )
202
+ print(
203
+ f" - IDR: {len(streamer.cached_idr) if streamer.cached_idr else 0}B"
204
+ )
205
+ print(f" - Total: {len(init_data)} bytes")
206
+
207
+ await websocket.send_bytes(init_data)
208
+ print("[video/stream] ✓ Initialization data sent successfully")
195
209
 
196
210
  # Debug: Save to file
197
211
  if debug_file:
@@ -210,23 +224,23 @@ async def video_stream_ws(
210
224
 
211
225
  stream_failed = False
212
226
  try:
213
- chunk_count = 0
227
+ nal_count = 0
214
228
  while True:
215
229
  try:
216
- # Disable auto_cache - we only cache once during initialization
217
- # Later chunks may have incomplete NAL units that would corrupt the cache
218
- h264_chunk = await streamer.read_h264_chunk(auto_cache=False)
219
- await websocket.send_bytes(h264_chunk)
230
+ # Read one complete NAL unit
231
+ # Each WebSocket message = one complete NAL unit (clear semantic boundary)
232
+ nal_unit = await streamer.read_nal_unit(auto_cache=True)
233
+ await websocket.send_bytes(nal_unit)
220
234
 
221
235
  # Debug: Save to file
222
236
  if debug_file:
223
- debug_file.write(h264_chunk)
237
+ debug_file.write(nal_unit)
224
238
  debug_file.flush()
225
239
 
226
- chunk_count += 1
227
- if chunk_count % 100 == 0:
240
+ nal_count += 1
241
+ if nal_count % 100 == 0:
228
242
  print(
229
- f"[video/stream] Device {device_id}: Sent {chunk_count} chunks"
243
+ f"[video/stream] Device {device_id}: Sent {nal_count} NAL units"
230
244
  )
231
245
  except ConnectionError as e:
232
246
  print(f"[video/stream] Device {device_id}: Connection error: {e}")
@@ -260,6 +274,6 @@ async def video_stream_ws(
260
274
  # Debug: Close file
261
275
  if debug_file:
262
276
  debug_file.close()
263
- print(f"[video/stream] DEBUG: Closed debug file")
277
+ print("[video/stream] DEBUG: Closed debug file")
264
278
 
265
279
  print(f"[video/stream] Device {device_id}: Stream ended")
@@ -0,0 +1,37 @@
1
+ """Platform-aware subprocess helpers to avoid duplicated Windows branches."""
2
+
3
+ import asyncio
4
+ import platform
5
+ import subprocess
6
+ from typing import Any, Sequence
7
+
8
+
9
+ def is_windows() -> bool:
10
+ """Return True if running on Windows."""
11
+ return platform.system() == "Windows"
12
+
13
+
14
+ async def run_cmd_silently(cmd: Sequence[str]) -> subprocess.CompletedProcess:
15
+ """Run a command, suppressing output; safe for async contexts on all platforms."""
16
+ if is_windows():
17
+ # Avoid blocking the event loop with a blocking subprocess call on Windows.
18
+ return await asyncio.to_thread(
19
+ subprocess.run, cmd, capture_output=True, check=False
20
+ )
21
+
22
+ process = await asyncio.create_subprocess_exec(
23
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
24
+ )
25
+ await process.wait()
26
+ return subprocess.CompletedProcess(cmd, process.returncode, None, None)
27
+
28
+
29
+ async def spawn_process(cmd: Sequence[str], *, capture_output: bool = False) -> Any:
30
+ """Start a long-running process with optional stdio capture."""
31
+ stdout = subprocess.PIPE if capture_output else None
32
+ stderr = subprocess.PIPE if capture_output else None
33
+
34
+ if is_windows():
35
+ return subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
36
+
37
+ return await asyncio.create_subprocess_exec(*cmd, stdout=stdout, stderr=stderr)
@@ -2,10 +2,12 @@
2
2
 
3
3
  import asyncio
4
4
  import os
5
- import platform
6
5
  import socket
7
6
  import subprocess
8
7
  from pathlib import Path
8
+ from typing import Any
9
+
10
+ from AutoGLM_GUI.platform_utils import is_windows, run_cmd_silently, spawn_process
9
11
 
10
12
 
11
13
  class ScrcpyStreamer:
@@ -34,7 +36,7 @@ class ScrcpyStreamer:
34
36
  self.port = port
35
37
  self.idr_interval_s = idr_interval_s
36
38
 
37
- self.scrcpy_process: subprocess.Popen | None = None
39
+ self.scrcpy_process: Any | None = None
38
40
  self.tcp_socket: socket.socket | None = None
39
41
  self.forward_cleanup_needed = False
40
42
 
@@ -47,6 +49,9 @@ class ScrcpyStreamer:
47
49
  self.sps_pps_locked = False # Lock SPS/PPS after initial complete capture
48
50
  # Note: IDR is NOT locked - we keep updating to the latest frame
49
51
 
52
+ # NAL unit reading buffer (for read_nal_unit method)
53
+ self._nal_read_buffer = bytearray()
54
+
50
55
  # Find scrcpy-server location
51
56
  self.scrcpy_server_path = self._find_scrcpy_server()
52
57
 
@@ -83,6 +88,10 @@ class ScrcpyStreamer:
83
88
 
84
89
  async def start(self) -> None:
85
90
  """Start scrcpy server and establish connection."""
91
+ # Clear NAL reading buffer to ensure clean state
92
+ self._nal_read_buffer.clear()
93
+ print("[ScrcpyStreamer] Cleared NAL read buffer")
94
+
86
95
  try:
87
96
  # 0. Kill existing scrcpy server processes on device
88
97
  print("[ScrcpyStreamer] Cleaning up existing scrcpy processes...")
@@ -119,50 +128,20 @@ class ScrcpyStreamer:
119
128
  if self.device_id:
120
129
  cmd_base.extend(["-s", self.device_id])
121
130
 
122
- # On Windows, use subprocess.run instead of asyncio.create_subprocess_exec
123
- # to avoid NotImplementedError in some Windows environments
124
- if platform.system() == "Windows":
125
- # Method 1: Try pkill
126
- cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
127
- subprocess.run(cmd, capture_output=True, check=False)
131
+ # Method 1: Try pkill
132
+ cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
133
+ await run_cmd_silently(cmd)
128
134
 
129
- # Method 2: Find and kill by PID (more reliable)
130
- cmd = cmd_base + [
131
- "shell",
132
- "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9",
133
- ]
134
- subprocess.run(cmd, capture_output=True, check=False)
135
-
136
- # Method 3: Remove port forward if exists
137
- cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
138
- subprocess.run(cmd_remove_forward, capture_output=True, check=False)
139
- else:
140
- # Original asyncio-based implementation for Unix systems
141
- # Method 1: Try pkill
142
- cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
143
- process = await asyncio.create_subprocess_exec(
144
- *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
145
- )
146
- await process.wait()
135
+ # Method 2: Find and kill by PID (more reliable)
136
+ cmd = cmd_base + [
137
+ "shell",
138
+ "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9",
139
+ ]
140
+ await run_cmd_silently(cmd)
147
141
 
148
- # Method 2: Find and kill by PID (more reliable)
149
- cmd = cmd_base + [
150
- "shell",
151
- "ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9",
152
- ]
153
- process = await asyncio.create_subprocess_exec(
154
- *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
155
- )
156
- await process.wait()
157
-
158
- # Method 3: Remove port forward if exists
159
- cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
160
- process = await asyncio.create_subprocess_exec(
161
- *cmd_remove_forward,
162
- stdout=subprocess.DEVNULL,
163
- stderr=subprocess.DEVNULL,
164
- )
165
- await process.wait()
142
+ # Method 3: Remove port forward if exists
143
+ cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
144
+ await run_cmd_silently(cmd_remove_forward)
166
145
 
167
146
  # Wait longer for resources to be released
168
147
  print("[ScrcpyStreamer] Waiting for cleanup to complete...")
@@ -175,13 +154,7 @@ class ScrcpyStreamer:
175
154
  cmd.extend(["-s", self.device_id])
176
155
  cmd.extend(["push", self.scrcpy_server_path, "/data/local/tmp/scrcpy-server"])
177
156
 
178
- if platform.system() == "Windows":
179
- subprocess.run(cmd, capture_output=True, check=False)
180
- else:
181
- process = await asyncio.create_subprocess_exec(
182
- *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
183
- )
184
- await process.wait()
157
+ await run_cmd_silently(cmd)
185
158
 
186
159
  async def _setup_port_forward(self) -> None:
187
160
  """Setup ADB port forwarding."""
@@ -190,13 +163,7 @@ class ScrcpyStreamer:
190
163
  cmd.extend(["-s", self.device_id])
191
164
  cmd.extend(["forward", f"tcp:{self.port}", "localabstract:scrcpy"])
192
165
 
193
- if platform.system() == "Windows":
194
- subprocess.run(cmd, capture_output=True, check=False)
195
- else:
196
- process = await asyncio.create_subprocess_exec(
197
- *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
198
- )
199
- await process.wait()
166
+ await run_cmd_silently(cmd)
200
167
  self.forward_cleanup_needed = True
201
168
 
202
169
  async def _start_server(self) -> None:
@@ -231,22 +198,14 @@ class ScrcpyStreamer:
231
198
  cmd.extend(server_args)
232
199
 
233
200
  # Capture stderr to see error messages
234
- if platform.system() == "Windows":
235
- # On Windows, use subprocess.Popen for async-like behavior
236
- self.scrcpy_process = subprocess.Popen(
237
- cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
238
- )
239
- else:
240
- self.scrcpy_process = await asyncio.create_subprocess_exec(
241
- *cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
242
- )
201
+ self.scrcpy_process = await spawn_process(cmd, capture_output=True)
243
202
 
244
203
  # Wait for server to start
245
204
  await asyncio.sleep(2)
246
205
 
247
206
  # Check if process is still running
248
207
  error_msg = None
249
- if platform.system() == "Windows":
208
+ if is_windows():
250
209
  # For Windows Popen, check returncode directly
251
210
  if self.scrcpy_process.poll() is not None:
252
211
  # Process has exited
@@ -307,9 +266,7 @@ class ScrcpyStreamer:
307
266
 
308
267
  raise ConnectionError("Failed to connect to scrcpy server")
309
268
 
310
- def _find_nal_units(
311
- self, data: bytes
312
- ) -> list[tuple[int, int, int, bool]]:
269
+ def _find_nal_units(self, data: bytes) -> list[tuple[int, int, int, bool]]:
313
270
  """Find NAL units in H.264 data.
314
271
 
315
272
  Returns:
@@ -385,9 +342,7 @@ class ScrcpyStreamer:
385
342
  f"[ScrcpyStreamer] ✓ Cached SPS ({size} bytes, complete={is_complete}): {hex_preview}..."
386
343
  )
387
344
  elif size < 10:
388
- print(
389
- f"[ScrcpyStreamer] ✗ Skipped short SPS ({size} bytes)"
390
- )
345
+ print(f"[ScrcpyStreamer] ✗ Skipped short SPS ({size} bytes)")
391
346
 
392
347
  elif nal_type == 8: # PPS
393
348
  # Only cache PPS if not yet locked
@@ -402,29 +357,22 @@ class ScrcpyStreamer:
402
357
  f"[ScrcpyStreamer] ✓ Cached PPS ({size} bytes, complete={is_complete}): {hex_preview}..."
403
358
  )
404
359
  elif size < 6:
405
- print(
406
- f"[ScrcpyStreamer] ✗ Skipped short PPS ({size} bytes)"
407
- )
360
+ print(f"[ScrcpyStreamer] ✗ Skipped short PPS ({size} bytes)")
408
361
 
409
362
  elif nal_type == 5: # IDR frame
410
- # CRITICAL: Only cache COMPLETE IDR frames
411
- # Incomplete IDR frames cause "error while decoding MB" errors
412
- if self.cached_sps and self.cached_pps and is_complete and size >= 1024:
363
+ # Cache IDR if it's large enough (size check is sufficient)
364
+ # Note: When called from read_nal_unit(), the NAL is guaranteed complete
365
+ # because we extract it between two start codes. The is_complete flag
366
+ # is only False because the NAL is isolated (no next start code in buffer).
367
+ if self.cached_sps and self.cached_pps and size >= 1024:
413
368
  is_first = self.cached_idr is None
414
369
  self.cached_idr = nal_data
415
370
  if is_first:
416
- print(
417
- f"[ScrcpyStreamer] ✓ Cached COMPLETE IDR frame ({size} bytes)"
418
- )
371
+ print(f"[ScrcpyStreamer] ✓ Cached IDR frame ({size} bytes)")
419
372
  # Don't log every IDR update (too verbose)
420
- elif not is_complete:
421
- if size > 1024: # Only log if it's a large incomplete IDR
422
- print(
423
- f"[ScrcpyStreamer] ⚠ Skipped INCOMPLETE IDR ({size} bytes, extends to chunk boundary)"
424
- )
425
373
  elif size < 1024:
426
374
  print(
427
- f"[ScrcpyStreamer] ✗ Skipped small IDR ({size} bytes)"
375
+ f"[ScrcpyStreamer] ✗ Skipped small IDR ({size} bytes, likely incomplete)"
428
376
  )
429
377
 
430
378
  # Lock SPS/PPS once we have complete initial parameters
@@ -432,60 +380,6 @@ class ScrcpyStreamer:
432
380
  self.sps_pps_locked = True
433
381
  print("[ScrcpyStreamer] 🔒 SPS/PPS locked (IDR will continue updating)")
434
382
 
435
- def _prepend_sps_pps_to_idr(self, data: bytes) -> bytes:
436
- """Prepend SPS/PPS before EVERY IDR frame unconditionally.
437
-
438
- This ensures that clients can start decoding from any IDR frame,
439
- even if they join mid-stream. We always prepend to guarantee
440
- that every IDR is self-contained.
441
-
442
- Returns:
443
- Modified data with SPS/PPS prepended to all IDR frames
444
- """
445
- if not self.cached_sps or not self.cached_pps:
446
- return data
447
-
448
- nal_units = self._find_nal_units(data)
449
- if not nal_units:
450
- return data
451
-
452
- # Find all IDR frames
453
- idr_positions = [
454
- (start, size)
455
- for start, nal_type, size, _ in nal_units
456
- if nal_type == 5
457
- ]
458
-
459
- if not idr_positions:
460
- return data
461
-
462
- # Build modified data by prepending SPS/PPS before each IDR
463
- result = bytearray()
464
- last_pos = 0
465
- sps_pps = self.cached_sps + self.cached_pps
466
-
467
- for idr_start, idr_size in idr_positions:
468
- # Add data before this IDR
469
- result.extend(data[last_pos:idr_start])
470
-
471
- # Check if SPS/PPS already exists right before this IDR
472
- # (to avoid duplicating if scrcpy already sent them)
473
- prepend_offset = max(0, idr_start - len(sps_pps))
474
- if data[prepend_offset:idr_start] != sps_pps:
475
- # Prepend SPS/PPS before this IDR
476
- result.extend(sps_pps)
477
- print(
478
- f"[ScrcpyStreamer] Prepended SPS/PPS before IDR at position {idr_start}"
479
- )
480
-
481
- # Update position to start of IDR
482
- last_pos = idr_start
483
-
484
- # Add remaining data (including all IDR frames and data after)
485
- result.extend(data[last_pos:])
486
-
487
- return bytes(result)
488
-
489
383
  def get_initialization_data(self) -> bytes | None:
490
384
  """Get cached SPS/PPS/IDR for initializing new connections.
491
385
 
@@ -563,6 +457,86 @@ class ScrcpyStreamer:
563
457
  )
564
458
  raise ConnectionError(f"Failed to read from socket: {e}") from e
565
459
 
460
+ async def read_nal_unit(self, auto_cache: bool = True) -> bytes:
461
+ """Read one complete NAL unit from socket.
462
+
463
+ This method ensures each returned chunk is a complete, self-contained NAL unit.
464
+ WebSocket messages will have clear semantic boundaries (one message = one NAL unit).
465
+
466
+ Args:
467
+ auto_cache: If True, automatically cache SPS/PPS/IDR from this NAL unit
468
+
469
+ Returns:
470
+ bytes: Complete NAL unit (including start code)
471
+
472
+ Raises:
473
+ ConnectionError: If socket is closed or error occurs
474
+ """
475
+ if not self.tcp_socket:
476
+ raise ConnectionError("Socket not connected")
477
+
478
+ while True:
479
+ # Look for start codes in buffer
480
+ buffer = bytes(self._nal_read_buffer)
481
+ start_positions = []
482
+
483
+ # Find all start codes (0x00 0x00 0x00 0x01 or 0x00 0x00 0x01)
484
+ i = 0
485
+ while i < len(buffer) - 3:
486
+ if buffer[i] == 0x00 and buffer[i + 1] == 0x00:
487
+ if buffer[i + 2] == 0x00 and buffer[i + 3] == 0x01:
488
+ start_positions.append(i)
489
+ i += 4
490
+ elif buffer[i + 2] == 0x01:
491
+ start_positions.append(i)
492
+ i += 3
493
+ else:
494
+ i += 1
495
+ else:
496
+ i += 1
497
+
498
+ # If we have at least 2 start codes, we can extract the first NAL unit
499
+ if len(start_positions) >= 2:
500
+ # Extract first complete NAL unit (from first start code to second start code)
501
+ nal_unit = buffer[start_positions[0] : start_positions[1]]
502
+
503
+ # Remove extracted NAL unit from buffer
504
+ self._nal_read_buffer = bytearray(buffer[start_positions[1] :])
505
+
506
+ # Cache parameter sets if enabled
507
+ if auto_cache:
508
+ self._cache_nal_units(nal_unit)
509
+
510
+ return nal_unit
511
+
512
+ # Need more data - read from socket
513
+ try:
514
+ loop = asyncio.get_event_loop()
515
+ chunk = await loop.run_in_executor(
516
+ None, self.tcp_socket.recv, 512 * 1024
517
+ )
518
+
519
+ if not chunk:
520
+ # Socket closed - return any remaining buffered data as final NAL unit
521
+ if len(self._nal_read_buffer) > 0:
522
+ final_nal = bytes(self._nal_read_buffer)
523
+ self._nal_read_buffer.clear()
524
+ if auto_cache:
525
+ self._cache_nal_units(final_nal)
526
+ return final_nal
527
+ raise ConnectionError("Socket closed by remote")
528
+
529
+ # Append new data to buffer
530
+ self._nal_read_buffer.extend(chunk)
531
+
532
+ except ConnectionError:
533
+ raise
534
+ except Exception as e:
535
+ print(
536
+ f"[ScrcpyStreamer] Unexpected error in read_nal_unit: {type(e).__name__}: {e}"
537
+ )
538
+ raise ConnectionError(f"Failed to read from socket: {e}") from e
539
+
566
540
  def stop(self) -> None:
567
541
  """Stop scrcpy server and cleanup resources."""
568
542
  # Close socket
@@ -1 +1 @@
1
- import{j as o}from"./index--ElIPD22.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-Dn3vR6uV.js";function t(){return o.jsx("div",{className:"p-2",children:o.jsx("h3",{children:"About"})})}export{t as component};