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
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()
|
|
|