第11章:导出与截图
11.1 章节概述
核心问题:如何将无限画布中的内容高质量导出为图片?
导出系统是无限画布的重要功能,需要解决以下技术挑战:
- 高精度渲染:支持不同分辨率的导出
- 大尺寸支持:处理超大画布的分块渲染
- 格式转换:支持多种图片格式(PNG、JPG、WebP 等)
- 异步渲染:等待所有资源加载完成
- 后台导出:页面隐藏时继续渲染
11.2 Extractor 导出器架构
11.2.1 核心类设计
Extractor 是导出功能的核心类,负责将数据模型渲染为图片:
typescript
// domains/editor/packages/editor/infinite-renderer/src/extractor/extractor.ts
export interface ExtractorOptions {
context: IContext;
appOptions?: Partial<IApplicationOptions>;
}
export class Extractor implements IExtractor {
ticker: Ticker;
private app: Application | null = null;
private isInternalApp = false;
private vmEngine: IVmEngine;
private context: IContext;
private appOptions: Partial<IApplicationOptions>;
constructor(options: ExtractorOptions) {
const { context, appOptions } = options;
this.vmEngine = new VmEngine({ context });
this.context = context;
this.appOptions = { ...appOptions };
// 使用特殊的 TimeTicker,支持后台渲染
this.ticker = new TimeTicker();
this.ticker.maxFPS = 1;
this.ticker.start();
}
}架构设计要点:
- 独立的 VmEngine:导出使用独立的 VM 引擎,不影响编辑器状态
- 独立的 Application:可复用或创建独立的渲染应用
- TimeTicker:特殊的计时器,页面隐藏时使用
setInterval替代requestAnimationFrame
11.2.2 导出接口定义
typescript
// domains/editor/packages/editor/infinite-renderer/src/types/extractor.ts
export interface IExportOptions {
/** 导出精度(分辨率) */
resolution?: number;
/** 最大导出宽度,默认取当前环境最大值 */
maxWidth?: number;
/** 最大导出高度,默认取当前环境最大值 */
maxHeight?: number;
/** 最大导出面积,默认取当前环境最大值 */
maxArea?: number;
/** 背景设置 */
background?: ColorSource;
/** 导出图像内边距 */
padding?: {
left?: number;
right?: number;
top?: number;
bottom?: number;
};
/** 超时时间 */
timeout?: number;
/** 水印相关配置 */
watermarkEnable?: boolean;
watermarkElement?: string;
aiWatermarkEnable?: boolean;
aiWatermarkLabel?: number;
/** 渲染报错时抛出异常 */
respectError?: boolean;
}
export interface IExportResult<T> {
/** 导出结果 */
result: T;
/** 导出渲染精度 */
resolution: number;
/** 元素模型包围盒 */
modelBounds: Rectangle;
/** 渲染包围盒 */
renderBounds: Rectangle;
}11.3 toCanvas 导出流程
11.3.1 完整导出流程
typescript
async toCanvas<T extends ICanvas>(
model: BaseElementModel | PageModel,
options: IExportOptions = {},
canvas?: ICanvas,
): Promise<IExportResult<T>> {
const { padding = {} } = options;
const { left = 0, top = 0, right = 0, bottom = 0 } = padding;
// 1. 获取渲染应用
const app = this.getApplication();
const renderer = app.renderer as SkRenderer;
// 2. 初始化目标 VM
const target = this.initTarget(model, options);
try {
// 3. 创建目标 Canvas
if (!canvas) {
canvas = PISO_SETTINGS.ADAPTER.createCanvas();
}
// 4. 刷新所有节点状态
traverse(target, (node) => node.flush());
// 5. 计算模型包围盒
const frame = target.getModelBounds(false, new Rectangle());
frame.pad(top, right, bottom, left);
frame.width = Math.max(1, Math.round(frame.width));
frame.height = Math.max(1, Math.round(frame.height));
// 6. 计算渲染分辨率
let resolution = this.calcResolution(frame.width, frame.height, options);
// 7. 渲染内容到画布
await this.render(renderer, this.ticker, canvas, resolution, target, frame, options);
// 8. 获取实际渲染包围盒(可能因特效而扩展)
const bounds = getRenderBounds(target);
bounds.pad(top, right, bottom, left);
// 9. 如果渲染包围盒与模型包围盒不同,重新渲染
if (
!isSame(bounds.x, frame.x) ||
!isSame(bounds.y, frame.y) ||
!isSame(bounds.width, frame.width) ||
!isSame(bounds.height, frame.height)
) {
resolution = this.calcResolution(bounds.width, bounds.height, options);
await this.render(renderer, this.ticker, canvas, resolution, target, bounds, options);
}
return {
result: canvas as T,
resolution,
modelBounds: frame,
renderBounds: bounds,
};
} finally {
// 10. 销毁临时 VM
target.destroy(true);
}
}11.3.2 流程图解
┌─────────────────────────────────────────────────────────────────┐
│ toCanvas 导出流程 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ 获取渲染应用 │
└─────────────────┘
│
▼
┌─────────────────┐
│ 初始化目标 VM │ ← 创建独立的 VM 用于导出
└─────────────────┘
│
▼
┌─────────────────┐
│ 刷新所有节点 │ ← traverse(target, node => node.flush())
└─────────────────┘
│
▼
┌─────────────────┐
│ 计算模型包围盒 │
└─────────────────┘
│
▼
┌─────────────────┐
│ 计算渲染分辨率 │ ← 受最大尺寸/面积限制
└─────────────────┘
│
▼
┌─────────────────┐
│ 第一次渲染 │
└─────────────────┘
│
▼
┌─────────────────┐
│ 检查渲染包围盒 │ ← 特效可能扩展包围盒
└─────────────────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
┌───────────┐ ┌───────────────┐
│ 相同 │ │ 不同,重新渲染 │
└───────────┘ └───────────────┘
│ │
└─────────┬─────────┘
│
▼
┌─────────────────┐
│ 返回导出结果 │
└─────────────────┘11.4 目标 VM 初始化
11.4.1 创建导出专用 Context
导出时会创建独立的 Context,配置与编辑时不同:
typescript
protected initTarget(
model: BaseElementModel | PageModel,
options: IExportOptions = {},
): IBaseContainerVm {
const {
watermarkEnable = this.context.watermarkEnable,
watermarkElement = this.context.watermarkElement,
aiWatermarkEnable = this.context.aiWatermarkEnable,
preferImageUrl = this.context.preferImageUrl,
resolution = this.context.pixelRatio,
} = options;
// 创建导出专用 Context
const context = new Context({
ticker: this.context.ticker,
modelAdaptor: this.context.modelAdaptor,
customRenderer: this.context.customRenderer,
stageMode: this.context.stageMode,
pixelRatio: resolution,
// 关键配置:渲染模式设为 'export'
renderingMode: 'export',
// 水印配置
watermarkEnable,
watermarkElement,
aiWatermarkEnable,
// 不隐藏编辑中的内容
hiddenOnEditing: false,
});
// 创建独立的 VM
const target = this.vmEngine.createVm(model, true, context);
return target;
}关键配置差异:
| 配置项 | 编辑模式 | 导出模式 |
|---|---|---|
renderingMode | 'normal' | 'export' |
hiddenOnEditing | true | false |
lazyUpdateCanvasSprite | true | false |
11.4.2 放大镜元素的特殊处理
放大镜元素依赖父容器进行渲染,需要特殊处理:
typescript
// 放大镜元素依赖 parent 容器出图
if (isMagnifierElementModel(model as BaseElementModel)) {
const pageVm = new PageVm();
pageVm.addChild(target as IBaseElementVm);
return pageVm;
}11.5 分辨率计算
11.5.1 分辨率限制算法
导出分辨率受多重限制:
typescript
protected calcResolution(
width: number,
height: number,
options: IExportOptions
): number {
const {
maxWidth = getMaxCanvasSize(),
maxHeight = getMaxCanvasSize(),
maxArea = getMaxCanvasArea(),
} = options;
let { resolution = 1 } = options;
// 1. 边长比例限制
resolution = Math.min(
resolution,
maxWidth / width,
maxHeight / height
);
// 2. 面积比例限制
const area = width * resolution * height * resolution;
if (area > maxArea) {
resolution = Math.min(
resolution,
Math.sqrt(maxArea) / Math.sqrt(area)
);
}
return resolution;
}限制条件:
- 边长限制:
宽度 × 分辨率 ≤ maxWidth,高度 × 分辨率 ≤ maxHeight - 面积限制:
宽度 × 高度 × 分辨率² ≤ maxArea
11.5.2 设备最大尺寸检测
typescript
// 获取设备最大 Canvas 尺寸
export function getMaxCanvasSize(): number {
// iOS Safari 限制
if (isIOS()) {
return 4096;
}
// 其他浏览器限制
return 8192;
}
// 获取设备最大 Canvas 面积
export function getMaxCanvasArea(): number {
// iOS Safari 有严格的面积限制
if (isIOS()) {
const memory = (navigator as any).deviceMemory || 2;
if (memory <= 2) return 2048 * 2048;
if (memory <= 4) return 4096 * 4096;
return 4096 * 4096;
}
// 桌面浏览器
return 16384 * 16384;
}
// WebGL 最大纹理尺寸
export function getMaxWebGLSize(): number {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) return 4096;
return gl.getParameter(gl.MAX_TEXTURE_SIZE);
}11.6 渲染执行
11.6.1 单次渲染流程
typescript
protected async _render(
renderer: IRenderer & SkRenderer,
ticker: Ticker,
target: IBaseContainerVm<object, IBaseElementVm>,
frame: Rectangle,
resolution: number,
clearColor: ColorSource,
resample: boolean,
respectError: boolean,
timeout: number,
): Promise<RenderTexture> {
// 1. 创建渲染纹理
const width = Math.max(1 / resolution, frame.width);
const height = Math.max(1 / resolution, frame.height);
const renderTexture = RenderTexture.create({
width,
height,
resolution,
clearColor,
alphaMode: ALPHA_MODES.NPM, // Non-Premultiplied Alpha
});
// 2. 计算变换矩阵(平移到原点)
const transform = new Matrix().translate(-frame.x, -frame.y);
// 3. 定义渲染函数
const render = () => {
this.renderObject(
renderer,
target.view,
renderTexture,
transform,
MIPMAP_STRATEGY.NEAREST, // 快速渲染使用最近邻
);
};
try {
// 4. 添加到 Ticker 循环渲染
ticker.add(render);
// 5. 等待所有元素加载完成
await waitForComplete(target, target.view.name || 'unknown', respectError, timeout);
// 6. 最终渲染(可选重采样)
this.renderObject(
renderer,
target.view,
renderTexture,
transform,
resample ? MIPMAP_STRATEGY.RESAMPLE : undefined,
);
return renderTexture;
} finally {
ticker.remove(render);
}
}11.6.2 渲染对象方法
typescript
protected renderObject(
renderer: IRenderer & SkRenderer,
object: Container,
renderTexture: RenderTexture,
transform: Matrix,
mipmap: MIPMAP_STRATEGY = MIPMAP_STRATEGY.NEAREST,
): void {
// 保存原始状态
const oldLazy = renderer.lazyUpdateCanvasSprite;
const oldZoom = renderer.zoom;
const oldMipmapStrategy = renderer.mipmapStrategy;
const oldEnableMaxUploadPerFrame = renderer.enableMaxUploadPerFrame;
try {
// 设置导出状态
renderer.lazyUpdateCanvasSprite = false; // 禁用延迟更新
renderer.zoom = 1; // 固定缩放
renderer.mipmapStrategy = mipmap; // Mipmap 策略
renderer.enableMaxUploadPerFrame = false; // 禁用分帧上传
// 执行渲染
renderer.render(object, {
clear: true,
renderTexture,
transform,
});
} finally {
// 恢复原始状态
renderer.reset();
renderer.lazyUpdateCanvasSprite = oldLazy;
renderer.zoom = oldZoom;
renderer.mipmapStrategy = oldMipmapStrategy;
renderer.enableMaxUploadPerFrame = oldEnableMaxUploadPerFrame;
}
}11.7 大尺寸分块渲染
11.7.1 为什么需要分块
当导出尺寸超过 WebGL 最大纹理限制时,需要分块渲染:
typescript
protected async render(
renderer: SkRenderer,
ticker: Ticker,
canvas: ICanvas,
resolution: number,
target: IBaseContainerVm<object, IBaseElementVm>,
frame: Rectangle,
options: IExportOptions = {},
): Promise<void> {
const width = Math.max(Math.round(frame.width * resolution), 1);
const height = Math.max(Math.round(frame.height * resolution), 1);
canvas.width = width;
canvas.height = height;
// 获取最大稳定尺寸
const maxStableSize = renderer.gl ? getMaxWebGLSize() : MaxStableSize;
if (width > maxStableSize || height > maxStableSize) {
// 需要分块渲染
const maxSize = maxStableSize / resolution;
const chunks = splitChunks(frame, maxSize, maxSize);
await this._renderChunks(
renderer, ticker, canvas, target, frame,
resolution, chunks, clearColor, resample, respectError, timeout
);
} else {
// 单次渲染
const renderTexture = await this._render(
renderer, ticker, target, frame, resolution,
clearColor, resample, respectError, timeout
);
canvasWriter(renderer, renderTexture, canvas, 0, 0);
renderTexture.destroy(true);
}
}11.7.2 分块算法
typescript
// helpers.ts
export function splitChunks(
chunk: Rectangle,
maxWidth: number,
maxHeight: number = maxWidth,
): Rectangle[] {
const hChunks = [chunk];
const vChunks: Rectangle[] = [];
const pieces: Rectangle[] = [];
// 1. 横向切割
while (hChunks.length > 0) {
const chunk = hChunks.shift()!;
if (chunk.width > maxWidth) {
const chunk1 = chunk.clone();
const chunk2 = chunk.clone();
chunk2.x += maxWidth;
chunk2.width = chunk1.width - maxWidth;
chunk1.width = maxWidth;
hChunks.push(chunk1, chunk2);
} else {
vChunks.push(chunk);
}
}
// 2. 纵向切割
while (vChunks.length > 0) {
const chunk = vChunks.shift()!;
if (chunk.height > maxHeight) {
const chunk1 = chunk.clone();
const chunk2 = chunk.clone();
chunk2.y += maxHeight;
chunk2.height = chunk1.height - maxHeight;
chunk1.height = maxHeight;
vChunks.push(chunk1, chunk2);
} else {
pieces.push(chunk);
}
}
return pieces;
}分块示意图:
原始区域 (6000 × 5000),最大尺寸 4096
┌──────────────────────────────────┐
│ │
│ chunk[0] │
│ (4096 × 4096) │
│ │
├──────────────────┬───────────────┤
│ │ │
│ chunk[2] │ chunk[3] │
│ (4096 × 904) │ (1904 × 904) │
│ │ │
└──────────────────┴───────────────┘
│
chunk[1] │
(1904 × 4096) │
│11.7.3 分块渲染与拼接
typescript
protected async _renderChunks(
renderer: SkRenderer,
ticker: Ticker,
canvas: ICanvas,
target: IBaseContainerVm<object, IBaseElementVm>,
frame: Rectangle,
resolution: number,
chunks: Rectangle[],
clearColor: ColorSource,
resample: boolean,
respectError: boolean,
timeout: number,
): Promise<void> {
if (chunks.length === 0) return;
// 取出一个分块
const chunk = chunks.shift()!;
// 渲染分块
const renderTexture = await this._render(
renderer, ticker, target, chunk, resolution,
clearColor, resample, respectError, timeout
);
// 写入目标 Canvas 的对应位置
canvasWriter(
renderer,
renderTexture,
canvas,
chunk.x - frame.x, // 相对于原始区域的偏移
chunk.y - frame.y
);
renderTexture.destroy(true);
// 递归渲染剩余分块
return this._renderChunks(
renderer, ticker, canvas, target, frame,
resolution, chunks, clearColor, resample, respectError, timeout
);
}11.8 像素数据写入
11.8.1 Canvas Writer
typescript
const canvasWriter: Writer<ICanvas> = (
renderer,
renderTexture,
target,
destX,
destY
) => {
const { resolution, width, height } = renderTexture;
const ctx = target.getContext('2d')!;
// 1. 从 GPU 读取像素数据
let pixels = renderer.extract.pixels(renderTexture);
if (!pixels) return;
// 2. 转换为 Uint8ClampedArray
if (!(pixels instanceof Uint8ClampedArray)) {
pixels = new Uint8ClampedArray(pixels);
}
// 3. 计算目标位置和尺寸
const dx = Math.round(destX * resolution);
const dy = Math.round(destY * resolution);
const sw = Math.round(width * resolution);
const sh = Math.round(height * resolution);
// 4. 创建 ImageData
const imageData = PISO_SETTINGS.ADAPTER.createImageData(pixels, sw, sh);
// 5. 写入目标 Canvas
ctx.putImageData(imageData, dx, dy);
};数据流:
RenderTexture (GPU)
│
▼
renderer.extract.pixels()
│
▼
Uint8Array / Uint8ClampedArray (CPU)
│
▼
createImageData()
│
▼
ctx.putImageData()
│
▼
目标 Canvas11.9 等待渲染完成
11.9.1 异步资源加载
导出时需要等待所有异步资源加载完成:
typescript
// helpers.ts
export async function waitForComplete(
parent: IBaseContainerVm<object, IBaseElementVm>,
name: string,
respectError: boolean,
timeout: number,
): Promise<void> {
return new Promise((resolve, reject) => {
let timer: NodeJS.Timeout | number = -1;
const nodes: BaseElementVm[] = [];
// 1. 收集所有元素并监听错误
traverse(parent, (node) => {
if (node instanceof BaseElementVm && !(node as IBaseElementVm).isError) {
nodes.push(node);
// 监听加载错误
node.emitter.once('error', (error) => {
(node as IBaseElementVm).isError = true;
if (respectError) {
reject(error);
return;
}
// 非严格模式下,忽略错误元素
const index = nodes.indexOf(node);
if (index !== -1) {
nodes.splice(index, 1);
}
});
}
});
// 2. 创建检查计时器
const ticker = new TimeTicker();
ticker.add(() => {
const completed = checkReadyForExport(nodes);
if (completed) {
globalThis.clearTimeout(timer);
ticker.destroy();
resolve();
}
});
ticker.start();
// 3. 设置超时
timer = globalThis.setTimeout(() => {
ticker.destroy();
reject(new Error(`Viewport render ${name} timeout: ${timeout}ms`));
}, timeout);
});
}11.9.2 完成状态检查
typescript
function checkReadyForExport(elements: IBaseElementVm[]): boolean {
for (const element of elements) {
// 元素可见、有透明度,但未完成渲染
if (
element.view.worldVisible &&
element.view.worldAlpha > 0 &&
!element.isCompleted
) {
return false;
}
}
return true;
}11.10 TimeTicker 后台渲染
11.10.1 问题背景
浏览器在页面隐藏时会暂停 requestAnimationFrame,导致导出卡住。
11.10.2 解决方案
typescript
// timer.ts
export class TimeTicker extends Ticker {
private _interval = -1;
private _eventAdded = false;
constructor() {
super();
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
}
private handleVisibilityChange(): void {
if (document.visibilityState === 'hidden') {
// 页面隐藏:停止 RAF,启用 setInterval
this._cancelIfNeeded();
const timeout = this.maxFPS > 0 ? 1000 / this.maxFPS : 1000 / 60;
this._interval = window.setInterval(() => {
this.update();
}, timeout);
} else {
// 页面显示:恢复 RAF
this._requestIfNeeded();
window.clearInterval(this._interval);
this._interval = -1;
}
}
start(): void {
super.start();
if (isBrowser()) {
if (!this._eventAdded) {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
this._eventAdded = true;
}
// 初始化时检查页面状态
if (document.visibilityState === 'hidden') {
this.handleVisibilityChange();
}
}
}
stop(): void {
if (isBrowser()) {
if (this._eventAdded) {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
this._eventAdded = false;
}
window.clearInterval(this._interval);
this._interval = -1;
}
super.stop();
}
}工作原理:
页面可见 页面隐藏
│ │
▼ ▼
requestAnimationFrame ←→ setInterval
│ │
│ visibilitychange │
└────────────────────────────┘11.11 toBlob 格式转换
11.11.1 导出为 Blob
typescript
async toBlob(
model: BaseElementModel | PageModel,
options: IExportBlobOptions = {},
): Promise<IExportResult<Blob>> {
const { type, quality } = options;
// 1. 先导出到 Canvas
const { result, resolution, modelBounds, renderBounds } = await this.toCanvas(
model,
options,
);
// 2. 转换为 Blob
return convertToBlob(result, type, quality).then((blob) => {
// 3. 释放 Canvas 内存
result.width = 0;
result.height = 0;
return {
result: blob,
resolution,
modelBounds,
renderBounds,
};
});
}11.11.2 Canvas 转 Blob
typescript
// utils/image.ts
export async function convertToBlob(
canvas: ICanvas,
type?: string,
quality?: number,
): Promise<Blob> {
return new Promise((resolve, reject) => {
if (canvas.toBlob) {
// 标准 Canvas API
canvas.toBlob(
(blob) => {
if (!blob) {
reject('Canvas.toBlob failed.');
return;
}
resolve(blob);
},
type,
quality,
);
} else if (canvas.convertToBlob) {
// OffscreenCanvas API
canvas.convertToBlob!({
type,
quality,
})
.then((blob) => resolve(blob))
.catch(reject);
} else {
reject(
new Error(
'Extractor.toBlob requires Canvas.toBlob or Canvas.convertToBlob'
),
);
}
});
}支持的格式:
| type | 说明 | quality 范围 |
|---|---|---|
image/png | 无损压缩,支持透明 | 不适用 |
image/jpeg | 有损压缩,不支持透明 | 0.0 - 1.0 |
image/webp | 有损/无损,支持透明 | 0.0 - 1.0 |
11.12 Picker 取色器
11.12.1 取色器实现
typescript
// picker.ts
class Picker implements IPicker {
private app: Application;
constructor(options: PickerOptions) {
this.app = options.app;
}
private readPixels(x: number, y: number, width: number, height: number): Uint8ClampedArray {
const { renderer } = this.app;
const roundedX = Math.round(x);
const roundedY = Math.round(y);
const roundedW = Math.round(width);
const roundedH = Math.round(height);
tempRect.x = roundedX;
tempRect.y = roundedY;
tempRect.width = roundedW;
tempRect.height = roundedH;
try {
// WebGL 上屏后不保留 drawingBuffer
// 需要生成 RenderTexture 来读取像素
const renderTexture = renderer.generateTexture(this.app.stage, {
resolution: renderer.activeResolution,
region: tempRect,
});
const data = renderer.extract.pixels(renderTexture) as Uint8ClampedArray;
return data;
} catch (error) {
renderer.reset();
throw error;
}
}
getPixel(x: number, y: number): Uint8ClampedArray {
return this.readPixels(x, y, 1, 1);
}
getPixels(x: number, y: number, width: number, height: number): Uint8ClampedArray {
return this.readPixels(x, y, width, height);
}
getResolution(): number {
return this.app.renderer.resolution;
}
getView(): ICanvas | null {
return this.app.view;
}
destroy(): void {
// @ts-expect-error deconstruct
this.app = null;
}
}使用场景:
- 吸管工具:获取画布上某点的颜色
- 颜色检测:检测特定区域的主色调
11.13 渲染包围盒扩展
11.13.1 特效导致的包围盒扩展
某些特效会使渲染结果超出模型边界:
typescript
// helpers.ts
export function getRenderBounds(target: IBaseElementVm | IPageVm): Rectangle {
const bounds = target.getBounds(false, new Rectangle());
const model = target.getModel();
if (model && isPageModel(model)) {
return bounds;
}
// Path 类型元素的投影效果
if (model && isPathElementModel(model)) {
const effect = model.pathEffects.find((effect) => effect.shadow?.enable);
if (effect?.shadow) {
// 计算阴影偏移
const offsetLeft = -effect.shadow.offsetX + effect.shadow.blur;
const offsetRight = effect.shadow.offsetX + effect.shadow.blur;
const offsetTop = -effect.shadow.offsetY + effect.shadow.blur;
const offsetBottom = effect.shadow.offsetY + effect.shadow.blur;
// 扩展包围盒
bounds.x -= Math.max(0, offsetLeft);
bounds.y -= Math.max(0, offsetTop);
bounds.width += Math.max(0, offsetLeft) + Math.max(0, offsetRight);
bounds.height += Math.max(0, offsetTop) + Math.max(0, offsetBottom);
}
}
return bounds;
}扩展示意图:
模型包围盒 渲染包围盒(带阴影)
┌─────────────┐ ┌─────────────────────┐
│ │ │ 阴影扩展区域 │
│ 元素 │ → │ ┌─────────────┐ │
│ │ │ │ 元素 │ │
└─────────────┘ │ └─────────────┘ │
└─────────────────────┘11.14 水印处理
11.14.1 导出时添加水印
typescript
const context = new Context({
// ... 其他配置
// 普通水印
watermarkEnable: options.watermarkEnable,
watermarkElement: options.watermarkElement,
enableWatermarkRepeatRenderer: options.enableWatermarkRepeatRenderer,
// AI 生成内容水印
aiWatermarkEnable: options.aiWatermarkEnable,
aiWatermarkLabel: options.aiWatermarkLabel,
aiWatermarkElements: options.aiWatermarkElements,
});水印类型:
- 普通水印:版权保护水印
- AI 水印:标识 AI 生成内容
11.15 完整导出示例
typescript
// 使用示例
async function exportDesign(layout: LayoutModel): Promise<Blob> {
// 创建 Extractor
const extractor = new Extractor({
context: new Context({
modelAdaptor: new ModelAdaptor(),
preferImageUrl: true,
}),
appOptions: {
canvasKit: await getCanvasKit('full'),
},
});
try {
// 导出为 PNG
const { result } = await extractor.toBlob(layout, {
resolution: 2, // 2 倍分辨率
type: 'image/png', // PNG 格式
timeout: 30000, // 30 秒超时
respectError: true, // 错误时抛出异常
watermarkEnable: true, // 启用水印
padding: { // 内边距
top: 10,
right: 10,
bottom: 10,
left: 10,
},
});
return result;
} finally {
extractor.destroy();
}
}11.16 本章小结
核心概念回顾
| 概念 | 说明 |
|---|---|
| Extractor | 导出器核心类,管理整个导出流程 |
| TimeTicker | 支持后台渲染的计时器 |
| 分块渲染 | 大尺寸导出时的分块处理策略 |
| 渲染包围盒 | 考虑特效后的实际渲染区域 |
| Picker | 画布取色器 |
导出流程要点
- 独立 Context:导出使用独立配置,不影响编辑器
- 分辨率限制:受设备最大尺寸和面积限制
- 异步等待:等待所有资源加载完成
- 分块渲染:超大尺寸自动分块处理
- 后台支持:页面隐藏时继续渲染
性能优化建议
- 合理设置分辨率:避免过高分辨率导致内存溢出
- 设置超时时间:防止资源加载失败导致卡住
- 及时销毁资源:导出完成后清理 Extractor
- 使用适当格式:根据需求选择 PNG/JPG/WebP
