329 lines
11 KiB
Python
329 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import platform
|
||
import re
|
||
import shutil
|
||
import socket
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
CONFIG_DIR = ROOT / "config"
|
||
JSON_PATH = CONFIG_DIR / "hardware-profile.json"
|
||
ENV_PATH = CONFIG_DIR / "hardware.env"
|
||
|
||
|
||
def run_text(cmd: list[str]) -> str:
|
||
try:
|
||
proc = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="ignore",
|
||
timeout=8,
|
||
check=False,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return ""
|
||
return (proc.stdout or proc.stderr or "").strip()
|
||
|
||
|
||
def detect_ffmpeg_hwaccels() -> list[str]:
|
||
if not shutil.which("ffmpeg"):
|
||
return []
|
||
text = run_text(["ffmpeg", "-hide_banner", "-hwaccels"])
|
||
accels: list[str] = []
|
||
for line in text.splitlines():
|
||
item = line.strip()
|
||
if not item or item.lower().startswith("hardware acceleration methods"):
|
||
continue
|
||
if item not in accels:
|
||
accels.append(item)
|
||
return accels
|
||
|
||
|
||
def detect_gpu() -> dict:
|
||
dri_dir = Path("/dev/dri")
|
||
render_nodes = sorted(str(path) for path in dri_dir.glob("renderD*")) if dri_dir.is_dir() else []
|
||
cards = sorted(str(path) for path in dri_dir.glob("card*")) if dri_dir.is_dir() else []
|
||
driver = ""
|
||
if cards:
|
||
card0 = Path(cards[0])
|
||
driver_link = Path(f"/sys/class/drm/{card0.name}/device/driver")
|
||
if driver_link.exists():
|
||
try:
|
||
driver = driver_link.resolve().name
|
||
except OSError:
|
||
driver = driver_link.name
|
||
return {
|
||
"present": bool(cards or render_nodes),
|
||
"cards": cards,
|
||
"render_nodes": render_nodes,
|
||
"driver": driver,
|
||
"vainfo": bool(shutil.which("vainfo")),
|
||
}
|
||
|
||
|
||
def detect_npu() -> dict:
|
||
hints = []
|
||
for path in (
|
||
Path("/dev/rknpu"),
|
||
Path("/dev/npu"),
|
||
Path("/dev/galcore"),
|
||
Path("/sys/class/misc/rknpu"),
|
||
Path("/sys/class/misc/verisilicon-npu"),
|
||
):
|
||
if path.exists():
|
||
hints.append(str(path))
|
||
return {
|
||
"present": bool(hints),
|
||
"devices": hints,
|
||
}
|
||
|
||
|
||
def detect_kernel_features() -> dict:
|
||
features = {
|
||
"drm": Path("/dev/dri/card0").exists(),
|
||
"render_node": any(Path("/dev/dri").glob("renderD*")) if Path("/dev/dri").is_dir() else False,
|
||
"v4l2loopback": Path("/sys/module/v4l2loopback").exists(),
|
||
"kvm": Path("/dev/kvm").exists(),
|
||
"nvidia": shutil.which("nvidia-smi") is not None,
|
||
}
|
||
return features
|
||
|
||
|
||
def detect_video_capture() -> dict:
|
||
if platform.system().lower() != "linux":
|
||
return {
|
||
"video_devices": [],
|
||
"v4l2_devices": [],
|
||
"usb_capture_matches": [],
|
||
"ready": False,
|
||
}
|
||
|
||
video_nodes = sorted(str(path) for path in Path("/dev").glob("video*"))
|
||
v4l2_raw = run_text(["v4l2-ctl", "--list-devices"]) if shutil.which("v4l2-ctl") else ""
|
||
v4l2_devices: list[dict] = []
|
||
if v4l2_raw:
|
||
current_name = ""
|
||
current_nodes: list[str] = []
|
||
for raw in v4l2_raw.splitlines():
|
||
line = raw.rstrip()
|
||
if not line.strip():
|
||
if current_name or current_nodes:
|
||
v4l2_devices.append({"name": current_name or "unknown", "nodes": current_nodes[:]})
|
||
current_name = ""
|
||
current_nodes = []
|
||
continue
|
||
if not line.startswith("\t") and not line.startswith(" "):
|
||
current_name = line.rstrip(":")
|
||
continue
|
||
node = line.strip()
|
||
if node.startswith("/dev/"):
|
||
current_nodes.append(node)
|
||
if current_name or current_nodes:
|
||
v4l2_devices.append({"name": current_name or "unknown", "nodes": current_nodes[:]})
|
||
|
||
lsusb_raw = run_text(["lsusb"]) if shutil.which("lsusb") else ""
|
||
capture_pat = re.compile(r"(capture|video|uvc|hdmi|elgato|macrosilicon|ezcap|cam\slink)", re.IGNORECASE)
|
||
usb_capture_matches = [line for line in lsusb_raw.splitlines() if capture_pat.search(line)]
|
||
|
||
ready = bool(video_nodes) and bool(shutil.which("ffmpeg"))
|
||
return {
|
||
"video_devices": video_nodes,
|
||
"v4l2_devices": v4l2_devices,
|
||
"usb_capture_matches": usb_capture_matches,
|
||
"v4l2_available": bool(shutil.which("v4l2-ctl")),
|
||
"ready": ready,
|
||
}
|
||
|
||
|
||
def detect_network() -> dict:
|
||
interfaces: list[dict] = []
|
||
route_raw = run_text(["ip", "-j", "route", "show", "default"]) if shutil.which("ip") else ""
|
||
addr_raw = run_text(["ip", "-j", "-4", "addr", "show", "scope", "global"]) if shutil.which("ip") else ""
|
||
|
||
default_iface = ""
|
||
gateway = ""
|
||
if route_raw:
|
||
try:
|
||
routes = json.loads(route_raw)
|
||
except json.JSONDecodeError:
|
||
routes = []
|
||
if routes:
|
||
default_iface = str(routes[0].get("dev", "") or "")
|
||
gateway = str(routes[0].get("gateway", "") or "")
|
||
|
||
if addr_raw:
|
||
try:
|
||
addr_rows = json.loads(addr_raw)
|
||
except json.JSONDecodeError:
|
||
addr_rows = []
|
||
for row in addr_rows:
|
||
name = str(row.get("ifname", "") or "").strip()
|
||
if not name or name.startswith(("docker", "br-", "veth", "virbr", "lo")):
|
||
continue
|
||
speed_path = Path("/sys/class/net") / name / "speed"
|
||
operstate_path = Path("/sys/class/net") / name / "operstate"
|
||
speed = None
|
||
if speed_path.is_file():
|
||
try:
|
||
speed = int(speed_path.read_text(encoding="utf-8", errors="replace").strip())
|
||
except ValueError:
|
||
speed = None
|
||
addresses = [
|
||
str(item.get("local", "")).strip()
|
||
for item in (row.get("addr_info") or [])
|
||
if str(item.get("local", "")).strip()
|
||
]
|
||
interfaces.append(
|
||
{
|
||
"name": name,
|
||
"addresses": addresses,
|
||
"speed_mbps": speed if speed and speed > 0 else None,
|
||
"operstate": operstate_path.read_text(encoding="utf-8", errors="replace").strip()
|
||
if operstate_path.is_file()
|
||
else "unknown",
|
||
"default_route": name == default_iface,
|
||
}
|
||
)
|
||
|
||
online = False
|
||
if gateway:
|
||
online = True
|
||
else:
|
||
try:
|
||
with socket.create_connection(("1.1.1.1", 53), timeout=1.2):
|
||
online = True
|
||
except OSError:
|
||
online = False
|
||
|
||
return {
|
||
"default_iface": default_iface,
|
||
"gateway": gateway,
|
||
"interfaces": interfaces,
|
||
"online": online,
|
||
}
|
||
|
||
|
||
def recommend(profile: dict) -> dict:
|
||
hwaccels = set(profile["ffmpeg_hwaccels"])
|
||
gpu = profile["gpu"]
|
||
capture = profile.get("video_capture") or {}
|
||
browser_hw = "auto" if gpu["present"] else "disabled"
|
||
decoder = "cpu"
|
||
if "vaapi" in hwaccels and gpu["render_nodes"]:
|
||
decoder = "vaapi"
|
||
elif "v4l2m2m" in hwaccels:
|
||
decoder = "v4l2m2m"
|
||
elif "cuda" in hwaccels or "nvdec" in hwaccels:
|
||
decoder = "cuda"
|
||
|
||
hdmi_player = "cpu"
|
||
if shutil.which("mpv") and gpu["cards"]:
|
||
hdmi_player = "mpv-drm"
|
||
elif shutil.which("ffplay") and (os.environ.get("DISPLAY") or shutil.which("startx")):
|
||
hdmi_player = "ffplay-x11"
|
||
|
||
risk = []
|
||
if not gpu["present"]:
|
||
risk.append("No DRM/VA render node detected, browser and HDMI playback will stay on conservative settings")
|
||
if decoder == "cpu":
|
||
risk.append("No safe hardware decoder detected, ffmpeg workloads will remain on CPU")
|
||
if hdmi_player == "cpu":
|
||
risk.append("No direct HDMI playback stack detected, install mpv or X11+ffplay for full-screen output")
|
||
if not capture.get("video_devices"):
|
||
risk.append("No /dev/video capture input detected, TikTok HDMI ingest cannot start yet")
|
||
|
||
return {
|
||
"video_decoder": decoder,
|
||
"browser_hwaccel": browser_hw,
|
||
"hdmi_player": hdmi_player,
|
||
"stability_mode": "safe" if risk else "balanced",
|
||
"degradation_reasons": risk,
|
||
}
|
||
|
||
|
||
def build_profile() -> dict:
|
||
profile = {
|
||
"status": "ok",
|
||
"arch": platform.machine(),
|
||
"kernel": platform.release(),
|
||
"platform": platform.platform(),
|
||
"ffmpeg_hwaccels": detect_ffmpeg_hwaccels(),
|
||
"gpu": detect_gpu(),
|
||
"npu": detect_npu(),
|
||
"kernel_features": detect_kernel_features(),
|
||
"video_capture": detect_video_capture(),
|
||
"network": detect_network(),
|
||
}
|
||
profile["recommendation"] = recommend(profile)
|
||
return profile
|
||
|
||
|
||
def suggest_video_encoder(profile: dict) -> str:
|
||
"""在 Linux 上根据 ffmpeg 能力与探测结果给出可选硬件编码器名(供 douyin→YouTube 读取 LIVE_VIDEO_ENCODER)。
|
||
|
||
优先级:RKMPP(常见 ARM SoC)→ V4L2M2M(通用 ARM/部分板)→ VA-API(x86 核显)→ QSV(Intel x86)。
|
||
NVIDIA 由 douyin_youtube_ffplay 内单独检测 h264_nvenc,此处不重复写入。
|
||
"""
|
||
if platform.system().lower() != "linux":
|
||
return ""
|
||
text = run_text(["ffmpeg", "-hide_banner", "-encoders"])
|
||
if not text:
|
||
return ""
|
||
hwaccels = set(profile.get("ffmpeg_hwaccels") or [])
|
||
gpu = profile.get("gpu") or {}
|
||
render_ok = bool(gpu.get("render_nodes"))
|
||
machine = platform.machine().lower()
|
||
has_v4l2_device = any(Path("/dev").glob("video*"))
|
||
|
||
if machine in ("aarch64", "arm64", "armv7l", "armv8l"):
|
||
if "h264_rkmpp" in text and Path("/dev/mpp_service").exists():
|
||
return "h264_rkmpp"
|
||
if "h264_v4l2m2m" in text and has_v4l2_device:
|
||
return "h264_v4l2m2m"
|
||
return ""
|
||
if "h264_vaapi" in text and render_ok and "vaapi" in hwaccels:
|
||
return "h264_vaapi"
|
||
if machine in ("x86_64", "amd64") and "h264_qsv" in text:
|
||
return "h264_qsv"
|
||
return ""
|
||
|
||
|
||
def write_outputs(profile: dict) -> None:
|
||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||
JSON_PATH.write_text(json.dumps(profile, indent=2, ensure_ascii=False), encoding="utf-8")
|
||
enc = suggest_video_encoder(profile)
|
||
env_lines = [
|
||
f"LIVE_HW_DECODER={profile['recommendation']['video_decoder']}",
|
||
f"LIVE_HDMI_PLAYER={profile['recommendation']['hdmi_player']}",
|
||
f"LIVE_BROWSER_HWACCEL={profile['recommendation']['browser_hwaccel']}",
|
||
f"LIVE_STABILITY_MODE={profile['recommendation']['stability_mode']}",
|
||
]
|
||
if enc:
|
||
env_lines.append(f"LIVE_VIDEO_ENCODER={enc}")
|
||
else:
|
||
env_lines.append("# LIVE_VIDEO_ENCODER= # optional: h264_v4l2m2m / h264_rkmpp when ffmpeg supports it")
|
||
ENV_PATH.write_text("\n".join(env_lines) + "\n", encoding="utf-8")
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="Probe Linux hardware capabilities for safe runtime defaults")
|
||
parser.add_argument("--write", action="store_true", help="Write JSON and env files into config/")
|
||
args = parser.parse_args()
|
||
|
||
profile = build_profile()
|
||
if args.write:
|
||
write_outputs(profile)
|
||
print(json.dumps(profile, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|