重构无人直播助手:内嵌 youLIVE、摄像头直推、推流诊断与 UI 焕新
将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
161
web2_camera.py
Normal file
161
web2_camera.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Physical camera capture config and FFmpeg device enumeration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def camera_ini_path(config_dir: Path) -> Path:
|
||||
return config_dir / "camera_live.ini"
|
||||
|
||||
|
||||
def default_camera_config() -> dict[str, str]:
|
||||
return {
|
||||
"video_device": "",
|
||||
"audio_device": "",
|
||||
"width": "1280",
|
||||
"height": "720",
|
||||
"fps": "30",
|
||||
"bitrate": "2500",
|
||||
"use_gpu": "是",
|
||||
"youtube_watch_url": "",
|
||||
}
|
||||
|
||||
|
||||
def load_camera_config(config_dir: Path) -> dict[str, str]:
|
||||
p = camera_ini_path(config_dir)
|
||||
out = default_camera_config()
|
||||
if not p.exists():
|
||||
return out
|
||||
cp = configparser.ConfigParser()
|
||||
try:
|
||||
cp.read(p, encoding="utf-8")
|
||||
except Exception:
|
||||
return out
|
||||
if not cp.has_section("camera"):
|
||||
return out
|
||||
for key in out:
|
||||
if cp.has_option("camera", key):
|
||||
out[key] = str(cp.get("camera", key) or "").strip()
|
||||
return out
|
||||
|
||||
|
||||
def save_camera_config(config_dir: Path, patch: dict[str, Any]) -> tuple[bool, str]:
|
||||
config_dir = Path(config_dir)
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
cur = load_camera_config(config_dir)
|
||||
for k, v in patch.items():
|
||||
if k in cur:
|
||||
cur[k] = str(v or "").strip()
|
||||
cp = configparser.ConfigParser()
|
||||
cp["camera"] = cur
|
||||
p = camera_ini_path(config_dir)
|
||||
try:
|
||||
with p.open("w", encoding="utf-8") as f:
|
||||
cp.write(f)
|
||||
return True, "摄像头配置已保存"
|
||||
except Exception as e:
|
||||
return False, f"保存失败: {e}"
|
||||
|
||||
|
||||
def camera_config_ready(config_dir: Path) -> str | None:
|
||||
cfg = load_camera_config(config_dir)
|
||||
if not str(cfg.get("video_device") or "").strip():
|
||||
return "请先选择摄像头设备"
|
||||
yp = Path(config_dir) / "youtube.ini"
|
||||
if not yp.exists():
|
||||
return "请先在 YouTube 配置中填写串流密钥"
|
||||
cp = configparser.ConfigParser()
|
||||
try:
|
||||
cp.read(yp, encoding="utf-8")
|
||||
except Exception:
|
||||
return "无法读取 youtube.ini"
|
||||
if not cp.has_section("youtube"):
|
||||
return "请先在 YouTube 配置中填写串流密钥"
|
||||
for name, val in cp.items("youtube"):
|
||||
v = (val or "").strip()
|
||||
if name in ("key", "stream_key") and v and not v.startswith("#"):
|
||||
return None
|
||||
return "请先在 YouTube 配置中填写串流密钥"
|
||||
|
||||
|
||||
def _ffmpeg_exe() -> str | None:
|
||||
return shutil.which("ffmpeg")
|
||||
|
||||
|
||||
def _run_ffmpeg_list(args: list[str], timeout: float = 12.0) -> str:
|
||||
exe = _ffmpeg_exe()
|
||||
if not exe:
|
||||
return ""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[exe, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return (proc.stderr or "") + (proc.stdout or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_dshow_devices(stderr: str) -> list[dict[str, str]]:
|
||||
devices: list[dict[str, str]] = []
|
||||
in_video = False
|
||||
in_audio = False
|
||||
for line in stderr.splitlines():
|
||||
if re.search(r"DirectShow video devices", line, re.I):
|
||||
in_video, in_audio = True, False
|
||||
continue
|
||||
if re.search(r"DirectShow audio devices", line, re.I):
|
||||
in_audio, in_video = True, False
|
||||
continue
|
||||
if line.strip().startswith("[") and not re.search(r'\] "(.*)" \((video|audio)\)', line):
|
||||
in_video = in_audio = False
|
||||
m = re.search(r'\] "(.*)" \((video|audio)\)', line)
|
||||
if not m:
|
||||
continue
|
||||
name, kind = m.group(1), m.group(2)
|
||||
if kind == "video" and in_video:
|
||||
devices.append({"id": name, "name": name, "kind": "video"})
|
||||
elif kind == "audio" and in_audio:
|
||||
devices.append({"id": name, "name": name, "kind": "audio"})
|
||||
return devices
|
||||
|
||||
|
||||
def _parse_v4l2_devices(stderr: str) -> list[dict[str, str]]:
|
||||
devices: list[dict[str, str]] = []
|
||||
for line in stderr.splitlines():
|
||||
m = re.search(r"\] (/dev/video\d+)", line)
|
||||
if m:
|
||||
dev = m.group(1)
|
||||
devices.append({"id": dev, "name": dev, "kind": "video"})
|
||||
return devices
|
||||
|
||||
|
||||
def list_capture_devices() -> dict[str, Any]:
|
||||
exe = _ffmpeg_exe()
|
||||
if not exe:
|
||||
return {"ok": False, "error": "未找到 ffmpeg,请确认安装目录完整", "devices": []}
|
||||
if sys.platform == "win32":
|
||||
stderr = _run_ffmpeg_list(["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"])
|
||||
devices = _parse_dshow_devices(stderr)
|
||||
elif sys.platform == "darwin":
|
||||
stderr = _run_ffmpeg_list(["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""])
|
||||
devices = []
|
||||
for line in stderr.splitlines():
|
||||
m = re.search(r"\[(\d+)\] (.+)", line)
|
||||
if m and re.search(r"AVFoundation|video devices", stderr, re.I):
|
||||
devices.append({"id": m.group(1), "name": m.group(2).strip(), "kind": "video"})
|
||||
else:
|
||||
stderr = _run_ffmpeg_list(["-hide_banner", "-list_devices", "true", "-f", "v4l2", "-i", "dummy"])
|
||||
devices = _parse_v4l2_devices(stderr)
|
||||
return {"ok": True, "devices": devices, "platform": sys.platform}
|
||||
Reference in New Issue
Block a user