nodejs 本地服务器(怎样用Nodejs,Express和FFmpeg.wasm构建一个处理多媒体的API)
介绍
处理媒体文件正成为现代后端服务的常见要求。使用基于云的专用解决方案可能会在您处理大规模或执行昂贵的操作(如视频转编码)时有所帮助。但是,当您所需要的只是从视频中提取缩略图或检查用户生成的内容是否格式正确时,使用云所需的额外的成本和增加的复杂性就显得不是那么必要。特别是在较小的规模下,直接在您的API中提供媒体处理功能是有意义的。
在本文中,您将使用Express和 ffmpeg.wasm构建一个媒体 API。ffmpeg.wasm是现下流行的,可用于网页上媒体处理的工具。 您将构建一个API端点,从视频中提取缩略图作为示例。您可以使用相同的技术将 FFmpeg 支持的其他功能添加到您的 API 中。
完成后,您将很好地掌握在Express中处理二进制数据,并用 ffmpeg.wasm 处理这些数据。同时您还将增强您的 API 对并行发起请求的处理。
前提条件
- Nodejs本地开发环境。参考http://nodejs.cn/learn关于如何设置nodejs本地环境。
- 知道如何用Express框架提供一个API。
- 有使用Html和JavaScript建设网站的经验。
- 一个测试用的视频文件。
文中示例已经在Node v16.11.0, npm v7.15.1,express v4.17.1和ffmpeg.wasm v0.10.1版本上验证通过。
步骤1—-创建一个项目和一个基本express服务
本步骤你将创建一个项目目录,nodejs项目初始化,安装ffmpeg和设置一个基础的Express服务器。
打开命令窗口,创建一个项目目录:
$ mkdir ffmpeg-api
Cd ffmpeg-api
运行npm init来创建一个package.json文件。参数-y表明你想用默认的项目设置。
$ npm init -y
最后,使用 npm install来安装构建 API 所需的包。- -save标志表示您希望将这些包名作为包依赖项保存。
$ npm install –save @ffmpeg/ffmpeg @ffmpeg/core express cors multer p-queue
现在,您已经安装了 ffmpeg,接下来将设置一个 Web 服务器,该服务器使用 Express 响应请求。
打开文本编辑器nano或其它,生成一个文件server.mjs:
$ nano server.mjs
文本中的代码注册了cors中间件,它允许网站跨域请求。在文件顶部,加入如下代码:
Import express from ‘express’;
Import cors from ‘cors’;
接着添加下面代码,创建一个Express实例并且启动,在端口3000侦听请求:
…
Const app = express();
Const port = 3000;
app.use(cors());
app.listen(port, () => {
console.log(`[info] ffmpeg-api listening at http://localhost:${port}`);
});
启动服务器:
$ node server.mjs
输出类似如下:
[info] ffmpeg-api listening at http://localhost:3000
当服务启动后,下一步你需要创建客户端来上传video,向服务器发送请求。
步骤2—-创建客户端并测试服务器
在这部分中,你将创建一个web页面,让你可以选择一个文件上传给后台的API处理。
编辑器创建文件client.xml并添加如下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Create a Thumbnail from a Video</title>
<style>
#thumbnail {
max-width: 100%;
}
</style>
</head>
<body>
<div>
<input id="file-input" type="file" />
<button id="submit">Create Thumbnail</button>
<div id="error"></div>
<img id="thumbnail" />
</div>
<script src="client.js"></script>
</body>
</html>
在这个html文件中,创建文件输入和创建缩略图按钮,添加一个空的<div>显示错误和图像,将显示 API 发回的缩略图。在<body>标记的末尾,加载一个名为client.js的脚本。
注意每个元素有一个唯一的ID,client.js需要这个ID来定位到页面元素。<style>部分的css定义#thumbnail确保图片装载时,填充屏幕的大小。
保存client.html并打开编辑client.js文件,输入下面的内容:
const fileInput = document.querySelector('#file-input');
const submitButton = document.querySelector('#submit');
const thumbnailPreview = document.querySelector('#thumbnail');
const errorDiv = document.querySelector('#error');
接着给submit按钮添加点击事件侦听:
…
submitButton.addEventListener('click', async () => {
const { files } = fileInput;
}
下一步创建一个showError函数,当没有文件选择的时候,显示错误信息:
const fileInput = document.querySelector('#file-input');
const submitButton = document.querySelector('#submit');
const thumbnailPreview = document.querySelector('#thumbnail');
const errorDiv = document.querySelector('#error');
function showError(msg) {
errorDiv.innerText = `ERROR: ${msg}`;
}
submitButton.addEventListener('click', async () => {
…
现在你将创建一个函数叫createThumbnail,它将发送http请求把video数据传到后端API,并接收响应结果。在client.js的顶部,定义一个后端/thumbnail API的URL常量:
const API_ENDPOINT = 'http://localhost:3000/thumbnail';
const fileInput = document.querySelector('#file-input');
const submitButton = document.querySelector('#submit');
const thumbnailPreview = document.querySelector('#thumbnail');
const errorDiv = document.querySelector('#error');
…
后面部分,相应的需要在服务器端你实现API端点/thumbnail。
在client.js中,新添加createThumbnail函数:
…
function showError(msg) {
errorDiv.innerText = `ERROR: ${msg}`;
}
async function createThumbnail(video) {
}
…
对于web应用接口,常常以JSON格式传送和接收客户端的数据。为了在JSON中包括video数据,你需要将video数据用base64编码,但这将增加30%的文件大小。 相反可以使用“multipart request”请求来避免这一点。 Multipart request允许你通过http传输结构数据包括二进制文件,无需不必要的负载。你可以用FormData()函数实现这一点。
在createThumbnail()函数中,创建FormData的一个实例并添加video文件依附在实例上。然后调用Fetch方法向后端服务发送POST请求,请求的内容是FormData的实例作为主体。 解析返回的数据为blob(表示二进制)类型并且把它转成一个数据URL,从而你可以把这个URL作为值赋给页面上的<img>标签。
下面是函数createThumbnail的完整实现:
…
async function createThumbnail(video) {
const payload = new FormData();
payload.append('video', video);
const res = await fetch(API_ENDPOINT, {
method: 'POST',
body: payload
});
if (!res.ok) {
throw new Error('Creating thumbnail failed');
}
const thumbnailBlob = await res.blob();
const thumbnail = await blobToDataURL(thumbnailBlob);
return thumbnail;
}
…
你可能注意到了函数blobToDataURL。它的作用是把blob数据类型转成data url。
创建函数blobToDataURL。注意函数的创建位置需要放在调用它的函数位置之前定义。
…
async function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.onabort = () => reject(new Error("Read aborted"));
reader.readAsDataURL(blob);
});
}
…
blobToDataURL使用FileReader读取二进制内容并且把它格式化成data url。
在createThumbnail和showError定义完成后,你就可以调用他们完成事件侦听:
…
submitButton.addEventListener('click', async () => {
const {files} = fileInput;
if (files.length > 0) {
const file = files [0];
try {
const thumbnail = await createThumbnail(file);
thumbnailPreview.src = thumbnail;
} catch(error) {
showError(error);
}
} else {
showError('Please select a file');
}
});
当用户点击submit按钮时,事件侦听器将把上传的文件传给createThumbnail函数。如果成功,它将把截取的视频片段赋值给前面创建的<img>标签。在用户没有选择文件或者调用失败时,showError函数被调用显示错误信息。
此时,你的client.js文件内容如下:
const API_ENDPOINT = 'http://localhost:3000/thumbnail';
const fileInput = document.querySelector('#file-input');
const submitButton = document.querySelector('#submit');
const thumbnailPreview = document.querySelector('#thumbnail');
const errorDiv = document.querySelector('#error');
function showError(msg) {
errorDiv.innerText = `ERROR: ${msg}`;
}
async function blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.onabort = () => reject(new Error("Read aborted"));
reader.readAsDataURL(blob);
});
}
async function createThumbnail(video) {
const payload = new FormData();
payload.append('video', video);
const res = await fetch(API_ENDPOINT, {
method: 'POST',
body: payload
});
if (!res.ok) {
throw new Error('Creating thumbnail failed');
}
const thumbnailBlob = await res.blob();
const thumbnail = await blobToDataURL(thumbnailBlob);
return thumbnail;
}
submitButton.addEventListener('click', async () => {
const { files } = fileInput;
if (files.length > 0) {
const file = files[0];
try {
const thumbnail = await createThumbnail(file);
thumbnailPreview.src = thumbnail;
} catch(error) {
showError(error);
}
} else {
showError('Please select a file');
}
});
客户端代码设置完成了,在页面上选择需要上传video文件,点击提交,你将收到一条错误的信息。这是因为/thumbnail端点还没有完成。下一步,你需要在Express服务端创建”/thumbnail”端点服务,接收video文件并创建缩略图。
步骤3—-设置接受二进制数据的服务端点
本步骤中,你将建立接受post请求的/thumbnail服务端点并用中间件接受multipart请求。
用编辑器打开server.mjs文件,导入multer中间件:
import express from 'express';
import cors from 'cors';
import multer from 'multer';
…
在请求传递给/thumbnail服务端点之前,数据流将先被Multer中间件截取。Multer中间件用于处理multipart/form-data请求。它从请求主体中提取字段和文件并组成一个数组,传递给Express服务器。你可以配置上传文件的存储路径,设置文件大小限制和格式。
导入Multer后,用下面选项初始化multer:
…
const app = express();
const port = 3000;
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 }
});
app.use(cors());
…
代码中Storage选项表明上传的文件是存储在缓存中,而不是写到磁盘上。Limits选项限制最大可以上传的文件大小。代码中文件最大可以是100MB。你也可以根据你服务器内存容量设置不同文件大小,以避免由于文件太大导致你的API服务器崩溃。
需要注意的是,当前ffmpeg.wasm不能处理文件超过2GB的文件。
下一步,实现POST端点服务/thumbnail:
…
app.use(cors());
app.post('/thumbnail', upload.single('video'), async (req, res) => {
const videoData = req.file.buffer;
res.sendStatus(200);
});
app.listen(port, () => {
console.log(`[info] ffmpeg-api listening at http://localhost:${port}`)
});
Upload.single(‘video’)调用中间件分析包含单个文件的multipart请求主体。第一个参数是字段名,它必须和在client.js中创建FormData时提供的字段名一致。本例中的字段名是’video’。然后multer将解析出的文件传递个req对象的参数。文件内容存在req.file.buffer中。
目前为止,服务端点对接受的数据还没有处理。它只是返回了一个代码200的空的响应来确认请求。在下一步骤,需要更新这段代码,从接收到的视频数据中提取一个缩略图。
步骤四—-用ffmpeg.wasm处理媒体数据
此步骤中,你将使用ffmpeg.wasm从视频中来抓取一个缩略图。
Ffmpeg.wasm时一个纯web技术组装和javascript语言的ffmpeg实现。它的主要目的是允许在浏览器中直接运行ffmpeg。 由于node.js是构建在V8(Chrome的javascript引擎)之上,你也可以在服务器上使用这个库。
在server.mjs顶部添加下面行来导入ffmpeg:
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import { createFFmpeg } from '@ffmpeg/ffmpeg';
…
然后,创建一个ffmpeg.wasm实例并开始装载核心组件:
…
import { createFFmpeg } from '@ffmpeg/ffmpeg';
const ffmpegInstance = createFFmpeg({ log: true });
let ffmpegLoadingPromise = ffmpegInstance.load();
const app = express();
…
核心组件是异步装载到内存并且返回一个promise。这个promise赋值给ffmpegLoadingPromise变量,因此你可以检查核心组件是否装载完成。
下一步,定义下面的辅助函数,它将用fmpegLoadingPromise,在第一个请求到达之前,确保核心组件装载完成。
…
let ffmpegLoadingPromise = ffmpegInstance.load();
async function getFFmpeg() {
if (ffmpegLoadingPromise) {
await ffmpegLoadingPromise;
ffmpegLoadingPromise = undefined;
}
return ffmpegInstance;
}
const app = express();
…
getFFmpeg函数返回ffmepg库的实例ffmpegInstance。在返回之前, 它检查库是否已经完成了装载,如果没有,它将等待直到ffmpegLoadingPromise返回resolve(表明装载完成)。如果第一个请求到达/thumbnail服务端点,实例ffmpegInstance的核心组件装载还没有完成,服务端点将等待核心组件装载完成,然后处理请求,而不是拒绝这个请求。
现在,更改服务端点/thumbnail的实现。 在函数的结尾,用getFFmpeg函数替换掉res.sendStatus(200):
…
app.post('/thumbnail', upload.single('video'), async (req, res) => {
const videoData = req.file.buffer;
const ffmpeg = await getFFmpeg();
});
…
Ffmpeg.wasm使用内存文件系统。你可以用ffmpeg.FS在内存中读和写。当FFmpeg运行时,你将虚拟文件名作为参数传递给ffmpeg.run函数,如同使用CLI工具一样。任何被FFmpeg创建的输出文件将被写到文件系统,以便你以后获取使用。
本例中,输入文件使一个视频。输出文件将会是一个PNG图片。在server.mjs定义如下变量:
…
const ffmpeg = await getFFmpeg();
const inputFileName = `input-video`;
const outputFileName = `output-image.png`;
let outputData = null;
});
…
调用ffmpeg.FS(),将视频数据写到内存文件系统:
…
let outputData = null;
ffmpeg.FS('writeFile', inputFileName, videoData);
});
…
接下来,调用FFmpeg的操作run方法:
…
ffmpeg.FS('writeFile', inputFileName, videoData);
await ffmpeg.run(
'-ss', '00:00:01.000',
'-i', inputFileName,
'-frames:v', '1',
outputFileName
);
});
…
参数-i指定输入视频文件。-ss指定视频已播放时间位置。-frames:v限制了写到输出文件的帧数。outputFileName是FFmpeg输出写入的文件。
FFmpeg执行后,用ffmpeg.FS()从输出的文件中读取数据并删除输入和输出文件释放内存。
…
await ffmpeg.run(
'-ss', '00:00:01.000',
'-i', inputFileName,
'-frames:v', '1',
outputFileName
);
outputData = ffmpeg.FS('readFile', outputFileName);
ffmpeg.FS('unlink', inputFileName);
ffmpeg.FS('unlink', outputFileName);
});
…
最后,将输出数据作为请求响应的主体返回。
…
ffmpeg.FS('unlink', outputFileName);
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Disposition': `attachment;filename=${outputFileName}`,
'Content-Length': outputData.length
});
res.end(Buffer.from(outputData, 'binary'));
});
…
调用res.writeHead()方法写入响应的请求头。Content-Dispositon包含将携带的请求的主体数据。res.end()函数发送二进制数据给客户端。outputData变量是一个ffmpeg.FS()返回的原始的字节数组,它传给Buffer.from用来初始化缓冲区,确保二进制数据被res.end()正确的处理。
到此,POST /thumbnail服务端点的实现整体像如下所示:
…
app.post('/thumbnail', upload.single('video'), async (req, res) => {
const videoData = req.file.buffer;
const ffmpeg = await getFFmpeg();
const inputFileName = `input-video`;
const outputFileName = `output-image.png`;
let outputData = null;
ffmpeg.FS('writeFile', inputFileName, videoData);
await ffmpeg.run(
'-ss', '00:00:01.000',
'-i', inputFileName,
'-frames:v', '1',
outputFileName
);
outputData = ffmpeg.FS('readFile', outputFileName);
ffmpeg.FS('unlink', inputFileName);
ffmpeg.FS('unlink', outputFileName);
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Disposition': `attachment;filename=${outputFileName}`,
'Content-Length': outputData.length
});
res.end(Buffer.from(outputData, 'binary'));
});
…
除了上传100M文件大小的限制外,没有输入验证或错误处理。当ffmpeg.wasm处理文件失败, 读取输出文件也将失败并阻止发送应答给客户端。 出于演示的目的,端点服务的实现将以一个try-catch的块包裹一下来处理失败时的场景。
…
…
app.post('/thumbnail', upload.single('video'), async (req, res) => {
try {
const videoData = req.file.buffer;
const ffmpeg = await getFFmpeg();
const inputFileName = `input-video`;
const outputFileName = `output-image.png`;
let outputData = null;
ffmpeg.FS('writeFile', inputFileName, videoData);
await ffmpeg.run(
'-ss', '00:00:01.000',
'-i', inputFileName,
'-frames:v', '1',
outputFileName
);
outputData = ffmpeg.FS('readFile', outputFileName);
ffmpeg.FS('unlink', inputFileName);
ffmpeg.FS('unlink', outputFileName);
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Disposition': `attachment;filename=${outputFileName}`,
'Content-Length': outputData.length
});
res.end(Buffer.from(outputData, 'binary'));
} catch(error) {
console.error(error);
res.sendStatus(500);
}
…
});
…
其次,ffmpeg.wasm不能处理两个并发的请求。你可以用下列方式启动服务来测试一下:
$ node –experimental-wasm-threads server.mjs
注意ffmpeg.wasm运行需要参数—experimental-wasm-threads。这个库依赖webAssembly threads(https://www.chromestatus.com/feature/5724132452859904)和批量内存操作(Bulk memory operations ,https://www.chromestatus.com/feature/5724132452859904)。 这两个特性从2019年就已经在V8/Chrome中了。但是从Node.js v16.11.0开始,webAssembly threads保留在一个标志的后面,以免在提案最终确定之前可能存在变动。批量内存操作在旧版的Node中也需要一个标志才能使用它。 如果你运行的node.js版本时15或更低,也需要添加参数—experimental-wasm-bulk-memory。
命令的输出类似如下:
[info] use ffmpeg.wasm v0.10.1
[info] load ffmpeg-core
[info] loading ffmpeg-core
[info] fetch ffmpeg.wasm-core script from @ffmpeg/core
[info] ffmpeg-api listening at http://localhost:3000
[info] ffmpeg-core loaded
在浏览器中打开client.html,选择一个视频文件。当点击创建缩略图按钮,你将看到页面上出现一个缩略图。在幕后,应用上传视屏给API,API处理后返回缩略图图片。但是,如果你以快速频率重复点击按钮,API将只处理第一个请求,后续的请求将失败:
Error: ffmpeg.wasm can only run one command at a time
at Object.run (…/ffmpeg-api/node_modules/@ffmpeg/ffmpeg/src/createFFmpeg.js:126:13)
at file://…/ffmpeg-api/server.mjs:54:26
at runMicrotasks (<anonymous>)
at processTicksAndRejections (internal/process/task_queues.js:95:5)
接下来的一部分,你将学习怎样处理并发的请求。
步骤五—-处理并发请求
由于ffmpeg.wasm在一个时间点上仅能处理单个请求,你将需要一种方式序来列化传入的请求并一次处理一个请求。在这个场景中,promise队列是一个很棒的解决方案。相对于立即处理每个请求,它将请求放入队列,按队列顺序处理请求。
用编辑器打开server.mjs:
$ nano server.mjs
在它的顶部导入p-queue模块:
Import express from ‘express’;
Import cors from ‘cors’;
Import {createFFmpeg} from ‘@ffmpeg/ffmpeg’;
Import PQueue from ‘p-queue’;
…
然后在变量ffmpegLoadingPromise下面创建一个新的队列实例:
…
const ffmpegInstance = createFFmpeg({ log: true });
let ffmpegLoadingPromise = ffmpegInstance.load();
const requestQueue = new PQueue({ concurrency: 1 });
…
在POST /thumbnail端点处理方法中,用一个将放入队列的函数封装调用ffmpeg:
…
app.post('/thumbnail', upload.single('video'), async (req, res) => {
try {
const videoData = req.file.buffer;
const ffmpeg = await getFFmpeg();
const inputFileName = `input-video`;
const outputFileName = `thumbnail.png`;
let outputData = null;
await requestQueue.add(async () => {
ffmpeg.FS('writeFile', inputFileName, videoData);
await ffmpeg.run(
'-ss', '00:00:01.000',
'-i', inputFileName,
'-frames:v', '1',
outputFileName
);
outputData = ffmpeg.FS('readFile', outputFileName);
ffmpeg.FS('unlink', inputFileName);
ffmpeg.FS('unlink', outputFileName);
});
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Disposition': `attachment;filename=${outputFileName}`,
'Content-Length': outputData.length
});
res.end(Buffer.from(outputData, 'binary'));
} catch(error) {
console.error(error);
res.sendStatus(500);
}
});
…
每次当新的请求进来时,请求放入队列直到它前面的请求都处理完成后,它才会被处理。 要注意的是,最终响应的发送时异步的。
重新启动服务,测试是否像期望那样工作:
node –experimental-wasm-threads server.mjs
在浏览器中打开client.hmtl文件并上传一个视屏。
随着队列的控制,所有的请求将按到达的顺序处理,API每次都能正确响应。
结论
本文中,你创建了一个Node.js的服务,它用ffmpeg.wasm库从一个视频中截取一段画面。 你学会了如何使用multipart 请求从浏览器上传二进制给后台的Express API,如何在Node.js中使用Ffmpeg处理媒体而无须依赖外部的工具或把数据写到磁盘中。
Ffmpeg是一个有很多功能的工具。你可以使用本向导中学到的知识来使用任意Ffmpeg支持的特性。 例如,在POST /thumbnail的方法中,更改ffmpeg.run的调用来产生一个三秒的GIF图像:
Server.mjs:
…
await ffmpeg.run(
'-y',
'-t', '3',
'-i', inputFileName,
'-filter_complex', 'fps=5,scale=720:-1:flags=lanczos[x];[x]split[x1][x2];[x1]palettegen[p];[x2][p]paletteuse',
'-f', 'gif',
outputFileName
);
…
另外,在使用Ffmpeg库时,注意它目前对处理视频文件的大小限制时2GB。所以当视频文件比较大时,确保你的机器有足够的内存。
发布于:2022-12-19,除非注明,否则均为
原创文章,转载请注明出处。
发表评论