如何使用 Express 和 FFmpeg.wasm 在 Node.js 中构建媒体处理 API

作者选择电子前沿基金会作为为国家写作计划的一部分接受捐赠。

简介

处理媒体资产已成为现代后端服务的普遍要求。在处理大规模事务或执行昂贵的操作(如视频转码)时,使用基于云的专用解决方案可能会有所帮助。但是,当你只需要从视频中提取缩略图或检查用户生成的内容格式是否正确时,额外的成本和增加的复杂性可能就难以得到合理的解释了。特别是在规模较小的情况下,直接在 Node.js API 中添加媒体处理功能是很有意义的。

在本指南中,您将使用 Expressffmpeg.wasm(流行媒体处理工具的 WebAssembly 移植)在 Node.js 中构建媒体 API。作为示例,您将构建一个从视频中提取缩略图的端点。您可以使用相同的技术将 FFmpeg 支持的其他功能添加到您的 API 中。

完成后,您将熟练掌握在 Express 中处理二进制数据以及使用 ffmpeg.wasm处理这些数据的方法。您还将处理向您的 API 提出的无法并行处理的请求。

先决条件

要完成本教程,您需要

本教程使用 Node v16.11.0、npm v7.15.1、express v4.17.1 和 ffmpeg.wasm v0.10.1 进行了验证。

步骤 1 - 设置项目并创建基本的 Express 服务器

在这一步中,您将创建一个项目目录,初始化 Node.js,安装 ffmpeg,并设置一个基本的 Express 服务器。

首先打开终端,为项目创建一个新目录:

1mkdir ffmpeg-api

导航至新目录:

1cd ffmpeg-api

使用 npm init 创建一个新的 package.json 文件。y "参数表示你对项目的默认设置感到满意。

1npm init -y

最后,使用 npm install 安装构建 API 所需的软件包。保存 "标志表示您希望将这些软件包作为依赖项保存在 "package.json "文件中。

1npm install --save @ffmpeg/ffmpeg @ffmpeg/core express cors multer p-queue

现在您已经安装了 ffmpeg,您将设置一个使用 Express 响应请求的网络服务器。

首先,用 nano 或你选择的编辑器打开一个名为 server.mjs 的新文件:

1nano server.mjs

该文件中的代码将注册 cors中间件,该中间件将允许来自不同来源 网站的请求。在文件顶部,导入 expresscors 依赖项:

1[label server.mjs]
2import express from 'express';
3import cors from 'cors';

然后,创建一个 Express 应用程序,并通过在 "导入 "语句下面添加以下代码,在端口":3000 "上启动服务器:

 1[label server.mjs]
 2...
 3const app = express();
 4const port = 3000;
 5
 6app.use(cors());
 7
 8app.listen(port, () => {
 9    console.log(`[info] ffmpeg-api listening at http://localhost:${port}`)
10});

运行以下命令即可启动服务器:

1node server.mjs

您将看到以下输出:

1[secondary_label Output]
2[info] ffmpeg-api listening at http://localhost:3000

当你尝试在浏览器中加载 http://localhost:3000 时,你会看到 Cannot GET /。这是 Express 在告诉你它正在监听请求。

现在您的 Express 服务器已经安装完毕,您将创建一个客户端来上传视频并向 Express 服务器发出请求。

第 2 步 - 创建客户端并测试服务器

在本节中,您将创建一个网页,让您选择文件并将其上传到 API 进行处理。

首先打开一个名为 client.html 的新文件:

1nano client.html

client.html文件中,创建一个文件输入和一个创建缩略图 按钮。在下面添加一个空的 <div> 元素,用于显示错误,并添加一张图片,用于显示 API 发送回来的缩略图。在 <body> 标签的末尾,加载名为 client.js 的脚本。最终的 HTML 模板应如下所示:

 1[label client.html]
 2<!DOCTYPE html>
 3<html lang="en">
 4<head>
 5    <meta charset="UTF-8">
 6    <title>Create a Thumbnail from a Video</title>
 7    <style>
 8        #thumbnail {
 9            max-width: 100%;
10        }
11    </style>
12</head>
13<body>
14    <div>
15        <input id="file-input" type="file" />
16        <button id="submit">Create Thumbnail</button>
17        <div id="error"></div>
18        <img id="thumbnail" />
19    </div>
20    <script src="client.js"></script>
21</body>
22</html>

请注意,每个元素都有一个唯一的 id。在引用 client.js 脚本中的元素时需要用到它们。在 # thumbnail 元素上设置样式是为了确保图片在加载时适合显示在屏幕上。

保存 client.html 文件并打开 client.js

1nano client.js

在您的 client.js 文件中,首先定义变量,用于存储对您创建的 HTML 元素的引用:

1[label client.js]
2const fileInput = document.querySelector('#file-input');
3const submitButton = document.querySelector('#submit');
4const thumbnailPreview = document.querySelector('#thumbnail');
5const errorDiv = document.querySelector('#error');

然后,在submitButton变量上附加一个点击事件监听器,以检查是否选择了文件:

1[label client.js]
2...
3submitButton.addEventListener('click', async () => {
4    const { files } = fileInput;
5}

接下来,创建一个函数 showError(),当文件未被选中时,它会输出一条错误信息。将 showError() 函数添加到事件监听器的上方:

 1[label client.js]
 2const fileInput = document.querySelector('#file-input');
 3const submitButton = document.querySelector('#submit');
 4const thumbnailPreview = document.querySelector('#thumbnail');
 5const errorDiv = document.querySelector('#error');
 6
 7function showError(msg) {
 8    errorDiv.innerText = `ERROR: ${msg}`;
 9}
10
11submitButton.addEventListener('click', async () => {
12...

现在,您将创建一个函数 createThumbnail(),该函数将向 API 发出请求、发送视频并接收响应的缩略图。在您的 client.js 文件顶部,定义一个新常量,其中包含指向 /thumbnail 端点的 URL:

1const API_ENDPOINT = 'http://localhost:3000/thumbnail';
2
3const fileInput = document.querySelector('#file-input');
4const submitButton = document.querySelector('#submit');
5const thumbnailPreview = document.querySelector('#thumbnail');
6const errorDiv = document.querySelector('#error');
7...

您将在 Express 服务器中定义并使用 /thumbnail 端点。

接下来,在showError()函数下面添加createThumbnail()函数:

 1[label client.js]
 2...
 3function showError(msg) {
 4    errorDiv.innerText = `ERROR: ${msg}`;
 5}
 6
 7async function createThumbnail(video) {
 8
 9}
10...

网络应用程序接口经常使用 JSON 从客户端向客户端传输结构化数据。要在 JSON 中包含视频,就必须对其进行 base64 编码,这将使其大小增加约 30%。使用 multipart requests可以避免这种情况。多部分请求允许你通过 http 传输包括二进制文件在内的结构化数据,而不会产生不必要的开销。您可以使用 FormData() 构造函数来实现这一点。

createThumbnail()函数中,创建一个 FormData 的实例,并将视频文件添加到该对象中。然后使用 Fetch API 向 API 端点发出一个以 FormData() 实例为主体的 POST 请求。将响应解释为二进制文件(或 blob),并将其转换为数据 URL,这样您就可以将其分配给之前创建的 <img> 标记。

下面是 createThumbnail() 的完整实现:

 1[label client.js]
 2...
 3async function createThumbnail(video) {
 4    const payload = new FormData();
 5    payload.append('video', video);
 6
 7    const res = await fetch(API_ENDPOINT, {
 8        method: 'POST',
 9        body: payload
10    });
11
12    if (!res.ok) {
13        throw new Error('Creating thumbnail failed');
14    }
15
16    const thumbnailBlob = await res.blob();
17    const thumbnail = await blobToDataURL(thumbnailBlob);
18
19    return thumbnail;
20}
21...

你会注意到 createThumbnail() 的正文中有函数 blobToDataURL()。这是一个辅助函数,用于将 blob 转换为 data URL

createThumbnail()函数上方,创建返回 promise 的函数blobDataToURL()

 1[label client.js]
 2...
 3async function blobToDataURL(blob) {
 4    return new Promise((resolve, reject) => {
 5        const reader = new FileReader();
 6        reader.onload = () => resolve(reader.result);
 7        reader.onerror = () => reject(reader.error);
 8        reader.onabort = () => reject(new Error("Read aborted"));
 9        reader.readAsDataURL(blob);
10    });
11}
12...

blobToDataURL() 使用 [FileReader`](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) 读取二进制文件的内容,并将其格式化为数据 URL。

现在已定义了 createThumbnail()showError() 函数,您可以使用它们来完成事件监听器的实现:

 1[label client.js]
 2...
 3submitButton.addEventListener('click', async () => {
 4    const { files } = fileInput;
 5
 6    if (files.length > 0) {
 7        const file = files[0];
 8        try {
 9            const thumbnail = await createThumbnail(file);
10            thumbnailPreview.src = thumbnail;
11        } catch(error) {
12            showError(error);
13        }
14    } else {
15        showError('Please select a file');
16    }
17});

当用户点击按钮时,事件监听器将把文件传给 createThumbnail()函数。如果成功,它将把缩略图分配给先前创建的 <img> 元素。如果用户没有选择文件或请求失败,它将调用 showError() 函数显示错误。

此时,您的 client.js 文件将如下所示:

 1[label client.js]
 2const API_ENDPOINT = 'http://localhost:3000/thumbnail';
 3
 4const fileInput = document.querySelector('#file-input');
 5const submitButton = document.querySelector('#submit');
 6const thumbnailPreview = document.querySelector('#thumbnail');
 7const errorDiv = document.querySelector('#error');
 8
 9function showError(msg) {
10    errorDiv.innerText = `ERROR: ${msg}`;
11}
12
13async function blobToDataURL(blob) {
14    return new Promise((resolve, reject) => {
15        const reader = new FileReader();
16        reader.onload = () => resolve(reader.result);
17        reader.onerror = () => reject(reader.error);
18        reader.onabort = () => reject(new Error("Read aborted"));
19        reader.readAsDataURL(blob);
20    });
21}
22
23async function createThumbnail(video) {
24    const payload = new FormData();
25    payload.append('video', video);
26
27    const res = await fetch(API_ENDPOINT, {
28        method: 'POST',
29        body: payload
30    });
31
32    if (!res.ok) {
33        throw new Error('Creating thumbnail failed');
34    }
35
36    const thumbnailBlob = await res.blob();
37    const thumbnail = await blobToDataURL(thumbnailBlob);
38
39    return thumbnail;
40}
41
42submitButton.addEventListener('click', async () => {
43    const { files } = fileInput;
44
45    if (files.length > 0) {
46        const file = files[0];
47
48        try {
49            const thumbnail = await createThumbnail(file);
50            thumbnailPreview.src = thumbnail;
51        } catch(error) {
52            showError(error);
53        }
54    } else {
55        showError('Please select a file');
56    }
57});

运行以下命令再次启动服务器

1node server.mjs

现在您的客户端已设置完毕,在此上传视频文件将收到一条错误信息。这是因为 /thumbnail 端点尚未创建。下一步,您将在 Express 中创建 /thumbnail 端点,以接受视频文件并创建缩略图。

第 3 步 - 设置接受二进制数据的端点

在这一步中,你将为 /thumbnail 端点设置一个 POST 请求,并使用中间件接受多部分请求。

在编辑器中打开 server.mjs

1nano server.mjs

然后,在文件顶部导入 multer

1[label server.mjs]
2import express from 'express';
3import cors from 'cors';
4import multer from 'multer';
5...

Multer 是一个中间件,用于处理传入的 "multipart/form-data "请求,然后将其传递给端点处理程序。它从正文中提取字段和文件,并将其作为数组提供给 Express 中的请求对象。您可以配置上传文件的存储位置,并设置文件大小和格式限制。

导入后,使用以下选项初始化 multer 中间件:

 1[label server.mjs]
 2...
 3const app = express();
 4const port = 3000;
 5
 6const upload = multer({
 7    storage: multer.memoryStorage(),
 8    limits: { fileSize: 100 * 1024 * 1024 }
 9});
10
11app.use(cors());
12...

storage "选项允许你选择传入文件的存储位置。调用 multer.memoryStorage() 会初始化一个存储引擎,将文件保存在内存中的 Buffer 对象中,而不是写入磁盘。通过limits选项,你可以定义接受文件的各种限制。将 "fileSize "限制设为 100MB,或根据需要和服务器可用内存量设定一个不同的数字。这将防止输入文件过大时 API 崩溃。

<$>[注] 注: 由于 WebAssembly 的限制,ffmpeg.wasm 无法处理超过 2GB 大小的输入文件。 <$>

接下来,设置 POST /thumbnail 端点本身:

 1[label server.mjs]
 2...
 3app.use(cors());
 4
 5app.post('/thumbnail', upload.single('video'), async (req, res) => {
 6    const videoData = req.file.buffer;
 7
 8    res.sendStatus(200);
 9});
10
11app.listen(port, () => {
12    console.log(`[info] ffmpeg-api listening at http://localhost:${port}`)
13});

upload.single('video')调用将只为该端点设置一个中间件,用于解析包含单个文件的多部分请求的主体。第一个参数是字段名。它必须与在 client.js中创建请求时给FormData的名称一致。在本例中,它是video。然后,multer 会将解析后的文件附加到req参数。文件内容将位于 req.file.buffer` 下。

此时,端点不会对收到的数据做任何处理。它会通过发送空的 200 响应来确认请求。在下一步中,你将用从接收到的视频数据中提取缩略图的代码来代替它。

第 4 步 - 使用 ffmpeg.wasm 处理媒体

在此步骤中,您将使用 ffmpeg.wasmPOST /thumbnail 端点接收的视频文件中提取缩略图。

ffmpeg.wasm是 FFmpeg 的纯 WebAssembly 和 JavaScript 移植。其主要目标是允许在浏览器中直接运行 FFmpeg。不过,由于 Node.js 是构建在 V8(Chrome 的 JavaScript 引擎 )之上的,因此您也可以在服务器上使用该库。

使用 FFmpeg 的原生端口而不是在 ffmpeg 命令之上构建封装器的好处是,如果您计划使用 Docker 部署应用程序,您就不必构建同时包含 FFmpeg 和 Node.js 的自定义映像。这将节省您的时间并减轻服务的维护负担。

server.mjs 顶部添加以下导入:

1[label server.mjs]
2import express from 'express';
3import cors from 'cors';
4import multer from 'multer';
5import { createFFmpeg } from '@ffmpeg/ffmpeg';
6...

然后,创建 ffmpeg.wasm 实例并开始加载核心:

1[label server.mjs]
2...
3import { createFFmpeg } from '@ffmpeg/ffmpeg';
4
5const ffmpegInstance = createFFmpeg({ log: true });
6let ffmpegLoadingPromise = ffmpegInstance.load();
7
8const app = express();
9...

ffmpegInstance 变量包含对库的引用。调用 ffmpegInstance.load()会开始异步将核心加载到内存中,并返回一个承诺。将承诺存储在 ffmpegLoadingPromise 变量中,以便检查核心是否已加载。

接下来,定义以下辅助函数,该函数将使用 fmpegLoadingPromise 来等待核心加载,以防第一个请求在核心准备就绪之前到达:

 1[label server.mjs]
 2...
 3let ffmpegLoadingPromise = ffmpegInstance.load();
 4
 5async function getFFmpeg() {
 6    if (ffmpegLoadingPromise) {
 7        await ffmpegLoadingPromise;
 8        ffmpegLoadingPromise = undefined;
 9    }
10
11    return ffmpegInstance;
12}
13
14const app = express();
15...

函数 getFFmpeg() 返回存储在变量 ffmpegInstance 中的库的引用。在返回之前,它会检查库是否已完成加载。如果没有,它会等待直到 ffmpegLoadingPromise 解析。如果向你的 POST /thumbnail 端点发出的第一个请求在 ffmpegInstance 准备就绪之前到达,你的应用程序接口会等待并在能够解决时解决它,而不是拒绝它。

现在,执行 POST /thumbnail 端点处理程序。将函数结尾处的res.sendStatus(200);替换为调用getFFmpeg,以便在准备就绪时获取对ffmpeg.wasm的引用:

1[label server.mjs]
2...
3app.post('/thumbnail', upload.single('video'), async (req, res) => {
4    const videoData = req.file.buffer;
5
6    const ffmpeg = await getFFmpeg();
7});
8...

ffmpeg.wasm 工作在内存文件系统之上。您可以使用 [ffmpeg.FS``](https://github.com/ffmpegwasm/ffmpeg.wasm/blob/master/docs/api.md#ffmpeg-fs)对其进行读写。运行 FFmpeg 操作时,您将把虚拟文件名作为参数传递给 [ffmpeg.run``](https://github.com/ffmpegwasm/ffmpeg.wasm/blob/master/docs/api.md ffmpegrunargs-promise)函数,方法与使用 CLI 工具时相同。FFmpeg 创建的任何输出文件都将写入文件系统,供您检索。

在本例中,输入文件是一个视频。输出文件将是一张 PNG 图像。定义以下变量

1[label server.mjs]
2...
3    const ffmpeg = await getFFmpeg();
4
5    const inputFileName = `input-video`;
6    const outputFileName = `output-image.png`;
7    let outputData = null;
8});
9...

文件名将用于虚拟文件系统。输出数据 "是缩略图准备就绪后的存储位置。

调用 ffmpeg.FS() 将视频数据写入内存文件系统:

1[label server.mjs]
2...
3    let outputData = null;
4
5    ffmpeg.FS('writeFile', inputFileName, videoData);
6});
7...

然后,运行 FFmpeg 操作:

 1[label server.mjs]
 2...
 3    ffmpeg.FS('writeFile', inputFileName, videoData);
 4
 5    await ffmpeg.run(
 6        '-ss', '00:00:01.000',
 7        '-i', inputFileName,
 8        '-frames:v', '1',
 9        outputFileName
10    );
11});
12...

参数 -i 指定输入文件。-ss 寻找指定的时间(本例中为视频开始后的 1 秒)。frames:v "限制写入输出的帧数(本例中为单帧)。末尾的 outputFileName 表示 FFmpeg 将把输出写入何处。

FFmpeg 退出后,使用 ffmpeg.FS() 从文件系统读取数据,并删除输入和输出文件以释放内存:

 1[label server.mjs]
 2...
 3    await ffmpeg.run(
 4        '-ss', '00:00:01.000',
 5        '-i', inputFileName,
 6        '-frames:v', '1',
 7        outputFileName
 8    );
 9
10    outputData = ffmpeg.FS('readFile', outputFileName);
11    ffmpeg.FS('unlink', inputFileName);
12    ffmpeg.FS('unlink', outputFileName);
13});
14...

最后,在响应正文中分派输出数据:

 1[label server.mjs]
 2...
 3    ffmpeg.FS('unlink', outputFileName);
 4
 5    res.writeHead(200, {
 6        'Content-Type': 'image/png',
 7        'Content-Disposition': `attachment;filename=${outputFileName}`,
 8        'Content-Length': outputData.length
 9    });
10    res.end(Buffer.from(outputData, 'binary'));
11});
12...

调用 res.writeHead() 会发送响应头部。第二个参数包括自定义的http headers),其中包含有关后续请求正文中数据的信息。res.end() 函数将其第一个参数中的数据作为请求正文发送,并最终完成请求。outputData 变量是由 ffmpeg.FS() 返回的字节原始数组。将其传递给 Buffer.from() 会初始化一个 Buffer,以确保 res.end() 能正确处理二进制数据。

此时,你的 POST /thumbnail 端点实现应该是这样的:

 1[label server.mjs]
 2...
 3app.post('/thumbnail', upload.single('video'), async (req, res) => {
 4    const videoData = req.file.buffer;
 5
 6    const ffmpeg = await getFFmpeg();
 7
 8    const inputFileName = `input-video`;
 9    const outputFileName = `output-image.png`;
10    let outputData = null;
11
12    ffmpeg.FS('writeFile', inputFileName, videoData);
13
14    await ffmpeg.run(
15        '-ss', '00:00:01.000',
16        '-i', inputFileName,
17        '-frames:v', '1',
18        outputFileName
19    );
20
21    outputData = ffmpeg.FS('readFile', outputFileName);
22    ffmpeg.FS('unlink', inputFileName);
23    ffmpeg.FS('unlink', outputFileName);
24
25    res.writeHead(200, {
26        'Content-Type': 'image/png',
27        'Content-Disposition': `attachment;filename=${outputFileName}`,
28        'Content-Length': outputData.length
29    });
30    res.end(Buffer.from(outputData, 'binary'));
31});
32...

除了上传文件的 100MB 限制外,没有输入验证或错误处理。当 ffmpeg.wasm 处理文件失败时,从虚拟文件系统读取输出将失败并阻止发送响应。为了本教程的目的,请将端点的实现封装在一个 try-catch 块中,以处理这种情况:

 1[label server.mjs]
 2...
 3app.post('/thumbnail', upload.single('video'), async (req, res) => {
 4    try {
 5        const videoData = req.file.buffer;
 6
 7        const ffmpeg = await getFFmpeg();
 8
 9        const inputFileName = `input-video`;
10        const outputFileName = `output-image.png`;
11        let outputData = null;
12
13        ffmpeg.FS('writeFile', inputFileName, videoData);
14
15        await ffmpeg.run(
16            '-ss', '00:00:01.000',
17            '-i', inputFileName,
18            '-frames:v', '1',
19            outputFileName
20        );
21
22        outputData = ffmpeg.FS('readFile', outputFileName);
23        ffmpeg.FS('unlink', inputFileName);
24        ffmpeg.FS('unlink', outputFileName);
25
26        res.writeHead(200, {
27            'Content-Type': 'image/png',
28            'Content-Disposition': `attachment;filename=${outputFileName}`,
29            'Content-Length': outputData.length
30        });
31        res.end(Buffer.from(outputData, 'binary'));
32    } catch(error) {
33        console.error(error);
34        res.sendStatus(500);
35    }
36...
37});

其次,ffmpeg.wasm 无法同时处理两个请求。您可以自己启动服务器试试:

1node --experimental-wasm-threads server.mjs

请注意 "ffmpeg.wasm "运行所需的标志。该库依赖于 WebAssembly threadsbulk memory operations。自 2019 年以来,V8/Chrome 中一直包含这些内容。不过,从 Node.js v16.11.0 开始,WebAssembly 线程仍被置于一个标记之后,以防在提案最终确定之前出现变更。在旧版本的 Node 中,批量内存操作也需要标记。如果你运行的是 Node.js 15 或更低版本,也请添加 --experimental-wasm-bulk-memory

命令的输出结果如下:

1[secondary_label Output]
2[info] use ffmpeg.wasm v0.10.1
3[info] load ffmpeg-core
4[info] loading ffmpeg-core
5[info] fetch ffmpeg.wasm-core script from @ffmpeg/core
6[info] ffmpeg-api listening at http://localhost:3000
7[info] ffmpeg-core loaded

在网络浏览器中打开 client.html 并选择一个视频文件。点击 "创建缩略图 "按钮后,页面上就会出现缩略图。在幕后,网站会将视频上传到 API,API 会对视频进行处理并生成图像。但是,当您连续多次点击该按钮时,API 会处理第一个请求。随后的请求将失败:

1[secondary_label Output]
2Error: ffmpeg.wasm can only run one command at a time
3    at Object.run (.../ffmpeg-api/node_modules/@ffmpeg/ffmpeg/src/createFFmpeg.js:126:13)
4    at file://.../ffmpeg-api/server.mjs:54:26
5    at runMicrotasks (<anonymous>)
6    at processTicksAndRejections (internal/process/task_queues.js:95:5)

在下一节中,您将学习如何处理并发请求。

步骤 5 - 处理并发请求

由于 ffmpeg.wasm 一次只能执行一个操作,因此您需要一种方法来序列化收到的请求,并一次处理一个。在这种情况下,允诺队列 是一个完美的解决方案。它不会立即开始处理每个请求,而是将其排成队列,在处理完之前到达的所有请求后再进行处理。

用你喜欢的编辑器打开 server.mjs

1nano server.mjs

server.mjs 顶部导入 p-queue

1[label server.mjs]
2import express from 'express';
3import cors from 'cors';
4import { createFFmpeg } from '@ffmpeg/ffmpeg';
5import PQueue from 'p-queue';
6...

然后,在 server.mjs 文件顶部的变量 ffmpegLoadingPromise 下创建一个新队列:

1[label server.mjs]
2...
3const ffmpegInstance = createFFmpeg({ log: true });
4let ffmpegLoadingPromise = ffmpegInstance.load();
5
6const requestQueue = new PQueue({ concurrency: 1 });
7...

POST /thumbnail 端点处理程序中,将对 ffmpeg 的调用封装在一个将被排队的函数中:

 1[label server.mjs]
 2...
 3app.post('/thumbnail', upload.single('video'), async (req, res) => {
 4    try {
 5        const videoData = req.file.buffer;
 6
 7        const ffmpeg = await getFFmpeg();
 8
 9        const inputFileName = `input-video`;
10        const outputFileName = `thumbnail.png`;
11        let outputData = null;
12
13        await requestQueue.add(async () => {
14            ffmpeg.FS('writeFile', inputFileName, videoData);
15
16            await ffmpeg.run(
17                '-ss', '00:00:01.000',
18                '-i', inputFileName,
19                '-frames:v', '1',
20                outputFileName
21            );
22
23            outputData = ffmpeg.FS('readFile', outputFileName);
24            ffmpeg.FS('unlink', inputFileName);
25            ffmpeg.FS('unlink', outputFileName);
26        });
27
28        res.writeHead(200, {
29            'Content-Type': 'image/png',
30            'Content-Disposition': `attachment;filename=${outputFileName}`,
31            'Content-Length': outputData.length
32        });
33        res.end(Buffer.from(outputData, 'binary'));
34    } catch(error) {
35        console.error(error);
36        res.sendStatus(500);
37    }
38});
39...

每次有新请求进来时,只有当前面没有其他队列时,才会开始处理。请注意,响应的最终发送可以异步进行。一旦 ffmpeg.wasm操作运行完毕,另一个请求就可以在响应发出的同时开始处理。

要测试一切是否按预期运行,请再次启动服务器:

1node --experimental-wasm-threads server.mjs

在浏览器中打开 client.html 文件并尝试上传文件。

已加载缩略图的 client.html 截图](how-to-build-a-media-processing-api-in-node-js-with-express-and-ffmpeg-wasm/image.png)

有了队列,API 现在就可以每次都做出响应。请求将按照到达的顺序依次处理。

结论

在本文中,您将构建一个 Node.js 服务,使用 ffmpeg.wasm 从视频中提取缩略图。您将学习如何使用多部分请求将二进制数据从浏览器上传到 Express API,以及如何在 Node.js 中使用 FFmpeg 处理媒体,而无需依赖外部工具或将数据写入磁盘。

FFmpeg 是一种用途极为广泛的工具。你可以利用本教程的知识,在你的项目中利用 FFmpeg 支持的任何功能。例如,要生成三秒的 GIF,可在 POST /thumbnail 端点上将 ffmpeg.run 调用改为这样:

 1[label server.mjs]
 2...
 3await ffmpeg.run(
 4    '-y',
 5    '-t', '3',
 6    '-i', inputFileName,
 7    '-filter_complex', 'fps=5,scale=720:-1:flags=lanczos[x];[x]split[x1][x2];[x1]palettegen[p];[x2][p]paletteuse',
 8    '-f', 'gif',
 9    outputFileName
10);
11...

该库接受的参数与原始的 ffmpeg CLI 工具相同。您可以使用 官方文档 找到适合您的用例的解决方案,并在终端快速进行测试。

由于 "ffmpeg.wasm "是自包含的,因此您可以使用现有的 Node.js 基本镜像对该服务进行 docker 化,并通过在负载平衡器后面保留多个节点来扩展您的服务。请参阅教程 如何使用 Docker 构建 Node.js 应用程序 了解更多信息。

如果您的用例需要执行更昂贵的操作(如转码大型视频),请确保您的服务在有足够内存的机器上运行。由于 WebAssembly 目前的限制,最大输入文件大小不能超过 2GB,不过将来 这一点可能会改变。

此外,ffmpeg.wasm 无法利用原始 FFmpeg 代码库中的一些 x86 汇编优化。这意味着某些操作可能需要很长时间才能完成。如果是这种情况,请考虑这是否适合您的使用情况。或者,让对 API 的请求异步化。无需等待操作完成,而是将其排成队列,并使用唯一的 ID 进行响应。创建另一个端点,供客户端查询处理是否结束以及输出文件是否准备就绪。进一步了解 REST API 的异步请求-响应模式 以及如何实现该模式。

Published At
Categories with 技术
comments powered by Disqus