ffmpeg=>pipe=>mpv

This commit is contained in:
Your Name
2025-12-30 04:37:35 +00:00
parent 88d54c1679
commit 8953e2f4d4
2 changed files with 268 additions and 98 deletions

352
main.py
View File

@@ -1622,15 +1622,15 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
error_window.append(1)
else:
# ====================== 抖音直播 → HDMI 全屏纯净显示mpv 原始结构 + 检测/重连优化版) ======================
# ====================== 抖音 → HDMI 转播mpv 低延迟 + 稳定重连 + 完整下播检测) ======================
import queue
import unicodedata
import re # 新增:用于精确检测活跃输出
import select # 新增:非阻塞读取 mpv stderr
# import time
# import datetime
# import subprocess
import platform
import traceback
class StreamEnded(Exception):
"""主播真实下播时抛出,用于彻底退出循环"""
pass
def sanitize_title(text: str, max_len: int = 120) -> str:
@@ -1641,16 +1641,69 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
text = ''.join(ch for ch in text if ord(ch) <= 0xFFFF)
return text.strip()[:max_len]
# 参数配置(原始不变)
def _stderr_reader_thread(proc, q):
try:
while True:
line = proc.stderr.readline()
if not line:
break
q.put(line)
except Exception as e:
try:
q.put(f"__ERR__READER__:{e}")
except:
pass
finally:
try:
q.put(None)
except:
pass
def cleanup_proc(proc):
try:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=5)
except:
if proc.poll() is None:
proc.kill()
except:
pass
# ====================== 配置参数 ======================
MAX_RETRY_DELAY = 90
INITIAL_RETRY_DELAY = 3
NO_OUTPUT_TIMEOUT = 120 # 连续无活跃输出视为下播(更准确)
START_CHECK_AFTER = 25
STDERR_QUEUE_TIMEOUT = 1.0
NO_FRAME_TIMEOUT = 120 # 连续多少秒无新帧视为下播
START_CHECK_AFTER = 25 # 开始检测无帧的最短时间(避免刚开播误判)
STALE_OUTPUT_SECONDS = 120 # 长时间无任何日志输出,视为僵死
END_KEYWORDS = [
"connection reset by peer",
"failed to write trailer",
"invalid data found",
"server returned 403",
"server returned 404",
"ingestion error",
"access denied",
"packet too short",
"client disconnected",
"live has ended",
"no route to host",
"connection timed out",
]
timestamp_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
_clean_title_in_name = sanitize_title(title_in_name)
stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title_in_name) if _clean_title_in_name else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始抖音直播纯净转播到 HDMI: {stream_title}")
_clean_title = sanitize_title(title_in_name)
stream_title = sanitize_title(f"{anchor_name}{('_' + _clean_title) if _clean_title else ''}_{timestamp_str}")
print(f"[INFO] {rec_info} 开始转播到 HDMI: {stream_title}")
# 编码参数(全志 A733 无 NVIDIA 硬件加速,使用 ultrafast 软件编码降低 CPU 负载)
codec_v = "libx264"
preset_v = "ultrafast"
tune_param = ["-tune", "zerolatency"]
douyin_headers = (
"User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15\r\n"
@@ -1658,104 +1711,196 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
"Origin: https://live.douyin.com\r\n"
)
# mpv 命令(完全保持原始 vo=drm + 所有滤镜)
mpv_cmd_base = [
# ====================== mpv 命令DRM 直驱 HDMI + 硬件解码 + 极致低延迟) ======================
mpv_cmd = [
"mpv",
"--really-quiet", # 正式运行静音,调试时去掉看日志
"-", # 从管道读取 FLV
"--vo=drm", # DRM 直接驱动 HDMI无桌面环境最佳
"--hwdec=v4l2m2m", # Allwinner VPU 硬件解码 H.264(大幅降低 CPU
"--profile=low-latency",
"--no-cache",
"--untimed",
"--framedrop=vo",
"--video-sync=display-resample",
"--demuxer-lavf-format=flv",
"--demuxer-lavf-o=fflags=nobuffer",
"--video-sync=audio",
"--hwdec=auto-safe",
"--profile=low-latency",
"--vo=drm", # 保持原始
"--drm-device=/dev/dri/card0", # 保持原始
"--fs",
"--fs-screen=0",
"--vf=fps=30,"
"scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,"
"pad=720:1280:(ow-iw)/2:(oh-ih)/2:black,"
"transpose=2,"
"colorspace=all=bt709:iall=bt709,"
"eq=gamma_r=0.95:gamma_g=1.0:gamma_b=1.05:brightness=0.03:saturation=0.85:contrast=1.05",
f"--http-header-fields={douyin_headers}",
"--referrer=https://live.douyin.com/",
real_url
"--quiet",
"--vf=transpose=2,colorspace=all=bt709:iall=bt709,"
"eq=gamma_r=0.95:gamma_g=1.0:gamma_b=1.05:"
"brightness=0.03:saturation=0.85:contrast=1.05"
]
proc = None
retry_delay = INITIAL_RETRY_DELAY
# ====================== FFmpeg 命令720p 轻量转码) ======================
ffmpeg_command = [
"ffmpeg",
"-y",
"-loglevel", "info",
"-nostdin",
"-re",
"-stats", "-stats_period", "1",
"-rw_timeout", "30000000",
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "10",
"-fflags", "+genpts+discardcorrupt+nobuffer+flush_packets",
"-flags", "low_delay",
"-err_detect", "ignore_err",
# 活跃输出正则覆盖抖音FLV常见进度/缓存/帧信息)
active_pattern = re.compile(r'A-V:|AO:|VO:|cache|fps|packet|dropped|queue|buffered|Playing')
"-headers", douyin_headers,
"-i", real_url,
# 720p 竖屏处理
"-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=lanczos,"
"pad=720:1280:(ow-iw)/2:(oh-ih)/2:black",
"-c:v", codec_v,
"-preset", preset_v,
*tune_param,
"-profile:v", "high",
"-b:v", "2600k",
"-maxrate", "3000k",
"-bufsize", "6000k",
"-g", "60",
"-keyint_min", "30",
"-r", "30",
"-vsync", "cfr",
"-bf", "0",
"-sc_threshold", "0",
"-x264-params", "force-cfr=1:scenecut=0:open_gop=0:sync-lookahead=0",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "128k",
"-ar", "44100",
"-ac", "2",
"-af", "aresample=async=1:first_pts=0",
"-flags", "+global_header",
"-flvflags", "no_duration_filesize",
"-max_muxing_queue_size", "1024", # 移到输出选项末尾,防止 Broken pipe
"-f", "flv",
"pipe:1"
]
# ====================== 主循环:重连 + 下播检测 ======================
proc = None
mpv_proc = None
stderr_q = None
reader_t = None
retry_delay = INITIAL_RETRY_DELAY
try:
while True:
if retry_delay > INITIAL_RETRY_DELAY:
print(f"[WARN] 防风控等待 {retry_delay}s 后重新连接...")
try:
if retry_delay > INITIAL_RETRY_DELAY:
print(f"[WARN] 防风控等待 {retry_delay} 秒后重连抖音源...")
time.sleep(retry_delay)
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=5)
except:
proc.kill()
# 清理旧 mpv先关闭 stdin避免 ffmpeg 写 Broken pipe
if mpv_proc and mpv_proc.poll() is None:
try:
mpv_proc.stdin.close()
mpv_proc.wait(timeout=5)
except:
mpv_proc.terminate()
mpv_proc = None
print(f"[INFO] [{datetime.datetime.now():%H:%M:%S}] 启动 mpv 纯净播放 → HDMI 全屏")
proc = subprocess.Popen(
mpv_cmd_base,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
# 清理旧 ffmpeg
cleanup_proc(proc)
stderr_q = queue.Queue()
start_time = time.time()
last_active_time = time.time()
no_output_streak = 0
print(f"[INFO][{datetime.datetime.now():%H:%M:%S}] 启动抖音拉流 → HDMI 全屏显示 (mpv)")
while proc.poll() is None:
ready, _, _ = select.select([proc.stderr], [], [], 10)
if ready:
line = proc.stderr.readline()
if line and active_pattern.search(line):
last_active_time = time.time()
no_output_streak = 0
else:
no_output_streak += 1
if time.time() - start_time > START_CHECK_AFTER:
if no_output_streak >= (NO_OUTPUT_TIMEOUT // 10): # 120s 无活跃
print(f"[WARN] 连续 {NO_OUTPUT_TIMEOUT}s 无活跃输出,判定主播已下播")
proc.terminate()
# 先启动 mpv
mpv_proc = subprocess.Popen(
mpv_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
# 再启动 ffmpeg输出到 mpv
proc = subprocess.Popen(
ffmpeg_command,
stdin=subprocess.DEVNULL,
stdout=mpv_proc.stdin,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True
)
proc.last_out_ts = time.time()
# 启动 stderr 读取线程
reader_t = threading.Thread(target=_stderr_reader_thread, args=(proc, stderr_q))
reader_t.daemon = True
reader_t.start()
start_time = last_frame_time = time.time()
# 监控循环:检测下播、卡顿、重连
while True:
try:
line = stderr_q.get(timeout=STDERR_QUEUE_TIMEOUT)
except queue.Empty:
# 长时间无日志 → 僵死,重启
if time.time() - proc.last_out_ts > STALE_OUTPUT_SECONDS:
print("[WARN] FFmpeg 长时间无输出,强制重启进程")
cleanup_proc(proc)
if mpv_proc:
try:
mpv_proc.stdin.close()
except:
pass
mpv_proc = None
break
continue
if line is None: # reader 线程结束
if proc.poll() is not None:
break
continue
line = line.strip()
if line.startswith("__ERR__READER__"):
print(f"[ERROR] stderr 读取异常: {line}")
continue
if line:
proc.last_out_ts = time.time()
print(f"[FFmpeg] {line}")
line_lower = line.lower()
# 检测明确下播关键字 → 真实下播
if any(kw in line_lower for kw in END_KEYWORDS):
print("[INFO] 检测到主播下播关键字,停止转播")
cleanup_proc(proc)
raise StreamEnded()
returncode = proc.returncode
print(f"[INFO] mpv 退出returncode={returncode}")
# 更新最近帧时间
if re.search(r'frame=\s*\d+', line_lower) or "fps=" in line_lower:
last_frame_time = time.time()
if returncode in [-15, None]: # 我们主动 terminate
raise StreamEnded()
# 长时间无新帧 → 判定下播
if time.time() - start_time > START_CHECK_AFTER:
if time.time() - last_frame_time > NO_FRAME_TIMEOUT:
print(f"[INFO] {NO_FRAME_TIMEOUT}s 无新帧,判定主播已下播")
cleanup_proc(proc)
raise StreamEnded()
# 网络波动/风控断联 → 快速重连(不拉长延迟)
print(f"[WARN] mpv 非预期退出(网络波动/风控),快速重连...")
retry_delay = INITIAL_RETRY_DELAY if retry_delay > 30 else min(retry_delay * 2, MAX_RETRY_DELAY)
except StreamEnded:
print(f"[INFO] 主播已下播,停止 HDMI 转播 → {record_name}")
break
except StreamEnded:
print(f"[INFO] 主播已下播,停止转播 → {record_name}")
retry_delay = INITIAL_RETRY_DELAY # 下次新直播快速连接
except Exception as e:
print(f"[ERROR] 播放异常: {e}")
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
finally:
if proc and proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=5)
except:
proc.kill()
except Exception as e:
print(f"[ERROR] 转播过程异常: {e}")
traceback.print_exc()
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
continue
# ====================== 正常结束清理 ======================
cleanup_proc(proc)
# 原始清理逻辑完全保留
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
@@ -1764,14 +1909,39 @@ def start_record(url_data: tuple, count_variable: int = -1) -> None:
global monitoring
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except Exception:
except:
pass
color_obj.print_colored(f"[{record_name}] HDMI 转播已停止并清理完毕\n", color_obj.GREEN)
color_obj.print_colored(f"[{record_name}] HDMI 转播已彻底停止并清理完毕\n", color_obj.GREEN)
return
except Exception as e:
print(f"[FATAL] HDMI 转播最外层异常: {e}")
traceback.print_exc()
cleanup_proc(proc)
if mpv_proc and mpv_proc.poll() is None:
try:
mpv_proc.stdin.close()
except:
mpv_proc.terminate()
try:
recording.discard(record_name)
recording_time_list.pop(record_name, None)
if record_url in running_list:
running_list.remove(record_url)
monitoring = max(0, monitoring - 1)
clear_record_info(record_name, record_url)
except:
pass
color_obj.print_colored(f"[{record_name}] HDMI 转播异常退出但已清理\n", color_obj.RED)
return
count_time = time.time()
except Exception as e:
logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
with max_request_lock: