Popiai-skill仓库
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

#!/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()