Flatten skill category directory structure
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user