395 lines
13 KiB
Python
395 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
CHAR-HERO-DESIGN-PACKER
|
||
生成/整理苏白主角资产包,让他不再像路人甲。
|
||
|
||
功能:
|
||
1. 读取候选参考图
|
||
2. 用 Seedance/Kling 生成多视角变体
|
||
3. 输出完整资产包到 approved/ 目录
|
||
4. 更新 manifest.hdlp
|
||
|
||
用法:
|
||
python char-hero-design-packer.py --character CHAR-003-SuBai --generate-all
|
||
python char-hero-design-packer.py --character CHAR-003-SuBai --view front_half_body
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
import shutil
|
||
from pathlib import Path
|
||
|
||
# 添加项目根目录到路径
|
||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||
sys.path.insert(0, str(PROJECT_ROOT / "engines"))
|
||
|
||
try:
|
||
from image_api_adapter import generate_image
|
||
except Exception as exc:
|
||
print(f"⚠️ 图片生成适配器未接通: {exc}")
|
||
print("💡 桥接需要 Node.js: image-api-bridge.js + image-api-adapter.js")
|
||
generate_image = None
|
||
|
||
try:
|
||
from hldp_prompt import expand_prompt
|
||
except Exception:
|
||
expand_prompt = None
|
||
|
||
|
||
class CharHeroDesignPacker:
|
||
"""主角资产包生成器"""
|
||
|
||
def __init__(self, character_id, assets_root=None):
|
||
self.character_id = character_id
|
||
self.assets_root = Path(assets_root or PROJECT_ROOT / "assets" / "characters" / character_id)
|
||
self.manifest_path = self.assets_root / "manifest.hdlp"
|
||
self.approved_dir = self.assets_root / "approved"
|
||
self.candidates_dir = self.assets_root / "candidates"
|
||
self.rejected_dir = self.assets_root / "rejected"
|
||
self.turnarounds_dir = self.assets_root / "turnarounds"
|
||
self.voice_dir = self.assets_root / "voice"
|
||
|
||
# 确保目录存在
|
||
for d in [self.approved_dir, self.candidates_dir, self.rejected_dir,
|
||
self.turnarounds_dir, self.voice_dir]:
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 读取 manifest
|
||
self.manifest = self._read_manifest()
|
||
|
||
# 读取角色描述
|
||
self.character_desc = self._load_character_description()
|
||
|
||
def _read_manifest(self):
|
||
"""读取 manifest.hdlp"""
|
||
if not self.manifest_path.exists():
|
||
return {"asset_id": self.character_id, "approval_status": "DRAFT"}
|
||
# 简单解析 HLDP 文件
|
||
manifest = {}
|
||
with open(self.manifest_path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line.startswith("asset_id:") or line.startswith("canonical_id:"):
|
||
manifest["asset_id"] = line.split(":", 1)[1].strip()
|
||
elif line.startswith("approval_status:"):
|
||
manifest["approval_status"] = line.split(":", 1)[1].strip()
|
||
elif line.startswith("candidate_front_half_body:"):
|
||
manifest["candidate_front_half_body"] = line.split(":", 1)[1].strip()
|
||
return manifest
|
||
|
||
def _load_character_description(self):
|
||
"""从 data/characters-v2.hdlp 加载角色描述"""
|
||
char_file = PROJECT_ROOT / "data" / "characters-v2.hdlp"
|
||
if not char_file.exists():
|
||
return None
|
||
|
||
# 简单解析
|
||
description = {}
|
||
with open(char_file, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
# 找 CHAR-003 段落
|
||
if "CHAR-003" in content:
|
||
lines = content.split("\n")
|
||
in_char = False
|
||
for line in lines:
|
||
if "CHAR-003" in line:
|
||
in_char = True
|
||
elif in_char:
|
||
if line.strip().startswith("---"):
|
||
break
|
||
if ":" in line:
|
||
key, _, val = line.partition(":")
|
||
description[key.strip()] = val.strip()
|
||
return description
|
||
|
||
def generate_front_half_body(self, output_name="front_half_body.png"):
|
||
"""
|
||
生成正面半身批准图
|
||
使用候选图作为参考,用 Seedance/Kling 图生图生成稳定版本
|
||
"""
|
||
print(f"[1/4] 生成正面半身图: {output_name}")
|
||
|
||
candidate = self.manifest.get("candidate_front_half_body")
|
||
if not candidate or not Path(candidate).exists():
|
||
print(f" ❌ 候选图不存在: {candidate}")
|
||
print(f" 💡 请先准备候选图,放入 candidates/ 目录")
|
||
return None
|
||
|
||
# 构建提示词
|
||
prompt = self._build_prompt("front_half_body")
|
||
|
||
# 调用图像生成 API (图生图)
|
||
print(f" 参考图: {candidate}")
|
||
print(f" 提示词: {prompt[:100]}...")
|
||
|
||
if generate_image is None:
|
||
print(" ⚠️ 当前仓库只有 JS 图片适配器 image-api-adapter.js,未提供 Python generate_image。")
|
||
print(" ⚠️ 暂不能生成新主角资产;仅将候选图复制到 candidates/ 供人工/后续工具处理。")
|
||
fallback_path = self.candidates_dir / output_name
|
||
shutil.copy2(candidate, fallback_path)
|
||
print(f" 🟡 已复制候选图: {fallback_path}")
|
||
return None
|
||
|
||
try:
|
||
result_path = generate_image(
|
||
prompt=prompt,
|
||
reference_image=candidate,
|
||
output_dir=str(self.approved_dir),
|
||
output_name=output_name
|
||
)
|
||
print(f" ✅ 已生成: {result_path}")
|
||
return result_path
|
||
except Exception as e:
|
||
print(f" ❌ 生成失败: {e}")
|
||
return None
|
||
|
||
def generate_side_face(self, output_name="side_face.png"):
|
||
"""生成侧脸批准图"""
|
||
print(f"[2/4] 生成侧脸图: {output_name}")
|
||
prompt = self._build_prompt("side_face")
|
||
candidate = self.approved_dir / "front_half_body.png"
|
||
|
||
if not candidate.exists():
|
||
print(f" ❌ 请先生成正面半身图")
|
||
return None
|
||
|
||
try:
|
||
result_path = generate_image(
|
||
prompt=prompt,
|
||
reference_image=str(candidate),
|
||
output_dir=str(self.approved_dir),
|
||
output_name=output_name
|
||
)
|
||
print(f" ✅ 已生成: {result_path}")
|
||
return result_path
|
||
except Exception as e:
|
||
print(f" ❌ 生成失败: {e}")
|
||
return None
|
||
|
||
def generate_full_body_costume(self, output_name="full_body_costume.png"):
|
||
"""生成全身服装图"""
|
||
print(f"[3/4] 生成全身服装图: {output_name}")
|
||
prompt = self._build_prompt("full_body_costume")
|
||
candidate = self.approved_dir / "front_half_body.png"
|
||
|
||
if not candidate.exists():
|
||
print(f" ❌ 请先生成正面半身图")
|
||
return None
|
||
|
||
try:
|
||
result_path = generate_image(
|
||
prompt=prompt,
|
||
reference_image=str(candidate),
|
||
output_dir=str(self.approved_dir),
|
||
output_name=output_name
|
||
)
|
||
print(f" ✅ 已生成: {result_path}")
|
||
return result_path
|
||
except Exception as e:
|
||
print(f" ❌ 生成失败: {e}")
|
||
return None
|
||
|
||
def generate_expression_sheet(self, output_name="expression_sheet.png"):
|
||
"""生成表情表"""
|
||
print(f"[4/4] 生成表情表: {output_name}")
|
||
prompt = self._build_prompt("expression_sheet")
|
||
candidate = self.approved_dir / "front_half_body.png"
|
||
|
||
if not candidate.exists():
|
||
print(f" ❌ 请先生成正面半身图")
|
||
return None
|
||
|
||
try:
|
||
result_path = generate_image(
|
||
prompt=prompt,
|
||
reference_image=str(candidate),
|
||
output_dir=str(self.approved_dir),
|
||
output_name=output_name
|
||
)
|
||
print(f" ✅ 已生成: {result_path}")
|
||
return result_path
|
||
except Exception as e:
|
||
print(f" ❌ 生成失败: {e}")
|
||
return None
|
||
|
||
def _build_prompt(self, view_type):
|
||
"""构建特定视角的提示词"""
|
||
base_desc = self.character_desc or {}
|
||
|
||
prompts = {
|
||
"front_half_body": f"""
|
||
{base_desc.get('visual_description', '中国古代修仙少年,16岁,主角辨识度强')}
|
||
正面半身像,胸部以上,面部清晰,眼神坚定,
|
||
3D国风漫剧风格,统一渲染风格,
|
||
高清,8K,最佳质量
|
||
""".strip(),
|
||
|
||
"side_face": f"""
|
||
{base_desc.get('visual_description', '中国古代修仙少年')}
|
||
侧脸45度,能看到面部轮廓和发型,
|
||
3D国风漫剧风格,统一渲染风格,
|
||
高清,8K,最佳质量
|
||
""".strip(),
|
||
|
||
"full_body_costume": f"""
|
||
{base_desc.get('visual_description', '中国古代修仙少年')}
|
||
全身像,站立姿势,完整展示服装细节,
|
||
服装细节必须来自角色资产清单,不得自由改色,
|
||
3D国风漫剧风格,统一渲染风格,
|
||
高清,8K,最佳质量
|
||
""".strip(),
|
||
|
||
"expression_sheet": f"""
|
||
{base_desc.get('visual_description', '中国古代修仙少年')}
|
||
表情表,网格布局,包含:
|
||
平静,微笑,大笑,生气,惊讶,悲伤,
|
||
每个表情单独一格,统一光照和背景,
|
||
3D国风漫剧风格,
|
||
高清,8K,最佳质量
|
||
""".strip(),
|
||
}
|
||
|
||
return prompts.get(view_type, prompts["front_half_body"])
|
||
|
||
def generate_all(self):
|
||
"""生成所有资产"""
|
||
print(f"\n🎬 开始生成 {self.character_id} 主角资产包")
|
||
print(f"=" * 60)
|
||
|
||
results = {}
|
||
|
||
# 1. 正面半身
|
||
path = self.generate_front_half_body()
|
||
if path:
|
||
results["front_half_body"] = path
|
||
|
||
# 2. 侧脸
|
||
path = self.generate_side_face()
|
||
if path:
|
||
results["side_face"] = path
|
||
|
||
# 3. 全身服装
|
||
path = self.generate_full_body_costume()
|
||
if path:
|
||
results["full_body_costume"] = path
|
||
|
||
# 4. 表情表
|
||
path = self.generate_expression_sheet()
|
||
if path:
|
||
results["expression_sheet"] = path
|
||
|
||
# 更新 manifest。只有四项资产都真实生成时才可进入 APPROVED。
|
||
self._update_manifest(results)
|
||
|
||
print(f"\n✅ 资产包生成完成!")
|
||
print(f" 已生成: {len(results)}/4 个资产")
|
||
print(f" 位置: {self.approved_dir}")
|
||
|
||
return results
|
||
|
||
def _update_manifest(self, results):
|
||
"""更新 manifest.hdlp"""
|
||
print(f"\n📝 更新 manifest.hdlp...")
|
||
|
||
required_assets = {"front_half_body", "side_face", "full_body_costume", "expression_sheet"}
|
||
is_complete = required_assets.issubset(results.keys())
|
||
approval_status = "APPROVED" if is_complete else "DRAFT_GENERATED"
|
||
type_label = "已批准" if is_complete else "待质检"
|
||
lock_note = (
|
||
"⊢ 资产已批准,可用于成片镜头。"
|
||
if is_complete
|
||
else "⊢ 资产包尚未完整生成/质检,禁止作为成片锁定资产。"
|
||
)
|
||
|
||
manifest_content = f"""# 资产清单 · {self.character_id}
|
||
|
||
> HLDP://video-ai-system/assets/characters/{self.character_id}/manifest
|
||
> 类型: 角色资产 · {type_label}
|
||
> 建立: D143 · 2026-06-23
|
||
> 更新: D144 · 2026-06-24
|
||
> 项目: 付费才能修仙 · EP01
|
||
|
||
---
|
||
|
||
## 状态
|
||
|
||
```
|
||
approval_status: {approval_status}
|
||
asset_type: character
|
||
canonical_id: {self.character_id}
|
||
canonical_name: 苏白
|
||
```
|
||
|
||
---
|
||
|
||
## 批准资产
|
||
|
||
```
|
||
front_half_body: {results.get("front_half_body", "NOT_GENERATED")}
|
||
side_face: {results.get("side_face", "NOT_GENERATED")}
|
||
full_body_costume: {results.get("full_body_costume", "NOT_GENERATED")}
|
||
expression_sheet: {results.get("expression_sheet", "NOT_GENERATED")}
|
||
```
|
||
|
||
---
|
||
|
||
## 视觉锁
|
||
|
||
```
|
||
face_shape: TODO_FROM_APPROVED_ASSET
|
||
hair_style: TODO_FROM_APPROVED_ASSET
|
||
costume: TODO_FROM_APPROVED_ASSET
|
||
age_band: 16岁
|
||
render_style: 3D国风漫剧
|
||
color_palette: TODO_FROM_APPROVED_ASSET
|
||
```
|
||
|
||
---
|
||
|
||
## 锁定
|
||
|
||
{lock_note}
|
||
⊢ 禁止使用 candidates/ 或 rejected/ 中的图片作为最终资产。
|
||
"""
|
||
|
||
with open(self.manifest_path, "w", encoding="utf-8") as f:
|
||
f.write(manifest_content)
|
||
|
||
print(f" ✅ 已更新: {self.manifest_path}")
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="CHAR-HERO-DESIGN-PACKER")
|
||
parser.add_argument("--character", type=str, default="CHAR-003-SuBai",
|
||
help="角色ID (默认: CHAR-003-SuBai)")
|
||
parser.add_argument("--generate-all", action="store_true",
|
||
help="生成所有资产")
|
||
parser.add_argument("--view", type=str,
|
||
choices=["front_half_body", "side_face", "full_body_costume", "expression_sheet"],
|
||
help="生成特定视角的资产")
|
||
|
||
args = parser.parse_args()
|
||
|
||
packer = CharHeroDesignPacker(args.character)
|
||
|
||
if args.generate_all:
|
||
packer.generate_all()
|
||
elif args.view:
|
||
method_map = {
|
||
"front_half_body": packer.generate_front_half_body,
|
||
"side_face": packer.generate_side_face,
|
||
"full_body_costume": packer.generate_full_body_costume,
|
||
"expression_sheet": packer.generate_expression_sheet,
|
||
}
|
||
method_map[args.view]()
|
||
else:
|
||
print("请指定 --generate-all 或 --view <view_type>")
|
||
parser.print_help()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|