# -*- 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()