From 8953e2f4d476aa5ca977969d0f6cc8d603c8ffa6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 30 Dec 2025 04:37:35 +0000 Subject: [PATCH] ffmpeg=>pipe=>mpv --- config/URL_config.ini | 14 +- main.py | 352 +++++++++++++++++++++++++++++++----------- 2 files changed, 268 insertions(+), 98 deletions(-) diff --git a/config/URL_config.ini b/config/URL_config.ini index 414e434..9eeebf0 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1,8 +1,8 @@ -https://live.douyin.com/372551154040,主播: 向嘉_手打柠檬茶 -https://live.douyin.com/203476627795,主播: 蜜桃姐姐(手打柠檬茶) -https://live.douyin.com/825627287092,主播: 杏绘茶档_(00后创业版_手打柠檬茶) -https://live.douyin.com/147650297461,主播: 娱乐摆摊记 -https://live.douyin.com/806666144080,主播: 卢荟胶 -https://live.douyin.com/589958309143,主播: 大萝卜 -https://live.douyin.com/35932729226,主播: 内向小学生 +; https://live.douyin.com/372551154040,主播: 向嘉_手打柠檬茶 +; https://live.douyin.com/203476627795,主播: 蜜桃姐姐(手打柠檬茶) +; https://live.douyin.com/825627287092,主播: 杏绘茶档_(00后创业版_手打柠檬茶) +; https://live.douyin.com/147650297461,主播: 娱乐摆摊记 +; https://live.douyin.com/806666144080,主播: 卢荟胶 +; https://live.douyin.com/589958309143,主播: 大萝卜 +; https://live.douyin.com/35932729226,主播: 内向小学生 https://live.douyin.com/9638535297,主播: 异度世界 diff --git a/main.py b/main.py index 43530c2..fa24bab 100644 --- a/main.py +++ b/main.py @@ -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: