乐于分享
好东西不私藏

火宝短剧源码深度解析:5 个 AI Agent 如何把小说变成短视频

火宝短剧源码深度解析:5 个 AI Agent 如何把小说变成短视频

前言

最近在 GitHub 上看到一个很有意思的开源项目——Huobao Drama,一个基于 AI Agent 的短剧自动化生产平台。它能把一篇小说自动变成短剧视频:改写剧本、提取角色、拆解分镜、生成图片、合成视频,全流程由 5 个 AI Agent 协作完成。

这个项目的架构设计很有参考价值——它不是简单地调 LLM 接口,而是用 Mastra AI Agent 框架 + Tool Use + Skill 注入的方式,让每个 Agent 各司其职。今天我们从源码层面拆解它的核心设计。

9c1a178dc0843730ba9fc5dd9274d82e

01839a76f0dae8ae08cd397d3ba41437

一、整体架构:一条 11 步流水线

先看项目结构:

huobao-drama/├── backend/    — Hono + Drizzle ORM + Mastra AI Agents + better-sqlite3├── frontend/   — Nuxt 3 + Vue 3 + TypeScript (纯 CSS,无 UI 框架)├── skills/     — Agent 技能定义 (SKILL.md)├── configs/    — config.yaml 配置文件└── data/       — SQLite 数据库 + 生成资源文件

后端用 Hono(轻量级 Node.js HTTP 框架)+ Drizzle ORM(类型安全的 ORM)+ better-sqlite3,前端用 Nuxt 3 纯 CSS 实现暗色主题 UI。

核心生产流程是一条 11 步流水线

原始小说 → ① 剧本改写 → ② 角色/场景提取 → ③ 分镜拆解         → ④ 音色分配 → ⑤ 角色/场景图生成 → ⑥ 宫格图生成         → ⑦ 视频生成 → ⑧ TTS 配音 → ⑨ 单镜头合成         → ⑩ 整集拼接 → ⑪ 导出

其中 ①②③④ 由 AI Agent 驱动,⑤⑥⑦ 由多厂商 AI 服务驱动,⑧⑨⑩ 由 FFmpeg 完成。


二、Agent 工厂:动态创建 + Skill 注入

2.1 核心入口:createAgent()

整个 Agent 系统的核心是 backend/src/agents/index.ts 的工厂函数:

// backend/src/agents/index.tsimport { Agent } from'@mastra/core/agent'import { createOpenAI } from'@ai-sdk/openai'exportfunctioncreateAgent(typestring, episodeId: number, dramaId: number): Agent | null{const defaults = DEFAULT_PROMPTS[type]if (!defaults) returnnullconst dbConfig = getAgentConfig(type)  // 从数据库读取配置const model = getModel(dbConfig)       // 构建 AI 模型实例// 关键:基础指令 + Skill 注入const baseInstructions = dbConfig?.systemPrompt?.trim() || defaults.instructionsconst skillInstructions = loadAgentSkills(type)const instructions = skillInstructions    ? [baseInstructions, '', skillInstructions].join('\n')    : baseInstructions// 根据类型注入不同的工具集let tools: Record<stringany> = {}switch (type) {case'script_rewriter': tools = createScriptTools(episodeId); breakcase'extractor': tools = createExtractTools(episodeId, dramaId); breakcase'storyboard_breaker': tools = createStoryboardTools(episodeId, dramaId); breakcase'voice_assigner': tools = createVoiceTools(episodeId, dramaId); breakcase'grid_prompt_generator': tools = createGridPromptTools(episodeId, dramaId); break  }returnnew Agent({ id: type, name, instructions, model, tools })}

设计亮点:每次请求都动态创建 Agent,把 episodeId 和 dramaId 注入工具闭包。 这意味着 Agent 的每个工具在执行时天然知道自己在操作哪一集、哪个短剧项目。

2.2 模型层:统一 OpenAI 兼容协议

functiongetModel(dbConfig: any{const textConfig = getTextConfig()const resolvedBaseURL = getTextProviderBaseUrl(textConfig)const provider = createOpenAI({    baseURL: resolvedBaseURL,    apiKey: textConfig.apiKey,  } asany)const modelName = dbConfig?.model || textConfig.modelreturn provider.chat(modelName)}

所有文本模型统一通过 @ai-sdk/openai 的 createOpenAI 创建,只需换 baseURL 就能接入不同厂商(OpenAI、OpenRouter、火山引擎、阿里等)。这是 Vercel AI SDK 的经典设计——用 OpenAI 兼容协议统一所有 LLM Provider。

2.3 Skill 系统:SKILL.md 热注入

Skill 是这个项目很有意思的设计。每个 Agent 有独立的技能定义文件:

skills/├── script_rewriter/SKILL.md       # 剧本改写规范├── extractor/SKILL.md             # 角色/场景提取规范├── storyboard_breaker/SKILL.md    # 分镜拆解规范├── voice_assigner/SKILL.md        # 音色分配规范└── grid_prompt_generator/SKILL.md # 图片提示词规范

加载逻辑在 backend/src/agents/skills.ts

const AGENT_SKILL_MAP: Record<stringstring[]> = {  script_rewriter: ['script_rewriter'],  extractor: ['extractor'],  storyboard_breaker: ['storyboard_breaker'],// ...}functionreadSkill(skillId: string): string{const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md')if (!fs.existsSync(skillPath)) return''const raw = fs.readFileSync(skillPath, 'utf-8')return [`## Skill: ${skillId}`,    stripFrontmatter(raw),   // 去掉 YAML 头  ].join('\n')}exportfunctionloadAgentSkills(agentType: string): string{const skillIds = AGENT_SKILL_MAP[agentType] || []const contents = skillIds.map(readSkill).filter(Boolean)if (!contents.length) return''return ['以下是该 Agent 专属的项目技能规范(SKILL.md)。','你必须在不违背当前工具边界的前提下优先遵守这些规范;若与用户明确要求冲突,以用户要求为准。','',    contents.join('\n\n'),  ].join('\n')}

Skill 在运行时从文件系统读取,拼接到 Agent 的 system prompt 中。 这意味着你可以不重启服务,直接修改 SKILL.md 文件就能调整 Agent 行为。这比把 prompt 写死在代码里灵活得多。


三、五大 Agent 详解

Agent 1:script_rewriter(剧本改写)

职责: 把小说原文转成格式化剧本。

工具集:read_episode_script → save_script

工作流程:

  1. 调用 read_episode_script 读取 episodes.content(原始小说内容)
  2. Agent 自行改写为分场景的格式化剧本
  3. 调用 save_script 保存到 episodes.scriptContent

输出格式:

## S01 | 内景 · 咖啡厅 | 午后林悦坐在窗边的位置上,指尖无意识地搅动着咖啡。林悦:(苦笑)又是一个人的下午茶。## S02 | 外景 · 街道 | 傍晚陈铭快步走过斑马线,手机不停地震动。

每个场景控制在 30-60 秒,这是短剧的黄金节奏。


Agent 2:extractor(角色/场景提取)

职责: 从格式化剧本中智能提取角色和场景信息,并与项目已有数据去重合并。

工具集(5 个):

exportfunctioncreateExtractTools(episodeId: number, dramaId: number{// 1. 读取格式化剧本const readScriptForExtraction = createTool({ ... })// 2. 读取项目中已存在的角色(用于去重)const readExistingCharacters = createTool({ ... })// 3. 读取项目中已存在的场景(用于去重)const readExistingScenes = createTool({ ... })// 4. 智能保存角色(按名字去重)const saveDedupCharacters = createTool({ ... })// 5. 智能保存场景(按地点+时间段去重)const saveDedupScenes = createTool({ ... })}

去重逻辑是亮点。 看 saveDedupCharacters 的实现:

const saveDedupCharacters = createTool({  id: 'save_dedup_characters',  inputSchema: z.object({    characters: z.array(z.object({      name: z.string(),      role: z.string().optional(),      appearance: z.string().optional(),      personality: z.string().optional(),    })),  }),  execute: async ({ characters }) => {for (const char of characters) {const existing = db.select().from(schema.characters)        .where(eq(schema.characters.dramaId, dramaId)).all()        .filter(c => !c.deletedAt)        .find(c => c.name === char.name)  // 按名字精确匹配if (existing) {// 已存在:合并信息,保留已有数据        db.update(schema.characters).set({          role: char.role || existing.role,          appearance: char.appearance || existing.appearance,          personality: char.personality || existing.personality,        }).where(eq(schema.characters.id, existing.id)).run()        linkCharToEpisode(episodeId, existing.id)  // 关联到当前集      } else {// 不存在:创建新角色const res = db.insert(schema.characters).values({ ... }).run()        linkCharToEpisode(episodeId, Number(res.lastInsertRowid))      }    }  },})

为什么需要去重? 因为短剧有多集。第 1 集出现的”林悦”和第 3 集出现的”林悦”是同一个角色。如果每集都创建一个新的,角色图片、配音就无法复用。通过 episodeCharacters 多对多关联表,同一个角色可以横跨多集。

场景去重更细致——同一地点不同时间段算不同场景

// 按【地点 + 时间段】精确匹配.find(s => s.location === scene.location && s.time === (scene.time || ''))

“咖啡厅 · 午后” 和 “咖啡厅 · 深夜” 是两个不同的场景,因为光线、氛围完全不同。


Agent 3:storyboard_breaker(分镜拆解)

职责: 把格式化剧本拆解为逐镜头的分镜序列。这是整条流水线中最复杂的 Agent。

每个镜头包含 17 个字段:

字段
用途
title
镜头标题(3-8 字)
shot_type
景别:远景/全景/中景/近景/特写
angle
机位:平视/仰视/俯视
movement
运镜:固定/推镜/拉镜/摇镜/跟拍
action
角色动作与表演细节
dialogue
对白或旁白
image_prompt
静态画面提示词(给图片生成模型用)
video_prompt
动态视频提示词(给视频生成模型用)
bgm_prompt
配乐风格描述
sound_effect
音效描述
duration
时长(10-15 秒)
scene_id
关联的场景 ID
character_ids
关联的角色 ID 列表

视频提示词有专门的格式规范,按 3 秒一段时间轴编写:

0-3秒:<location>咖啡厅</location>,近景,<role>林悦</role>低头看手机,表情焦虑。<n>3-6秒:<location>咖啡厅</location>,全景,门铃响,<role>陈铭</role>推门走入。<n>6-9秒:中景,<role>陈铭</role>微笑走向林悦,坐下。

标签系统(<location><role><voice><n>)让后续的视频生成模型能准确理解场景切换、角色动作和时间节奏。

save_storyboards 工具会做校验:

functionvalidateStoryboardBindings(episodeId, sceneId, characterIds{const episodeSceneIds = getEpisodeSceneIds(episodeId)const episodeCharacterIds = getEpisodeCharacterIds(episodeId)if (sceneId != null && !episodeSceneIds.has(sceneId)) {thrownewError(`scene_id ${sceneId} 不属于当前集`)  }const invalidCharacterIds = (characterIds || []).filter(id => !episodeCharacterIds.has(id))if (invalidCharacterIds.length) {thrownewError(`character_ids 不属于当前集: ${invalidCharacterIds.join(', ')}`)  }}

Agent 不能凭空编造不存在的场景 ID 或角色 ID——所有关联必须指向前一步(extractor)已经提取并存入数据库的实体。这是 Agent 工具设计中的约束边界:用代码校验来弥补 LLM 可能出现的幻觉。


Agent 4:voice_assigner(音色分配)

职责: 为每个角色分配 TTS 音色。

流程简单但重要:

  1. list_voices 读取 ai_voices 表(MiniMax 预置音色库)
  2. get_characters 获取所有角色
  3. Agent 根据角色性别、性格、年龄匹配音色
  4. assign_voice 写入 characters.voiceStyle

SKILL.md 中的匹配策略:

- 性别匹配:男/女- 年龄匹配:青年/中年/少年/老年- 气质匹配:明亮/沉稳/深沉/甜美- 角色定位:主角(鲜明独特)/ 配角(中性自然)

Agent 5:grid_prompt_generator(宫格图提示词生成)

职责: 为角色肖像、场景背景、宫格图生成高质量的英文提示词。

宫格图是这个项目的特色功能。它把多个分镜画面拼成一张 NxM 的网格图,交给图片生成模型一次生成,然后再切分回去。这样做的好处是 风格一致性远好于逐帧生成

三种模式:

  • first_frame:每格是一个镜头的开头画面
  • first_last:交替排列首帧和尾帧,形成节奏感
  • multi_ref:同一个镜头的多角度参考图

四、Agent 执行引擎:Tool Use 循环

Agent 通过 HTTP 接口被前端调用:

// backend/src/routes/agent.tsapp.post('/:type/chat'async (c) => {const agentType = c.req.param('type')const { message, drama_id, episode_id } = await c.req.json()// 动态创建 Agentconst agent = createAgent(agentType, episode_id, drama_id)// 执行:最多 20 步工具调用const result = await agent.generate(    [{ role: 'user', content: message }],    { maxSteps: 20 },  )return success(c, {    text: result.text || '',    toolCalls: normalizedToolCalls,    toolResults: normalizedToolResults,  })})

agent.generate() 是 Mastra 框架的核心方法。它内部实现了 ReAct 循环

用户消息 → LLM 思考 → 调用工具 → 获取结果 → LLM 继续思考 → 调用下一个工具 → ... → 返回最终文本

maxSteps: 20 限制了最大工具调用轮数,防止 Agent 无限循环。


五、多厂商适配层:策略模式

图片、视频、TTS 的生成涉及多个 AI 厂商。项目用 策略模式 + 注册表 做了优雅的抽象:

5.1 Adapter 接口

// backend/src/services/adapters/types.tsinterface ImageProviderAdapter {  provider: string  buildGenerateRequest(config: AIConfig, record: ImageGenerationRecord): ProviderRequest  parseGenerateResponse(result: any): ImageGenResponse  buildPollRequest(config: AIConfig, taskId: string): ProviderRequest  parsePollResponse(result: any): ImagePollResponse  extractImageUrl(result: any): string | null  extractImageBase64(result: any): { data: string; mimeType: string } | null}

每个方法对应生成流程的一个环节:构建请求、解析响应、构建轮询、解析轮询。不同厂商的差异被封装在各自的 Adapter 里。

5.2 注册表

// backend/src/services/adapters/registry.tsexportconst imageAdapters: Record<string, ImageProviderAdapter> = {  minimax: new MiniMaxImageAdapter(),  openai:  new OpenAIImageAdapter(),  gemini:  new GeminiImageAdapter(),  volcengine: new VolcEngineImageAdapter(),  ali:     new AliImageAdapter(),  chatfire: new OpenAIImageAdapter(),  // Chatfire 复用 OpenAI 协议}exportconst videoAdapters: Record<string, VideoProviderAdapter> = {  minimax:    new MiniMaxVideoAdapter(),  volcengine: new VolcEngineVideoAdapter(),  vidu:       new ViduVideoAdapter(),  ali:        new AliVideoAdapter(),}exportconst ttsAdapters: Record<string, TTSProviderAdapter> = {  minimax: new MiniMaxTTSAdapter(),}

要加新厂商?实现接口,注册一行代码。

5.3 同步 vs 异步模式

不同厂商的响应模式不同:

  • OpenAI DALL-E:同步返回图片 URL
  • Gemini:同步返回 base64 数据
  • MiniMax/火山引擎/阿里:异步返回 taskId,需要轮询
  • Vidu:异步,通过 Webhook 回调

统一的处理逻辑:

// backend/src/services/image-generation.tsconst { isAsync, taskId, imageUrl } = adapter.parseGenerateResponse(result)if (!isAsync && imageUrl) {// 同步 + URL:直接下载await handleImageComplete(id, config.provider, imageUrl)elseif (!isAsync && !imageUrl) {// 同步 + base64(Gemini):保存为文件const b64 = adapter.extractImageBase64(result)await handleImageCompleteBase64(id, config.provider, b64.data, b64.mimeType)else {// 异步:记录 taskId,开始轮询  db.update(schema.imageGenerations)    .set({ taskId, status: 'processing' })    .where(eq(schema.imageGenerations.id, id)).run()  pollImageTask(id, config, taskId!)}

轮询有 10 分钟超时、120 次最大尝试、5 秒间隔,错误时自动重试。


六、FFmpeg 合成:从碎片到成片

6.1 单镜头合成

每个分镜的合成是 composeStoryboard() 函数完成的,它把三样东西揉在一起:

视频(AI 生成的裸视频) + TTS 音频(对白配音) + SRT 字幕 = 成品镜头

核心流程:

// backend/src/services/ffmpeg-compose.tsexportasyncfunctioncomposeStoryboard(storyboardId: number): Promise<string{// 1. 解析对白,识别说话人const parsedDialogue = parseDialogueForTTS(sb.dialogue)// 2. 找到角色对应的音色,调用 TTS 生成配音if (!parsedDialogue.ignorable) {const chars = db.select().from(schema.characters)      .where(eq(schema.characters.dramaId, ep.dramaId)).all()const found = chars.find(c => c.name === parsedDialogue.speaker)if (found?.voiceStyle) voiceId = found.voiceStyleconst ttsPath = await generateTTS({      text: parsedDialogue.pureText,      voice: voiceId,    })  }// 3. 生成 SRT 字幕文件const srtContent = `1\n00:00:00,500 --> 00:00:${duration-1},000\n${pureText}\n`  fs.writeFileSync(subtitlePath, srtContent, 'utf-8')// 4. FFmpeg 合成let cmd = ffmpeg(videoPath)if (audioPath) cmd = cmd.input(audioPath)if (subtitlePath) {    cmd = cmd.videoFilter([`subtitles=filename='${escapedPath}':force_style='FontSize=20,...'`])  }  cmd.outputOptions(['-c:v''libx264''-preset''fast''-crf''23'])     .output(outputPath).run()}

对白解析有细节处理——过滤掉环境音、音效等非人声内容:

const IGNORE_TTS_SPEAKERS = /^(环境音|音效|sfx|bgm|背景音乐|ambient)$/iconst IGNORE_TTS_TEXT = /^(无|无对白|none|null|纯音效|仅环境音)$/i

6.2 整集拼接

所有镜头合成完成后,用 FFmpeg concat 协议拼接为一集完整视频:

// backend/src/services/ffmpeg-merge.tsasyncfunctiondoMerge(mergeId, episodeId, videos{// 生成 concat 列表文件const listContent = videos    .map(v =>`file '${toAbsPath(v)}'`)    .join('\n')  fs.writeFileSync(listPath, listContent, 'utf-8')// FFmpeg concat 拼接  ffmpeg()    .input(listPath)    .inputOptions(['-f''concat''-safe''0'])    .outputOptions(['-c:v''libx264''-preset''medium''-crf''23','-c:a''aac''-ar''48000''-b:a''192k','-movflags''+faststart',  // Web 播放优化    ])    .output(outputPath).run()// 更新 episode.videoUrl  db.update(schema.episodes)    .set({ videoUrl: mergedRelative })    .where(eq(schema.episodes.id, episodeId)).run()}

-movflags +faststart 是个重要细节——它把 MP4 的 moov atom 移到文件头,让浏览器可以边下载边播放。


七、数据库设计:16 张表的关系网

项目使用 Drizzle ORM + SQLite(WAL 模式),一共 16 张表。核心关系:

dramas (1) ──── (N) episodes  │                    │  ├── (N) characters ──┤── episode_characters (多对多)  │                    │  ├── (N) scenes ──────┤── episode_scenes (多对多)  │                    │  └── (N) props        └── (N) storyboards                              │                              ├── storyboard_characters (多对多)                              ├── image_generations                              ├── video_generations                              └── assets

几个值得关注的设计:

1. 多对多关联支持跨集复用

角色和场景挂在 drama 级别,通过中间表关联到 episode。第 1 集提取的”林悦”在第 3 集可以直接复用,保持角色形象、配音的一致性。

2. AI 配置支持多厂商切换

ai_service_configs 表支持同一类型(如 image)配置多个厂商,通过 priority 字段选择默认。episodes 表还有 imageConfigIdvideoConfigIdaudioConfigId 三个字段,支持逐集指定不同的 AI 服务。

3. 生成任务全链路追踪

image_generations 和 video_generations 表记录了每次生成的完整信息:prompt、model、provider、status、taskId、errorMsg、localPath。任何一次生成失败,都能定位到具体的请求参数。


八、设计模式总结

回顾整个项目,几个设计模式值得借鉴:

1. 工厂模式 + 闭包注入

// Agent 工厂:每次请求创建新 Agent,通过闭包注入上下文exportfunctioncreateAgent(type, episodeId, dramaId{ ... }// 工具工厂:每个 Agent 类型有独立的工具集,通过闭包绑定数据exportfunctioncreateExtractTools(episodeId, dramaId{ ... }

Agent 是无状态的,所有上下文通过闭包传递给工具。这意味着同一个 Agent 类型可以同时处理不同集的请求而互不干扰。

2. 策略模式 + 注册表

多厂商适配不用 if-else,而是用接口 + 注册表:

const adapter = getImageAdapter(config.provider)  // 一行代码拿到对应策略const { url, method, headers, body } = adapter.buildGenerateRequest(config, record)

3. Skill 即 Prompt

把 Agent 的专业知识(分镜规范、提示词工程、去重策略)写成独立的 Markdown 文件,运行时注入 system prompt。好处:

  • 可维护性:非开发者(如编剧、导演)也能修改 Skill 文件调整 Agent 行为
  • 热更新:不需要重启服务
  • 可复用:一个 Agent 可以加载多个 Skill

4. 工具约束 = LLM 护栏

Agent 的工具不是无条件执行的——validateStoryboardBindings 会校验 Agent 传入的 scene_id 和 character_ids 是否合法。这是用代码在 LLM 幻觉和数据库真实状态之间建立了一道防线。


九、如果要二次开发

如果你想基于这个项目做二次开发,几个建议:

  1. 加新 Agent:在 DEFAULT_PROMPTS 加一条、在 switch 加一个 case、创建工具文件、写 SKILL.md
  2. 加新图片/视频厂商:实现 ImageProviderAdapter / VideoProviderAdapter 接口,在 registry.ts 注册
  3. 调整 Agent 行为:直接改 skills/ 下的 SKILL.md,不需要改代码
  4. 换 LLM:在设置页面切换 text provider,或修改 ai_service_configs 表

总结

火宝短剧这个项目的源码质量不错,值得一读的核心设计有:

  • 5 个 Agent 各司其职:剧本改写、角色提取、分镜拆解、音色分配、提示词生成,形成清晰的流水线
  • 工具闭包 + Zod 校验:每个 Agent 的工具都是带上下文的闭包,输入用 Zod schema 约束
  • Skill 热注入:专业知识写在 Markdown 文件里,运行时拼接到 system prompt
  • 策略模式适配多厂商:接口 + 注册表,新增厂商只需实现接口并注册
  • 全链路可追溯:每次 AI 调用都有完整的数据库记录

如果你对 AI Agent 的工程实践感兴趣,这个项目是一个很好的学习案例——它展示了如何把”调一下 API”变成一套可维护、可扩展的生产系统。

项目地址:https://github.com/chatfire-AI/huobao-drama