跳到主要内容

大尺寸 & 高清导出

做营销海报、大画幅喷绘、印刷物料时,经常会遇到「画布只有 1920×1080 预览好好的,但客户要 5m × 3m 的 300dpi 成品」这种需求。这类场景下的瓶颈不在 Ydesign 本身,而在于浏览器 canvas 的尺寸上限。这一篇讲清楚几件事:

  • 浏览器到底能画多大
  • Ydesign 客户端导出(store.toDataURL / toBlob / saveAsImage)的真实能力边界
  • 画布尺寸 vs multiplier vs dpi 这三个数该怎么配合
  • 什么时候必须切换到服务端渲染:云渲染 API(Ydesign 托管)或自建 @ydesign/node(规划中)
  • 超大资源图片的替换策略(「编辑用小图、导出用大图」)

为什么不能无限放大

一句话:浏览器 canvas 每一边都有硬上限,超过就渲染失败甚至崩页

浏览器单边像素上限说明
Chrome65535 px,但 area ≤ 2^28 (~268M px²)实际测下来长边安全值约 16384 px
SafariiOS < 16384 px,macOS 略宽松移动端极其敏感,经常 < 8192 已有问题
Firefox11180×11180 左右超过直接返回透明图片

超限时你看到的症状是:

  • store.toDataURL() 返回一张全透明或只画了左上角一小块的图
  • 浏览器整个 tab 崩溃(Aw, Snap!)
  • Console 报 Failed to execute 'toDataURL' on 'HTMLCanvasElement'
  • 移动端直接杀进程

💡 这不是 Ydesign 的限制,是浏览器 / 操作系统 / GPU 共同决定的物理上限。换任何一个前端 canvas 框架都会遇到。

Ydesign 客户端导出的安全区

综合经验值:

环境单边安全上限推荐用法
桌面 Chrome12000 pxmultiplier: 2 ~ 4
桌面 Safari10000 pxmultiplier: 2 ~ 3
移动端4096 px优先用云渲染
低端设备2048 px必须走云渲染

只要输出最长边 ≤ 8000 px,绝大部分现代桌面浏览器都能稳定工作。一旦超过这个线,就要开始考虑云端方案了。


三个关键数之间的关系

你在使用 Ydesign 的时候,会同时接触到三个数,搞清楚它们各自管什么很重要:

名词属于谁决定什么
画布尺寸store.width / store.height编辑器里看到的"设计尺寸",单位是像素
multiplier导出选项(ExportOptions输出图是画布的几倍 → 决定输出像素
dpistore.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:云渲染出来的图和客户端预览不一致

检查两件事:

  1. 字体有没有传fonts 数组必须覆盖所有非系统字体,字体详解
  2. 图片能不能被云端抓到:不能用需要登录态的私有链接,CORS 详解

延伸阅读