用户12339161 AI漫剧全流程制作实战:从剧本到成片,手把手搭建自动化管线 原创 用户12339161 修改 2026-08-04 13:40:21 修改于 2026-08-04 13:40:21 AI漫剧全流程制作实战:从剧本到成片,手把手搭建自动化管线 作者:某厂算法工程师 | 本文首发于思否,全文约1.2万字,含完整工程代码 写在前面 2025年下半年我开始尝试用AI制作短篇漫剧(Motion Comic),目标是实现“输入一个故事梗概,输出一段2~3分钟带配音和动态效果的视频”。一开始参考了网上很多教程,但要么只讲Stable Diffusion生图,要么只讲AnimateDiff,没有一个能端到端跑通。经过两个月反复迭代,我沉淀了一套相对稳定的生产管线,并在A100服务器 上每天可批量产出10条成品。 这篇文章不讲空泛的概念,只讲如何落地。我会从环境配置、模型选型、核心代码、调参经验、故障修复五个维度展开,并附上我使用的全部脚本(已脱敏)。相信读完您也能在3天内搭建出自己的漫剧工厂。
系统架构与硬件选型
1.1 整体流程图(文字版) 代码语言: javascript AI代码解释 用户输入故事主题 [LLM] 生成结构化分镜JSON(含场景描述、对话、时长) [分镜→提示词] 用另一个LLM转换为SD正/负向提示词 + ControlNet控制条件 [角色一致性] 加载预训练的LoRA(角色A/B) + IP-Adapter(标准照) [关键帧生成] SDXL + ControlNet(OpenPose/Depth) 批量出图,每场景4~6帧 [动态化] AnimateDiff v3 生成16帧短视频片段(可调节运动幅度) [音频] Edge TTS 多情感语音生成 + 时间轴对齐 [口型] Wav2Lip + GFPGAN 面部增强(仅针对说话人) [合成] MoviePy + FFmpeg 拼接、加字幕、转场 1.2 硬件与软件版本 GPU : NVIDIA A100 (40GB) 或 RTX 4090 (24GB) —— 前者可并行跑多个任务,后者单任务足够。 操作系统: Ubuntu 22.04, CUDA 12.1, PyTorch 2.3.0 核心库版本: diffusers==0.26.0 transformers==4.38.0 accelerate==0.28.0 opencv-python==4.9.0 moviepy==1.0.3 edge-tts==6.1.0 facexlib==0.3.0 (用于Wav2Lip预处理) 模型文件(存放于 /models/ 下): SDXL base: stabilityai/stable-diffusion-xl-base-1.0 LoRA: 自训练 roleA.safetensors , roleB.safetensors IP-Adapter: ip-adapter-plus_sdxl.bin ControlNet: lllyasviel/control_v11p_sd15_openpose (AnimateDiff用SD1.5版) AnimateDiff: guoyww/animatediff-motion-adapter-v1-5-2 Wav2Lip: wav2lip_gan.pth GFPGAN: GFPGANv1.4.pth
剧本与分镜:让LLM输出可解析的JSON
2.1 为什么要用两个LLM? 刚开始我直接用GPT-4生成完整剧本,但发现它对“视觉描述”和“分镜时长”的估计很不准。后来拆成两步: GPT-4(或Qwen-72B)生成故事大纲+分场,输出固定JSON 格式。 Mistral-7B 专门负责将每个场景的visual_desc转化为SDXL可接受的提示词,并自动补全负面词。 2.2 核心代码(LLM调用封装) 代码语言: javascript AI代码解释 import json import requests
def generate_script(theme: str) -> list: 调用本地Qwen-72B服务(OpenAI兼容接口) 返回 list[dict],每个dict包含 scene_id, location, characters, visual_desc, dialogue(list of {speaker, text}), duration_sec system = """你是一个漫剧编剧。输出严格JSON数组,每个元素结构如下:
"scene_id": 0,
"location": "咖啡馆", "characters": ["男主", "女主"], "visual_desc": "雨天,室内暖光,女主低头端咖啡杯,男主望向窗外", "dialogue": [{"speaker": "女主", "text": "我明天就要走了。"}], "duration_sec": 6
对话要简洁,每场景不超过4句。 user = f"主题:{theme}" payload = {
"model": "Qwen/Qwen-72B-Chat", "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], "temperature": 0.7
resp = requests.post("http://localhost:8000/v1/chat/completions", json=payload) data = resp.json()["choices"][0]["message"]["content"] # 提取JSON(防止LLM输出多余文字) import re json_str = re.search(r'\[.*\]', data, re.DOTALL).group() return json.loads(json_str)
def enhance_prompt(visual_desc: str, style: str = "anime") -> tuple: 调用Mistral-7B将视觉描述转为SD正负提示词 返回 (positive, negative) prompt = f"""将以下场景描述转化为Stable Diffusion提示词,画风为{style}。 输出格式:正面提示词:... 负面提示词:... 描述:{visual_desc}
# 调用本地Mistral服务(省略) # 示例返回: pos = "masterpiece, best quality, anime style, rainy day, warm interior lighting, a young woman holding a coffee cup, looking down, sad expression, detailed background" neg = "bad anatomy, blurry, ugly, watermark, lowres, extra limbs" return pos, neg 踩坑: LLM有时会生成“镜头移动”等非视觉词,需要在提示词中强调只描述静态画面,动态部分留给AnimateDiff。
角色一致性:LoRA训练 + IP-Adapter调参实录
3.1 LoRA训练细节(Kohya_ss) 我为每个主要角色准备了20张半身像(不同角度、表情),用Stable Diffusion 1.5生成(因为SDXL训练较慢)。训练参数如下: 代码语言: javascript AI代码解释 # config.json 关键参数 pretrained_model_name_or_path: "stabilityai/stable-diffusion-xl-base-1.0" v2: false v_parameterization: false learning_rate: 1e-4 lr_scheduler: "cosine" lr_warmup_steps: 100 train_batch_size: 2 epoch: 10 save_every_n_epochs: 2 mixed_precision: "fp16" network_module: "networks.lora" network_dim: 32 network_alpha: 16 训练过程中我监控了loss,大约在800步后趋于稳定(loss≈0.12)。最终生成的.safetensors约96MB。 关键心得:如果角色服饰颜色单一,LoRA容易过拟合导致背景也变得单一。因此我在训练集中混入不同背景的图片,并适当降低学习率至5e-5 。 3.2 IP-Adapter + ControlNet 联合生成关键帧 我直接使用ComfyUI的 API进行批量生成,但为了完全可控,我改用diffusers库编写自定义pipeline,便于集成到调度脚本中。 以下是一个完整的单帧生成函数(使用SDXL + IP-Adapter + OpenPose ControlNet): 代码语言: javascript AI代码解释 import torch from diffusers import StableDiffusionXLControlNetPipeline, ControlNetModel from diffusers import IPAdapterModel, IPAdapterProcessor from PIL import Image import cv2 import numpy as np
def generate_keyframe(
positive_prompt: str,
negative_prompt: str,
pose_image: Image.Image, # 姿态参考图(由上一帧或预设骨骼生成) identity_image: Image.Image, # 角色标准照 lora_path: str,
seed: int = 42 ) -> Image.Image:
# 1. 加载ControlNet controlnet = ControlNetModel.from_pretrained( "lllyasviel/control_v11p_sd15_openpose", # 注意SD1.5版 torch_dtype=torch.float16
# 2. 加载SDXL pipeline pipe = StableDiffusionXLControlNetPipeline.from_pretrained( "stabilityai/stable-diffusion-xl-base-1.0", controlnet=controlnet, torch_dtype=torch.float16 ).to("cuda")
# 3. 加载LoRA pipe.load_lora_weights(lora_path, adapter_name="role") pipe.set_adapters(["role"], adapter_weights=[1.0]) # 4. 加载IP-Adapter(需额外注入) ip_model = IPAdapterModel.from_pretrained("h94/IP-Adapter", subfolder="models", torch_dtype=torch.float16) ip_processor = IPAdapterProcessor() # 将identity_image编码 ip_image = identity_image.resize((224, 224)) ip_embeds = ip_processor(ip_image, return_tensors="pt").to("cuda") # 手动注入到pipeline(简化版,实际需调用pipe.unet的forward钩子,这里只展示思路) # 官方推荐用IPAdapterPipeline,但为保持ControlNet共存,我修改了__call__参数 # 实际我使用ComfyUI API,此处仅示意 # 5. 生成 generator = torch.Generator("cuda").manual_seed(seed) image = pipe( prompt=positive_prompt, negative_prompt=negative_prompt, image=pose_image, # ControlNet输入 num_inference_steps=30, guidance_scale=7.0, generator=generator, ip_adapter_embeds=ip_embeds, # 需扩展pipe支持 ).images[0]
return image 实际上,由于diffusers对IP-Adapter和ControlNet同时支持不够完善,我最终采用ComfyUI API(通过comfyui-client库),速度更快且稳定。但上述代码是理解原理的关键。 3.3 一致性保障的“三板斧” 固定随机种子:每个场景的所有关键帧使用同一个seed(但不同场景用不同seed),可保证风格统一。 首帧参考:场景1首帧完全由prompt生成,后续每个场景的首帧使用上一场景最后一帧作为ControlNet输入(姿势引导),防止跳跃。 IP-Adapter权重动态调整:当角色表情变化大时(如笑→哭),将IP-Adapter权重从0.8降至0.5,让prompt主导。
动态化:AnimateDiff 的硬核调参
4.1 为什么要自己写采样循环? 官方AnimateDiff直接生成16帧,但很多时候运动不是太强就是太弱。我拆解了它的采样过程,对每个去噪步动态调整motion_scale,以实现“先慢后快”或“匀速”效果。 4.2 核心参数配置 我的AnimateDiff调用脚本(基于diffusers 的AnimateDiffPipeline,但该版本当时还不成熟,我改用社区版animatediff-cli,并改造其generate.py ): 代码语言: javascript AI代码解释 # 关键参数(存于config.yaml) motion_adapter: "guoyww/animatediff-motion-adapter-v1-5-2" num_frames: 16 # 每次生成16帧 context_length: 16 context_overlap: 4 # 重叠4帧用于平滑 fps: 8 # 生成后插值到24fps motion_scale: 1.0 # 基础幅度 实际调参经验: 对话场景(人物面部特写):motion_scale=0.6~0.8,否则微表情过大导致五官扭曲。 全景场景(人物走动):motion_scale=1.2~1.5 ,并配合ControlNet(Depth) 防止背景漂移。 如果视频有闪烁,降低guidance_scale到6.0,并增加context_overlap 到6。 4.3 运动描述如何传给AnimateDiff? AnimateDiff本身没有文本控制运动,需要借助Motion LoRA或T2V-0。我采用的是MotionDirector 方法,在生成时传入“motion prompt”(如“缓慢左移”),它会修改attention map。但经过测试,简单的“平移”效果还行,“旋转”则容易崩。因此我放弃复杂运动,只保留zoom in/out, pan left/right四种。 代码中通过修改MotionAdapter 中的motion_scale 和translation参数实现(基于社区hack): 代码语言: javascript AI代码解释 # 在pipeline中注入平移向量 pipe.motion_adapter.set_motion_scale(motion_scale) pipe.motion_adapter.set_translation(dx=0.0, dy=-0.02) # 缓慢上移
音频及口型同步(踩坑合集)
5.1 Edge TTS 多情感语音 Edge TTS的语音列表可查,注意中文情感参数是style ,如"style": "sad"。我写了一个批量生成函数,并为每个角色分配固定voice(避免音色突变)。 代码语言: javascript AI代码解释 import asyncio import edge_tts
async def tts_generate(text, voice, output_path, rate='+0%', pitch='+0Hz'): communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch) await communicate.save(output_path) # 返回时长(秒) import wave with wave.open(output_path, 'rb') as wf: frames = wf.getnframes() rate = wf.getframerate() duration = frames / float(rate) return duration 注意:Edge TTS有时会生成末尾静音,导致视频音频长度不匹配。我统一在生成后使用pydub修剪首尾静音,并精确计算有效时长。 5.2 Wav2Lip 与 GFPGAN 的工程化 Wav2Lip官方代码需要逐帧处理,我将其封装为一个函数,输入视频路径和音频路径,输出带口型的视频(仅面部区域)。由于Wav2Lip对光照敏感,我在预处理时用retinaface 检测人脸并做直方图均衡。 关键步骤: 代码语言: javascript AI代码解释 def wav2lip_sync(video_path, audio_path, face_box=None): # 1. 提取所有帧 # 2. 对每一帧裁剪人脸,使用FaceXLib的detection # 3. 调用wav2lip的inference,批量生成人脸帧 # 4. 将生成的人脸贴回原图(使用mask融合) # 5. 保存新视频 # 具体代码较长,可参考开源项目Wav2Lip的demo 之后用GFPGAN增强人脸清晰度(因为Wav2Lip输出模糊)。这里需要注意只增强人脸区域,否则背景会过度锐化。我使用facexlib提取mask,然后只对mask区域做增强。 踩坑:当角色侧脸或遮挡时,Wav2Lip效果很差。我的应对方案是:仅对正面说话的特写镜头做口型同步,远景或背对镜头直接省略,避免画蛇添足。
自动化剪辑与合成(带转场和字幕)
MoviePy虽然方便,但处理长视频时内存占用大。我采用分段合成,最后用FFmpeg concat拼接。下面是实际使用的compositor.py 核心类: 代码语言: javascript AI代码解释 from moviepy.editor import VideoFileClip, AudioFileClip, CompositeVideoClip, TextClip, concatenate_videoclips import os
class ComicCompositor: def __init__(self, scenes, output_dir): self.scenes = scenes # list of dict with keys: video_path, audio_path, subtitle_text, start_time, end_time self.output_dir = output_dir self.temp_clips = []
def add_scene(self, scene): # video clip without audio vid = VideoFileClip(scene['video_path']) # trim to exact duration dur = scene.get('duration', vid.duration) vid = vid.subclip(0, min(dur, vid.duration)) # audio if os.path.exists(scene['audio_path']): aud = AudioFileClip(scene['audio_path']) # if audio shorter, loop or pad if aud.duration < dur: aud = aud.loop(duration=dur) else: aud = aud.subclip(0, dur) vid = vid.set_audio(aud) # subtitle txt = TextClip(scene['subtitle_text'], fontsize=28, color='white', stroke_color='black', stroke_width=1, font='SimHei') txt = txt.set_position(('center', 0.85), relative=True).set_duration(dur) final = CompositeVideoClip([vid, txt]) self.temp_clips.append(final)
def expo...