大尺寸 & 高清导出
做营销海报、大画幅喷绘、印刷物料时,经常会遇到「画布只有 1920×1080 预览好好的,但客户要 5m × 3m 的 300dpi 成品」这种需求。这类场景下的瓶颈不在 Ydesign 本身,而在于浏览器 canvas 的尺寸上限。这一篇讲清楚几件事:
- 浏览器到底能画多大
- Ydesign 客户端导出(
store.toDataURL/toBlob/saveAsImage)的真实能力边界 - 画布尺寸 vs
multipliervsdpi这三个数该怎么配合 - 什么时候必须切换到服务端渲染:云渲染 API(Ydesign 托管)或自建
@ydesign/node(规划中) - 超大资源图片的替换策略(「编辑用小图、导出用大图」)
为什么不能无限放大
一句话:浏览器 canvas 每一边都有硬上限,超过就渲染失败甚至崩页。
| 浏览器 | 单边像素上限 | 说明 |
|---|---|---|
| Chrome | 65535 px,但 area ≤ 2^28 (~268M px²) | 实际测下来长边安全值约 16384 px |
| Safari | iOS < 16384 px,macOS 略宽松 | 移动端极其敏感,经常 < 8192 已有问题 |
| Firefox | 11180×11180 左右 | 超过直接返回透明图片 |
超限时你看到的症状是:
store.toDataURL()返回一张全透明或只画了左上角一小块的图- 浏览器整个 tab 崩溃(Aw, Snap!)
- Console 报
Failed to execute 'toDataURL' on 'HTMLCanvasElement' - 移动端直接杀进程
💡 这不是 Ydesign 的限制,是浏览器 / 操作系统 / GPU 共同决定的物理上限。换任何一个前端 canvas 框架都会遇到。
Ydesign 客户端导出的安全区
综合经验值:
| 环境 | 单边安全上限 | 推荐用法 |
|---|---|---|
| 桌面 Chrome | 12000 px | multiplier: 2 ~ 4 |
| 桌面 Safari | 10000 px | multiplier: 2 ~ 3 |
| 移动端 | 4096 px | 优先用云渲染 |
| 低端设备 | 2048 px | 必须走云渲染 |
只要输出最长边 ≤ 8000 px,绝大部分现代桌面浏览器都能稳定工作。一旦超过这个线,就要开始考虑云端方案了。
三个关键数之间的关系
你在使用 Ydesign 的时候,会同时接触到三个数,搞清楚它们各自管什么很重要:
| 名词 | 属于谁 | 决定什么 |
|---|---|---|
| 画布尺寸 | store.width / store.height | 编辑器里看到的"设计尺寸",单位是像素 |
multiplier | 导出选项(ExportOptions) | 输出图是画布的几倍 → 决定输出像素 |
dpi | store.dpi(通过 store.setUnit 设置) | 只用于标尺 / 尺寸输入的单位换算,不影响导出像素 |
换算公式
输出像素(px) = 画布像素 × multiplier
物理尺寸(mm) = 画布像素 / dpi × 25.4
有效 DPI = multiplier × (画布像素 / 物理尺寸 × 25.4)
举个栗子:A4 纸 @ 300 DPI 印刷
A4 物理尺寸是 210 × 297 mm。在 300 DPI 下,像素尺寸是 2480 × 3508。
方案 1:画布直接按印刷像素设(推荐用于印刷物料)
// 画布直接用 300dpi 的实际像素
store.setSize({ width: 2480, height: 3508 });
store.setUnit({ unit: 'mm', dpi: 300 });
// 导出时 multiplier=1 就已经是 300dpi 印刷级了
await store.saveAsImage({
multiplier: 1,
format: 'png',
fileName: 'a4-print.png',
});
✅ 优点:标尺、元素位置所见即所得,和印刷结果 1:1 ⚠️ 代价:画布大 → 渲染稍慢、JSON 体积稍大
方案 2:画布按 72dpi 做 + 导出时靠 multiplier 放大
// 按屏幕 dpi 做画布,轻量
store.setSize({ width: 595, height: 842 }); // A4 @ 72dpi
store.setUnit({ unit: 'mm', dpi: 72 });
// 导出时放大 4 倍 → 2380×3368 ≈ 288dpi
await store.saveAsImage({
multiplier: 4,
format: 'png',
});
✅ 优点:画布小,编辑器操作流畅 ⚠️ 代价:小字号和细描边放大后可能出现 1-2px 偏差
怎么选? 如果业务里印刷物料是主线(封面、包装、海报),用方案 1;如果是以屏幕预览为主、偶尔导出印刷图,用方案 2。
大尺寸画布的四种做法
拿到需求后先不急着写代码,先用下面这张表决定走哪条路:
| 场景 | 推荐做法 | 运行位置 |
|---|---|---|
| 单边 ≤ 8000 px,桌面端用户 | 👉 做法 A:直接加大画布 | 客户端 |
| 单边 ≤ 12000 px,想让编辑更流畅 | 👉 做法 B:按比例编辑 + multiplier | 客户端 |
| 想快速上线服务端渲染、不想运维 | 👉 做法 C:云渲染 API | 服务端(Ydesign 托管) |
| 单边 > 12000 px、数据敏感、自有内网 | 👉 做法 D:自建 @ydesign/node | 服务端(你自己部署) |
| 移动端 / 低端设备用户 | 👉 做法 C 或 D(服务端) | 服务端 |
| 未知设备能力 | 👉 做法 E:混合方案(自动挑) | 客户端 + 服务端 |
💡 服务端的两条路(C 和 D)用的是同一套渲染内核。意味着你可以先用 C 快速上线,后续数据敏感 / 成本敏感时平滑迁到 D,产出的图像素级一致。
做法 A:直接加大画布(≤ 8000px 单边)
最直接。适用于横幅、单页海报这类"不太过分"的大图。
// 1.5m × 1m @ 150dpi 横幅
// 1500 / 25.4 * 150 ≈ 8858 px → 有点超了
// 降到 120dpi: 1500 / 25.4 * 120 ≈ 7087 px ✅
const targetDPI = 120;
const widthMM = 1500;
const heightMM = 1000;
const widthPx = Math.round((widthMM / 25.4) * targetDPI);
const heightPx = Math.round((heightMM / 25.4) * targetDPI);
store.setSize({ width: widthPx, height: heightPx });
store.setUnit({ unit: 'mm', dpi: targetDPI });
适用: 安全范围内(≤ 8000px)、桌面端用户 不适用: 移动端、超大画幅
做法 B:按比例编辑,导出时用 multiplier(≤ 8000px 输出)
编辑时画布小、操作快;导出时放大到目标尺寸。
// 目标:3m × 2m @ 200dpi 喷绘
// 真实输出像素:23622 × 15748(远超浏览器上限!)
// 按 1/4 比例做画布:5906 × 3937(安全范围)
const scale = 0.25;
const targetDPI = 200;
const widthMM = 3000;
const heightMM = 2000;
store.setSize({
width: Math.round((widthMM / 25.4) * targetDPI * scale),
height: Math.round((heightMM / 25.4) * targetDPI * scale),
});
// 让标尺依然显示真实物理尺寸:3000mm × 2000mm
store.setUnit({ unit: 'mm', dpi: targetDPI * scale });
// 导出:multiplier = 1/scale,输出的还是目标真实像素
await store.saveAsImage({ multiplier: 1 / scale }); // → 23622 × 15748
适用: 真实像素 ≤ 浏览器上限(约 1.6 亿像素内) 不适用: 移动端、单边 > 12000 px
做法 C:走云渲染 API(Ydesign 托管,无限放大)
超过浏览器上限,或者你想把渲染从用户设备上卸载掉,又不想自己搭服务,就直接调云渲染 API:
const res = await fetch('https://api.ydesign.com/api/render/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_API_KEY',
},
body: JSON.stringify({
json: store.toJSON(),
format: 'png',
multiplier: 8, // 云端支持到 8×,客户端只能到 4×
fonts: store.fonts.map(f => ({
fontFamily: f.fontFamily,
url: f.url,
})),
}),
});
const { url } = await res.json();
window.open(url, '_blank');
特点:
- ☁️ 托管服务,5 分钟接入 —— 拿到 API Key 就能用
- 🚀 无基础设施要求,弹性伸缩,Ydesign 统一维护
- 💰 按调用量计费(企业套餐),详见 定价
- ⚠️ JSON 要上传到 Ydesign 服务器,数据敏感场景请评估
适用:
- 真实输出 > 12000 px 单边
- 移动端 / 低端设备用户
- 批量生成(1000 张变体图)
- 后台定时任务 / 消息队列消费
- 想快速上线、不想运维
详见 云渲染 API 文档。
做法 D:自建服务端渲染(@ydesign/node,规划中)
如果你不想依赖外部云服务(数据敏感、已有内网渲染集群、成本敏感、离线部署),就用我们即将发布的独立 Node 包:
# 🚧 规划中 — 暂未发布
pnpm add @ydesign/node
// 规划中的 API 设计(详见 node-render.md)
import { createRenderer } from '@ydesign/node';
const renderer = await createRenderer({ concurrency: 5 });
const png = await renderer.jsonToImage(store.toJSON(), {
format: 'png',
multiplier: 8,
fonts: [{ fontFamily: 'AlibabaPuHuiTi_2_115_Black', url: 'https://cdn/.../.ttf' }],
});
// 矢量 PDF:分辨率无关,3m × 5m 喷绘也能秒出
const pdf = await renderer.jsonToPDF(store.toJSON(), { vector: true });
await renderer.close();
特点:
- 🏠 完全在你自己的服务器运行,数据不出域
- 🧩 和云渲染 API 用的是同一套内核 —— 产出图像素级一致,可平滑迁移
- 🎨 支持
jsonToImage/jsonToPDF(含矢量 PDF) - 💪 需要自行部署 + 运维(Docker 镜像 + Helm Chart 会一起发布)
适用:
- 数据敏感(设计稿不能上传到外部 API)
- 已有内网 Node 服务集群,想直接接入
- 成本敏感,调用量巨大(只付自己服务器的钱)
- 离线 / 私有化部署
做法 C vs D 快速对比
| 维度 | ☁️ 做法 C:云渲染 API | 🏠 做法 D:@ydesign/node(规划中) |
|---|---|---|
| 部署方式 | Ydesign 托管 | 你自己的 Node 服务 |
| 上手成本 | 5 分钟拿 API Key | 需搭建环境 + 生产部署 |
| 数据出域 | JSON 上传到 Ydesign | 完全不出内网 |
| 渲染内核 | 同一套 | 同一套 |
| 矢量 PDF | 支持 | 支持 |
| 并发扩容 | 按套餐弹性伸缩 | 自行扩容 |
| 成本模型 | 按调用计费 | 只付你自己服务器的钱 |
| 适用场景 | 上线快、免运维 | 数据敏感、内网、成本敏感 |
📖 完整部署指南:服务端图像生成(Node.js) —— 包含 API 详解、字体处理、Docker 部署、并发策略、BullMQ 队列等全套方案。
进度: 核心渲染内核已完成(就是云渲染 API 在用的那套),正在剥离成独立可发布包。进展请关注 GitHub Discussions 或 路线图。
推荐迁移路径: 现在先用 C 快速上线 → 未来自建需求明确后平滑迁到 D。
自动挑选渲染策略(混合方案)
实际产品里,你往往不知道用户是什么设备 —— 可能是 M3 MacBook Pro,也可能是中端安卓机。再加上你可能同时存在「云渲染」和「自建服务端」两条后备路线,推荐抽一个调度函数:
// packages/editor/src/utils/export-strategy.ts
import { isMobile } from './screen';
/**
* 渲染去向:
* - 'client' → 客户端导出(store.saveAsImage)
* - 'cloud' → Ydesign 云渲染 API(托管)
* - 'self-hosted' → 自建 @ydesign/node 服务(你自己的后端)
*/
type Strategy = 'client' | 'cloud' | 'self-hosted';
interface PickStrategyOptions {
/** 画布宽度 */
canvasWidth: number;
/** 画布高度 */
canvasHeight: number;
/** 导出倍数 */
multiplier: number;
/**
* 服务端首选:
* - 'cloud'(默认)→ 没自建时走云渲染
* - 'self-hosted' → 有自建时优先自建
*/
serverPreference?: 'cloud' | 'self-hosted';
}
export function pickExportStrategy({ canvasWidth, canvasHeight, multiplier, serverPreference = 'cloud' }: PickStrategyOptions): Strategy {
const maxSide = Math.max(canvasWidth, canvasHeight) * multiplier;
// 移动端对大 canvas 极不友好 → 一律服务端
if (isMobile()) return serverPreference;
// @ts-expect-error navigator.deviceMemory 不在标准库里但多数浏览器支持
const lowMemory = (navigator.deviceMemory ?? 8) < 4;
if (lowMemory) return serverPreference;
// 桌面端 ≤ 8000px 走客户端,否则上服务端
return maxSide <= 8000 ? 'client' : serverPreference;
}
然后在业务层:
import { observer } from 'mobx-react-lite';
import { Button, message } from 'antd';
import { pickExportStrategy } from './utils/export-strategy';
export const ExportButton = observer(({ store }) => {
const exportAt = async (multiplier: number) => {
const strategy = pickExportStrategy({
canvasWidth: store.width,
canvasHeight: store.height,
multiplier,
// 自建了 @ydesign/node → 设为 'self-hosted'
// 否则保持默认 'cloud'
serverPreference: 'cloud',
});
const key = 'export';
message.loading({ content: '导出中…', key });
const payload = {
json: store.toJSON(),
format: 'png',
multiplier,
fonts: store.fonts.map(f => ({ fontFamily: f.fontFamily, url: f.url })),
};
let url: string;
if (strategy === 'client') {
await store.saveAsImage({ multiplier, format: 'png' });
message.success({ content: '导出完成', key });
return;
} else if (strategy === 'cloud') {
// 做法 C:Ydesign 云渲染
const res = await fetch('https://api.ydesign.com/api/render/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_YDESIGN_KEY}`,
},
body: JSON.stringify(payload),
});
({ url } = await res.json());
} else {
// 做法 D:自建 @ydesign/node
const res = await fetch('/api/internal/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
({ url } = await res.json());
}
window.open(url, '_blank');
message.success({ content: '渲染完成', key });
};
return <Button onClick={() => exportAt(4)}>导出 4 倍高清</Button>;
});
💡 如果你同时部署了云渲染(托底)和自建 Node(主力),可以做三级降级:自建失败 → 云渲染兜底 → 客户端最后尝试。给用户最大成功率。
资源替换:编辑用小图、导出用大图
这是做大尺寸导出时最重要的一个优化。
场景:用户上传了一张 8000×6000 的原图,在 A4 画布里其实只占一小块。编辑器里加载原图会:
- 内存爆炸(单张 180MB 解码后)
- 拖拽卡顿
- 保存 JSON 时 base64 体积巨大
标准做法:编辑时用预览图(800px)、导出前临时换回原图。
1. 上传时同时保留两个 URL
setUploadFunc(详见 自定义图片上传)允许你在上传图片时返回扩展字段。把高清 URL 存到对象的 keyValues 自定义字段里:
editor.setUploadFunc(async file => {
// 上传原图
const originalUrl = await uploadToOSS(file);
// 生成一张 800px 预览图
const previewUrl = await generateThumb(file, 800);
return {
url: previewUrl, // 编辑器加载的
keyValues: {
hdSrc: originalUrl, // 记下高清地址
},
};
});
💡
keyValues是 Ydesign 对 fabric 对象加的自定义字段,会跟随toJSON一起序列化,不会丢。
2. 导出前临时替换
async function exportHD(store, multiplier = 4) {
// 收集所有需要换回的图片
const images = store.editor!.customCanvas.canvas.getObjects().filter(o => o.type === 'image' && (o as any).keyValues?.hdSrc) as FabricImage[];
// 记下原 src,逐个换成高清版
const originals: { img: FabricImage; src: string }[] = [];
await Promise.all(
images.map(async img => {
originals.push({ img, src: img.getSrc() });
await img.setSrc((img as any).keyValues.hdSrc, { crossOrigin: 'anonymous' });
})
);
store.editor!.customCanvas.canvas.requestRenderAll();
try {
await store.saveAsImage({ multiplier, format: 'png' });
} finally {
// 不管成败,恢复预览图避免编辑器变卡
await Promise.all(originals.map(({ img, src }) => img.setSrc(src, { crossOrigin: 'anonymous' })));
store.editor!.customCanvas.canvas.requestRenderAll();
}
}
3. 预先校验图片是否够用
给运营 / 用户一个友好提示:"你这张图在 300dpi 下会糊" ——
/**
* 检查图片对于目标印刷尺寸是否够清晰
*/
export async function validateImageResolution(
src: string,
widthMM: number,
heightMM: number,
targetDPI = 300
): Promise<{ ok: boolean; actual: [number, number]; required: [number, number] }> {
const requiredW = Math.round((widthMM / 25.4) * targetDPI);
const requiredH = Math.round((heightMM / 25.4) * targetDPI);
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.crossOrigin = 'anonymous';
i.onload = () => resolve(i);
i.onerror = reject;
i.src = src;
});
return {
ok: img.naturalWidth >= requiredW && img.naturalHeight >= requiredH,
actual: [img.naturalWidth, img.naturalHeight],
required: [requiredW, requiredH],
};
}
业务层:
const r = await validateImageResolution(file.src, 210, 297, 300);
if (!r.ok) {
Modal.warn({
title: '图片分辨率不足',
content: `当前 ${r.actual[0]}×${r.actual[1]},印刷 A4 @ 300dpi 至少需要 ${r.required[0]}×${r.required[1]}`,
});
}
最佳实践清单
编辑器性能:
- ✅ 编辑器里永远加载预览图,高清地址存到
keyValues.hdSrc - ✅ 对单张 > 5MB 的图片强制生成缩略图
- ✅ 设置 CORS 并用 CDN 分发高清资源
导出策略:
- ✅ 用
pickExportStrategy根据设备 + 输出尺寸自动选路 - ✅ 印刷物料优先「画布直接按真实像素 +
multiplier: 1」 - ✅ 超过 8000px 单边直接上服务端(云渲染 / 自建)
- ✅ 批量 / 后台任务一律用服务端
- ✅ 数据敏感、私有部署场景优先自建
@ydesign/node - ✅ 想快速上线、不想运维就用云渲染 API
资源管理:
- ✅ 上传时就校验图片分辨率是否够当前画布印刷用
- ✅ 字体、图片都要能被服务端抓取(公开访问 + CORS 开放)
- ✅ 云渲染返回的 URL 只保留 24 小时,业务侧务必立刻转存
常见排查
Q1:导出的图是全白或全透明
几乎一定是画布超出浏览器上限。
定位: console 里会看到警告或 toDataURL 返回异常的 base64(开头不是 data:image/png;base64,iVBORw 这种正常开头)。
解决:
- 降
multiplier,或者 - 缩小
store.width / height,或者 - 走云渲染
Q2:移动端 Safari 导出后 app 卡死
移动端 canvas 内存远比桌面紧。按下面处理:
if (isMobile()) {
// 移动端强制走云端
return await exportViaCloud(store, multiplier);
}
Q3:印刷厂说字体边缘发虚
通常是 multiplier 不够。印刷要求至少 300dpi,等价于:
multiplier >= 300 / 画布实际 dpi
如果画布是 72dpi 设计,要 300dpi 印刷,至少 multiplier: 4.2。
Q4:multiplier: 8 浏览器崩了
客户端导出受浏览器内存限制,实测 4× 是大多数桌面设备的稳定上限。要更高请用云渲染 API(支持到 8×)。
Q5:云渲染出来的图和客户端预览不一致
检查两件事:
延伸阅读
- 👉 单位与度量 ——
dpi/multiplier/ 单位换算完整说明 - 👉 云渲染 API —— 做法 C:Ydesign 托管的服务端渲染
- 👉 服务端图像生成(Node.js) —— 做法 D:自建
@ydesign/node的完整部署方案 - 👉 场景 & 导入导出 ——
toDataURL/toBlob/saveAsImageAPI - 👉 字体一致性 —— 保证前后端字体一致
- 👉 跨域资源(CORS) —— 远程图片 / 字体的 CORS 配置