布局管理器和整体流程
一、自定义布局管理器(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 端收到脚本后的
manim
CLI 命令,在本地进行分片渲染,绑定 HLS 会话将每次场景渲染的 MP4 分片追加至 m3u8;若代码有误,则通过 FixVideoCodeService 重新向 AI 请求修复代码并重试。 - 分片合并:所有场景分片完成后,调用 NativeMedia C 库将 HLS 各分片合并为单个 MP4,并计算总时长。
- 实时输出:Java 端通过 SSE 不断向前端推送进度(AI 回答、渲染步骤、错误信息、最终 URL),使前端无需轮询即可刷新状态。
通过上述设计,系统实现了从“用户输入文字”到“最终 Manim 可视化视频”无人值守的全自动闭环,兼顾了布局自动化、AI 代码生成脱敏、渲染可靠重试以及对外实时进度反馈。