从AI 视频到可用资产:聊浏览器端图像编辑器的架构设计

帧编辑器的架构设计:命令模式、操作回放与状态管理

AI 生成视频已经可以让不会美术的人生成自己想要的动画。但生成出来的视频,要如何应用?除了短剧和解说漫,它能不能变成游戏引擎能用的精灵图?能不能变成发到社交平台的透明 GIF?能不能做成游戏 MOD?这些是和普通大众日常息息相关的。

FramePacker 是我们在做的一个浏览器端工具,目标是打通从 AI 视频到可用资产的最后一公里。只要拖入视频、GIF 或者一批图片,就可以在浏览器里完成所有处理,导出为你需要的动画资产。在上篇文章中我们讲了在浏览器中实现视频抽帧和导出管线的设计。这一篇我们就从工程角度来聊聊编辑器的架构设计和取舍。

为什么必须要做编辑器?因为 AI 生成的角色动画不是"抽帧就能用",不仅要去除背景、水印,可能也需要修边缘、修细节、调节奏。这些问题很难通过一键来完全解决,所以最好的方式,就是在抽帧和导出之间加一步"编辑",才能达到易用性与灵活性的平衡。

具体来说,这个编辑器的实现要面对四个实际问题。

问题一:十几种工具,如何设计统一的架构调度

做改图工具,最基础的是要有一组编辑功能:画笔、橡皮擦、矩形填充、油漆桶、换色、反色、柔化、选区移动......总共十几种甚至几十种。每种工具的交互方式完全不同。

最简单的做法是在按下事件的回调里写 if-else 链:

javascript 复制代码
function onPress(tool, point) {
    if (tool === 'brush') {
        startStroke(point)
    } else if (tool === 'rect') {
        startShape(point)
    } else if (tool === 'bucket') {
        doFill(point)
    } else if (tool === 'clbg') {
        sampleColor(point)
    }
    // 每加一个工具,这个函数多一条分支
}

但显然这是不行的。分支爆炸只是表面问题。真正的问题更深:每个工具的完整交互远比"按下时做什么"复杂。例如换色需要采样→预览→参数调节→应用/取消的完整状态机;反色需要预览→通道开关→应用→退出;而选区移动有拖拽偏移→确认→填充模式选择。这些状态机塞不进一个 switch-case。

按交互原语分类

实际的设计是按主导交互原语把工具分成五个族。分类依据是决定工具在主循环中走哪条分发路径的那条交互特征,不按其他辅助行为归类------套索虽然有拖拽,但它建立的是选区,归入选区族;换色虽然有预览,但它始于画布采样,归入采样族:

工具 交互特征
拖拽族 画笔、橡皮擦、矩形、椭圆 按下→拖拽→抬起,连续操作
采样族 换色 画布取色采样→预览→参数调节→确认/取消
单击族 油漆桶、吸管 单击即执行,无后续交互
预览族 柔化、反色 预览→参数调节→应用,无指针操作
选区族 矩形选框、套索、魔棒、选区移动 选框建立→选区操作

每个族共享一套交互骨架。拖拽族在按下、拖拽、抬起三个阶段各调一个钩子,采样族在采样后接管预览状态机。主循环按族分发事件,不需要知道当前激活的是族里的哪个具体工具。

统一命令接口

每个命令实现同一个描述符:

javascript 复制代码
// 命令接口定义
{
    name: string,                      // 工具标识
    family: 'drag' | 'sample' | 'one-shot' | 'preview' | 'selection',
    canEnter?(state): boolean,
    onEnter?(state): void,
    onExit?(state): void,
    onPress?(state, point): void,
    onDrag?(state, point): void,
    onRelease?(state, point): void,
    onCancel?(state): boolean,         // 返回 true 表示已处理,不往上冒泡
    apply?(state): void,
    discard?(state): void,
}

所有钩子都是可选的。单击族可能只需要 onPressapply。预览族完全不需要指针钩子。拖拽族的三个指针钩子全部透传到编辑器主循环:命令文件只是一个薄的映射层:

javascript 复制代码
// 画笔:拖拽族的典型形态
export const Brush = {
    name: 'brush',
    family: 'drag',
    onPress(state, point)  { doStrokeStart(state, point) },
    onDrag(state, point)   { doStrokeMove(state, point) },
    onRelease(state, point) { doStrokeEnd(state, point) },
}

采样族完全不同。换色工具有完整的状态机:

javascript 复制代码
export const ClearBackground = {
    name: 'clbg',
    family: 'sample',

    async apply(state) {
        if (state.tool !== 'clbg') return
        if (state.samplePoint == null) return
        await doClearBackground(state, state.samplePoint.x, state.samplePoint.y)
        state.samplePoint = null
    },

    discard(state) {
        state.samplePoint = null
    },

    onCancel(state) {
        if (state.samplePoint != null) {
            state.samplePoint = null
            return true   // 已处理,不往上冒泡
        }
        return false       // 不在采样态,让外层处理
    },
}

onCancel 有两级处理:如果在采样态(已取色还没应用),Esc 清掉采样点,事件到此为止。不在采样态,返回 false,Esc 冒泡到通用编辑器层处理,防止采样点被清掉的同时工具也被退出。

除了接口统一,所有命令的 apply 实现也遵守同一条安全规则:先执行计算,成功后才修改状态。这保证在任何一步出错时编辑器状态不会出现脏数据。

"先算后推"不变量

所有命令的 apply 方法都遵循同一个规则:先执行计算,成功后才修改状态。反色工具把这个模式体现得最清楚:

javascript 复制代码
apply(state) {
    const ctx = state.ctx
    if (ctx == null || state.tool !== 'invert') return

    const imageData = ctx.getImageData(0, 0, W, H)
    // 执行反色计算
    doInvert(imageData, cfg, mask)
    // 保存撤销快照(此时主画布仍为未反色状态)
    saveSnapshot(state)
    // 写入画布
    ctx.putImageData(imageData, 0, 0)
    // 记录操作
    state.history.push(makeInvertRecord(maskId, cfg))  // maskId:是选区标识(下一节展开)
    // 退出工具
    selectTool('brush')
}

每一步的顺序不能乱。如果第 1 步(反色计算)抛错了,第 2-4 步不会执行,状态完全未变,没有脏数据需要回滚。如果 RGB 和 Alpha 两个通道都关着------实际上什么都没做,就直接切回画笔退出,不在撤销栈里留空操作。

这是加新工具时的第一道检查:新的 apply 方法里,保存快照是不是在算法执行之后才调?

工具配置系统

命令接口定义了工具"能做什么"。但工具还有一层配置,定义了"什么时候能做"和"做了之后走什么流程"。

javascript 复制代码
// 预览工具的配置骨架
{
    canApply(state): boolean,            // 工具是否可以执行
    hasChanged(state): boolean,          // 是否真的改变了什么
    confirmOnSwitch: boolean,            // 切工具时是否弹确认框
    crossFrameNav: boolean,              // 是否支持跨帧预览导航
    uiPanels: ['tolerance', 'opacity', ...],  // 声明式面板布局
}

canApply 控制预览工具的"应用"按钮是否可点。换色需要采样点非空才就绪。没有 canApply 的话,apply 里的所有 if (samplePoint == null) return 守卫就要在 UI 层再做一遍。

hasChanged 解决"静默退出"问题。如果在预览态下调整了很多参数,在没有应用的情况下切走或者关闭,就需要询问一下是否要应用效果,不然很容易手滑错误关闭,丢失编辑操作。

confirmOnSwitch 控制从预览工具切换时的行为。为 true 的工具切换前弹"是否应用当前效果",为 false 的跳过。

这三个字段集中在配置文件中声明。命令只管"如何执行",配置只管"何时可执行、何时可退出"。这是刻意做的分层。

注册表

所有命令以名称索引存在一个字典里:

javascript 复制代码
const toolRegistry = {
  brush:              Brush,
  eraser:             Eraser,
  rect:               Rect,
  clearBackground:    clearBackground,
  floodFill:          FloodFill,
  soften:             Soften,
  invert:             Invert,
  eyedropper:         Eyedropper,
  // 十多种工具,五种交互族
}

主循环的指针事件处理不知道当前激活的是哪个工具,只管查表:

javascript 复制代码
function handlePointerDown(state, point) {
  const tool = toolRegistry[state.activeTool]
  tool?.onPress?.(state, point)
}

分发路径:UI 层点击工具按钮,设 state.activeTool → 后续指针事件由主循环查注册表 → onPress/onDrag/onRelease 按族统一分发。

加一个新工具不需要改主循环代码。假设后续要给编辑器加一个「马赛克」工具:在画布上涂抹一块区域,按指定粒度打像素块。它属于拖拽族,交互是按下→拖拽→抬起:

  1. mosaic.js:实现 onPressonDragonRelease 三个钩子,标注 family: 'drag'
  2. 在注册表加一行:mosaic: Mosaic
  3. 在工具配置里声明 UI 面板:uiPanels: ['blockSize']
  4. 工具栏加个按钮,点击时设 state.activeTool = 'mosaic'

handlePointerDownhandlePointerMovehandlePointerUp 一行不改。工具配置系统接管 UI 面板的显隐,按族分发接管交互流程。

顺便说下选区系统:所有工具共享的像素上下文

前面操作记录里每条都带了一个 maskId。它是什么?

选区是在 UI 上画的虚线框或套索轮廓,这是几何概念。选区底层数据是光栅化到像素空间的二进制结果:一个跟画布等大的像素数组,不透明度为 255 的位置在选区内,0 在选区外。

工具操作的是像素,不需要知道选区的几何形状。画笔在选区内画,只查底层数据在当前坐标的不透明度。换色也只替换选区内的像素。光栅化之后,所有工具用同一个查询接口。

使用过的选区数据存在缓存池里:

javascript 复制代码
maskCache: Map<maskId, ImageData>
activeMask: string | null

操作记录创建时记下当前的选区标识,重放时通过这个标识从缓存池取对应的数据。选区约束跟编辑时一致。

缓存池用引用计数管理生命周期。每个选区数据被三处引用:操作记录、重做栈、当前选区。撤销时条目从操作记录尾部移出,重做时移回来。

GC 在保存撤销快照时触发:扫描三个引用源,删除计数归零的缓存条目。不用引用计数的话,被移出操作记录的条目对应的选区数据会被错误回收,因为撤销之后再重做时选区数据已经没了。

常规编辑下,切帧时缓存池重建为空:选区是帧级概念,无需跨帧。唯一的例外是部分预览工具(换色、反色等)支持「跨帧预览导航」,这个场景下会手动恢复选区上下文,具体在后面问题四中展开。

问题二:操作必须可序列化,从单帧编辑到批量处理

单帧编辑好做。但这里要处理的是几十帧的批量编辑。例如在帧 3 做好了去背景、换色、柔化等编辑,也许需要能重放到帧 4-20。所以,这里需要一条独立的操作记录:

javascript 复制代码
// 笔刷
{ type: 'brush-stroke', path: [{x,y}...], size, color, blend, hardness, maskId }

// 换色
{ type: 'clbg', x, y, tolerance, color, maskId }

// 选区移动
{ type: 'selection-move', offsetX, offsetY, fillMode, maskId, fillColor }

坐标全部归一化到 0, 1。例如画笔在 800×600 帧上画了一笔经过 (400, 300) 的轨迹,归一化后存为 (0.5, 0.5)。回放到 1920×1080 帧时反归一化得到 (960, 540),笔画可以自动适配新尺寸。当前实际上是没有这个需求的,这一步纯粹是为了将来的可扩展性。

回放函数按序遍历操作记录数组,switch 分派到各类型的处理逻辑。

序列化 = 多功能

因为操作记录是纯 JSON,批量编辑就天然成立:记录当前帧的操作,对目标帧依次回放。不需要额外的批量引擎。

实际使用时,在帧列表中多选目标帧,点击批量应用,选中帧会各自独立回放操作记录。这个设计也可以方便地导出为独立文件,用来实现工程保存功能。

撤销/重做栈涉及快照保护、操作计数等独立设计,后续单独写一篇展开。本文聚焦操作记录在跨帧重放上的应用。

橡皮擦:回放需要外部数据的特例

大多数工具回放时只需要操作记录就够了。坐标、颜色、笔刷参数都在记录里,直接在 Canvas 上就可以画出来,只有橡皮擦是例外。

但这里橡皮擦设计不是"画透明色",而是"还原此前的编辑操作,回到原始画面"。它需要先读后写:从画布之外的某个数据源取出原始像素,再写回当前画布。所以回放时除了操作记录,还要记录从哪里还原:

javascript 复制代码
// 画笔:不需要外部数据,回放只需要这一条记录
{ type: 'brush-stroke', path: [{x,y}...], size, color, hardness }

// 橡皮擦:回放需要外部数据源,eraseMode 决定从哪获取
{ type: 'eraser', path: [{x,y}...], size, eraseMode: 'original' | 'saved' }

橡皮擦有两种还原目标:original 是原始视频帧,saved 是本次打开编辑器时的画布状态。区别在于:original 永远回到刚刚从视频提出来的未编辑状态,saved 是回到之前手动保存的一个阶段性成果(比如做完去背景和换色之后),而非退到原始帧。可以在工具栏上切换这两种模式。

为什么橡皮擦要这么设计?在大多数改图工具里,橡皮擦就是一个"画透明色的画笔"。但FramePacker的定位是个"改图工具"而非"画图工具",因为改图的使用场景是要修改现成的原画,而非从零开始画画。当改图时不小心改错、改多,那橡皮擦的作用就应该是需要"恢复"被错误编辑的区域,而非直接擦到透明。当然,如果真的需要改到透明,那直接把画笔调到透明色就可以了。

问题三:70+ 字段,切帧时怎么处理

编辑器状态由 13 个子域组成,共 70+ 字段:

javascript 复制代码
function buildEditorState() {
    return {
        ...buildSession(),      // 会话版本号、帧标识
        ...buildCanvas(),       // 画布尺寸、缩放
        ...buildHistory(),      // 操作记录 + 撤销/重做栈
        ...buildInteraction(),  // 拖拽中间态
        ...buildToolPrefs(),    // 工具选择、容差、不透明度
        ...buildBrush(),        // 笔刷大小、硬度
        ...buildSoften(),       // 柔化参数
        // ... 其余 6 个子域
    }
}

切帧时状态工厂函数重新执行,所有字段回到默认值。但有些手动做的修改是不应该被重置的,所以这里做了一个保留白名单。9 个子域各自声明哪些字段跨帧携带:

  • 工具偏好:当前工具、容差、不透明度、笔刷大小、笔刷硬度
  • 预览参数:柔化半径、反色通道开关
  • 换色:保护色列表
  • 选区:魔棒连续模式

切帧时从旧状态中把白名单字段拷贝到新状态,其余字段(撤销栈、重做栈、操作记录、交互中间态等)全部归零。加新工具时如果需要跨帧字段,就在对应的保留函数里加一行字段名。维护成本很低。

白名单的选入标准很简单,就是切换帧后不希望重新设置的参数,例如笔刷大小、强度、容差等。反之,对于大多数切帧时不应该保留的就应该统一清零。

javascript 复制代码
// 切帧时的状态处理(伪代码)
function switchFrame(oldState, newFrameData) {
  const keepKeys = ['activeTool', 'tolerance', 'opacity', 'brushSize', ...]
  const newState = buildEditorState()
  for (const key of keepKeys) {
    newState[key] = oldState[key]
  }
  commitState(newState)
}

白名单集中在切帧函数里维护,而不是散落在 13 个子域的工厂函数里。加一个新工具需要跨帧携带参数时,改一行白名单就行。

编辑器会话生命周期

编辑器状态什么时候创建、什么时候销毁?打开编辑器是做四件事:用状态工厂创建全新状态 → 从帧数据读像素写主画布 → 拷贝跨帧保留字段 → 重置所有预览计数器。设计选择是替换整个状态对象引用,而不是逐字段清零。Vue 响应式追踪引用变化,所有 watcher 统一响应切帧事件。

提交编辑是出口:提交预览态,序列化操作记录和最终画布像素写入帧存储。取消编辑不用序列化,会话层状态直接跟着状态对象被回收。

问题四:预览和提交

换色时实际看到的是叠加层上的实时预览。主画布完全没变。只有点"应用"后,命令的 apply 才执行算法、保存撤销快照、putImageData 写入主画布。

物理上是三个 Canvas 层叠:

交互层上的内容随时可以清空丢弃,不触发撤销/重做。主画布是真正的图像。

异步回调的竞态

有些工具的预览渲染走异步 WASM 调用。如果在第 3 帧调了柔化参数,在结果回来之前切到了第 5 帧。WASM 回调返回的是第 3 帧的结果,如果直接贴到交互层上,第 5 帧会短暂显示第 3 帧的像素。

解决方案是:每次打开编辑器时重置一个预览请求计数器。发起异步调用时记录当前计数值,回调回来时校验。计数不匹配说明上下文已经变了,回调直接丢弃。

跨帧预览导航

有些预览工具支持跨帧导航。如果在第 3 帧用换色取色、调好参数、看到预览效果,然后切到第 5 帧,这时不需要重新点选采样位置。

实现方式:切帧前把采样点坐标归一化到 0,1、快照当前选区和数据。切帧后反归一化到新帧坐标空间,从快照恢复选区上下文,交互层在新帧上重新渲染预览。注意这里选区的「跨帧」是主动恢复,而非自动继承,它只在跨帧预览时触发,和常规切帧时清空选区的行为是互补的。

收尾

最后,我们按实际操作的一次完整操作流程走一遍:

首先选画笔在帧 3 上画了一笔 → 命令模式按拖拽族分发 onPress/onDrag/onRelease → 操作序列化进记录,带归一化坐标和当前选区 maskId。然后切到换色工具采样背景色 → 预览渲染到交互叠加层,主画布不改 → 调好参数点"应用"→ apply 按「先算后推」执行算法、保存快照、putImageData 写入主画布 → 配置系统在过程中控制「采样点非空才可应用」和「切换时是否弹确认」。最后点批量应用到其他帧 → 回放函数遍历操作记录,归一化坐标反归一化到各帧实际尺寸,逐帧重放。

后续会再写一篇单独展开 Undo / Redo 的功能设计。本文建立的命令模式、序列化操作记录和先算后推的快照保护,正是 Undo/Redo 需要的基础设施。或者也可以讲讲 Sprite Sheet 的自动布局算法,有感兴趣的同学可以mark一下。对 FramePacker 完整工具感兴趣的同学,可以在专栏简介中看到链接。

相关推荐
传感器与混合集成电路1 小时前
基于FPGA与ADC协同架构的高密度数据采集模块设计原理与应用场景分析
fpga开发·架构
蜀道山老天师1 小时前
从零读懂 K8s:核心概念、集群架构与工作原理
容器·架构·kubernetes
zlinear数据采集卡2 小时前
2mV纹波的代价:硬核拆解ZLinear模拟前端放大器与正负可调电源设计
arm开发·单片机·嵌入式硬件·fpga开发·架构·开源
阳明山水2 小时前
TimesFM与Moirai MoE零样本预测解析
人工智能·深度学习·算法·机器学习·架构
霸道流氓气质3 小时前
微服务架构核心概念业务示例
微服务·云原生·架构
带娃的IT创业者3 小时前
当推理速度突破物理极限:深度解析 MiMo-v2.5-Pro-UltraSpeed 的 1000 TPS 架构革命
人工智能·架构·大模型·架构优化·mimo·tps·推理速度
XUHUOJUN4 小时前
从 S2D 到 Azure Local Storage Capability:微软 HCI 存储控制面的演进趋势
架构·azure local·storage spaces
一枚码农出身的猎头4 小时前
岗位招聘:架构师,base湖南长沙,70-80w
java·架构
2601_954706494 小时前
云虚拟化终端架构解析:ARM 云端集群部署与自动化运维实践
运维·arm开发·架构