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,108 @@
#!/usr/bin/env python3
"""下载 sword-dance 内置预设场景图到本地缓存。
用法:
python3 download_scene_templates.py <cache_dir>
python3 download_scene_templates.py <cache_dir> --scene scene-01
也可作为模块导入,调用 ensure_scene_templates(cache_dir) 获取本地路径。
CDN 源仅在本脚本内部使用,不对外暴露。下载到本地后,所有调用方
render_scene_select.py / Phase 2 reference_image_paths)都使用本地路径。
"""
import argparse
import sys
import urllib.request
from pathlib import Path
# Internal CDN source — do not reference these URLs from markdown or other code.
_CDN_BASE = "https://cdn.hailuoai.com/hailuo-video-web/public_assets"
_PRESETS = {
"scene-01": ["cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"],
"scene-02": ["cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"],
"scene-03": ["cell_1.png", "cell_2.png", "cell_3.png", "cell_4.png"],
}
PRESET_SCENES: tuple[str, ...] = tuple(_PRESETS.keys())
def _fetch_one(scene_id: str, filename: str, cache_dir: Path) -> Path | None:
"""Download a single preset image; skip if already cached.
Returns the local path on success (cached or freshly downloaded), or None
if the download failed.
"""
out_dir = cache_dir / scene_id
out_dir.mkdir(parents=True, exist_ok=True)
local = out_dir / filename
if local.exists():
return local
url = f"{_CDN_BASE}/{scene_id}/{filename}"
try:
urllib.request.urlretrieve(url, local)
except Exception as exc: # noqa: BLE001 — log and continue
print(f"WARN: failed to download {url}: {exc}", file=sys.stderr)
return None
return local
def ensure_scene_templates(
cache_dir: Path,
scenes: tuple[str, ...] | None = None,
) -> dict[str, list[Path]]:
"""Ensure preset scene images exist under cache_dir; download missing ones.
Args:
cache_dir: Directory to cache preset images. Created if absent.
scenes: Optional subset of scene ids to ensure. Defaults to all known.
Returns:
{scene_id: [local_path, ...]} — only includes scenes with at least one
successfully cached image.
"""
targets = scenes or PRESET_SCENES
result: dict[str, list[Path]] = {}
for scene_id in targets:
filenames = _PRESETS.get(scene_id)
if not filenames:
print(f"WARN: unknown scene id: {scene_id}", file=sys.stderr)
continue
paths: list[Path] = []
for fn in filenames:
local = _fetch_one(scene_id, fn, cache_dir)
if local is not None:
paths.append(local)
if paths:
result[scene_id] = paths
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Download sword-dance built-in scene templates")
parser.add_argument("cache_dir", type=Path, help="Local cache directory")
parser.add_argument(
"--scene",
action="append",
choices=PRESET_SCENES,
help="Only download specific scene(s); may be passed multiple times",
)
args = parser.parse_args()
cache_dir: Path = args.cache_dir.resolve()
selected = tuple(args.scene) if args.scene else None
result = ensure_scene_templates(cache_dir, scenes=selected)
if not result:
print("ERROR: no scene templates cached", file=sys.stderr)
return 1
for scene_id, paths in result.items():
print(f"{scene_id}: {len(paths)} files")
for p in paths:
print(f" {p}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""启动图片选择预览服务器。
用法:python3 render_image_select.py --title "人设图选择" --images a.png b.png c.png d.png
展示候选图片供用户点击选择,选择结果写入 feedback.md,服务器自动退出。
"""
import argparse
import base64
import json
import mimetypes
import os
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"
PROJECT_ROOT = Path.cwd() # MCP tool spawns with cwd = user project dir
WORK_DIR = PROJECT_ROOT / ".sword-dance"
def to_url(abs_path: str) -> str:
encoded = base64.urlsafe_b64encode(abs_path.encode()).decode()
try:
mtime = int(os.path.getmtime(abs_path))
except OSError:
mtime = 0
return f"/images/{encoded}?t={mtime}"
class Handler(BaseHTTPRequestHandler):
html_content: str = ""
feedback_path: Path = Path("feedback.md")
server_ref: HTTPServer | None = None
def do_GET(self):
if self.path.startswith("/images/"):
self._serve_image()
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_image(self):
raw = self.path[len("/images/"):]
encoded = raw.split("?")[0] # strip cache-buster query
try:
img_path = Path(base64.urlsafe_b64decode(encoded).decode("utf-8"))
except Exception:
self.send_error(400)
return
if not img_path.exists():
self.send_error(404)
return
mime = mimetypes.guess_type(str(img_path))[0] or "image/png"
data = img_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", "no-cache, no-store, must-revalidate")
self.end_headers()
self.wfile.write(data)
def do_POST(self):
if self.path == "/feedback":
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length).decode("utf-8"))
feedback = body.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}).encode())
else:
self.send_error(404)
def _shutdown(self):
if self.server_ref:
self.server_ref.shutdown()
def log_message(self, fmt, *args):
pass
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--title", required=True, help="页面标题")
parser.add_argument("--images", nargs="+", required=True, help="候选图片路径列表")
parser.add_argument("--feedback", default=str(WORK_DIR / "feedback.md"),
help="反馈文件路径")
parser.add_argument("--lang", default="", help="UI language: zh/en")
parser.add_argument("--port", type=int, default=0)
args = parser.parse_args()
template_path = TEMPLATE_DIR / "preview-image_select.html"
if not template_path.exists():
print(f"ERROR: 模板不存在: {template_path}")
sys.exit(1)
feedback_path = Path(args.feedback)
feedback_path.parent.mkdir(parents=True, exist_ok=True)
# Build candidate data
candidates = []
for i, img_path in enumerate(args.images):
p = Path(img_path).resolve()
if not p.exists():
print(f"WARNING: 图片不存在: {p}")
continue
candidates.append({
"url": to_url(str(p)),
"label": f"Candidate {i + 1}" if args.lang == "en" else f"候选 {i + 1}",
"filename": p.name,
})
if not candidates:
print("ERROR: 没有有效的候选图片")
sys.exit(1)
data = {"title": args.title, "candidates": candidates}
template = template_path.read_text(encoding="utf-8")
injected = template.replace(
"const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */",
f"const PREVIEW_DATA = {json.dumps(json.dumps(data, ensure_ascii=False))};",
)
injected = injected.replace(
"const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */",
f"const PREVIEW_LANG = {json.dumps(args.lang or None)};",
)
server = HTTPServer(("127.0.0.1", args.port), Handler)
port = server.server_address[1]
Handler.html_content = injected
Handler.feedback_path = feedback_path
Handler.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,235 @@
#!/usr/bin/env python3
"""启动空镜选择预览服务器。
用法:python3 render_scene_select.py [--port PORT]
加载内置预设场景(本地 templates/ 优先,否则由 download_scene_templates
脚本下载到 templates_cache/)和 .sword-dance/generated/ 下 AI 生成的场景,
注入 HTML 模板并在浏览器中打开。
用户选择 2 张空镜后,结果写入 feedback.md,服务器自动退出。
"""
import base64
import json
import mimetypes
import os
import sys
import threading
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
# Allow importing sibling script as module when invoked directly
sys.path.insert(0, str(Path(__file__).resolve().parent))
from download_scene_templates import PRESET_SCENES, ensure_scene_templates # noqa: E402
SKILL_DIR = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = SKILL_DIR / "html"
TEMPLATES_DIR = SKILL_DIR / "templates"
PROJECT_ROOT = Path.cwd() # MCP tool spawns with cwd = user project dir
WORK_DIR = PROJECT_ROOT / ".sword-dance"
EXTRA_SCENES_DIR = WORK_DIR / "generated"
TEMPLATES_CACHE_DIR = WORK_DIR / "templates_cache"
FEEDBACK_PATH = WORK_DIR / "feedback.md"
SELECTED_PATH = WORK_DIR / "selected.md"
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
SCENE_META = {
"scene-01": {"zh": "落花飞舞", "en": "Falling Petals"},
"scene-02": {"zh": "蝴蝶", "en": "Butterfly"},
"scene-03": {"zh": "古风书房", "en": "Ancient Study"},
}
def to_url(abs_path: str) -> str:
encoded = base64.urlsafe_b64encode(abs_path.encode()).decode()
try:
mtime = int(os.path.getmtime(abs_path))
except OSError:
mtime = 0
return f"/images/{encoded}?t={mtime}"
def scene_name(scene_id: str, lang: str) -> str:
meta = SCENE_META.get(scene_id)
if isinstance(meta, dict):
return meta.get(lang, meta.get("en", scene_id))
return meta or scene_id
def list_scene_images(scene_dir: Path) -> list[Path]:
return sorted(
p for p in scene_dir.iterdir() if p.suffix.lower() in IMG_EXTS
)
def fetch_cdn_preset(scene_id: str) -> list[Path]:
"""通过 download_scene_templates 模块下载该场景的预设图到本地缓存。"""
cached = ensure_scene_templates(TEMPLATES_CACHE_DIR, scenes=(scene_id,))
return cached.get(scene_id, [])
def make_scene_entry(scene_id: str, image_paths: list[Path], lang: str) -> dict:
return {
"name": scene_name(scene_id, lang),
"images": [
{
"url": to_url(str(p.resolve())),
"path": str(p.resolve()),
"filename": p.name,
}
for p in image_paths
],
}
def build_scene_data(lang: str = "zh") -> dict:
scenes: dict[str, dict] = {}
# Step 1: 预设池 — 本地 templates/ 优先,缺失则从 CDN 下载到 templates_cache/
for scene_id in PRESET_SCENES:
local_dir = TEMPLATES_DIR / scene_id
if local_dir.is_dir():
images = list_scene_images(local_dir)
if images:
scenes[scene_id] = make_scene_entry(scene_id, images, lang)
continue
cached = fetch_cdn_preset(scene_id)
if cached:
scenes[scene_id] = make_scene_entry(scene_id, cached, lang)
# Step 2: AI 生成场景叠加(同名覆盖,但预设用 scene-01~03,生成用 scene-04+
if EXTRA_SCENES_DIR.exists():
for d in sorted(EXTRA_SCENES_DIR.iterdir()):
if not d.is_dir() or not d.name.startswith("scene-"):
continue
images = list_scene_images(d)
if images:
scenes[d.name] = make_scene_entry(d.name, images, lang)
return {"scenes": scenes}
class Handler(BaseHTTPRequestHandler):
html_content: str = ""
feedback_path: Path = Path("feedback.md")
server_ref: HTTPServer | None = None
def do_GET(self):
if self.path.startswith("/images/"):
self._serve_image()
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_image(self):
raw = self.path[len("/images/"):]
encoded = raw.split("?")[0] # strip cache-buster query
try:
img_path = Path(base64.urlsafe_b64decode(encoded).decode("utf-8"))
except Exception:
self.send_error(400)
return
if not img_path.exists():
self.send_error(404)
return
mime = mimetypes.guess_type(str(img_path))[0] or "image/png"
data = img_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", "no-cache, no-store, must-revalidate")
self.end_headers()
self.wfile.write(data)
def do_POST(self):
if self.path == "/feedback":
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length).decode("utf-8"))
feedback = body.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}).encode())
else:
self.send_error(404)
def _shutdown(self):
if self.server_ref:
self.server_ref.shutdown()
def log_message(self, fmt, *args):
pass
def load_preselect(selected_path: Path) -> list[str]:
if not selected_path.exists():
return []
paths = []
for line in selected_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
paths.append(line)
return paths
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--lang", default="", help="UI language: zh/en")
args = parser.parse_args()
template_path = TEMPLATE_DIR / "preview-scene_select.html"
if not template_path.exists():
print(f"ERROR: 模板不存在: {template_path}")
sys.exit(1)
feedback_path = FEEDBACK_PATH
WORK_DIR.mkdir(parents=True, exist_ok=True)
preselect = load_preselect(SELECTED_PATH)
template = template_path.read_text(encoding="utf-8")
data = build_scene_data(lang=args.lang or "zh")
content = json.dumps(data, ensure_ascii=False)
injected = template.replace(
"const PREVIEW_DATA = null; /* __PREVIEW_DATA__ */",
f"const PREVIEW_DATA = {json.dumps(content)};",
)
injected = injected.replace(
"const PRESELECT = []; /* __PRESELECT__ */",
f"const PRESELECT = {json.dumps(preselect)};",
)
injected = injected.replace(
"const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */",
f"const PREVIEW_LANG = {json.dumps(args.lang or None)};",
)
server = HTTPServer(("127.0.0.1", args.port), Handler)
port = server.server_address[1]
Handler.html_content = injected
Handler.feedback_path = feedback_path
Handler.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()