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.
223 lines
7.4 KiB
223 lines
7.4 KiB
#!/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()
|
|
|