221 lines
8.5 KiB
Python
221 lines
8.5 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
storyboard_to_workflow.py — 分镜JSON → ComfyUI批量出图桥接
|
|||
|
|
读取分镜JSON,为每个镜头生成ComfyUI API调用脚本
|
|||
|
|
用法:
|
|||
|
|
python tools/storyboard_to_workflow.py <分镜JSON> [-o 输出目录] [--workflow 基础工作流]
|
|||
|
|
python tools/storyboard_to_workflow.py <分镜JSON> --generate-sh # 生成批量调用shell脚本
|
|||
|
|
"""
|
|||
|
|
import sys, os, json, argparse, shutil
|
|||
|
|
|
|||
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||
|
|
|
|||
|
|
# 默认的工作流模板路径(ComfyUI蓝图)
|
|||
|
|
DEFAULT_WORKFLOW = os.path.expanduser("~/comfy/ComfyUI/blueprints/Text to Image.json")
|
|||
|
|
|
|||
|
|
def load_storyboard(json_path):
|
|||
|
|
"""读取分镜JSON"""
|
|||
|
|
with open(json_path, 'r', encoding='utf-8') as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
|
|||
|
|
def shot_to_prompt(shot, project_style=""):
|
|||
|
|
"""将单镜转换为图像生成prompt"""
|
|||
|
|
desc = shot.get('description', '')
|
|||
|
|
camera = shot.get('camera', '中景')
|
|||
|
|
characters = shot.get('characters', [])
|
|||
|
|
scenes = shot.get('scenes', [])
|
|||
|
|
props = shot.get('props', [])
|
|||
|
|
|
|||
|
|
# 构建英文prompt
|
|||
|
|
parts = []
|
|||
|
|
if camera:
|
|||
|
|
parts.append(camera)
|
|||
|
|
if scenes:
|
|||
|
|
parts.append(f"in {scenes[0]}")
|
|||
|
|
parts.append(desc[:80])
|
|||
|
|
if project_style:
|
|||
|
|
parts.append(project_style)
|
|||
|
|
|
|||
|
|
prompt = ", ".join(parts)
|
|||
|
|
return prompt
|
|||
|
|
|
|||
|
|
def generate_comfyui_api_json(shot, base_workflow_path, prompt, output_dir, shot_num):
|
|||
|
|
"""为单镜生成ComfyUI API调用JSON"""
|
|||
|
|
# 读取基础工作流
|
|||
|
|
try:
|
|||
|
|
with open(base_workflow_path, 'r') as f:
|
|||
|
|
workflow = json.load(f)
|
|||
|
|
except:
|
|||
|
|
# 如果文件不存在,创建一个最小工作流
|
|||
|
|
workflow = {
|
|||
|
|
"3": {
|
|||
|
|
"class_type": "KSampler",
|
|||
|
|
"inputs": {
|
|||
|
|
"seed": shot_num * 100 + 42,
|
|||
|
|
"steps": 20,
|
|||
|
|
"cfg": 7,
|
|||
|
|
"sampler_name": "euler",
|
|||
|
|
"scheduler": "normal",
|
|||
|
|
"denoise": 1,
|
|||
|
|
"model": ["4", 0],
|
|||
|
|
"positive": ["6", 0],
|
|||
|
|
"negative": ["7", 0],
|
|||
|
|
"latent_image": ["5", 0]
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "realvisxlV40_v40BDFPonNoobVae.safetensors"}},
|
|||
|
|
"5": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 768, "batch_size": 1}},
|
|||
|
|
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["4", 1]}},
|
|||
|
|
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "worst quality, low quality, blurry", "clip": ["4", 1]}},
|
|||
|
|
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0], "vae": ["4", 2]}},
|
|||
|
|
"9": {"class_type": "SaveImage", "inputs": {"filename_prefix": f"shot_{shot_num:04d}", "images": ["8", 0]}}
|
|||
|
|
}
|
|||
|
|
return workflow
|
|||
|
|
|
|||
|
|
# 如果用了现有工作流,替换prompt
|
|||
|
|
for node_id, node in workflow.items():
|
|||
|
|
if isinstance(node, dict):
|
|||
|
|
ct = node.get('class_type', '')
|
|||
|
|
if 'CLIPTextEncode' in ct or 'Prompt' in ct:
|
|||
|
|
if 'text' in node.get('inputs', {}):
|
|||
|
|
workflow[node_id]['inputs']['text'] = prompt
|
|||
|
|
if 'KSampler' in ct or 'Sampler' in ct:
|
|||
|
|
if 'seed' in node.get('inputs', {}):
|
|||
|
|
workflow[node_id]['inputs']['seed'] = shot_num * 100 + 42
|
|||
|
|
|
|||
|
|
return workflow
|
|||
|
|
|
|||
|
|
def generate_shell_script(shots, output_dir, comfy_api_url="http://127.0.0.1:8188"):
|
|||
|
|
"""生成批量调用ComfyUI API的shell脚本"""
|
|||
|
|
script_path = os.path.join(output_dir, 'batch_render.sh')
|
|||
|
|
|
|||
|
|
lines = [
|
|||
|
|
'#!/bin/bash',
|
|||
|
|
f'# 批量渲染分镜 - 自动生成',
|
|||
|
|
f'# ComfyUI API: {comfy_api_url}',
|
|||
|
|
f'# 分镜数: {len(shots)}',
|
|||
|
|
f'# 生成时间: {__import__("datetime").datetime.now().isoformat()}',
|
|||
|
|
'',
|
|||
|
|
'set -e',
|
|||
|
|
'',
|
|||
|
|
'TOTAL=' + str(len(shots)),
|
|||
|
|
'SUCCESS=0',
|
|||
|
|
'FAIL=0',
|
|||
|
|
'',
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
for i, shot in enumerate(shots):
|
|||
|
|
sn = shot.get('shot_number', f'S{i+1:02d}')
|
|||
|
|
desc = shot.get('description', '')[:40]
|
|||
|
|
wf_file = os.path.join(output_dir, f'workflow_{i+1:04d}.json')
|
|||
|
|
out_file = os.path.join(output_dir, f'shot_{i+1:04d}.png')
|
|||
|
|
|
|||
|
|
lines.extend([
|
|||
|
|
f'',
|
|||
|
|
f'echo "🎬 [{i+1}/$TOTAL] {sn}: {desc}"',
|
|||
|
|
f'echo " → 发送ComfyUI API..."',
|
|||
|
|
f'RESP=$(curl -s -X POST "{comfy_api_url}/prompt" \\',
|
|||
|
|
f' -H "Content-Type: application/json" \\',
|
|||
|
|
f' -d @{wf_file})',
|
|||
|
|
f'if echo "$RESP" | grep -q "error"; then',
|
|||
|
|
f' echo " ❌ 失败: $RESP"',
|
|||
|
|
f' FAIL=$((FAIL + 1))',
|
|||
|
|
f'else',
|
|||
|
|
f' echo " ✅ 已提交"',
|
|||
|
|
f' SUCCESS=$((SUCCESS + 1))',
|
|||
|
|
f'fi',
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
lines.extend([
|
|||
|
|
'',
|
|||
|
|
'echo "=========================="',
|
|||
|
|
'echo "📊 渲染完成: $SUCCESS 成功, $FAIL 失败 / $TOTAL 总镜"',
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
with open(script_path, 'w') as f:
|
|||
|
|
f.write('\n'.join(lines))
|
|||
|
|
os.chmod(script_path, 0o755)
|
|||
|
|
return script_path
|
|||
|
|
|
|||
|
|
def generate_prompt_file(shots, output_dir, project_style=""):
|
|||
|
|
"""生成每个镜头的prompt文本文件"""
|
|||
|
|
prompt_path = os.path.join(output_dir, 'prompts.txt')
|
|||
|
|
with open(prompt_path, 'w', encoding='utf-8') as f:
|
|||
|
|
for i, shot in enumerate(shots):
|
|||
|
|
sn = shot.get('shot_number', f'S{i+1:02d}')
|
|||
|
|
prompt = shot_to_prompt(shot, project_style)
|
|||
|
|
f.write(f"=== {sn} ===\n")
|
|||
|
|
f.write(f"景别: {shot.get('camera', 'N/A')}\n")
|
|||
|
|
f.write(f"时长: {shot.get('duration', 'N/A')}s\n")
|
|||
|
|
f.write(f"角色: {', '.join(shot.get('characters', []))}\n")
|
|||
|
|
f.write(f"场景: {', '.join(shot.get('scenes', []))}\n")
|
|||
|
|
f.write(f"Prompt: {prompt}\n\n")
|
|||
|
|
return prompt_path
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
parser = argparse.ArgumentParser(description='分镜JSON → ComfyUI批量出图桥接')
|
|||
|
|
parser.add_argument('storyboard', help='分镜JSON文件路径')
|
|||
|
|
parser.add_argument('-o', '--output-dir', help='输出目录 (默认: 分镜JSON同目录下的renders/)')
|
|||
|
|
parser.add_argument('--workflow', default=DEFAULT_WORKFLOW, help=f'基础工作流JSON (默认: {DEFAULT_WORKFLOW})')
|
|||
|
|
parser.add_argument('--style', default='', help='全局风格描述 (如: cinematic, anime)')
|
|||
|
|
parser.add_argument('--generate-sh', action='store_true', help='生成批量调用shell脚本')
|
|||
|
|
parser.add_argument('--comfy-url', default='http://127.0.0.1:8188', help='ComfyUI API地址')
|
|||
|
|
|
|||
|
|
args = parser.parse_args()
|
|||
|
|
|
|||
|
|
# 读取分镜
|
|||
|
|
storyboard = load_storyboard(args.storyboard)
|
|||
|
|
shots = storyboard.get('shots', [])
|
|||
|
|
if not shots:
|
|||
|
|
print(f'❌ 分镜JSON中没有shots')
|
|||
|
|
sys.exit(1)
|
|||
|
|
|
|||
|
|
# 输出目录
|
|||
|
|
if args.output_dir:
|
|||
|
|
out_dir = args.output_dir
|
|||
|
|
else:
|
|||
|
|
sb_dir = os.path.dirname(os.path.abspath(args.storyboard))
|
|||
|
|
out_dir = os.path.join(sb_dir, 'renders')
|
|||
|
|
os.makedirs(out_dir, exist_ok=True)
|
|||
|
|
|
|||
|
|
print(f'📖 分镜: {args.storyboard}')
|
|||
|
|
print(f'🎬 共 {len(shots)} 镜')
|
|||
|
|
print(f'📁 输出: {out_dir}')
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
# 1. 生成prompt文件
|
|||
|
|
prompt_path = generate_prompt_file(shots, out_dir, args.style)
|
|||
|
|
print(f'✅ Prompt文件: {prompt_path}')
|
|||
|
|
|
|||
|
|
# 2. 为每镜生成工作流JSON
|
|||
|
|
wf_dir = os.path.join(out_dir, 'workflows')
|
|||
|
|
os.makedirs(wf_dir, exist_ok=True)
|
|||
|
|
|
|||
|
|
for i, shot in enumerate(shots):
|
|||
|
|
sn = shot.get('shot_number', f'S{i+1:02d}')
|
|||
|
|
prompt = shot_to_prompt(shot, args.style)
|
|||
|
|
workflow = generate_comfyui_api_json(shot, args.workflow, prompt, wf_dir, i+1)
|
|||
|
|
|
|||
|
|
wf_path = os.path.join(wf_dir, f'workflow_{i+1:04d}.json')
|
|||
|
|
with open(wf_path, 'w', encoding='utf-8') as f:
|
|||
|
|
json.dump(workflow, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
print(f'✅ 工作流JSON: {len(shots)}个 → {wf_dir}/')
|
|||
|
|
|
|||
|
|
# 3. 可选:生成批量shell脚本
|
|||
|
|
if args.generate_sh:
|
|||
|
|
sh_path = generate_shell_script(shots, out_dir, args.comfy_url)
|
|||
|
|
print(f'✅ 批量脚本: {sh_path}')
|
|||
|
|
print(f' 运行: bash {sh_path}')
|
|||
|
|
|
|||
|
|
# 4. 总结
|
|||
|
|
print()
|
|||
|
|
print('📊 各镜一览:')
|
|||
|
|
for i, shot in enumerate(shots):
|
|||
|
|
sn = shot.get('shot_number', f'S{i+1:02d}')
|
|||
|
|
prompt = shot_to_prompt(shot, args.style)
|
|||
|
|
print(f' {sn:>4} | {shot.get("camera","?"): <4} | {shot.get("duration","?"):>2}s | {prompt[:50]}...')
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
main()
|