首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >AI漫剧全流程制作实战:从剧本到成片,手把手搭建自动化管线

AI漫剧全流程制作实战:从剧本到成片,手把手搭建自动化管线

原创
作者头像
用户12339161
修改2026-08-04 13:40:21
修改2026-08-04 13:40:21
1990
举报

AI漫剧全流程制作实战:从剧本到成片,手把手搭建自动化管线

作者:某厂算法工程师 | 本文首发于思否,全文约1.2万字,含完整工程代码

写在前面

2025年下半年我开始尝试用AI制作短篇漫剧(Motion Comic),目标是实现“输入一个故事梗概,输出一段2~3分钟带配音和动态效果的视频”。一开始参考了网上很多教程,但要么只讲Stable Diffusion生图,要么只讲AnimateDiff,没有一个能端到端跑通。经过两个月反复迭代,我沉淀了一套相对稳定的生产管线,并在A100服务器上每天可批量产出10条成品。

这篇文章不讲空泛的概念,只讲如何落地。我会从环境配置、模型选型、核心代码、调参经验、故障修复五个维度展开,并附上我使用的全部脚本(已脱敏)。相信读完您也能在3天内搭建出自己的漫剧工厂。


一、系统架构与硬件选型

1.1 整体流程图(文字版)

代码语言:javascript
复制
用户输入故事主题
    ↓
[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生成完整剧本,但发现它对“视觉描述”和“分镜时长”的估计很不准。后来拆成两步:

  1. GPT-4(或Qwen-72B)生成故事大纲+分场,输出固定JSON格式。
  2. Mistral-7B 专门负责将每个场景的visual_desc转化为SDXL可接受的提示词,并自动补全负面词。

2.2 核心代码(LLM调用封装)

代码语言:javascript
复制
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
复制
# 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
复制
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调用脚本(基于diffusersAnimateDiffPipeline,但该版本当时还不成熟,我改用社区版animatediff-cli,并改造其generate.py):

代码语言:javascript
复制
# 关键参数(存于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 LoRAT2V-0。我采用的是MotionDirector方法,在生成时传入“motion prompt”(如“缓慢左移”),它会修改attention map。但经过测试,简单的“平移”效果还行,“旋转”则容易崩。因此我放弃复杂运动,只保留zoom in/out, pan left/right四种。

代码中通过修改MotionAdapter中的motion_scaletranslation参数实现(基于社区hack):

代码语言:javascript
复制
# 在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
复制
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
复制
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
复制
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并指定。


七、性能优化与批处理经验

7.1 显存管理

  • 生成关键帧时,每次生成完立即torch.cuda.empty_cache(),防止碎片。
  • AnimateDiff生成16帧约占用14GB显存,如果同时运行多个任务,使用CUDA_VISIBLE_DEVICES隔离。
  • 我将所有模型加载到内存中,避免重复加载LoRA(用pipe.load_lora_weights每次耗时0.5秒,累积也可观)。

7.2 时间成本实测

环节

耗时(单场景)

LLM生成剧本(含分镜)

~3秒

关键帧(4张,含ControlNet+IP-Adapter)

~25秒(A100)

AnimateDiff(16帧)

~60秒

TTS+Wav2Lip

~20秒

合成

~5秒

总计(10场景)

约18分钟

7.3 常见错误与修复表

错误现象

原因

解决方案

生成的角色衣服颜色突变

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截取视频,不要依赖预估时长


八、项目目录结构与一键运行

代码语言:javascript
复制
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只需一行:

代码语言:javascript
复制
from scripts.pipeline import run_pipeline
run_pipeline(theme="雨天咖啡馆告别", output_dir="outputs/my_comic")

内部已封装所有异常重试机制(如Wav2Lip失败自动跳过口型)。


九、未来改进与致谢

当前管线仍有不足:

  • 多人同框时角色一致性会互相干扰(计划引入FaceID);
  • 运动多样性有限(后续会尝试SVD和CogVideoX)。

但就目前而言,它已经能稳定生成60秒内的高质量漫剧,帮助我团队节省了80%的漫画制作成本。

如果您在搭建过程中遇到任何问题,欢迎在评论区留言,我会定期回复。代码已脱敏并上传至GitHub(待整理后公开),敬请关注。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • AI漫剧全流程制作实战:从剧本到成片,手把手搭建自动化管线
    • 写在前面
    • 一、系统架构与硬件选型
      • 1.1 整体流程图(文字版)
      • 1.2 硬件与软件版本
    • 二、剧本与分镜:让LLM输出可解析的JSON
      • 2.1 为什么要用两个LLM?
      • 2.2 核心代码(LLM调用封装)
    • 三、角色一致性:LoRA训练 + IP-Adapter调参实录
      • 3.1 LoRA训练细节(Kohya_ss)
      • 3.2 IP-Adapter + ControlNet 联合生成关键帧
      • 3.3 一致性保障的“三板斧”
    • 四、动态化:AnimateDiff 的硬核调参
      • 4.1 为什么要自己写采样循环?
      • 4.2 核心参数配置
      • 4.3 运动描述如何传给AnimateDiff?
    • 五、音频及口型同步(踩坑合集)
      • 5.1 Edge TTS 多情感语音
      • 5.2 Wav2Lip 与 GFPGAN 的工程化
    • 六、自动化剪辑与合成(带转场和字幕)
    • 七、性能优化与批处理经验
      • 7.1 显存管理
      • 7.2 时间成本实测
      • 7.3 常见错误与修复表
    • 八、项目目录结构与一键运行
    • 九、未来改进与致谢
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档