feat(export): add PNG export size option, default 1x with paid 2x tier
这条业务线解决的是“用户从编辑器导出可交付图片”时的默认值、清晰度、批量导出和跨页面类型一致性问题。此前 PNG 的 2× 来自 export worker 契约的隐式默认,用户不能选择,产物体积偏大;DESIGN、CODE、多页 ZIP、MP4 和 agent snapshot 也没有统一表达导出尺度的入口
source_automation: “voyager-merged-pr” run_date: “2026-07-20” anchor_pr_number: 4938 pr_number: 4938 pr_title: “feat(export): add PNG export size option, default 1x with paid 2x tier” pr_url: “https://github.com/adastralab-ai/voyager/pull/4938 ↗” author: “luin” merged_at: “2026-07-20T04:01:56Z” modules: [“packages/site/src/app/(main)/files/[id]/_components/ExportPanel.tsx”,“packages/site/src/app/(main)/files/[id]/_components/ExportButton.tsx”,“packages/site/src/app/(main)/files/[id]/export/_lib/export.ts”,“packages/site/src/app/(main)/files/[id]/export/image/route.ts”,“packages/site/src/app/(main)/files/[id]/export/images-zip/route.ts”,“packages/site/src/app/(main)/files/[id]/export/selection/route.ts”,“packages/site/src/app/(main)/files/[id]/export/video/route.ts”,“backend/workers/agent/src/tools/snapshotTool.ts”,“packages/site/src/app/_lib/PlanDialogContext.tsx”] files_changed: 12 learning_tags: [“export-pipeline”,“png-scale”,“billing-entitlement”,“route-contract”,“code-render”,“zip-bounds”,“worker-runtime”,“cross-layer-api”,“compatibility-migration”] business_line: “统一 PNG 导出尺度与跨编辑器渲染边界” related_prs: [4706,4626,4837] line_stage: “CODE 单页出口 -> 多页 ZIP 边界 -> 当前页默认入口 -> PNG scale 产品化与调用方默认显式化” open_questions: [“服务端仍未按 free/pro entitlement 强制 scale,替代客户端可直接请求 2×、3× 或 4×。”,“主 PNG/ZIP、MP4、agent snapshot 为 1×,selection 与 worker fallback 为 2×,需要确认是否长期保留多套默认。”,“需要验证 billing data 缺失但 isLoading/isError 均为 false 时的 2× 可选边界。”,“需要用真实 free/pro 账号和真实 Playwright/ZIP 大图验证像素、截断、413、长尾和 overlay 行为。”,“agents SDK 或 export worker 合约升级时需要复验 snapshot scale 默认和 route 显式默认。”] feishu_doc_url: “https://my.feishu.cn/docx/J8nFdxVsDoUpC1x3sRGcboYCnRg ↗”#
正文来源:飞书学习文档 ↗。以下为通过个人 Feishu API 获取并转换后的完整 Markdown 正文。
业务线概览#
这条业务线解决的是“用户从编辑器导出可交付图片”时的默认值、清晰度、批量导出和跨页面类型一致性问题。此前 PNG 的 2× 来自 export worker 契约的隐式默认,用户不能选择,产物体积偏大;DESIGN、CODE、多页 ZIP、MP4 和 agent snapshot 也没有统一表达导出尺度的入口。
本次范围覆盖 site 的导出面板与请求、Next.js 导出路由、共享导出编排、export worker 合约,以及 agent snapshot 工具。它没有完成服务端付费 entitlement 强制;2× 仍主要是 UI 层的产品门禁。
**业务线名称:**统一 PNG 导出尺度与跨编辑器渲染边界。
**当前范围:**DESIGN 单页、多页 PNG ZIP、CODE 单页 PNG、MP4 默认尺度、selection 导出和 agent snapshot 默认尺度。
**本次新增:**把 scale 变成调用方显式传递的参数,PNG 主流程默认 1×,付费用户可选择 2×,并把旧 worker fallback 留在兼容边界。
今日锚点#
**PR #4938:**feat(export): add PNG export size option, default 1x with paid 2x tier
- 作者:luin
- 合入时间:2026-07-20T04:01:56Z,即 Australia/Melbourne 2026-07-20 14:01:56 AEST
- 链接:GitHub PR #4938 ↗
- 合入提交:36f08ff8be6a33717fa6f6860f8d8c3db9f30aa1,已验证可达 origin/main
它是今天最合适的入口,因为它不是孤立的 UI 选项:12 个文件把 scale 从 ExportPanel 传到单页/ZIP/CODE/MP4/agent snapshot 的边界,并修正了多个隐藏默认。它还能用 #4706、#4626、#4837 的真实代码自然串起 CODE 出口、多页边界和用户入口演进;今天的其他候选中,依赖升级和纯 UI 抽取的工程密度较低,agent 小修也与最近两次 agent 可靠性学习重复度更高。
演进时间线#
| 阶段 | PR | 改变的层 | 代码证据与意义 |
|---|---|---|---|
| 前置:跨编辑器单页出口 | #4706 ↗,作者 yansanmu1990,2026-07-15 05:57:08 AEST | IDL / Go API / site 编排 / export worker | CODE 不再复用 DESIGNValue 渲染,而是发送原始 HTML;Go API 为 workspace 签发 10 分钟 worker token,Playwright 在 worker 内解析 assets.local 和字体,并限制外部网络、捕获尺寸和执行时限。 |
| 前置:批量 PNG 能力 | #4626 ↗,作者 lawvs,2026-07-15 18:43:42 AEST | site route / export contract / worker handler / 测试 | 增加有序多页 ZIP;最多 20 页,累计 PNG 输入超过 32 MiB 返回 413;worker 串行截图、按页裁剪资源集合、生成稳定的 page-NNN.png,并用测试锁住顺序、资源隔离和内存边界。 |
| 前置:用户入口收敛 | #4837 ↗,作者 litemo,2026-07-16 00:08:58 AEST | site ExportPanel / 行为测试 | 把 DESIGN 导出的初始格式从 PDF 改成当前页 PNG,并用 ExportButton 测试固定默认行为,为今天把默认 1× 放在 PNG 主流程提供了稳定入口。 |
| 当前锚点:尺度产品化 | #4938 ↗,作者 luin,2026-07-20 14:01:56 AEST | site UI / billing UX / route contract / worker caller / agent tool | PNG 面板新增 Standard 1× 与 High resolution 2×;free 用户点击 2× 进入 pricing dialog;billing loading/error 时对付费项 fail closed;所有主调用方显式传 scale,路由默认明确区分 1× 与 selection 的 2×。 |
以上四个 PR 的 merge commit 均已验证可达 origin/main;没有把未合入分支或仅存在于 PR 分支的改动纳入报告。
当前架构与数据流#
用户入口 → ExportButton → ExportPanel。单页 DESIGN/CODE 调用 /files/[id]/export/image;多页 DESIGN 调用 /files/[id]/export/images-zip;selection 仍走 /export/selection;MP4 走 /export/video。#4938 让主 PNG UI 把 scale 与 pageIds 一起交给 ExportButton。
Next.js route 先用 Zod 校验请求,再由 export/_lib/export.ts 并行物化页面和初始化临时上传。DESIGN 路径解析持久化 DesignValue,向 Go API 批量取资产/字体,把带 scale 的 render payload 发给 export worker;worker 用浏览器截图并把结果上传到 signed URL。
CODE 路径在相同的临时上传边界下分支:Go API 的 /api/export/worker-token 校验 workspace member 后签发 10 分钟 token;export worker 接收原始 HTML,在 Playwright context 级别拦截网络,按 token 从 API 解析 assets.local 和字体,图片重定向到 CDN、字体小体积代理,其他外部 HTTP/WebSocket 默认拒绝,然后按 deviceScaleFactor 截图。
ZIP 路径先取所有页面数据,再把有序 pages、资产、字体、scale 发给 worker。worker 按页串行渲染,限制 20 页和 32 MiB 累计 PNG 输入,生成稳定文件名后一次上传 ZIP。前端最后下载返回的 signed URL。
当前调用方默认值是:主单页 PNG 1×、多页 PNG ZIP 1×、MP4 1×、agent snapshot 1×;selection PNG 仍为 2×。worker 的 image、video、ZIP 合约仍保留 2× fallback,因此产品默认必须在更靠近调用方的 route/schema 层显式钉住。
关键代码#
1. UI 把 scale 变成导出请求的一部分(#4938,ExportPanel.tsx:110-126;ExportButton.tsx:89-108)。
if (format === "png" && currentPageType === "CODE" && currentPageId != null) {
onExportPng([currentPageId], pngScale);
return;
}
if (format === "png") onExportPng(orderedSelected, pngScale);plaintext设计点:同一个面板同时覆盖 CODE 单页和 DESIGN 单页/ZIP,但不让下游猜测当前 scale。#4938 的测试分别断言 DESIGN、CODE、ZIP 请求体都带 scale: 1。
2. 付费门禁在 UI 侧明确表达,并对已知未知状态收敛(#4938,ExportPanel.tsx:461-513)。
const isHighResLocked = billingMe?.plan === "free";
const isEntitlementUnknown = isBillingLoading || isBillingError;
if (opt.isPaidOnly && isHighResLocked) {
showPlanDialog({ source: "export_size", intent: "upgrade" });
return;
}
isItemDisabled={(opt) => opt.isPaidOnly && isEntitlementUnknown}plaintext设计点:free 用户仍能看到能力和升级入口;billing 请求加载中或失败时不允许误选付费项。PlanDialog 增加了 export_size 来源,保留转化追踪边界。
3. 默认值下沉到调用方 route,而不是继续依赖 worker fallback(#4938,image/route.ts:8-12;images-zip/route.ts:12-16;selection/route.ts:8-16;video/route.ts:8-12)。
scale: z.number().positive().max(4).default(1) // image / ZIP / video
scale: z.number().positive().max(4).default(2) // selectionplaintext设计点:route 先把产品语义固定,再把必需的 number 传入共享编排;selection 的旧 2× 高清行为被显式保留。这样可以兼容 worker 仍接受缺省 scale 的其他调用方,但也留下了多个默认源,需要后续统一策略。
4. ZIP worker 以资源隔离、串行和字节上限换取可控内存(#4626,exportImagesZip.ts:21-60)。
for (const [index, { designValue }] of pages.entries()) {
const result = await renderImageExport({
...renderInput, designValue, assets: pageAssets
});
sourceBytes += result.data.byteLength;
if (sourceBytes > 32 * 1024 * 1024) throw new ImagesZipTooLargeError();
}plaintext设计点:不并发启动多个浏览器截图,避免 Worker isolate 同时持有多个大 PNG;单页只携带自己引用的 assets/fonts,减少 payload;超过限制在上传前失败,并由 route 映射为 413。
5. CODE 导出把资源能力限制在 workspace token 内(#4706,handler/export.go:14-35;renderCode.ts:136-205)。
const exportWorkerTokenTTL = 10 * time.Minute
if _, err := h.authzService.EnsureMember(ctx, workspaceID); err != nil {
return
}
token, err := h.workerTokenSigner.Mint(workspaceID, exportWorkerTokenTTL)plaintext设计点:raw HTML 无法像 DesignValue 一样预先枚举全部资源,所以 worker 需要按浏览器请求动态解析;短期、workspace-scoped token 让动态取资源仍有明确授权边界。#4706 同时在 renderCode.ts 对网络做默认拒绝,并区分字体代理与图片 CDN 重定向,降低 CORS 和 CDP 大 body 的代价。
工程取舍#
- **契约位置:**把产品默认放在 Next.js route/schema,避免 UI 漏传时再次落到 worker 的 2× fallback;同时保留 worker fallback 以兼容 selection 或其他直接调用方。这是低风险迁移路径,但默认值目前分散在 image、ZIP、video、selection、agent tool 和 worker 合约多个地方。
- **复用边界:**DESIGN 继续走稳定的 DesignValue + 批量资源表;CODE 不强行转成同一模型,而是复用导出上传、worker session、截图与下载链路,独立处理 HTML 资源解析。这个边界避免把 iframe/HTML 的动态资源误建模为静态资产表。
- **性能:**ZIP 串行截图和 32 MiB 上限牺牲部分吞吐,换取 Worker 内存可预测;PNG 已压缩,ZIP 使用 level 0,避免再次为压缩付出 CPU。
- **可靠性:**CODE 有 16,384 device-pixel 捕获上限、15 秒 capture budget 和 truncated 返回;ZIP 超限在上传前失败;短期 token 和 membership check 限制资源读取范围。
- 测试策略:#4938 的 site 测试验证三个请求体都传 1×;#4626 测试 ZIP 顺序、资源隔离、串行启动和 32 MiB 失败;#4706 的 Go contract test 覆盖成功、未登录、非 member 和非法 workspace ID;#4837 用行为测试固定当前页 PNG 默认。本次仅做只读代码审阅,未执行测试。
和最近学习记录的关系#
今天没有选择日志中已经作为锚点的 PR;日志中今天窗口没有去重项,昨天窗口的 #4844 被跳过。最近的 #4815 学习过 export worker 如何把持久化页面物化为 cover artifact,#4598 学习过 CODE 的持久化 source 与 ephemeral DOM 边界;本次不重复 cover 或 inline-edit 代码,而是新增“导出尺度是跨调用方产品契约”和“付费能力在 route/worker 边界如何落位”的视角。
最近 #4895 与 #4844 已覆盖 agent replay、soft interrupt 和用户进度可靠性;本次只把 agent snapshot 的 1× 默认作为导出调用方之一引用,不重复 agent 流协议。
我会怎么吸收#
- 把隐式 fallback 当作迁移风险:先在最靠近产品语义的边界把默认值显式化,再决定底层 fallback 是否保留。
- 跨页面类型时复用 transport、上传和观测边界,保留 DESIGN 结构化数据与 CODE raw HTML 的不同资源解析模型。
- 批量渲染优先建立页数、字节、时间和并发边界,并把稳定输出命名、413 错误和不上传失败结果做成契约。
- 当权限信息未知时 fail closed,但不要把 UI 门禁误当服务端授权;付费能力必须在可信后端或 worker boundary 再校验。
- 用请求体断言和 worker contract test 验证跨层参数真的传到最终渲染器,而不是只测试面板显示。
边界、风险与未解问题#
- **服务端 entitlement 缺口:**当前 route 只校验 scale 为正数且不超过 4,未校验 free plan;替代客户端可以直接请求 2×、3× 或 4×。PR body 已明确把 maxExportScale 类服务端强制留给后续。
- **默认值分裂:**主 PNG/ZIP、MP4、agent snapshot 是 1×,selection 和 worker fallback 是 2×。需要确认 selection 的 2× 是否长期产品规则,并考虑把能力/默认集中到共享策略。
- **未知 billing 状态:**代码覆盖 loading/error,但仍需确认 query 在 data 缺失而没有 isLoading/isError 的边界是否可能让 2× 可选。
- **ZIP 资源与延迟:**串行渲染把内存压平但放大长尾;累计字节超限是在完成当前页截图后才检测,真实大图和超时场景需要运行时验证。
- **CODE 资源失败:**字体失败会回退为空 CSS、图片解析失败返回 404;需要确认导出 UI 是否充分提示资源缺失,而不是只得到视觉降级结果。
- overlay 交互:#4938 已知 pricing dialog 外点会连带关闭导出面板,属于全站浮层栈问题,尚未在本线解决。
- **回归验证:**需要真实 free/pro 账号验证 1×/2× 像素、ZIP 产物、CODE 截断提示、MP4 1×,并在 agents SDK/worker 合约升级时复验 snapshot 的 scale 默认。
候选说明#
- 候选窗口按 Australia/Melbourne 的 merge 时间计算:今天 9 条(2026-07-19T14:00Z 至 2026-07-20T14:00Z),昨天 9 条(2026-07-18T14:00Z 至 2026-07-19T14:00Z)。
- 本地日志已有锚点 PR:#4124、#4156、#4309、#4314、#4344、#4389、#4390、#4516、#4580、#4591、#4598、#4637、#4651、#4720、#4815、#4844、#4895。今天没有去重跳过;昨天跳过 #4844。
- 选择 #4938 是因为它同时提供产品默认、API/route 契约、worker runtime、billing gate、测试与兼容迁移的证据,并能自然连接 #4706、#4626、#4837,形成一条完整的 PNG 导出演进线。
元数据#
| 字段 | 值 |
|---|---|
| businessLine | 统一 PNG 导出尺度与跨编辑器渲染边界 |
| anchorPrNumber | 4938 |
| relatedPRs | 4706, 4626, 4837 |
| modules | site export UI/routes, shared export orchestration, export worker contracts/handlers, agent snapshot tool, Go worker-token API |
| learningTags | export-pipeline, png-scale, billing-entitlement, route-contract, code-render, zip-bounds, worker-runtime, cross-layer-api |
| anchorFilesChanged | 12 |
| lineStage | CODE 单页出口 → 多页 ZIP 边界 → 当前页默认入口 → PNG scale 产品化与调用方默认显式化 |
飞书文档#
本报告创建后将把当前云文档链接回填到本地学习日志。