Skip to content

第11章:导出与截图

11.1 章节概述

核心问题:如何将无限画布中的内容高质量导出为图片?

导出系统是无限画布的重要功能,需要解决以下技术挑战:

  1. 高精度渲染:支持不同分辨率的导出
  2. 大尺寸支持:处理超大画布的分块渲染
  3. 格式转换:支持多种图片格式(PNG、JPG、WebP 等)
  4. 异步渲染:等待所有资源加载完成
  5. 后台导出:页面隐藏时继续渲染

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();
    }
}

架构设计要点

  1. 独立的 VmEngine:导出使用独立的 VM 引擎,不影响编辑器状态
  2. 独立的 Application:可复用或创建独立的渲染应用
  3. 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'
hiddenOnEditingtruefalse
lazyUpdateCanvasSpritetruefalse

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;
}

限制条件

  1. 边长限制宽度 × 分辨率 ≤ maxWidth高度 × 分辨率 ≤ maxHeight
  2. 面积限制宽度 × 高度 × 分辨率² ≤ 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()


目标 Canvas

11.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,
});

水印类型

  1. 普通水印:版权保护水印
  2. 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画布取色器

导出流程要点

  1. 独立 Context:导出使用独立配置,不影响编辑器
  2. 分辨率限制:受设备最大尺寸和面积限制
  3. 异步等待:等待所有资源加载完成
  4. 分块渲染:超大尺寸自动分块处理
  5. 后台支持:页面隐藏时继续渲染

性能优化建议

  1. 合理设置分辨率:避免过高分辨率导致内存溢出
  2. 设置超时时间:防止资源加载失败导致卡住
  3. 及时销毁资源:导出完成后清理 Extractor
  4. 使用适当格式:根据需求选择 PNG/JPG/WebP

如有转载或 CV 的请标注本站原文地址