离屏渲染:照片捕获为什么需要独立的 canvas

2 min read
Zekari
架构设计前端开发Purikura 项目

两个世界的碰撞

照片捕获是实时流。摄像头每秒产生 30 帧画面,用户调整滤镜,期待立即看到效果。这是一个持续更新、永不停止的过程。

画布合成是静态操作。用户拖拽图片、调整布局、添加文字。每个操作产生一个新的状态,这个状态会保持,直到下一次修改。

这两个世界在同一个应用里相遇。它们的本质冲突决定了一件事:必须分离。

离屏策略的必然性

照片捕获系统不触碰主画布。当用户点击"拍照",一个隐藏的 canvas 元素在内存中被创建。它抓取视频的当前帧,应用选中的滤镜,转换成 PNG 文件,然后消失。

整个过程不出现在 DOM 树中。主画布甚至不知道这件事发生了。

这不是性能优化,而是设计的必然。

主画布服务于合成。它的更新周期由用户操作驱动。它的渲染依赖 DOM 和 CSS。它需要保持状态的持久性。

照片捕获服务于瞬间。它的更新周期是摄像头的帧率。它的渲染依赖 Canvas API。它不需要持久化,只需要在那一刻精确捕获。

如果强行让主画布处理照片捕获,就会出现两种破坏:

  • 实时流的频繁更新会干扰合成画布的状态管理
  • 合成画布的 DOM 结构会拖慢实时流的渲染性能

💡 Click the maximize icon to view in fullscreen

分离不是复杂度,而是对本质差异的尊重。

当两个功能的更新周期、性能要求、失败模式都不同时,它们应该使用不同的实现。

这不是提前规划出来的,而是从问题本质中自然涌现的。

捕获引擎的封装

usePhotoBooth 这个 hook 封装了整个照片捕获引擎。它管理摄像头权限,处理倒计时,在关键时刻调用 internalCaptureFrame 完成捕获。

捕获的核心逻辑很简单:

  1. 在内存中创建临时 canvas
  2. 从 video 元素绘制当前帧(非镜像)
  3. 通过 ctx.filter 应用滤镜
  4. 转换为 PNG File 对象

只有最终的 PNG 文件跨越边界,进入全局状态,出现在资源面板。

这个设计有一个微妙的地方。照片捕获不是生成"图像数据",而是生成"文件对象"。文件是应用中资源的通用格式。无论是用户上传的图片,还是摄像头捕获的照片,还是从素材库选择的贴纸,它们在进入主画布之前,都统一为文件。

统一的数据格式消除了后续处理的差异。主画布不需要知道这个图像来自哪里,只需要知道它是一个可用的资源。

完整的捕获序列:

  1. SoloCaptureForm 调用 hook 的 startCapture()
  2. takePhotoLoop 启动倒计时
  3. 倒计时结束,调用 internalCaptureFrame
  4. 创建离屏 canvas,尺寸匹配视频分辨率
  5. 绘制非镜像的当前帧
  6. 应用用户选择的 CSS 滤镜
  7. 转换为 PNG File 对象
  8. 添加到 hook 的 photosTaken 状态
  9. useEffect 监听变化,调用全局 handleFileAdd
  10. 照片出现在资源面板

这个多步骤流程确保照片捕获与主画布的渲染周期完全隔离。

关于导出管道的双路径设计,双重导出管道的架构选择有详细讨论。

导出的用户上下文分叉

用户完成创作,点击"生成分享链接"。系统必须做一个判断:这个用户是否登录?这个布尔值决定了两条完全不同的执行路径。

游客用户的所有操作都留在浏览器。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 组件脱离重复泥潭

2 min read

当同一份数据散落在多个文件中,维护成本呈指数级增长。集中式配置不是技术选择,而是对抗熵增的必然手段。

架构设计React组件
Read More

双重导出管道的架构选择

2 min read

在用户生成内容场景中,速度与质量的权衡决定了导出架构。理解两种不同管道的设计逻辑,能够更准确地把握产品体验的边界。

架构设计图像导出
Read More

Purikura的页面系统

3 min read

通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。

架构设计React
Read More

重复数据的迁移实践:从 N 个文件到 1 个真相源

3 min read

当同一份 Reddit posts 配置散落在多个文件中,维护成本以文件数量指数增长。迁移到集中式配置不是技术选择,而是对复杂度的清算。

架构设计配置管理
Read More
Featured

定价界面优化的三层方法

4 min read

数据诚实、决策引导、视觉精调——三层递进的优化方法。从移除虚假功能到帮助用户选择,再到像素级修复,每一步都在解决真实问题

UI/UX定价策略
Read More

约束驱动设计:为何选择内存追踪

2 min read

在 Cloudflare Workers 环境中实现追踪系统,持久化和内存存储之间的权衡不是技术偏好,而是约束驱动的必然选择。

架构设计Cloudflare Workers
Read More

依赖注入

2 min read

依赖注入不是关于框架或工具,而是关于控制权的转移。理解这个转移,就理解了软件设计的核心原则。

软件设计系统思维
Read More

让文档跟着代码走

2 min read

文档过时是熵增的必然。对抗衰败的方法不是更频繁的手工维护,而是让文档"活"起来——跟随代码自动更新。三种文档形态,三种生命周期。

文档软件工程
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

分层修复

3 min read

生产问题没有银弹。P0 止血,P1 加固,P2 优化。优先级不是排序,而是在不确定性下的决策框架。

工程实践问题修复
Read More

多厂商 AI 调度:统一混乱的供应商生态

3 min read

当你依赖第三方 AI 服务时,单点故障是最大的风险。多厂商调度不只是技术架构,更是对不确定性的应对策略。

Purikura 项目系统架构
Read More

分布式 Workers 的解耦设计

3 min read

通过微服务架构和队列系统,实现高可用的 AI 任务处理。从单体到分布式,每个设计决策都是对复杂度的权衡。

Purikura 项目系统架构
Read More

Studio 前端架构:从画布到组件的设计思考

3 min read

深入 Purikura Studio 前端架构设计,探讨 DOM-based 画布、状态管理和组件化的实践经验

Purikura 项目前端架构
Read More

Studio 系统架构:从状态机到端到端流程

3 min read

深入 Studio 系统的状态管理中心、组件协调机制和 AI 生成的完整数据流,理解前后端集成的设计逻辑

Purikura 项目系统架构
Read More

Context 驱动的认证状态管理

3 min read

认证系统的核心不在登录按钮,而在状态同步。如何让整个应用感知用户状态变化,决定了用户体验的流畅度。

软件设计认证系统
Read More

第三方回调的状态映射完整性

5 min read

KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。

Purikura 项目系统设计
Read More

统一积分系统的设计实践

2 min read

从多套积分到单一积分池的架构演进,以及背后的原子性、一致性设计

系统架构数据库设计
Read More

Zustand Store

2 min read

状态管理的本质不在于框架的复杂度,而在于你如何理解数据流动的边界

React状态管理
Read More