布局管理器和整体流程
一、自定义布局管理器(LayoutManager)介绍
项目中提供了一个通用的、基于 Manim 的布局管理器,以解决多元素同时放置时避免重叠、自动缩放和对齐的问题。该布局管理器由以下核心类构成:
LayoutRegion
描述一个矩形区域,包含
x_min、x_max、y_min、y_max四个属性。提供
width、height属性,返回区域宽高;get_center()方法返回该区域中心坐标。核心方法
place(mobject, aligned_edge, buff):将某个 Mobject(包含整个 VGroup)缩放并移动到该区域内,使其以aligned_edge(如UL、UR、LEFT等)所指示的边或点对齐,并在各边留出buff间距。实现思路:- 先计算给定 Mobject 的包围盒宽高;
- 根据区域可用宽高(减去两倍
buff)计算统一缩放因子,使 Mobject 能完整放入; - 将 Mobject 缩放,并将其对齐到指定边:
aligned_edge表示要把 Mobject 的哪一边对齐到区域对应边或角落; - 最终返回移动/缩放后的 Mobject,保证不超出区域。
LayoutAtom
- 代表布局定义中的“最小单元”,在内层配合
Layout使用,无需额外逻辑,仅用于占比计算。 - 其
_resolve_size(region)方法直接返回传入的 Region,不做拆分。
- 代表布局定义中的“最小单元”,在内层配合
LayoutDirection
- 枚举:
HORIZONTAL(水平切分)或VERTICAL(垂直切分),用于指导Layout如何在父区域里分配子区域。
- 枚举:
Layout
接收一个
LayoutDirection以及一个映射Map<String, (proportion, LayoutAtom|Layout)>,将一个父级 Region 水平或垂直切分成若干子区域:- 首先计算权重比例总和
proportion_sum。 - 将各自权重归一化,累加得到每个子区域在父区域上的起止比例位置;
- 每个子区域再被封装成新的
LayoutRegion并传给子元素(如果子元素本身是嵌套的Layout,则递归调用_resolve_size;否则传给LayoutAtom)。
- 首先计算权重比例总和
最终
resolve(scene: Scene)会从场景中获取camera.frame_width、camera.frame_height,创建初始LayoutRegion,并返回一个二级嵌套的 Map,对应每个区域的具体 Region 实例。
LayoutManager
负责将真正的 Mobject 或 VGroup 放入上面由
Layout计算出的子区域。构造时指定
scene、direction(埋点不常用)、buff(内边距)、background(背景色,影响默认文字/公式颜色和调色板)、palette(可选调色板)。核心方法:
register(mobject, important, color, opacity, adjust_camera):将单个 Mobject 或 VGroup 注册到内部容器self.container,并根据类型(Text、MathTex、其他 VMobject)自动分配颜色;若是VGroup,则对子元素统一设置颜色与透明度。update_color_and_weight(mobject, important, color_override, opacity):为新增的 Mobject 根据“是否重要”“是否指定颜色”等,计算最终颜色并应用;若是Text且important=True,还会加粗。update_color(sub, group_color_override):为 VGroup 内的子元素单独调整颜色,支持继承父级颜色或按调色板循环分配。set_background(background_color_name):更改背景色后,会遍历所有已注册的 Mobject,重新执行颜色分配逻辑(对于之前指定过“自定义颜色”的对象,尝试保留该颜色;对纯按调色板生成的颜色,则重新从新背景下的配色板中选取)。gentle_camera_adjustment():在容器内所有已注册的 Mobject(self.container)完成arrange(direction, buff)排列后,计算整体宽高并与当前摄像机 Frame 对比,必要时按最大缩放比例对摄像机frame进行scale(scale_factor),保证所有内容都能完整显示;最后将容器移动到画面顶边。arrange_objects(objects, direction, buff):辅助方法,将传入的 Mobject 列表包装成临时VGroup并做一次arrange排列,返回该临时组。
使用示例:
在场景
construct()中先调用:layout = Layout(LayoutDirection.VERTICAL, { "title": (1.0, LayoutAtom()), "body": (7.0, Layout(LayoutDirection.HORIZONTAL, { "text": (4.0, LayoutAtom()), "figure": (3.0, LayoutAtom()) })) }).resolve(self)生成各个子区域的
LayoutRegion:layout["title"]获取第一个垂直区域的 Regionlayout["body"]["text"]获取第二个垂直区域(body)里的左半个水平区域layout["body"]["figure"]获取第二个垂直区域(body)里的右半个水平区域
对各个 Mobject 或 VGroup 做:
layout["title"].place(title_mobject, aligned_edge=UL, buff=MED_SMALL_BUFF) layout["body"]["text"].place(text_group, aligned_edge=UL, buff=SMALL_BUFF) layout["body"]["figure"].place(figure_group, aligned_edge=ORIGIN, buff=MED_SMALL_BUFF)如果要动态调整子元素字体、颜色、粗细,只需在
register()时传递相应参数即可。
总结:通过上述类的协同,用户只需在场景代码里一句 Layout(...).resolve(self),就能得到各区的具体 Region;再用 place(...) 统一缩放、对齐,彻底避免了多元素“超出画面”或“手工调整位置”带来的繁琐与不一致。
二、代码生成、执行、视频渲染与流式输出流程
本节仅介绍:如何在 Java 端或 Linux 端,基于上一步 AI 生成的 Python 脚本,完成 Manim 代码运行、渲染出视频分片(HLS)并实时通过 SSE/JSON 流式输送给前端的关键环节。下面分为三部分阐述。
1. Python 脚本的生成(Code Generation)
输入:在 Java 后端,已经通过聊天模型(Gemini/其他)生成了 Manim 场景的 Python 代码文本(完整的
CombinedScene类或子场景代码),并存储在临时字符串变量code中。保存脚本:
- 将
code写入磁盘文件,路径示例scripts/{topic}_{雪花ID}.py。 - 保持脚本编码
UTF-8,以便 Manim 在 Linux 端能够正确解析中文、UTF-8 注释等。
- 将
注意事项:生成的脚本必须遵循 ManimCE v0.19.0 的 API 要求(用户已在 Prompt 中限制),且文件里不包含多余注释,以减少渲染时的解析开销。
在 Python 端,用户并未显式展示 AI 生成脚本部分的代码——它位于 Java 后端的
CodeGenerateService中,核心是调用PredictService.generate()得到纯文本后,使用工具函数CodeUtils.parsePythonCode()抽取代码,再写入本地文件。该过程已经在上一部分项目概述中说明,此处不再赘述。
2. 脚本执行与 HLS 分片(Manim 渲染)
所有 Python 脚本的实际渲染都在独立的 Linux 服务器上,通过以下步骤完成:
2.1 启动 HLS 会话
Java 端调用
LinuxService.startMainmSession(),内部等价于对LinuxClient.startMainmSession(apiBase, api_key)发起 HTTP 请求。Linux 服务端的
ManimHanlder.start():- 生成唯一
sessionId = SnowflakeIdUtils.id(),在服务器子目录./data/session/{sessionId}下创建目录并初始化main.m3u8文件; - 定义分片模板路径
./data/session/{sessionId}/segment_video_%03d.ts,以及startNumber=0, segmentDuration=2s; - 调用
NativeMedia.initPersistentHls(m3u8Path, tsPattern, startNumber, segmentDuration),开启 HLS 節流合成会话(返回值sessionIdPrt代表 C 库内部句柄); - 将结果封装成
ProcessResult{ sessionId, sessionIdPrt, output = m3u8Path }JSON 返回给 Java;
- 生成唯一
Java 端接收到 sessionIdPrt 与 m3u8Path 后,即可将它保存到数据库及内存,作为后续代码渲染时的上下文。
2.2 渲染某个场景脚本(单次请求)
Java 端通过
LinuxService.runManimCode(code, sessionIdPrt, m3u8Path, channelContext)发起 HTTP POST 请求到 Linux 端的/manim/index接口,请求体为完整的 Python 脚本字符串code,并在 URL 或请求头中附带session_prt、m3u8_path、stream=true(如果希望 SSE 实时流式返回日志)。Linux 端的
ManimHanlder.index(HttpRequest):若
stream=true,则先对 HTTP 响应启用 Server-Sent Events,保持连接不关闭;将请求体里的 Python 脚本写入本地临时文件:
folder = "scripts/{taskId}" # 例如 scripts/163489243... scriptPath = folder + "/script.py" FileUtil.writeString(code, scriptPath, "UTF-8")调用
ManimCodeExecuteService.executeCode(scriptPath, taskFolder)执行渲染:在
scriptPath同级路径下构造manim_utils.py等辅助文件,并拷贝到同一目录;在
cache/{taskId}下创建缓存目录taskFolder,该目录用于存放 Manim 渲染生成的资源与分片;调用
ProcessBuilder("manim", "-ql", "--fps", "10", "--progress_bar", "none", "--verbosity", "WARNING", "--media_dir", taskFolder, "--output_file", "CombinedScene", scriptPath, "CombinedScene"),在脚本所在目录启动 Manim 渲染进程:-ql:快速质量--media_dir:将产出文件写入缓存目录,以便后续 HLS 合并--output_file CombinedScene:指定输出文件名(不附后缀,后续合并时会自动处理)
将渲染进程的 stdout/stderr 重定向到脚本目录下的
stdout.log和stderr.log,方便后续排错。等待渲染完成,最长 120 秒,若超时则强制销毁进程并返回状态码
exitCode = -1。渲染完成后,在缓存目录
cache/{taskId}中搜索videos/{分辨率}/{CombinedScene.mp4},若存在则:- 如果
sessionPrt != null(HLS 会话存在),则调用NativeMedia.appendVideoSegmentToHls(sessionPrt, filePath),将此分片追加到当前 HLS 播放列表; - 将 HLS 分片相对路径
"/" + filePath(例如/cache/12345/videos/480p30/CombinedScene.mp4)设置到ProcessResult.output。
- 如果
若没有找到任何分辨率视频文件,则认为渲染失败,在
ProcessResult.stdErr填入错误原因。返回
ProcessResult{ exitCode, stdOut, stdErr, output }给ManimHanlder.index;
ManimHanlder.index将ProcessResult序列化为 JSON 并通过 SSE 实时推送给 Java;若stream=true,连接保持打开状态,Java 可在收到分片路径后立即更新并通知前端;请求返回后该 SSE 通道仍可保持,直到 Java 主动关闭。
2.3 多场景循环与错误修复
Java 端在收到上一次
ProcessResult后,检查output是否为空:- 若不为空,表示本场景分片生成成功,将
output(HLS 路径)加入m3u8List,通过 SSE 通知前端“已生成第 N 场景分片”; - 若为空,表示渲染出错,Java 端将当前
code(错误脚本)、stdOut、stdErr信息封装为FixCodeVo,调用FixVideoCodeService.fixCode(...)获取修复后的代码,并重新请求渲染。
- 若不为空,表示本场景分片生成成功,将
这种“渲染—检查—修复—重试”循环在每个场景中允许出现多个尝试,最多达到预设次数(如 5 次失败后跳过此场景)。
当 AI 返回的下一场景 prompt 文本为“done”或已达最大场景数时,跳出循环,转入合并阶段。
3. HLS 分片合并与最终 MP4
当所有场景的 HLS 分片都成功提交至 Linux 端 HLS 会话后,Java 端调用 LinuxService.finish(sessionIdPrt, m3u8Path, videos),其中 videos 为逗号分隔的所有分片 m3u8 路径。例如:
videos=" /cache/123/videos/480p30/CombinedScene.mp4,/cache/123/videos/720p30/CombinedScene.mp4 "
(注意:实际传入格式由 ExplanationVideoService 拼接 m3u8List 时确定)
Linux 端 ManimHanlder.finish(HttpRequest):
首先检查
m3u8Path指向的 HLS 播放列表文件是否存在;- 若存在,调用
NativeMedia.finishPersistentHls(session_prt, m3u8Path),结束 HLS 会话并确保最后一个分片写入; - 若不存在,只需调用
NativeMedia.freeHlsSession(session_prt)释放 C 库内部资源;
- 若存在,调用
解析
videos.split(","),得到每个分片对应的本地 MP4 路径(+ ".mp4"后缀);调用
NativeMedia.merge(mp4FileList, outputPath),将所有片段按顺序拼接到outputPath(如./data/session/{sessionId}/main.mp4);如果合并成功,则调用
NativeMedia.getVideoLength(outputPath)返回合并后的视频总时长(单位秒),写入ProcessResult.video_length;返回 JSON 给 Java。
Java 接到结果后,将视频时长四舍五入存入数据库,拼接最终可访问 URL(例如 http://your-video-server/data/session/{sessionId}/main.mp4),通过 SSE 推送给前端“main”事件,表示视频已可在线观看或下载。
4. 前端实时流式输出
在以上各环节,Java 端均通过 Tio SSE 通道向前端推送关键进度:
- task:首次启动 HLS 会话后,向前端返回
{"id": "{任务ID}"},前端可根据 ID 构造视频播放或轮询地址。 - progress:每次执行关键步骤(如 “start generate python code”、“finish first scene code”、“start run N sence code”等)时,Java 端都会发送带
{"info": "某某步骤完成"}的 progress 事件,使前端实时显示日志或进度条。 - answer:AI 回答生成后,将具体回答文本通过 SSE 返回。
- error:若渲染或修复出错,将错误信息推送给前端。
- metadata:在最终合并前,将
{"id": "{任务ID}"}再次发送,前端可在收到后展示视频预览位置或更新播放按钮状态。 - main:合并完成后,发送
{"url": "{最终MP4链接}"},前端即可启动视频播放器或提示用户下载。 - 最终
close:在流程最后关闭 SSE 通道,通知前端可以结束轮询。
这种通过 SSE 推送进度的方式,使前端无需频繁轮询服务端,即可实时获取 AI 回答、Manim 渲染日志、最终视频 URL 等关键节点状态,从而大幅提升用户体验。
流程示意图
用户发起请求 → Java 后端校验 → 检查缓存
├── 命中缓存 → 直接返回 metadata + 视频 URL
└── 未命中缓存 → AI 回答生成 → 保存 answer
↓
Python 代码生成 → 保存脚本文件
↓
Linux HLS 会话开启
↓
循环渲染场景(Manim 渲染 + 错误修复)
↓
所有场景渲染完成 → 合并 HLS 分片 → 生成 MP4
↓
返回视频时长 + MP4 链接 → 异步生成封面图 → SSE 推送完成
小结
布局管理器(LayoutManager)
- 通过
LayoutRegion、LayoutAtom、Layout、LayoutManager四个核心类,实现了将多个 Mobject 分区自动缩放、对齐的能力,消除了手工使用next_to、move_to带来的繁琐和不一致。 - 用户只需在场景中调用
Layout(...).resolve(self),并对各区域进行一次性place(),即可完成复杂的左右/上下分栏、嵌套布局。
- 通过
代码生成到视频渲染的流水线
- AI 代码生成:Java 端调用 PredictService,通过 LLM(如 Gemini)生成符合 ManimCE v0.19.0 规范的脚本,将纯 Python 代码解析并保持到本地磁盘。
- 渲染执行:Linux 端收到脚本后的
manimCLI 命令,在本地进行分片渲染,绑定 HLS 会话将每次场景渲染的 MP4 分片追加至 m3u8;若代码有误,则通过 FixVideoCodeService 重新向 AI 请求修复代码并重试。 - 分片合并:所有场景分片完成后,调用 NativeMedia C 库将 HLS 各分片合并为单个 MP4,并计算总时长。
- 实时输出:Java 端通过 SSE 不断向前端推送进度(AI 回答、渲染步骤、错误信息、最终 URL),使前端无需轮询即可刷新状态。
通过上述设计,系统实现了从“用户输入文字”到“最终 Manim 可视化视频”无人值守的全自动闭环,兼顾了布局自动化、AI 代码生成脱敏、渲染可靠重试以及对外实时进度反馈。
