Popiai-skill仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

321 lines
12 KiB

#!/usr/bin/env python3
"""校验 storyboard.md 分镜规划格式是否符合规范。
用法:
python validate_storyboard.py .mv/{song}/{theme}/storyboard.md
规则:
- 每幕必须用 ## 标题
- 时间码格式:* M:SS - M:SS (Xs)
- 每幕必须包含:出场角色、场景背景、场景描述、视频提示词、参考图
- 出场角色(非"")对应的角色目录和 selected.jpg 必须存在
- 场景背景对应的 backgrounds/ 目录和 selected.jpg 必须存在
- 关键物件对应的 props/ 目录和 selected.jpg 必须存在
- 可选:与 script.md 交叉校验幕数和时间码
"""
import re
import sys
from pathlib import Path
SCENE_RE = re.compile(r"^##\s+(.+)$")
TIME_RE = re.compile(
r"^\*\s+(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})"
r"(?:\s+\((\d+)s\))?\s*$"
)
def parse_time(minutes: str, seconds: str) -> int:
return int(minutes) * 60 + int(seconds)
def parse_storyboard(path: str) -> list[dict]:
"""解析 storyboard.md,返回幕列表。"""
text = Path(path).read_text(encoding="utf-8")
lines = text.split("\n")
scenes: list[dict] = []
current: dict | None = None
current_field: str | None = None
for line_raw in lines:
line = line_raw.strip()
scene_match = SCENE_RE.match(line)
if scene_match:
if current:
scenes.append(current)
current = {
"title": scene_match.group(1),
"start": None,
"end": None,
"duration_tag": None,
"time_raw": None,
"characters": None,
"background": None,
"props": None,
"has_desc": False,
"has_prompt": False,
"has_refs": False,
}
current_field = None
continue
if current is None:
continue
time_match = TIME_RE.match(line)
if time_match:
current["time_raw"] = line.lstrip("* ")
current["start"] = parse_time(time_match.group(1), time_match.group(2))
current["end"] = parse_time(time_match.group(3), time_match.group(4))
if time_match.group(5) is not None:
current["duration_tag"] = int(time_match.group(5))
current_field = None
continue
# 字段检测 (兼容中英)
if line.startswith(("出场角色:", "出场角色:", "Characters:", "Characters:")):
chars = line.split("", 1)[-1].split(":", 1)[-1].strip()
current["characters"] = chars
current_field = "characters"
continue
if line.startswith(("场景背景:", "场景背景:", "Scene Background:", "Scene Background:")):
bg = line.split("", 1)[-1].split(":", 1)[-1].strip()
current["background"] = bg
current_field = "background"
continue
if line.startswith(("关键物件:", "关键物件:", "Key Props:", "Key Props:")):
props = line.split("", 1)[-1].split(":", 1)[-1].strip()
current["props"] = props
current_field = "props"
continue
if line.startswith(("场景描述:", "场景描述:", "Scene Description:", "Scene Description:")):
current["has_desc"] = True
current_field = "desc"
continue
if line.startswith(("生图提示词:", "生图提示词:", "视频提示词:", "视频提示词:", "Video Prompt:", "Video Prompt:")):
current["has_prompt"] = True
current_field = "prompt"
continue
if line.startswith(("参考图:", "参考图:", "Reference:", "Reference:")):
current["has_refs"] = True
current_field = "refs"
continue
if current:
scenes.append(current)
return scenes
def validate(scenes: list[dict], theme_dir: Path) -> list[str]:
"""校验幕列表,返回问题列表。"""
issues: list[str] = []
if not scenes:
issues.append("ERROR 未找到任何 ## 场景标题")
return issues
for i, sc in enumerate(scenes):
label = sc["title"]
# 时间码
if sc["start"] is None:
issues.append(f"ERROR [{label}] 缺少时间码行(格式:* M:SS - M:SS (Xs))")
else:
duration = sc["end"] - sc["start"]
if duration <= 0:
issues.append(f"ERROR [{label}] 时长无效 ({duration}s)")
if sc["duration_tag"] is not None and sc["duration_tag"] != duration:
issues.append(
f"ERROR [{label}] 时长标签 ({sc['duration_tag']}s) "
f"与实际时长 ({duration}s) 不一致"
)
# 连续性
if i > 0 and scenes[i - 1]["end"] is not None:
prev_end = scenes[i - 1]["end"]
if abs(sc["start"] - prev_end) > 1:
issues.append(
f"ERROR [{label}] 与上一幕不连续 "
f"(上幕结束 {_fmt(prev_end)}, 本幕开始 {_fmt(sc['start'])})"
)
# 必填字段
if sc["characters"] is None:
issues.append(f"ERROR [{label}] 缺少 出场角色/Characters")
if sc["background"] is None:
issues.append(f"ERROR [{label}] 缺少 场景背景/Scene Background")
if not sc["has_desc"]:
issues.append(f"ERROR [{label}] 缺少 场景描述/Scene Description")
if not sc["has_prompt"]:
issues.append(f"ERROR [{label}] 缺少 视频提示词/Video Prompt")
if not sc["has_refs"]:
issues.append(f"WARN [{label}] 缺少 参考图/Reference")
# 出场角色 → 角色目录 + selected.jpg 检查
if sc["characters"] and sc["characters"].lower() not in ("(无)", "(无)", "", "none", "(none)", "n/a"):
char_names = [c.strip() for c in sc["characters"].split("")]
if len(char_names) == 1:
char_names = [c.strip() for c in sc["characters"].split(",")]
for cname in char_names:
if not cname:
continue
char_dir = theme_dir / "characters" / cname
if not char_dir.exists():
issues.append(
f"WARN [{label}] 角色目录不存在: characters/{cname}/"
)
else:
sel = char_dir / "selected.jpg"
sel_png = char_dir / "selected.png"
if not sel.exists() and not sel_png.exists():
issues.append(
f"WARN [{label}] 角色 {cname} 缺少 selected.jpg/png"
)
# 场景背景 → backgrounds/ 目录 + selected.jpg 检查
if sc["background"]:
bg_dir = theme_dir / "backgrounds" / sc["background"]
if not bg_dir.exists():
issues.append(
f"WARN [{label}] 背景目录不存在: backgrounds/{sc['background']}/"
)
else:
sel = bg_dir / "selected.jpg"
sel_png = bg_dir / "selected.png"
if not sel.exists() and not sel_png.exists():
issues.append(
f"WARN [{label}] 背景 {sc['background']} 缺少 selected.jpg/png"
)
# 关键物件 → props/ 目录 + selected.jpg 检查
if sc["props"] and sc["props"].lower() not in ("(无)", "(无)", "", "", "none", "(none)", "n/a"):
prop_names = [p.strip() for p in sc["props"].split("")]
if len(prop_names) == 1:
prop_names = [p.strip() for p in sc["props"].split(",")]
for pname in prop_names:
if not pname:
continue
prop_dir = theme_dir / "props" / pname
if not prop_dir.exists():
issues.append(
f"WARN [{label}] 物件目录不存在: props/{pname}/"
)
else:
sel = prop_dir / "selected.jpg"
sel_png = prop_dir / "selected.png"
if not sel.exists() and not sel_png.exists():
issues.append(
f"WARN [{label}] 物件 {pname} 缺少 selected.jpg/png"
)
# 与 script.md 交叉校验
script_path = theme_dir / "script.md"
if script_path.exists():
_cross_validate_script(scenes, script_path, issues)
return issues
def _cross_validate_script(scenes: list[dict], script_path: Path, issues: list[str]):
"""与 script.md 交叉校验幕数和时间码。"""
text = script_path.read_text(encoding="utf-8")
script_scenes = []
for line in text.split("\n"):
line = line.strip()
sm = SCENE_RE.match(line)
if sm:
script_scenes.append({"title": sm.group(1), "start": None, "end": None})
tm = TIME_RE.match(line)
if tm and script_scenes:
script_scenes[-1]["start"] = parse_time(tm.group(1), tm.group(2))
script_scenes[-1]["end"] = parse_time(tm.group(3), tm.group(4))
if len(scenes) != len(script_scenes):
issues.append(
f"WARN [交叉校验] 分镜幕数 ({len(scenes)}) "
f"与脚本幕数 ({len(script_scenes)}) 不一致"
)
return
for i, (sb, sc) in enumerate(zip(scenes, script_scenes)):
if sb["start"] is not None and sc["start"] is not None:
if sb["start"] != sc["start"] or sb["end"] != sc["end"]:
issues.append(
f"WARN [交叉校验] 第 {i+1} 幕时间码与脚本不一致 "
f"(分镜 {sb['time_raw']}, 脚本 {_fmt(sc['start'])}-{_fmt(sc['end'])})"
)
def _fmt(seconds: int) -> str:
return f"{seconds // 60}:{seconds % 60:02d}"
def print_summary(scenes: list[dict], issues: list[str]) -> None:
total = len(scenes)
total_duration = 0
if scenes and scenes[-1]["end"] is not None and scenes[0]["start"] is not None:
total_duration = scenes[-1]["end"] - scenes[0]["start"]
with_chars = sum(
1 for s in scenes
if s["characters"] and s["characters"].lower() not in ("(无)", "(无)", "", "none", "(none)", "n/a")
)
print("=== 分镜规划校验报告 ===\n")
print(f"总时长: {_fmt(total_duration)}")
print(f"总幕数: {total} (含角色 {with_chars}, 纯空镜 {total - with_chars})")
print()
print(
f"{'幕名':<20} {'时间码':<20} {'时长':>5} {'角色':>4} "
f"{'背景':>4} {'描述':>4} {'prompt':>6}"
)
print("-" * 78)
for sc in scenes:
duration = (sc["end"] - sc["start"]) if sc["start"] is not None else 0
time_str = sc["time_raw"] or "缺失"
check = lambda v: "Y" if v else "-"
has_char = sc["characters"] is not None and sc["characters"].lower() not in ("(无)", "(无)", "", "none", "(none)", "n/a")
has_bg = sc["background"] is not None
title = sc["title"][:18]
print(
f"{title:<20} {time_str:<20} {duration:>3}s "
f"{check(has_char):>4} {check(has_bg):>4} "
f"{check(sc['has_desc']):>4} {check(sc['has_prompt']):>6}"
)
print()
if issues:
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]
print(f"发现 {len(issues)} 个问题 ({len(errors)} 错误, {len(warns)} 警告, {len(infos)} 信息):\n")
for issue in issues:
print(f" {issue}")
else:
print("ALL PASS")
def main():
if len(sys.argv) < 2:
print("用法: python validate_storyboard.py <storyboard.md 路径>")
sys.exit(1)
sb_path = Path(sys.argv[1]).resolve()
if not sb_path.exists():
print(f"ERROR: 文件不存在: {sb_path}")
sys.exit(1)
theme_dir = sb_path.parent
print(f"\n--- 分镜规划校验 ---\n")
scenes = parse_storyboard(str(sb_path))
issues = validate(scenes, theme_dir)
print_summary(scenes, issues)
has_error = any("ERROR" in i for i in issues)
sys.exit(1 if has_error else 0)
if __name__ == "__main__":
main()