自本年 3 月离职以来,中间休息了半个月、准备了半个月面试,到 4 月中旬上岸。因为本身还是想找一个偏渲染 + Agent 开发的工作,所以开发了这款集跨端 + 渲染 + Agent的作品用于面试(面试多靠它)。经过陆陆续续的打磨,功能已完善。目前支持:

效果预览 + 性能指标

画面:

Agent 驱动的 NLE 编辑器

Agent 驱动的 NLE 编辑器

Agent 驱动的资源设计

Agent 驱动的资源设计

性能指标数据(毫秒级展示):

[22:47:31] 初始化...
[22:47:32] GPU: Google Inc. (Apple) - ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Version 26.1 (Build 25B78))
[22:47:32] Addon 加载成功 | GPU: Google Inc. (Apple) - ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Version 26.1 (Build 25B78))
[22:47:32] 就绪
[22:47:32] 停止
[22:47:32] 加载配置: 3 轨道
[22:47:33] ✓ 加载成功 (349.7ms) | ID: test | 720×1280 | 30.00fps | 00:00:06.000
[22:47:33] 轨道组: 3
[22:47:33] [0] "track_video_0" | video
[22:47:33] [0] "segment_0" | 00:00:00.000~00:00:05.000 | 24.0fps
[22:47:33] [1] "segment_1" | 00:00:05.000~00:00:06.000 | 24.0fps
[22:47:33] [1] "track_text_0" | text
[22:47:33] [0] "segment_2" | 00:00:00.000~00:00:05.000 | text="测试文本"
[22:47:33] [2] "track_audio_0" | audio
[22:47:33] [0] "segment_3" | 00:00:00.000~00:00:05.000
[22:47:33] 音频信息: 2 条 [segment_0, segment_3]
[22:47:33] segment_0: vol=0.5 path=/Users/beyond-today/Proj/electron-rendering/resources/test.mp4 type=video
[22:47:33] segment_3: vol=0.5 path=/Users/beyond-today/Proj/electron-rendering/resources/test.wav type=audio
[22:47:33] ✓ 音频解码: 1/2 条成功 | ctx=running
[22:47:33] segment_3: 94.22s 2ch 48000Hz
[22:47:33] #1 | 00:00:00.000 | 渲染 426.30ms | 总: 426.70ms
[22:47:33] 项目已加载: /Users/beyond-today/Proj/electron-rendering/resources/test.json
[22:47:43] 播放 (音轨: 1, ctx: running)
[22:47:43] #2 | 00:00:00.032 | 缓存 0.50ms | 总: 1.10ms
[22:47:43] #3 | 00:00:00.064 | 缓存 0.40ms | 总: 0.50ms
[22:47:43] #4 | 00:00:00.096 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] #5 | 00:00:00.133 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] #6 | 00:00:00.165 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] #7 | 00:00:00.197 | 缓存 0.10ms | 总: 0.30ms
[22:47:44] #8 | 00:00:00.229 | 缓存 0.10ms | 总: 0.40ms
[22:47:44] #9 | 00:00:00.261 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] #10 | 00:00:00.298 | 缓存 0.20ms | 总: 0.60ms
[22:47:44] #11 | 00:00:00.330 | 缓存 0.30ms | 总: 0.70ms
[22:47:44] #12 | 00:00:00.362 | 缓存 0.10ms | 总: 0.30ms
[22:47:44] #13 | 00:00:00.394 | 缓存 0.30ms | 总: 0.30ms
[22:47:44] #14 | 00:00:00.432 | 缓存 0.10ms | 总: 0.30ms
[22:47:44] #15 | 00:00:00.464 | 缓存 0.10ms | 总: 0.40ms
[22:47:44] #16 | 00:00:00.496 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] #17 | 00:00:00.533 | 缓存 0.40ms | 总: 0.70ms
[22:47:44] #18 | 00:00:00.565 | 缓存 0.20ms | 总: 0.30ms
[22:47:44] #19 | 00:00:00.597 | 缓存 0.30ms | 总: 0.50ms
[22:47:44] #20 | 00:00:00.629 | 缓存 0.10ms | 总: 0.20ms
[22:47:44] #21 | 00:00:00.661 | 缓存 0.20ms | 总: 0.50ms
[22:47:44] #22 | 00:00:00.698 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] #23 | 00:00:00.730 | 缓存 0.20ms | 总: 0.40ms
[22:47:44] 暂停

技术栈选型

其中主要的技术栈是:

  1. ANGLE + Skia 实现跨端 OpenGL 支持,底层则使用传统 GLES 实现开发;
  2. 视频解码 使用本地 VideoToolbox 硬解码。正常这里要以插件设计,具体功能实现外包给不同平台:
  3. Web 端使用 WebCodecs;
  4. macOS 则使用 VideoToolbox;
  5. Linux 则使用 FFmpeg(可能有 GPL 协议风险,所以这也是插件设计的初衷,避免 GPL 传播);
  6. 文字渲染 使用 Skia 的 GL 后端渲染,支持复杂的排版、富文本、描边、阴影、渐变等复杂效果,基本上已满足日常需求;
  7. Electron 作为前端展示(可编译 WASM 适配 Web 端开发,后续会讲);
  8. LangChain Agent 开发:
  9. 对话上下文持久化;
  10. 工具调用;
  11. 上下文压缩;
  12. 对话历史管理;
  13. GoogleTest 单元测试,保证代码行、分支、函数覆盖率;

由于当前文章和公司的开发内容比较接近,所以有一些和公司开发内容重叠的技术栈就不深入讲解了,可加在主页 → 关于我加 QQ 群聊。

渲染资源支持

渲染资源使用的方案是之前写过的:自定义特效、动画、转场资源设计(支持剪映资源)。这里补充的是:对于文中提到的剪映动态渲染资源,其实也就是 Lua 支持,实现也很简单,也就是实现几个事件,在 Lua 中去调用:

  1. new(构造函数);
  2. onStart(一次调用);
  3. onEvent(事件调用触发,如前端更改参数滑条等);
  4. onUpdate(每次更新调用,可以理解为每次绘制都会调用);

可以以剪映的调色资源去参考(这里不再赘述)。后续的资源设计 Agent 也依赖此处的格式设计,约定好格式、提供编译反馈基本上就 OK 了。每次 Agent 生成的特效资源加载后如果有错误反馈,返回给 LLM。有了错误反馈,Agent 做起来就容易多了,相当于给 Agent 提供了测试环境。

解码优化

moov + 仅向后解码(考虑 I 帧、B 帧、P 帧)

解码采用的原则是:使用 moov 数据去驱动 NLE 取帧。

moov 解析后,能拿到的数据格式是:

struct FrameLocation {
    // —— 时间戳(毫秒,零点 = 视频首帧的 PTS)——
    TimeMs pts_ms = 0;         // 显示时间戳(Presentation Time Stamp)
    TimeMs dts_ms = 0;         // 解码时间戳(Decoding Time Stamp);有 B 帧时 dts_ms != pts_ms
    TimeMs start_ms = 0;       // 该帧在显示时间轴上的起点;当前实现下恒等于 pts_ms
    TimeMs end_ms = 0;         // 该帧显示区间的终点(= 显示顺序的下一帧的 start_ms)
    TimeMs gop_pts_ms = 0;     // 该帧所在 GOP 起始关键帧(I 帧)的 pts_ms;seek 用

    // —— 帧索引(两套互相独立的编号体系)——
    int sample_index = 0;      // 在 MP4 stbl 中的 sample 编号(即 DTS / 文件物理顺序,从 0 开始)
    int display_index = 0;     // 按 PTS 排序后的位置(即人眼看到的第几帧,从 0 开始)
    int gop_index = 0;         // 第几个 GOP(从 0 开始)
    int frame_in_gop = 0;      // 在所属 GOP 内的位置(按 sample 顺序,I 帧为 0)
    int gop_frame_count = 0;   // 该 GOP 包含的帧数
    int total_frames = 0;      // 整个视频的总帧数

    // —— 文件物理位置(字节,相对文件起点)——
    int64_t sample_offset = 0; // 该 sample 在 mp4 文件中的起始字节偏移
    int64_t sample_size = 0;   // 该 sample 的字节长度
    int64_t gop_offset = 0;    // 该 GOP 起始 sample(I 帧)的 sample_offset
    int64_t gop_size = 0;      // 该 GOP 所有 sample 的字节长度之和
};

一个视频的 moov 数据则是一个 FrameLocation 数组。解封装成 moov 的 FrameLocation 列表后,取帧逻辑就简单多了——因为每一帧的开始结束时间、所属 GOP 索引、字节偏移等数据都拿到了。在某一刻,取固定帧的逻辑就好写了:

bool MoovHelper::queryFrame(TimeMs time_ms, FrameLocation &out) const {
    const auto &frames = impl_->frames;
    const auto &display_order = impl_->display_order;
    if (frames.empty() || display_order.empty()) {
        return false;
    }

    const int n = static_cast<int>(frames.size());
    const int last_display_sample = display_order.back();
    if (time_ms >= frames[last_display_sample].end_ms) {
        return false;
    }

    const int last = std::min(std::max(impl_->last_index, 0), n - 1);
    const bool back = time_ms < frames[display_order[last]].start_ms;
    const int lo = back ? std::max(0, last - 5) : last;
    const int hi = back ? last : std::min(n - 1, last + 5);

    int found = -1;
    for (int i = back ? hi : lo; lo <= i && i <= hi; i += back ? -1 : 1) {
        const FrameInfo &frame = frames[display_order[i]];
        if (frame.start_ms <= time_ms && time_ms < frame.end_ms) {
            found = i;
            break;
        }
    }

    if (found < 0) {
        int l = 0;
        int r = n - 1;
        while (l < r) {
            const int m = (l + r) / 2;
            if (frames[display_order[m]].end_ms <= time_ms) {
                l = m + 1;
            } else {
                r = m;
            }
        }
        const FrameInfo &frame = frames[display_order[l]];
        if (frame.start_ms <= time_ms && time_ms < frame.end_ms) {
            found = l;
        }
    }
    if (found < 0) {
        return false;
    }

    impl_->last_index = found;
    const int sample_idx = display_order[found];
    const FrameInfo &frame = frames[sample_idx];
    const FrameInfo &gop_start = frames[frame.gop_start_idx];

    out.pts_ms = frame.start_ms;
    out.dts_ms = frame.dts_ms;
    out.start_ms = frame.start_ms;
    out.end_ms = frame.end_ms;
    out.gop_pts_ms = gop_start.start_ms;
    out.sample_index = sample_idx;
    out.display_index = frame.display_index;
    out.gop_index = frame.gop_index;
    out.gop_offset = gop_start.file_offset;
    out.gop_size = frame.gop_size;
    out.sample_offset = frame.file_offset;
    out.sample_size = frame.byte_size;
    out.frame_in_gop = frame.frame_in_gop;
    out.gop_frame_count = frame.gop_frame_count;
    out.total_frames = n;
    return true;
}

取帧思路:

  1. 记录上一次请求的时间和返回的帧;
  2. 再次请求时,±5 帧去查找,是 + 还是 - 取决于是在上次前还是后;
  3. 未找到时,采用二分法定位帧。

这样的好处是:对于大部分顺序播放的场景,查找效果只是向下顺序查找;对于远距离 seek 的场景,使用二分法则更优。

解码优化逻辑:

  1. 记录上次解码的 FrameLocation 数据;
  2. 当前 FrameLocation 和上一帧的跨 GOP gop_index 不相等,则触发 seek 到当前 GOP;
  3. 仅向后解码(GOP 级别缓存),同 GOP 在往回 play、seek 直接从缓存列表查询;
  4. 相同 GOP,请求的 frame_in_gop 靠后,则触发解码,直到当前帧解码出来;
  5. GOP 跳转、图层 active 改变(通常是播放离开当前图层时间),则清空 GOP 缓存,避免高 GPU 资源占用。

这样的好处是:往前 seek 的时候,不会触发频繁的 seek + 解码,这对于 NLE 的流畅编辑体验很重要。同一个 GOP 的帧只会解码一次。

Web 端的 WebCodecs 解码也是相同逻辑。但是为了体验更好,可以优化成:进入 GOP 则异步触发 GOP 的整个解码,每次解码完则发一次通知,当使用方知道当前的 frame_in_gop 解码完成后则拿走对应的帧即可,这样就不用管 I、B、P 帧了。

Web 端的解码其实能比本地做得更好,像分片下载、浏览器缓存技术都很成熟了。由此,剪映 Web 端下线其实是非常遗憾的(网传是 Web 端难度比较大;最近工作上就是 Web 端,个人觉得能做到和 Native 性能一样好,这里就不展开讨论了)。

渲染优化(削峰填谷)

本节优化主要针对 Electron 端。

背景: Electron 通过 NAPI 调用原生渲染时,无法直接把纹理画到 Canvas 上,必须先 readPixels 读出 RGBA 数据,再用 putImageData 绘制。若要跳过这一步、实现纹理直绘,需要改用 WebAssembly 方案(纯前端方案)。

核心思路——削峰填谷: 绘制当前帧的同时,异步预取下一帧(含 readPixels,这是最耗时的步骤)。前端 Timeline 约 40ms 后再来取帧时,RGBA 数据已就绪,直接拿指针即可。

效果:

LangChain Agent 基础开发

前段时间 Claude Code 被开源后,网上涌现了大量「用 Claude Code 读 Claude Code 源码」的分析文章和教程。其中我觉得质量比较好的是:learn-claude-code

不过个人理解,Agent 开发绕不开这几块:Tools、Prompt、MCP、RAG。Skills、SubAgent、Teammate 等概念,本质上都是对它们的组合封装,并没有跳出这个框架。以 Skills 为例:

  1. 在 System Prompt 里只放 Skills 的摘要信息;
  2. 需要时再调用 load_skills,把完整内容注入 System Prompt。

核心目的还是按需加载、控制上下文占用——老思路,新包装。

Agent 驱动 UI

Agent 驱动 UI 更新的本质很简单:Tool 改数据,UI 跟着刷。比如 Agent 调用「新增图层」,Timeline 就新增对应轨道和片段——数据层和视图层保持同步。

下面以 updateText 为例,链路分三步:

1. 注册 Tools(暴露给 LLM 的接口)

function createTools() {
    return [
        new DynamicStructuredTool({
            name: 'update_text',
            description: '修改指定文字图层的文本内容。需要提供准确的图层 id(协议 segment 的 id 字段,不是 material id)。',
            schema: z.object({
                layerId: z.string().describe('文字图层的 id(与工程协议中 segment 的 id 一致)'),
                text: z.string().describe('新的文本内容'),
            }),
            func: async ({ layerId, text }) => {
                const result = await callEditor('updateText', { layerId, text });
                return JSON.stringify(result);
            },
        }),

        new DynamicStructuredTool({
            name: 'set_layer_property',
            description: '修改图层的视觉属性。可修改的属性:alpha(透明度0-1), visible(可见性), scaleX/scaleY(缩放), rotation(旋转角度), transformX/transformY(位移)。',
            schema: z.object({
                layerId: z.string().describe('图层 id(协议 segment 的 id)'),
                property: z.enum([
                    'alpha', 'visible', 'scaleX', 'scaleY',
                    'rotation', 'transformX', 'transformY',
                ]).describe('要修改的属性名'),
                value: z.union([z.number(), z.boolean()]).describe('新的属性值'),
            }),
            func: async (params) => {
                const result = await callEditor('setLayerProperty', params);
                return JSON.stringify(result);
            },
        }),

        new DynamicStructuredTool({
            name: 'set_current_time',
            description: '跳转到指定时间点(毫秒)并刷新画面。',
            schema: z.object({
                timeMs: z.number().describe('目标时间点,单位毫秒'),
            }),
            func: async ({ timeMs }) => {
                const result = await callEditor('setCurrentTime', { timeMs });
                return JSON.stringify(result);
            },
        }),
    ];
}

2. 路由 Tools(callEditor → 具体 action)

function executeAction(action, params) {
    switch (action) {
        case 'getProjectInfo': return getProjectInfo();
        case 'getTextLayerDigest': return getTextLayerDigest();
        case 'getProjectProtocol': return getProjectProtocol();
        case 'updateText': return updateText(params);
        case 'setLayerProperty': return setLayerProperty(params);
        case 'setCurrentTime': return setCurrentTime(params);
        default: throw new Error(`Unknown action: ${action}`);
    }
}

3. 执行并刷新 UI(改数据 + 触发重绘)

function updateText({ layerId, text }) {
    const layer = findLayerById(layerId);
    if (layer.type !== 'text') {
        throw new Error(`图层 "${layerId}" 不是文字图层(类型: ${layer.type})`);
    }
    const oldText = layer.text;
    layer.text = text;
    refreshAfterLayerMutation();  // 刷新 Timeline、预览区等 UI
    return { layerId, oldText, newText: text };
}

refreshAfterLayerMutation 负责在数据变更后统一刷新页面 UI(Timeline 轨道、预览画面等),避免每个 Tool 各自处理视图更新。

Agent 资源设计

资源设计 Agent 与上文「渲染资源支持」衔接:格式约定 + 加载反馈 → 纠错重写,构成可迭代闭环。

在此基础上,再叠加上文提到的动态资源(Lua 脚本),即可兼容剪映渲染资源。实践上可以封装一个 Skill,把格式规范、Lua 事件约定和参考资源模板写进去,交给 Codex 等 Agent 按需加载——既能设计新资源,也能对照剪映资源做改写建议。

预览环境: 内置一个简易播放器,默认两个图层(兼容转场、特效场景),特效默认挂在第一个图层上。Agent 写完资源即可实时预览效果。

工作流:

  1. 在 System Prompt 中描述好资源格式规范(可用 AI 辅助生成 Prompt 模板);
  2. Agent 通过文件 Tools 在沙箱内编写 Shader、config.json 等资源文件;
  3. 写入 config.json 后自动触发加载(__onResourceWritten);
  4. 加载失败则将错误信息反馈给 Agent,由其据此纠错并重写。

这就等于给 Agent 配齐了「写资源代码的手脚」和「跑起来看效果的测试环境」。

必要的文件 Tools:

Tool 作用
list_dir 查看目录结构
read_file 读取已有文件(参考模板、排查错误)
write_file 写入资源文件(仅限沙箱内)
function createTools() {
    return [
        new DynamicStructuredTool({
            name: 'list_dir',
            description: '列出目录内容。相对路径基于沙箱根目录解析;也接受绝对路径(用于参考外部资料)。',
            schema: z.object({
                path: z.string().default('.').describe('目录路径,相对沙箱根或绝对路径'),
            }),
            func: async ({ path: p }) => {
                try {
                    const full = sandbox.resolveReadPath(p || '.');
                    const stat = _safeStat(full);
                    if (!stat) {
                        return JSON.stringify({ error: `path not found: ${full}` });
                    }
                    if (!stat.isDirectory()) {
                        return JSON.stringify({ error: `not a directory: ${full}` });
                    }
                    const entries = fs.readdirSync(full, { withFileTypes: true });
                    const truncated = entries.length > MAX_LIST_ENTRIES;
                    const slice = truncated ? entries.slice(0, MAX_LIST_ENTRIES) : entries;
                    const items = slice.map((e) => {
                        const childPath = path.join(full, e.name);
                        const childStat = _safeStat(childPath);
                        return {
                            name: e.name,
                            type: e.isDirectory() ? 'dir' : (e.isFile() ? 'file' : 'other'),
                            size: childStat?.isFile() ? childStat.size : undefined,
                        };
                    });
                    return JSON.stringify({
                        resolved: full,
                        sandboxRoot: sandbox.getSandboxRoot(),
                        truncated,
                        total: entries.length,
                        items,
                    });
                } catch (e) {
                    return JSON.stringify({ error: e.message });
                }
            },
        }),

        new DynamicStructuredTool({
            name: 'read_file',
            description: '读取文本文件(最大 1 MB)。相对路径基于沙箱根;可读任意位置。',
            schema: z.object({
                path: z.string().describe('文件路径,相对沙箱根或绝对路径'),
            }),
            func: async ({ path: p }) => {
                try {
                    const full = sandbox.resolveReadPath(p);
                    const stat = _safeStat(full);
                    if (!stat) return JSON.stringify({ error: `file not found: ${full}` });
                    if (!stat.isFile()) return JSON.stringify({ error: `not a file: ${full}` });
                    if (stat.size > MAX_READ_BYTES) {
                        return JSON.stringify({
                            error: `file too large (${stat.size} bytes, limit ${MAX_READ_BYTES})`,
                        });
                    }
                    const content = fs.readFileSync(full, 'utf-8');
                    return JSON.stringify({ resolved: full, size: stat.size, content });
                } catch (e) {
                    return JSON.stringify({ error: e.message });
                }
            },
        }),

        new DynamicStructuredTool({
            name: 'write_file',
            description: '写入文本文件,自动创建父目录。**仅沙箱内允许**:路径必须落在 RESOURCE_SANDBOX 之下。',
            schema: z.object({
                path: z.string().describe('沙箱内相对路径,如 my_effect/shaders/pass0.frag'),
                content: z.string().describe('文件内容'),
            }),
            func: async ({ path: p, content }) => {
                try {
                    const full = sandbox.resolveWritePath(p);
                    fs.mkdirSync(path.dirname(full), { recursive: true });
                    fs.writeFileSync(full, content, 'utf-8');

                    // auto-mount:仅 config.json 写完触发;shader 分次写不重复 load
                    if (full.endsWith('config.json') && typeof window !== 'undefined' && typeof window.__onResourceWritten === 'function') {
                        try { window.__onResourceWritten(full); } catch { /* UI 可能未就绪 */ }
                    }

                    return JSON.stringify({
                        ok: true,
                        resolved: full,
                        bytes: Buffer.byteLength(content, 'utf-8'),
                    });
                } catch (e) {
                    return JSON.stringify({ error: e.message });
                }
            },
        }),
    ];
}

安全边界: 读操作可访问沙箱外路径(方便参考外部资料),写操作严格限制在 RESOURCE_SANDBOX 内,避免 Agent 误改工程文件。