视频转动图接口设计优化

457 阅读4分钟

上回说到 nodejs + ffmpeg 实现视频转动图,只是讲述了最基本的流程,从设计上看比较拙略,有许多可以改进的地方。本文主要从以下几个角度进行优化

  1. 抽离出视频转动图的逻辑作为一个 RequestHandler (中间件);
  2. 在该中间件的基础上新增更多接口服务;
  3. 抽离出 Option 设计成一个 class (用于执行 ffmpeg 命令行的类);
  4. 使用 crontab 定时执行删除任务,替代 setTimeout 定时器任务;
  5. 使用 ffmpeg 生成 全局调色板 增加画面质量

优化 - 封装 Option 为 class 对象

// ffmpegOption.js
const propertys = ['-v', '-ss', '-to', '-i', '-fs', '-crf', '-preset', '-vf', "-lavfi", '-s', '-r', '-y']

module.exports = class {
  constructor () {
    this.init()
  }
  init() {
    propertys.forEach(x => this[x] = '')
  }
  add(name, value) {
    this[name] += (this[name] ? ',' : '') + value
  }
  set(name, value) {
    this[name] = value;
  }
  get(name) {
    return this[name] ? `${name} ${this[name]} ` : ''
  }
  getValue(name) {
    return this[name]
  }
  toString() {
    return 'ffmpeg ' + propertys.reduce(((p, c) => p + this.get(c)), '')
  }
}

由于某些场景需要,添加了 setgetValue 方法。使用时只需要引入该模块然后实例化即可。

const ffmpegOption = require("./ffmpegOption")
const Option = new ffmpegOption()

关于 propertys 的设置,可以参考 ffmpeg 官方文档

优化 - 视频转动图 handler

// ffmpeg.js
const fs = require('fs')
const util = require('util');
const child = require('child_process')
const exec = util.promisify(child.exec);
const ffmpegOption = require("./ffmpegOption")

function custom_transfrom(path) {
  return (req, res) => {
    let { filename } = req.body
    const filePath = require('path').join(path, filename)
    try {
      fs.statSync(filePath)
    } catch {
      return res.send({ err: -4, msg: 'File Not Found'})
    }
    req.file = {
      filename,
      path: filePath,
    }
    transform(req, res)
  }
}

async function transform(req, res) {
    // ......
}

module.exports = {
  transform,
  custom_transfrom,
}

该模块里两个方法:

  • transform :转换动图的方法。
  • custom_transform :高阶函数,接受一个路径参数,返回一个自定义文件路径的 hander,把文件信息挂载在 req.file 然后调用 transform ,用于处理服务器本地文件的文件,无需用户上传。

util.promisify 方法把 child_process.exec 方法转换成 promise ,减少回调函数的嵌套。

需要注意的是:"如果调用 exec 方法的 util.promisify() 版本,则返回 Promise(会传入具有 stdoutstderr 属性的 Object)。 返回的 ChildProcess 实例会作为 child 属性附加到 Promise。 如果出现错误(包括导致退出码不为 0 的任何错误),则返回 reject 的 promise,并传入与回调中相同的 error 对象,但是还有两个额外的属性 stdoutstderr。" ( 参考: node 官方文档

优化 - 路由调用

const express = require('express')
const router = express.Router()
const upload = require('../../util/multer')
const ffmpeg = require('./ffmpeg');

// 上传视频 -> 转成GIF
router.post('/gif', upload.single('file'), ffmpeg.transform)
// 本地视频转换成GIF (无需上传)
router.post('/gif-temp', ffmpeg.custom_transfrom('uploads'))
// 本地动图转换成GIF (GIF修改)
router.post('/gif-local', ffmpeg.custom_transfrom('public/picture/gif'))

module.exports = router

upload 是中间件 multer 构造出的 multer 对象。

/gifupload.single() 上传文件,再调用 ffmpeg.transform 生成成 gif

/gif-temp :用户调用 /transform/gif上传视频文件之后,再次视频转动图时无需再次上传,只需调用该接口从上传文件夹找到对应的文件转换就行了。

/gif-local :与上面的类似,只是切换了文件夹路径,在生成的 gif 里找到对应的文件,再根据 body 数据把 gif 转换成对应格式。

优化 - 使用全局调色板提升动图质量

此处参考:

Linux公社 - 使用 FFmpeg 处理高质量 GIF 图片

OSCHINA - 使用 FFmpeg 处理高质量 GIF 图片

// ffmpeg.js transform funciton
// 调色板图片路径
PalettePicPath = `tmp/palette-${filename}.png`;
await exec(`ffmpeg ${Option.get('-ss') + Option.get('-to')} -i ${path} -vf palettegen  -y ${PalettePicPath}`)
    .catch(err => {
    	console.error("全局调色板生成错误:", err);
	})
Option.add('-i', PalettePicPath)
Option.add('-lavfi', Option.getValue('-vf'))
Option.add('-lavfi', `paletteuse`)
Option.set('-vf', '');

-ss-to 指定开始和结束时间,以减少生成的时间,在 -vf 添加 "palettegen" 。这个滤波器对每一帧的所有颜色制作一个直方图,并且基于这些生成一个调色板。

palette

将调色板作为输入源需要指定两个-i (一个源文件、一个调色板文件), -vf 的配置需要换成 -lavfi 以配置全局的滤波 (同-filter_complex),并同样在后边添加 "paletteuse"

因为要指定两个输入源,所以在后面配置 -i 时需要这样:

// -i input.mp4 -i palette.png
Option.set('-i', `${path} ${Option.get('-i')}`);

path 为源文件路径,Option.get('-i') 为上文添加的配置。

这是为什么 ffmpegOption.js 中要添加 set()getValue() 的原因,实现的方法有很多种,这里选择了最省事的方法

Crontab 定时删除文件

上回使用 setTimeout 来执行定时任务,当访问量较大时,由于闭包导致内存泄露,会让服务器性能下降,是一个不靠谱的设计。

使用 crontab,每小时执行一次 查找并删除两小时前的 gif 和 mp4 文件。

crontab -e
0  *  *   *   *     find /root/miniprogram/ -regex ".+\.\(gif\|mp4\)$" -mmin +120  -exec rm {} \;

exec 执行命令

去掉了 child_process.exec 的回调函数,和 setTimeout 函数,整个世界变得很清静。

转换后删除配色板文件,再返回即可。

// ffmpeg.js transform funciton
const ffmpegCommand = Option.toString()

await exec(ffmpegCommand)
  .catch(err => {
    console.error(err)
    res.send({ err: -1, msg: 'exec error' })
  })
PalettePicPath && fs.unlink(PalettePicPath, () => {
  console.log("配色板文件清除")
})

const expired = +new Date() + 3 * 60 * 60 * 1000
const stat = fs.statSync(rfilen)
res.send({
  err: 0,
  msg: `ok`,
  url: `https://${config.host}/${rfilen}`,
  size: stat.size,
  expiredIn: expired,
});

前端体验

小程序码-视频转动图

总结

服务端的优化,总是朝着高内聚低耦合的方向。简而言之,就是抽象 + 模块化。后端是为前端服务的,有时候不仅后端需要优化,前端也需要配合后端做优化。有空写一篇视频转动图小程序端的实现。