Spaces:
Running
Running
File size: 14,458 Bytes
d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 | """
编码器探针 —— 录制编码器自动选择(v1.0.9:录制直出 HEVC)。
纯函数模块(无 Qt 依赖),供 EgoDataWriter 在录制启动前调用(发生在
pipeline 的 _start_async 后台线程内,写盘线程在其返回后才启动,探针
不卡 UI、不影响写入节奏):
- 候选 ffmpeg 二进制枚举(imageio 静态 → PATH,-version 自检)
- 编码器能力探测(-encoders)与速度探针(null muxer 微型编码)
- 编码器选择(auto: hevc_nvenc → libx265 速度达标 → libx264 回退)
与参数映射
选择结果进程内缓存(同一进程内机器不变不重探;负结果也缓存)。探针
同步执行、最坏 ~10-20s;所有探针异常按「该编码器不可用」降级处理。
"""
from __future__ import annotations
import dataclasses
import shutil
import subprocess
import threading
import time
from typing import Callable, Dict, List, Optional
from config import settings
# ── 进程内缓存 ────────────────────────────────────────
_FFMPEG_CACHE: Optional[List[str]] = None
_ENCODER_CACHE: Dict[tuple, bool] = {}
_SELECT_CACHE: Dict[tuple, Optional["EncoderChoice"]] = {}
@dataclasses.dataclass
class EncoderChoice:
"""一次编码器选择的结果。"""
encoder: str # "hevc_nvenc" | "libx265" | "libx264"
codec: str # "HEVC" | "H.264"
crf: int # 本选择实际用的档位(x264 用回退档)
args: list # ffmpeg 编码参数(不含 -c:v)
ffmpeg: str # 采用的 ffmpeg 二进制
label: str # UI 显示名,如 "HEVC (hevc_nvenc)"
probe: dict # 探针明细(写 metadata.json)
selected_by: str # settings.RECORD_VIDEO_ENCODER 原值
# ═══════════════════════════════════════════════════════
# ffmpeg 二进制枚举 / 编码器能力
# ═══════════════════════════════════════════════════════
def list_working_ffmpegs() -> List[str]:
"""候选 ffmpeg 二进制(imageio 静态 → PATH),经 -version 自检。
全部自检失败时,仍把 imageio 静态二进制作为最后兜底返回(与旧
_get_ffmpeg 口径一致:它已在录制段被证明可用)。
"""
global _FFMPEG_CACHE
if _FFMPEG_CACHE is not None:
return list(_FFMPEG_CACHE)
candidates: List[str] = []
imageio_exe = None
try:
import imageio_ffmpeg
imageio_exe = imageio_ffmpeg.get_ffmpeg_exe()
candidates.append(imageio_exe)
except Exception:
pass
for name in ("ffmpeg", "ffmpeg.exe"):
p = shutil.which(name)
if p and p not in candidates:
candidates.append(p)
working = []
for exe in candidates:
try:
r = subprocess.run([exe, "-version"], capture_output=True,
timeout=10)
if r.returncode == 0:
working.append(exe)
except Exception:
continue
if not working and imageio_exe:
working.append(imageio_exe) # 兜底:录段历史证明可用
_FFMPEG_CACHE = working
return list(working)
def ffmpeg_has_encoder(ffmpeg: str, encoder: str) -> bool:
"""-encoders 输出里是否含该编码器(结果缓存)。"""
key = (ffmpeg, encoder)
if key in _ENCODER_CACHE:
return _ENCODER_CACHE[key]
found = False
try:
r = subprocess.run([ffmpeg, "-hide_banner", "-encoders"],
capture_output=True, timeout=30)
text = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", "ignore")
found = encoder in text.split()
except Exception:
found = False
_ENCODER_CACHE[key] = found
return found
# ═══════════════════════════════════════════════════════
# 参数映射 / 微型探针
# ═══════════════════════════════════════════════════════
def encoder_args(encoder: str, crf: Optional[int] = None) -> List[str]:
"""编码器参数映射(HEVC 一律带 hvc1 容器标记)。"""
if crf is None:
crf = settings.RECORD_VIDEO_CRF
table = {
"hevc_nvenc": ["-rc", "vbr", "-cq", str(crf), "-preset", "p4",
"-tag:v", "hvc1"],
"libx265": ["-crf", str(crf), "-preset", "veryfast", "-tag:v", "hvc1"],
"libx264": ["-preset", "ultrafast",
"-crf", str(settings.RECORD_VIDEO_X264_CRF)],
}
return list(table[encoder])
def _kill(p: subprocess.Popen) -> None:
try:
p.kill()
except Exception:
pass
try:
p.wait(timeout=3)
except Exception:
pass
def _feed_raw(proc: subprocess.Popen, frame_bytes: bytes, frames: int) -> None:
try:
for _ in range(frames):
proc.stdin.write(frame_bytes)
proc.stdin.flush()
finally:
try:
proc.stdin.close()
except Exception:
pass
def probe_nvenc(ffmpeg: str, width: int, height: int, fps: float,
crf: Optional[int] = None, timeout: int = 10) -> bool:
"""NVENC 微型探针:~1s 编码(null muxer)确认 GPU 可用。"""
frames = max(3, int(fps))
cmd = [ffmpeg, "-y", "-f", "rawvideo", "-pix_fmt", "bgr24",
"-s", f"{width}x{height}", "-r", str(fps), "-i", "-",
"-frames:v", str(frames),
"-c:v", "hevc_nvenc", *encoder_args("hevc_nvenc", crf),
"-f", "null", "-"]
try:
p = subprocess.Popen(cmd, stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
_feed_raw(p, bytes(width * height * 3), frames)
return p.wait(timeout=timeout) == 0
except Exception:
return False
def probe_x265(ffmpeg: str, width: int, height: int, fps: float,
n_streams: int, frames: Optional[int] = None,
timeout: Optional[int] = None) -> dict:
"""x265 速度探针:并行 n 流各编 frames 帧 → null,返回每流实测 fps。
n_streams = 本会话视频流数(封顶 ENCODER_PROBE_MAX_STREAMS)。全部
流并行跑满同一墙钟,每流 fps = frames / 总耗时 —— 与写盘线程每 tick
串行写全部槽的背压口径近似一致。合成帧为全零帧(编码最快),1.5×
门槛余量即为此乐观偏差所设。
"""
if frames is None:
frames = settings.ENCODER_PROBE_FRAME_COUNT
if timeout is None:
timeout = settings.ENCODER_PROBE_TIMEOUT_S
n = max(1, min(int(n_streams), settings.ENCODER_PROBE_MAX_STREAMS))
frame_bytes = bytes(width * height * 3)
cmd_base = [ffmpeg, "-y", "-f", "rawvideo", "-pix_fmt", "bgr24",
"-s", f"{width}x{height}", "-r", str(fps), "-i", "-",
"-frames:v", str(frames),
"-c:v", "libx265", *encoder_args("libx265"),
"-f", "null", "-"]
procs: List[subprocess.Popen] = []
start = time.monotonic()
ok = True
try:
for _ in range(n):
procs.append(subprocess.Popen(
cmd_base, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL))
feeders = [threading.Thread(target=_feed_raw, args=(p, frame_bytes, frames),
daemon=True) for p in procs]
for t in feeders:
t.start()
for p in procs:
p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
ok = False
for p in procs:
if p.poll() is None:
_kill(p)
ok = False
elif p.returncode != 0:
ok = False
elapsed = time.monotonic() - start
if not ok or elapsed <= 0:
return {"per_stream_fps": [], "min_fps": 0.0, "passed": False,
"streams": n, "frames": frames, "elapsed_s": round(elapsed, 2)}
per = [frames / elapsed] * n
min_fps = min(per)
threshold = fps * settings.ENCODER_X265_MIN_FPS_RATIO
return {"per_stream_fps": [round(x, 1) for x in per],
"min_fps": round(min_fps, 1), "passed": min_fps >= threshold,
"threshold_fps": round(threshold, 1),
"streams": n, "frames": frames, "elapsed_s": round(elapsed, 2)}
def probe_depth_12bit(ffmpeg: str, timeout: int = 8) -> bool:
"""深度 12-bit 灰度编码能力探针:gray12le → libx265 → null。
x265 版本/构建差异下 gray12le(4:0:0,hevc Rext)未必可用;
单帧微型编码(32×32 → null)确认能力,结果进程内缓存。
失败时调用方回落 FFV1 gray16le MKV(无损 16-bit 旧格式)。
"""
key = (ffmpeg, "depth12")
if key in _ENCODER_CACHE:
return _ENCODER_CACHE[key]
ok = False
try:
p = subprocess.Popen(
[ffmpeg, "-y", "-f", "rawvideo", "-pix_fmt", "gray12le",
"-s", "32x32", "-r", "30", "-i", "-",
"-frames:v", "1", "-c:v", "libx265",
"-x265-params", "qp=6:range=full", "-f", "null", "-"],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
_feed_raw(p, bytes(32 * 32 * 2), 1)
ok = p.wait(timeout=timeout) == 0
except Exception:
ok = False
_ENCODER_CACHE[key] = ok
return ok
def find_depth_12bit_ffmpeg() -> Optional[str]:
"""候选二进制中第一个支持 gray12le 12-bit 编码的;全不支持返回 None。"""
return next((b for b in list_working_ffmpegs() if probe_depth_12bit(b)),
None)
# ═══════════════════════════════════════════════════════
# 选择入口
# ═══════════════════════════════════════════════════════
def select_encoder(prefer: str, width: int, height: int, fps: float,
n_streams: int, log: Optional[Callable[[str], None]] = None
) -> Optional[EncoderChoice]:
"""选择录制编码器。返回 EncoderChoice,全链失败返回 None。
prefer: settings.RECORD_VIDEO_ENCODER("auto"|"nvenc"|"x265"|"x264")
auto 链: hevc_nvenc(探针可用) → libx265(速度达标) → libx264(兜底)
显式指定: 跳过速度门槛强制用该编码器;二进制不支持/探针失败时按
auto 链回退并播报原因。
"""
if log is None:
log = lambda msg: None
cache_key = (prefer, width, height, fps,
min(int(n_streams), settings.ENCODER_PROBE_MAX_STREAMS))
if cache_key in _SELECT_CACHE:
return _SELECT_CACHE[cache_key]
bins = list_working_ffmpegs()
if not bins:
log("[编码器] 未找到可用 ffmpeg 二进制")
_SELECT_CACHE[cache_key] = None
return None
def find_with(enc: str) -> Optional[str]:
return next((b for b in bins if ffmpeg_has_encoder(b, enc)), None)
def make(enc: str, ffmpeg: str, probe: Optional[dict] = None) -> EncoderChoice:
is_hevc = enc != "libx264"
crf = settings.RECORD_VIDEO_CRF if is_hevc else settings.RECORD_VIDEO_X264_CRF
return EncoderChoice(
encoder=enc,
codec="HEVC" if is_hevc else "H.264",
crf=crf,
args=encoder_args(enc),
ffmpeg=ffmpeg,
label=("HEVC (%s)" % enc) if is_hevc else "H.264 (libx264)",
probe=probe or {},
selected_by=prefer)
def auto_chain(why: Optional[str] = None) -> Optional[EncoderChoice]:
if why:
log(f"[编码器] {why},按 auto 链回退")
exe = find_with("hevc_nvenc")
if exe and probe_nvenc(exe, width, height, fps,
settings.RECORD_VIDEO_CRF):
log("[编码器] hevc_nvenc 探针通过 → HEVC 硬件编码")
return make("hevc_nvenc", exe, {"kind": "nvenc", "ok": True})
if exe:
log("[编码器] hevc_nvenc 探针失败(GPU 不可用),跳过")
exe = find_with("libx265")
if exe:
probe = probe_x265(exe, width, height, fps, n_streams)
if probe["passed"]:
log(f"[编码器] libx265 实测 {probe['min_fps']:.0f} fps/流 "
f"(门槛 {probe['threshold_fps']:.0f}) → HEVC 软编")
return make("libx265", exe, probe)
log(f"[编码器] libx265 实测 {probe['min_fps']:.0f} fps/流 "
f"(门槛 {probe['threshold_fps']:.0f}) → 不达标")
exe = find_with("libx264")
if exe:
log(f"[编码器] 回退 H.264 (libx264 ultrafast, "
f"CRF{settings.RECORD_VIDEO_X264_CRF})")
return make("libx264", exe)
return None
if prefer == "nvenc":
exe = find_with("hevc_nvenc")
if exe and probe_nvenc(exe, width, height, fps,
settings.RECORD_VIDEO_CRF):
log("[编码器] 显式 nvenc:hevc_nvenc 探针通过")
choice = make("hevc_nvenc", exe, {"kind": "nvenc", "ok": True})
else:
choice = auto_chain("显式 nvenc 但 hevc_nvenc 探针失败")
elif prefer == "x265":
exe = find_with("libx265")
if exe:
log("[编码器] 显式 x265:跳过速度门槛,强制 HEVC 软编")
choice = make("libx265", exe)
else:
choice = auto_chain("显式 x265 但无二进制支持 libx265")
elif prefer == "x264":
exe = find_with("libx264")
if exe:
log("[编码器] 显式 x264:跳过探针,直接 H.264")
choice = make("libx264", exe)
else:
choice = auto_chain("显式 x264 但无二进制支持 libx264")
else: # auto
choice = auto_chain()
_SELECT_CACHE[cache_key] = choice
return choice
|