mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
712 lines
26 KiB
Python
712 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
import asyncio
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
from typing import Optional, Dict, Any
|
||
import logging
|
||
|
||
import requests
|
||
import websockets
|
||
|
||
logging.getLogger("zeep").setLevel(logging.WARNING)
|
||
logging.getLogger("zeep.xsd.schema").setLevel(logging.WARNING)
|
||
logging.getLogger("zeep.xsd.schema.schema").setLevel(logging.WARNING)
|
||
from onvif import ONVIFCamera # 新增:ONVIF PTZ 控制
|
||
|
||
|
||
# ======================= 独立的 PTZController =======================
|
||
class PTZController:
|
||
def __init__(self, host: str, port: int, user: str, password: str):
|
||
"""
|
||
:param host: 摄像机 IP 或域名(和 RTSP 的一样即可)
|
||
:param port: ONVIF 端口(多数为 80,看你的设备)
|
||
:param user: 摄像机用户名
|
||
:param password: 摄像机密码
|
||
"""
|
||
self.host = host
|
||
self.port = port
|
||
self.user = user
|
||
self.password = password
|
||
|
||
self.cam: Optional[ONVIFCamera] = None
|
||
self.media_service = None
|
||
self.ptz_service = None
|
||
self.profile = None
|
||
|
||
def connect(self) -> bool:
|
||
"""
|
||
建立 ONVIF 连接并初始化 PTZ 能力,失败返回 False(不抛异常)
|
||
Note: 首先 pip install onvif-zeep
|
||
"""
|
||
try:
|
||
self.cam = ONVIFCamera(self.host, self.port, self.user, self.password)
|
||
self.media_service = self.cam.create_media_service()
|
||
self.ptz_service = self.cam.create_ptz_service()
|
||
profiles = self.media_service.GetProfiles()
|
||
if not profiles:
|
||
print("[PTZ] No media profiles found on camera.", file=sys.stderr)
|
||
return False
|
||
self.profile = profiles[0]
|
||
return True
|
||
except Exception as e:
|
||
print(f"[PTZ] Failed to init ONVIF PTZ: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
def _continuous_move(self, pan: float, tilt: float, zoom: float, duration: float) -> bool:
|
||
"""
|
||
连续移动一段时间(秒),之后自动停止。
|
||
此函数为阻塞模式:只有在 Stop 调用结束后,才返回 True/False。
|
||
"""
|
||
if not self.ptz_service or not self.profile:
|
||
print("[PTZ] _continuous_move: ptz_service or profile not ready", file=sys.stderr)
|
||
return False
|
||
|
||
# 进入前先强行停一下,避免前一次残留动作
|
||
self._force_stop()
|
||
|
||
req = self.ptz_service.create_type("ContinuousMove")
|
||
req.ProfileToken = self.profile.token
|
||
|
||
req.Velocity = {
|
||
"PanTilt": {"x": pan, "y": tilt},
|
||
"Zoom": {"x": zoom},
|
||
}
|
||
|
||
try:
|
||
print(f"[PTZ] ContinuousMove start: pan={pan}, tilt={tilt}, zoom={zoom}, duration={duration}", file=sys.stderr)
|
||
self.ptz_service.ContinuousMove(req)
|
||
except Exception as e:
|
||
print(f"[PTZ] ContinuousMove failed: {e}", file=sys.stderr)
|
||
return False
|
||
|
||
# 阻塞等待:这里决定“运动时间”
|
||
import time
|
||
wait_seconds = max(2 * duration, 0.0)
|
||
time.sleep(wait_seconds)
|
||
|
||
# 运动完成后强制停止
|
||
return self._force_stop()
|
||
|
||
def stop(self) -> bool:
|
||
"""
|
||
阻塞调用 Stop(带重试),成功 True,失败 False。
|
||
"""
|
||
return self._force_stop()
|
||
|
||
# ------- 对外动作接口(给 CameraController 调用) -------
|
||
# 所有接口都为“阻塞模式”:只有在运动 + Stop 完成后才返回 True/False
|
||
|
||
def move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||
print(f"[PTZ] move_up called, speed={speed}, duration={duration}", file=sys.stderr)
|
||
return self._continuous_move(pan=0.0, tilt=+speed, zoom=0.0, duration=duration)
|
||
|
||
def move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||
print(f"[PTZ] move_down called, speed={speed}, duration={duration}", file=sys.stderr)
|
||
return self._continuous_move(pan=0.0, tilt=-speed, zoom=0.0, duration=duration)
|
||
|
||
def move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
print(f"[PTZ] move_left called, speed={speed}, duration={duration}", file=sys.stderr)
|
||
return self._continuous_move(pan=-speed, tilt=0.0, zoom=0.0, duration=duration)
|
||
|
||
def move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
print(f"[PTZ] move_right called, speed={speed}, duration={duration}", file=sys.stderr)
|
||
return self._continuous_move(pan=+speed, tilt=0.0, zoom=0.0, duration=duration)
|
||
|
||
# ------- 占位的变倍接口(当前设备不支持) -------
|
||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
"""
|
||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||
"""
|
||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
||
return False
|
||
|
||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
"""
|
||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||
"""
|
||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
||
return False
|
||
|
||
def _force_stop(self, retries: int = 3, delay: float = 0.1) -> bool:
|
||
"""
|
||
尝试多次调用 Stop,作为“强制停止”手段。
|
||
:param retries: 重试次数
|
||
:param delay: 每次重试间隔(秒)
|
||
"""
|
||
if not self.ptz_service or not self.profile:
|
||
print("[PTZ] _force_stop: ptz_service or profile not ready", file=sys.stderr)
|
||
return False
|
||
|
||
import time
|
||
last_error = None
|
||
for i in range(retries):
|
||
try:
|
||
print(f"[PTZ] _force_stop: calling Stop(), attempt={i+1}", file=sys.stderr)
|
||
self.ptz_service.Stop({"ProfileToken": self.profile.token})
|
||
print("[PTZ] _force_stop: Stop() returned OK", file=sys.stderr)
|
||
return True
|
||
except Exception as e:
|
||
last_error = e
|
||
print(f"[PTZ] _force_stop: Stop() failed at attempt {i+1}: {e}", file=sys.stderr)
|
||
time.sleep(delay)
|
||
|
||
print(f"[PTZ] _force_stop: all {retries} attempts failed, last error: {last_error}", file=sys.stderr)
|
||
return False
|
||
|
||
# ======================= CameraController(加入 PTZ) =======================
|
||
|
||
class CameraController:
|
||
"""
|
||
Uni-Lab-OS 摄像头驱动(driver 形式)
|
||
启动 Uni-Lab-OS 后,立即开始推流
|
||
|
||
- WebSocket 信令:通过 signal_backend_url 连接到后端
|
||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
||
- 媒体服务器:通过 rtmp_url / webrtc_api / webrtc_stream_url
|
||
当前配置为 SRS,与独立 HostSimulator 独立运行脚本保持一致。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
host_id: str = "demo-host",
|
||
|
||
# (1)信令后端(WebSocket)
|
||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
||
|
||
# (2)媒体后端(RTMP + WebRTC API)
|
||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
||
camera_rtsp_url: str = "",
|
||
|
||
# (3)PTZ 控制相关(ONVIF)
|
||
ptz_host: str = "", # 一般就是摄像头 IP,比如 "192.168.31.164"
|
||
ptz_port: int = 80, # ONVIF 端口,不一定是 80,按实际情况改
|
||
ptz_user: str = "", # admin
|
||
ptz_password: str = "", # admin123
|
||
):
|
||
self.host_id = host_id
|
||
self.camera_rtsp_url = camera_rtsp_url
|
||
|
||
# 拼接最终的 WebSocket URL:.../host/<host_id>
|
||
signal_backend_url = signal_backend_url.rstrip("/")
|
||
if not signal_backend_url.endswith("/host"):
|
||
signal_backend_url = signal_backend_url + "/host"
|
||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
||
|
||
# 媒体服务器配置
|
||
self.rtmp_url = rtmp_url
|
||
self.webrtc_api = webrtc_api
|
||
self.webrtc_stream_url = webrtc_stream_url
|
||
|
||
# PTZ 控制
|
||
self.ptz_host = ptz_host
|
||
self.ptz_port = ptz_port
|
||
self.ptz_user = ptz_user
|
||
self.ptz_password = ptz_password
|
||
self._ptz: Optional[PTZController] = None
|
||
self._init_ptz_if_possible()
|
||
|
||
# 运行时状态
|
||
self._ws: Optional[object] = None
|
||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
||
self._running = False
|
||
self._loop_task: Optional[asyncio.Future] = None
|
||
|
||
# 事件循环 & 线程
|
||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||
self._loop_thread: Optional[threading.Thread] = None
|
||
|
||
try:
|
||
self.start()
|
||
except Exception as e:
|
||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
||
|
||
# ------------------------ PTZ 初始化 ------------------------
|
||
|
||
# ------------------------ PTZ 公开动作方法(一个动作一个函数) ------------------------
|
||
|
||
def ptz_move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||
print(f"[CameraController] ptz_move_up called, speed={speed}, duration={duration}")
|
||
return self._ptz.move_up(speed=speed, duration=duration)
|
||
|
||
def ptz_move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
||
print(f"[CameraController] ptz_move_down called, speed={speed}, duration={duration}")
|
||
return self._ptz.move_down(speed=speed, duration=duration)
|
||
|
||
def ptz_move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
print(f"[CameraController] ptz_move_left called, speed={speed}, duration={duration}")
|
||
return self._ptz.move_left(speed=speed, duration=duration)
|
||
|
||
def ptz_move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
print(f"[CameraController] ptz_move_right called, speed={speed}, duration={duration}")
|
||
return self._ptz.move_right(speed=speed, duration=duration)
|
||
|
||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
"""
|
||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||
"""
|
||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
||
return False
|
||
|
||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
||
"""
|
||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
||
"""
|
||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
||
return False
|
||
|
||
def ptz_stop(self):
|
||
if self._ptz is None:
|
||
print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
||
return
|
||
self._ptz.stop()
|
||
|
||
def _init_ptz_if_possible(self):
|
||
"""
|
||
根据 ptz_host / user / password 初始化 PTZ;
|
||
如果配置信息不全则不启用 PTZ(静默)。
|
||
"""
|
||
if not (self.ptz_host and self.ptz_user and self.ptz_password):
|
||
return
|
||
ctrl = PTZController(
|
||
host=self.ptz_host,
|
||
port=self.ptz_port,
|
||
user=self.ptz_user,
|
||
password=self.ptz_password,
|
||
)
|
||
if ctrl.connect():
|
||
self._ptz = ctrl
|
||
else:
|
||
self._ptz = None
|
||
|
||
# ---------------------------------------------------------------------
|
||
# 对外暴露的方法:供 Uni-Lab-OS 调用
|
||
# ---------------------------------------------------------------------
|
||
|
||
def start(self, config: Optional[Dict[str, Any]] = None):
|
||
"""
|
||
启动 Camera 连接 & 消息循环,并在启动时就开启 FFmpeg 推流,
|
||
"""
|
||
|
||
if self._running:
|
||
return {"status": "already_running", "host_id": self.host_id}
|
||
|
||
# 应用 config 覆盖(如果有)
|
||
if config:
|
||
self.camera_rtsp_url = config.get("camera_rtsp_url", self.camera_rtsp_url)
|
||
cfg_host_id = config.get("host_id")
|
||
if cfg_host_id:
|
||
self.host_id = cfg_host_id
|
||
|
||
signal_backend_url = config.get("signal_backend_url")
|
||
if signal_backend_url:
|
||
signal_backend_url = signal_backend_url.rstrip("/")
|
||
if not signal_backend_url.endswith("/host"):
|
||
signal_backend_url = signal_backend_url + "/host"
|
||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
||
|
||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
||
self.webrtc_stream_url = config.get(
|
||
"webrtc_stream_url", self.webrtc_stream_url
|
||
)
|
||
|
||
# PTZ 相关配置也允许通过 config 注入
|
||
self.ptz_host = config.get("ptz_host", self.ptz_host)
|
||
self.ptz_port = int(config.get("ptz_port", self.ptz_port))
|
||
self.ptz_user = config.get("ptz_user", self.ptz_user)
|
||
self.ptz_password = config.get("ptz_password", self.ptz_password)
|
||
self._init_ptz_if_possible()
|
||
|
||
self._running = True
|
||
|
||
# === start 时启动 FFmpeg 推流 ===
|
||
self._start_ffmpeg()
|
||
|
||
# 创建新的事件循环和线程(用于 WebSocket 信令)
|
||
self._loop = asyncio.new_event_loop()
|
||
|
||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
loop.run_forever()
|
||
except Exception as e:
|
||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
||
|
||
self._loop_thread = threading.Thread(
|
||
target=loop_runner, args=(self._loop,), daemon=True
|
||
)
|
||
self._loop_thread.start()
|
||
|
||
self._loop_task = asyncio.run_coroutine_threadsafe(
|
||
self._run_main_loop(), self._loop
|
||
)
|
||
|
||
return {
|
||
"status": "started",
|
||
"host_id": self.host_id,
|
||
"signal_backend_url": self.signal_backend_url,
|
||
"rtmp_url": self.rtmp_url,
|
||
"webrtc_api": self.webrtc_api,
|
||
"webrtc_stream_url": self.webrtc_stream_url,
|
||
}
|
||
|
||
def stop(self) -> Dict[str, Any]:
|
||
"""
|
||
停止推流 & 断开 WebSocket,并关闭事件循环线程。
|
||
"""
|
||
self._running = False
|
||
|
||
self._stop_ffmpeg()
|
||
|
||
if self._ws and self._loop is not None:
|
||
async def close_ws():
|
||
try:
|
||
await self._ws.close()
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when closing WebSocket: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
||
|
||
if self._loop_task is not None:
|
||
if not self._loop_task.done():
|
||
self._loop_task.cancel()
|
||
try:
|
||
self._loop_task.result()
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] main loop task error in stop(): {e}",
|
||
file=sys.stderr,
|
||
)
|
||
finally:
|
||
self._loop_task = None
|
||
|
||
if self._loop is not None:
|
||
try:
|
||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when stopping event loop: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
if self._loop_thread is not None:
|
||
try:
|
||
self._loop_thread.join(timeout=5)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when joining loop thread: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
finally:
|
||
self._loop_thread = None
|
||
|
||
self._ws = None
|
||
self._loop = None
|
||
|
||
return {"status": "stopped", "host_id": self.host_id}
|
||
|
||
def get_status(self) -> Dict[str, Any]:
|
||
"""
|
||
查询当前状态,方便在 Uni-Lab-OS 中做监控。
|
||
"""
|
||
ws_closed = None
|
||
if self._ws is not None:
|
||
ws_closed = getattr(self._ws, "closed", None)
|
||
|
||
if ws_closed is None:
|
||
websocket_connected = self._ws is not None
|
||
else:
|
||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
||
|
||
return {
|
||
"host_id": self.host_id,
|
||
"running": self._running,
|
||
"websocket_connected": websocket_connected,
|
||
"ffmpeg_running": bool(
|
||
self._ffmpeg_process and self._ffmpeg_process.poll() is None
|
||
),
|
||
"signal_backend_url": self.signal_backend_url,
|
||
"rtmp_url": self.rtmp_url,
|
||
}
|
||
|
||
# ---------------------------------------------------------------------
|
||
# 内部实现逻辑:WebSocket 循环 / FFmpeg / WebRTC Offer 处理
|
||
# ---------------------------------------------------------------------
|
||
|
||
async def _run_main_loop(self):
|
||
try:
|
||
while self._running:
|
||
try:
|
||
async with websockets.connect(self.signal_backend_url) as ws:
|
||
self._ws = ws
|
||
await self._recv_loop()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
if self._running:
|
||
print(
|
||
f"[CameraController] WebSocket connection error: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
await asyncio.sleep(3)
|
||
except asyncio.CancelledError:
|
||
pass
|
||
|
||
async def _recv_loop(self):
|
||
assert self._ws is not None
|
||
ws = self._ws
|
||
|
||
async for message in ws:
|
||
try:
|
||
data = json.loads(message)
|
||
except json.JSONDecodeError:
|
||
print(
|
||
f"[CameraController] received non-JSON message: {message}",
|
||
file=sys.stderr,
|
||
)
|
||
continue
|
||
|
||
try:
|
||
await self._handle_message(data)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error while handling message {data}: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
async def _handle_message(self, data: Dict[str, Any]):
|
||
"""
|
||
处理来自信令后端的消息:
|
||
- command: start_stream / stop_stream / ptz_xxx
|
||
- type: offer (WebRTC)
|
||
"""
|
||
cmd = data.get("command")
|
||
|
||
# ---------- 推流控制 ----------
|
||
if cmd == "start_stream":
|
||
try:
|
||
self._start_ffmpeg()
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when starting FFmpeg on start_stream: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
return
|
||
|
||
if cmd == "stop_stream":
|
||
try:
|
||
self._stop_ffmpeg()
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when stopping FFmpeg on stop_stream: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
return
|
||
|
||
# # ---------- PTZ 控制 ----------
|
||
# # 例如信令可以发:
|
||
# # {"command": "ptz_move", "direction": "down", "speed": 0.5, "duration": 0.5}
|
||
# if cmd == "ptz_move":
|
||
# if self._ptz is None:
|
||
# # 没有初始化 PTZ,静默忽略或打印一条
|
||
# print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
||
# return
|
||
|
||
# direction = data.get("direction", "")
|
||
# speed = float(data.get("speed", 0.5))
|
||
# duration = float(data.get("duration", 0.5))
|
||
|
||
# try:
|
||
# if direction == "up":
|
||
# self._ptz.move_up(speed=speed, duration=duration)
|
||
# elif direction == "down":
|
||
# self._ptz.move_down(speed=speed, duration=duration)
|
||
# elif direction == "left":
|
||
# self._ptz.move_left(speed=speed, duration=duration)
|
||
# elif direction == "right":
|
||
# self._ptz.move_right(speed=speed, duration=duration)
|
||
# elif direction == "zoom_in":
|
||
# self._ptz.zoom_in(speed=speed, duration=duration)
|
||
# elif direction == "zoom_out":
|
||
# self._ptz.zoom_out(speed=speed, duration=duration)
|
||
# elif direction == "stop":
|
||
# self._ptz.stop()
|
||
# else:
|
||
# # 未知方向,忽略
|
||
# pass
|
||
# except Exception as e:
|
||
# print(
|
||
# f"[CameraController] error when handling PTZ move: {e}",
|
||
# file=sys.stderr,
|
||
# )
|
||
# return
|
||
|
||
# ---------- WebRTC Offer ----------
|
||
if data.get("type") == "offer":
|
||
offer_sdp = data.get("sdp", "")
|
||
camera_id = data.get("cameraId", "camera-01")
|
||
try:
|
||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when handling WebRTC offer: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
return
|
||
|
||
if self._ws:
|
||
answer_payload = {
|
||
"type": "answer",
|
||
"sdp": answer_sdp,
|
||
"cameraId": camera_id,
|
||
"hostId": self.host_id,
|
||
}
|
||
try:
|
||
await self._ws.send(json.dumps(answer_payload))
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when sending WebRTC answer: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
# ------------------------ FFmpeg 相关 ------------------------
|
||
|
||
def _start_ffmpeg(self):
|
||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
||
return
|
||
|
||
cmd = [
|
||
"ffmpeg",
|
||
"-rtsp_transport", "tcp",
|
||
"-i", self.camera_rtsp_url,
|
||
|
||
"-c:v", "libx264",
|
||
"-preset", "ultrafast",
|
||
"-tune", "zerolatency",
|
||
"-profile:v", "baseline",
|
||
"-b:v", "1M",
|
||
"-maxrate", "1M",
|
||
"-bufsize", "2M",
|
||
"-g", "10",
|
||
"-keyint_min", "10",
|
||
"-sc_threshold", "0",
|
||
"-pix_fmt", "yuv420p",
|
||
"-x264-params", "bframes=0",
|
||
|
||
"-c:a", "aac",
|
||
"-ar", "44100",
|
||
"-ac", "1",
|
||
"-b:a", "64k",
|
||
|
||
"-f", "flv",
|
||
self.rtmp_url,
|
||
]
|
||
|
||
try:
|
||
self._ffmpeg_process = subprocess.Popen(
|
||
cmd,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.STDOUT,
|
||
shell=False,
|
||
)
|
||
except Exception as e:
|
||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
||
self._ffmpeg_process = None
|
||
raise
|
||
|
||
def _stop_ffmpeg(self):
|
||
proc = self._ffmpeg_process
|
||
|
||
if proc and proc.poll() is None:
|
||
try:
|
||
proc.terminate()
|
||
try:
|
||
proc.wait(timeout=5)
|
||
except subprocess.TimeoutExpired:
|
||
try:
|
||
proc.kill()
|
||
try:
|
||
proc.wait(timeout=2)
|
||
except subprocess.TimeoutExpired:
|
||
print(
|
||
f"[CameraController] FFmpeg process did not exit even after kill (pid={proc.pid})",
|
||
file=sys.stderr,
|
||
)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] failed to kill FFmpeg process: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] error when stopping FFmpeg: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
self._ffmpeg_process = None
|
||
|
||
# ------------------------ WebRTC Offer 相关 ------------------------
|
||
|
||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
||
payload = {
|
||
"api": self.webrtc_api,
|
||
"streamurl": self.webrtc_stream_url,
|
||
"sdp": offer_sdp,
|
||
}
|
||
|
||
headers = {"Content-Type": "application/json"}
|
||
|
||
def _do_request():
|
||
return requests.post(
|
||
self.webrtc_api,
|
||
json=payload,
|
||
headers=headers,
|
||
timeout=10,
|
||
)
|
||
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
resp = await loop.run_in_executor(None, _do_request)
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] failed to send offer to media server: {e}",
|
||
file=sys.stderr,
|
||
)
|
||
raise
|
||
|
||
try:
|
||
resp.raise_for_status()
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] media server HTTP error: {e}, "
|
||
f"status={resp.status_code}, body={resp.text[:200]}",
|
||
file=sys.stderr,
|
||
)
|
||
raise
|
||
|
||
try:
|
||
data = resp.json()
|
||
except Exception as e:
|
||
print(
|
||
f"[CameraController] failed to parse media server JSON: {e}, "
|
||
f"raw={resp.text[:200]}",
|
||
file=sys.stderr,
|
||
)
|
||
raise
|
||
|
||
answer_sdp = data.get("sdp", "")
|
||
if not answer_sdp:
|
||
msg = f"empty SDP from media server: {data}"
|
||
print(f"[CameraController] {msg}", file=sys.stderr)
|
||
raise RuntimeError(msg)
|
||
|
||
return answer_sdp |