This commit is contained in:
Kevin
2026-05-21 15:48:03 +08:00
commit c869fcc6b9
261 changed files with 45423 additions and 0 deletions

126
scripts/demo_client/README.md Executable file
View File

@@ -0,0 +1,126 @@
# Demo Client
一个浏览器里的单页 demo用于手动触发 `app/api.py` 里部分 `/client/*` 接口:开始/结束手术、查询结果等。语音待确认、TTS 与麦克风录音请使用 **`voice-confirmation-web/`**(独立静态页)或其它专用客户端,或通过 Swagger/`curl` 直接调 API。
## 结构
```
scripts/demo_client/
server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json
index.html # 单文件页面(原生 JS零构建依赖
fake_rtsp_from_file.py # 无真摄像头时:把本地视频按文件时长推一次到 RTSPffmpeg + Docker MediaMTX
```
## 调试:无真实摄像头,用录好的视频模拟 RTSP
监控服务**只从 RTSP URL 拉流**`cv2.VideoCapture`**没有**「上传视频文件」的 HTTP 接口;在不改 Python 后端的前提下,只能让「摄像头地址」指向一个**真实可连的 RTSP 源。
推荐做法:在**本机**把视频文件用 **ffmpeg** 推到本机上的 **RTSP 服务**(脚本用 Docker 启动 [MediaMTX](https://github.com/bluenviron/mediamtx)),得到 `rtsp://127.0.0.1:<端口>/<路径>`,再通过**环境变量**告诉后端(**只改配置,不改仓库里的后端代码**
**单路**(一个文件、一个 `camera_id`,兼容旧命令):
```bash
# 依赖ffmpeg、Docker首次会拉取 bluenviron/mediamtx
cd /path/to/operation-room-monitor-server
python3 scripts/demo_client/fake_rtsp_from_file.py /path/to/recording.mp4 --port 18554 --path demo
```
**两路**(两路不同视频、两个 `camera_id`**一个** MediaMTX、**两路** ffmpeg每路用不同的 `RTSP_PATH`
```bash
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` 格式为 `CAMERA_ID|文件路径|RTSP_PATH`(竖线分隔,整条加引号),脚本会在 stderr 打印含 `video_rtsp_urls``voice_or_room_bindings: []`**站点 JSON 片段**,可合并进 `OR_SITE_CONFIG_JSON_FILE`
在**另一终端**启动监控服务前 `source` 或手动 `export` 上述变量,使 `POST /client/surgeries/start` 里使用的 `camera_ids`(如 `or-cam-01,or-cam-02`)能解析到对应 URL。Demo 页里「将 camera_id 填到开始手术」可一键同步两路 id。
### 监控在 Docker、假 RTSP 在宿主机(推荐联调拓扑)
常见安排是:**假摄像头脚本**`fake_rtsp_from_file.py` + ffmpeg + MediaMTX在**宿主机**终端里跑,推流地址是 `rtsp://127.0.0.1:<端口>/...`**监控 API 服务**在 **Docker 容器**里跑,容器里的进程要访问宿主机上的 RTSP应使用
- **macOS / Windows Docker Desktop**`rtsp://host.docker.internal:<端口>/<路径>`
- **Linux**`host.docker.internal` 可能未预置,可任选其一:
- 给该服务容器加 `--add-host=host.docker.internal:host-gateway`Docker 20.10+),或
- 直接把 URL 写成宿主在 **docker0/桥接网** 上可达的局域网 IP`192.168.x.x`),保证从容器内 `curl`/`ffprobe` 能通
生产/容器环境请使用 `**OR_SITE_CONFIG_JSON_FILE`** 指向完整站点 JSON`video_rtsp_urls``voice_or_room_bindings`)。**不要**在仅容器可解析的配置里写 `127.0.0.1` 去指宿主机上的 RTSP`127.0.0.1` 在容器内是容器自己)。
若监控与假 RTSP **都在宿主机同一系统**里直接跑(非容器),则用 `rtsp://127.0.0.1:...` 即可;否则应使用上面「容器连宿主」的写法。
发布失败时,可尝试把输入转码后再推流(示例,需自行调整):
```bash
ffmpeg -re -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:18554/demo
```
(仍须先自行启动 MediaMTX 或等价 RTSP 服务端;上例为**播完即止**,若要循环请加 `-stream_loop -1`。)
Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传。若必须完全手跑 `fake_rtsp_from_file.py`,请将其打印的站点 JSON 合并进 `OR_SITE_CONFIG_JSON_FILE`
## 一键开录(不再手抄命令)
在 §4.1 勾选 **「一键联调」** 后,在「调试」里为**路1/路2**各选一段视频,再点 **开始手术**,浏览器会把两路视频 **multipart 上传到监控 API**`POST /internal/demo/orchestrate-and-start`),由服务进程依次:
1. 落盘两路视频到临时目录
2. 用 Docker 起 MediaMTX、两路 ffmpeg 推 RTSP`fake_rtsp_from_file.py` 等效)
3. 把当前假流的 **video_rtsp_urls** 合并写入 `OR_SITE_CONFIG_JSON_FILE`(保留已有 `voice_or_room_bindings`;与开录/拉流同进程,固定本机回环)
4. 调用与普通开录相同逻辑
**需同时满足**
- `.env``DEMO_ORCHESTRATOR_ENABLED=true`(并重启 API
- 已设置 `OR_SITE_CONFIG_JSON_FILE` 指向**可写**的站点 JSONDocker 中请用 **bind-mount** 到容器内同一路径
- **运行 `main.py` 的进程**能执行本机 `docker``ffmpeg`(与手动跑 `fake_rtsp_from_file` 相同)。**仅将 API 放 Docker、且不挂载** `/var/run/docker.sock` 时,容器内往往无法为你在宿主机起 MediaMTX此时请继续用手动假流方式。
由于每次解析都会重新读取 `video_rtsp_url_map()`,覆盖 JSON 后**无需重启**主服务即可被下一次开录用到。
## 运行方式
```bash
# 1) 启动宿主机 API默认自动使用已激活的非 base conda 环境,否则使用 uv
./deploy.sh
# 2) 启动 demo 客户端静态服务(默认 0.0.0.0:38081局域网可访问
./start_demo_client.sh
# 或直接:
python scripts/demo_client/server.py
# 浏览器热重载(需 dev 依赖;会生成本目录 labels.json 供静态托管):
uv run --group dev python scripts/demo_client/server.py --live
# 或指定端口:
python scripts/demo_client/server.py -p 9000 --host 0.0.0.0
# 3) 浏览器访问:
open http://127.0.0.1:38081/
```
页面顶部的「服务端 Base URL」默认指向当前页面同主机的 `:38080`。局域网访问 Demo 页时,通常会自动变成 `http://<宿主机局域网IP>:38080`;如果后端部署在其他主机/端口,直接改这里即可。
### 与「语音确认」页一致的热重载
- **API**:默认在宿主机 conda/uv 环境运行;修改后端代码后重启 `./deploy.sh` 进程即可。
- **本 Demo**`--live`livereload监视本目录与 `app/resources/consumable_classifier_labels.yaml`)。
- **`voice-confirmation-web/`**:部署载体与 **`start_http.*`** 说明见 [voice-confirmation-web/README.md](../../voice-confirmation-web/README.md);仓库根的 `start_voice_confirmation_web.sh|.bat` 为封装入口(`--plain` / `--single` / livereload
## 页面包含什么
- `GET /health` 连通性检查
- §4.1 `POST /client/surgeries/start` — 含 `surgery_id` 校验、`camera_ids` 多值输入、`candidate_consumables` 标签编辑器(初始值从 `/labels.json` 载入,可增删)
- §4.2 `POST /client/surgeries/end`
- §4.3 `GET /client/surgeries/{id}/result` — 以表格渲染 `details``summary`
- **调试:无摄像头** — 多路视频选择与 `camera_id`;一键联调见上文;手跑假流见 `fake_rtsp_from_file.py` 与本文「调试:无真实摄像头」
`GET /client/surgeries/{id}/pending-confirmation``POST .../resolve` 仍由后端提供,本 Demo 页**不包含**相应 UI无待确认拉取、无 TTS、无麦克风录音
右侧「响应日志」按时间倒序展示每次请求的 method/url/status/body便于联调截图。
## 关于 `/labels.json`
`server.py` 在进程启动时读 `app/resources/consumable_classifier_labels.yaml``names` 映射并返回 `{"labels": [...]}`。优先用 `PyYAML`(主项目依赖已间接引入),缺失时回退到手写的最小 YAML 解析器(仅兼容该文件的已知形状)。
## 关闭 CORS生产环境
独立部署的 Demo 页和语音确认页跨域访问 API 时,需要 `DEMO_CORS_ENABLED=true`。正式部署建议在 `.env` 里把 `DEMO_CORS_ORIGINS` 收窄为具体来源,例如 `http://my-host:38081,https://or-demo.example.com`

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Publish local video file(s) to RTSP once per file (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); put the printed ``video_rtsp_urls`` into ``OR_SITE_CONFIG_JSON_FILE``.
Requires:
- ffmpeg in PATH
- Docker; 默认拉取 ``MEDIAMTX_DOCKER_IMAGE``DaoCloud 前缀的 bluenviron/mediamtx
或本地已拉取的镜像;也可用 PATH 中的 ``mediamtx`` 二进制(高级)。
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
# 默认 DaoCloud 镜像前缀;可设 MEDIAMTX_DOCKER_IMAGE=bluenviron/mediamtx:latest 直连 Docker Hub
MEDIAMTX_IMAGE = os.environ.get(
"MEDIAMTX_DOCKER_IMAGE",
"m.daocloud.io/docker.io/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="Play each video file once to an RTSP URL (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",
"-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)
site_doc = {"video_rtsp_urls": url_map, "voice_or_room_bindings": []}
print("---", file=sys.stderr)
print("RTSP mapping (per camera):", file=sys.stderr)
for k, u in url_map.items():
print(f" {k}: {u}", file=sys.stderr)
print("", file=sys.stderr)
print(
"OR site config (merge video_rtsp_urls into OR_SITE_CONFIG_JSON_FILE; add voice_or_room_bindings as needed):",
file=sys.stderr,
)
print(json.dumps(site_doc, ensure_ascii=False, indent=2), 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: each file plays once; script exits when ffmpeg ends "
"(Ctrl+C to stop early; 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())

1070
scripts/demo_client/index.html Executable file

File diff suppressed because it is too large Load Diff

211
scripts/demo_client/server.py Executable file
View File

@@ -0,0 +1,211 @@
"""Tiny stdlib HTTP server for the demo client page.
- Serves `index.html` (and other sibling files) from this directory.
- Exposes `GET /labels.json`, which parses the repo's
`app/resources/consumable_classifier_labels.yaml` and returns its label
list so the page can prefill the candidate-consumables input.
- ``--live`` 使用 dev 依赖 ``livereload``:修改本目录下 HTML/JS/CSS
或更新 ``labels.json``/YAML 后,浏览器自动刷新(需 ``uv run --group dev``)。
Run:
python scripts/demo_client/server.py # 0.0.0.0:38081
python scripts/demo_client/server.py -p 9000 # custom port
uv run --group dev python scripts/demo_client/server.py --live
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import threading
import time
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parents[1]
LABELS_YAML = REPO_ROOT / "app" / "resources" / "consumable_classifier_labels.yaml"
# ``--live`` 模式写入静态 `labels.json`,与 livereload 兼容(勿提交,见 .gitignore
LABELS_JSON_ARTIFACT = SCRIPT_DIR / "labels.json"
def _load_labels_with_pyyaml(path: Path) -> list[str] | None:
try:
import yaml # type: ignore[import-untyped]
except ImportError:
return None
try:
data: Any = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError):
return None
if not isinstance(data, dict):
return None
names = data.get("names")
if isinstance(names, dict):
try:
items = sorted(names.items(), key=lambda kv: int(kv[0]))
except (TypeError, ValueError):
items = list(names.items())
return [str(v) for _, v in items]
if isinstance(names, list):
return [str(v) for v in names]
return None
def _load_labels_fallback(path: Path) -> list[str]:
"""Minimal parser for the known labels.yaml shape: `<int>: <text>` under `names:`."""
labels: list[tuple[int, str]] = []
in_names = False
pattern = re.compile(r"^\s+(\d+)\s*:\s*(.+?)\s*$")
try:
text = path.read_text(encoding="utf-8")
except OSError:
return []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("names:"):
in_names = True
continue
if in_names and not line.startswith((" ", "\t")):
in_names = False
if not in_names:
continue
match = pattern.match(line)
if match:
labels.append((int(match.group(1)), match.group(2)))
labels.sort(key=lambda kv: kv[0])
return [name for _, name in labels]
def load_labels() -> list[str]:
if not LABELS_YAML.is_file():
return []
labels = _load_labels_with_pyyaml(LABELS_YAML)
if labels is None:
labels = _load_labels_fallback(LABELS_YAML)
return labels
def write_labels_json_artifact() -> None:
body = json.dumps({"labels": load_labels()}, ensure_ascii=False) + "\n"
LABELS_JSON_ARTIFACT.write_text(body, encoding="utf-8")
def _spawn_labels_yaml_poll() -> None:
p = LABELS_YAML
state: list[float] = [0.0]
if p.is_file():
state[0] = p.stat().st_mtime
def loop() -> None:
while True:
time.sleep(1.0)
if not p.is_file():
continue
m = p.stat().st_mtime
if m > state[0]:
state[0] = m
try:
write_labels_json_artifact()
except OSError as e:
print(
f"[demo-client] labels.json 写入失败: {e}",
file=sys.stderr,
)
threading.Thread(target=loop, daemon=True, name="DemoClientLabelsYamlWatch").start()
def run_livereload(host: str, port: int) -> None:
try:
from livereload import Server
except ImportError as exc: # pragma: no cover
print(
"``--live`` 需要 dev 依赖。请执行: uv sync --group dev",
file=sys.stderr,
)
raise SystemExit(1) from exc
write_labels_json_artifact()
_spawn_labels_yaml_poll()
server = Server()
server.watch(str(SCRIPT_DIR))
if LABELS_YAML.is_file():
server.watch(str(LABELS_YAML))
url = f"http://{host}:{port}/"
print(f"Demo client (livereload) at {url}")
print(f" static dir : {SCRIPT_DIR}")
print(f" labels yaml: {LABELS_YAML} -> {LABELS_JSON_ARTIFACT.name}")
print("Press Ctrl+C to stop.")
server.serve(
root=str(SCRIPT_DIR),
host=host,
port=port,
open_url=False,
debug=False,
)
class DemoHandler(SimpleHTTPRequestHandler):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs)
def do_GET(self) -> None: # noqa: N802 (stdlib override)
if self.path.split("?", 1)[0] == "/labels.json":
self._send_labels()
return
super().do_GET()
def _send_labels(self) -> None:
body = json.dumps({"labels": load_labels()}, ensure_ascii=False).encode("utf-8")
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
sys.stderr.write("[demo-client] %s - %s\n" % (self.address_string(), format % args))
def run_plain(host: str, port: int) -> None:
server = ThreadingHTTPServer((host, port), DemoHandler)
url = f"http://{host}:{port}/"
print(f"Demo client serving at {url}")
print(f" static dir : {SCRIPT_DIR}")
print(f" labels yaml: {LABELS_YAML}")
print("Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
finally:
server.server_close()
def main() -> None:
parser = argparse.ArgumentParser(description="Operation room demo client server")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("-p", "--port", type=int, default=38081)
parser.add_argument(
"--live",
action="store_true",
help="浏览器热重载(需 uv sync --group dev会生成同目录 labels.json",
)
args = parser.parse_args()
if args.live:
run_livereload(args.host, args.port)
else:
run_plain(args.host, args.port)
_main = "__main__"
if __name__ == _main:
main()