feat: 语音确认、联调与运维增强
- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining; 百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。 - Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。 - 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。 - 注意:.env 仍被 gitignore,本地密钥不会进入本提交。 Made-with: Cursor
This commit is contained in:
266
scripts/demo_client/fake_rtsp_from_file.py
Normal file
266
scripts/demo_client/fake_rtsp_from_file.py
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish local video file(s) as looping RTSP stream(s) (fake camera) for local dev.
|
||||
|
||||
The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API.
|
||||
This script does NOT change the application backend: it runs ffmpeg + a small
|
||||
RTSP server (MediaMTX) so you can point VIDEO_RTSP_URLS_JSON to rtsp://.../yourpath.
|
||||
|
||||
Requires:
|
||||
- ffmpeg in PATH
|
||||
- Docker, with the image pulled: bluenviron/mediamtx (recommended), OR a local
|
||||
`mediamtx` binary in PATH (advanced).
|
||||
|
||||
Single stream (legacy)::
|
||||
python3 scripts/demo_client/fake_rtsp_from_file.py /path/to/video.mp4
|
||||
python3 scripts/demo_client/fake_rtsp_from_file.py video.mp4 --port 18554 --path demo
|
||||
|
||||
Multiple streams (one MediaMTX, one ffmpeg per camera; different RTSP path per stream)::
|
||||
|
||||
python3 scripts/demo_client/fake_rtsp_from_file.py --port 18554 \\
|
||||
--stream 'or-cam-01|./a.mp4|demo1' \\
|
||||
--stream 'or-cam-02|./b.mp4|demo2'
|
||||
|
||||
--stream format: ``CAMERA_ID|FILE|RTSP_PATH`` (use quotes in shell; RTSP path is
|
||||
the last segment, e.g. ``demo1`` -> ``rtsp://127.0.0.1:<port>/demo1``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
MEDIAMTX_IMAGE = os.environ.get("MEDIAMTX_DOCKER_IMAGE", "bluenviron/mediamtx:latest")
|
||||
CONTAINER_NAME = "orm-fake-rtsp-mediamtx"
|
||||
|
||||
|
||||
def _has_docker() -> bool:
|
||||
return shutil.which("docker") is not None
|
||||
|
||||
|
||||
def _has_ffmpeg() -> bool:
|
||||
return shutil.which("ffmpeg") is not None
|
||||
|
||||
|
||||
def _stop_mediamtx_container() -> None:
|
||||
if not _has_docker():
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", CONTAINER_NAME],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
|
||||
def _start_mediamtx_docker(host_port: int) -> bool:
|
||||
_stop_mediamtx_container()
|
||||
cmd = [
|
||||
"docker", "run", "-d",
|
||||
"--name", CONTAINER_NAME,
|
||||
"-p", f"127.0.0.1:{host_port}:8554",
|
||||
MEDIAMTX_IMAGE,
|
||||
]
|
||||
print("[fake-rtsp] Starting MediaMTX:", " ".join(cmd), file=sys.stderr)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
print(f"[fake-rtsp] docker run failed: {exc}", file=sys.stderr)
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "").strip()
|
||||
print(f"[fake-rtsp] docker run exit {proc.returncode}: {err}", file=sys.stderr)
|
||||
return False
|
||||
atexit.register(_stop_mediamtx_container)
|
||||
return True
|
||||
|
||||
|
||||
def _parse_stream_arg(spec: str) -> tuple[str, Path, str]:
|
||||
parts = spec.split("|", 2)
|
||||
if len(parts) != 3:
|
||||
raise ValueError(
|
||||
f"Invalid --stream {spec!r}; expected CAM|FILE|RTSP_PATH (three fields separated by |)"
|
||||
)
|
||||
cam = parts[0].strip()
|
||||
fpath = Path(parts[1].strip()).expanduser()
|
||||
rpath = parts[2].strip().strip("/")
|
||||
if not cam:
|
||||
raise ValueError("empty camera id in --stream")
|
||||
if not rpath:
|
||||
rpath = "demo"
|
||||
return cam, fpath, rpath
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Loop video file(s) to RTSP URL(s) (dev fake camera; no backend code change).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"video",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="(single-stream mode) Path to a video file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default="demo",
|
||||
help="(single-stream mode) RTSP path segment (rtsp://host:port/<path>)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=18554,
|
||||
help="Host port mapped to MediaMTX RTSP (container internal 8554). Default: 18554",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stream",
|
||||
action="append",
|
||||
default=None,
|
||||
help=(
|
||||
"Multi-stream mode. Repeat for each camera. "
|
||||
"Format: CAM|FILE|RTSP_PATH e.g. or-cam-01|./a.mp4|demo1"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-docker",
|
||||
action="store_true",
|
||||
help="Do not start Docker; run MediaMTX yourself on the host port mapping.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not _has_ffmpeg():
|
||||
print("ffmpeg not found in PATH. Install ffmpeg and retry.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
streams: list[tuple[str, Path, str]] = []
|
||||
if args.stream:
|
||||
for s in args.stream:
|
||||
try:
|
||||
streams.append(_parse_stream_arg(s))
|
||||
except ValueError as exc:
|
||||
print(f"[fake-rtsp] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
elif args.video is not None:
|
||||
fpath = args.video.resolve()
|
||||
sp = (args.path or "demo").strip().strip("/") or "demo"
|
||||
streams = [("or-cam-01", fpath, sp)]
|
||||
else:
|
||||
parser.error("Provide a video file (single mode) or one or more --stream CAM|FILE|RTSP_PATH")
|
||||
|
||||
for cam, fpath, rpath in streams:
|
||||
rp_file = fpath.resolve()
|
||||
if not rp_file.is_file():
|
||||
print(f"File not found: {rp_file} (camera {cam!r})", file=sys.stderr)
|
||||
return 1
|
||||
for ch in rpath:
|
||||
if ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-":
|
||||
print(
|
||||
f"[fake-rtsp] RTSP path segment {rpath!r} for {cam!r} should be "
|
||||
r"[a-zA-Z0-9_.-] only; adjust --path/--stream",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
host_port: int = args.port
|
||||
if not args.no_docker:
|
||||
if not _has_docker():
|
||||
print("Docker not found. Use --no-docker and start MediaMTX manually.", file=sys.stderr)
|
||||
return 1
|
||||
if not _start_mediamtx_docker(host_port):
|
||||
return 1
|
||||
print("[fake-rtsp] MediaMTX container started. Waiting for RTSP…", file=sys.stderr)
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
print(
|
||||
f"[fake-rtsp] --no-docker: ensure an RTSP server is listening for publish on port {host_port}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
procs: list[subprocess.Popen] = []
|
||||
url_map: dict[str, str] = {}
|
||||
|
||||
for cam, fpath, stream_path in streams:
|
||||
fp = fpath.resolve()
|
||||
dest_url = f"rtsp://127.0.0.1:{host_port}/{stream_path}"
|
||||
url_map[cam] = dest_url
|
||||
publish_cmd: list[str] = [
|
||||
"ffmpeg",
|
||||
"-hide_banner", "-loglevel", "info",
|
||||
"-re",
|
||||
"-stream_loop", "-1",
|
||||
"-i", str(fp),
|
||||
"-c", "copy",
|
||||
"-f", "rtsp",
|
||||
"-rtsp_transport", "tcp",
|
||||
dest_url,
|
||||
]
|
||||
print("---", file=sys.stderr)
|
||||
print(f"Publish {cam} -> {dest_url}", file=sys.stderr)
|
||||
print(" " + " ".join(publish_cmd), file=sys.stderr)
|
||||
p = subprocess.Popen(publish_cmd) # noqa: S603
|
||||
procs.append(p)
|
||||
|
||||
j_compact = json.dumps(url_map, ensure_ascii=False, separators=(",", ":"))
|
||||
print("---", file=sys.stderr)
|
||||
print("RTSP mapping (set on monitoring server):", file=sys.stderr)
|
||||
for k, u in url_map.items():
|
||||
print(f" {k}: {u}", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("export (same machine as monitoring server, env snippet):", file=sys.stderr)
|
||||
print(f" export VIDEO_RTSP_URLS_JSON='{j_compact}'", file=sys.stderr)
|
||||
print("", file=sys.stderr)
|
||||
print("If the server runs in Docker on Mac/Win, use host.docker.internal, e.g.:", file=sys.stderr)
|
||||
for cam, u in url_map.items():
|
||||
h = u.replace("127.0.0.1", "host.docker.internal", 1)
|
||||
print(f" {cam}: {h}", file=sys.stderr)
|
||||
print("---", file=sys.stderr)
|
||||
print("Fake RTSP running (Ctrl+C to stop; MediaMTX container removed on exit).", file=sys.stderr)
|
||||
|
||||
def on_sigint(_sig: int, _frame) -> None:
|
||||
for p in procs:
|
||||
if p.poll() is None:
|
||||
p.terminate()
|
||||
_stop_mediamtx_container()
|
||||
raise SystemExit(130)
|
||||
|
||||
signal.signal(signal.SIGINT, on_sigint)
|
||||
signal.signal(signal.SIGTERM, on_sigint)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
for p in procs:
|
||||
if p.poll() is not None:
|
||||
print(
|
||||
f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise KeyboardInterrupt
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
for p in procs:
|
||||
if p.poll() is None:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
_stop_mediamtx_container()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user