nodejs使用graphicsmagick生成缩略图

2,722 阅读3分钟

笔者在《使用eggjs+typescript+react开发全栈项目(博客CMS)》提到了图片上传方案。

这个方案上线之后遇到了一个问题:上传到相册内都是用单反拍摄的高清大图,每张图片体积都在1MB以上,导致在加载图片列表时,耗时非常长,严重影响用户体验。

思索之后,想到的是解决方案是生成一张缩略图,加载图片列表时,显示缩略图;查看具体某张图片时,显示原图。

前期准备

既然要处理图片,就需要一个图片处理库。业界知名的处理库有两个:ImageMagickGraphicsMagick,两个库功能基本一致。初步了解了一下,GraphicsMagickImageMagick速度更快,更稳定。因此,我选择GraphicsMagick做为图片处理库。

MacOS安装GraphicsMagick

MacOS用户是最幸福的,只需一行命令就行完成安装。

$ brew install graphicsmagick

CentOS安装GraphicsMagick

网站服务器是CentOS系统,这个安装就麻烦多了。

首先要安装依赖。

$ yum install -y gcc libpng libjpeg libpng-devel libjpeg-devel ghostscript libtiff libtiff-devel freetype freetype-devel

下载并解压,官方的源无法连接,所以我换了一个源地址。

$ wget http://78.108.103.11/MIRROR/GraphicsMagick/1.3/GraphicsMagick-1.3.29.tar.gz
$ tar -zxvf GraphicsMagick-1.3.29.tar.gz

编辑安装

$ cd GraphicsMagick-1.3.29
$ ./configure --prefix=/root/GraphicsMagick-1.3.29
$ make
$ make install

设置环境变量

$ vi /etc/profile

## 添加以下代码
export GMAGICK_HOME=/root/GraphicsMagick-1.3.29
export PATH=$GMAGICK_HOME/bin:$PATH

$ source /etc/profile

检查安装是否成功

$ gm version

项目安装gm模块

库安装完之后,我们还需要让nodejs能够使用GraphicsMagick

gm模块可以让用户在nodejs环境操作GraphicsMagick库。我们通过npm命令安装到项目中即可。

$ npm install --save gm

图片处理

考虑我们的图片列表都是以1:1的比例展示图片的,所以裁切方案是:先把图片裁成1:1宽高比,然后缩小成宽高均为1000px的图片。

计算裁切范围

由于原图的宽高比各种各样,因此我们需要对不同类型的图片进行分类处理。

代码层面上,我们通过gmsize()方法获取图片宽高,计算出裁切需要的widthheightxy四个值。为保持代码风格一致,将这个过程Promise化了。

/**
 * 图片裁切范围
 * 
 * @param size 图片尺寸
 */
_getCropArea(file: IFile): Promise<ICropArea> {
    return new Promise((resolve, reject) => {
        gm(file.filepath).size((err, size) => {
            if (!err) {
                if (size.width >= size.height) {
                    resolve({
                        width: size.height,
                        height: size.height,
                        x: (size.width - size.height) / 2,
                        y: 0,
                    })
                } else {
                    resolve({
                        width: size.width,
                        height: size.width,
                        x: 0,
                        y: (size.height - size.width) / 2,
                    })
                }
            } else {
                reject(err);
            }
        })
    })
}

裁切图片,获取缩略图

获取裁切区域之后,利用crop()对图片进行裁切,然后通过resize()缩小至1000px,输出到指定路径。

/**
 * 裁切图片,返回缩略图
 * 
 * @param file 
 */
async _cropImg(file: IFile, targetPath: string, targetName: string): Promise<string> {
    const area: ICropArea = await this._getCropArea(file);

    return new Promise((resolve, reject) => {
        const filename: string = `${targetName}_${PREVIEW_IMG_SIZE.width}x${PREVIEW_IMG_SIZE.height}.${file.filename.split(".").slice(-1)}`;
        const filePath = path.join(targetPath, filename);

        gm(file.filepath)
            .crop(area.width, area.height, area.x, area.y)
            .resize(PREVIEW_IMG_SIZE.width, PREVIEW_IMG_SIZE.height)
            .write(filePath, (err) => {
                if (!err) {
                    resolve(filename);
                } else {
                    reject(err);
                }
            })
    })
}

最后来看,改造后的路由方法。

async upload() {
    const { ctx, app } = this;

    if (!ctx.isAuthenticated()) throw { status: 401 };

    try {
        const file = ctx.request.files[0];
        const needCrop = parseInt(ctx.request.body.crop) || 0;

        if (!file) throw { status: 404, message: 'file not found' };
        // 确认图片文件夹
        const targetPath = path.join(app.baseDir, app.config.uploadPath, moment().format("YYYYMM"));
        await fs.ensureDir(targetPath);
        // 保存完整图片
        const filename = `pic${new Date().getTime()}`;
        const filepath = `${filename}.${file.filename.split(".").slice(-1)}`;
        await fs.copyFile(file.filepath, path.join(targetPath, filepath));
        let outputPath = "/assets/upload/" + moment().format("YYYYMM") + "/" + filepath;
        // 生成缩略图
        if (needCrop) {
            const previewPath = await this._cropImg(file, targetPath, filename);
            outputPath = "/assets/upload/" + moment().format("YYYYMM") + "/" + previewPath;
        }

        ctx.body = {
            code: 0,
            message: "success",
            url: outputPath
        };
    } catch (err) {
        throw { status: 500, message: err }
    } finally {
        await ctx.cleanupRequestFiles();
    }
}

根据参数crop判断是否需要裁切。需要裁切时,原图和缩略图的文件名除了最后的后缀以外保持一致,然后返回缩略图地址,需要展示原图时,只要将后缀(_1000x1000)去掉即可。