离屏渲染:照片捕获为什么需要独立的 canvas
两个世界的碰撞
照片捕获是实时流。摄像头每秒产生 30 帧画面,用户调整滤镜,期待立即看到效果。这是一个持续更新、永不停止的过程。
画布合成是静态操作。用户拖拽图片、调整布局、添加文字。每个操作产生一个新的状态,这个状态会保持,直到下一次修改。
这两个世界在同一个应用里相遇。它们的本质冲突决定了一件事:必须分离。
离屏策略的必然性
照片捕获系统不触碰主画布。当用户点击"拍照",一个隐藏的 canvas 元素在内存中被创建。它抓取视频的当前帧,应用选中的滤镜,转换成 PNG 文件,然后消失。
整个过程不出现在 DOM 树中。主画布甚至不知道这件事发生了。
这不是性能优化,而是设计的必然。
主画布服务于合成。它的更新周期由用户操作驱动。它的渲染依赖 DOM 和 CSS。它需要保持状态的持久性。
照片捕获服务于瞬间。它的更新周期是摄像头的帧率。它的渲染依赖 Canvas API。它不需要持久化,只需要在那一刻精确捕获。
如果强行让主画布处理照片捕获,就会出现两种破坏:
- 实时流的频繁更新会干扰合成画布的状态管理
- 合成画布的 DOM 结构会拖慢实时流的渲染性能
💡 Click the maximize icon to view in fullscreen
分离不是复杂度,而是对本质差异的尊重。
当两个功能的更新周期、性能要求、失败模式都不同时,它们应该使用不同的实现。
这不是提前规划出来的,而是从问题本质中自然涌现的。
捕获引擎的封装
usePhotoBooth 这个 hook 封装了整个照片捕获引擎。它管理摄像头权限,处理倒计时,在关键时刻调用 internalCaptureFrame 完成捕获。
捕获的核心逻辑很简单:
- 在内存中创建临时 canvas
- 从 video 元素绘制当前帧(非镜像)
- 通过
ctx.filter应用滤镜 - 转换为 PNG File 对象
只有最终的 PNG 文件跨越边界,进入全局状态,出现在资源面板。
这个设计有一个微妙的地方。照片捕获不是生成"图像数据",而是生成"文件对象"。文件是应用中资源的通用格式。无论是用户上传的图片,还是摄像头捕获的照片,还是从素材库选择的贴纸,它们在进入主画布之前,都统一为文件。
统一的数据格式消除了后续处理的差异。主画布不需要知道这个图像来自哪里,只需要知道它是一个可用的资源。
完整的捕获序列:
SoloCaptureForm调用 hook 的startCapture()takePhotoLoop启动倒计时- 倒计时结束,调用
internalCaptureFrame - 创建离屏 canvas,尺寸匹配视频分辨率
- 绘制非镜像的当前帧
- 应用用户选择的 CSS 滤镜
- 转换为 PNG File 对象
- 添加到 hook 的
photosTaken状态 useEffect监听变化,调用全局handleFileAdd- 照片出现在资源面板
这个多步骤流程确保照片捕获与主画布的渲染周期完全隔离。
关于导出管道的双路径设计,双重导出管道的架构选择有详细讨论。
导出的用户上下文分叉
用户完成创作,点击"生成分享链接"。系统必须做一个判断:这个用户是否登录?这个布尔值决定了两条完全不同的执行路径。
游客用户的所有操作都留在浏览器。html-to-image 库遍历主画布的 DOM 结构,捕获所有的插槽、图片、变换和背景色,渲染成 PNG data URL。一个新标签页打开,展示图像。用户可以保存,但没有持久链接,无法真正分享。
登录用户的流程涉及后端。同样的 data URL 被发送到 /api/creations 端点,由 Cloudflare Worker 处理。Worker 生成唯一 ID,上传图像到 R2 存储,构建公开 URL,写入 Supabase 数据库。响应返回客户端,触发分享弹窗。
这种设计反映了用户上下文。游客想要即时结果,零摩擦。登录用户想要持久性和分享能力。同一个 DOM 转图像的操作服务于两者,但之后的路径完全分叉。
💡 Click the maximize icon to view in fullscreen
发现的裂缝
系统存在一个明显的 bug。前端发送的是 JSON 格式的 Base64 数据:
body: JSON.stringify({ image: dataUrl })
但后端 worker 期待的是 FormData 格式的文件上传:
const formData = await c.req.formData();
const file = formData.get('file');
这意味着上传流程目前是断的。
这不是小问题。它揭示了一个更深层的设计疏漏:前后端的接口契约没有统一。可能的原因是前端代码改过,后端没跟上。也可能是后端代码是从另一个项目复制的,接口定义不一致。
两种修复方案:
方案 A:前端改用 FormData
- 需要将 data URL 转换为 Blob
- 需要调整上传逻辑
- 代价:前端改动较大
方案 B:后端接受 JSON
- 需要解码 Base64 字符串
- 需要转换为二进制数据
- 需要上传到 R2
- 代价:后端改动较大,但前端不变
方案 B 更合理。因为前端已经生成了 data URL,这是 html-to-image 库的输出格式。强行在前端转换会增加不必要的步骤。
但更根本的问题是:为什么这个 bug 没有被及时发现?可能是缺少端到端测试,也可能是这个功能还没有真正投入使用。
这个 bug 提醒我们:即使架构设计合理,实现中依然会有裂缝。重要的不是避免所有错误,而是建立能够快速发现和修复错误的机制。
设计的本质
照片捕获和画布合成的分离,体现了一个核心设计原则:让架构匹配操作的本质。
捕获是流式的、实时的。它需要低层 canvas API 和内存效率。 合成是静态的、基于 DOM 的。它需要布局灵活性和 CSS 变换。 导出是上下文依赖的。它需要根据用户认证状态分叉。
如果试图用统一的接口处理这三种操作,会在每个层面产生摩擦。分离让每个子系统可以独立优化、测试、调试。它们之间的契约很简单:文件进,图像出。
这种分离不是事先规划的,而是从问题中涌现的。当你理解了捕获、合成、导出的本质差异,分离就是自然的选择。
从意图到架构讨论了这种从用户意图反推技术实现的思维方式。当设计匹配问题的本质,系统就变得容易理解、容易扩展、容易修复。
边界的价值
好的架构不是消除边界,而是建立清晰的边界。
照片捕获的边界是文件。画布合成的边界是 DOM。导出的边界是用户的认证状态。
每个边界都定义了责任的范围。照片捕获不需要知道文件会被如何使用。画布合成不需要知道资源来自哪里。导出不需要知道画布是如何渲染的。
这种分离让系统的每个部分都可以独立演化。未来可以换掉照片捕获的实现,只要输出格式不变。可以重写画布合成的逻辑,只要接受的资源类型不变。可以改变导出的存储方式,只要返回的 URL 格式不变。
清晰的边界不是限制,而是自由。它让每个模块在自己的领域内做到最好,而不必担心影响其他模块。
当架构匹配操作的本质,系统就不需要额外的抽象层来掩盖不匹配。代码变得简单,因为它直接反映了问题的结构。
Related Posts
Articles you might also find interesting
集中式配置:让 Reddit 组件脱离重复泥潭
当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。
双重导出管道的架构选择
在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
重复数据的迁移实践:从 N 个文件到 1 个真相源
当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。
定价界面优化的三层方法
数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题
约束驱动设计:为何选择内存追踪
在 Cloudflare Workers 环境中实现追踪系统,持久化和内存存储之间的权衡不是技术偏好,而是约束驱动的必然选择。
依赖注入
依赖注入不是关于框架或工具,而是关于控制权的转移。理解这个转移,就理解了软件设计的核心原则。
让文档跟着代码走
文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。
在运行的系统上生长新功能
扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。
分层修复
生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。
多厂商 AI 调度:统一混乱的供应商生态
当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。
分布式 Workers 的解耦设计
通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。
Studio 前端架构:从画布到组件的设计思考
深入 Purikura Studio 前端架构设计,探讨 DOM-based 画布、状态管理和组件化的实践经验
Studio 系统架构:从状态机到端到端流程
深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑
Context 驱动的认证状态管理
认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。
第三方回调的状态映射完整性
KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。
统一积分系统的设计实践
从多套积分到单一积分池的架构演进,以及背后的原子性、一致性设计
Zustand Store
状态管理的本质不在于框架的复杂度,而在于你如何理解数据流动的边界