将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
6.4 KiB
Python
223 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Physical camera / microphone → YouTube RTMPS live relay (Windows DirectShow first)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import configparser
|
||
import os
|
||
import platform
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from web2_proxy_config import merge_child_env
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
CONFIG_DIR = BASE_DIR / "config"
|
||
CAMERA_INI = CONFIG_DIR / "camera_live.ini"
|
||
YOUTUBE_INI = CONFIG_DIR / "youtube.ini"
|
||
|
||
RUNNING = True
|
||
|
||
|
||
def _on_signal(*_args):
|
||
global RUNNING
|
||
RUNNING = False
|
||
|
||
|
||
signal.signal(signal.SIGTERM, _on_signal)
|
||
signal.signal(signal.SIGINT, _on_signal)
|
||
|
||
|
||
def _load_camera() -> dict[str, str]:
|
||
defaults = {
|
||
"video_device": "",
|
||
"audio_device": "",
|
||
"width": "1280",
|
||
"height": "720",
|
||
"fps": "30",
|
||
"bitrate": "2500",
|
||
"use_gpu": "是",
|
||
}
|
||
if not CAMERA_INI.exists():
|
||
return defaults
|
||
cp = configparser.ConfigParser()
|
||
cp.read(CAMERA_INI, encoding="utf-8")
|
||
if not cp.has_section("camera"):
|
||
return defaults
|
||
out = dict(defaults)
|
||
for k in out:
|
||
if cp.has_option("camera", k):
|
||
out[k] = (cp.get("camera", k) or "").strip()
|
||
return out
|
||
|
||
|
||
def _load_youtube_key() -> tuple[str, str]:
|
||
rtmps = "rtmps://a.rtmp.youtube.com/live2/"
|
||
if not YOUTUBE_INI.exists():
|
||
return "", rtmps
|
||
cp = configparser.ConfigParser()
|
||
cp.read(YOUTUBE_INI, encoding="utf-8")
|
||
if not cp.has_section("youtube"):
|
||
return "", rtmps
|
||
key = ""
|
||
for name, val in cp.items("youtube"):
|
||
v = (val or "").strip()
|
||
if name in ("key", "stream_key") and v and not v.startswith("#"):
|
||
key = v
|
||
break
|
||
if key.startswith("rtmp://") or key.startswith("rtmps://"):
|
||
return key, key
|
||
return key, rtmps + key if key else ""
|
||
|
||
|
||
def _pick_nvenc(force_sw: bool) -> tuple[str, list[str]]:
|
||
if force_sw:
|
||
return "libx264", ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high"]
|
||
try:
|
||
if platform.system().lower() not in ("windows", "linux"):
|
||
raise RuntimeError("skip")
|
||
check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5)
|
||
if check.returncode != 0:
|
||
raise RuntimeError("no nvidia")
|
||
enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5)
|
||
if "h264_nvenc" in (enc.stdout or ""):
|
||
print("[INFO] 使用 NVENC 硬件编码")
|
||
return "h264_nvenc", ["-profile:v", "high", "-tune", "ll", "-rc", "cbr"]
|
||
except Exception:
|
||
pass
|
||
return "libx264", ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high"]
|
||
|
||
|
||
def _build_cmd(cfg: dict[str, str], youtube_url: str) -> list[str]:
|
||
w = int(cfg.get("width") or "1280")
|
||
h = int(cfg.get("height") or "720")
|
||
fps = int(float(cfg.get("fps") or "30"))
|
||
br = int(cfg.get("bitrate") or "2500")
|
||
b_v = f"{br}k"
|
||
maxrate = f"{min(int(br * 1.2), 8000)}k"
|
||
bufsize = f"{br * 2}k"
|
||
video_dev = cfg.get("video_device") or ""
|
||
audio_dev = cfg.get("audio_device") or ""
|
||
force_sw = (cfg.get("use_gpu") or "").lower() in ("否", "0", "false", "无", "关", "off")
|
||
codec, vopts = _pick_nvenc(force_sw)
|
||
|
||
sys_name = platform.system().lower()
|
||
if sys_name == "windows":
|
||
inp = f"video={video_dev}"
|
||
if audio_dev:
|
||
inp += f":audio={audio_dev}"
|
||
input_args = [
|
||
"-f",
|
||
"dshow",
|
||
"-video_size",
|
||
f"{w}x{h}",
|
||
"-framerate",
|
||
str(fps),
|
||
"-i",
|
||
inp,
|
||
]
|
||
elif sys_name == "darwin":
|
||
dev = video_dev or "0"
|
||
input_args = ["-f", "avfoundation", "-framerate", str(fps), "-video_size", f"{w}x{h}", "-i", f"{dev}:0"]
|
||
else:
|
||
dev = video_dev or "/dev/video0"
|
||
input_args = ["-f", "v4l2", "-framerate", str(fps), "-video_size", f"{w}x{h}", "-i", dev]
|
||
if audio_dev:
|
||
input_args = ["-f", "v4l2", "-i", dev, "-f", "alsa", "-i", audio_dev]
|
||
|
||
return [
|
||
"ffmpeg",
|
||
"-hide_banner",
|
||
"-loglevel",
|
||
"info",
|
||
*input_args,
|
||
"-c:v",
|
||
codec,
|
||
*vopts,
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-b:v",
|
||
b_v,
|
||
"-maxrate",
|
||
maxrate,
|
||
"-bufsize",
|
||
bufsize,
|
||
"-g",
|
||
str(fps * 2),
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
"-ar",
|
||
"44100",
|
||
"-f",
|
||
"flv",
|
||
youtube_url,
|
||
]
|
||
|
||
|
||
def main() -> None:
|
||
print("[INFO] 摄像头 → YouTube 转播进程已启动")
|
||
proc: subprocess.Popen | None = None
|
||
while RUNNING:
|
||
cfg = _load_camera()
|
||
key, youtube_url = _load_youtube_key()
|
||
if not key:
|
||
print("[WARN] 请在 config/youtube.ini 配置 YouTube 串流密钥 (key=…)")
|
||
time.sleep(5)
|
||
continue
|
||
if not (cfg.get("video_device") or "").strip():
|
||
print("[WARN] 请在摄像头配置中选择 video_device")
|
||
time.sleep(5)
|
||
continue
|
||
cmd = _build_cmd(cfg, youtube_url)
|
||
print("[INFO] 启动 FFmpeg 摄像头推流…")
|
||
print("[INFO] ffmpeg " + " ".join(cmd[1:]))
|
||
env = merge_child_env(os.environ.copy())
|
||
try:
|
||
proc = subprocess.Popen(
|
||
cmd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
env=env,
|
||
cwd=str(BASE_DIR),
|
||
)
|
||
except Exception as e:
|
||
print(f"[ERROR] 无法启动 FFmpeg: {e}")
|
||
time.sleep(5)
|
||
continue
|
||
|
||
assert proc.stdout is not None
|
||
while RUNNING:
|
||
line = proc.stdout.readline()
|
||
if not line:
|
||
break
|
||
print(line.rstrip(), flush=True)
|
||
code = proc.wait()
|
||
proc = None
|
||
if not RUNNING:
|
||
break
|
||
print(f"[WARN] FFmpeg 退出 code={code},3 秒后重试…")
|
||
time.sleep(3)
|
||
|
||
if proc and proc.poll() is None:
|
||
try:
|
||
proc.terminate()
|
||
proc.wait(timeout=5)
|
||
except Exception:
|
||
try:
|
||
proc.kill()
|
||
except Exception:
|
||
pass
|
||
print("[INFO] 摄像头转播进程已退出")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|