← Files 中文口播智能粗剪ARCHIVED FILE

skills/rough-cut/scripts/doctor.py

8.67 KB · Oct 4, 2026 · 12:29 UTC

↓ Download file

#!/usr/bin/env python3
"""检查 rough-cut Skill 的本机运行条件;不读取或打印任何密钥。"""
import argparse
import importlib
import importlib.metadata
import json
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from config_loader import config_candidates, load_config
from runtime_support import FFMPEG, FFPROBE

SKILL_ROOT = Path(__file__).resolve().parents[1]


def inspect_config(explicit=None):
    for path in config_candidates(explicit):
        if path.is_file():
            try:
                data, loaded_path = load_config(explicit)
            except Exception as exc:
                return "error", str(path), f"JSON 无法读取:{exc}"
            asr = data.get("volc_asr")
            tos = data.get("volc_tos")
            if not isinstance(asr, dict) or not isinstance(tos, dict):
                return "error", loaded_path, "缺少 volc_asr 或 volc_tos"

            def usable(value):
                text = str(value or "").strip()
                return bool(text) and not any(mark in text for mark in ("填", "如 "))

            api_key_ok = usable(asr.get("api_key"))
            legacy_ok = usable(asr.get("app_id")) and usable(asr.get("access_token"))
            missing = []
            if not (api_key_ok or legacy_ok):
                missing.append("ASR鉴权(api_key 或 app_id+access_token)")
            if not usable(asr.get("endpoint")):
                missing.append("ASR endpoint")
            for key in ("access_key", "secret_key", "endpoint", "region", "bucket"):
                if not usable(tos.get(key)):
                    missing.append("TOS " + key)
            if missing:
                return "error", loaded_path, "未填写或仍为占位值:" + "、".join(missing)
            return "ok", loaded_path, "结构完整(密钥未显示)"
    return (
        "error", None,
        "未配置用户自己的火山 ASR/TOS。请运行 scripts/init_config.py,"
        "填写自己的账号和密钥;不会自动使用其他用户的账号。")


def main():
    ap = argparse.ArgumentParser(description="检查口播粗剪 Skill 的依赖、字体和可选云端配置")
    ap.add_argument("--config", help="config.json 路径;也可设置 ROUGH_CUT_CONFIG")
    ap.add_argument("--json", action="store_true", help="输出机器可读 JSON")
    ap.add_argument(
        "--allow-no-asr", action="store_true",
        help="只处理已有转写时允许缺少 ASR;首次安装和自动转写前不要使用")
    ap.add_argument(
        "--require-optional", action="store_true",
        help="把剪映草稿等可选依赖也作为必需项检查")
    args = ap.parse_args()

    checks = []
    system = platform.system()
    machine = platform.machine()
    checks.append({
        "name": "操作系统",
        "status": "ok" if system in ("Windows", "Darwin", "Linux") else "warn",
        "detail": f"{system} {platform.release()} ({machine})",
    })
    checks.append({
        "name": "64位环境",
        "status": "ok" if sys.maxsize > 2**32 else "error",
        "detail": "64-bit" if sys.maxsize > 2**32 else "检测到32位Python,请安装64位Python 3.11",
    })
    for binary, resolved in (("ffmpeg", FFMPEG), ("ffprobe", FFPROBE)):
        path = resolved if Path(resolved).is_file() else shutil.which(resolved)
        install_hint = (
            "按 references\\windows_setup.md 运行 scripts\\bootstrap_windows.ps1"
            if system == "Windows"
            else "macOS 可运行 brew install ffmpeg")
        checks.append({
            "name": binary, "status": "ok" if path else "error",
            "detail": str(path) if path else f"未安装;{install_hint}",
        })
    for module, label in (
        ("numpy", "numpy"),
        ("PIL", "Pillow"),
        ("jieba", "jieba"),
        ("tos", "火山TOS SDK"),
        ("requests", "requests"),
        ("crcmod", "crcmod"),
    ):
        try:
            importlib.import_module(module)
            distribution = "Pillow" if module == "PIL" else (
                "tos" if module == "tos" else module)
            try:
                version = importlib.metadata.version(distribution)
            except importlib.metadata.PackageNotFoundError:
                version = None
            ok, detail = True, f"已安装{f'({version})' if version else ''}"
        except Exception as exc:
            ok, detail = False, f"无法导入:{type(exc).__name__}: {exc}"
        checks.append({
            "name": label, "status": "ok" if ok else "error",
            "detail": detail if ok else (
                f"{detail};请重新安装 {SKILL_ROOT / 'requirements.txt'}"),
        })
    try:
        importlib.import_module("pyJianYingDraft")
        optional, optional_detail = True, "已安装"
    except Exception as exc:
        optional = False
        optional_detail = f"无法导入:{type(exc).__name__}: {exc}"
    checks.append({
        "name": "pyJianYingDraft",
        "status": "ok" if optional else ("error" if args.require_optional else "warn"),
        "detail": optional_detail if optional else (
            optional_detail + ";只影响可选剪映草稿"),
    })
    if system == "Windows" and optional:
        try:
            importlib.import_module("uiautomation")
            ui_ok, ui_detail = True, "已安装"
        except Exception as exc:
            ui_ok, ui_detail = False, f"无法导入:{type(exc).__name__}: {exc}"
        checks.append({
            "name": "Windows剪映自动化依赖",
            "status": "ok" if ui_ok else (
                "error" if args.require_optional else "warn"),
            "detail": ui_detail,
        })
    fonts = [
        SKILL_ROOT / "assets/fonts/SourceHanSerifSC-Heavy.otf",
        Path("/System/Library/Fonts/PingFang.ttc"),
        Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts/msyh.ttc",
        Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts/simhei.ttf",
    ]
    font = next((p for p in fonts if p.is_file()), None)
    checks.append({
        "name": "中文字体", "status": "ok" if font else "error",
        "detail": str(font) if font else "未找到 Skill 字体或系统回退字体",
    })
    cfg_status, cfg_path, cfg_detail = inspect_config(args.config)
    if args.allow_no_asr and cfg_status == "error":
        cfg_status = "warn"
        cfg_detail += "(当前仅按“已有转写”模式检查)"
    checks.append({
        "name": "火山配置", "status": cfg_status,
        "detail": f"{cfg_path + ';' if cfg_path else ''}{cfg_detail}",
    })
    checks.append({
        "name": "Python", "status": "ok" if sys.version_info >= (3, 10) else "error",
        "detail": sys.version.split()[0],
    })
    free = shutil.disk_usage(Path.cwd()).free
    checks.append({
        "name": "工作盘剩余空间",
        "status": "ok" if free >= 20 * 1024**3 else "warn",
        "detail": f"{free / 1024**3:.1f} GB(建议至少20GB,4K/长视频建议更多)",
    })
    ffmpeg_path = FFMPEG if Path(FFMPEG).is_file() else shutil.which(FFMPEG)
    if ffmpeg_path:
        enc = subprocess.run(
            [str(ffmpeg_path), "-hide_banner", "-encoders"],
            capture_output=True, text=True, errors="replace")
        encoders = enc.stdout + enc.stderr
        preferred = "h264_nvenc" if system == "Windows" else (
            "h264_videotoolbox" if system == "Darwin" else "libx264")
        has_preferred = preferred in encoders
        checks.append({
            "name": "推荐视频编码器",
            "status": "ok" if has_preferred else "warn",
            "detail": (
                f"{preferred} 可用"
                if has_preferred
                else f"{preferred} 未列出;仍会回退 libx264"),
        })
    if system == "Windows":
        nvidia_smi = shutil.which("nvidia-smi")
        checks.append({
            "name": "NVIDIA驱动",
            "status": "ok" if nvidia_smi else "warn",
            "detail": (
                "检测到 nvidia-smi;NVENC 可在真实渲染时进一步验证"
                if nvidia_smi
                else "未检测到 nvidia-smi;更新显卡驱动后可用NVENC,当前仍可回退CPU编码"),
        })

    ready = not any(c["status"] == "error" for c in checks)
    result = {"ready": ready, "skill_root": str(SKILL_ROOT), "checks": checks}
    if args.json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        icon = {"ok": "✓", "warn": "△", "error": "✗"}
        for check in checks:
            print(f"{icon[check['status']]} {check['name']}:{check['detail']}")
        print("环境就绪" if ready else "环境未就绪:请先处理上面的 ✗ 项")
    raise SystemExit(0 if ready else 1)


if __name__ == "__main__":
    main()

SHA-256: 6d9a503535cbff08ea6426033e36c1a2c973649310c88410227f760ba0e68915