EED: 经验记忆推送——技能库(11技能+开源角色设定板)+遇事不决先搜索铁律+人物三/四视图+场景四视图工业级方法论+声画装配+多视图工具

- skill/ 技能库:index.json 11 技能 + community_techniques.md 社区技巧 + character_sheet_learn.md 角色版学习 + scene_4view_learn.md 场景四视图工业级方法 + character-sheet-generator 开源技能(7风格模板)
- tools/character_turnaround.py:人物三/四视图(主视觉+三视图/展示台)+场景四视角,Z-Image 本地
- tools/audio_pipeline.py:Edge-TTS配音+字幕+BGM+混音,Agent stage⑥有声成片
- agent_short_drama.py:一键短剧 Agent --until audio 全链路
- eed_web.py:技能库端点(/api/skills,/api/skill)+E2BIG根治(字节截断+巨兽降级)+一键短剧按钮
This commit is contained in:
Zhuyuan Operations 2026-08-01 03:16:48 +08:00
parent 45ffbb6022
commit 80805449ca
21 changed files with 2886 additions and 67 deletions

View File

@ -41,6 +41,54 @@ EED_SYS = (
"涉及花钱/调API等现实操作按 EED-PROTO-005 走 申请→爸爸验证码→固定动作→回执,自己不执行。"
)
# ---- 停止机制:杀掉正在运行的 codebuddy 子进程(治"停止后还在后台等" ----
ACTIVE = {"proc": None, "lock": threading.Lock()}
def _kill_proc(proc):
if proc is None or proc.poll() is not None:
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except Exception:
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
pass
def _register(proc):
with ACTIVE["lock"]:
ACTIVE["proc"] = proc
def _unregister(proc):
with ACTIVE["lock"]:
if ACTIVE["proc"] is proc:
ACTIVE["proc"] = None
def _kill_active():
with ACTIVE["lock"]:
p = ACTIVE["proc"]
ACTIVE["proc"] = None
_kill_proc(p)
PAGE = r"""<!DOCTYPE html>
<html lang="zh">
<head>
@ -132,7 +180,8 @@ PAGE = r"""<!DOCTYPE html>
background:var(--bg);color:var(--txt);padding:13px;font-size:15px;font-family:inherit;outline:none;}
#msg:focus{border-color:var(--me);}
#send,#stop{border:none;border-radius:12px;background:var(--me);color:#fff;padding:0 20px;font-size:15px;cursor:pointer;height:52px;}
#stop{background:#e0594f;display:none;}
#stop{background:#e0594f;opacity:.45;transition:opacity .15s;color:#fff;}
#stop.live{opacity:1;box-shadow:0 0 0 2px rgba(224,89,79,.45);}
#tobottom{position:absolute;right:24px;bottom:84px;background:var(--panel);border:1px solid var(--line);
color:var(--me);border-radius:20px;padding:6px 14px;font-size:12px;cursor:pointer;display:none;z-index:20;box-shadow:0 4px 14px rgba(0,0,0,.25);}
/* markdown */
@ -234,6 +283,8 @@ PAGE = r"""<!DOCTYPE html>
<button id="btnRead">📂 读仓库文件</button>
<button id="btnSearch">🔍 联网搜索</button>
<button id="btnAttach">📎 附文件</button>
<button id="btnDrama" title="一键短剧:剧本/分镜 → 全自动成片">🎬 一键短剧</button>
<button id="btnSkill" title="技能库:编剧/分镜/风格转绘/LTX/Z-Image 即插即用">🧠 技能库</button>
<input id="fileinp" type="file" accept=".txt,.md,.json,.py,.js,.csv,.hdlp,.log,.yaml,.yml,.toml,.text" style="display:none">
<input id="impinp" type="file" accept=".json" style="display:none">
</div>
@ -266,6 +317,35 @@ PAGE = r"""<!DOCTYPE html>
<p style="color:var(--mut);font-size:11px;">对话仅存本机浏览器关掉再开还在</p>
<div class="close"><button id="modalClose">知道了</button></div>
</div></div>
<div id="dramaModal" style="display:none"><div class="card" style="max-width:660px">
<h3>🎬 一键短剧 Agent</h3>
<textarea id="dramaInput" style="width:100%;height:120px;resize:vertical" placeholder="贴入 剧本 或 分镜JSON…"></textarea>
<div style="margin:8px 0">
<label>类型
<select id="dramaType">
<option value="storyboard" selected>分镜JSON零成本出图视频成片</option>
<option value="script">剧本需豆包分镜授权</option>
</select>
</label>
<label> 集号 <input id="dramaEp" type="number" value="1" style="width:56px"></label>
<label> 帧数 <input id="dramaFrames" type="number" value="49" style="width:64px"></label>
</div>
<label style="color:#e0594f"><input id="dramaAuth" type="checkbox"> 授权豆包拆分镜 ¥0.01/EED-PROTO-005</label>
<div style="margin:8px 0">
<button id="dramaGo">开始生成</button>
<button onclick="$('#dramaModal').style.display='none'">关闭</button>
</div>
<pre id="dramaLog" style="max-height:260px;overflow:auto;background:#111;color:#8f8;padding:8px;border-radius:8px;font-size:12px;white-space:pre-wrap"></pre>
<div id="dramaResult"></div>
</div></div>
<div id="skillModal" style="display:none"><div class="card" style="max-width:560px">
<h3>🧠 苍耳技能库</h3>
<p style="color:var(--mut);font-size:12px">提示词型=注入方法论到输入框可编辑后发送工具型=本地执行生图/出片/拼接</p>
<div id="skillList" style="max-height:60vh;overflow-y:auto"></div>
<div style="margin-top:10px"><button onclick="$('#skillModal').style.display='none'">关闭</button></div>
</div></div>
<div id="balModal"><div class="card">
<h2>💰 积分余额</h2>
<p>打开 <a href="https://www.codebuddy.cn/profile/plans-usage" target="_blank" rel="noopener">codebuddy.cn/profile/plans-usage</a> 登录后"套餐总额""已用"填进来蛋蛋帮你算剩余</p>
@ -297,7 +377,8 @@ const KEY='eed_conversations';
let convs=JSON.parse(localStorage.getItem(KEY)||'[]');
function save(){localStorage.setItem(KEY,JSON.stringify(convs));}
function newConv(){
cur={id:'c'+Date.now(),title:'新对话',messages:[],model:modelSel.value,pinned:false};
cur={id:'c'+Date.now(),title:'新对话',messages:[],model:modelSel.value,pinned:false,
sid:'eed_'+Date.now().toString(36)+Math.random().toString(36).slice(2,8),started:false};
convs.unshift(cur);save();renderSide();renderLog();showHint();
}
function sortedConvs(){return [...convs].sort((a,b)=>(b.pinned?1:0)-(a.pinned?1:0));}
@ -343,6 +424,8 @@ function renderMD(src){
s=s.replace(/^&gt;\s?(.*)$/gm,'<blockquote>$1</blockquote>');
s=s.replace(/^###\s+(.*)$/gm,'<h3>$1</h3>').replace(/^##\s+(.*)$/gm,'<h2>$1</h2>').replace(/^#\s+(.*)$/gm,'<h1>$1</h1>');
s=s.replace(/\*\*([^*]+)\*\*/g,'<b>$1</b>').replace(/\*([^*]+)\*/g,'<i>$1</i>').replace(/`([^`]+)`/g,'<code class="ic">$1</code>');
s=s.replace(/!\[([^\]]*)\]\((\/media\/[^)]+\.(?:png|jpe?g|gif|webp))\)/g,'<img src="$2" alt="$1" style="max-width:100%;border-radius:10px;margin:.4em 0">');
s=s.replace(/\[([^\]]+)\]\((\/media\/[^)]+\.(?:mp4|webm|mov))\)/g,'<video src="$2" controls style="max-width:100%;border-radius:10px;margin:.4em 0"></video>');
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,'<a href="$2" target="_blank" rel="noopener">$1</a>');
s=s.replace(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/gm,(m,chk,t)=>'<li class="task">'+(chk.toLowerCase()==='x'?'':'')+' '+t+'</li>');
s=s.replace(/^\s*[-*]\s+(.*)$/gm,'<li>$1</li>');
@ -403,6 +486,7 @@ function delMsg(idx){
if(busy)return;
cur.messages.splice(idx,1);
if(!cur.messages.length)cur.title='新对话';
resetSid();
save();renderLog();renderSide();
}
function editMsg(idx){
@ -422,11 +506,14 @@ function editMsg(idx){
cx.onclick=()=>renderLog();
sv.onclick=()=>{
const v=ta.value.trim();if(!v){renderLog();return;}
m.text=v;cur.messages=cur.messages.slice(0,idx+1);save();renderLog();runStream(v);
m.text=v;cur.messages=cur.messages.slice(0,idx+1);
const keptHist=cur.messages.slice(0,cur.messages.length-1).map(x=>({role:x.role,text:x.text}));
resetSid();save();renderLog();runStream(v,keptHist);
};
}
/* ---------- 发送 / 流式 ---------- */
function resetSid(){cur.sid='eed_'+Date.now().toString(36)+Math.random().toString(36).slice(2,8);cur.started=false;}
function buildHistory(){
const msgs=cur.messages;
const lastUserIdx=[...msgs].reverse().findIndex(m=>m.role==='me');
@ -434,9 +521,9 @@ function buildHistory(){
const idx=msgs.length-1-lastUserIdx;
return msgs.slice(0,idx).map(m=>({role:m.role,text:m.text}));
}
async function runStream(text){
async function runStream(text, extraHistory){
if(busy)return;
busy=true;$('#stop').style.display='';
busy=true;$('#stop').classList.add('live');
const wrap=addBubble('egg','',true);
const bub=wrap.bub;
const bw=wrap.row.querySelector('.bubwrap');
@ -471,8 +558,9 @@ async function runStream(text){
typing.textContent='💭 思考中…';
let acc='';abortCtl=new AbortController();let thinkingParts=[];let thinkEl=null;
try{
const hist=extraHistory!==undefined?extraHistory:buildHistory();
const res=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:text,model:cur.model,history:buildHistory()}),signal:abortCtl.signal});
body:JSON.stringify({message:text,model:cur.model,history:hist,session_id:cur.sid,started:!!cur.started}),signal:abortCtl.signal});
if(!res.ok)throw new Error('服务返回 '+res.status);
const reader=res.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
@ -501,7 +589,9 @@ async function runStream(text){
const nm=ev.id&&toolNames[ev.id]?toolNames[ev.id]:'';
addItem('📥','返回'+(nm?' · '+nm:''), ev.id?'id:'+ev.id:'', ev.content||'', true);}
else if(t==='delta'){acc+=ev.text;bub.innerHTML=renderMD(acc);log.scrollTop=log.scrollHeight;}
else if(t==='done'){acc=ev.text||acc;typing.textContent='';}
else if(t==='done'){acc=ev.text||acc;typing.textContent='';
if(ev.session_id){cur.sid=ev.session_id;cur.started=true;save();}
if(ev.compacted)toast('📦 上下文过长已自动压缩,记忆已衔接(新场次)');}
else if(t==='usage'){
const u=ev.usage||{};const cost=ev.cost;
totalIn+=(u.input_tokens||0);totalOut+=(u.output_tokens||0);
@ -513,7 +603,7 @@ async function runStream(text){
else if(t==='error'){acc='(出错了:'+ev.text+'';typing.textContent='';}
}
}
}catch(e){ if(e.name!=='AbortError'){acc='(连接中断:'+e+'';} }
}catch(e){ if(e.name!=='AbortError'){acc='(连接中断:'+e+'';} else {acc='(已停止)';} }
if(!acc)acc='(没有回复)';
bub.innerHTML=renderMD(acc);
/* 思考过程固化折叠在气泡上方 */
@ -525,7 +615,7 @@ async function runStream(text){
}
if(tcount===0)trace.style.display='none';
cur.messages.push({role:'egg',text:acc,ts:Date.now()});save();renderSide();
typing.textContent='';busy=false;$('#stop').style.display='none';abortCtl=null;
typing.textContent='';busy=false;$('#stop').classList.remove('live');abortCtl=null;
pump();
}
function addMe(text){
@ -561,7 +651,9 @@ function regen(){
if(ui<0)return;
while(msgs.length&&msgs[msgs.length-1].role!=='egg')msgs.pop();
if(msgs[msgs.length-1].role==='egg')msgs.pop();
const text=msgs[ui].text;save();renderLog();runStream(text);
const text=msgs[ui].text;
const keptHist=msgs.slice(0,ui).map(x=>({role:x.role,text:x.text}));
resetSid();save();renderLog();runStream(text,keptHist);
}
/* ---------- 导出 / 导入 ---------- */
@ -611,11 +703,11 @@ function toggleMenu(){
/* ---------- 按钮 / 事件 ---------- */
$('#send').onclick=doSend;
$('#stop').onclick=()=>{if(abortCtl)abortCtl.abort();};
$('#stop').onclick=()=>{if(!busy){toast('当前没有在跑的任务 🥚');return;}if(abortCtl)abortCtl.abort();fetch('/api/stop',{method:'POST'}).catch(()=>{});toast('已发送停止指令,正在终止…');};
$('#newchat').onclick=newConv;
$('#collapse').onclick=()=>{$('#side').classList.toggle('collapsed');};
$('#theme').onclick=()=>{document.body.classList.toggle('light');localStorage.setItem('eed_theme',document.body.classList.contains('light')?'light':'dark');};
$('#clear').onclick=()=>{if(!cur)return;if(!confirm('清空当前对话上下文?'))return;cur.messages=[];save();renderLog();showHint();};
$('#clear').onclick=()=>{if(!cur)return;if(!confirm('清空当前对话上下文?'))return;cur.messages=[];resetSid();save();renderLog();showHint();};
$('#export').onclick=event=>{event.stopPropagation();toggleMenu();};
$('#import').onclick=doImport;
$('#about').onclick=()=>{$('#mModel').textContent=modelSel.value; $('#modal').style.display='flex';};
@ -649,6 +741,65 @@ $('#btnRead').onclick=()=>{const p=prompt('要读 cang-ying 下哪个文件?\n
$('#btnSearch').onclick=()=>{const q=prompt('想联网搜什么?');if(q)userSend('【联网搜索】请使用 WebSearch 工具搜索以下问题并汇总要点:'+q.trim());};
$('#btnTrace').onclick=()=>document.body.classList.toggle('hidetrace');
$('#btnAttach').onclick=()=>$('#fileinp').click();
$('#btnDrama').onclick=()=>{$('#dramaLog').textContent='';$('#dramaResult').innerHTML='';$('#dramaModal').style.display='flex';};
$('#btnSkill').onclick=async()=>{
try{
const r=await fetch('/api/skills',{method:'POST'});
const d=await r.json();
const list=d.skills||[];
if(!list.length){toast('技能库为空');return;}
$('#skillList').innerHTML=list.map(s=>
`<div class="skill" data-id="${s.id}" data-type="${s.type}" style="padding:10px;margin:6px 0;border:1px solid #333;border-radius:8px;cursor:pointer;background:#1a1a1f">
<div style="font-weight:700">${s.name} <span style="color:var(--mut);font-size:11px;font-weight:400">${s.type==='prompt'?'💉 提示词型':'⚙️ 工具型'}</span></div>
<div style="font-size:12px;color:var(--mut);margin-top:3px">${s.desc||''}</div>
</div>`).join('');
$('#skillModal').style.display='flex';
document.querySelectorAll('#skillList .skill').forEach(el=>{
el.onclick=async()=>{
const id=el.dataset.id, type=el.dataset.type;
try{
const r2=await fetch('/api/skill',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'load',skill:id})});
const s=await r2.json();
if(type==='prompt'&&s.prompt){
$('#msg').value='【技能注入:'+s.name+'\n'+s.prompt+'\n\n---\n'+ (s.usage||'请用以上方法论') +' 请开始:';
$('#msg').focus();toast('已注入提示词,可编辑后发送');
}else if(type==='tool'){
$('#msg').value='【工具技能:'+s.name+'\n执行命令:'+s.tool+' <参数>\n\n请告诉我参数(如提示词/图片路径),我就跑。';
$('#msg').focus();toast('工具技能就位,说明参数即可');
}else{toast('技能无提示词内容');}
}catch(e){toast('技能加载失败:'+e);}
$('#skillModal').style.display='none';
};
});
}catch(e){toast('技能库加载失败:'+e);}
};
$('#dramaGo').onclick=async()=>{
const input=$('#dramaInput').value.trim();
if(!input){toast('先贴入剧本或分镜JSON 🥚');return;}
const type=$('#dramaType').value;
if(type==='script'&&!$('#dramaAuth').checked){toast('剧本分镜需豆包(约¥0.01/集),请勾选授权');return;}
const log=$('#dramaLog');log.textContent='🚀 开始…\n';
const btn=$('#dramaGo');btn.disabled=true;
try{
const res=await fetch('/api/agent',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({input_text:input,input_type:type,episode:parseInt($('#dramaEp').value)||1,
frames:parseInt($('#dramaFrames').value)||49,style:'',doubao_auth:$('#dramaAuth').checked})});
if(!res.ok){const e=await res.json().catch(()=>({}));toast(e.reply||'请求失败');btn.disabled=false;return;}
const rd=res.body.getReader();const dec=new TextDecoder();let buf='';
while(true){const {done,value}=await rd.read();if(done)break;
buf+=dec.decode(value,{stream:true});
let idx;while((idx=buf.indexOf('\n\n'))>=0){const ev=buf.slice(0,idx);buf=buf.slice(idx+2);
const dm=ev.match(/data: (.+)/);if(!dm)continue;
try{const d=JSON.parse(dm[1]);
if(d.type==='agent_log'){log.textContent+=d.text+'\n';log.scrollTop=log.scrollHeight;}
else if(d.type==='agent_done'){log.textContent+='\n✅ 完成\n';if(d.url){$('#dramaResult').innerHTML='<video src="'+d.url+'" controls style="max-width:100%;border-radius:10px;margin-top:8px"></video>';}else if(d.reply){log.textContent+=d.reply+'\n';}}
else if(d.type==='error'){log.textContent+='\n'+d.text+'\n';}
}catch(e2){}
}
}
}catch(err){toast('出错了:'+err.message);}
btn.disabled=false;
};
$('#fileinp').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{attach={name:f.name,text:r.result};const c=$('#attachchip');c.style.display='block';c.textContent='📎 已附:'+f.name+'(发送时一并发给蛋蛋)';};r.readAsText(f);};
modelSel.onchange=()=>{if(cur)cur.model=modelSel.value;save();};
$('#search').oninput=renderSide;
@ -665,16 +816,30 @@ msg.addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();doSend();}
else if(e.key==='Escape'&&busy){if(abortCtl)abortCtl.abort();}
});
function uploadMedia(file,cb){
const r=new FileReader();
r.onload=()=>{
fetch('/api/upload',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({name:file.name,data:r.result.split(',')[1]})})
.then(x=>x.json()).then(d=>{if(d.url)cb(d.url);else toast('上传失败');})
.catch(()=>toast('上传失败'));
};
r.readAsDataURL(file);
}
msg.addEventListener('paste',e=>{
const item=[...e.clipboardData.items].find(i=>i.type.startsWith('image/'));
if(item){e.preventDefault();toast('图片暂不支持发(蛋蛋是文本模型)');}
if(item){e.preventDefault();const f=item.getAsFile();if(!f)return;
uploadMedia(f,url=>{msg.value+=(msg.value?'\n':'')+'【附图】['+f.name+']('+url+')';autoGrow();updateCount();});}
});
/* 拖拽文件到窗口 -> 作为附件 */
['dragover','drop'].forEach(ev=>log.addEventListener(ev,e=>{if(ev==='dragover'){e.preventDefault();}}));
log.addEventListener('drop',e=>{
e.preventDefault();const f=e.dataTransfer.files[0];if(!f)return;
if(/\.(png|jpe?g|gif|webp)$/i.test(f.name)){toast('图片暂不支持发(文本模型)');return;}
if(/\.(png|jpe?g|gif|webp|mp4|webm|mov)$/i.test(f.name)){
uploadMedia(f,url=>{msg.value+=(msg.value?'\n':'')+'【附图】['+f.name+']('+url+')';autoGrow();updateCount();});
return;
}
const r=new FileReader();r.onload=()=>{attach={name:f.name,text:r.result};const c=$('#attachchip');c.style.display='block';c.textContent='📎 已附:'+f.name+'(发送时一并发给蛋蛋)';};r.readAsText(f);
});
@ -703,19 +868,126 @@ else{cur=convs[0];modelSel.value=cur.model||DEFAULT;renderSide();renderLog();}
"""
import os
import uuid
import time
import base64
import re
import signal
import sys
def build_prompt(history, message):
def build_prompt(history, message, limit=80000):
"""拼对话历史按【字节数】硬截断默认80KB确保 -p 参数永不超内核 128KB 单参数上限E2BIG
注意内核按字节计中文一个字占3字节所以不能用字符数当上限"""
lines = ["以下是你和苍耳爸爸的对话记录:"]
total = len(lines[0].encode("utf-8"))
skipped = 0
for h in history:
who = "苍耳" if h.get("role") == "me" else "蛋蛋"
lines.append(f"{who}: {h.get('text','')}")
line = f"{who}: {h.get('text','')}"
n = len(line.encode("utf-8"))
if total + n > limit:
skipped += 1
continue
total += n
lines.append(line)
if skipped:
lines.insert(1, f"[较早的 {skipped} 条对话已省略,如需细节可提问]")
lines.append("")
lines.append(f"苍耳: {message}")
lines.append("蛋蛋:")
return "\n".join(lines)
COMPACT_THRESHOLD = 180 * 1024 # 会话文件超过 180KB 触发自动压缩
COMPACT_RESUME_MAX = 4 * 1024 * 1024 # 超过4MB的会话不尝试模型摘要必超时直接读尾部降级
def maybe_compact(sid, model):
"""会话文件过大时:先 resume 出一份摘要,再开新场次衔接。返回 dict 或 None。
若模型摘要失败/文件超大自动降级为直接读文件尾部生成原始摘要保证永远有衔接"""
f = os.path.join(os.path.expanduser("~/.codebuddy/projects/home-ls"), sid + ".jsonl")
if not os.path.exists(f):
return None
size = os.path.getsize(f)
if size < COMPACT_THRESHOLD:
return None
summary = None
if size <= COMPACT_RESUME_MAX:
summary_cmd = [CODEBUDDY, "--print", "--model", model, "--output-format", "json",
"--tools", "Read", "--system-prompt", EED_SYS,
"--resume", sid, "-p",
"请把当前对话的所有重要信息压缩成不超过500字的结构化摘要包含①关键事实 ②已做的决策 ③进行中的任务/下一步 ④爸爸的偏好。只输出摘要正文,不要任何其他内容。"]
try:
r = subprocess.run(summary_cmd, capture_output=True, text=True, timeout=120)
summary = ""
for line in r.stdout.splitlines():
try:
ev = json.loads(line)
except Exception:
continue
if ev.get("type") == "result":
summary = ev.get("result", "") or ""
break
except Exception:
summary = None
if not summary:
# 降级:不调模型,直接读文件尾部最近消息(永不超时、永不卡死)
summary = _tail_summary(f)
if not summary:
return None
new_sid = "eed_" + uuid.uuid4().hex[:12]
return {"new_sid": new_sid, "summary": summary, "old_sid": sid, "size": size}
def _tail_summary(path, max_items=40, max_bytes=80000):
"""不调模型:从 jsonl 尾部读最近消息,生成原始截断摘要(兜底用)。
codebuddy 会话格式role 在顶层文本块 type output_text/input_text/text"""
try:
with open(path, "rb") as fh:
fh.seek(0, os.SEEK_END)
size = fh.tell()
fh.seek(max(0, size - 2 * 1024 * 1024)) # 只读尾部最多2MB
tail = fh.read().decode("utf-8", errors="replace")
items = []
for line in tail.splitlines():
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except Exception:
continue
if ev.get("type") != "message":
continue # 跳过 reasoning/snapshot 等非消息行
role = ev.get("role", "")
if role not in ("user", "assistant"):
continue
txt = ""
content = ev.get("content") or []
if isinstance(content, str):
txt = content
elif isinstance(content, list):
for c in content:
if isinstance(c, dict):
ct = c.get("type", "")
if "text" in ct: # output_text / input_text / text
txt += c.get("text", "")
if not txt.strip():
continue
who = "苍耳" if role == "user" else "蛋蛋"
items.append(f"{who}: {txt.strip()[:300]}")
if not items:
return ""
head = "[本会话文件过大,以下为自动截取的最近对话(作背景记忆):]\n"
body = "\n".join(items[-max_items:])
if len(head + body) > max_bytes:
body = body[-(max_bytes - len(head)):]
return head + body
except Exception:
return ""
def _text_of(content):
"""把工具返回内容统一成字符串(兼容 str / list[block] / dict"""
if content is None:
@ -758,43 +1030,134 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
def _media_type(self, path):
if path.endswith(".png"): return "image/png"
if path.endswith((".jpg", ".jpeg")): return "image/jpeg"
if path.endswith(".gif"): return "image/gif"
if path.endswith(".webp"): return "image/webp"
if path.endswith(".mp4"): return "video/mp4"
if path.endswith(".webm"): return "video/webm"
if path.endswith(".mov"): return "video/quicktime"
return "application/octet-stream"
def do_GET(self):
if self.path.split("?")[0] in ("/", "/index.html"):
p = self.path.split("?")[0]
if p in ("/", "/index.html"):
self._send(200, PAGE.encode("utf-8"), "text/html; charset=utf-8")
elif p.startswith("/media/"):
fpath = os.path.join(os.path.expanduser("~/cang-ying"), p[len("/media/"):])
if os.path.isfile(fpath):
with open(fpath, "rb") as fh:
self._send(200, fh.read(), self._media_type(fpath))
else:
self._send(404, b"not found")
else:
self._send(404, b"not found")
def do_POST(self):
if self.path.split("?")[0] != "/api/chat":
self._send(404, b"not found"); return
def do_POST_upload(self):
"""接收 base64 图片/视频,存入 ~/cang-ying/inbox/,返回 /media/ 访问路径。"""
try:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
data = json.loads(raw or b"{}")
message = str(data.get("message", "")).strip()
model = str(data.get("model", DEFAULT_MODEL)).strip()
if model not in ALLOWED:
model = DEFAULT_MODEL
history = data.get("history", [])
if not message:
self._send(400, json.dumps({"reply": "(没收到内容)"}).encode("utf-8")); return
prompt = build_prompt(history, message)
name = str(data.get("name", "")).strip() or "file.bin"
b64 = str(data.get("data", "")).strip()
if not b64 or not re.search(r"\.(png|jpe?g|gif|webp|mp4|webm|mov)$", name, re.I):
self._send(400, json.dumps({"error": "只支持图片/视频文件"}).encode("utf-8")); return
content = base64.b64decode(b64)
inbox = os.path.expanduser("~/cang-ying/inbox")
os.makedirs(inbox, exist_ok=True)
fname = time.strftime("%Y%m%d_%H%M%S") + "_" + os.path.basename(name)
with open(os.path.join(inbox, fname), "wb") as fh:
fh.write(content)
self._send(200, json.dumps({"url": "/media/inbox/" + fname}).encode("utf-8"))
except Exception as e:
self._send(400, json.dumps({"error": str(e)}).encode("utf-8"))
def do_POST_agent(self):
"""🎬 一键短剧 Agent贴剧本/分镜 → 全自动出成片SSE 流式进度)"""
proc = None
try:
import urllib.request, sys
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
data = json.loads(raw or b"{}")
input_text = str(data.get("input_text", "")).strip()
input_type = str(data.get("input_type", "storyboard"))
episode = int(data.get("episode", 1) or 1)
frames = int(data.get("frames", 49) or 49)
style = str(data.get("style", "")).strip()
if not input_text:
self._send(400, json.dumps({"reply": "(没贴内容)"}).encode("utf-8")); return
try:
urllib.request.urlopen("http://127.0.0.1:8188/system_stats", timeout=3)
except Exception:
self._send(503, json.dumps({"reply": "ComfyUI 没在跑,先启动 ComfyUI 再试"}).encode("utf-8")); return
import shutil, glob as _g
ws = os.path.expanduser("~/cang-ying/agent_workspace")
os.makedirs(ws, exist_ok=True)
proj = os.path.join(ws, "proj_" + uuid.uuid4().hex[:8])
os.makedirs(proj, exist_ok=True)
agent = os.path.expanduser("~/cang-ying/video-ai-system/agent_short_drama.py")
if input_type == "script":
if not data.get("doubao_auth"):
self._send(400, json.dumps({"reply": "剧本分镜需豆包(约¥0.01/集),请勾选授权后再试"}).encode("utf-8")); return
sp = os.path.join(proj, "script.txt")
with open(sp, "w", encoding="utf-8") as f:
f.write(input_text)
cmd = [sys.executable, agent, sp, "-e", str(episode), "--until", "compose"]
if style: cmd += ["--style", style]
else:
sb = os.path.join(proj, "storyboard.json")
with open(sb, "w", encoding="utf-8") as f:
f.write(input_text)
cmd = [sys.executable, agent, sb, "--from", "render", "--until", "compose"]
if style: cmd += ["--style", style]
cmd += ["--frames", str(frames)]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Connection", "keep-alive")
self.end_headers()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, start_new_session=True)
_register(proc)
final = ""
for line in proc.stdout:
line = line.rstrip()
if line:
self._event("agent_log", {"text": line})
final += line + "\n"
proc.wait()
vids = _g.glob(os.path.join(proj, "renders", "*.mp4"))
if vids:
out_p = os.path.expanduser("~/cang-ying/outputs/agent_EP.mp4")
shutil.copy(vids[0], out_p)
self._event("agent_done", {"url": "/media/outputs/agent_EP.mp4"})
else:
self._event("agent_done", {"reply": "未找到成片。\n" + final[-500:]})
except (BrokenPipeError, ConnectionResetError):
_kill_active()
except Exception as e:
print("DO_POST_AGENT_ERR:", repr(e), flush=True)
try: self._event("error", {"text": str(e)})
except Exception: pass
finally:
if proc is not None:
_unregister(proc)
if proc.poll() is None:
_kill_proc(proc)
try: self._chunk_end()
except Exception: pass
acc = ""
proc = subprocess.Popen(
[CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash",
"--no-session-persistence", "--output-format", "stream-json",
"--system-prompt", EED_SYS, "-p", prompt],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1,
)
def _stream_cmd(self, cmd):
"""跑一次 codebuddy 子进程,边解析边把事件流式推给前端,返回累计文本。"""
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1, start_new_session=True)
_register(proc)
acc = ""
try:
for line in proc.stdout:
line = line.strip()
if not line:
@ -805,10 +1168,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
continue
t = ev.get("type")
if t == "thinking":
# 某些后端把思考作为顶层事件发出
self._event("thinking", {"text": ev.get("thinking") or ev.get("text", "")})
elif t == "tool_result":
# 工具返回作为顶层事件
self._event("tool_result", {"id": ev.get("tool_use_id", ""),
"content": _text_of(ev.get("content", ""))})
elif t == "assistant":
@ -827,7 +1188,6 @@ class Handler(http.server.BaseHTTPRequestHandler):
self._event("tool_result", {"id": c.get("tool_use_id", ""),
"content": _text_of(c.get("content", ""))})
elif t == "user":
# 工具返回常以 user 消息(带 tool_result 内容块)回流
for c in ev.get("message", {}).get("content", []):
if c.get("type") == "tool_result":
self._event("tool_result", {"id": c.get("tool_use_id", ""),
@ -839,18 +1199,139 @@ class Handler(http.server.BaseHTTPRequestHandler):
cost = ev.get("total_cost_usd", None)
if usage or cost is not None:
self._event("usage", {"usage": usage, "cost": cost})
proc.wait()
finally:
try:
proc.wait()
except Exception:
pass
_unregister(proc)
if proc.poll() is None:
_kill_proc(proc)
return acc
def do_POST_skills(self):
"""🧠 技能库:返回全部技能列表。"""
try:
sys.path.insert(0, os.path.expanduser("~/cang-ying"))
from skill.skill_center import list_skills
self._send(200, json.dumps({"skills": list_skills()}, ensure_ascii=False).encode("utf-8"))
except Exception as e:
self._send(500, json.dumps({"error": str(e)}, ensure_ascii=False).encode("utf-8"))
def do_POST_skill(self):
"""🧠 技能执行:{action:load|run, skill, args}"""
try:
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
data = json.loads(raw or b"{}")
action = str(data.get("action", "load"))
skill = str(data.get("skill", ""))
args = data.get("args") or []
sys.path.insert(0, os.path.expanduser("~/cang-ying"))
from skill.skill_center import load as sk_load, run as sk_run
if action == "run":
ok, out = sk_run(skill, args)
self._send(200, json.dumps({"ok": ok, "output": out}, ensure_ascii=False).encode("utf-8"))
else:
s = sk_load(skill)
if not s:
self._send(404, json.dumps({"error": "技能不存在"}).encode("utf-8"))
else:
self._send(200, json.dumps(s, ensure_ascii=False).encode("utf-8"))
except Exception as e:
self._send(500, json.dumps({"error": str(e)}, ensure_ascii=False).encode("utf-8"))
def do_POST(self):
path = self.path.split("?")[0]
if path == "/api/upload":
self.do_POST_upload(); return
if path == "/api/agent":
self.do_POST_agent(); return
if path == "/api/skills":
self.do_POST_skills(); return
if path == "/api/skill":
self.do_POST_skill(); return
if path == "/api/stop":
_kill_active()
self._send(200, json.dumps({"ok": True}).encode("utf-8"))
return
if path != "/api/chat":
self._send(404, b"not found"); return
try:
proc = None
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
data = json.loads(raw or b"{}")
message = str(data.get("message", "")).strip()
model = str(data.get("model", DEFAULT_MODEL)).strip()
if model not in ALLOWED:
model = DEFAULT_MODEL
history = data.get("history", [])
sid = str(data.get("session_id", "")).strip()
started = bool(data.get("started"))
if not message:
self._send(400, json.dumps({"reply": "(没收到内容)"}).encode("utf-8")); return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Connection", "keep-alive")
self.end_headers()
# ---- 会话模式:老会话 resume 续聊;新会话/重建 用 session-id 开新场 ----
# 关键容错:若 resume 的旧 session 已失效(被清理/不存在),--resume 后无输出,
# 此时自动退回「全新场次 + 历史」重新生成,保证爸爸一定有回复。
compacted = None
acc = ""
if sid and started:
compacted = maybe_compact(sid, model)
if compacted:
sid = compacted["new_sid"]
summary_ctx = ("\n\n[以下是本对话更早内容的自动压缩摘要,请作为背景记忆]\n"
+ compacted["summary"])
cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash",
"--output-format", "stream-json", "--system-prompt", EED_SYS,
"--append-system-prompt", summary_ctx,
"--session-id", sid, "-p", message]
else:
cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash",
"--output-format", "stream-json", "--system-prompt", EED_SYS,
"--resume", sid, "-p", message]
acc = self._stream_cmd(cmd)
if not acc.strip():
# 退回全新场次(带历史),用新 session-id
sid = "eed_" + uuid.uuid4().hex[:12]
cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash",
"--output-format", "stream-json", "--system-prompt", EED_SYS,
"--session-id", sid, "-p", build_prompt(history, message)]
acc2 = self._stream_cmd(cmd)
if acc2.strip():
acc = acc2
compacted = None
else:
if not sid:
sid = "eed_" + uuid.uuid4().hex[:12]
prompt = build_prompt(history, message)
cmd = [CODEBUDDY, "--print", "--model", model, "--tools", "Read,WebSearch,Bash",
"--output-format", "stream-json", "--system-prompt", EED_SYS,
"--session-id", sid, "-p", prompt]
acc = self._stream_cmd(cmd)
if not acc:
acc = "(蛋蛋没回话,换个说法试试~)"
self._event("done", {"text": acc})
self._event("done", {"text": acc, "session_id": sid,
"compacted": bool(compacted),
"summary": (compacted or {}).get("summary", "")})
except (BrokenPipeError, ConnectionResetError):
pass
_kill_active() # 浏览器断开(点停止/关页)→ 杀掉后台进程
except Exception as e:
try:
self._event("error", {"text": str(e)})
except Exception:
pass
finally:
# 子进程的生命周期由 _stream_cmd 负责清理;这里只需收尾 SSE 流
try:
self._chunk_end()
except Exception:

View File

@ -0,0 +1,159 @@
# Character Sheet Generator - 人物角色设定板生成技能
<div align="center">
![Character Sheet](https://img.shields.io/badge/Character%20Sheet-Generator-blueviolet)
![Version](https://img.shields.io/badge/version-1.0.0-green)
![Style](https://img.shields.io/badge/styles-6%2B-orange)
![Models](https://img.shields.io/badge/models-compatible-red)
**专业AI人物角色设定板三视图生成提示词技能**
[English](#english) | [中文](#中文)
</div>
---
## 中文
### 简介
这是一个用于AI图像生成的专业角色设定板Character Design Sheet / Turnaround生成技能。它可以帮助你生成标准化的人物三视图设定稿包含
- **主视觉区**:正面 + 侧面 + 背面 半身像
- **补充信息区**:面部特写 + 配色色板(附色值)
- **局部细节区**:配饰、纹样、关键道具放大展示
- **全身比例照**:带身高标尺的全身立绘
### 支持的风格
| 模板 | 适用场景 |
|------|----------|
| `base.md` | 通用基础模板,所有风格适用 |
| `ancient_chinese.md` | 国风古风、汉服、仙侠、武侠 |
| `realistic.md` | 真人写实、摄影级肖像 |
| `anime.md` | 日系动漫、二次元、赛璐璐 |
| `modern.md` | 现代都市、时装、职场 |
| `sci-fi.md` | 赛博朋克、科幻、未来 |
| `fantasy.md` | 西式奇幻、中世纪、DND |
### 快速开始
1. **选择风格模板**:根据你的角色类型选择对应的模板文件
2. **填写参数**:替换模板中的 `{{变量名}}` 为你的角色设定
3. **生成图片**将完整提示词输入AI绘图工具即梦、Midjourney、Stable Diffusion、Seedream等
4. **迭代优化**:根据生成结果调整参数
### 模板变量说明
| 变量 | 说明 | 示例 |
|------|------|------|
| `{{gender}}` | 性别 | 女性/男性 |
| `{{age}}` | 外观年龄 | 20岁 |
| `{{face_shape}}` | 脸型 | 瓜子脸 |
| `{{expression}}` | 表情 | 微笑 |
| `{{eyebrow_type}}` | 眉型 | 柳叶眉 |
| `{{eye_type}}` | 眼型 | 杏仁眼 |
| `{{eye_temperament}}` | 眼神 | 眼神傲慢 |
| `{{hair_style}}` | 发型 | 黑长发束起 |
| `{{hair_accessories}}` | 发饰 | 佩戴汉服头饰 |
| `{{height}}` | 身高 | 170cm |
| `{{temperament}}` | 气质 | 气质清冷 |
| `{{outfit_description}}` | 服饰描述 | 粉底黑纹汉服 |
| `{{outfit_colors}}` | 配色 | 粉+黑+金 |
| `{{key_accessories}}` | 配饰 | 玉簪、珍珠耳坠 |
### 使用示例
参见 `examples/` 目录:
- `example_ancient.md` - 国风汉服女子(参考案例)
- `example_modern.md` - 现代职场男性
- `example_anime.md` - 动漫二次元少女
### 推荐生成参数
- **尺寸**:竖版 3:4 或 2:3如 864x1152、1024x1536
- **模型**:根据风格选择对应模型
- **建议**同时生成2-4张挑选一致性最好的
### 目录结构
```
character-sheet-skill/
├── SKILL.md # 技能主文件(核心说明)
├── README.md # 本说明文件
├── templates/ # 提示词模板库
│ ├── base.md # 通用基础模板
│ ├── ancient_chinese.md # 国风古风模板
│ ├── realistic.md # 真人写实模板
│ ├── anime.md # 动漫二次元模板
│ ├── modern.md # 现代都市模板
│ ├── sci-fi.md # 科幻赛博模板
│ └── fantasy.md # 西式奇幻模板
├── examples/ # 完整示例
│ ├── example_ancient.md
│ ├── example_modern.md
│ └── example_anime.md
├── assets/ # 参考资源
└── scripts/ # 辅助脚本
```
---
## English
### Introduction
A professional AI character design sheet (turnaround) prompt engineering skill. Generate standardized character reference boards with:
- **Main View**: Front + Side + Back bust portraits
- **Reference Panel**: Facial close-up + Color palette with hex codes
- **Detail Callouts**: Close-ups of accessories, patterns, key items
- **Full Body**: Full body standing portrait with height scale
### Supported Styles
| Template | Use Case |
|----------|----------|
| `base.md` | Universal base template |
| `ancient_chinese.md` | Chinese ancient / Hanfu / Xianxia / Wuxia |
| `realistic.md` | Photorealistic / Photography portrait |
| `anime.md` | Japanese anime / Cel-shading / 2D |
| `modern.md` | Modern urban / Fashion / Contemporary |
| `sci-fi.md` | Cyberpunk / Sci-fi / Futuristic |
| `fantasy.md` | High fantasy / Medieval / D&D |
### Quick Start
1. **Choose a template** from `templates/` based on your character style
2. **Fill in parameters** by replacing `{{variable_name}}` placeholders
3. **Generate image** using the full prompt in AI image generators (Jimeng, Midjourney, Stable Diffusion, Seedream, etc.)
4. **Iterate** based on results
### Template Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `{{gender}}` | Character gender | Female/Male |
| `{{age}}` | Apparent age | 20 years old |
| `{{face_shape}}` | Face shape | Oval face |
| `{{height}}` | Height | 170cm |
| `{{outfit_description}}` | Outfit details | Pink and black hanfu |
See `SKILL.md` for full documentation.
### Recommended Settings
- **Aspect Ratio**: Portrait 3:4 or 2:3 (e.g., 864x1152, 1024x1536)
- **Tip**: Generate 2-4 variants and pick the most consistent one
---
## License
MIT License - Feel free to use and modify.
## Contributing
Contributions are welcome! Feel free to submit pull requests with new style templates or improvements.

View File

@ -0,0 +1,218 @@
---
name: character-sheet-generator
version: 1.0.0
description: "专业人物角色设定板Character Sheet / 三视图)生成技能。当用户需要生成角色三视图、人物设定图、角色参考板、原画设定稿,或提到'角色板'、'三视图'、'人物设定'、'character sheet'、'人设图'、'turnaround'时使用。支持国风古风、现代写实、动漫二次元、科幻赛博等多种风格,可生成包含正面/侧面/背面三视图、面部特写、配色色板、局部细节、身高比例对照的专业角色设定图。"
metadata:
category: image-generation
tags: ["character-design", "ai-art", "character-sheet", "turnaround", "三视图", "人设"]
compatible_models: ["jimeng", "midjourney", "dall-e", "stable-diffusion", "seedream"]
author: "AI Character Design Studio"
---
# 人物角色设定板生成器
> 一个专业的AI图像生成提示词工程技能用于生成标准化的人物角色设定板Character Design Sheet / Turnaround
## 技能概述
本技能帮助用户生成专业级的人物角色设定板,包含:
- **主视觉区**:正面 + 侧面 + 背面 三个核心视角(半身像)
- **补充信息区**:面部特写 + 配色色板(附色值标注)
- **局部细节区**:配饰、纹样、关键道具的放大展示
- **全身比例照**:带身高标尺的全身立绘,含黄金比例参考
## 快速使用
### 基础调用格式
当用户需要生成角色设定板时,按以下流程操作:
1. **收集角色参数**(如用户未提供完整信息,主动询问缺失项):
- 性别 / 年龄外观
- 面部特征(脸型、眉眼、表情、气质)
- 发型发色
- 服饰风格与配色
- 身高体型
- 艺术风格(写实/动漫/古风/科幻等)
- 关键配饰与身份标识
2. **选择对应风格模板**(从 `templates/` 目录选择):
- `templates/base.md` - 基础通用模板
- `templates/ancient_chinese.md` - 国风古风汉服(推荐)
- `templates/realistic.md` - 真人写实
- `templates/anime.md` - 动漫二次元
- `templates/modern.md` - 现代都市
- `templates/sci-fi.md` - 科幻赛博
- `templates/fantasy.md` - 西式奇幻
3. **填充模板变量**,生成最终提示词
4. **调用图像生成工具**(如 byted-seedream-image-generate 或其他AI绘图工具生成图片
### 最简调用示例
```
用户帮我生成一个20岁古风女子的角色板穿粉色汉服黑长发身高165cm
→ 使用 ancient_chinese 模板,填充参数,生成提示词,输出图片
```
## 模板变量说明
所有模板支持以下变量,使用 `{{变量名}}` 标记:
| 变量名 | 说明 | 示例值 |
|--------|------|--------|
| `{{gender}}` | 性别 | 女性/男性 |
| `{{age}}` | 外观年龄 | 20岁 |
| `{{face_shape}}` | 脸型 | 瓜子脸/鹅蛋脸/方圆脸 |
| `{{expression}}` | 表情神态 | 微笑/冷傲/温柔/轻蔑 |
| `{{eyebrow_type}}` | 眉型 | 柳叶眉/剑眉/远山眉 |
| `{{eye_type}}` | 眼型 | 杏仁眼/丹凤眼/桃花眼 |
| `{{eye_temperament}}` | 眼神气质 | 眼神温柔/眼神傲慢/目光坚毅 |
| `{{hair_style}}` | 发型 | 黑长发束起/高马尾/双马尾/发髻 |
| `{{hair_accessories}}` | 发饰 | 佩戴汉服头饰/发簪/步摇 |
| `{{height}}` | 身高 | 170cm/165cm/180cm |
| `{{temperament}}` | 整体气质 | 气质霸道清冷/温婉贤淑/英气逼人 |
| `{{outfit_description}}` | 服饰描述 | 粉底黑纹长襦裙配精美花饰刺绣 |
| `{{outfit_colors}}` | 主要配色 | 粉色+黑色+金色 |
| `{{key_accessories}}` | 关键配饰 | 玉坠发簪、珍珠耳坠、腰封流苏 |
| `{{style_keywords}}` | 风格关键词 | 超写实国风8K高清纹理 |
| `{{background}}` | 背景 | 纯白色背景 |
| `{{quality_tags}}` | 质量标签 | 最高品质细节丰富masterpiece |
## 角色板标准布局规范
生成的角色设定板必须严格遵循以下布局(参考行业标准):
```
┌─────────────────────────────────────────────────────────────┐
│ [主视区] [侧视区] [背视区] │
│ 正面半身 侧面半身 背面半身 ← 上方:三视图半身 │
├────────────────┬────────────────────────────────────────────┤
│ [面部特写] │ │
│ 脸部大特写 │ [全身比例照] │
├────────────────┤ 全身立绘 + 身高标尺 │
│ [配色色板] │ 右侧标注cm刻度线
│ 主要颜色+色值 │ │
├────────────────┤ │
│ [局部细节区] │ │
│ 发饰/耳饰/纹样 │ │
│ 等配饰放大展示 │ │
└────────────────┴────────────────────────────────────────────┘
```
### 各区详细要求
**1. 主视觉区(上方,横向三等分)**
- 正面半身(胸部以上):清晰展示正脸、前襟、肩部装饰
- 侧面半身(胸部以上):展示侧脸轮廓、发型侧面、侧面服饰线条
- 背面半身(胸部以上):展示后脑勺发型、后背设计、背面装饰
- 三个视角人物大小一致,水平对齐
**2. 补充信息区(左侧)**
- 面部特写:脸部近距离特写,清晰展示五官妆容细节
- 配色色板3-8个主要颜色色块每个色块标注色值#F5D0C5浅粉#1A1A1A玄黑
- 按服饰层次顺序排列:主色→辅色→点缀色→金属色
**3. 局部细节区(左下角)**
- 小模块网格排列,每格展示一个关键部件
- 包含但不限于:发饰特写、耳饰/项链、腰封/腰带、鞋履、服饰纹样特写、手持道具
- 每个细节图背景独立,与主体风格一致
**4. 全身比例照(右侧,占据右侧大部分区域)**
- 人物全身立绘,标准站姿(双手自然交叠于身前或两侧)
- 人物右侧有垂直标尺以10cm为刻度线标注关键高度点
- 可加入黄金比例参考物(如标准头高测量线)
- 底部标注总身高(如"170cm"
**5. 整体规范**
- 背景为纯白色(#FFFFFF),无多余杂物
- 各区之间有细分割线或适当留白
- 各区可用小号字体标注中文/英文标签(如"主视区 Front View"
- 所有视角保持人物形象一致性(同一人物、同一套服饰)
## 风格预设库
### 国风写实风格(默认推荐)
关键词超写实国风真人写实风格质感光照自然光线质感十足8K高清纹理布料褶皱自然艺术写实风格电影级光影
### 动漫二次元风格
关键词anime stylecel-shading动画赛璐璐风格精致日系画风清晰线稿平涂上色明亮色彩二次元人设
### 现代写实风格
关键词:现代都市,摄影级写实质感,时尚大片风格,商业摄影灯光,高清皮肤纹理,真实布料质感,杂志风
### 科幻赛博风格
关键词cyberpunk赛博朋克未来科技感霓虹光效金属质感全息元素机械义体高科技面料
### 西式奇幻风格
关键词fantasy art魔幻风格中世纪奇幻精灵/骑士/法师风格,魔法光效,厚重油画质感,史诗感
## 提示词组合公式
标准提示词结构(按重要性排序):
```
[布局指令] + [人物基础设定] + [面部与发型] + [服饰细节] + [气质表情] + [配饰道具] + [风格与质量] + [技术参数]
```
### 布局指令(必须放在最前)
```
Character design sheet, turnaround model sheet, white background,
top section: three-view bust portraits (front view + side view + back view),
left section: facial close-up + color palette with hex codes + detail callouts of accessories,
right section: full body standing portrait with height measurement scale in centimeters,
professional character reference board, multiple views of the same character, consistent character design,
```
### 中文布局指令(用于中文模型)
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+发饰耳饰等局部细节放大图,
右侧:全身立绘配身高厘米刻度标尺,
专业原画设定稿,同一人物多角度展示,形象高度一致,
```
## 工作流
当用户请求生成角色板时,执行以下步骤:
1. **参数提取**:从用户描述中提取角色参数,填充模板变量
2. **补全询问**:对关键缺失信息(性别、风格、服饰)进行简短询问,非关键项使用合理默认值
3. **模板选择**:根据服饰/时代/风格自动选择最合适的模板,不确定时使用 `base.md`
4. **提示词生成**:将参数填入模板,生成完整的中英文双语提示词
5. **质量增强**自动追加质量标签masterpiece, best quality, 8K, ultra-detailed等
6. **输出结果**:输出最终提示词,并调用图像生成工具生成图片;如无图像生成工具,直接输出提示词供用户使用
7. **迭代优化**:如用户对结果不满意,根据反馈调整参数重新生成
## 提示词优化技巧
1. **一致性控制**:在提示词中多次强调"same character"、"same outfit"、"consistent design"
2. **避免背景干扰**:明确"pure white background"、"no shadows on background"
3. **细节强化**:对关键特征重复描述(如"black long hair"在发型区和整体区各提一次)
4. **负面提示词**:建议搭配负面提示词使用:`different characters, multiple people, mutated hands, ugly, deformed, blurry, watermark, text, signature`
5. **尺寸建议**:推荐生成尺寸为竖版 3:4 或 2:3如 1024x1536、864x1152竖版更适合角色板布局
## 负面提示词模板
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, watermark, signature, text, logo, cropped, out of frame,
complex background, gradient background, shadows on background,
```
## 示例
参见 `examples/` 目录下的完整示例:
- `examples/example_ancient.md` - 国风汉服女子完整示例(即用户提供的参考案例)
- `examples/example_modern.md` - 现代都市男子示例
- `examples/example_anime.md` - 动漫少女示例
## 版本历史
- v1.0.0 - 初始版本包含基础布局和5种风格预设

View File

@ -0,0 +1,63 @@
# 示例:国风汉服女子(参考案例)
基于用户提供的参考图片和提示词生成的完整示例。
## 输入参数
```yaml
gender: 女性
age: 20岁
face_shape: 瓜子脸
expression: 微笑型
eyebrow_type: 柳叶眉
eye_type: 杏仁眼
eye_temperament: 眼神傲慢轻蔑
hair_style: 黑长发束起
hair_accessories: 佩戴汉服头饰(粉花金簪步摇)
height: 170cm
temperament: 气质霸道迂腐,威压感强烈
outfit_description: 粉底黑纹长襦裙配精美花饰刺绣,宽袖流仙裙,丝绸面料,黑色腰封配粉玉流苏
outfit_colors: 粉色+黑色+金色(#F5D0C5浅粉#1A1A1A玄黑#D4AF37金色
key_accessories: 玉簪花发饰、珍珠耳坠、粉玉腰佩流苏、刺绣牡丹纹样
style_keywords: 【真人写实】超写实国风写实风格质感光照自然光线质感十足8K高清纹理布料褶皱自然艺术写实风格营造出震撼的视觉效果
```
## 生成的完整中文提示词
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+发饰耳饰等局部细节放大图,
右侧全身立绘配身高170cm厘米刻度标尺
专业原画设定稿,同一人物多角度展示,形象高度一致,
【真人写实】超写实国风现代风格写实风格质感光照自然光线质感十足8K高清纹理
布料褶皱自然,艺术写实风格,营造出震撼的视觉效果,电影级光影,
女性外表20岁黑长发束起佩戴汉服头饰粉花金簪步摇
瓜子脸带微笑型,柳叶眉杏仁眼,眼神傲慢轻蔑,
身高170cm气质霸道迂腐威压感强烈
粉底黑纹长襦裙配精美花饰刺绣,宽袖流仙裙,丝绸面料,黑色腰封配粉玉流苏,
主要配色:粉色+黑色+金色(#F5D0C5浅粉#1A1A1A玄黑#D4AF37金色
关键配饰:玉簪花发饰、珍珠耳坠、粉玉腰佩流苏、刺绣牡丹纹样,
刺绣纹样精致,丝绸质感,古风妆造,传统中式美学,
最高品质细节丰富masterpiecebest quality8Kultra-detailed
character turnaround sheet, multiple views, same character, consistent design,
hanfu, chinese ancient clothing, oriental beauty
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
modern clothing, western clothing, casual wear, complex background, shadows on background
```
## 推荐参数
- 推荐尺寸864x1152竖版3:4或 1024x1536竖版2:3
- 推荐模型即梦AI、Midjourney v6、Seedream
- 生成次数建议生成2-4张挑选最佳效果

View File

@ -0,0 +1,61 @@
# 示例:动漫二次元少女
## 输入参数
```yaml
gender: 女性
age: 16岁
face_shape: 精致鹅蛋脸
expression: 元气开朗的笑容
eyebrow_type: 柔和细眉
eye_type: 大大圆眼(星星眼高光)
eye_temperament: 眼神明亮活泼
hair_style: 双马尾,粉色渐变长发,蓬松刘海
hair_accessories: 白色蝴蝶结发带,星星发夹
height: 158cm
temperament: 气质元气可爱,活力满满
outfit_description: 日式水手服校服,水蓝色领子配白色三本线,白色上衣,百褶裙,白色过膝袜,黑色制服鞋
outfit_colors: 水蓝+白色+藏青+粉色(#87CEEB水蓝#FFFFFF纯白#1A1A4D藏青#FFB6C1粉
key_accessories: 学生书包、粉色兔子挂件、腕带装饰
style_keywords: 日系动画风格,赛璐璐上色,精致线稿,平涂结合阴影,明亮通透的色彩,二次元美少女画风
```
## 生成的完整中文提示词
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+发饰服饰等局部细节放大图,
右侧全身立绘配身高158cm厘米刻度标尺
专业动画人设稿,同一角色多角度展示,形象高度一致,
【动漫风格】日系动画风格,赛璐璐上色,精致线稿,平涂结合阴影,
明亮通透的色彩,二次元美少女画风,动画原画级别,京都动画级别人设质量,
女性外表16岁双马尾粉色渐变长发蓬松刘海白色蝴蝶结发带星星发夹
精致鹅蛋脸,元气开朗的笑容,柔和细眉,大大圆眼(星星眼高光),眼神明亮活泼,
身高158cm气质元气可爱活力满满
日式水手服校服,水蓝色领子配白色三本线,白色上衣,百褶裙,白色过膝袜,黑色制服鞋,
主要配色:水蓝+白色+藏青+粉色(#87CEEB水蓝#FFFFFF纯白#1A1A4D藏青#FFB6C1粉
关键配饰:学生书包、粉色兔子挂件、腕带装饰,
大眼睛,高光水润瞳孔,精致五官,动漫头身比例,清晰线稿,干净上色,无多余线条,
最高品质masterpiecebest qualityanime style, cel shading,
character turnaround sheet, multiple views, same character, consistent design,
official art, anime coloring, lineart
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
photorealistic, realistic, 3d render, live action, photo,
complex background, sketch, rough lineart, messy coloring
```
## 推荐参数
- 推荐尺寸864x1152竖版3:4
- 推荐模型NovelAI、Anything V5、Seedream动漫模式

View File

@ -0,0 +1,60 @@
# 示例:现代都市职场男性
## 输入参数
```yaml
gender: 男性
age: 28岁
face_shape: 棱角分明的国字脸
expression: 沉稳自信的微笑
eyebrow_type: 浓黑剑眉
eye_type: 深邃双眼
eye_temperament: 目光锐利有神
hair_style: 利落短发,侧分,深棕色
hair_accessories: 无(简洁商务发型)
height: 183cm
temperament: 气质精英干练,商务精英范
outfit_description: 深藏青色定制三件套西装,白色法式衬衫,深蓝色暗纹领带,棕色牛津皮鞋,白色口袋巾
outfit_colors: 深藏青+白色+棕色+金色点缀(#1A2F4B藏青#FFFFFF纯白#6B4423棕#D4AF37金
key_accessories: 银色机械腕表、袖扣、金丝边眼镜、真皮公文包
style_keywords: 现代都市,摄影级写实质感,时尚大片风格,商业摄影灯光,高清皮肤纹理,真实布料质感
```
## 生成的完整中文提示词
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+服饰配件局部细节放大图,
右侧全身立绘配身高183cm厘米刻度标尺
专业摄影级角色参考板,同一人物多角度展示,形象高度一致,
【真人写实】超写实风格,摄影级质感,影棚灯光,自然光线,质感十足,
8K高清纹理皮肤纹理真实布料褶皱自然商业人像摄影风格杂志大片质感
男性外表28岁利落短发侧分深棕色无发饰
棱角分明的国字脸,沉稳自信的微笑,浓黑剑眉,深邃双眼,目光锐利有神,
身高183cm气质精英干练商务精英范
深藏青色定制三件套西装,白色法式衬衫,深蓝色暗纹领带,棕色牛津皮鞋,白色口袋巾,
主要配色:深藏青+白色+棕色+金色点缀(#1A2F4B藏青#FFFFFF纯白#6B4423棕#D4AF37金
关键配饰:银色机械腕表、袖扣、金丝边眼镜、真皮公文包,
专业妆造,真实材质感,高端时尚摄影,
最高品质细节丰富masterpiecebest quality8Kultra-detailedhyperrealistic
photorealistic, character turnaround sheet, multiple views, same character, consistent design
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
anime, cartoon, 3d render, casual clothing, sportswear, t-shirt,
complex background, shadows on background, overexposed
```
## 推荐参数
- 推荐尺寸864x1152竖版3:4
- 推荐模型Midjourney v6、DALL-E 3、Seedream写实模式

View File

@ -0,0 +1,116 @@
# 国风古风汉服模板
专门用于中国风古风、汉服、仙侠、武侠等中式古典风格角色设定。
## 中文提示词模板
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+发饰耳饰等局部细节放大图,
右侧:全身立绘配身高{{height}}厘米刻度标尺,
专业原画设定稿,同一人物多角度展示,形象高度一致,
【真人写实】超写实国风现代风格写实风格质感光照自然光线质感十足8K高清纹理
布料褶皱自然,艺术写实风格,营造出震撼的视觉效果,电影级光影,
{{gender}},外表{{age}}{{hair_style}}{{hair_accessories}}
{{face_shape}}带{{expression}}{{eyebrow_type}}{{eye_type}}{{eye_temperament}}
身高{{height}}{{temperament}},威压感强烈,
{{outfit_description}}
主要配色:{{outfit_colors}}
关键配饰:{{key_accessories}}
刺绣纹样精致,丝绸质感,古风妆造,传统中式美学,
最高品质细节丰富masterpiecebest quality8Kultra-detailed
character turnaround sheet, multiple views, same character, consistent design,
hanfu, chinese ancient clothing, oriental beauty
```
## 英文提示词模板
```
Character design sheet, turnaround model sheet, pure white background,
top row: three-view bust portraits (front view + side view + back view) arranged horizontally,
left column: facial close-up + color palette swatches with hex codes + hair ornament and jewelry detail callouts,
right column: full body standing portrait with height measurement scale marked in centimeters ({{height}}),
professional Chinese ancient style character reference board, multiple views of the same character, highly consistent design,
photorealistic Chinese ancient style, hanfu fashion, cinematic lighting, natural light,
rich textures, 8K high-definition texture, natural fabric folds, artistic realistic style,
visually stunning, oriental aesthetic,
{{gender}}, appearance of {{age}} years old, {{hair_style}}, {{hair_accessories}},
{{face_shape}} face with {{expression}}, {{eyebrow_type}}, {{eye_type}}, {{eye_temperament}},
height {{height}}, {{temperament}}, strong presence,
{{outfit_description}},
main colors: {{outfit_colors}},
key accessories: {{key_accessories}},
exquisite embroidery patterns, silk texture, traditional Chinese makeup,
masterpiece, best quality, 8K, ultra-detailed, same character, consistent design
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
modern clothing, western clothing, casual wear, complex background, shadows on background
```
## 风格参数选项
### 发型选项
- 黑长发束起,佩戴汉服头饰
- 高盘发髻配金步摇
- 双环髻配丝带飘带
- 单刀半翻髻配玉簪
- 高马尾配红发带(武侠风)
- 披散长发配花环(仙侠风)
### 脸型与五官选项
- 瓜子脸带微笑,柳叶眉杏仁眼,眼神温柔
- 鹅蛋脸面无表情,远山眉丹凤眼,眼神清冷
- 方圆脸带英气,剑眉星目,目光坚毅
- 瓜子脸带轻蔑,柳叶眉桃花眼,眼神傲慢
### 服饰风格选项
- **汉服贵族版**:粉底黑纹长襦裙配精美花饰刺绣,丝绸面料,宽袖流仙裙
- **汉服宫廷版**:明制立领长袄配马面裙,织金面料,凤冠霞帔
- **仙侠修仙版**:白衣广袖流仙裙配轻纱披帛,仙气飘飘,玉佩流苏
- **武侠江湖版**:劲装短打配护腕腰带,利落剪裁,皮革装饰
- **唐风富贵版**:齐胸襦裙配大袖衫,披帛绕臂,牡丹花饰
- **宋制清雅版**:褙子配百迭裙,素雅色调,珍珠妆面
### 气质选项
- 气质温婉贤淑,大家闺秀风范
- 气质霸道清冷,威压感强烈
- 气质仙气飘逸,不食人间烟火
- 气质英气逼人,侠女风范
- 气质雍容华贵,贵妃仪态
- 气质迂腐刻板,老气横秋
## 配色方案参考
### 粉黑经典(参考案例)
- 主色:#F5D0C5 浅粉 / #F2B4B4 樱花粉
- 辅色:#1A1A1A 玄黑
- 点缀:#D4AF37 金色 / #E8C4C4 肉粉
- 过渡:#8B7355 棕褐
### 青花瓷
- 主色:#FFFFFF 月白
- 辅色:#1A4D8F 青花蓝
- 点缀:#D4AF37 描金
### 朱砂墨
- 主色:#1A1A1A 墨黑
- 辅色:#C23A30 朱砂红
- 点缀:#FFD700 金色
### 翠竹青
- 主色:#F0F5E8 月白
- 辅色:#5B8C5A 竹青
- 点缀:#C9A961 芽黄

View File

@ -0,0 +1,74 @@
# 动漫二次元模板
适用于日系动漫、赛璐璐风格、二次元角色设定。
## 中文提示词模板
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+发饰服饰等局部细节放大图,
右侧:全身立绘配身高{{height}}厘米刻度标尺,
专业动画人设稿,同一角色多角度展示,形象高度一致,
【动漫风格】日系动画风格,赛璐璐上色,精致线稿,平涂结合阴影,
明亮通透的色彩,二次元美少女/美少年画风,动画原画级别,
京都动画/Aniplex级别人设质量
{{gender}},外表{{age}}岁,{{hair_style}}{{hair_accessories}}
{{face_shape}}{{expression}}{{eyebrow_type}}{{eye_type}}{{eye_temperament}}
身高{{height}}{{temperament}}
{{outfit_description}}
主要配色:{{outfit_colors}}
关键配饰:{{key_accessories}}
大眼睛,高光水润瞳孔,精致五官,动漫头身比例,
清晰线稿,干净上色,无多余线条,
最高品质masterpiecebest qualityanime style, cel shading,
character turnaround sheet, multiple views, same character, consistent design,
official art, anime coloring, lineart
```
## 英文提示词模板
```
Character design sheet, turnaround model sheet, pure white background,
top row: three-view bust portraits (front view + side view + back view) arranged horizontally,
left column: facial close-up + color palette swatches with hex codes + accessory detail callouts,
right column: full body standing portrait with height measurement scale marked in centimeters ({{height}}),
professional anime character reference sheet, multiple views of the same character, highly consistent design,
Japanese anime style, cel-shading, clean lineart, flat coloring with soft shadows,
bright vibrant colors, 2D animation style, official art quality,
{{gender}}, appearance of {{age}} years old, {{hair_style}}, {{hair_accessories}},
{{face_shape}} face, {{expression}}, {{eyebrow_type}}, {{eye_type}}, {{eye_temperament}},
height {{height}}, {{temperament}},
{{outfit_description}},
main colors: {{outfit_colors}},
key accessories: {{key_accessories}},
large expressive eyes, glossy highlight pupils, delicate anime facial features, idealized proportions,
clean linework, anime coloring,
masterpiece, best quality, anime style, cel shading, official art, same character
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
photorealistic, realistic, 3d render, live action, photo,
complex background, sketch, rough lineart, messy coloring
```
## 风格参数选项
### 动漫风格子分类
- **萌系Q版**:二头身/三头身Q版圆润可爱萌系画风
- **现代日系**:标准七头身,精致美型,京都动画风格
- **复古赛璐璐**90年代动画风格明显阴影边界复古色调
- **赛璐璐厚涂混合**:现代动画电影风格,线稿淡,色彩层次丰富
- **少女漫画风**:大眼睛,花眼,星光点缀,花瓣背景元素
- **少年漫画风**:锐利线条,热血风格,棱角分明

View File

@ -0,0 +1,63 @@
# 基础通用模板
适用于所有风格的通用角色设定板模板。
## 中文提示词模板
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+发饰耳饰等局部细节放大图,
右侧:全身立绘配身高{{height}}厘米刻度标尺,
专业原画设定稿,同一人物多角度展示,形象高度一致,
{{gender}},外表{{age}}{{hair_style}}{{hair_accessories}}
{{face_shape}}{{expression}}{{eyebrow_type}}{{eye_type}}{{eye_temperament}}
身高{{height}}{{temperament}}
{{outfit_description}}
主要配色:{{outfit_colors}}
关键配饰:{{key_accessories}}
{{style_keywords}}{{background}}{{quality_tags}}
布料褶皱自然细节丰富最高品质masterpiecebest quality8Kultra-detailed
character turnaround sheet, multiple views, same character, consistent design
```
## 英文提示词模板
```
Character design sheet, turnaround model sheet, pure white background,
top row: three-view bust portraits (front view + side view + back view) arranged horizontally,
left column: facial close-up + color palette swatches with hex codes + accessory detail callouts,
right column: full body standing portrait with height measurement scale marked in centimeters ({{height}}),
professional character reference board, multiple views of the same character, highly consistent design,
{{gender}}, appearance of {{age}} years old, {{hair_style}}, {{hair_accessories}},
{{face_shape}}, {{expression}}, {{eyebrow_type}}, {{eye_type}}, {{eye_temperament}},
height {{height}}, {{temperament}},
{{outfit_description}},
main colors: {{outfit_colors}},
key accessories: {{key_accessories}},
{{style_keywords}}, {{background}}, {{quality_tags}},
natural fabric folds, rich details, masterpiece, best quality, 8K, ultra-detailed
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs, missing limbs,
bad anatomy, bad hands, missing fingers, extra fingers, watermark, signature, text, logo,
cropped, out of frame, complex background, gradient background, shadows on background
```
## 默认参数值
当用户未提供以下参数时使用默认值:
| 参数 | 默认值 |
|------|--------|
| `{{style_keywords}}` | 超写实风格,质感光照,自然光线 |
| `{{background}}` | 纯白色背景 |
| `{{quality_tags}}` | 最高品质,细节丰富 |

View File

@ -0,0 +1,73 @@
# 西式奇幻模板
适用于中世纪奇幻、精灵/骑士/法师、D&D/魔兽世界风格角色设定。
## 中文提示词模板
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+武器/装备/饰品局部细节放大图,
右侧:全身立绘配身高{{height}}厘米刻度标尺,
专业奇幻角色设定稿,同一人物多角度展示,形象高度一致,
【西式奇幻】魔幻风格,中世纪奇幻设定,厚重油画质感,史诗感,
魔法光效点缀,皮革金属磨损细节,魔兽世界/指环王视觉风格,
DND角色设定标准
{{gender}},外表{{age}}岁,{{hair_style}}{{hair_accessories}}
{{face_shape}}{{expression}}{{eyebrow_type}}{{eye_type}}{{eye_temperament}}
身高{{height}}{{temperament}}
{{outfit_description}}
主要配色:{{outfit_colors}}
关键配饰:{{key_accessories}}
皮革纹理真实,金属做旧磨损,布料厚重质感,
魔法微光,符文雕刻,宝石镶嵌细节,
最高品质细节丰富masterpiecebest quality8Kultra-detailed
fantasy concept art, character turnaround sheet, same character, medieval fantasy
```
## 英文提示词模板
```
Character design sheet, turnaround model sheet, pure white background,
top row: three-view bust portraits (front view + side view + back view) arranged horizontally,
left column: facial close-up + color palette swatches with hex codes + weapon/armor/accessory detail callouts,
right column: full body standing portrait with height measurement scale marked in centimeters ({{height}}),
professional fantasy character reference board, multiple views of the same character, highly consistent design,
high fantasy style, medieval fantasy setting, rich oil painting texture, epic atmosphere,
subtle magical glow effects, weathered leather and metal details,
World of Warcraft / Lord of the Rings visual style, D&D character design standard,
{{gender}}, appearance of {{age}} years old, {{hair_style}}, {{hair_accessories}},
{{face_shape}} face, {{expression}}, {{eyebrow_type}}, {{eye_type}}, {{eye_temperament}},
height {{height}}, {{temperament}},
{{outfit_description}},
main colors: {{outfit_colors}},
key accessories: {{key_accessories}},
realistic leather texture, worn and weathered metal, heavy fabric texture,
magical shimmer, engraved runes, gemstone setting details,
masterpiece, best quality, 8K, ultra-detailed, fantasy concept art, medieval fantasy
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
modern clothing, sci-fi, cyberpunk, futuristic, gun, modern weapon,
complex background, shadows on background
```
## 奇幻职业选项
- **精灵游侠**:尖耳朵,皮革轻甲,长弓,森林绿棕配色,树叶披风
- **人类骑士**:全身板甲,盾牌长剑,家徽纹章,披风,银蓝配色
- **法师/巫师**:长袍法袍,法杖,魔法书,奥术光效,紫金/深蓝配色
- **矮人战士**:矮壮身材,战斧,锻造板甲,胡须编织,红棕铁灰配色
- **盗贼/刺客**:紧身皮甲,匕首兜帽,暗影色调,黑色深灰配色
- **牧师/圣骑**:白金配色,神圣光效,战锤/圣典,教会纹章
- **野蛮人**:皮毛皮革,巨大武器,战纹涂装,肌肉发达,大地色系

View File

@ -0,0 +1,68 @@
# 现代都市模板
适用于现代时装、都市职场、休闲潮流等当代风格角色设定。
## 中文提示词模板
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+服饰配件局部细节放大图,
右侧:全身立绘配身高{{height}}厘米刻度标尺,
专业时尚角色参考板,同一人物多角度展示,形象高度一致,
【现代风格】现代都市风格,时尚街拍质感,高级成衣风格,
写实与时尚插画结合,杂志大片视觉感,潮流穿搭参考,
{{gender}},外表{{age}}岁,{{hair_style}}{{hair_accessories}}
{{face_shape}}{{expression}}{{eyebrow_type}}{{eye_type}}{{eye_temperament}}
身高{{height}}{{temperament}}
{{outfit_description}}
主要配色:{{outfit_colors}}
关键配饰:{{key_accessories}}
面料质感真实,版型剪裁立体,穿搭层次分明,
最高品质细节丰富masterpiecebest quality8Kultra-detailed
fashion illustration, modern style, character turnaround sheet, same character
```
## 英文提示词模板
```
Character design sheet, turnaround model sheet, pure white background,
top row: three-view bust portraits (front view + side view + back view) arranged horizontally,
left column: facial close-up + color palette swatches with hex codes + fabric and accessory detail callouts,
right column: full body standing portrait with height measurement scale marked in centimeters ({{height}}),
professional modern fashion character reference board, multiple views of the same character,
modern urban style, street fashion aesthetic, high-end ready-to-wear,
fashion editorial look, trendy outfit reference,
{{gender}}, appearance of {{age}} years old, {{hair_style}}, {{hair_accessories}},
{{face_shape}} face, {{expression}}, {{eyebrow_type}}, {{eye_type}}, {{eye_temperament}},
height {{height}}, {{temperament}},
{{outfit_description}},
main colors: {{outfit_colors}},
key accessories: {{key_accessories}},
realistic fabric textures, structured tailoring, layered styling,
masterpiece, best quality, 8K, ultra-detailed, fashion illustration, same character
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
historical clothing, armor, hanfu, kimono, medieval clothing,
complex background, shadows on background
```
## 穿搭风格选项
- **职场精英**:剪裁合体西装,衬衫领带/丝巾,干练皮鞋
- **休闲街头**oversize卫衣牛仔裤运动鞋棒球帽
- **高街潮流**:设计师品牌,潮牌单品,层次感叠穿
- **学院风**:针织衫,格纹裙/百褶裙,乐福鞋,学院徽章
- **轻熟优雅**:针织连衣裙,小香风外套,低跟鞋,珍珠配饰
- **运动风**:运动套装,跑鞋,发带,运动手表

View File

@ -0,0 +1,76 @@
# 真人写实模板
适用于摄影级写实风格、现代真人、肖像级角色设定。
## 中文提示词模板
```
人物角色设定板,三视图设定稿,纯白色背景,
上方:正面半身+侧面半身+背面半身三个视角水平排列,
左侧:面部大特写+配色色板附色值标注+服饰配件局部细节放大图,
右侧:全身立绘配身高{{height}}厘米刻度标尺,
专业摄影级角色参考板,同一人物多角度展示,形象高度一致,
【真人写实】超写实风格,摄影级质感,影棚灯光,自然光线,质感十足,
8K高清纹理皮肤纹理真实布料褶皱自然毛孔细节可见
商业人像摄影风格,杂志大片质感,
{{gender}},外表{{age}}岁,{{hair_style}}{{hair_accessories}}
{{face_shape}}{{expression}}{{eyebrow_type}}{{eye_type}}{{eye_temperament}}
身高{{height}}{{temperament}}
{{outfit_description}}
主要配色:{{outfit_colors}}
关键配饰:{{key_accessories}}
专业妆造,真实材质感,高端时尚摄影,
最高品质细节丰富masterpiecebest quality8Kultra-detailedhyperrealistic
photorealistic, character turnaround sheet, multiple views, same character, consistent design
```
## 英文提示词模板
```
Character design sheet, turnaround model sheet, pure white studio background,
top row: three-view bust portraits (front view + side view + back view) arranged horizontally,
left column: facial close-up + color palette swatches with hex codes + fabric and accessory detail callouts,
right column: full body standing portrait with height measurement scale marked in centimeters ({{height}}),
professional photorealistic character reference board, multiple views of the same character, highly consistent design,
hyperrealistic photography, studio lighting, natural light, rich textures,
8K high-definition, realistic skin texture with visible pores, natural fabric wrinkles,
commercial portrait photography, fashion editorial quality,
{{gender}}, appearance of {{age}} years old, {{hair_style}}, {{hair_accessories}},
{{face_shape}} face, {{expression}}, {{eyebrow_type}}, {{eye_type}}, {{eye_temperament}},
height {{height}}, {{temperament}},
{{outfit_description}},
main colors: {{outfit_colors}},
key accessories: {{key_accessories}},
professional makeup, realistic material textures, high-end fashion photography,
masterpiece, best quality, 8K, ultra-detailed, hyperrealistic, photorealistic, same character
```
## 负面提示词
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, watermark, signature, text, logo,
anime, cartoon, 3d render, cgi, painting, illustration, drawing, anime style,
complex background, shadows on background, overexposed
```
## 风格参数选项
### 摄影灯光选项
- 影棚柔光箱灯光,均匀布光,商业人像标准
- 自然窗光,柔和侧光,文艺肖像风格
- 硬光摄影,高对比度,时尚杂志风格
- 逆光轮廓光,发丝光,电影感肖像
### 妆造风格选项
- 自然裸妆,清透底妆,日常感
- 精致浓妆,烟熏眼妆,晚宴风格
- 复古港风妆红唇卷发90年代风格
- 韩系清透妆,水光肌,偶像风格

View File

@ -0,0 +1,64 @@
# 人物三视图/四视图/角色版 · 系统学习笔记2026-08-01
> 来源GitHub character-sheet-generator开源技能、GPT-Image-2 五模块法CSDN piaoxue166
> NanoBanana/即梦/高定 art 教程、AI漫剧角色一致性系列文章
## 一、人物三视图front/side/back
**定义**同一角色正面、90°侧面、背面三视角角色设计标准配置。
**标准 prompt 结构**
```
character design sheet / turnaround model sheet, pure white background,
三视图平行排开front / side profile / backfull body 或 半身 bust
同一角色,形象高度一致,无崩
```
**关键**:三面要一致(脸型/发型/服饰/比例量化描述8头身/172cm+服饰分层(领+纹+饰)。
## 二、人物四视图front/back/side/face close-up
**定义**:正面 + 背面 + 侧面 + 脸部特写(爸爸定的标准;社区头条文章:"肩部以上的正面特写 + 全身三视图")。
**标准 prompt**(社区主流"主视觉+三视图"
```
character sheet, 16:9, 纯白背景, 平光无阴影,
左侧 1/3面部大特写发丝级细节
右侧 2/3并排正面/侧面/背面全身站姿,
同一角色8头身/172cm服饰褶皱/配饰位置跨视角完全一致
```
**替代布局**3D 展示台(上三视图 + 下细节特写组——面部/面料/配饰)。
## 三、人物角色版 Character Sheet开源技能标准四区布局
```
┌───────────────────────────────────────────┐
│ [主视区] [侧视区] [背视区] ← 上方三视图半身 │
├──────────────┬────────────────────────────┤
│ [面部特写] │ │
│ [配色色板] │ [全身比例照] │
│ 附色值 │ 身高标尺(cm) │
│ [局部细节区] │ │
│ 配饰/纹样放大│ │
└──────────────┴────────────────────────────┘
```
**完整变量体系**开源技能gender/age/face_shape/expression/eyebrow_type/eye_type/eye_temperament/hair_style/hair_accessories/height/temperament/outfit_description/outfit_colors/key_accessories/style_keywords/background/quality_tags
**7 风格模板**base(通用) / ancient_chinese(国风汉服) / realistic(写实) / anime(日系动漫) / modern(都市) / sci-fi(科幻) / fantasy(西式奇幻)
**推荐尺寸**:竖版 3:4 或 2:3864x1152 / 1024x1536
**负面提示词(社区标准)**
```
(worst quality, low quality:1.4), deformed, mutated, ugly, disfigured, blurry,
multiple characters, different outfits, inconsistent design, extra limbs,
bad anatomy, bad hands, missing fingers, watermark, signature, text, logo
```
## 四、用法(怎么用)
1. **选模板**按角色风格选动漫→anime.md古风→ancient_chinese.md
2. **填变量**{{变量}} 替换为角色设定(性别/年龄/脸型/发型/服饰分层/配饰)
3. **生成**:输入绘图工具(咱=Z-Image 本地)
4. **迭代**:生成 2-4 张挑一致性最好的;做动画/漫剧时用设定板锁角色身份
## 五、一致性铁律(学到的最核心)
1. 量化身材8头身/172cm+ 服饰分层描述(领+纹+饰)
2. 重复"同一角色/完全一致"关键词
3. 平光无阴影(减少干扰)
4. 相似 seed 系列锁风格
5. 设定板 = 角色资产库,后续分镜/动画统一引用LibTV 式"资产库+多视图锁定"

View File

@ -0,0 +1,89 @@
# 社区技巧库 · AI漫剧/角色一致性2026-08-01 网络搜集)
> 来源知乎《100天AI漫剧出海》系列、CSDN 漫剧攻略、搜狐角色一致性解析、
> NanoBanana 三视图教程、头条分镜教程、B站漫剧教程等 10+ 篇
## 一、角色一致性行业第一大痛点76% 创作者)
**核心观点**:一致性不是靠单一提示词锁死的,是"资产库 + 多视图 + 参数固定"的组合拳。
1. **资产库锁定**:先定妆(三视图/多视图)→ 存为资产 → 后续所有分镜统一引用
2. **参数固定**seed、模型、负面词、风格描述全局一致我们 Agent 已做 seed=1000+i*17
3. **提示词锁定**:角色特征写成固定模板(发型/发色/发饰/服装/配饰/鞋/体型)插入每个镜头
4. **三视图的真正价值**:能被后续分镜环节**有效复用**(不是画得好看,是"标准统一可复用"
## 二、三视图提示词 · 社区标准模板GPT-Image-2 / NanoBanana / 高定 / 即梦)
把提示词拆成 5 个模块,缺一不可:
1. **画面类型**character design sheet / character sheet / 设定图 / 角色设计
2. **排版结构****核心:左 1/3 脸部特写 + 右 2/3 三全身(社区严格比例)**
3. **人物本体**:年龄/性别/身材量化(**8头身 / 160cm**+ 姿态A-pose
4. **造型细节**:发型/发色/发饰/服装/配饰/鞋 + **服饰分层描述**(如"交领长袍+暗纹云绣+玉带吊坠"
5. **光影背景**:白底 + **平光无阴影** + **masterpiece, best quality, 8K超清**
**标准模板(社区主流,可直接复制)**
```
8K超清, masterpiece, best quality, 16:9横版, 纯白背景, 平光无阴影, character sheet, masterpiece, best quality,
画面布局左侧1/3为面部特写发丝级细节右侧2/3并排正面、侧面、背面全身站姿视图
角色描述:【性别/年龄/身材量化】,【五官/发型/发色+量化五官细节】,【服饰分层+配饰位置】,
一致性要求:同一角色,**8头身 / 172cm**,身高/肩宽/体态统一,服饰褶皱/配饰位置跨视角完全一致,无崩
负面提示词nsfw, lowres, bad anatomy, text, error, missing fingers, blurry, distorted
```
**视图指令关键**:英文关键词 `character sheet, front/side/back view, full body` 强制布局
**一致性 4 大技巧**
1. 重复"同一角色""完全一致"关键词
2. **量化身材**8头身/172cm
3. **服饰分层描述**(领+纹+饰 三个层次描述)
4. 统一 seed 系列锁风格
**避坑要点**
- 单图"四等分 panels"在 Z-Image 下不稳定(画 5-7 个),**严格 left 1/3 + right 2/3 比例**控制力最强
- 避免"可爱"等模糊词,用"圆杏眼+齐刘海"客观细节
- 平光照明减少阴影干扰(特写必备)
- 3D 建模需加 "orthographic view"(正交视图)
- cfg=1 下否定词部分失效,但元素词/比例词有效
## 三、场景四视图(工业级方法论 · 2026-08-01 系统学习)
**核心认知**:一张场景图只是"固定角度/光线/景别的截面",让模型脑补其他角度只能猜。
→ 场景四视图需要**空间逻辑先行 + 多角度资产**,不能靠"单图+文字"。
**四视角标准**(动画场景设计语义):①正面外观 ②背面外观 ③室内视角 ④鸟瞰俯视
**工业级 5 招(社区共识)**
1. **俯视图锁定空间逻辑**(核心):先生成俯视/平面图定义布局→所有视角共享空间基准
2. **九宫格多角度**:一张图 9 个角度
3. **720° 全景图自由取景**:宽幅全景→切段(空间逻辑天然一致)
4. **360° 环绕截图**
5. **参考图控图**最强Seedance/即梦/ComfyUI IPAdapter+ControlNet 基于俯视基准图生成各视角
**prompt 要点**
- 俯视图:`top-down orthographic floor plan, bird eye view, building layout, rooms, entrances, clean line`
- 立面:`front/back exterior elevation view, architectural facade, straight-on`
- 室内:`interior view, room perspective, walls floor ceiling furniture`
- 一致性词:`consistent with the floor plan layout, same spatial logic`
- 负面词:`characters, people, furniture clutter, inconsistent layout`
**咱的落地**Z-Image 无参考图机制→单图 4 视角会自由发挥;
务实用"宽幅全景→切段"(空间逻辑一致)或进阶 ComfyUI SDXL+ControlNet-Union
## 四、AI 漫剧五步工业化流水线CSDN 权威版)
```
剧本生成 → 视觉素材制作(角色/场景资产) → 动态化生成(图生视频) → 音频合成(配音/BGM) → 后期精修
```
对照咱苍耳管线:✅ 全对齐剧本→分镜→资产→出图→LTX/Wan→拼接→声画装配
## 五、ComfyUI 一致性实战
- IPAdapter = "图像提示词":告诉 AI 照着参考图画(身份/风格保持)
- 组合拳ControlNet(构图) + IPAdapter(身份) + 固定 seed
- 注意IPAdapter 分 SDXL/SD1.5 版本,**必须匹配底模架构**(咱 Z-Image 是 Kolors 系SDXL 版 IPAdapter 不兼容)
## 六、分镜技巧(留存率骨架)
- 分镜是"骨架",决定观众留存率
- 七大要素:景别 / 运镜 / 视角 / 光影 / 构图 / 人物动作 / 环境动态
- 提示词公式:镜头语言 + 主体 + 场景 + 光影 + 情绪
## 七、咱的落地清单(已做/待做)
- ✅ Z-Image 三视图直出(五模块结构化 prompt 已内置)
- ✅ 场景四视图2×2 网格:前/左/右/俯视)
- ⏳ 面部特写+三视图布局16:9 游戏立绘风)— 可加
- ⏳ 资产库规范化(三视图→分镜统一引用)— 结合 skill 系统

126
skill/index.json Normal file
View File

@ -0,0 +1,126 @@
{
"version": "1.0",
"skills": [
{
"id": "screenplay",
"name": "🎭 编剧大师",
"type": "prompt",
"desc": "剧本创作方法论4格式+8步工作流+5概念组合+5结构",
"prompt_file": "../memory/eed/screenplay_skill.md",
"usage": "注入编剧大师方法论,用「输入创意→大师流程→成稿」方式创作剧本"
},
{
"id": "storyboard",
"name": "🎬 分镜生成",
"type": "tool",
"desc": "剧本→分镜故事板含5色标注法",
"prompt_file": "../memory/eed/storyboard_自动化.md",
"tool": "python3 ../video-ai-system/tools/run_storyboard.py",
"args_schema": {
"script": "剧本文本或文件路径"
}
},
{
"id": "style_transfer",
"name": "🎨 风格转绘",
"type": "prompt",
"desc": "2D⇄真人双向转绘提示词骨架+10种风格库",
"prompt_file": "../memory/eed/style_transfer_skill.md",
"usage": "注入风格转绘骨架,描述原图+目标风格→生成转绘提示词"
},
{
"id": "ltx_video",
"name": "🎥 LTX 出片",
"type": "tool",
"desc": "文本/图片→LTX视频GGUF Q4本地推理",
"prompt_file": "../memory/eed/ltx_prompt_skill.md",
"tool": "python3 ../video-ai-system/tools/run_ltx_i2v.py",
"args_schema": {
"prompt": "视频提示词",
"image": "起始图路径(可选)"
}
},
{
"id": "zimage",
"name": "🖼️ Z-Image 生图",
"type": "tool",
"desc": "文本→Z-Image高清图本地ComfyUI",
"prompt_file": null,
"tool": "python3 ../video-ai-system/tools/generate_assets_local.py",
"args_schema": {
"prompt": "生图提示词"
}
},
{
"id": "compose",
"name": "✂️ 拼接成片",
"type": "tool",
"desc": "多段视频拼接+成片FFmpeg",
"prompt_file": null,
"tool": "python3 ../video-ai-system/tools/video_composer.py",
"args_schema": {
"input": "视频目录",
"output": "输出路径"
}
},
{
"id": "character_turnaround",
"name": "👤 人物三视图/场景四视图",
"type": "tool",
"desc": "Z-Image人物三视图(front/side/back)+特写 / 场景四视图(正面/背面/室内/鸟瞰)",
"prompt_file": null,
"tool": "python3 ../video-ai-system/tools/character_turnaround.py",
"args_schema": {
"type": "char(人物三视图)|scene(场景四视图)",
"desc": "角色/场景描述",
"views": "3或4",
"output": "输出路径(可选)"
}
},
{
"id": "audio_voice",
"name": "🎙 Edge-TTS 配音",
"type": "tool",
"desc": "分镜 dialogue → Edge-TTS 多角色配音(微软免费)",
"prompt_file": null,
"tool": "python3 ../video-ai-system/tools/audio_pipeline.py",
"args_schema": {
"action": "voice",
"sb": "分镜JSON",
"out": "输出目录"
}
},
{
"id": "audio_mix",
"name": "🎛 声画装配",
"type": "tool",
"desc": "配音+字幕+BGM+混音成片Agent stage ⑥)",
"prompt_file": null,
"tool": "python3 ../video-ai-system/tools/audio_pipeline.py",
"args_schema": {
"action": "mix/srt/bgm",
"video": "成片",
"voice_dir": "配音目录",
"bgm": "BGM文件",
"out": "输出"
}
},
{
"id": "community_techniques",
"name": "📚 社区技巧库",
"type": "prompt",
"desc": "AI漫剧/角色一致性/三视图/场景四视图/分镜 社区实战技巧+角色版学习笔记",
"prompt_file": "community_techniques.md",
"prompt_file_alt": "community_techniques.md",
"usage": "注入社区技巧库,按需套用(角色锁定/三视图布局/场景四视图/分镜公式)"
},
{
"id": "character_sheet",
"name": "📋 人物角色设定板(开源技能)",
"type": "prompt",
"desc": "社区开源 character-sheet-generator角色三视图设定板生成技能7风格模板+变量体系+四区布局规范)",
"prompt_file": "character-sheet-generator/SKILL.md",
"usage": "注入开源角色设定板技能,选风格模板(base/ancient/realistic/anime/modern/sci-fi/fantasy),填变量生成标准角色设定板"
}
]
}

View File

@ -0,0 +1,37 @@
# 场景四视图 · 系统学习笔记2026-08-01
> 来源头条《一个提示词生成四视图》、B站《AI视频场景一致性完整工作流》、
> 抖音《场景穿帮 5 招工业级解决方案》、gpt88 场景一致性专题、知乎 Day40
## 一、核心认知(为什么我之前失败)
**根本限制**(头条那篇点破):一张场景图只是"固定角度、固定光线、固定景别的截面"。
让模型脑补"从侧面看、从空中看、从门口往里看",它没有那个信息,**只能猜**。
→ 场景四视图不是"单图 + 文字描述"能解决的,需要**空间逻辑先行 + 多角度资产**。
## 二、工业级 5 招(社区共识)
1. **俯视图锁定空间逻辑**(核心):先生成俯视图/平面图,定义建筑布局、房间关系、出入口——所有视角共享的"空间基准"
2. **九宫格多角度**:一张图内排 9 个角度的场景视图(前/后/左/右/45°×4 + 俯视)
3. **720° 全景图自由取景**:生成宽幅全景 → 任意切段作为不同机位的背景
4. **360° 环绕**:环绕视频/多帧截图 → 取不同帧作为视角
5. **参考图控图**用支持参考图的模型Seedance/即梦/ComfyUI IPAdapter+ControlNet基于"俯视基准图"生成各视角——**最强的做法**
## 三、场景四视图 = 俯视图锁空间 + 各视角生成(标准流程)
```
第1步俯视图/平面图(锁定空间逻辑、布局、出入口)→ 基准图
第2步基于基准图逐视角生成正面外观 / 背面外观 / 室内 / 鸟瞰
第3步参考图控图基准图作参考保证每个视角与空间逻辑一致
```
(对应爸爸之前定的"正面/背面/室内/鸟瞰"四视角 ✅)
## 四、prompt 要点
- **俯视图 prompt**`top-down orthographic floor plan, bird eye view, showing building layout, rooms, entrances, furniture arrangement, clean line, blue print style`
- **正面/背面**`front/back exterior elevation view, architectural facade, straight-on view`
- **室内**`interior view, room perspective, showing walls floor ceiling and furniture`
- **关键**:强调 "consistent with the floor plan layout, same spatial logic"
- **负面词**`characters, people, furniture clutter, inconsistent layout`
## 五、咱的落地方向
- **文生图Z-Image极限**:单图 4 视角(前/背/室内/鸟瞰Z-Image 会自由发挥(无参考图机制)
- **务实方案 A**:俯视图基准 → 逐视角生成(统一描述+相似seed效果一般
- **务实方案 B推荐**:宽幅全景图 → PIL 切段(空间逻辑天然一致,工业级"全景自由取景"
- **进阶方案 C**ComfyUI SDXL + ControlNet-Union用俯视基准图作控制条件——彻底锁定

76
skill/skill_center.py Normal file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""苍耳技能中心:技能注册表 + 提示词加载 + 工具执行
用法python3 skill_center.py [list|load <id>|run <id> [args...]]"""
import json, os, subprocess, sys
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
INDEX = os.path.join(SKILL_DIR, "index.json")
def _index():
with open(INDEX, encoding="utf-8") as f:
return json.load(f)
def list_skills():
return _index().get("skills", [])
def load(skill_id):
"""返回技能定义 + 提示词全文prompt_file 存在时)。"""
for s in list_skills():
if s["id"] == skill_id:
data = dict(s)
pf = data.get("prompt_file")
if pf:
p = os.path.normpath(os.path.join(SKILL_DIR, pf))
try:
with open(p, encoding="utf-8") as f:
data["prompt"] = f.read()
except Exception as e:
data["prompt"] = ""
data["prompt_error"] = str(e)
return data
return None
def run(skill_id, args=None):
"""执行工具型技能,返回 (ok, output)。"""
s = load(skill_id)
if not s:
return False, "技能不存在: " + skill_id
if s.get("type") != "tool":
return False, f"技能「{s.get('name','')}」是提示词型,请注入提示词使用"
tool = s.get("tool")
if not tool:
return False, "技能无执行命令"
cmd = tool.split() + (args or [])
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
return r.returncode == 0, (r.stdout or r.stderr)[-3000:]
except subprocess.TimeoutExpired:
return False, "执行超时10分钟"
except Exception as e:
return False, str(e)
if __name__ == "__main__":
if len(sys.argv) < 2:
print(json.dumps(list_skills(), ensure_ascii=False, indent=2)); sys.exit(0)
cmd = sys.argv[1]
if cmd == "list":
print(json.dumps({"skills": list_skills()}, ensure_ascii=False, indent=2)); sys.exit(0)
elif cmd == "load":
s = load(sys.argv[2]) if len(sys.argv) > 2 else None
if s:
print(json.dumps({"name": s.get("name"), "type": s.get("type"),
"prompt_len": len(s.get("prompt", "")),
"tool": s.get("tool")}, ensure_ascii=False))
else:
print("技能不存在"); sys.exit(1)
elif cmd == "run":
ok, out = run(sys.argv[2], sys.argv[3:])
print(("OK\n" if ok else "FAIL\n") + out)
sys.exit(0 if ok else 1)
else:
print("未知命令: " + cmd); sys.exit(1)

View File

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
agent_short_drama.py 苍耳 · 一键短剧 Agent
剧本 分镜(豆包) 路由(豆包) 逐镜出图(本地Z-Image)
逐镜视频(LTX I2V, 本地) 拼接(FFmpeg) 成片
用法:
python agent_short_drama.py <剧本> -e 1 --until render # 只到出图
python agent_short_drama.py <剧本> -e 1 --until video # 到视频
python agent_short_drama.py <剧本> -e 1 --until compose # 到成片
python agent_short_drama.py <分镜.json> --from render # 从出图续跑
python agent_short_drama.py <manifest> --from video # 从视频续跑
"""
import os, sys, json, time, subprocess, urllib.request, argparse, glob, shutil
COMFY = "http://127.0.0.1:8188"
VIDEO_AI = os.path.dirname(os.path.abspath(__file__))
COMFY_OUT = os.path.expanduser("~/comfy/ComfyUI/output")
COMFY_IN = os.path.expanduser("~/comfy/ComfyUI/input")
LTX_NEG = ("static, frozen, no movement, flickering, jittery, choppy motion, morphing, "
"deformed, twisted, contorted, distorted, blurry, low resolution, extra limbs, "
"unnatural body, ugly, bad anatomy, disfigured, malformed, warped spine")
# ---------- 工具 ----------
def run_script(script, args):
cmd = [sys.executable, os.path.join(VIDEO_AI, script)] + args
print(" $", " ".join(cmd))
r = subprocess.run(cmd)
if r.returncode != 0:
raise RuntimeError(f"{script} 退出码 {r.returncode}")
def comfy_submit(prompt):
req = urllib.request.Request(f"{COMFY}/prompt",
data=json.dumps({"prompt": prompt}).encode(),
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req, timeout=15))["prompt_id"]
def comfy_wait(pid, timeout=900):
t0 = time.time()
while time.time() - t0 < timeout:
time.sleep(3)
try:
h = json.load(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8))
except Exception:
continue
if pid in h and h[pid].get("outputs"):
return h[pid]["outputs"]
raise TimeoutError(f"ComfyUI 任务 {pid} 超时")
def wait_file(path, timeout=60):
t0 = time.time()
while time.time() - t0 < timeout:
if os.path.isfile(path) and os.path.getsize(path) > 0:
return True
time.sleep(2)
return False
# ---------- Z-Image 出图 ----------
def zimage_workflow(prompt, seed, prefix, width=1024, height=1024):
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}},
"5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}},
"7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"8": {"class_type": "KSampler", "inputs": {"model": ["7", 0], "seed": seed, "steps": 8, "cfg": 1.0,
"sampler_name": "res_multistep", "scheduler": "simple",
"positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
# ---------- LTX I2V 出视频 ----------
def ltx_i2v_workflow(image_name, prompt, seed, prefix, frames=49, width=768, height=512):
return {
"1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": "ltx-2.3-22b-distilled-1.1-Q4_K_M.gguf"}},
"2": {"class_type": "LTXAVTextEncoderLoader", "inputs": {"text_encoder": "gemma_3_12B_it.safetensors", "ckpt_name": "ltx-2.3-22b-embconn.safetensors", "device": "cpu"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": LTX_NEG}},
"5": {"class_type": "LTXVConditioning", "inputs": {"positive": ["3", 0], "negative": ["4", 0], "frame_rate": 24.0}},
"6": {"class_type": "LoadImage", "inputs": {"image": image_name}},
"7": {"class_type": "VAELoader", "inputs": {"vae_name": "LTX23_video_vae_bf16.safetensors"}},
"8": {"class_type": "LTXVImgToVideo", "inputs": {"positive": ["5", 0], "negative": ["5", 1], "vae": ["7", 0], "image": ["6", 0], "width": width, "height": height, "length": frames, "batch_size": 1, "strength": 1.0}},
"9": {"class_type": "RandomNoise", "inputs": {"noise_seed": seed}},
"10": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler_ancestral_cfg_pp"}},
"11": {"class_type": "CFGGuider", "inputs": {"model": ["1", 0], "positive": ["8", 0], "negative": ["8", 1], "cfg": 1.0}},
"12": {"class_type": "ManualSigmas", "inputs": {"sigmas": "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0"}},
"13": {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["9", 0], "guider": ["11", 0], "sampler": ["10", 0], "sigmas": ["12", 0], "latent_image": ["8", 2]}},
"14": {"class_type": "LTXVTiledVAEDecode", "inputs": {"vae": ["7", 0], "latents": ["13", 0], "horizontal_tiles": 2, "vertical_tiles": 2, "overlap": 6, "last_frame_fix": False, "working_device": "cpu", "working_dtype": "float16"}},
"15": {"class_type": "CreateVideo", "inputs": {"images": ["14", 0], "fps": 24.0}},
"16": {"class_type": "SaveVideo", "inputs": {"video": ["15", 0], "filename_prefix": prefix, "format": "mp4", "codec": "h264"}},
}
def shot_to_prompt(shot, style=""):
parts = []
cam = shot.get('camera', '中景')
scenes = shot.get('scenes', [])
chars = shot.get('characters', [])
desc = shot.get('description', '')
if cam: parts.append(cam)
if scenes: parts.append("in " + scenes[0])
if chars: parts.append("with " + ", ".join(chars[:3]))
if desc: parts.append(desc[:120])
if style: parts.append(style)
return ", ".join(parts)
def shot_to_ltx_prompt(desc, camera=""):
"""分镜 → LTX I2V 运动提示词(四段式简化:起势→动作→环境→收尾)"""
base = (desc or "").strip()
action = base if base else "the subject moves naturally"
parts = [
f"Opening on the first frame scene, {camera or 'medium shot'}. The subject {action}, "
"with gentle flowing motion, grass and light drifting softly around. "
"Camera holds steady, shallow depth of field, warm natural light. "
"Slow, fluid, seamless motion, high detail. "
"Ends with the subject settling into a calm final pose."
]
return " ".join(parts)
# ---------- 各阶段 ----------
def stage_storyboard(script, episode, pro=False):
args = [script, "-e", str(episode)]
if pro: args.append("--pro")
run_script("tools/run_storyboard.py", args)
d = os.path.dirname(os.path.abspath(script))
m = glob.glob(os.path.join(d, "STORYBOARD*.json"))
if not m:
raise RuntimeError("未找到分镜 JSON")
return m[0]
def stage_route(sb_json):
run_script("tools/route_shots.py", [sb_json])
return sb_json
def stage_render(sb_json, out_dir=None, style="", max_shots=0):
sb = json.load(open(sb_json, encoding="utf-8"))
shots = sb.get("shots", [])
if max_shots: shots = shots[:max_shots]
if not shots: raise RuntimeError("分镜无 shots")
if not out_dir:
out_dir = os.path.join(os.path.dirname(os.path.abspath(sb_json)), "renders")
os.makedirs(out_dir, exist_ok=True)
manifest = os.path.join(out_dir, "shot_manifest.json")
entries = []
for i, sh in enumerate(shots):
p = shot_to_prompt(sh, style)
seed = 1000 + i * 17
prefix = f"eed_shot_{i+1:04d}"
pid = comfy_submit(zimage_workflow(p, seed, prefix))
print(f" 🎬 [{i+1}/{len(shots)}] {sh.get('shot_number', f'S{i+1:02d}')}: {p[:50]}...")
comfy_wait(pid)
# 等文件落盘
img = os.path.join(COMFY_OUT, f"{prefix}_00001_.png")
wait_file(img)
entries.append({"shot": sh.get("shot_number", f"S{i+1:02d}"), "prompt": p, "ltx_desc": sh.get("description",""),
"camera": sh.get("camera",""), "seed": seed, "prefix": prefix})
with open(manifest, "w", encoding="utf-8") as f:
json.dump({"out_dir": out_dir, "shots": entries}, f, ensure_ascii=False, indent=2)
print(f" 📦 出图清单: {manifest}")
return manifest
def stage_video(manifest, frames=49, max_shots=0):
data = json.load(open(manifest, encoding="utf-8"))
entries = data["shots"]
if max_shots: entries = entries[:max_shots]
vdir = os.path.join(data["out_dir"], "videos")
os.makedirs(vdir, exist_ok=True)
vlist = []
for i, e in enumerate(entries):
src = os.path.join(COMFY_OUT, f"{e['prefix']}_00001_.png")
if not os.path.isfile(src):
print(f" ⚠️ 缺起始图 {src},跳过 {e['shot']}"); continue
iname = f"agent_shot_{i+1:04d}.png"
shutil.copy(src, os.path.join(COMFY_IN, iname))
prompt = shot_to_ltx_prompt(e.get("ltx_desc", ""), e.get("camera", ""))
seed = 5000 + i * 29
prefix = f"eed_vid_{i+1:04d}"
pid = comfy_submit(ltx_i2v_workflow(iname, prompt, seed, prefix, frames=frames))
print(f" 🎥 [{i+1}/{len(entries)}] {e['shot']} LTX I2V {frames}帧 提交,预计 {frames*4}s...")
comfy_wait(pid, timeout=900)
vout = os.path.join(COMFY_OUT, f"{prefix}_00001_.mp4")
if not wait_file(vout, timeout=60):
print(f" ⚠️ 视频未落盘 {vout}")
continue
dest = os.path.join(vdir, f"shot_{i+1:04d}.mp4")
shutil.copy(vout, dest)
vlist.append({"shot": e["shot"], "file": dest, "prompt": prompt})
print(f"{e['shot']}{dest}")
vman = os.path.join(vdir, "video_manifest.json")
with open(vman, "w", encoding="utf-8") as f:
json.dump({"video_dir": vdir, "videos": vlist}, f, ensure_ascii=False, indent=2)
print(f" 📦 视频清单: {vman}")
return vman
def stage_compose(vman_or_dir, output="", title="", credits=""):
if os.path.isfile(vman_or_dir) and vman_or_dir.endswith("video_manifest.json"):
d = json.load(open(vman_or_dir, encoding="utf-8"))
folder = d["video_dir"]
else:
folder = vman_or_dir
vids = sorted(glob.glob(os.path.join(folder, "shot_*.mp4")))
if not vids:
raise RuntimeError(f"没有可拼接的视频片段: {folder}")
if not output:
output = os.path.join(os.path.dirname(folder), "EP01.mp4")
if len(vids) == 1 and not title and not credits:
# 单段且无片头片尾直接拷贝LTX 视频无声video_composer 方案B 会因缺音频流失败)
shutil.copy(vids[0], output)
print(f" 🎬 单段成片(直接拷贝): {output}")
return output
args = [folder, "-o", output]
if title: args += ["--title", title]
if credits: args += ["--credits", credits]
run_script("tools/video_composer.py", args)
print(f" 🎬 成片: {output}")
return output
def _video_duration(path):
try:
r = subprocess.run(["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", path], capture_output=True, text=True)
return float(r.stdout.strip() or 0)
except Exception:
return 0
def stage_audio_pipeline(sb_json, composed, ep_dir):
"""⑥ 声画装配:配音 → 字幕 → BGM → 混音 → 有声终成片"""
from tools import audio_pipeline as ap
print(" 🎙 配音 (Edge-TTS)...")
voice = ap.generate_voice(sb_json, ep_dir)
print(" 📝 字幕...")
ap.generate_srt(sb_json, ep_dir, voice)
dur = _video_duration(composed) or 20
print(f" 🎵 BGM ({dur:.0f}s)...")
ap.generate_bgm(ep_dir, int(dur) + 2)
final = os.path.join(ep_dir, "FINAL.mp4")
print(" 🎛 混音...")
ap.mix_final(composed, os.path.join(ep_dir, "voice"),
os.path.join(ep_dir, "bgm.mp3"), final)
print(f" 🎉 有声终成片: {final}")
return final
def main():
ap = argparse.ArgumentParser(description="苍耳 · 一键短剧 Agent")
ap.add_argument("input", help="剧本 / 分镜JSON(--from render,route) / 清单(--from video,compose)")
ap.add_argument("-e", "--episode", type=int, default=1)
ap.add_argument("--pro", action="store_true", help="分镜用Pro模型")
ap.add_argument("--style", default="", help="全局风格")
ap.add_argument("--until", choices=["storyboard","route","render","video","compose","audio"], default="render")
ap.add_argument("--from", dest="from_stage", choices=["route","render","video","compose","audio"])
ap.add_argument("--max-shots", type=int, default=0, help="只跑前 N 镜")
ap.add_argument("--frames", type=int, default=49, help="LTX 视频帧数(默认49)")
ap.add_argument("-o", "--output", default="", help="成片路径")
args = ap.parse_args()
sb_json = None
if args.from_stage in ("video", "compose"):
manifest = args.input
print(f"📖 从清单续跑: {manifest}")
if args.from_stage == "video":
vman = stage_video(manifest, frames=args.frames, max_shots=args.max_shots)
if args.from_stage == "compose":
out = stage_compose(manifest, args.output)
print("🎯 成片完成:", out); return
if args.from_stage == "audio":
out = stage_compose(manifest, args.output)
sb = glob.glob(os.path.join(os.path.dirname(os.path.dirname(out)), "STORYBOARD*.json"))
if sb:
ep_dir = os.path.dirname(os.path.abspath(out))
final = stage_audio_pipeline(sb[0], out, ep_dir)
print("🎯 有声终成片:", final)
return
print("🎯 阶段完成。续跑: --from compose")
return
if args.from_stage:
sb_json = args.input
if not os.path.isfile(sb_json):
print(f"❌ 找不到分镜: {sb_json}"); sys.exit(1)
print(f"📖 从分镜续跑: {sb_json}")
else:
if not os.path.isfile(args.input):
print(f"❌ 找不到剧本: {args.input}"); sys.exit(1)
print(f"🚀 苍耳 · 一键短剧 Agent | 剧本: {args.input} | EP{args.episode:02d} | 到: {args.until}")
print("" * 44); print("① 剧本→分镜 (豆包, ¥0.01/集 级)"); print("" * 44)
sb_json = stage_storyboard(args.input, args.episode, args.pro)
print(f"✅ 分镜: {sb_json}")
if not sb_json: sys.exit(1)
if args.from_stage in (None, "storyboard") and args.until not in ("storyboard",):
print("" * 44); print("② 逐镜路由分配 (豆包)"); print("" * 44)
sb_json = stage_route(sb_json)
print("✅ 路由完成")
if args.until in ("render", "video", "compose"):
print("" * 44); print("③ 逐镜出图 (本地 Z-Image, 零成本)"); print("" * 44)
manifest = stage_render(sb_json, style=args.style, max_shots=args.max_shots)
else:
print("🎯 阶段完成。"); return
if args.until in ("video", "compose"):
print("" * 44); print("④ 逐镜视频 (本地 LTX I2V, 零成本)"); print("" * 44)
vman = stage_video(manifest, frames=args.frames, max_shots=args.max_shots)
else:
print("🎯 出图完成。续跑: --from video <清单> 或 --until video")
return
if args.until in ("compose", "audio"):
print("" * 44); print("⑤ 剪辑拼接 (FFmpeg)"); print("" * 44)
out = stage_compose(vman, args.output)
print(f"\n🎬 成片: {out}")
if args.until == "audio":
print("" * 44); print("⑥ 声画装配 (配音+字幕+BGM+混音)"); print("" * 44)
sb = sb_json or (glob.glob(os.path.join(os.path.dirname(os.path.dirname(out)), "STORYBOARD*.json")) or [None])[0]
if sb:
ep_dir = os.path.dirname(os.path.abspath(out))
final = stage_audio_pipeline(sb, out, ep_dir)
print(f"\n🎉🎉 有声终成片: {final}")
else:
print("⚠️ 未找到分镜JSON跳过声画装配")
elif args.until != "compose":
print("🎯 视频完成。续跑: --from compose <清单> 或 --until compose/audio")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
audio_pipeline.py 声画流水线配音 / 字幕 / BGM / 混音
剧本有声成片的最后一段把分镜 dialogue 变成配音+字幕+BGM混入成片
用法:
python audio_pipeline.py voice <分镜.json> <输出目录> # Edge-TTS 配音
python audio_pipeline.py srt <分镜.json> <输出目录> # 生成 SRT 字幕
python audio_pipeline.py bgm <输出目录> <秒数> # 生成 BGMstable_audio 失败则 ffmpeg 氛围音)
python audio_pipeline.py mix <成片.mp4> <配音目录> <bgm.mp3> <输出.mp4> # 混音
"""
import os, sys, json, asyncio, subprocess, math
VOICES = {
"": "zh-CN-YunxiNeural",
"": "zh-CN-XiaoxiaoNeural",
"旁白": "zh-CN-XiaoyiNeural",
"少年": "zh-CN-YunyangNeural",
"老人": "zh-CN-YunfengNeural",
}
def load_shots(sb_json):
d = json.load(open(sb_json, encoding="utf-8"))
shots = d.get("shots", [])
return shots
def _dialogue_of(shot):
"""从分镜镜头提取台词(支持字符串或 dict"""
dl = shot.get("dialogue", "")
if not dl:
return []
if isinstance(dl, str):
return [{"text": dl, "char": shot.get("characters", [""])[0] if shot.get("characters") else "旁白"}]
if isinstance(dl, list):
out = []
for it in dl:
if isinstance(it, str):
out.append({"text": it, "char": shot.get("characters", [""])[0] if shot.get("characters") else "旁白"})
elif isinstance(it, dict):
out.append({"text": it.get("text", ""), "char": it.get("character", it.get("char", "旁白"))})
return [x for x in out if x["text"].strip()]
return []
def generate_voice(sb_json, out_dir):
"""Edge-TTS 逐条配音。返回 [(shot_no, 文件路径, 台词, 时长)]"""
shots = load_shots(sb_json)
vdir = os.path.join(out_dir, "voice")
os.makedirs(vdir, exist_ok=True)
results = []
for i, sh in enumerate(shots):
lines = _dialogue_of(sh)
if not lines:
continue
shot_no = sh.get("shot_number", f"S{i+1:02d}")
for j, ln in enumerate(lines):
voice = VOICES.get(ln["char"], VOICES.get(ln["char"][:1], "zh-CN-XiaoxiaoNeural"))
fname = f"shot_{i+1:04d}_{j:02d}.mp3"
fpath = os.path.join(vdir, fname)
if not os.path.isfile(fpath):
text = ln["text"][:500]
try:
asyncio.run(_tts(text, voice, fpath))
except Exception as e:
print(f" ⚠️ 配音失败 {shot_no}:{ln['char']}: {e}")
continue
dur = _mp3_duration(fpath)
results.append({"shot": shot_no, "char": ln["char"], "text": ln["text"],
"file": fpath, "duration": dur})
print(f" 🎙 [{shot_no}] {ln['char']}: {ln['text'][:40]} ({dur:.1f}s)")
return results
async def _tts(text, voice, fpath):
import edge_tts
c = edge_tts.Communicate(text, voice)
await c.save(fpath)
def _mp3_duration(path):
try:
r = subprocess.run(["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
"-of", "csv=p=0", path], capture_output=True, text=True)
return float(r.stdout.strip() or 0)
except Exception:
return 0
def generate_srt(sb_json, out_dir, voice_results=None):
"""生成 SRT 字幕(基于配音时长分配时间轴)。"""
shots = load_shots(sb_json)
sdir = os.path.join(out_dir, "subs")
os.makedirs(sdir, exist_ok=True)
srt_path = os.path.join(sdir, "subtitles.srt")
idx = 1
t = 0.0
lines = []
for i, sh in enumerate(shots):
lines_d = _dialogue_of(sh)
if not lines_d:
continue
shot_no = sh.get("shot_number", f"S{i+1:02d}")
for ln in lines_d:
dur = 2.5
if voice_results:
for vr in voice_results:
if vr["shot"] == shot_no and vr["char"] == ln["char"]:
dur = max(1.0, vr["duration"] + 0.3)
break
st = t
t += dur
lines.append(f"{idx}\n{_fmt_srt(st)} --> {_fmt_srt(t)}\n{ln['char']}: {ln['text']}\n")
idx += 1
with open(srt_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f" 📝 字幕: {srt_path} ({idx-1} 条)")
return srt_path
def _fmt_srt(sec):
h = int(sec // 3600); m = int((sec % 3600) // 60); s = int(sec % 60); ms = int((sec - int(sec)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def generate_bgm(out_dir, seconds=20, mood="calm ambient background music, gentle piano", use_stable=False):
"""生成 BGM默认 ffmpeg 氛围音稳定use_stable=True 时先试 stable_audio_3。"""
bgm_path = os.path.join(out_dir, "bgm.mp3")
ok = False
if use_stable:
ok = _try_stable_audio(bgm_path, seconds, mood)
if ok:
print(f" 🎵 BGM(stable_audio_3): {bgm_path} ({seconds}s)")
if not ok:
_ffmpeg_pad(bgm_path, seconds)
print(f" 🎵 BGM(ffmpeg氛围音): {bgm_path} ({seconds}s)")
return bgm_path
def _try_stable_audio(path, seconds, mood):
"""调本地 ComfyUI stable_audio_3 生成。返回 bool。"""
try:
import json, urllib.request, time
COMFY = "http://127.0.0.1:8188"
sigmas = ", ".join(str(round(1.0 - i / 24, 4)) for i in range(25)) + ", 0.0"
wf = {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "stable_audio_3_medium_base.safetensors"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": mood, "clip": ["1", 1]}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "vocals, singing, noisy, harsh, distorted, speech", "clip": ["1", 1]}},
"4": {"class_type": "ConditioningStableAudio", "inputs": {"positive": ["2", 0], "negative": ["3", 0], "seconds_start": 0, "seconds_total": seconds}},
"5": {"class_type": "EmptyLatentAudio", "inputs": {"seconds": seconds, "batch_size": 1}},
"6": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "euler"}},
"9": {"class_type": "ManualSigmas", "inputs": {"sigmas": sigmas}},
"10": {"class_type": "SamplerCustom", "inputs": {"model": ["1", 0], "positive": ["4", 0], "negative": ["3", 0], "cfg": 4.0, "noise_seed": 42, "add_noise": True, "sampler": ["6", 0], "sigmas": ["9", 0], "latent_image": ["5", 0]}},
"11": {"class_type": "VAEDecodeAudio", "inputs": {"samples": ["10", 0], "vae": ["1", 2]}},
"12": {"class_type": "SaveAudio", "inputs": {"audio": ["11", 0], "filename_prefix": "eed_bgm_gen"}},
}
data = json.dumps({"prompt": wf}).encode()
req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"})
pid = json.loads(urllib.request.urlopen(req, timeout=15).read())["prompt_id"]
for _ in range(15): # 30秒内不成功即放弃避免卡住
time.sleep(2)
h = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8).read())
if pid in h and h[pid].get("outputs"):
aud = h[pid]["outputs"].get("12", {}).get("audio", [])
if aud:
import shutil
shutil.copy(os.path.join(os.path.expanduser("~/comfy/ComfyUI/output"), aud[0]["filename"]), path)
return True
if pid in h and h[pid].get("status", {}).get("status_str") == "error":
return False
except Exception:
return False
return False
def _ffmpeg_pad(path, seconds):
"""ffmpeg 生成柔和氛围音垫底aevalsrc 单输入多频和声,避免多 lavfi 输入偶发失败)。"""
expr = (f"0.04*sin(2*PI*220*t)+0.04*sin(2*PI*277*t)+0.04*sin(2*PI*330*t)"
f"+0.03*sin(2*PI*165*t)")
cmd = ["ffmpeg", "-y", "-f", "lavfi",
"-i", f"aevalsrc={expr}:d={seconds}:s=44100",
"-af", "lowpass=f=1500,volume=0.35", path]
try:
r = subprocess.run(cmd, capture_output=True, timeout=60)
if r.returncode != 0:
print(f" ⚠️ BGM ffmpeg 退出码 {r.returncode}: {(r.stderr or '')[-200:]}")
except Exception as e:
print(f" ⚠️ BGM ffmpeg 异常: {e}")
def mix_final(video, voice_dir, bgm, out_path, subtitles=None):
"""ffmpeg 混音:成片(无声) + 配音 + BGM(压低)。"""
v = ["-i", video]
inputs = [video]
filters = []
n = 0
vf_in = {}
if voice_dir and os.path.isdir(voice_dir):
mps = sorted([os.path.join(voice_dir, f) for f in os.listdir(voice_dir) if f.endswith(".mp3")])
for mp in mps:
inputs.append(mp); vf_in[f"v{n+1}"] = mp
n += 1
if bgm and os.path.isfile(bgm):
inputs.append(bgm); vf_in[f"v{n+1}"] = bgm
n += 1
if not inputs[1:]:
# 无任何音轨:直接复制视频
subprocess.run(["ffmpeg", "-y", "-i", video, "-c", "copy", out_path], capture_output=True)
return out_path
# 简单混音:所有输入 amixBGM 音量压低
labels = [f"[{i}:a]" for i in range(1, len(inputs))]
vol = []
for i in range(1, len(inputs)):
if inputs[i] == bgm:
vol.append(f"[{i}:a]volume=0.15[v{i}]")
else:
vol.append(f"[{i}:a][v{i}]" if False else f"[{i}:a]volume=1.0[v{i}]")
fc = ";".join(vol) + ";" + "".join(f"[v{i}]" for i in range(1, len(inputs))) + f"amix=inputs={n}:normalize=0[aout]"
cmd = ["ffmpeg", "-y"] + sum([["-i", i] for i in inputs], []) + \
["-filter_complex", fc, "-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", out_path]
try:
subprocess.run(cmd, capture_output=True, timeout=120)
except Exception as e:
print(" 混音失败:", e)
return out_path
if __name__ == "__main__":
if len(sys.argv) < 2:
print(__doc__); sys.exit(0)
cmd = sys.argv[1]
if cmd == "voice" and len(sys.argv) >= 4:
generate_voice(sys.argv[2], sys.argv[3])
elif cmd == "srt" and len(sys.argv) >= 4:
generate_srt(sys.argv[2], sys.argv[3])
elif cmd == "bgm" and len(sys.argv) >= 4:
generate_bgm(sys.argv[2], int(float(sys.argv[3])), sys.argv[4] if len(sys.argv) > 4 else "calm ambient background music, gentle piano")
elif cmd == "mix" and len(sys.argv) >= 5:
mix_final(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5])
else:
print(__doc__)

View File

@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
character_turnaround.py Z-Image 角色三视图/四视图生成
漫剧角色资产同角色 正面/侧面/背面 3/4 视角展板单图一张出
用法:
python character_turnaround.py --desc "长黑发红发箍白裙少女" --output char.png
python character_turnaround.py --desc "...(详细角色描述)" --views 4 --seed 888
# --desc 支持中英文;建议给足细节:发型/发色/发饰/服装/配饰/鞋/体型
"""
import os, sys, json, urllib.request, time, argparse, uuid
COMFY = "http://127.0.0.1:8188"
COMFY_OUT = os.path.expanduser("~/comfy/ComfyUI/output")
VIEWS_3 = ("front view on the left, 90 degree side profile view in the middle, back view on the right")
# 人物四视图标准:正面 / 背面 / 侧面 / 脸部特写(动画·游戏角色资产规范)
VIEWS_4 = ("character sheet, 16:9 horizontal composition, pure white background, flat lighting no shadow, masterpiece, best quality, "
"left 1/3 of the image is a face extreme close-up portrait with hair-level facial detail looking at viewer, "
"right 2/3 of the image shows three full-body views arranged in a horizontal row: front view, 90 degree side profile view, back view, "
"the same character in every panel, identical face hairstyle and outfit across all views, "
"full body standing pose for the three right-side views")
# 社区布局:面部特写+三视图16:9 游戏立绘风)
PORTRAIT = ("character design sheet, horizontal layout divided into four panels: "
"front view, back view, 90 degree side profile view, and a large close-up portrait of the face, "
"the SAME character in all four panels, identical face hairstyle and outfit, "
"plain white background, anime style, clean lineart, high quality, detailed")
# 3D展示台布局爸爸发的开源格式上方三视图 + 下方细节特写镜头组
SHOWCASE = ("3D model display, three views of the character (front view, side view, back view), "
"clean neutral background, below the three main views are close-up detail shots showing "
"fabric, clothing details, face and accessories, "
"detail shots of face, collar, fabric texture, accessories, "
"modern style, 3D render, high quality, masterpiece")
# 场景四视图(动画场景设计标准):同一空间的 4 种视角,**纯场景无人物**
# ①正面外观 ②背面外观 ③室内视角 ④鸟瞰俯视 —— 强制 empty scene no people
SCENE_VIEWS = [
("front", "front exterior view from street level showing the main facade and entrance, empty scene, no characters, no people"),
("back", "back exterior view from behind showing the rear side of the building, empty scene, no characters, no people"),
("interior", "interior view inside the empty room showing indoor layout, tables chairs lanterns and architectural details, no characters, no people"),
("aerial", "top-down orthographic bird eye view of the whole building layout with rooms courtyard trees stone path, floor plan style, no characters, no people"),
]
def submit_wide(desc, seed, prefix, width=2048, height=1024):
"""Z-Image 宽幅全景图(用于场景切段法)。"""
prompt = (f"wide panoramic establishing shot, continuous sweeping vista of: {desc}, "
f"single unbroken scene from left to right, consistent architecture and scenery throughout, "
f"clean background, anime style, game environment design, high quality, detailed")
wf = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": NEG_PROMPT}},
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}},
"7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"8": {"class_type": "KSampler", "inputs": {"model": ["7", 0], "seed": seed, "steps": 8, "cfg": 1.0,
"sampler_name": "res_multistep", "scheduler": "simple",
"positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
data = json.dumps({"prompt": wf}).encode()
req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"})
pid = json.loads(urllib.request.urlopen(req, timeout=15).read())["prompt_id"]
for _ in range(120):
time.sleep(2)
h = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8).read())
if pid in h and h[pid].get("outputs"):
return h[pid]["outputs"]["10"]["images"][0]["filename"]
if pid in h and h[pid].get("status", {}).get("status_str") == "error":
for m in h[pid]["status"].get("messages", []):
if isinstance(m, list) and len(m) > 1 and m[0] == "execution_error":
raise RuntimeError(m[1].get("exception_message", "").strip()[:300])
raise TimeoutError("全景生成超时")
def scene_panorama_views(desc, seed, output_dir, out_name="scene_4view"):
"""全景图切四段法:宽幅全景 → 切 4 段 → 拼成四视图。
四段天然来自同一张图一致性 100% 锁定"""
from PIL import Image
import shutil
prefix = "eed_scene_pan_" + time.strftime("%H%M%S")
f = submit_wide(desc, seed, prefix, width=2048, height=1024)
src = os.path.join(COMFY_OUT, f)
os.makedirs(output_dir, exist_ok=True)
im = Image.open(src)
w, h = im.size
seg_w = w // 4
views = []
for i in range(4):
seg = im.crop((i * seg_w, 0, (i + 1) * seg_w, h))
seg_path = os.path.join(output_dir, f"{out_name}_view{i+1}.png")
seg.save(seg_path)
views.append(seg_path)
# 拼四视图展示版
gap = 16
canvas = Image.new("RGB", (seg_w * 4 + gap * 3, h), "white")
for i, v in enumerate(views):
canvas.paste(Image.open(v), (i * (seg_w + gap), 0))
final = os.path.join(output_dir, f"{out_name}_combo.png")
canvas.save(final)
return final, views
def char_four_views_compose(desc, seed, output_dir, out_name="char_4view"):
"""人物四视图(逐张生成 + PIL 拼图,**精确 4 张**
正面 背面 侧面 脸部特写
逐张提交 Z-Image 标准出图角色一致性靠相似 seed 锁定"""
import shutil
from PIL import Image
os.makedirs(output_dir, exist_ok=True)
# 每张强制 ONLY ONE single character避免 Z-Image 自由发挥画多个),简化元素避免歧义
cmds = [
("front", f"character design, ONLY ONE single character, {desc}, front view looking at viewer, full body, A-pose, isolated on plain white background, no other figures, anime style, high quality"),
("back", f"character design, ONLY ONE single character, {desc}, back view facing away, full body, A-pose, isolated on plain white background, no other figures, anime style, high quality"),
("side", f"character design, ONLY ONE single character, {desc}, 90 degree side profile view facing left, full body, A-pose, isolated on plain white background, no other figures, anime style, high quality"),
("face", f"character design, ONLY ONE single character, {desc}, face close-up portrait, head and shoulders only, looking at viewer, isolated on plain white background, no other figures, anime style, high quality"),
]
paths = []
for i, (key, prompt) in enumerate(cmds):
f = submit(prompt, 1, seed + i * 13, f"eed_char_{key}_{time.strftime('%H%M%S')}")
p = os.path.join(output_dir, f"{out_name}_{key}.png")
shutil.copy(os.path.join(COMFY_OUT, f), p)
paths.append(p)
print(f"{key}: {p}")
# 2x2 拼图
ims = [Image.open(p) for p in paths]
w, h = ims[0].size
gap = 12
canvas = Image.new("RGB", (w * 2 + gap, h * 2 + gap), "white")
canvas.paste(ims[0], (0, 0))
canvas.paste(ims[1], (w + gap, 0))
canvas.paste(ims[2], (0, h + gap))
canvas.paste(ims[3], (w + gap, h + gap))
combo = os.path.join(output_dir, f"{out_name}_combo.png")
canvas.save(combo)
return combo, paths
def scene_four_views(desc, seed, output_dir, out_name="scene_4view"):
"""场景四视图(专业语义):同一场景的 ①正面外观 ②背面外观 ③室内 ④鸟瞰。
统一场景描述串 + 相近 seed 逐张生成保证是"同一个空间""""
import shutil
from PIL import Image
os.makedirs(output_dir, exist_ok=True)
views = []
base_seed = seed
for i, (key, view_word) in enumerate(SCENE_VIEWS):
prompt = f"{desc}, {view_word}, empty scene no characters no people, the same building and location as the other views, consistent architecture details, anime style, game environment concept art, clean style, high quality, detailed"
print(f" 🎨 视角{i+1}/4 [{key}]: {view_word[:40]}...")
f = submit(prompt, 1, base_seed + i * 7, f"eed_scene_{key}_{time.strftime('%H%M%S')}")
v = os.path.join(output_dir, f"{out_name}_{key}.png")
shutil.copy(os.path.join(COMFY_OUT, f), v)
views.append(v)
print(f"{v}")
# 拼 2x2 四视图展示版
ims = [Image.open(v) for v in views]
w, h = ims[0].size
gap = 12
canvas = Image.new("RGB", (w * 2 + gap, h * 2 + gap), "white")
pos = [(0, 0), (w + gap, 0), (0, h + gap), (w + gap, h + gap)]
for im, (x, y) in zip(ims, pos):
canvas.paste(im, (x, y))
combo = os.path.join(output_dir, f"{out_name}_combo.png")
canvas.save(combo)
return combo, views
NEG_PROMPT = "nsfw, lowres, bad anatomy, text, error, missing fingers, blurry, distorted, watermark, multiple characters, characters, people, persons, human figures, crowd"
def submit(desc, views, seed, prefix, ctype="char", layout="standard"):
if ctype == "scene":
prompt = f"environment concept art, {desc}, anime style, high quality"
elif layout == "showcase":
prompt = f"{SHOWCASE}. Character and outfit: {desc}"
elif layout == "portrait":
prompt = f"{PORTRAIT}. Character: {desc}"
else:
view_part = VIEWS_4 if views >= 4 else VIEWS_3
prompt = (f"character reference sheet, model sheet, {views} views of the SAME character: "
f"{view_part}, identical character design across all views, same face hairstyle outfit, "
f"plain white background, anime style, clean lineart, "
f"character design sheet, high quality, detailed. Character: {desc}")
wf = {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "z_image_turbo_bf16.safetensors", "weight_dtype": "default"}},
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_3_4b.safetensors", "type": "lumina2", "device": "default"}},
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "ae.safetensors"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": prompt}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"clip": ["2", 0], "text": NEG_PROMPT}}, # 真正的负面词
"6": {"class_type": "EmptySD3LatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}},
"7": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3.0}},
"8": {"class_type": "KSampler", "inputs": {"model": ["7", 0], "seed": seed, "steps": 8, "cfg": 1.0,
"sampler_name": "res_multistep", "scheduler": "simple",
"positive": ["4", 0], "negative": ["5", 0], "latent_image": ["6", 0], "denoise": 1.0}},
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": prefix}},
}
data = json.dumps({"prompt": wf}).encode()
req = urllib.request.Request(f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"})
pid = json.loads(urllib.request.urlopen(req, timeout=15).read())["prompt_id"]
for _ in range(90):
time.sleep(2)
h = json.loads(urllib.request.urlopen(f"{COMFY}/history/{pid}", timeout=8).read())
if pid in h and h[pid].get("outputs"):
return h[pid]["outputs"]["10"]["images"][0]["filename"]
if pid in h and h[pid].get("status", {}).get("status_str") == "error":
for m in h[pid]["status"].get("messages", []):
if isinstance(m, list) and len(m) > 1 and m[0] == "execution_error":
raise RuntimeError(m[1].get("exception_message", "").strip()[:300])
raise TimeoutError("Z-Image 生成超时")
def main():
ap = argparse.ArgumentParser(description="Z-Image 角色三视图/四视图生成")
ap.add_argument("--desc", required=True, help="角色描述(细节越足越好)")
ap.add_argument("--views", type=int, default=3, choices=[3, 4], help="视图数3或4仅 char 用)")
ap.add_argument("--type", dest="ctype", default="char", choices=["char", "scene"], help="char=人物三视图 / scene=场景四视图")
ap.add_argument("--layout", default="standard", choices=["standard", "portrait", "showcase"], help="char布局standard=标准三栏 / portrait=面部特写+三视图 / showcase=3D展示台(上三视图+下细节特写)")
ap.add_argument("--method", default="prompt", choices=["prompt", "panorama", "compose"], help="场景: prompt/panorama; 人物四视图: compose=逐张生成+PIL拼图(精确4张)")
ap.add_argument("--seed", type=int, default=777)
ap.add_argument("--output", default="", help="输出路径(默认 cang-ying/outputs/character_<time>.png")
args = ap.parse_args()
prefix = ("eed_scene_" if args.ctype == "scene" else "eed_char_") + time.strftime("%H%M%S")
label = "场景四视图" if args.ctype == "scene" else f"角色{args.views}视图"
print(f"🎨 Z-Image 生成 {label}: {args.desc[:60]}...")
import shutil
if args.ctype == "char" and args.views >= 4 and args.method == "compose":
out_dir = os.path.expanduser("~/cang-ying/outputs")
os.makedirs(out_dir, exist_ok=True)
final, views = char_four_views_compose(args.desc, args.seed, out_dir)
print(f"✅ 人物四视图(逐张生成+拼图): {final}")
print(f" 四张图: {', '.join(os.path.basename(v) for v in views)}")
return
if args.ctype == "scene":
out_dir = os.path.expanduser("~/cang-ying/outputs")
os.makedirs(out_dir, exist_ok=True)
if args.method == "panorama":
final, views = scene_panorama_views(args.desc, args.seed, out_dir)
print(f"✅ 场景四视图(全景切段,备选): {final}")
else:
final, views = scene_four_views(args.desc, args.seed, out_dir)
print(f"✅ 场景四视图(正面/背面/室内/鸟瞰): {final}")
print(f" 四张视角图: {', '.join(os.path.basename(v) for v in views)}")
return
f = submit(args.desc, args.views, args.seed, prefix, args.ctype, args.layout)
src = os.path.join(COMFY_OUT, f)
if not args.output:
out_dir = os.path.expanduser("~/cang-ying/outputs")
os.makedirs(out_dir, exist_ok=True)
args.output = os.path.join(out_dir, f"character_{time.strftime('%m%d_%H%M%S')}.png")
shutil.copy(src, args.output)
print(f"{label}: {args.output}")
if __name__ == "__main__":
main()

View File

@ -1,32 +1,117 @@
# 步骤⑦ storyboard 引擎: 豆包 AI 拆解剧本
import sys, json
sys.path.insert(0, '.')
#!/usr/bin/env python3
# 步骤⑦ storyboard 引擎: 豆包 AI 拆解剧本 → 结构化分镜 JSON
# 通用版 — 支持任意剧本文件
# DEPENDS: lib/doubao_chat.py
import sys, os, json, argparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.doubao_chat import breakdown_script
with open('projects/deep-sea-voyage/EP01-SCRIPT-LOCK.hdlp', 'r', encoding='utf-8') as f:
script = f.read()
print('发送豆包 AI 拆解剧本...')
result = breakdown_script(script, 1)
def extract_json(text):
"""从 AI 回复中提取 JSON 内容(兼容带 ```json 包裹的情况)"""
if '```json' in text:
return text.split('```json')[1].split('```')[0].strip()
if '```' in text:
return text.split('```')[1].split('```')[0].strip()
# 尝试直接解析
maybe = text.strip()
if maybe.startswith('{'):
return maybe
return maybe
if 'error' in result:
print('API ERROR:', json.dumps(result, ensure_ascii=False, indent=2))
elif 'content' in result:
content = result['content']
# 提取 JSON
if '```json' in content:
content = content.split('```json')[1].split('```')[0]
elif '```' in content:
content = content.split('```')[1].split('```')[0]
def main():
parser = argparse.ArgumentParser(description='豆包AI → 拆解剧本为结构化分镜JSON')
parser.add_argument('script', help='剧本文件路径 (.hdlp 或 .txt)')
parser.add_argument('-e', '--episode', type=int, default=1, help='集号 (默认: 1)')
parser.add_argument('-o', '--output', help='输出路径 (默认: 自动推导)')
parser.add_argument('--pro', action='store_true', help='使用 Pro 模型 (doubao-pro, 更强更贵)')
parser.add_argument('--dry-run', action='store_true', help='只打印将发送的提示词不调API')
# 保存到文件
out_path = 'projects/deep-sea-voyage/STORYBOARD-AI.json'
args = parser.parse_args()
# 读剧本
if not os.path.isfile(args.script):
print(f'❌ 找不到剧本文件: {args.script}')
sys.exit(1)
with open(args.script, 'r', encoding='utf-8') as f:
script_text = f.read()
print(f'📖 剧本: {args.script}')
print(f'📐 长度: {len(script_text)} 字符')
print(f'🎬 集号: EP{args.episode:02d}')
print(f'🧠 模型: {"doubao-pro" if args.pro else "doubao-lite"}')
print()
if args.dry_run:
print('🧪 DRY RUN — 将发送的提示词:')
print('=' * 60)
from lib.doubao_chat import breakdown_script as bd
print(bd.__doc__)
print('=' * 60)
return
# 调用豆包 API
print('🚀 发送豆包 AI 拆解剧本...')
result = breakdown_script(script_text, args.episode, model='pro' if args.pro else 'lite')
if 'error' in result:
print('❌ API ERROR:', json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
if 'content' not in result or not result['content']:
print('❌ API 返回空内容:', json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(1)
content = result['content']
json_str = extract_json(content)
# 解析 JSON 验证有效性
try:
parsed = json.loads(json_str)
shot_count = len(parsed.get('shots', []))
except json.JSONDecodeError as e:
print(f'⚠️ JSON 解析失败: {e}')
print('原始内容:')
print(content[:500])
shot_count = 0
parsed = None
# 确定输出路径
if args.output:
out_path = args.output
else:
# 从剧本路径自动推导
script_dir = os.path.dirname(os.path.abspath(args.script))
script_base = os.path.splitext(os.path.basename(args.script))[0]
# 如果剧本文件名包含 SCRIPT 或 BREAKDOWN替换为 STORYBOARD
if 'SCRIPT' in script_base.upper() or 'BREAKDOWN' in script_base.upper():
storyboard_name = script_base.replace('SCRIPT', 'STORYBOARD').replace('BREAKDOWN', 'STORYBOARD')
else:
model_tag = 'PRO' if args.pro else 'AI'
storyboard_name = f'STORYBOARD-{model_tag}-EP{args.episode:02d}'
out_path = os.path.join(script_dir, f'{storyboard_name}.json')
# 保存
with open(out_path, 'w', encoding='utf-8') as f:
f.write(content.strip())
print('SAVED:', out_path)
print('tokens:', result.get('tokens', 'N/A'))
print('---')
print(content.strip())
else:
print('UNKNOWN:', result)
f.write(json_str if parsed else content.strip())
print(f'\n✅ 分镜已保存: {out_path}')
if parsed:
print(f'📊 共 {shot_count}')
print(f'💰 tokens: {result.get("tokens", "N/A")}')
print(f'⏱️ 完成原因: {result.get("finish", "N/A")}')
# 打印前3镜预览
if parsed and shot_count > 0:
print('\n📋 前3镜预览:')
for s in parsed['shots'][:3]:
print(f' {s.get("shot_number","?"):>4} | {s.get("camera","?"): <4} | {s.get("duration","?"):>2}s | {s.get("description","")[:40]}')
if __name__ == '__main__':
main()