新增拉流转推功能,并按业务重设计摄像头/拉流独立界面

支持 RTMP/SRT/HLS/FLV/WHEP 拉流到 YouTube;摄像头与拉流页采用独立工作台布局,更新 README 文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
eric
2026-07-07 21:16:43 -05:00
parent 9dd3a63109
commit 59ad853140
15 changed files with 2286 additions and 224 deletions

190
pull_stream_youtube.py Normal file
View File

@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
"""Pull stream (RTMP / SRT / HLS / FLV / WHEP) → YouTube RTMPS relay."""
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
from web2_pull_stream import build_pull_input_args, load_pull_stream_config, resolve_protocol
BASE_DIR = Path(__file__).resolve().parent
CONFIG_DIR = BASE_DIR / "config"
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_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", "veryfast", "-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", "veryfast", "-tune", "zerolatency", "-profile:v", "high"]
def _build_cmd(cfg: dict[str, str], youtube_url: str) -> list[str]:
input_args, proto = build_pull_input_args(cfg)
transcode = (cfg.get("transcode") or "transcode").lower()
br = int(cfg.get("bitrate") or "4500")
b_v = f"{br}k"
maxrate = f"{min(int(br * 1.2), 8000)}k"
bufsize = f"{br * 2}k"
force_sw = (cfg.get("use_gpu") or "").lower() in ("", "0", "false", "", "", "off")
print(f"[INFO] 拉流协议: {proto}")
print(f"[INFO] 源地址: {(cfg.get('source_url') or '').strip()}")
if transcode == "copy":
output_args = [
"-c:v",
"copy",
"-c:a",
"copy",
"-bsf:a",
"aac_adtstoasc",
"-f",
"flv",
youtube_url,
]
return ["ffmpeg", *input_args, *output_args]
codec, vopts = _pick_nvenc(force_sw)
fps = 30
output_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,
]
return ["ffmpeg", *input_args, *output_args]
def main() -> None:
print("[INFO] 拉流 → YouTube 转播进程已启动")
proc: subprocess.Popen | None = None
while RUNNING:
cfg = load_pull_stream_config(CONFIG_DIR)
key, youtube_url = _load_youtube_key()
if not key:
print("[WARN] 请在 config/youtube.ini 配置 YouTube 串流密钥 (key=…)")
time.sleep(5)
continue
if not (cfg.get("source_url") or "").strip():
print("[WARN] 请在拉流配置中填写 source_url")
time.sleep(5)
continue
if resolve_protocol(cfg) == "auto":
print("[WARN] 无法识别拉流协议,请在配置中指定 protocol")
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())
code = proc.wait()
proc = None
if not RUNNING:
break
print(f"[WARN] FFmpeg 退出 code={code}5 秒后重试…")
time.sleep(5)
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()