Files
gitlab-instance-0a899031_d2…/camera_youtube.py
eric d61526698d 重构无人直播助手:内嵌 youLIVE、摄像头直推、推流诊断与 UI 焕新
将 README 改写为产品文档;移除微信/EasyTier/工作台等遗留模块;新增 youLIVE 目标端监控、摄像头推流、质量守卫与转播历史能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 20:50:44 -05:00

223 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()