Flatten skill category directory structure

This commit is contained in:
2026-05-20 17:04:50 +08:00
parent 0dd552a6f7
commit 63f2baa4bd
354 changed files with 0 additions and 0 deletions
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""启动本地预览服务器,用于 drama-soundtrack skill 的预览功能。
支持两种预览类型:
- soundtrack_plan: 原声蓝图审阅(卡片式展示所有计划曲目)
- soundtrack_gallery: 最终音频画廊(带播放器的音频展示)
用法:
python3 render_soundtrack_preview.py <目录路径> --type soundtrack_plan
python3 render_soundtrack_preview.py <目录路径> --type soundtrack_gallery
流程:
1. 解析 soundtrack-plan.md 或扫描音频文件
2. 注入 HTML 模板
3. 启动本地 HTTP 服务器
4. 在浏览器中打开预览页面
5. 用户提交反馈后,写入 feedback.md,服务器自动退出
"""
import argparse
import base64
import json
import mimetypes
import re
import sys
import threading
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = SKILL_DIR / "html"
AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".m4a", ".flac"}
def parse_soundtrack_plan(plan_path: Path) -> dict:
"""解析 soundtrack-plan.md,返回结构化数据。"""
text = plan_path.read_text(encoding="utf-8")
result = {
"title": "",
"character_themes": [],
"opening": None,
"ending": None,
"bgm": [],
}
# 提取标题
m = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
if m:
result["title"] = m.group(1).strip()
# 按二级标题拆分
sections: dict[str, str] = {}
current = None
lines: list[str] = []
for line in text.split("\n"):
h2 = re.match(r"^##\s+(.+)$", line)
if h2:
if current:
sections[current] = "\n".join(lines)
current = h2.group(1).strip()
lines = []
else:
lines.append(line)
if current:
sections[current] = "\n".join(lines)
def parse_track(block: str) -> dict:
"""从一个三级标题下的文本块中提取曲目信息。"""
track: dict = {}
for line in block.split("\n"):
line = line.strip()
if line.startswith("- 风格标签:"):
track["style_tags"] = line[len("- 风格标签:"):].strip()
elif line.startswith("- 人声风格:"):
track["vocal_style"] = line[len("- 人声风格:"):].strip()
elif line.startswith("- 情感基调:"):
track["emotion"] = line[len("- 情感基调:"):].strip()
elif line.startswith("- 关键乐器:"):
track["instruments"] = line[len("- 关键乐器:"):].strip()
elif line.startswith("- 歌词方向:"):
track["lyrics_direction"] = line[len("- 歌词方向:"):].strip()
elif line.startswith("- 节奏:"):
track["tempo"] = line[len("- 节奏:"):].strip()
elif line.startswith("- 设计理由:"):
track["rationale"] = line[len("- 设计理由:"):].strip()
elif line.startswith("- Hook 策略:"):
track["hook_strategy"] = line[len("- Hook 策略:"):].strip()
elif line.startswith("- 场景描述:"):
track["scene_description"] = line[len("- 场景描述:"):].strip()
elif line.startswith("- 情感类型:"):
track["emotion_type"] = line[len("- 情感类型:"):].strip()
elif line.startswith("- 时长预估:"):
track["duration_estimate"] = line[len("- 时长预估:"):].strip()
return track
def parse_tracks_in_section(section_text: str) -> list[dict]:
"""拆分三级标题,解析每个曲目块。"""
tracks = []
current_name = None
current_lines: list[str] = []
for line in section_text.split("\n"):
h3 = re.match(r"^###\s+(.+)$", line)
if h3:
if current_name:
track = parse_track("\n".join(current_lines))
track["name"] = current_name
tracks.append(track)
current_name = h3.group(1).strip()
current_lines = []
else:
current_lines.append(line)
if current_name:
track = parse_track("\n".join(current_lines))
track["name"] = current_name
tracks.append(track)
return tracks
# 角色主题曲
for key in sections:
if "角色主题曲" in key:
result["character_themes"] = parse_tracks_in_section(sections[key])
# 片头曲
for key in sections:
if "片头曲" in key or "OP" in key:
tracks = parse_tracks_in_section(sections[key])
if tracks:
result["opening"] = tracks[0]
# 片尾曲
for key in sections:
if "片尾曲" in key or "ED" in key:
tracks = parse_tracks_in_section(sections[key])
if tracks:
result["ending"] = tracks[0]
# 场景配乐
for key in sections:
if "场景配乐" in key:
result["bgm"] = parse_tracks_in_section(sections[key])
return result
def scan_audio_gallery(project_dir: Path) -> dict:
"""扫描项目目录下的所有 selected.mp3 文件,构建画廊数据。"""
def to_url(abs_path: str) -> str:
encoded = base64.urlsafe_b64encode(abs_path.encode()).decode()
return f"/audio/{encoded}"
gallery = {
"title": project_dir.name,
"character_themes": [],
"opening": None,
"ending": None,
"bgm": [],
}
# 角色主题曲
ct_dir = project_dir / "character-themes"
if ct_dir.exists():
for d in sorted(ct_dir.iterdir()):
if not d.is_dir():
continue
selected = d / "selected.mp3"
if selected.exists():
lyrics_file = d / "lyrics.md"
prompt_file = d / "prompt.md"
gallery["character_themes"].append({
"name": d.name,
"audio_url": to_url(str(selected.resolve())),
"has_lyrics": lyrics_file.exists(),
"has_prompt": prompt_file.exists(),
})
# OP
op_selected = project_dir / "opening" / "selected.mp3"
if op_selected.exists():
gallery["opening"] = {
"name": "片头曲",
"audio_url": to_url(str(op_selected.resolve())),
}
# ED
ed_selected = project_dir / "ending" / "selected.mp3"
if ed_selected.exists():
gallery["ending"] = {
"name": "片尾曲",
"audio_url": to_url(str(ed_selected.resolve())),
}
# BGM
bgm_dir = project_dir / "bgm"
if bgm_dir.exists():
for d in sorted(bgm_dir.iterdir()):
if not d.is_dir():
continue
selected = d / "selected.mp3"
if selected.exists():
gallery["bgm"].append({
"name": d.name,
"audio_url": to_url(str(selected.resolve())),
})
return gallery
class PreviewHandler(BaseHTTPRequestHandler):
html_content: str = ""
feedback_path: Path = Path("feedback.md")
server_ref: HTTPServer | None = None
def do_GET(self):
if self.path.startswith("/audio/"):
self._serve_audio()
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(self.html_content.encode("utf-8"))
def _serve_audio(self):
encoded = self.path[len("/audio/"):]
try:
audio_path = Path(base64.urlsafe_b64decode(encoded).decode("utf-8"))
except Exception:
self.send_error(400, "invalid audio path")
return
if not audio_path.exists():
self.send_error(404, "audio not found")
return
mime = mimetypes.guess_type(str(audio_path))[0] or "audio/mpeg"
data = audio_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "max-age=3600")
self.end_headers()
self.wfile.write(data)
def do_POST(self):
if self.path == "/feedback":
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8")
data = json.loads(body)
feedback = data.get("feedback", "").strip()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
if feedback:
self.feedback_path.write_text(feedback, encoding="utf-8")
self.wfile.write(json.dumps({"ok": True}).encode())
print(f"FEEDBACK_FILE={self.feedback_path}")
threading.Timer(0.5, self._shutdown).start()
else:
self.wfile.write(json.dumps({"ok": False, "msg": "empty"}).encode())
else:
self.send_error(404)
def _shutdown(self):
if self.server_ref:
self.server_ref.shutdown()
def log_message(self, format, *args):
pass
def main():
parser = argparse.ArgumentParser(description="启动 Drama Soundtrack 预览服务器")
parser.add_argument("path", help="项目目录路径 (如 .drama-soundtrack/剧名/)")
parser.add_argument("--type", required=True,
choices=["soundtrack_plan", "soundtrack_gallery"],
help="预览类型")
parser.add_argument("--port", type=int, default=0, help="端口号,默认自动分配")
parsed = parser.parse_args()
src = Path(parsed.path).resolve()
if not src.exists():
print(f"ERROR: 路径不存在: {src}")
sys.exit(1)
preview_type = parsed.type
template_path = TEMPLATE_DIR / f"preview-{preview_type}.html"
if not template_path.exists():
print(f"ERROR: 模板不存在: {template_path}")
sys.exit(1)
template = template_path.read_text(encoding="utf-8")
if preview_type == "soundtrack_plan":
plan_file = src / "soundtrack-plan.md"
if not plan_file.exists():
print(f"ERROR: soundtrack-plan.md 不存在: {plan_file}")
sys.exit(1)
content = json.dumps(parse_soundtrack_plan(plan_file), ensure_ascii=False)
else: # soundtrack_gallery
content = json.dumps(scan_audio_gallery(src), ensure_ascii=False)
injected = template.replace(
"const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */",
f"const PREVIEW_DATA = {json.dumps(content)};",
)
server = HTTPServer(("127.0.0.1", parsed.port), PreviewHandler)
port = server.server_address[1]
PreviewHandler.html_content = injected
PreviewHandler.feedback_path = src / "feedback.md"
PreviewHandler.server_ref = server
url = f"http://127.0.0.1:{port}"
print(f"PREVIEW_URL={url}")
print("等待用户提交...")
webbrowser.open(url)
server.serve_forever()
print("服务器已停止")
if __name__ == "__main__":
main()
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""校验 analysis.md 剧情分析报告格式是否符合要求。
规则:
- 必须包含 5 个二级章节:类型分析、情感弧线、角色分析、关键场景、文化背景
- 角色分析至少 1 个,最多 5 个
- 每个角色必须包含:性格关键词、情感旅程、关键转折、音乐性格
- 音乐性格必须包含 4 个维度:节奏倾向、能量级、明暗、冷暖
- 关键场景至少 3 个,最多 10 个
- 每个场景必须包含:编号(S格式)、位置、场景描述、情感类型、强度
- 文化背景必须包含:时代、文化元素、乐器倾向
"""
import re
import sys
from pathlib import Path
REQUIRED_SECTIONS = ["类型分析", "情感弧线", "角色分析", "关键场景", "文化背景"]
CHARACTER_FIELDS = ["性格关键词", "情感旅程", "关键转折", "音乐性格"]
MUSIC_PROFILE_FIELDS = ["节奏倾向", "能量级", "明暗", "冷暖"]
CULTURE_FIELDS = ["时代", "文化元素", "乐器倾向"]
SCENE_ID_RE = re.compile(r"S\d{2}")
def parse_sections(text: str) -> dict[str, str]:
"""按二级标题拆分文档。"""
sections: dict[str, str] = {}
current_title = None
current_lines: list[str] = []
for line in text.split("\n"):
m = re.match(r"^##\s+(.+)$", line)
if m:
if current_title:
sections[current_title] = "\n".join(current_lines)
current_title = m.group(1).strip()
current_lines = []
else:
current_lines.append(line)
if current_title:
sections[current_title] = "\n".join(current_lines)
return sections
def parse_characters(section_text: str) -> list[dict]:
"""从角色分析章节提取角色列表。"""
characters: list[dict] = []
current_name = None
current_content: list[str] = []
for line in section_text.split("\n"):
m = re.match(r"^###\s+【(.+?)】", line)
if m:
if current_name:
characters.append({"name": current_name, "content": "\n".join(current_content)})
current_name = m.group(1)
current_content = []
else:
current_content.append(line)
if current_name:
characters.append({"name": current_name, "content": "\n".join(current_content)})
return characters
def parse_scenes(section_text: str) -> list[dict]:
"""从关键场景章节提取场景列表(支持表格和列表格式)。"""
scenes: list[dict] = []
# 尝试匹配表格行(| 分隔)
for line in section_text.split("\n"):
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.split("|")[1:-1]]
if len(cells) >= 5 and SCENE_ID_RE.match(cells[0]):
scenes.append({
"id": cells[0],
"location": cells[1],
"description": cells[2],
"emotion_type": cells[3],
"intensity": cells[4],
})
return scenes
def validate(path: str) -> list[str]:
"""校验 analysis.md,返回问题列表。"""
text = Path(path).read_text(encoding="utf-8")
issues: list[str] = []
sections = parse_sections(text)
# 检查必需章节
for section_name in REQUIRED_SECTIONS:
if section_name not in sections:
issues.append(f"ERROR 缺少必需章节:## {section_name}")
# 检查角色分析
if "角色分析" in sections:
characters = parse_characters(sections["角色分析"])
if len(characters) < 1:
issues.append("ERROR 角色分析中未找到任何角色(需用 ### 【角色名】 格式)")
elif len(characters) > 5:
issues.append(f"WARN 角色数量 {len(characters)} 超过推荐上限 5 个")
for char in characters:
for field in CHARACTER_FIELDS:
if field not in char["content"]:
issues.append(f"ERROR 角色【{char['name']}】缺少字段:{field}")
# 检查音乐性格子维度
if "音乐性格" in char["content"]:
for mf in MUSIC_PROFILE_FIELDS:
if mf not in char["content"]:
issues.append(f"WARN 角色【{char['name']}】音乐性格缺少维度:{mf}")
# 检查关键场景
if "关键场景" in sections:
scenes = parse_scenes(sections["关键场景"])
if len(scenes) < 3:
issues.append(f"ERROR 关键场景数量不足:{len(scenes)} 个(至少需要 3 个)")
elif len(scenes) > 10:
issues.append(f"WARN 关键场景数量 {len(scenes)} 超过推荐上限 10 个")
# 检查文化背景
if "文化背景" in sections:
culture_text = sections["文化背景"]
for field in CULTURE_FIELDS:
if field not in culture_text:
issues.append(f"WARN 文化背景缺少字段:{field}")
return issues
def print_summary(path: str, issues: list[str]) -> None:
text = Path(path).read_text(encoding="utf-8")
sections = parse_sections(text)
print(f"=== 剧情分析校验报告 ===\n")
# 章节概览
print(f"{'章节':<15} {'状态':<8}")
print("-" * 25)
for name in REQUIRED_SECTIONS:
status = "" if name in sections else "✗ 缺失"
print(f"{name:<15} {status:<8}")
# 角色统计
if "角色分析" in sections:
characters = parse_characters(sections["角色分析"])
print(f"\n角色数量: {len(characters)}")
for char in characters:
print(f" - 【{char['name']}")
# 场景统计
if "关键场景" in sections:
scenes = parse_scenes(sections["关键场景"])
print(f"\n关键场景数量: {len(scenes)}")
print()
if issues:
errors = [i for i in issues if i.startswith("ERROR")]
warns = [i for i in issues if i.startswith("WARN")]
print(f"发现 {len(errors)} 个错误, {len(warns)} 个警告:\n")
for issue in issues:
print(f" {issue}")
else:
print("ALL PASS")
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
print("用法: python validate_analysis.py <analysis.md 路径>")
print()
print("校验剧情分析报告格式是否符合 drama-soundtrack skill 要求。")
print()
print("校验规则:")
print(" - 必须包含 5 个二级章节:类型分析、情感弧线、角色分析、关键场景、文化背景")
print(" - 角色 1-5 个,每个含性格关键词、情感旅程、关键转折、音乐性格")
print(" - 音乐性格含 4 维度:节奏倾向、能量级、明暗、冷暖")
print(" - 关键场景 3-10 个,含编号(S格式)、位置、场景描述、情感类型、强度")
print(" - 文化背景含:时代、文化元素、乐器倾向")
sys.exit(0)
path = sys.argv[1]
if not Path(path).exists():
print(f"ERROR: 文件不存在: {path}")
sys.exit(1)
issues = validate(path)
print_summary(path, issues)
has_error = any(i.startswith("ERROR") for i in issues)
sys.exit(1 if has_error else 0)
if __name__ == "__main__":
main()