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