
作者:某厂算法工程师 | 本文首发于思否,全文约1.2万字,含完整工程代码
2025年下半年我开始尝试用AI制作短篇漫剧(Motion Comic),目标是实现“输入一个故事梗概,输出一段2~3分钟带配音和动态效果的视频”。一开始参考了网上很多教程,但要么只讲Stable Diffusion生图,要么只讲AnimateDiff,没有一个能端到端跑通。经过两个月反复迭代,我沉淀了一套相对稳定的生产管线,并在A100服务器上每天可批量产出10条成品。
这篇文章不讲空泛的概念,只讲如何落地。我会从环境配置、模型选型、核心代码、调参经验、故障修复五个维度展开,并附上我使用的全部脚本(已脱敏)。相信读完您也能在3天内搭建出自己的漫剧工厂。
用户输入故事主题
↓
[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 拼接、加字幕、转场/models/ 下):
stabilityai/stable-diffusion-xl-base-1.0roleA.safetensors, roleB.safetensorsip-adapter-plus_sdxl.binlllyasviel/control_v11p_sd15_openpose(AnimateDiff用SD1.5版)guoyww/animatediff-motion-adapter-v1-5-2wav2lip_gan.pthGFPGANv1.4.pth刚开始我直接用GPT-4生成完整剧本,但发现它对“视觉描述”和“分镜时长”的估计很不准。后来拆成两步:
visual_desc转化为SDXL可接受的提示词,并自动补全负面词。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。
我为每个主要角色准备了20张半身像(不同角度、表情),用Stable Diffusion 1.5生成(因为SDXL训练较慢)。训练参数如下:
# 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。
我直接使用ComfyUI的API进行批量生成,但为了完全可控,我改用diffusers库编写自定义pipeline,便于集成到调度脚本中。
以下是一个完整的单帧生成函数(使用SDXL + IP-Adapter + OpenPose ControlNet):
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库),速度更快且稳定。但上述代码是理解原理的关键。
官方AnimateDiff直接生成16帧,但很多时候运动不是太强就是太弱。我拆解了它的采样过程,对每个去噪步动态调整motion_scale,以实现“先慢后快”或“匀速”效果。
我的AnimateDiff调用脚本(基于diffusers的AnimateDiffPipeline,但该版本当时还不成熟,我改用社区版animatediff-cli,并改造其generate.py):
# 关键参数(存于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。AnimateDiff本身没有文本控制运动,需要借助Motion LoRA或T2V-0。我采用的是MotionDirector方法,在生成时传入“motion prompt”(如“缓慢左移”),它会修改attention map。但经过测试,简单的“平移”效果还行,“旋转”则容易崩。因此我放弃复杂运动,只保留zoom in/out, pan left/right四种。
代码中通过修改MotionAdapter中的motion_scale和translation参数实现(基于社区hack):
# 在pipeline中注入平移向量
pipe.motion_adapter.set_motion_scale(motion_scale)
pipe.motion_adapter.set_translation(dx=0.0, dy=-0.02) # 缓慢上移Edge TTS的语音列表可查,注意中文情感参数是style,如"style": "sad"。我写了一个批量生成函数,并为每个角色分配固定voice(避免音色突变)。
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修剪首尾静音,并精确计算有效时长。
Wav2Lip官方代码需要逐帧处理,我将其封装为一个函数,输入视频路径和音频路径,输出带口型的视频(仅面部区域)。由于Wav2Lip对光照敏感,我在预处理时用retinaface检测人脸并做直方图均衡。
关键步骤:
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核心类:
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 export(self, output_path, transition_duration=0.5):
# 添加淡入淡出转场
final_clips = []
for i, clip in enumerate(self.temp_clips):
if i > 0:
clip = clip.crossfadein(transition_duration)
final_clips.append(clip)
final = concatenate_videoclips(final_clips, method="compose")
final.write_videofile(output_path, fps=24, codec='libx264', audio_codec='aac')注意:中文显示需要安装中文字体,否则TextClip会乱码。我在服务器上放置了/usr/share/fonts/SimHei.ttf并指定。
torch.cuda.empty_cache(),防止碎片。CUDA_VISIBLE_DEVICES隔离。pipe.load_lora_weights每次耗时0.5秒,累积也可观)。环节 | 耗时(单场景) |
|---|---|
LLM生成剧本(含分镜) | ~3秒 |
关键帧(4张,含ControlNet+IP-Adapter) | ~25秒(A100) |
AnimateDiff(16帧) | ~60秒 |
TTS+Wav2Lip | ~20秒 |
合成 | ~5秒 |
总计(10场景) | 约18分钟 |
错误现象 | 原因 | 解决方案 |
|---|---|---|
生成的角色衣服颜色突变 | LoRA过拟合或IP-Adapter权重过高 | 降低IP-Adapter至0.6,增加prompt中的颜色约束 |
AnimateDiff视频闪烁 | motion_scale过大或context_overlap不足 | 增大overlap至6,降低scale至0.8 |
Wav2Lip口型滞后 | 音频帧率与视频不匹配 | 统一视频FPS=24,音频采样率=16000,并使用ffmpeg重新采样 |
合成后音画不同步 | 剪辑时未按音频精确截取视频 | 在合成前用audio.duration截取视频,不要依赖预估时长 |
comic_pipeline/
├── config.yaml # 所有模型路径、参数
├── scripts/
│ ├── generate_script.py
│ ├── generate_frames.py
│ ├── animate_diff.py
│ ├── tts.py
│ ├── wav2lip_sync.py
│ └── compositor.py
├── models/ # 存放所有预训练权重
├── outputs/ # 每部作品输出目录
├── temp/ # 中间文件
└── main.py # 总入口main.py只需一行:
from scripts.pipeline import run_pipeline
run_pipeline(theme="雨天咖啡馆告别", output_dir="outputs/my_comic")内部已封装所有异常重试机制(如Wav2Lip失败自动跳过口型)。
当前管线仍有不足:
但就目前而言,它已经能稳定生成60秒内的高质量漫剧,帮助我团队节省了80%的漫画制作成本。
如果您在搭建过程中遇到任何问题,欢迎在评论区留言,我会定期回复。代码已脱敏并上传至GitHub(待整理后公开),敬请关注。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。