FramePacker - 浏览器端图像编辑器架构设计

发布时间:2026/7/18 13:55:26
FramePacker - 浏览器端图像编辑器架构设计 引言AI 生成视频的能力在过去一年进步飞快。豆包、即梦、可灵这些工具已经可以让一个不会画画的人生成一段角色动画。但视频生成只是前半程。后半程的问题是生成出来的视频普通人要如何应用除了短剧和解说漫它能不能变成游戏引擎能用的精灵图能不能变成发到社交平台的透明 GIF能不能做成游戏 MOD这些是和普通大众日常息息相关的。FramePacker 是我们在做的一个浏览器端工具目标是打通从 AI 视频到可用资产的最后一公里。它在一个网页里实现了视频抽帧、抠图、编辑精修、批量改图、智能帧检测和自由分辨率导出。拖入视频、GIF 或者一批图片在浏览器里完成所有处理导出为你需要的动画资产。帧编辑器的架构设计命令模式、操作回放与状态管理上篇文章我们讲了在浏览器中实现视频抽帧和导出管线的设计。但是FramePacker 首先是一个改图编辑器然后才是一个视频处理工具。具体来说一个视频丢进 FramePacker 之后的流程是选择片段抽帧把视频拆分成几十张图片→ 进入编辑器进行批量抠图和改图 → 调整节奏 → 导出。这一篇就我们聚焦中间的编辑器来聊聊编辑器的架构设计和取舍。为什么必须要做编辑器因为 AI 生成的角色动画不是抽帧就能用不仅要去除背景、水印可能也需要修边缘、修细节、调节奏。这些问题很难通过一键来完全解决所以最好的方式就是在抽帧和导出之间加一步编辑才能达到易用性与灵活性的平衡。所以 FramePacker 不是一个简单的把视频转成帧动画的转换器。它更是一个能同时处理几十张、几百张图的像素级改图编辑器。这个定位决定了后面所有的架构选择。具体来说做这个编辑器要面对四个实际问题。问题一十几种工具如何设计统一的架构调度做改图工具最基础的是要有一组编辑功能画笔、橡皮擦、矩形填充、油漆桶、换色、反色、柔化、选区移动……总共十几种甚至几十种。每种工具的交互方式完全不同。最简单的做法是在按下事件的回调里写 if-else 链functiononPress(tool,point){if(toolbrush){startStroke(point)}elseif(toolrect){startShape(point)}elseif(toolbucket){doFill(point)}elseif(toolclbg){sampleColor(point)}// 每加一个工具这个函数多一条分支}但显然这是不行的。分支爆炸只是表面问题。真正的问题更深每个工具的完整交互远比按下时做什么复杂。例如换色需要采样→预览→参数调节→应用/取消的完整状态机反色需要预览→通道开关→应用→退出而选区移动有拖拽偏移→确认→填充模式选择。这些状态机塞不进一个 switch-case。按交互原语分类实际的设计是按主导交互原语把工具分成五个族。分类依据是决定工具在主循环中走哪条分发路径的那条交互特征不按其他辅助行为归类——套索虽然有拖拽但它建立的是选区归入选区族换色虽然有预览但它始于画布采样归入采样族族工具交互特征拖拽族画笔、橡皮擦、矩形、椭圆按下→拖拽→抬起连续操作采样族换色画布取色采样→预览→参数调节→确认/取消单击族油漆桶、吸管单击即执行无后续交互预览族柔化、反色预览→参数调节→应用无指针操作选区族矩形选框、套索、魔棒、选区移动选框建立→选区操作每个族共享一套交互骨架。拖拽族在按下、拖拽、抬起三个阶段各调一个钩子采样族在采样后接管预览状态机。主循环按族分发事件不需要知道当前激活的是族里的哪个具体工具。统一命令接口每个命令实现同一个描述符// 命令接口定义{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,}所有钩子都是可选的。单击族可能只需要onPress和apply。预览族完全不需要指针钩子。拖拽族的三个指针钩子全部透传到编辑器主循环命令文件只是一个薄的映射层// 画笔拖拽族的典型形态exportconstBrush{name:brush,family:drag,onPress(state,point){doStrokeStart(state,point)},onDrag(state,point){doStrokeMove(state,point)},onRelease(state,point){doStrokeEnd(state,point)},}采样族完全不同。换色工具有完整的状态机exportconstClearBackground{name:clbg,family:sample,asyncapply(state){if(state.tool!clbg)returnif(state.samplePointnull)returnawaitdoClearBackground(state,state.samplePoint.x,state.samplePoint.y)state.samplePointnull},discard(state){state.samplePointnull},onCancel(state){if(state.samplePoint!null){state.samplePointnullreturntrue// 已处理不往上冒泡}returnfalse// 不在采样态让外层处理},}onCancel有两级处理用户在采样态已取色还没应用Esc 清掉采样点事件到此为止。不在采样态返回 falseEsc 冒泡到通用编辑器层处理防止采样点被清掉的同时工具也被退出。除了接口统一所有命令的apply实现也遵守同一条安全规则先执行计算成功后才修改状态。这保证在任何一步出错时编辑器状态不会出现脏数据。先算后推不变量所有命令的apply方法都遵循同一个规则先执行计算成功后才修改状态。反色工具把这个模式体现得最清楚apply(state){constctxstate.ctxif(ctxnull||state.tool!invert)returnconstimageDatactx.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 方法里保存快照是不是在算法执行之后才调工具配置系统命令接口定义了工具能做什么。但工具还有一层配置定义了什么时候能做和做了之后走什么流程。// 预览工具的配置骨架{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 的跳过。这三个字段集中在配置文件中声明。命令只管如何执行配置只管何时可执行、何时可退出。这是刻意做的分层。注册表所有命令以名称索引存在一个字典里consttoolRegistry{brush:Brush,eraser:Eraser,rect:Rect,clearBackground:clearBackground,floodFill:FloodFill,soften:Soften,invert:Invert,eyedropper:Eyedropper,// 十多种工具五种交互族}主循环的指针事件处理不知道当前激活的是哪个工具只管查表functionhandlePointerDown(state,point){consttooltoolRegistry[state.activeTool]tool?.onPress?.(state,point)}分发路径UI 层点击工具按钮设state.activeTool→ 后续指针事件由主循环查注册表 →onPress/onDrag/onRelease按族统一分发。加一个新工具不需要改主循环代码。假设后续要给编辑器加一个「马赛克」工具在画布上涂抹一块区域按指定粒度打像素块。它属于拖拽族交互是按下→拖拽→抬起写mosaic.js实现onPress、onDrag、onRelease三个钩子标注family: drag在注册表加一行mosaic: Mosaic在工具配置里声明 UI 面板uiPanels: [blockSize]工具栏加个按钮点击时设state.activeTool mosaichandlePointerDown、handlePointerMove、handlePointerUp一行不改。工具配置系统接管 UI 面板的显隐按族分发接管交互流程。顺便说下选区系统所有工具共享的像素上下文前面操作记录里每条都带了一个maskId。它是什么选区是用户在 UI 上画的虚线框或套索轮廓这是几何概念。选区底层数据是光栅化到像素空间的二进制结果一个跟画布等大的像素数组不透明度为 255 的位置在选区内0 在选区外。工具操作的是像素不需要知道选区的几何形状。画笔在选区内画只查底层数据在当前坐标的不透明度。换色也只替换选区内的像素。光栅化之后所有工具用同一个查询接口。使用过的选区数据存在缓存池里maskCache:MapmaskId,ImageDataactiveMask:string|null操作记录创建时记下当前的选区标识重放时通过这个标识从缓存池取对应的数据。选区约束跟编辑时一致。缓存池用引用计数管理生命周期。每个选区数据被三处引用操作记录、重做栈、当前选区。撤销时条目从操作记录尾部移出重做时移回来。GC 在保存撤销快照时触发扫描三个引用源删除计数归零的缓存条目。不用引用计数的话被移出操作记录的条目对应的选区数据会被错误回收因为撤销之后再重做时选区数据已经没了。常规编辑下切帧时缓存池重建为空选区是帧级概念无需跨帧。唯一的例外是部分预览工具换色、反色等支持「跨帧预览导航」这个场景下会手动恢复选区上下文具体在后面问题四中展开。问题二操作必须可序列化从单帧编辑到批量处理单帧编辑好做。但这里要处理的是几十帧的批量编辑。例如用户在帧 3 做好了去背景、换色、柔化等编辑也许需要能重放到帧 4-20。所以我们需要一条独立的操作记录// 笔刷{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 上就可以画出来只有橡皮擦是例外。因为 FramePacker 橡皮擦的设计不是画透明色而是还原用户编辑操作回到原始画面。它需要先读后写从画布之外的某个数据源取出原始像素再写回当前画布。所以回放时除了操作记录还要记录从哪里还原// 画笔不需要外部数据回放只需要这一条记录{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 字段functionbuildEditorState(){return{...buildSession(),// 会话版本号、帧标识...buildCanvas(),// 画布尺寸、缩放...buildHistory(),// 操作记录 撤销/重做栈...buildInteraction(),// 拖拽中间态...buildToolPrefs(),// 工具选择、容差、不透明度...buildBrush(),// 笔刷大小、硬度...buildSoften(),// 柔化参数// ... 其余 6 个子域}}切帧时状态工厂函数重新执行所有字段回到默认值。但有些用户做的操作是不应该被重置的所以这里做了一个保留白名单。9 个子域各自声明哪些字段跨帧携带工具偏好当前工具、容差、不透明度、笔刷大小、笔刷硬度预览参数柔化半径、反色通道开关换色保护色列表选区魔棒连续模式切帧时从旧状态中把白名单字段拷贝到新状态其余字段撤销栈、重做栈、操作记录、交互中间态等全部归零。加新工具时如果需要跨帧字段就在对应的保留函数里加一行字段名。维护成本很低。白名单的选入标准很简单就是切换帧后用户不希望重新设置的参数例如笔刷大小、强度、容差等。反之对于大多数切帧时不应该保留的就应该统一清零。// 切帧时的状态处理伪代码functionswitchFrame(oldState,newFrameData){constkeepKeys[activeTool,tolerance,opacity,brushSize,...]constnewStatebuildEditorState()for(constkeyofkeepKeys){newState[key]oldState[key]}commitState(newState)}白名单集中在切帧函数里维护而不是散落在 13 个子域的工厂函数里。加一个新工具需要跨帧携带参数时改一行白名单就行。编辑器会话生命周期编辑器状态什么时候创建、什么时候销毁打开编辑器是做四件事用状态工厂创建全新状态 → 从帧数据读像素写主画布 → 拷贝跨帧保留字段 → 重置所有预览计数器。设计选择是替换整个状态对象引用而不是逐字段清零。Vue 响应式追踪引用变化所有 watcher 统一响应切帧事件。提交编辑是出口提交预览态序列化操作记录和最终画布像素写入帧存储。取消编辑不用序列化会话层状态直接跟着状态对象被回收。一个实际踩过的坑切帧时画布宽高也在清空列表里工厂函数创建的初始状态里它们是 0。在 Vue 响应式下画布元素的 CSS 样式绑定这两个值尺寸从实际值塌缩到 0 再弹回来用户能看到闪烁。修法是在打开编辑器时防御性恢复上帧的宽高不走保留白名单机制。问题四预览和提交是两件事换色时用户看到的是叠加层上的实时预览。主画布完全没变。只有点应用后命令的 apply 才执行算法、保存撤销快照、putImageData写入主画布。物理上是三个 Canvas 层叠交互层上的内容随时可以清空丢弃不触发撤销/重做。主画布是真正的图像。异步回调的竞态有些工具的预览渲染走异步 WASM 调用。用户在第 3 帧调了柔化参数在结果回来之前切到了第 5 帧。WASM 回调返回的是第 3 帧的结果如果直接贴到交互层上第 5 帧会短暂显示第 3 帧的像素。解决方案是每次打开编辑器时重置一个预览请求计数器。发起异步调用时记录当前计数值回调回来时校验。计数不匹配说明上下文已经变了回调直接丢弃。跨帧预览导航有些预览工具支持跨帧导航。用户在第 3 帧用换色取色、调好参数、看到预览效果然后切到第 5 帧这时不需要重新点选采样位置。实现方式切帧前把采样点坐标归一化到 [0,1]、快照当前选区和数据。切帧后反归一化到新帧坐标空间从快照恢复选区上下文交互层在新帧上重新渲染预览。注意这里选区的「跨帧」是主动恢复而非自动继承它只在跨帧预览时触发和常规切帧时清空选区的行为是互补的。收尾FramePacker 需要是个专业的改图编辑器AI 视频抽出来的帧不能直接用需要去背景、调色、修补。这意味着要在浏览器里做一个能同时处理几十张图的像素级编辑工具。这个定位引出了三个连锁的架构需求工具多十多种、共五种交互模式→ 命令模式统一接口 工具配置系统声明行为约束。帧数多编辑一帧要能批量应用到其余帧→ 操作序列化成 JSON 可回放归一化坐标保证跨分辨率兼容。状态多70 字段、13 个子域→ 保留白名单处理切帧编辑器会话状态机管理生命周期。这三个需求互相独立又整体在架构下协同调度处理。我们按用户的一次完整操作流程走一遍用户选画笔在帧 3 上画了一笔 → 命令模式按拖拽族分发onPress/onDrag/onRelease→ 操作序列化进记录带归一化坐标和当前选区maskId。用户切到换色工具采样背景色 → 预览渲染到交互叠加层主画布不改 → 调好参数点应用→apply按「先算后推」执行算法、保存快照、putImageData写入主画布 → 配置系统在过程中控制「采样点非空才可应用」和「切换时是否弹确认」。最后用户点批量应用到其他帧 → 回放函数遍历操作记录归一化坐标反归一化到各帧实际尺寸逐帧重放。后续会再写一篇单独展开 Undo / Redo 的功能设计。本文建立的命令模式、序列化操作记录和先算后推的快照保护正是 Undo/Redo 需要的基础设施。或者也可以讲讲 Sprite Sheet 的自动布局算法有感兴趣的同学可以mark一下。对 FramePacker 完整工具感兴趣的同学可以在专栏简介中看到链接。