基于 AI Agent 的童话编剧与绘本生成器(八)角色锁定 Agent、绘本排版打磨与听读并行收官
一、角色锁定 Cast Agent:全书外貌一次定稿
多角色接线之后,配角能进 characters[] 了,但实测仍有一个问题:出图 Prompt Agent 每页各自写 characters[].prompt,小松鼠第 3 页和第 7 页毛色、体型对不上。
我在 image_prompt_generation.py 里把出图 LLM 拆成 两步:
【Cast Agent】→ locked_character_prompts(全书角色外貌表)
↓
【Page Agent】→ 每页 scene + action(prompt 强制引用 locked 表)
Cast Agent 单独调一次 LLM,输入主角设定 + supporting_cast,输出 ImageCastRegistryLLM:
def _generate_locked_character_prompts(self, *, story_id, payload, supporting_cast):
prompt, parser, llm = self._build_cast_chain()
result = chain.invoke({
"protagonist": payload.protagonist,
"character_design": payload.character_design or payload.protagonist,
"supporting_cast_json": cast_json,
...
})
parsed = ImageCastRegistryLLM.model_validate(result)
locked = {item.name: item.prompt[:600] for item in parsed.characters if item.name}
return locked or fallback # Agent 失败 → build_cast_registry 规则兜底
Page Agent 生成完后,_finalize_payloads 把各页 characters[].prompt 覆盖为 locked 表里的同名字符串,只有 action 允许页间变化:
def _apply_locked_prompts(llm_chars, *, locked_prompts, protagonist):
for ch in llm_chars:
key = hero if slot == "主角" or name == hero else name
if locked := locked_prompts.get(key):
item["prompt"] = locked[:600]
日志里会先出现 cast 锁定,再出现 image_prompt_agent ok。Qwen 参考图 + VL 一致性检测,终于有稳定的文字锚点。
二、构图多样性:composition_type 从分镜传到出图
8 页插图构图容易雷同——全是中景、主角居中。我在 v6 分镜 LLM 的输出 Schema 里加了 composition_type:
class StoryboardPageLLM(BaseModel):
composition_type: str = Field(
description="远景环境镜头/中景叙事镜头/近景表情镜头/特写细节镜头/俯视全景镜头",
)
storyboard_generation.py 里还有 _normalize_composition:相邻两页禁止重复同一景别,避免「8 页全是中景」。
这条字段一路传到出图 Prompt Agent 的输入 JSON,并在 Prompt 里加了硬性规则:
> 写 scene 时必须体现该景别,禁止与 composition_type 矛盾(近景表情镜头不得写成广阔远景)。
实测一本 8 页森林故事,景别分布类似:远景 → 中景 → 特写 → 近景 → 俯视 → …,翻起来更像绘本而不是 PPT。
三、字数策略:按年龄段控每页文字量
v6 先写完整故事再切片,但 LLM 有时一段写太长,阅读页一页塞满字。新增 page_text_policy.py:
def max_chars_per_page(payload) -> int:
upper = _parse_age_upper(payload.age_group, payload.age)
if upper <= 5: return 45
if upper <= 8: return 75
return 95
叙事 Prompt 注入 page_text_limit_guidance;reading_layout.py 在落库前调用 enforce_page_reading_text,超长则在句末截断并打日志 page_text_trimmed。
同一套策略也复用到 PDF 字号:pdf_font_sizes 按年龄段返回 body / dialogue / title 字号,3~5 岁 body 18pt,6~8 岁 15pt,更大 14pt。
四、PDF 导出:从「网页截图感」到「纸质绘本感」
左图右文骨架有了之后,我继续打磨 pdf_export.py,主要改动:
| 项 | 做法 |
|---|---|
| 左页满幅图 | 有图时去掉圆角灰框,插图从页面左缘铺满左半 spread |
| 装订 gutter | 中缝两侧各留 _INSIDE_MARGIN,文字/人脸不贴书脊 |
| 旁白 / 对白分样式 | 有 narration/dialogue 时分开展示;对白略大 + 浅橙背景块 |
| 封面满幅 + 书名条 | 优先 cover.png 全页铺满,底部半透明黑条显示书名 |
| 浅纸色 | 每页背景 #FFF8F0 代替纯白 |
导出时从创作参数快照读取 age_group / age,PDF 和在线阅读用同一套字数/字号策略。
五、独立封面 cover.png
之前 PDF 封面往往直接复用第 1 页内页图,风格偏「叙事瞬间」而不是「书封」。
build_cover_payload 单独拼封面 scene:主角居中、无文字、满幅构图,输出文件名固定 cover.png:
return {
"scene": scene[:800],
"illustration_style": illustration_style + ",封面级精美构图",
"negative_prompt": _NEGATIVE + ",画面文字,标题,书名,对话框",
"_output_filename": "cover.png",
...
}
render_story_images_from_payloads 在页循环之外先画封面;PDF _resolve_cover_path 优先读 cover.png,没有再 fallback 到 1.png。
六、出图 ∥ TTS 并行:缩短总墙钟时间
CosyVoice TTS 如果等 imaging 全部完成再跑,8 页又要多等几分钟。
加了 PIPELINE_PARALLEL_TTS=true(默认开):每画完一页,线程池立刻合成该页 MP3,Job 进度同时显示「第 x/y 页插图 · 朗读 a/b 页」:
def on_page_complete(page_index, total):
if not parallel_tts:
return
futures.append(executor.submit(_tts_worker)) # try_synthesize_page(...)
render_story_images_from_payloads(..., on_page_complete=on_page_complete)
narrating 阶段会跳过已合成的页,只补漏。8 页绘本总耗时从「出图 + TTS 线性相加」缩到「大致取较长者」,等待感明显减轻。
七、系列收官
回顾这八篇,系统演进路径大致是:
(一~二)环境 + SD 单角色验证
(三) Qwen-Image + VL 一致性模块
(四) FastAPI 后端接入
(五) 双流编剧:故事 LLM + 出图 Prompt LLM
(六) 多角色分镜 + 出图 RAG + 语料冷启动
(七) v6 叙事/分镜拆分 + 按页进度 + 绘本 PDF
(八) Cast 锁定 + 构图/字数/PDF/封面/听读并行 ← 本篇
当前完整链路:
用户参数
→ v6 叙事(full_story + paragraphs + supporting_cast)
→ v6 分镜(composition_type + appearing_characters)
→ Cast Agent 锁角色 + Page Agent 写出图 JSON + RAG
→ Qwen 出图(cover + N 页,按页进度)
→ CosyVoice TTS(与出图并行)
→ 在线阅读 / PDF 导出
小结
第八篇补上了「角色一致性」和「成品感」两块拼图:Cast Agent 让多角色外貌全书统一,composition_type 让镜头有节奏,字数策略 + PDF 打磨 + 独立封面 让导出更像儿童绘本,出图∥TTS 缩短等待。
八篇下来,这不只是一个「调 API 拼 Demo」的项目,而是一条 多层 Agent + RAG + 一致性检测 + 异步 Job 的垂直链路。系列完结。
更多推荐


所有评论(0)