Flatten skill category directory structure
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Burn subtitles into video using PIL text rendering + ffmpeg overlay.
|
||||
|
||||
This is the ONLY reliable subtitle method for this pipeline:
|
||||
- Cloud services (burn_in_subtitles_to_video, batch_burn_subtitles) may 404
|
||||
- ffmpeg drawtext/subtitles filters require libfreetype/libass (often missing)
|
||||
- mv_final_assembly subtitle feature may silently fail (reports success, no visible subtitles)
|
||||
|
||||
How it works:
|
||||
1. PIL renders each unique subtitle as a transparent PNG (white text + black outline)
|
||||
2. Builds a full frame sequence (1 PNG per video frame), reusing cached images
|
||||
3. Encodes frame sequence as a transparent video (PNG codec + RGBA)
|
||||
4. ffmpeg overlays the subtitle video onto the main video in a single pass
|
||||
|
||||
Usage:
|
||||
python3 burn_subs.py <input_video> <output_video>
|
||||
|
||||
Before running, edit the SUBTITLES list below with your actual subtitle data.
|
||||
Video dimensions are auto-detected via ffprobe.
|
||||
|
||||
Subtitle data format:
|
||||
SUBTITLES = [
|
||||
(start_seconds, end_seconds, "subtitle text"),
|
||||
(0.00, 2.50, "Hey folks, welcome back."),
|
||||
(2.50, 6.10, "Today's burning question:\\nwhy do cats think\\nthey own the house?"),
|
||||
...
|
||||
]
|
||||
|
||||
Tips:
|
||||
- Use \\n for line breaks within a subtitle (max 3 lines recommended)
|
||||
- Keep each subtitle under 3-4 seconds for readability
|
||||
- Leave small gaps (0.1-0.5s) between subtitles at natural pauses
|
||||
- ASR timestamps are a starting point — cross-verify with actual audio
|
||||
- At segment boundaries (15s marks), ASR timestamps may drift ~0.5s
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# SUBTITLE DATA — Edit this before running
|
||||
# ═══════════════════════════════════════════
|
||||
SUBTITLES = [
|
||||
(0.00, 2.50, "Hey folks, welcome back."),
|
||||
(2.50, 6.10, "Today's burning question:\nwhy do cats think\nthey own the house?"),
|
||||
# Add your subtitles here...
|
||||
]
|
||||
|
||||
# Remove empty entries
|
||||
SUBTITLES = [(s, e, t) for s, e, t in SUBTITLES if t]
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# STYLE PARAMETERS
|
||||
# ═══════════════════════════════════════════
|
||||
FPS = 25
|
||||
FONT_SIZE = 36
|
||||
OUTLINE_WIDTH = 3
|
||||
BOTTOM_MARGIN = 180 # pixels from bottom edge
|
||||
|
||||
|
||||
def detect_video_dimensions(video_path):
|
||||
"""Auto-detect video width and height via ffprobe."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-select_streams", "v:0",
|
||||
video_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: ffprobe failed, using default 720x1280")
|
||||
return 720, 1280
|
||||
streams = json.loads(result.stdout).get("streams", [])
|
||||
if not streams:
|
||||
print(f"Warning: no video stream found, using default 720x1280")
|
||||
return 720, 1280
|
||||
w = int(streams[0]["width"])
|
||||
h = int(streams[0]["height"])
|
||||
return w, h
|
||||
|
||||
|
||||
def get_video_duration(video_path):
|
||||
"""Get video duration in seconds via ffprobe."""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
video_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
return float(json.loads(result.stdout)["format"]["duration"])
|
||||
|
||||
|
||||
def find_font():
|
||||
"""Find a suitable font for subtitle rendering (macOS paths)."""
|
||||
for p in [
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/System/Library/Fonts/SFCompact.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Linux
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux
|
||||
]:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
return ImageFont.truetype(p, FONT_SIZE)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def find_chinese_font():
|
||||
"""Find a font that supports Chinese characters (macOS/Linux)."""
|
||||
for p in [
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc", # macOS
|
||||
"/System/Library/Fonts/PingFang.ttc", # macOS
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc", # macOS
|
||||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", # Linux
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", # Linux
|
||||
]:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
return ImageFont.truetype(p, FONT_SIZE)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def detect_language(subtitles):
|
||||
"""Detect if subtitles contain Chinese characters."""
|
||||
all_text = "".join(t for _, _, t in subtitles)
|
||||
chinese_chars = sum(1 for c in all_text if '\u4e00' <= c <= '\u9fff')
|
||||
return "zh" if chinese_chars > len(all_text) * 0.1 else "en"
|
||||
|
||||
|
||||
def get_active_subtitle(t):
|
||||
"""Return the subtitle text active at time t, or None."""
|
||||
for start, end, text in SUBTITLES:
|
||||
if start <= t < end:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def render_frame(text, font, width, height):
|
||||
"""Render a frame with subtitle text (or transparent if None)."""
|
||||
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
if text is None:
|
||||
return img
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
bbox = draw.multiline_textbbox((0, 0), text, font=font, align="center")
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
x = (width - text_w) // 2
|
||||
y = height - BOTTOM_MARGIN - text_h
|
||||
|
||||
# Draw outline (black border for readability)
|
||||
for dx in range(-OUTLINE_WIDTH, OUTLINE_WIDTH + 1):
|
||||
for dy in range(-OUTLINE_WIDTH, OUTLINE_WIDTH + 1):
|
||||
if dx * dx + dy * dy <= OUTLINE_WIDTH * OUTLINE_WIDTH:
|
||||
draw.multiline_text(
|
||||
(x + dx, y + dy), text, font=font,
|
||||
fill=(0, 0, 0, 255), align="center"
|
||||
)
|
||||
# Draw white text on top
|
||||
draw.multiline_text(
|
||||
(x, y), text, font=font,
|
||||
fill=(255, 255, 255, 255), align="center"
|
||||
)
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python3 burn_subs.py <input_video> <output_video>")
|
||||
print()
|
||||
print("Edit the SUBTITLES list in this script before running.")
|
||||
sys.exit(1)
|
||||
|
||||
input_video = sys.argv[1]
|
||||
output_video = sys.argv[2]
|
||||
|
||||
if not SUBTITLES or (len(SUBTITLES) == 1 and "Add your" in SUBTITLES[0][2]):
|
||||
print("Error: No subtitle data. Edit the SUBTITLES list in this script first.")
|
||||
sys.exit(1)
|
||||
|
||||
# Auto-detect video dimensions
|
||||
width, height = detect_video_dimensions(input_video)
|
||||
duration = get_video_duration(input_video)
|
||||
total_frames = int(duration * FPS) + 1
|
||||
|
||||
# Select font based on subtitle language
|
||||
lang = detect_language(SUBTITLES)
|
||||
if lang == "zh":
|
||||
font = find_chinese_font() or find_font()
|
||||
print(f"Detected Chinese subtitles, using CJK font")
|
||||
else:
|
||||
font = find_font()
|
||||
|
||||
print(f"Video: {width}x{height}, {duration:.2f}s, {total_frames} frames @ {FPS}fps")
|
||||
print(f"Subtitles: {len(SUBTITLES)} segments")
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="burn_subs_")
|
||||
print(f"Rendering {total_frames} subtitle frames to {tmpdir}...")
|
||||
|
||||
# Pre-compute: render each unique subtitle once, cache for reuse
|
||||
cached_images = {}
|
||||
blank = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
|
||||
for frame_num in range(total_frames):
|
||||
t = frame_num / FPS
|
||||
text = get_active_subtitle(t)
|
||||
|
||||
if text is None:
|
||||
img = blank
|
||||
else:
|
||||
if text not in cached_images:
|
||||
cached_images[text] = render_frame(text, font, width, height)
|
||||
img = cached_images[text]
|
||||
|
||||
frame_path = os.path.join(tmpdir, f"frame_{frame_num:05d}.png")
|
||||
img.save(frame_path)
|
||||
|
||||
if frame_num % 100 == 0:
|
||||
print(f" Frame {frame_num}/{total_frames} (t={t:.1f}s)")
|
||||
|
||||
print(f"Rendered {total_frames} frames, {len(cached_images)} unique subtitle images")
|
||||
|
||||
# Step 1: Create subtitle overlay video from PNG sequence (preserves alpha)
|
||||
sub_video = os.path.join(tmpdir, "subs.mov")
|
||||
cmd1 = [
|
||||
"ffmpeg", "-y",
|
||||
"-framerate", str(FPS),
|
||||
"-i", os.path.join(tmpdir, "frame_%05d.png"),
|
||||
"-c:v", "png",
|
||||
"-pix_fmt", "rgba",
|
||||
sub_video,
|
||||
]
|
||||
print("Creating subtitle overlay video...")
|
||||
r1 = subprocess.run(cmd1, capture_output=True, text=True)
|
||||
if r1.returncode != 0:
|
||||
print("Error creating subtitle video:", r1.stderr[-2000:])
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Overlay subtitle video onto main video (single pass)
|
||||
cmd2 = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", input_video,
|
||||
"-i", sub_video,
|
||||
"-filter_complex", "[0:v][1:v]overlay=0:0:shortest=1[outv]",
|
||||
"-map", "[outv]",
|
||||
"-map", "0:a",
|
||||
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
|
||||
"-c:a", "copy",
|
||||
output_video,
|
||||
]
|
||||
print("Overlaying subtitles onto video...")
|
||||
r2 = subprocess.run(cmd2, capture_output=True, text=True)
|
||||
if r2.returncode != 0:
|
||||
print("Error overlaying:", r2.stderr[-2000:])
|
||||
sys.exit(1)
|
||||
|
||||
# Cleanup temp files
|
||||
import shutil
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
print(f"Done! Output: {output_video}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seedance 视频片段质量审查模板。
|
||||
|
||||
此脚本不直接调用 API,而是:
|
||||
1. 输出标准化的 read_media 审查 prompt
|
||||
2. 解析审查结果 JSON 并给出 PASS/FAIL 判定
|
||||
3. 将审查记录写入 review_log.json
|
||||
|
||||
使用方式:
|
||||
# 生成审查 prompt(供 Claude 调用 read_media 时使用)
|
||||
python3 review_segment.py prompt <segment.mp4>
|
||||
|
||||
# 记录审查结果
|
||||
python3 review_segment.py record <segment.mp4> <PASS|FAIL> <reason> [--log review_log.json]
|
||||
|
||||
# 检查所有片段是否通过审查
|
||||
python3 review_segment.py check [--log review_log.json]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 审查标准定义
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
REVIEW_CRITERIA = {
|
||||
"style": {
|
||||
"name": "风格一致性",
|
||||
"name_en": "Style Consistency",
|
||||
"description": "必须是 3D 皮克斯风格动画,不能是 2D 扁平/写实/其他风格",
|
||||
"weight": "CRITICAL", # CRITICAL = 不通过即 FAIL
|
||||
},
|
||||
"camera": {
|
||||
"name": "分镜切换",
|
||||
"name_en": "Camera Transitions",
|
||||
"description": "15s 片段内必须有至少 2 次镜头切换(全景↔特写),不能全程固定机位",
|
||||
"weight": "CRITICAL",
|
||||
},
|
||||
"audio": {
|
||||
"name": "音频质量",
|
||||
"name_en": "Audio Quality",
|
||||
"description": "对话清晰无乱码、无重叠/重复/AI 噪声,全程可懂",
|
||||
"weight": "CRITICAL",
|
||||
},
|
||||
"character": {
|
||||
"name": "角色一致性",
|
||||
"name_en": "Character Consistency",
|
||||
"description": "角色外观(服装、配饰、体型)与参考图一致",
|
||||
"weight": "IMPORTANT", # IMPORTANT = 警告但可酌情通过
|
||||
},
|
||||
"content": {
|
||||
"name": "内容匹配",
|
||||
"name_en": "Content Match",
|
||||
"description": "对话内容与脚本大致吻合,不必逐字匹配但意思要对",
|
||||
"weight": "IMPORTANT",
|
||||
},
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 标准化审查 Prompt
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
REVIEW_PROMPT = """Evaluate this video segment against these 5 criteria. For each criterion, give a PASS or FAIL verdict with a brief reason.
|
||||
|
||||
## Criteria
|
||||
|
||||
1. **STYLE** (Critical): Is this 3D Pixar-style animation? NOT 2D flat/realistic/other styles. Rate: PASS or FAIL.
|
||||
|
||||
2. **CAMERA TRANSITIONS** (Critical): Count the number of distinct camera angle changes (e.g. wide shot → close-up → wide shot). A 15s segment MUST have at least 2 camera changes. List each shot with timestamp. Rate: PASS or FAIL.
|
||||
|
||||
3. **AUDIO QUALITY** (Critical): Is ALL dialogue clear and understandable throughout the entire clip? Check for: garbled speech, overlapping/doubled audio tracks, AI noise/artifacts, nonsensical words. Even a brief 2-second garble = FAIL. Rate: PASS or FAIL.
|
||||
|
||||
4. **CHARACTER CONSISTENCY** (Important): Do characters maintain consistent appearance (clothes, accessories, body type) across shots? Rate: PASS or FAIL.
|
||||
|
||||
5. **CONTENT MATCH** (Important): Does the spoken dialogue roughly match expected content? (Don't need exact words, but the topic/meaning should be right.) Rate: PASS or FAIL.
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond in this exact JSON format:
|
||||
```json
|
||||
{
|
||||
"style": {"verdict": "PASS/FAIL", "reason": "..."},
|
||||
"camera": {"verdict": "PASS/FAIL", "reason": "...", "shot_count": N, "shots": ["0:00-0:05 wide shot", ...]},
|
||||
"audio": {"verdict": "PASS/FAIL", "reason": "...", "garbled_sections": []},
|
||||
"character": {"verdict": "PASS/FAIL", "reason": "..."},
|
||||
"content": {"verdict": "PASS/FAIL", "reason": "..."},
|
||||
"overall": "PASS/FAIL",
|
||||
"summary": "One sentence summary"
|
||||
}
|
||||
```
|
||||
|
||||
OVERALL is PASS only if ALL Critical criteria (style, camera, audio) pass. Important criteria failures are warnings but don't block overall PASS."""
|
||||
|
||||
|
||||
def cmd_prompt(segment_path: str):
|
||||
"""输出标准化的审查 prompt。"""
|
||||
print("=" * 60)
|
||||
print(f"审查片段: {segment_path}")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("请使用以下 prompt 调用 read_media:")
|
||||
print()
|
||||
print(REVIEW_PROMPT)
|
||||
print()
|
||||
print("审查标准:")
|
||||
for key, c in REVIEW_CRITERIA.items():
|
||||
print(f" [{c['weight']}] {c['name']} ({c['name_en']}): {c['description']}")
|
||||
|
||||
|
||||
def cmd_record(segment_path: str, verdict: str, reason: str, log_path: str = "review_log.json"):
|
||||
"""记录审查结果到 JSON 日志。"""
|
||||
log_file = Path(log_path)
|
||||
if log_file.exists():
|
||||
log = json.loads(log_file.read_text())
|
||||
else:
|
||||
log = {"reviews": [], "segments": {}}
|
||||
|
||||
segment_name = Path(segment_path).name
|
||||
entry = {
|
||||
"segment": segment_name,
|
||||
"path": str(segment_path),
|
||||
"verdict": verdict.upper(),
|
||||
"reason": reason,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
log["reviews"].append(entry)
|
||||
log["segments"][segment_name] = entry
|
||||
|
||||
log_file.write_text(json.dumps(log, indent=2, ensure_ascii=False))
|
||||
icon = "✅" if verdict.upper() == "PASS" else "❌"
|
||||
print(f"{icon} {segment_name}: {verdict.upper()} — {reason}")
|
||||
|
||||
|
||||
def cmd_check(log_path: str = "review_log.json"):
|
||||
"""检查所有片段审查状态。"""
|
||||
log_file = Path(log_path)
|
||||
if not log_file.exists():
|
||||
print("未找到审查日志,请先进行审查")
|
||||
sys.exit(1)
|
||||
|
||||
log = json.loads(log_file.read_text())
|
||||
segments = log.get("segments", {})
|
||||
|
||||
if not segments:
|
||||
print("无审查记录")
|
||||
sys.exit(1)
|
||||
|
||||
all_pass = True
|
||||
print("=== 审查状态汇总 ===\n")
|
||||
for name, entry in sorted(segments.items()):
|
||||
icon = "✅" if entry["verdict"] == "PASS" else "❌"
|
||||
print(f" {icon} {name}: {entry['verdict']} — {entry['reason']}")
|
||||
if entry["verdict"] != "PASS":
|
||||
all_pass = False
|
||||
|
||||
print()
|
||||
if all_pass:
|
||||
print("ALL PASS — 所有片段通过审查,可以进行拼接合成")
|
||||
else:
|
||||
failed = [n for n, e in segments.items() if e["verdict"] != "PASS"]
|
||||
print(f"BLOCKED — {len(failed)} 个片段未通过: {', '.join(failed)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == "prompt":
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python review_segment.py prompt <segment.mp4>")
|
||||
sys.exit(1)
|
||||
cmd_prompt(sys.argv[2])
|
||||
|
||||
elif cmd == "record":
|
||||
if len(sys.argv) < 5:
|
||||
print("用法: python review_segment.py record <segment.mp4> <PASS|FAIL> <reason>")
|
||||
sys.exit(1)
|
||||
log_path = "review_log.json"
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--log" and i + 1 < len(sys.argv):
|
||||
log_path = sys.argv[i + 1]
|
||||
cmd_record(sys.argv[2], sys.argv[3], sys.argv[4], log_path)
|
||||
|
||||
elif cmd == "check":
|
||||
log_path = "review_log.json"
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--log" and i + 1 < len(sys.argv):
|
||||
log_path = sys.argv[i + 1]
|
||||
cmd_check(log_path)
|
||||
|
||||
else:
|
||||
print(f"未知命令: {cmd}")
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""校验 animal-podcast 搞笑播客脚本格式是否符合规范。
|
||||
|
||||
规则:
|
||||
- 文件头部必须包含元信息(话题、动物组合、目标时长)
|
||||
- 每个场景必须用 ## [SCENE:id] 标题
|
||||
- 每段台词必须包含 **{角色名}:** 标记说话角色
|
||||
- 每段台词必须包含 **画面描述:**
|
||||
- 每段台词必须包含 **时长预估:** Xs
|
||||
- 可选 **音效:** 标记(大笑、惊讶、鼓掌等)
|
||||
- 场景之间用 --- 分隔
|
||||
- scene_id 不能重复
|
||||
- 单段台词时长 3~12s,单场景总时长不超过 20s
|
||||
- 总时长不超过 39s
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCENE_RE = re.compile(r"^##\s+\[SCENE:(\w+)\]\s+(.+)$")
|
||||
SPEAKER_RE = re.compile(r"^\*\*(.+?):\*\*$|^\*\*(.+?):\*\*$")
|
||||
DURATION_RE = re.compile(r"(\d+)s")
|
||||
|
||||
# 非角色台词的系统字段(中文 + 英文)
|
||||
SYSTEM_FIELDS = {"画面描述", "时长预估", "音效",
|
||||
"Visual Description", "Estimated Duration", "SFX"}
|
||||
|
||||
|
||||
def parse_script(path: str) -> tuple[dict, list[dict]]:
|
||||
"""解析 script.md,返回 (元信息, 场景列表)。
|
||||
|
||||
每个场景包含多个台词段(line),每段有 speaker / has_visual / has_duration / duration_seconds。
|
||||
"""
|
||||
text = Path(path).read_text(encoding="utf-8")
|
||||
lines = text.split("\n")
|
||||
|
||||
meta: dict = {"topic": "", "animals": "", "target_duration": "", "aspect_ratio": ""}
|
||||
scenes: list[dict] = []
|
||||
current_scene: dict | None = None
|
||||
current_line: dict | None = None
|
||||
|
||||
in_header = True
|
||||
|
||||
for raw in lines:
|
||||
line = raw.strip()
|
||||
|
||||
# ---------- 文件头元信息 ----------
|
||||
if in_header and not line.startswith("##"):
|
||||
for key, field in [("话题", "topic"), ("动物组合", "animals"),
|
||||
("目标时长", "target_duration"), ("画面比例", "aspect_ratio"),
|
||||
("Topic", "topic"), ("Animal Pairing", "animals"),
|
||||
("Target Duration", "target_duration"), ("Aspect Ratio", "aspect_ratio")]:
|
||||
if line.startswith(f"{key}:") or line.startswith(f"{key}:"):
|
||||
# 只按第一个中/英文冒号拆分,保留值中的冒号
|
||||
if ":" in line:
|
||||
meta[field] = line.split(":", 1)[1].strip()
|
||||
else:
|
||||
meta[field] = line.split(":", 1)[1].strip()
|
||||
break
|
||||
continue
|
||||
|
||||
in_header = False
|
||||
|
||||
# ---------- 场景标题 ----------
|
||||
scene_match = SCENE_RE.match(line)
|
||||
if scene_match:
|
||||
if current_line and current_scene:
|
||||
current_scene["lines"].append(current_line)
|
||||
current_line = None
|
||||
if current_scene:
|
||||
scenes.append(current_scene)
|
||||
current_scene = {
|
||||
"id": scene_match.group(1),
|
||||
"title": scene_match.group(2),
|
||||
"lines": [],
|
||||
}
|
||||
continue
|
||||
|
||||
if current_scene is None:
|
||||
continue
|
||||
|
||||
# ---------- 角色台词标记 ----------
|
||||
speaker_match = SPEAKER_RE.match(line)
|
||||
if speaker_match:
|
||||
speaker_name = (speaker_match.group(1) or speaker_match.group(2)).strip()
|
||||
# 如果是系统字段,不算新台词段
|
||||
if speaker_name in SYSTEM_FIELDS:
|
||||
if current_line:
|
||||
if speaker_name in ("画面描述", "Visual Description"):
|
||||
current_line["has_visual"] = True
|
||||
elif speaker_name in ("时长预估", "Estimated Duration"):
|
||||
current_line["has_duration"] = True
|
||||
# 尝试从同行提取数字
|
||||
m = DURATION_RE.search(line)
|
||||
if m:
|
||||
current_line["duration_seconds"] = int(m.group(1))
|
||||
elif speaker_name in ("音效", "SFX"):
|
||||
current_line["has_sfx"] = True
|
||||
continue
|
||||
|
||||
# 新的角色台词段
|
||||
if current_line:
|
||||
current_scene["lines"].append(current_line)
|
||||
current_line = {
|
||||
"speaker": speaker_name,
|
||||
"has_visual": False,
|
||||
"has_duration": False,
|
||||
"has_sfx": False,
|
||||
"duration_seconds": 0,
|
||||
}
|
||||
continue
|
||||
|
||||
# ---------- 时长预估(可能出现在独立行) ----------
|
||||
if current_line and (line.startswith("**时长预估:**") or line.startswith("**时长预估:**")
|
||||
or line.startswith("**Estimated Duration:**")):
|
||||
current_line["has_duration"] = True
|
||||
m = DURATION_RE.search(line)
|
||||
if m:
|
||||
current_line["duration_seconds"] = int(m.group(1))
|
||||
elif current_line and (line.startswith("**画面描述:**") or line.startswith("**画面描述:**")
|
||||
or line.startswith("**Visual Description:**")):
|
||||
current_line["has_visual"] = True
|
||||
elif current_line and (line.startswith("**音效:**") or line.startswith("**音效:**")
|
||||
or line.startswith("**SFX:**")):
|
||||
current_line["has_sfx"] = True
|
||||
|
||||
# 收尾
|
||||
if current_line and current_scene:
|
||||
current_scene["lines"].append(current_line)
|
||||
if current_scene:
|
||||
scenes.append(current_scene)
|
||||
|
||||
return meta, scenes
|
||||
|
||||
|
||||
def validate(meta: dict, scenes: list[dict]) -> list[str]:
|
||||
"""校验,返回问题列表。"""
|
||||
issues: list[str] = []
|
||||
|
||||
# 元信息检查
|
||||
if not meta["topic"]:
|
||||
issues.append("WARN 文件头缺少 话题:")
|
||||
if not meta["animals"]:
|
||||
issues.append("WARN 文件头缺少 动物组合:")
|
||||
if not meta["target_duration"]:
|
||||
issues.append("WARN 文件头缺少 目标时长:")
|
||||
|
||||
if not scenes:
|
||||
issues.append("ERROR 未找到任何 ## [SCENE:xxx] 场景")
|
||||
return issues
|
||||
|
||||
# scene_id 唯一性
|
||||
ids = [s["id"] for s in scenes]
|
||||
seen: set[str] = set()
|
||||
for sid in ids:
|
||||
if sid in seen:
|
||||
issues.append(f"ERROR scene_id 重复: {sid}")
|
||||
seen.add(sid)
|
||||
|
||||
# 收集所有角色名
|
||||
all_speakers: set[str] = set()
|
||||
total_duration = 0
|
||||
|
||||
for sc in scenes:
|
||||
scene_label = f"[{sc['id']}] {sc['title']}"
|
||||
scene_duration = 0
|
||||
|
||||
if not sc["lines"]:
|
||||
issues.append(f"WARN {scene_label} 没有台词段")
|
||||
|
||||
for idx, ln in enumerate(sc["lines"], 1):
|
||||
line_label = f"{scene_label} 台词#{idx}({ln['speaker']})"
|
||||
all_speakers.add(ln["speaker"])
|
||||
|
||||
if not ln["has_visual"]:
|
||||
issues.append(f"ERROR {line_label} 缺少 **画面描述:**")
|
||||
if not ln["has_duration"]:
|
||||
issues.append(f"ERROR {line_label} 缺少 **时长预估:**")
|
||||
elif ln["duration_seconds"] <= 0:
|
||||
issues.append(f"ERROR {line_label} 时长预估格式错误(应为 Xs,如 8s)")
|
||||
elif ln["duration_seconds"] < 3:
|
||||
issues.append(f"WARN {line_label} 台词过短 ({ln['duration_seconds']}s < 3s)")
|
||||
elif ln["duration_seconds"] > 12:
|
||||
issues.append(f"WARN {line_label} 单段台词过长 ({ln['duration_seconds']}s > 12s)")
|
||||
|
||||
scene_duration += ln["duration_seconds"]
|
||||
|
||||
if scene_duration > 20:
|
||||
issues.append(f"WARN {scene_label} 场景总时长过长 ({scene_duration}s > 20s)")
|
||||
|
||||
total_duration += scene_duration
|
||||
|
||||
# 角色数量检查
|
||||
if len(all_speakers) < 2:
|
||||
issues.append("WARN 只检测到 1 个角色,搞笑播客建议至少 2 个角色对话")
|
||||
|
||||
# 总时长检查(默认上限 39s)
|
||||
if total_duration > 0:
|
||||
if total_duration > 39:
|
||||
issues.append(f"WARN 总时长 {total_duration}s 超出上限 (39s)")
|
||||
elif total_duration < 20:
|
||||
issues.append(f"WARN 总时长 {total_duration}s 过短 (建议 30-39s)")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def print_summary(meta: dict, scenes: list[dict], issues: list[str]) -> None:
|
||||
total_lines = sum(len(s["lines"]) for s in scenes)
|
||||
total_duration = sum(
|
||||
ln["duration_seconds"] for s in scenes for ln in s["lines"]
|
||||
)
|
||||
|
||||
# 角色台词统计
|
||||
speaker_stats: dict[str, int] = {}
|
||||
for sc in scenes:
|
||||
for ln in sc["lines"]:
|
||||
speaker_stats[ln["speaker"]] = speaker_stats.get(ln["speaker"], 0) + 1
|
||||
|
||||
print("=== 小动物搞笑播客脚本校验报告 ===\n")
|
||||
print(f"话题: {meta.get('topic', '未指定')}")
|
||||
print(f"动物组合: {meta.get('animals', '未指定')}")
|
||||
print(f"目标时长: {meta.get('target_duration', '未指定')}")
|
||||
print(f"画面比例: {meta.get('aspect_ratio', '未指定')}")
|
||||
print(f"总场景数: {len(scenes)}")
|
||||
print(f"总台词段: {total_lines}")
|
||||
print(f"预估总时长: {total_duration}s ({total_duration // 60}m{total_duration % 60}s)")
|
||||
print()
|
||||
|
||||
# 角色统计
|
||||
print("角色台词分布:")
|
||||
for speaker, count in sorted(speaker_stats.items(), key=lambda x: -x[1]):
|
||||
print(f" {speaker}: {count} 段")
|
||||
print()
|
||||
|
||||
# 场景明细
|
||||
check = lambda v: "Y" if v else "-"
|
||||
print(f"{'场景ID':<14} {'标题':<14} {'台词':>4} {'时长':>6} {'画面':>4} {'时长标记':>6}")
|
||||
print("-" * 56)
|
||||
for sc in scenes:
|
||||
scene_dur = sum(ln["duration_seconds"] for ln in sc["lines"])
|
||||
all_visual = all(ln["has_visual"] for ln in sc["lines"]) if sc["lines"] else False
|
||||
all_dur = all(ln["has_duration"] for ln in sc["lines"]) if sc["lines"] else False
|
||||
print(
|
||||
f"{sc['id']:<14} {sc['title']:<14} {len(sc['lines']):>4} "
|
||||
f"{scene_dur:>5}s {check(all_visual):>4} {check(all_dur):>6}"
|
||||
)
|
||||
|
||||
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")
|
||||
meta, scenes = parse_script(str(script_path))
|
||||
issues = validate(meta, scenes)
|
||||
print_summary(meta, scenes, issues)
|
||||
|
||||
has_error = any("ERROR" in i for i in issues)
|
||||
sys.exit(1 if has_error else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user