☰
Toonflow 实战:小说转短剧漫剧自动化流水线搭建指南
2026/9/30 2:55:29 网站建设 项目流程

简介:Toonflow 是一套面向短剧与漫剧创作者的 AI 一站式生成工具,核心能力是把小说文本自动转化为剧本,再结合 AI 生成的图片与视频素材,完成从剧本到成片的自动化流程。它适合个人创作者、小团队以及预算有限的短剧项目,用来降低制作门槛、节省时间与人力,让创作者把精力集中在故事创意与情感表达上。资源包共 190 个文件,以 155 个 ts 源码文件为主体,辅以 png、jpg 图片素材、yml 与 json 配置、md 与 txt 说明文档,以及 Dockerfile、dockerignore、gitignore 等部署相关文件,整体约 9.93MB,结构完整,便于本地运行与二次开发。目前已有 338 人学习下载。借助这套源码,读者可以了解 AI 短剧漫剧从文本解析、剧本生成到视觉素材合成的完整链路,参考其工程组织与配置方式,快速搭建属于自己的自动化内容生产流程。

1. Toonflow 到底解决什么问题:从小说文本到短剧漫剧的自动化流水线

手里有一本几十万字的小说,想把它变成能发出去的短剧或漫剧,传统流程是什么样?先找人改编剧本,再画分镜,再逐帧出图,再合成视频,一套下来周期以月计,成本以万计。Toonflow 这类 AI 短剧漫剧工具瞄准的就是这条链路——把小说文本自动转成结构化剧本,再用 AI 生成角色图、场景图和视频片段,最后拼成可发布的短剧或漫剧。它适合两类人:手里有小说版权或原创文本、想低成本试水短剧的内容创作者;以及想搭一套自动化内容生产流水线的开发者。核心价值不在于某一个环节的 AI 效果有多惊艳,而在于把「小说 → 剧本 → 分镜 → 图片 → 视频」串成一条可重复跑的管线,让批量生产成为可能。下面按实际落地顺序拆开讲。

2. 小说转剧本:分章、抽角色、生成结构化脚本

2.1 为什么不能直接把整本小说丢给大模型

很多人第一反应是写个 prompt 把小说全文塞进去让模型输出剧本。这条路在实操中基本走不通,原因有三个。第一,上下文长度限制,一本番茄小说动辄几十万字,即使模型支持长上下文,成本和延迟也不可接受。第二,信息密度问题,小说里大量环境描写、心理独白对剧本没有直接价值,全量输入会稀释关键情节。第三,角色一致性问题,整本一次性处理时模型容易混淆角色关系,尤其是多线叙事的作品。

常见做法是先把小说按章节切分,再对每章做结构化信息抽取,最后汇总成全局角色表和分集剧本。这个思路和做数据管道的逻辑一样:先 ETL 再聚合。

2.2 分章与文本清洗的代码实现

import re def split_chapters(raw_text: str) -> list[dict]: """ 按常见章节标题模式切分小说文本。 支持 '第X章'、'第X节'、'Chapter X' 等格式。 """ # 匹配中文章节标题,允许前后有空白 pattern = re.compile( r'^\s*(第[一二三四五六七八九十百千\d]+[章节回])\s*(.*)$', re.MULTILINE ) matches = list(pattern.finditer(raw_text)) if not matches: # 没有识别到章节标题时,按固定字数切块 chunk_size = 3000 return [ {"index": i, "title": f"chunk_{i}", "content": raw_text[i:i+chunk_size]} for i in range(0, len(raw_text), chunk_size) ] chapters = [] for i, m in enumerate(matches): start = m.start() end = matches[i + 1].start() if i + 1 < len(matches) else len(raw_text) chapters.append({ "index": i, "title": m.group(0).strip(), "content": raw_text[start:end].strip() }) return chapters def clean_chapter(text: str) -> str: """去掉广告行、作者的话、重复空行等噪声。""" lines = text.split('\n') cleaned = [] for line in lines: stripped = line.strip() # 跳过常见噪声行 if not stripped: continue if re.match(r'^(作者|PS|ps|求票|求收藏|感谢)', stripped): continue cleaned.append(stripped) return '\n'.join(cleaned)

split_chapters的核心逻辑是用正则匹配章节标题行,按匹配位置切分。chunk_size是兜底参数,当正则匹配不到任何章节标题时(比如某些 txt 格式混乱的小说),按固定字数切块,一般设 2000 到 4000 字比较合适,太短会丢失上下文,太长会增加后续抽取的噪声。clean_chapter处理的是从网上下载的 txt 里常见的广告行和作者留言,这些内容如果不清掉,会被模型当成正文处理,污染角色和情节抽取结果。

2.3 用大模型抽取角色和分集大纲

分章之后,对每章做一次结构化抽取。这里的关键是设计好输出格式,让模型返回 JSON,方便后续程序处理。

import json EXTRACT_PROMPT = """你是一个剧本改编助手。请阅读以下小说章节内容,完成两件事: 1. 列出本章出现的所有角色(只列有台词或推动情节的角色),每个角色给出:姓名、身份、本章中的关键行为。 2. 用 3-5 句话概括本章的核心情节,标注适合改编为剧本的场景切换点。 输出格式为 JSON: { "characters": [{"name": "", "role": "", "actions": ""}], "plot_summary": "", "scene_breaks": ["场景1描述", "场景2描述"] } 章节内容: {chapter_text} """ def extract_chapter_info(chapter_text: str, llm_client) -> dict: prompt = EXTRACT_PROMPT.format(chapter_text=chapter_text[:4000]) resp = llm_client.chat(prompt, temperature=0.3) try: return json.loads(resp) except json.JSONDecodeError: # 模型偶尔会输出非标准 JSON,做一次修复尝试 resp = llm_client.chat( f"请把以下内容修复为合法 JSON,只输出 JSON:\n{resp}", temperature=0 ) return json.loads(resp)

temperature=0.3是为了在抽取任务中保持输出稳定,不要用太高的随机性。chapter_text[:4000]是截断保护,单章超过 4000 字时只取前 4000 字做抽取,因为章节的核心情节通常集中在前半部分。如果小说章节普遍很长,可以改成滑动窗口分段抽取再合并。

2.4 汇总角色表与生成分集剧本

逐章抽取完成后,把所有章节的角色信息合并去重,得到全局角色表。合并时要注意同名不同人的情况——比如两个角色都叫「小雅」,需要通过身份描述区分。

def merge_characters(all_chapter_infos: list[dict]) -> dict: """合并所有章节的角色信息,按姓名聚合。""" char_map = {} for info in all_chapter_infos: for c in info.get("characters", []): name = c["name"] if name not in char_map: char_map[name] = { "name": name, "roles": set(), "actions": [] } char_map[name]["roles"].add(c.get("role", "")) char_map[name]["actions"].append(c.get("actions", "")) # 转成可序列化格式 result = {} for name, data in char_map.items(): result[name] = { "name": name, "role": " / ".join(filter(None, data["roles"])), "key_actions": data["actions"][:10] # 只保留前10条关键行为 } return result def generate_episode_script( chapter_summaries: list[str], character_table: dict, episodes: int = 10 ) -> list[dict]: """把章节摘要按集数分组,生成分集剧本大纲。""" per_episode = max(1, len(chapter_summaries) // episodes) scripts = [] for ep in range(episodes): start = ep * per_episode end = start + per_episode chunk = chapter_summaries[start:end] if not chunk: break scripts.append({ "episode": ep + 1, "source_chapters": f"{start+1}-{end}", "summary": " ".join(chunk), "characters_involved": list(character_table.keys())[:8] }) return scripts

per_episode控制每集覆盖多少章,这个参数直接影响短剧节奏。短剧一般每集 1-3 分钟,对应小说大概 3-5 章的内容量。如果小说章节本身很短(比如每章 1000 字),可以适当调大。characters_involved这里做了简化处理,实际使用时应该根据每集摘要内容做角色匹配,而不是直接取前 8 个。

3. AI 出图与角色一致性:从文字描述到可用素材

3.1 角色图生成的核心矛盾

剧本有了,下一步是出图。AI 短剧漫剧对图片的要求和普通 AI 绘画不一样:同一个角色在不同场景、不同表情下必须保持外貌一致。这是整个流水线里最容易翻车的环节。常见做法是先用角色描述生成一张「角色定妆照」,然后用图生图或 IP-Adapter 类方案锁定角色特征,再生成不同场景下的图片。

3.2 角色定妆照的 prompt 构造

def build_character_prompt(character: dict, style: str = "anime") -> str: """ 根据角色信息构造出图 prompt。 style 可选 anime / realistic / comic。 """ style_map = { "anime": "anime style, clean lines, vibrant colors", "realistic": "photorealistic, cinematic lighting, 8k", "comic": "comic book style, bold outlines, halftone shading" } base = ( f"character portrait, {character['name']}, " f"{character.get('role', '')}, " f"{style_map.get(style, style_map['anime'])}, " f"front view, upper body, neutral expression, " f"white background, high detail" ) return base # 示例 char = {"name": "林月", "role": "剑客,冷峻,黑色长发"} prompt = build_character_prompt(char, style="anime") print(prompt) # 输出: character portrait, 林月, 剑客,冷峻,黑色长发, anime style, ...

prompt 里front view, upper body, neutral expression, white background这几个约束很重要。正面、上半身、中性表情、白底,是为了给后续的图生图提供最干净的参考图。如果定妆照本身就是侧脸或者复杂背景,后续生成其他场景时角色特征会漂移。

3.3 用参考图锁定角色一致性

生成定妆照后,后续每个场景的图片生成都要带上这张参考图。不同工具的具体接口不一样,但核心参数就几个:

参数作用建议值
reference_image角色参考图路径定妆照
reference_strength参考强度0.6-0.8
denoising_strength重绘幅度0.4-0.6
seed随机种子固定值

reference_strength太低角色不像,太高场景变化出不来。0.6-0.8 是实测比较稳的区间。denoising_strength控制新图和参考图的差异程度,场景变化大就调高,只是换表情就调低。seed固定住可以减少同一角色在不同图片之间的随机波动。

3.4 场景图批量生成的工程化处理

一个 10 集的短剧大概需要 50-100 张场景图。手动一张张生成不现实,需要批量处理。

import os import time def batch_generate_scenes( scenes: list[dict], character_refs: dict, output_dir: str, generator ) -> list[str]: """ scenes: [{"episode": 1, "scene_desc": "...", "characters": ["林月"]}] character_refs: {"林月": "/path/to/ref.png"} """ os.makedirs(output_dir, exist_ok=True) results = [] for i, scene in enumerate(scenes): # 取第一个出场角色的参考图 ref_char = scene["characters"][0] if scene["characters"] else None ref_img = character_refs.get(ref_char) out_path = os.path.join(output_dir, f"ep{scene['episode']}_scene{i:03d}.png") try: generator.generate( prompt=scene["scene_desc"], reference_image=ref_img, reference_strength=0.7, denoising_strength=0.5, output_path=out_path ) results.append(out_path) except Exception as e: print(f"[FAIL] scene {i}: {e}") results.append(None) time.sleep(1) # 避免请求过密 return results

time.sleep(1)是给 API 留缓冲,如果是本地部署的模型可以去掉。异常处理里把失败的场景记为None而不是直接中断,这样一批跑完后可以单独重跑失败项。实际使用中建议把scenes和character_refs持久化到 JSON 文件,方便断点续跑。

4. 图片转视频与合成:让静态素材动起来

4.1 图生视频的两种路线

静态图转视频目前有两条路。一条是用图生视频模型(比如常见的 image-to-video 方案),输入一张图输出几秒的动态片段。另一条是用传统的 Ken Burns 效果——推拉摇移,把静态图做出镜头运动感。前者效果更自然但成本高、速度慢,后者零成本但动感有限。实际做短剧漫剧时,常见做法是混合使用:关键镜头用图生视频,过渡镜头用 Ken Burns。

4.2 用 FFmpeg 做镜头运动

# 缓慢推近效果:从原图中心放大 1.0 到 1.15 ffmpeg -loop 1 -i scene.png -vf "zoompan=z='min(zoom+0.001,1.15)':d=125:s=1080x1920" \ -c:v libx264 -t 5 -pix_fmt yuv420p scene_motion.mp4 # 缓慢平移效果:从左到右 ffmpeg -loop 1 -i scene.png -vf "crop=iw/1.2:ih:iw/6*t:0,scale=1080:1920" \ -c:v libx264 -t 5 -pix_fmt yuv420p scene_pan.mp4

第一条命令的zoompan滤镜实现推近,z='min(zoom+0.001,1.15)'表示每帧放大 0.001 倍,最大到 1.15 倍,d=125是总帧数(5 秒 × 25fps),s=1080x1920是竖屏短剧常见分辨率。第二条命令用crop实现平移,iw/6*t控制水平偏移速度。这两条命令是短剧漫剧里最常用的镜头运动模板,改参数就能适配不同节奏。

4.3 拼接、字幕与配音

import subprocess def concat_clips(clip_paths: list[str], output: str): """用 FFmpeg concat 协议拼接视频片段。""" list_file = "concat_list.txt" with open(list_file, "w") as f: for p in clip_paths: f.write(f"file '{p}'\n") subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", output ], check=True) def add_subtitles(video: str, srt_path: str, output: str): """烧录字幕到视频。""" subprocess.run([ "ffmpeg", "-y", "-i", video, "-vf", f"subtitles={srt_path}:force_style='FontSize=18,PrimaryColour=&HFFFFFF'", "-c:a", "copy", output ], check=True)

concat_clips用的是 FFmpeg 的 concat 协议,要求所有片段的编码参数一致(分辨率、帧率、编码格式),否则拼接会出问题。如果片段来源不统一,需要先统一转码。add_subtitles里的force_style控制字幕样式,FontSize=18在 1080x1920 竖屏下大概占画面宽度的 1/20,是比较舒服的阅读大小。配音部分一般用 TTS 接口逐句生成音频,再按时间轴对齐,这里不展开。

5. 避坑与排查:这条流水线上最容易翻车的五个地方

5.1 角色名在抽取结果里对不上

现象:第 3 章抽出来叫「林月」,第 7 章变成「林玥」,合并角色表时出现两个角色。

原因:大模型在抽取时对同音字、形近字没有统一能力,尤其是网文里作者自己都可能写混。

解决:在合并前加一层别名归一化。维护一个alias_map,把常见变体映射到标准名。也可以用编辑距离做模糊匹配,相似度超过 0.85 的自动合并,但需要人工确认一遍。

5.2 生成的场景图和剧本对不上

现象:剧本写的是「夜晚,雨中街道」,生成的图是白天晴天。

原因:场景描述在从剧本到 prompt 的转换过程中丢失了关键修饰词,或者模型对否定词理解不好。

解决:在场景描述转 prompt 时,把时间、天气、光线作为独立字段强制拼接到 prompt 开头。不要依赖模型从长句里自己提取这些信息。

5.3 视频拼接后音画不同步

现象:拼接多个片段后,后面片段的音频比画面快或慢半秒。

原因:不同片段的帧率和音频采样率不一致,concat 时没有统一。

解决:拼接前统一转码,所有片段强制转为相同的帧率(短剧一般 25fps 或 30fps)和音频采样率(44100Hz)。这一步多花几分钟,但能省掉后面大量排查时间。

5.4 API 调用超时导致批量任务中断

现象:批量生成 50 张图,跑到第 30 张时程序崩溃,前面 29 张的结果也没保存。

原因:没有做增量保存和异常恢复。

解决:每生成一张图就写一次状态文件,记录已完成的任务 ID。程序启动时先读状态文件,跳过已完成的。这个习惯在跑任何批量 AI 任务时都值得养成。

5.5 输出视频在手机上播放黑屏

现象:电脑上播放正常,发到手机上只有声音没有画面。

原因:编码格式不兼容,常见于用了手机不支持的像素格式或编码器。

解决:输出时统一用-c:v libx264 -pix_fmt yuv420p,这是兼容性最好的组合。分辨率用 1080x1920 竖屏,码率控制在 4-6 Mbps。

6. 把整条流水线串起来:一个可复用的调度脚本

前面几章拆开讲了每个环节,实际跑的时候需要一个调度层把它们串起来。我一般会写一个简单的 pipeline 脚本,用配置文件驱动,每个阶段独立可重跑。

import json import os class ToonflowPipeline: def __init__(self, config_path: str): with open(config_path) as f: self.cfg = json.load(f) self.state_file = self.cfg.get("state_file", "pipeline_state.json") self.state = self._load_state() def _load_state(self) -> dict: if os.path.exists(self.state_file): with open(self.state_file) as f: return json.load(f) return {"chapters_done": False, "scripts_done": False, "images_done": False, "videos_done": False} def _save_state(self): with open(self.state_file, "w") as f: json.dump(self.state, f, ensure_ascii=False, indent=2) def run(self): if not self.state["chapters_done"]: self._split_and_clean() self.state["chapters_done"] = True self._save_state() if not self.state["scripts_done"]: self._extract_and_generate_scripts() self.state["scripts_done"] = True self._save_state() if not self.state["images_done"]: self._generate_all_images() self.state["images_done"] = True self._save_state() if not self.state["videos_done"]: self._compose_videos() self.state["videos_done"] = True self._save_state() def _split_and_clean(self): # 调用第 2 章的分章和清洗逻辑 pass def _extract_and_generate_scripts(self): # 调用第 2 章的抽取和剧本生成逻辑 pass def _generate_all_images(self): # 调用第 3 章的批量出图逻辑 pass def _compose_videos(self): # 调用第 4 章的拼接和字幕逻辑 pass

这个调度脚本的核心是状态文件。每个阶段完成后写一次状态,下次启动时自动跳过已完成的阶段。state_file建议放在项目根目录,和输出目录分开,避免清理输出时误删。实际使用时可以把每个_xxx方法里的具体逻辑替换成前面章节的代码,配置文件里放 API key、模型名称、输出路径这些可变参数。

验证整条流水线是否跑通,最直接的方法是拿一篇 3-5 章的短篇小说做端到端测试。先确认分章结果正确,再检查角色表有没有明显遗漏,然后看生成的场景图是否和剧本描述匹配,最后播放合成视频检查音画同步。每一步的输出都单独存一份,出问题时能快速定位是哪个环节的锅。

我自己的习惯是每换一个小说来源(比如从番茄小说换成其他平台),先跑一遍分章和抽取,人工检查前 10 章的结果,确认没有系统性问题后再批量跑。这个前置检查花 10 分钟,能省掉后面几个小时的返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询