#!/usr/bin/env python3 """校验物件设计 design.md 格式是否符合规范。 用法: # 校验单个物件 python validate_props_design.py .mv/{song}/{theme}/props/{prop}/design.md # 校验 props 目录下所有物件 python validate_props_design.py .mv/{song}/{theme}/props/ 规则: - 必须有一级标题 # 物件名 - 必须包含 5 个二级标题分区:外观描述、叙事作用、生图设定、候选图、选定图 - 外观描述必须包含至少 3 项关键词(类型、形态、颜色/材质、尺寸/比例、细节特征) - 生图设定必须包含:提示词、模型、参考图 - 候选图必须有至少 1 条列表项 - 候选图路径对应的文件必须存在 """ import re import sys from pathlib import Path REQUIRED_SECTIONS = ["外观描述", "叙事作用", "生图设定", "候选图", "选定图"] PROMPT_FIELDS = ["提示词:", "模型:", "参考图:"] # 中英 section / field 映射 SECTION_ALIASES = { "外观描述": "Appearance", "叙事作用": "Narrative Role", "生图设定": "Image Settings", "候选图": "Candidates", "选定图": "Selected", } PROMPT_FIELD_ALIASES = { "提示词:": ("Prompt:", "Prompt:"), "模型:": ("Model:", "Model:"), "参考图:": ("Reference:", "Reference:"), } IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"} H1_RE = re.compile(r"^#\s+(.+)$") H2_RE = re.compile(r"^##\s+(.+)$") LIST_RE = re.compile(r"^-\s+(.+)$") CANDIDATE_RE = re.compile(r"^-\s+(candidate_\S+)") def parse_design(text: str) -> dict: """解析 design.md,返回结构化数据。""" lines = text.split("\n") result = {"title": None, "sections": {}} current_section = None for line in lines: stripped = line.strip() h1 = H1_RE.match(stripped) if h1: result["title"] = h1.group(1) continue h2 = H2_RE.match(stripped) if h2: current_section = h2.group(1) result["sections"][current_section] = [] continue if current_section is not None: result["sections"][current_section].append(stripped) return result def get_section(sections: dict, zh_name: str) -> list: """从 sections 字典中获取内容,兼容中英 section header。""" en_name = SECTION_ALIASES.get(zh_name, "") return sections.get(zh_name, sections.get(en_name, [])) def has_section(sections: dict, zh_name: str) -> bool: """检查 section 是否存在,兼容中英。""" en_name = SECTION_ALIASES.get(zh_name, "") return zh_name in sections or en_name in sections def has_prompt_field(content: str, zh_field: str) -> bool: """检查生图设定字段是否存在,兼容中英。""" if zh_field in content: return True for alias in PROMPT_FIELD_ALIASES.get(zh_field, ()): if alias in content: return True return False def validate_one(design_path: Path) -> list[str]: """校验单个 design.md,返回问题列表。""" issues = [] prop_dir = design_path.parent prop_name = prop_dir.name text = design_path.read_text(encoding="utf-8") data = parse_design(text) # 物件名 if not data["title"]: issues.append(f"ERROR [{prop_name}] 缺少一级标题 # 物件名") # 必需分区 for section in REQUIRED_SECTIONS: if not has_section(data["sections"], section): en = SECTION_ALIASES.get(section, "") issues.append(f"ERROR [{prop_name}] 缺少分区 ## {section}/{en}") # 外观描述:需要包含至少 3 项列表项 appearance = get_section(data["sections"], "外观描述") if appearance: items = [l for l in appearance if LIST_RE.match(l)] if len(items) < 3: issues.append( f"WARN [{prop_name}] 外观描述/Appearance 列表项不足(需至少 3 项,当前 {len(items)} 项)" ) # 叙事作用:需要有内容 narrative = get_section(data["sections"], "叙事作用") if narrative: content = "\n".join(narrative).strip() if not content: issues.append(f"WARN [{prop_name}] 叙事作用/Narrative Role 内容为空") # 生图设定:需要提示词、模型、参考图 img_settings = get_section(data["sections"], "生图设定") if img_settings: content = "\n".join(img_settings) for field in PROMPT_FIELDS: if not has_prompt_field(content, field): issues.append(f"ERROR [{prop_name}] 生图设定/Image Settings 缺少字段 {field}") # 候选图:需要至少 1 条,且文件存在 candidates_lines = get_section(data["sections"], "候选图") if candidates_lines: candidates = [] for line in candidates_lines: m = CANDIDATE_RE.match(line) if m: candidates.append(m.group(1)) if not candidates: issues.append(f"ERROR [{prop_name}] 候选图/Candidates 为空(需要至少 1 张)") for fname in candidates: actual_name = fname.split()[0] if " " in fname else fname fpath = prop_dir / actual_name if not fpath.exists(): issues.append(f"WARN [{prop_name}] 候选图文件不存在: {actual_name}") # 选定图:检查是否已选定 selected_lines = get_section(data["sections"], "选定图") if selected_lines: content = "\n".join(selected_lines).strip() if not content: issues.append(f"INFO [{prop_name}] 选定图/Selected 尚未填写(待用户挑选)") elif "selected" in content: sel_path = prop_dir / "selected.jpg" sel_png = prop_dir / "selected.png" if not sel_path.exists() and not sel_png.exists(): issues.append(f"WARN [{prop_name}] selected.jpg/png 文件不存在") return issues def find_designs(path: Path) -> list[Path]: """查找所有 design.md 文件。""" if path.is_file() and path.name == "design.md": return [path] if path.is_dir(): designs = [] for d in sorted(path.iterdir()): if d.is_dir(): dm = d / "design.md" if dm.exists(): designs.append(dm) return designs return [] def print_summary(all_issues: dict[str, list[str]]) -> None: print("=== 物件设计校验报告 ===\n") print(f"物件数量: {len(all_issues)}") print() total_errors = 0 total_warns = 0 for prop_name, issues in all_issues.items(): errors = [i for i in issues if "ERROR" in i] warns = [i for i in issues if "WARN" in i] infos = [i for i in issues if "INFO" in i] total_errors += len(errors) total_warns += len(warns) status = "PASS" if not errors else "FAIL" print(f" [{status}] {prop_name} — {len(errors)} 错误, {len(warns)} 警告") print() if total_errors == 0 and total_warns == 0: no_info = all(not any("INFO" in i for i in iss) for iss in all_issues.values()) if no_info: print("ALL PASS") else: print("ALL PASS (部分物件待用户选图)") else: all_issues_flat = [i for iss in all_issues.values() for i in iss] print(f"发现 {len(all_issues_flat)} 个问题:\n") for issue in all_issues_flat: print(f" {issue}") def main(): if len(sys.argv) < 2: print("用法:") print(" python validate_props_design.py ") print(" python validate_props_design.py ") sys.exit(1) target = Path(sys.argv[1]).resolve() if not target.exists(): print(f"ERROR: 路径不存在: {target}") sys.exit(1) designs = find_designs(target) if not designs: print(f"ERROR: 未找到 design.md 文件: {target}") sys.exit(1) print(f"\n--- 物件设计校验 ---\n") all_issues = {} for dp in designs: prop_name = dp.parent.name issues = validate_one(dp) all_issues[prop_name] = issues print_summary(all_issues) has_error = any("ERROR" in i for iss in all_issues.values() for i in iss) sys.exit(1 if has_error else 0) if __name__ == "__main__": main()