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.
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/.gitignore +1 -0
- autoglm_gui-0.1.10/AutoGLM_GUI/scrcpy_stream.py +499 -0
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/server.py +113 -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
- autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/chat-Bl1mU48-.js +4 -0
- 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
- autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-BCzw2xc6.css +1 -0
- 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
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/static/index.html +2 -2
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/PKG-INFO +2 -2
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/pyproject.toml +2 -2
- autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/chat-DiGLXxmX.js +0 -1
- autoglm_gui-0.1.9/AutoGLM_GUI/static/assets/index-DJf9qMan.css +0 -1
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/__init__.py +0 -0
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/__main__.py +0 -0
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/adb_plus/__init__.py +0 -0
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/AutoGLM_GUI/adb_plus/screenshot.py +0 -0
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/LICENSE +0 -0
- {autoglm_gui-0.1.9 → autoglm_gui-0.1.10}/README.md +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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};
|