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.
1034 lines
40 KiB
1034 lines
40 KiB
#!/usr/bin/env python3
|
|
"""启动本地预览服务器,将 .md 数据注入 preview.html 并在浏览器中打开。
|
|
|
|
用法:
|
|
python3 render_preview.py <文件路径> [--type lyric]
|
|
python3 render_preview.py <目录路径> --type art_style
|
|
|
|
流程:
|
|
1. 读取 .md 数据文件(或目录下的图片),注入 HTML 模板
|
|
2. 启动本地 HTTP 服务器(后台运行)
|
|
3. 在浏览器中打开预览页面
|
|
4. 用户提交编辑建议后,写入 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
|
|
|
|
# Allow importing sibling script as module when invoked directly
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from download_style_presets import ensure_style_presets # noqa: E402
|
|
|
|
SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
TEMPLATE_DIR = SKILL_DIR / "html"
|
|
STYLE_PRESETS_DIR = SKILL_DIR / "style-presets"
|
|
STYLE_CACHE_SUBDIR = "style_cache"
|
|
|
|
# 风格预设元数据
|
|
STYLE_META = {
|
|
"cinematic": {
|
|
"name": {"zh": "电影质感写实", "en": "Cinematic Realism"},
|
|
"description": {
|
|
"zh": "35mm 胶片颗粒、暖琥珀色调、自然光、宽银幕。参考《Into the Wild》《Nomadland》的视觉语言",
|
|
"en": "35mm film grain, warm amber tones, natural lighting, widescreen. Visual language inspired by Into the Wild & Nomadland",
|
|
},
|
|
"tags": ["cinematic", "film grain", "natural lighting", "warm amber", "35mm", "anamorphic"],
|
|
"recommend": "Midjourney V7 / Kling",
|
|
},
|
|
"oil-painting": {
|
|
"name": {"zh": "油画风格", "en": "Oil Painting"},
|
|
"description": {
|
|
"zh": "厚重笔触、印象派色彩、painterly 质感,将公路旅行转化为一幅流动的画作",
|
|
"en": "Thick brushstrokes, impressionist colors, painterly texture — turning a road trip into a flowing painting",
|
|
},
|
|
"tags": ["oil painting", "impressionist", "thick brushstrokes", "warm palette", "painterly"],
|
|
"recommend": "Midjourney V7 --style raw / Seedream 4.5",
|
|
},
|
|
"noir-illustration": {
|
|
"name": {"zh": "黑色插画", "en": "Noir Illustration"},
|
|
"description": {
|
|
"zh": "高对比度光影、图像小说般的视觉张力、琥珀色点缀的单色世界",
|
|
"en": "High-contrast lighting, graphic novel visual tension, monochromatic world with amber accents",
|
|
},
|
|
"tags": ["noir", "high contrast", "silhouette", "graphic novel", "monochromatic", "amber accent"],
|
|
"recommend": "Midjourney V7 --style raw / OpenAI GPT-Image",
|
|
},
|
|
}
|
|
|
|
IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
|
|
|
|
|
|
# 中英 section header 映射,用于解析器兼容双语 markdown
|
|
SECTION_ALIASES = {
|
|
"外貌特征": "Appearance",
|
|
"性格气质": "Personality",
|
|
"标志物件": "Signature Items",
|
|
"生图设定": "Image Settings",
|
|
"候选图": "Candidates",
|
|
"选定图": "Selected",
|
|
"场景描述": "Scene Description",
|
|
"出场幕列表": "Scene List",
|
|
"外观描述": "Appearance",
|
|
"叙事作用": "Narrative Role",
|
|
"场景背景": "Scene Background",
|
|
"关键物件": "Key Props",
|
|
"出场角色": "Characters",
|
|
}
|
|
|
|
|
|
def get_section(section_lines: dict, zh_name: str) -> list:
|
|
"""从 section_lines 字典中获取内容,兼容中英 section header。"""
|
|
en_name = SECTION_ALIASES.get(zh_name, "")
|
|
return section_lines.get(zh_name, section_lines.get(en_name, []))
|
|
|
|
|
|
def scan_style_presets(cache_dir: Path | None = None):
|
|
"""扫描 style-presets/ 目录,返回 {style_id: {meta..., images: [path, ...]}}。
|
|
|
|
本地 style-presets/ 优先;缺失时从 CDN 下载到 cache_dir(需提供,
|
|
通过 download_style_presets 模块完成下载)。
|
|
所有返回的 images 都是本地文件路径,确保下游 image_paths 可直接使用。
|
|
"""
|
|
styles = {}
|
|
has_local = STYLE_PRESETS_DIR.exists() and any(
|
|
d.is_dir() and any(p.suffix.lower() in IMG_EXTS for p in d.iterdir())
|
|
for d in STYLE_PRESETS_DIR.iterdir()
|
|
)
|
|
|
|
if has_local:
|
|
for d in sorted(STYLE_PRESETS_DIR.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
style_id = d.name
|
|
images = sorted(p for p in d.iterdir() if p.suffix.lower() in IMG_EXTS)
|
|
if not images:
|
|
continue
|
|
meta = STYLE_META.get(style_id, {
|
|
"name": {"zh": style_id, "en": style_id},
|
|
"description": {"zh": "", "en": ""},
|
|
"tags": [],
|
|
"recommend": "",
|
|
})
|
|
styles[style_id] = {**meta, "images": [str(p) for p in images], "source": "local"}
|
|
return styles
|
|
|
|
if cache_dir is None:
|
|
# CDN fallback unavailable without a cache directory
|
|
return styles
|
|
|
|
cached = ensure_style_presets(cache_dir)
|
|
for style_id, paths in cached.items():
|
|
meta = STYLE_META.get(style_id, {
|
|
"name": {"zh": style_id, "en": style_id},
|
|
"description": {"zh": "", "en": ""},
|
|
"tags": [],
|
|
"recommend": "",
|
|
})
|
|
styles[style_id] = {**meta, "images": [str(p) for p in paths], "source": "cached"}
|
|
return styles
|
|
|
|
|
|
def scan_user_references(theme_dir: Path):
|
|
"""扫描用户上传的参考图"""
|
|
ref_dir = theme_dir / "style_references"
|
|
if not ref_dir.exists():
|
|
return []
|
|
return sorted(str(p) for p in ref_dir.iterdir() if p.suffix.lower() in IMG_EXTS)
|
|
|
|
|
|
class PreviewHandler(BaseHTTPRequestHandler):
|
|
html_content: str = ""
|
|
feedback_path: Path = Path("feedback.md")
|
|
theme_dir: Path | None = None
|
|
server_ref: HTTPServer | None = None
|
|
|
|
def do_GET(self):
|
|
# 静态图片请求:/images/<abs_path_base64>
|
|
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, "invalid image path")
|
|
return
|
|
if not img_path.exists():
|
|
self.send_error(404, "image not found")
|
|
return
|
|
mime = mimetypes.guess_type(str(img_path))[0] or "image/png"
|
|
file_size = img_path.stat().st_size
|
|
|
|
# 支持 Range 请求(视频拖拽播放需要)
|
|
range_header = self.headers.get("Range")
|
|
if range_header and range_header.startswith("bytes="):
|
|
range_spec = range_header[6:]
|
|
start_str, _, end_str = range_spec.partition("-")
|
|
start = int(start_str) if start_str else 0
|
|
end = int(end_str) if end_str else file_size - 1
|
|
end = min(end, file_size - 1)
|
|
length = end - start + 1
|
|
with open(img_path, "rb") as f:
|
|
f.seek(start)
|
|
data = f.read(length)
|
|
self.send_response(206)
|
|
self.send_header("Content-Type", mime)
|
|
self.send_header("Content-Length", str(length))
|
|
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
else:
|
|
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("Accept-Ranges", "bytes")
|
|
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":
|
|
self._handle_feedback()
|
|
elif self.path == "/upload":
|
|
self._handle_upload()
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def _handle_feedback(self):
|
|
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())
|
|
|
|
def _handle_upload(self):
|
|
"""处理用户上传的参考图。
|
|
|
|
支持两种模式:
|
|
- art_style: 上传到 style_references/(限 1 张)
|
|
- character_design: 上传到 {character_dir}/(按 char_index 定位角色目录)
|
|
"""
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = self.rfile.read(length)
|
|
|
|
try:
|
|
data = json.loads(body)
|
|
img_data = base64.b64decode(data["data"])
|
|
filename = data.get("filename", "upload.png")
|
|
char_index = data.get("char_index")
|
|
except Exception as e:
|
|
self.send_response(400)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({"ok": False, "msg": str(e)}).encode())
|
|
return
|
|
|
|
if not self.theme_dir:
|
|
self.send_error(500, "theme_dir not set")
|
|
return
|
|
|
|
ext = Path(filename).suffix or ".png"
|
|
|
|
if char_index is not None:
|
|
# character_design: 保存到角色目录下
|
|
characters_dir = self.theme_dir / "characters"
|
|
char_dirs = sorted(
|
|
d for d in characters_dir.iterdir()
|
|
if d.is_dir() and (d / "design.md").exists()
|
|
) if characters_dir.exists() else []
|
|
if char_index < 0 or char_index >= len(char_dirs):
|
|
self.send_error(400, "invalid char_index")
|
|
return
|
|
save_dir = char_dirs[char_index]
|
|
save_path = save_dir / f"regen_ref{ext}"
|
|
elif data.get("storyboard"):
|
|
# storyboard: 保存到 storyboard/ 目录下
|
|
save_dir = self.theme_dir / "storyboard"
|
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
save_path = save_dir / f"ref_{filename}"
|
|
elif data.get("clips"):
|
|
# clips: 保存到 clips/ 目录下作为参考图
|
|
save_dir = self.theme_dir / "clips"
|
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
save_path = save_dir / f"ref_{filename}"
|
|
else:
|
|
# art_style: 保存到 style_references/
|
|
save_dir = self.theme_dir / "style_references"
|
|
save_dir.mkdir(parents=True, exist_ok=True)
|
|
for old in save_dir.iterdir():
|
|
if old.suffix.lower() in IMG_EXTS:
|
|
old.unlink()
|
|
save_path = save_dir / f"user_ref{ext}"
|
|
|
|
save_path.write_bytes(img_data)
|
|
|
|
encoded = base64.urlsafe_b64encode(str(save_path.resolve()).encode()).decode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(json.dumps({
|
|
"ok": True,
|
|
"url": f"/images/{encoded}",
|
|
"path": str(save_path),
|
|
}).encode())
|
|
|
|
def _shutdown(self):
|
|
if self.server_ref:
|
|
self.server_ref.shutdown()
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
|
|
def build_art_style_data(theme_dir: Path, lang: str = "zh"):
|
|
"""构建美术风格数据 JSON"""
|
|
lang = "zh" if lang == "zh" else "en" # normalize: only zh/en supported
|
|
cache_dir = theme_dir / STYLE_CACHE_SUBDIR
|
|
presets = scan_style_presets(cache_dir=cache_dir)
|
|
user_refs = scan_user_references(theme_dir)
|
|
|
|
# 将本地图片路径转为 URL
|
|
def to_url(abs_path):
|
|
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}"
|
|
|
|
result = {"presets": {}, "user_refs": []}
|
|
for style_id, info in presets.items():
|
|
name = info["name"]
|
|
desc = info["description"]
|
|
result["presets"][style_id] = {
|
|
"name": name.get(lang, name.get("en", "")) if isinstance(name, dict) else name,
|
|
"description": desc.get(lang, desc.get("en", "")) if isinstance(desc, dict) else desc,
|
|
"tags": info["tags"],
|
|
"recommend": info["recommend"],
|
|
"images": [{"url": to_url(p), "filename": Path(p).name} for p in info["images"]],
|
|
}
|
|
for p in user_refs:
|
|
result["user_refs"].append({"url": to_url(p), "filename": Path(p).name})
|
|
|
|
return result
|
|
|
|
|
|
def build_storyboard_data(theme_dir: Path):
|
|
"""构建分镜数据 JSON,解析 storyboard.md 和 storyboard/ 目录。"""
|
|
import re
|
|
|
|
H2_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*$"
|
|
)
|
|
LIST_RE = re.compile(r"^-\s+(.+)$")
|
|
|
|
def to_url(abs_path):
|
|
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}"
|
|
|
|
storyboard_file = theme_dir / "storyboard.md"
|
|
storyboard_dir = theme_dir / "storyboard"
|
|
scenes = []
|
|
|
|
if not storyboard_file.exists():
|
|
return {"scenes": scenes}
|
|
|
|
text = storyboard_file.read_text(encoding="utf-8")
|
|
lines = text.split("\n")
|
|
|
|
current = None
|
|
current_field = None
|
|
|
|
for line_raw in lines:
|
|
line = line_raw.strip()
|
|
|
|
h2 = H2_RE.match(line)
|
|
if h2:
|
|
if current:
|
|
scenes.append(current)
|
|
current = {
|
|
"title": h2.group(1),
|
|
"time_raw": "",
|
|
"lyrics": [],
|
|
"characters": "",
|
|
"scene_background": "",
|
|
"key_props": "",
|
|
"char_refs": [],
|
|
"description": "",
|
|
"prompt": "",
|
|
"ref_images": [],
|
|
"model": "",
|
|
"candidates": [],
|
|
"selected": None,
|
|
}
|
|
current_field = None
|
|
continue
|
|
|
|
if current is None:
|
|
continue
|
|
|
|
if line == "---":
|
|
current_field = None
|
|
continue
|
|
|
|
tm = TIME_RE.match(line)
|
|
if tm:
|
|
m1, s1, m2, s2 = tm.group(1), tm.group(2), tm.group(3), tm.group(4)
|
|
dur = tm.group(5) or ""
|
|
current["time_raw"] = f"{m1}:{s1} - {m2}:{s2}" + (f" ({dur}s)" if dur else "")
|
|
current_field = None
|
|
continue
|
|
|
|
if line.startswith(">"):
|
|
current["lyrics"].append(line[1:].strip())
|
|
continue
|
|
|
|
# 字段检测 (兼容中英)
|
|
if line.startswith(("出场角色:", "出场角色:", "Characters:", "Characters:")):
|
|
chars = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
current["characters"] = chars
|
|
current_field = "characters"
|
|
continue
|
|
if line.startswith(("场景背景:", "场景背景:", "Scene Background:", "Scene Background:")):
|
|
bg = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
current["scene_background"] = bg
|
|
current_field = "scene_background"
|
|
continue
|
|
if line.startswith(("关键物件:", "关键物件:", "Key Props:", "Key Props:")):
|
|
props = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
current["key_props"] = props
|
|
current_field = "key_props"
|
|
continue
|
|
if line.startswith(("场景描述:", "场景描述:", "Scene Description:", "Scene Description:")):
|
|
rest = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
if rest:
|
|
current["description"] = rest
|
|
current_field = "desc"
|
|
continue
|
|
if line.startswith(("生图提示词:", "生图提示词:", "视频提示词:", "视频提示词:", "Video Prompt:", "Video Prompt:")):
|
|
rest = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
if rest:
|
|
current["prompt"] = rest
|
|
current_field = "prompt"
|
|
continue
|
|
if line.startswith(("参考图:", "参考图:", "Reference:", "Reference:")):
|
|
current_field = "refs"
|
|
continue
|
|
if line.startswith(("模型:", "模型:", "Model:", "Model:")):
|
|
current["model"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
current_field = "model"
|
|
continue
|
|
if line.startswith(("候选图:", "候选图:", "Candidates:", "Candidates:")):
|
|
current_field = "candidates"
|
|
continue
|
|
if line.startswith(("选定图:", "选定图:", "Selected:", "Selected:")):
|
|
current_field = "selected"
|
|
continue
|
|
|
|
# 续行内容
|
|
if not line:
|
|
continue
|
|
|
|
if current_field == "desc":
|
|
current["description"] += (" " if current["description"] else "") + line
|
|
elif current_field == "prompt":
|
|
current["prompt"] += (" " if current["prompt"] else "") + line
|
|
elif current_field == "refs":
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
ref_text = m.group(1)
|
|
# 提取路径部分(括号前)
|
|
ref_path = ref_text.split("(")[0].split("(")[0].strip()
|
|
note = ""
|
|
if "(" in ref_text:
|
|
note = ref_text.split("(", 1)[1].rstrip(")")
|
|
elif "(" in ref_text:
|
|
note = ref_text.split("(", 1)[1].rstrip(")")
|
|
abs_ref = theme_dir / ref_path
|
|
url = to_url(str(abs_ref.resolve())) if abs_ref.exists() else ""
|
|
current["ref_images"].append({
|
|
"path": ref_path,
|
|
"note": note,
|
|
"url": url,
|
|
})
|
|
elif current_field == "candidates":
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
parts = m.group(1).split("—", 1)
|
|
filename = parts[0].strip().split()[0]
|
|
desc = parts[1].strip() if len(parts) > 1 else ""
|
|
img_path = storyboard_dir / filename
|
|
if img_path.exists():
|
|
current["candidates"].append({
|
|
"filename": filename,
|
|
"label": filename,
|
|
"description": desc,
|
|
"url": to_url(str(img_path.resolve())),
|
|
})
|
|
elif current_field == "selected" and line:
|
|
current["selected"] = line.strip()
|
|
|
|
if current:
|
|
scenes.append(current)
|
|
|
|
# 为有角色的场景添加角色参考图 URL + 结构化角色名列表
|
|
# 同时从 design.md 读取显示名(H1 标题),用于预览页展示
|
|
char_display_names = {} # dir_name → display_name
|
|
characters_dir = theme_dir / "characters"
|
|
if characters_dir.exists():
|
|
for d in characters_dir.iterdir():
|
|
if d.is_dir() and (d / "design.md").exists():
|
|
for line in (d / "design.md").read_text(encoding="utf-8").split("\n"):
|
|
h1 = re.match(r"^#\s+(.+)$", line.strip())
|
|
if h1:
|
|
char_display_names[d.name] = h1.group(1)
|
|
break
|
|
|
|
# 反向映射:显示名 → 目录名,兼容 storyboard 中使用显示名引用角色的情况
|
|
char_display_to_dir = {v: k for k, v in char_display_names.items()}
|
|
|
|
def resolve_char_dir(name: str) -> str:
|
|
"""将角色名解析为目录名,兼容目录名和显示名两种输入。"""
|
|
if (theme_dir / "characters" / name).exists():
|
|
return name
|
|
return char_display_to_dir.get(name, name)
|
|
|
|
for sc in scenes:
|
|
chars_val = (sc["characters"] or "").strip().lower()
|
|
if sc["characters"] and chars_val not in ("(无)", "(无)", "无", "none", "(none)", "n/a", ""):
|
|
char_names_raw = [c.strip() for c in re.split(r'[,、&/]|\s+and\s+', sc["characters"]) if c.strip()]
|
|
# 解析为目录名(兼容输入为显示名的情况)
|
|
char_dirs = [resolve_char_dir(cn) for cn in char_names_raw]
|
|
# 用显示名替换目录名用于 UI 展示
|
|
sc["character_names"] = [char_display_names.get(d, d) for d in char_dirs]
|
|
# 优先从 Reference 列表中提取角色参考图(按位置与 character_names 配对)
|
|
char_ref_imgs = [r for r in sc["ref_images"]
|
|
if "角色" in r.get("note", "") or "character" in r.get("note", "").lower()]
|
|
if char_ref_imgs:
|
|
for i, dir_name in enumerate(char_dirs):
|
|
display = char_display_names.get(dir_name, dir_name)
|
|
if i < len(char_ref_imgs) and char_ref_imgs[i]["url"]:
|
|
sc["char_refs"].append({"name": display, "url": char_ref_imgs[i]["url"]})
|
|
else:
|
|
# Fallback: 用目录名查找 selected.jpg
|
|
for dir_name in char_dirs:
|
|
if not dir_name:
|
|
continue
|
|
display = char_display_names.get(dir_name, dir_name)
|
|
sel_path = theme_dir / "characters" / dir_name / "selected.jpg"
|
|
if not sel_path.exists():
|
|
sel_path = theme_dir / "characters" / dir_name / "selected.png"
|
|
if sel_path.exists():
|
|
sc["char_refs"].append({
|
|
"name": display,
|
|
"url": to_url(str(sel_path.resolve())),
|
|
})
|
|
else:
|
|
sc["character_names"] = []
|
|
|
|
return {"scenes": scenes}
|
|
|
|
|
|
def build_character_design_data(theme_dir: Path):
|
|
"""构建角色设计数据 JSON,扫描 theme 下所有角色目录。"""
|
|
import re
|
|
|
|
H1_RE = re.compile(r"^#\s+(.+)$")
|
|
H2_RE = re.compile(r"^##\s+(.+)$")
|
|
LIST_RE = re.compile(r"^-\s+(.+)$")
|
|
|
|
def to_url(abs_path):
|
|
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}"
|
|
|
|
characters = []
|
|
characters_dir = theme_dir / "characters"
|
|
if not characters_dir.exists():
|
|
return {"characters": characters}
|
|
for d in sorted(characters_dir.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
design_file = d / "design.md"
|
|
if not design_file.exists():
|
|
continue
|
|
|
|
text = design_file.read_text(encoding="utf-8")
|
|
lines = text.split("\n")
|
|
|
|
char = {
|
|
"dir_name": d.name,
|
|
"title": d.name,
|
|
"appearance": [],
|
|
"personality": "",
|
|
"items": [],
|
|
"prompt_settings": {"prompt": "", "model": "", "reference": ""},
|
|
"candidates": [],
|
|
"selected": None,
|
|
}
|
|
|
|
current_section = None
|
|
section_lines = {}
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped == "---":
|
|
continue
|
|
h1 = H1_RE.match(stripped)
|
|
if h1:
|
|
char["title"] = h1.group(1)
|
|
continue
|
|
h2 = H2_RE.match(stripped)
|
|
if h2:
|
|
current_section = h2.group(1)
|
|
section_lines[current_section] = []
|
|
continue
|
|
if current_section is not None:
|
|
section_lines[current_section].append(stripped)
|
|
|
|
# 外貌特征
|
|
for line in get_section(section_lines, "外貌特征"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
char["appearance"].append(m.group(1))
|
|
|
|
# 性格气质
|
|
personality_lines = [line for line in get_section(section_lines, "性格气质") if line]
|
|
char["personality"] = "\n".join(personality_lines)
|
|
|
|
# 标志物件
|
|
for line in get_section(section_lines, "标志物件"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
char["items"].append(m.group(1))
|
|
|
|
# 生图设定
|
|
for line in get_section(section_lines, "生图设定"):
|
|
if line.startswith(("提示词:", "提示词:", "Prompt:", "Prompt:")):
|
|
char["prompt_settings"]["prompt"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif line.startswith(("模型:", "模型:", "Model:", "Model:")):
|
|
char["prompt_settings"]["model"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif line.startswith(("参考图:", "参考图:", "Reference:", "Reference:")):
|
|
char["prompt_settings"]["reference"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif not line.startswith(("提示词", "模型", "参考图", "Prompt", "Model", "Reference")) and line:
|
|
# 多行提示词续行
|
|
char["prompt_settings"]["prompt"] += " " + line.strip()
|
|
|
|
# 候选图
|
|
for line in get_section(section_lines, "候选图"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
parts = m.group(1).split("—", 1)
|
|
filename = parts[0].strip().split()[0]
|
|
desc = parts[1].strip() if len(parts) > 1 else ""
|
|
img_path = d / filename
|
|
if img_path.exists():
|
|
char["candidates"].append({
|
|
"filename": filename,
|
|
"label": filename,
|
|
"description": desc,
|
|
"url": to_url(str(img_path.resolve())),
|
|
})
|
|
|
|
# 选定图
|
|
for line in get_section(section_lines, "选定图"):
|
|
if line.strip():
|
|
char["selected"] = line.strip()
|
|
|
|
characters.append(char)
|
|
|
|
return {"characters": characters}
|
|
|
|
|
|
def build_backgrounds_data(theme_dir: Path):
|
|
"""构建背景图数据 JSON,扫描 theme/backgrounds/ 下所有背景目录。"""
|
|
import re
|
|
|
|
H1_RE = re.compile(r"^#\s+(.+)$")
|
|
H2_RE = re.compile(r"^##\s+(.+)$")
|
|
LIST_RE = re.compile(r"^-\s+(.+)$")
|
|
|
|
def to_url(abs_path):
|
|
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}"
|
|
|
|
characters = [] # 复用 characters 结构以匹配模板
|
|
bg_dir = theme_dir / "backgrounds"
|
|
if not bg_dir.exists():
|
|
return {"characters": characters}
|
|
|
|
for d in sorted(bg_dir.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
design_file = d / "design.md"
|
|
if not design_file.exists():
|
|
continue
|
|
|
|
text = design_file.read_text(encoding="utf-8")
|
|
lines = text.split("\n")
|
|
|
|
char = {
|
|
"dir_name": d.name,
|
|
"title": d.name,
|
|
"appearance": [], # 映射 场景描述
|
|
"personality": "", # 映射 出场幕列表
|
|
"items": [],
|
|
"prompt_settings": {"prompt": "", "model": "", "reference": ""},
|
|
"candidates": [],
|
|
"selected": None,
|
|
}
|
|
|
|
current_section = None
|
|
section_lines = {}
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped == "---":
|
|
continue
|
|
h1 = H1_RE.match(stripped)
|
|
if h1:
|
|
char["title"] = h1.group(1)
|
|
continue
|
|
h2 = H2_RE.match(stripped)
|
|
if h2:
|
|
current_section = h2.group(1)
|
|
section_lines[current_section] = []
|
|
continue
|
|
if current_section is not None:
|
|
section_lines[current_section].append(stripped)
|
|
|
|
# 场景描述 → appearance
|
|
for line in get_section(section_lines, "场景描述"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
char["appearance"].append(m.group(1))
|
|
|
|
# 出场幕列表 → personality
|
|
scene_lines = [line for line in get_section(section_lines, "出场幕列表") if line]
|
|
char["personality"] = "\n".join(scene_lines)
|
|
|
|
# 生图设定
|
|
for line in get_section(section_lines, "生图设定"):
|
|
if line.startswith(("提示词:", "提示词:", "Prompt:", "Prompt:")):
|
|
char["prompt_settings"]["prompt"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif line.startswith(("模型:", "模型:", "Model:", "Model:")):
|
|
char["prompt_settings"]["model"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif line.startswith(("参考图:", "参考图:", "Reference:", "Reference:")):
|
|
char["prompt_settings"]["reference"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif not line.startswith(("提示词", "模型", "参考图", "Prompt", "Model", "Reference")) and line:
|
|
char["prompt_settings"]["prompt"] += " " + line.strip()
|
|
|
|
# 候选图
|
|
for line in get_section(section_lines, "候选图"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
parts = m.group(1).split("—", 1)
|
|
filename = parts[0].strip().split()[0]
|
|
desc = parts[1].strip() if len(parts) > 1 else ""
|
|
img_path = d / filename
|
|
if img_path.exists():
|
|
char["candidates"].append({
|
|
"filename": filename,
|
|
"label": filename,
|
|
"description": desc,
|
|
"url": to_url(str(img_path.resolve())),
|
|
})
|
|
|
|
# 选定图
|
|
for line in get_section(section_lines, "选定图"):
|
|
if line.strip():
|
|
char["selected"] = line.strip()
|
|
|
|
characters.append(char)
|
|
|
|
return {"characters": characters}
|
|
|
|
|
|
def build_videos_data(theme_dir: Path):
|
|
"""构建分镜视频预览数据 JSON,复用 storyboard 解析 + 扫描 clips/ 目录。"""
|
|
storyboard_data = build_storyboard_data(theme_dir)
|
|
scenes = storyboard_data.get("scenes", [])
|
|
|
|
def to_url(abs_path):
|
|
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}"
|
|
|
|
clips_dir = theme_dir / "clips"
|
|
|
|
for si, sc in enumerate(scenes):
|
|
scene_num = str(si + 1).zfill(2)
|
|
# 尝试多种命名格式匹配视频文件
|
|
video_path = None
|
|
for pattern in [
|
|
f"scene_{scene_num}.mp4",
|
|
f"scene_{scene_num}*.mp4",
|
|
sc.get("title", "").replace(" ", "_") + ".mp4",
|
|
]:
|
|
if "*" in pattern:
|
|
matches = list(clips_dir.glob(pattern)) if clips_dir.exists() else []
|
|
if matches:
|
|
video_path = matches[0]
|
|
break
|
|
else:
|
|
candidate = clips_dir / pattern
|
|
if candidate.exists():
|
|
video_path = candidate
|
|
break
|
|
|
|
if video_path and video_path.exists():
|
|
sc["video_url"] = to_url(str(video_path.resolve()))
|
|
sc["video_path"] = str(video_path.relative_to(theme_dir))
|
|
sc["status"] = "generated"
|
|
else:
|
|
sc["video_url"] = ""
|
|
sc["video_path"] = ""
|
|
sc["status"] = "missing"
|
|
|
|
return {"scenes": scenes}
|
|
|
|
|
|
def build_props_data(theme_dir: Path):
|
|
"""构建关键物件数据 JSON,扫描 theme/props/ 下所有物件目录。"""
|
|
import re
|
|
|
|
H1_RE = re.compile(r"^#\s+(.+)$")
|
|
H2_RE = re.compile(r"^##\s+(.+)$")
|
|
LIST_RE = re.compile(r"^-\s+(.+)$")
|
|
|
|
def to_url(abs_path):
|
|
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}"
|
|
|
|
props = []
|
|
props_dir = theme_dir / "props"
|
|
if not props_dir.exists():
|
|
return {"props": props}
|
|
|
|
for d in sorted(props_dir.iterdir()):
|
|
if not d.is_dir():
|
|
continue
|
|
design_file = d / "design.md"
|
|
if not design_file.exists():
|
|
continue
|
|
|
|
text = design_file.read_text(encoding="utf-8")
|
|
lines = text.split("\n")
|
|
|
|
prop = {
|
|
"dir_name": d.name,
|
|
"title": d.name,
|
|
"appearance": [],
|
|
"narrative_role": "",
|
|
"prompt_settings": {"prompt": "", "model": "", "reference": ""},
|
|
"candidates": [],
|
|
"selected": None,
|
|
}
|
|
|
|
current_section = None
|
|
section_lines = {}
|
|
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped == "---":
|
|
continue
|
|
h1 = H1_RE.match(stripped)
|
|
if h1:
|
|
prop["title"] = h1.group(1)
|
|
continue
|
|
h2 = H2_RE.match(stripped)
|
|
if h2:
|
|
current_section = h2.group(1)
|
|
section_lines[current_section] = []
|
|
continue
|
|
if current_section is not None:
|
|
section_lines[current_section].append(stripped)
|
|
|
|
# 外观描述 → appearance
|
|
for line in get_section(section_lines, "外观描述"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
prop["appearance"].append(m.group(1))
|
|
|
|
# 叙事作用 → narrative_role
|
|
role_lines = [line for line in get_section(section_lines, "叙事作用") if line]
|
|
prop["narrative_role"] = "\n".join(role_lines)
|
|
|
|
# 生图设定
|
|
for line in get_section(section_lines, "生图设定"):
|
|
if line.startswith(("提示词:", "提示词:", "Prompt:", "Prompt:")):
|
|
prop["prompt_settings"]["prompt"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif line.startswith(("模型:", "模型:", "Model:", "Model:")):
|
|
prop["prompt_settings"]["model"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif line.startswith(("参考图:", "参考图:", "Reference:", "Reference:")):
|
|
prop["prompt_settings"]["reference"] = line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
|
elif not line.startswith(("提示词", "模型", "参考图", "Prompt", "Model", "Reference")) and line:
|
|
prop["prompt_settings"]["prompt"] += " " + line.strip()
|
|
|
|
# 候选图
|
|
for line in get_section(section_lines, "候选图"):
|
|
m = LIST_RE.match(line)
|
|
if m:
|
|
parts = m.group(1).split("—", 1)
|
|
filename = parts[0].strip().split()[0]
|
|
desc = parts[1].strip() if len(parts) > 1 else ""
|
|
img_path = d / filename
|
|
if img_path.exists():
|
|
prop["candidates"].append({
|
|
"filename": filename,
|
|
"label": filename,
|
|
"description": desc,
|
|
"url": to_url(str(img_path.resolve())),
|
|
})
|
|
|
|
# 选定图
|
|
for line in get_section(section_lines, "选定图"):
|
|
if line.strip():
|
|
prop["selected"] = line.strip()
|
|
|
|
props.append(prop)
|
|
|
|
return {"props": props}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="启动 MV 预览服务器")
|
|
parser.add_argument("file", help="数据文件路径 (如 .mv/xxx/lyric.md) 或目录路径")
|
|
parser.add_argument("--type", default="", help="数据类型 (如 lyric/script/art_style)")
|
|
parser.add_argument("--port", type=int, default=0, help="端口号,默认自动分配")
|
|
parser.add_argument("--lang", default="", choices=["zh", "en", ""], help="UI language: zh/en, default auto-detect")
|
|
parsed = parser.parse_args()
|
|
|
|
src = Path(parsed.file).resolve()
|
|
if not src.exists():
|
|
print(f"ERROR: 路径不存在: {src}")
|
|
sys.exit(1)
|
|
|
|
preview_type = parsed.type
|
|
if not preview_type:
|
|
print("ERROR: 必须指定 --type 参数 (如 --type lyric)")
|
|
sys.exit(1)
|
|
|
|
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 == "art_style":
|
|
# art_style: 输入是目录路径
|
|
theme_dir = src if src.is_dir() else src.parent
|
|
lang = parsed.lang or "zh"
|
|
content = json.dumps(build_art_style_data(theme_dir, lang), ensure_ascii=False)
|
|
feedback_dir = theme_dir
|
|
elif preview_type == "character_design":
|
|
# character_design: 输入是目录路径(theme 目录)
|
|
theme_dir = src if src.is_dir() else src.parent
|
|
content = json.dumps(build_character_design_data(theme_dir), ensure_ascii=False)
|
|
feedback_dir = theme_dir
|
|
elif preview_type == "backgrounds":
|
|
# backgrounds: 输入是目录路径(theme 目录)
|
|
theme_dir = src if src.is_dir() else src.parent
|
|
content = json.dumps(build_backgrounds_data(theme_dir), ensure_ascii=False)
|
|
feedback_dir = theme_dir
|
|
elif preview_type == "storyboard":
|
|
# storyboard: 输入是目录路径(theme 目录)
|
|
theme_dir = src if src.is_dir() else src.parent
|
|
content = json.dumps(build_storyboard_data(theme_dir), ensure_ascii=False)
|
|
feedback_dir = theme_dir
|
|
elif preview_type == "props":
|
|
# props: 输入是目录路径(theme 目录)
|
|
theme_dir = src if src.is_dir() else src.parent
|
|
content = json.dumps(build_props_data(theme_dir), ensure_ascii=False)
|
|
feedback_dir = theme_dir
|
|
elif preview_type == "videos":
|
|
# videos: 输入是目录路径(theme 目录)
|
|
theme_dir = src if src.is_dir() else src.parent
|
|
content = json.dumps(build_videos_data(theme_dir), ensure_ascii=False)
|
|
feedback_dir = theme_dir
|
|
else:
|
|
# lyric / script: 输入是文件路径
|
|
content = src.read_text(encoding="utf-8")
|
|
feedback_dir = src.parent
|
|
theme_dir = None
|
|
|
|
injected = template.replace(
|
|
"const PREVIEW_LANG = null; /* __PREVIEW_LANG__ */",
|
|
f"const PREVIEW_LANG = {json.dumps(parsed.lang or None)};",
|
|
).replace(
|
|
"const PREVIEW_TYPE = null; /* __PREVIEW_TYPE__ */",
|
|
f"const PREVIEW_TYPE = {json.dumps(preview_type or None)};",
|
|
).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 = feedback_dir / "feedback.md"
|
|
PreviewHandler.theme_dir = theme_dir
|
|
PreviewHandler.server_ref = server
|
|
|
|
url = f"http://127.0.0.1:{port}"
|
|
print(f"PREVIEW_URL={url}")
|
|
print(f"等待用户提交...")
|
|
|
|
webbrowser.open(url)
|
|
server.serve_forever()
|
|
print("服务器已停止")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|