Files
Popiai-skill/video-creation/mv-creator/scripts/validate_script.py
T

224 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""校验 script.md 脚本格式是否符合预览解析器要求。
规则:
- 每幕必须用 ## 标题
- 时间码格式:* M:SS - M:SS (Xs)(不加粗、不带段落标签)
- 每幕时长必须为 4~15 秒的整数
- 时间段连续,不能有间隙或重叠
- 每幕必须包含 **场景描述:**
- 每幕可选包含 **关键物件:**
- 每幕必须包含 **视觉风格:**
- 每幕必须包含 **转场:**
- 可选:与 lyric.md 交叉校验时间码
"""
import re
import sys
from pathlib import Path
MIN_DURATION = 4
MAX_DURATION = 15
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*$"
)
BAD_TIME_BOLD_RE = re.compile(r"^\*\s+\*\*")
def parse_time(minutes: str, seconds: str) -> int:
return int(minutes) * 60 + int(seconds)
def parse_script(path: str) -> list[dict]:
"""解析 script.md,返回幕列表。"""
text = Path(path).read_text(encoding="utf-8")
lines = text.split("\n")
scenes: list[dict] = []
current: dict | 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),
"time_raw": None,
"start": None,
"end": None,
"duration_tag": None,
"has_lyrics": False,
"has_desc": False,
"has_props": False,
"has_style": False,
"has_transition": False,
"raw_lines": [],
}
continue
if current is None:
continue
current["raw_lines"].append(line)
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))
continue
if line.startswith(">"):
current["has_lyrics"] = True
if line.startswith("**场景描述:**") or line.startswith("**场景描述:**"):
current["has_desc"] = True
if line.startswith("**关键物件:**") or line.startswith("**关键物件:**"):
current["has_props"] = True
if line.startswith("**视觉风格:**") or line.startswith("**视觉风格:**"):
current["has_style"] = True
if line.startswith("**转场:**") or line.startswith("**转场:**"):
current["has_transition"] = True
if current:
scenes.append(current)
return scenes
def validate(scenes: list[dict]) -> 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)")
continue
# 检查加粗时间码
for raw_line in sc["raw_lines"]:
if BAD_TIME_BOLD_RE.match(raw_line):
issues.append(f"ERROR [{label}] 时间码不应加粗(去掉 **)")
break
duration = sc["end"] - sc["start"]
if duration <= 0:
issues.append(f"ERROR [{label}] 时长无效 ({duration}s)")
continue
# 时长标签检查
if sc["duration_tag"] is None:
issues.append(f"ERROR [{label}] 缺少时长标签(格式:* M:SS - M:SS (Xs)")
elif sc["duration_tag"] != duration:
issues.append(
f"ERROR [{label}] 时长标签 ({sc['duration_tag']}s) "
f"与实际时长 ({duration}s) 不一致"
)
# 时长范围检查
if not (MIN_DURATION <= duration <= MAX_DURATION):
issues.append(
f"ERROR [{label}] 时长 {duration}s 超出范围 "
f"(要求 {MIN_DURATION}{MAX_DURATION}s)"
)
# 整数检查(时间码本身是整数秒,这里 double check
if duration != int(duration):
issues.append(f"ERROR [{label}] 时长必须为整数秒 (当前 {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 not sc["has_desc"]:
issues.append(f"WARN [{label}] 缺少 **场景描述:**")
if not sc["has_style"]:
issues.append(f"WARN [{label}] 缺少 **视觉风格:**")
if not sc["has_transition"]:
issues.append(f"WARN [{label}] 缺少 **转场:**")
return issues
def _fmt(seconds: float) -> str:
return f"{int(seconds // 60)}:{int(seconds % 60):02d}"
def print_summary(scenes: list[dict], issues: list[str]) -> None:
total = len(scenes)
with_lyrics = sum(1 for s in scenes if s["has_lyrics"])
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"]
print("=== 脚本校验报告 ===\n")
print(f"总时长: {_fmt(total_duration)}")
print(f"总幕数: {total} (歌词幕 {with_lyrics}, 纯画面幕 {total - with_lyrics})")
print(f"时长约束: {MIN_DURATION}{MAX_DURATION}s")
print()
print(f"{'幕名':<20} {'时间码':<20} {'时长':>5} {'歌词':>4} {'描述':>4} {'物件':>4} {'风格':>4} {'转场':>4}")
print("-" * 82)
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 "-"
title = sc["title"][:18]
ok = " " if MIN_DURATION <= duration <= MAX_DURATION else "!!"
print(
f"{title:<20} {time_str:<20} {duration:>3.0f}s{ok}"
f"{check(sc['has_lyrics']):>4} {check(sc['has_desc']):>4} "
f"{check(sc['has_props']):>4} {check(sc['has_style']):>4} "
f"{check(sc['has_transition']):>4}"
)
print()
if issues:
print(f"发现 {len(issues)} 个问题:\n")
for issue in issues:
print(f" {issue}")
else:
print("ALL PASS")
def main():
if len(sys.argv) < 2:
print("用法: python validate_script.py <script.md 路径>")
sys.exit(1)
script_path = Path(sys.argv[1])
if not script_path.exists():
print(f"ERROR: 文件不存在: {script_path}")
sys.exit(1)
print(f"\n--- {script_path} ---\n")
scenes = parse_script(str(script_path))
issues = validate(scenes)
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()