著者は、 Electronic Frontier Foundation を選択して、 Write forDOnationsプログラムの一環として寄付を受け取りました。

序章

メディア資産の処理は、最新のバックエンドサービスの一般的な要件になりつつあります。 専用のクラウドベースのソリューションを使用すると、大規模な処理を行っている場合や、ビデオトランスコーディングなどの高価な操作を実行している場合に役立つことがあります。 ただし、ビデオからサムネイルを抽出するか、ユーザー生成コンテンツが正しい形式であることを確認するだけでよい場合、追加のコストと複雑さの追加を正当化するのは難しい場合があります。 特に小規模では、メディア処理機能をNode.jsAPIに直接追加することは理にかなっています。

このガイドでは、人気のあるメディア処理ツールのWebAssemblyポートであるExpressおよびffmpeg.wasmを使用してNode.jsでメディアAPIを構築します。 例として、ビデオからサムネイルを抽出するエンドポイントを作成します。 同じ手法を使用して、FFmpegでサポートされている他の機能をAPIに追加できます。

終了すると、Expressでのバイナリデータの処理とffmpeg.wasmでの処理について十分に理解できるようになります。 また、並行して処理できないAPIに対して行われたリクエストも処理します。

前提条件

このチュートリアルを完了するには、次のものが必要です。

このチュートリアルは、Node v16.11.0、npm v7.15.1、express v4.17.1、およびffmpeg.wasmv0.10.1で検証されました。

ステップ1—プロジェクトのセットアップと基本的なExpressサーバーの作成

このステップでは、プロジェクトディレクトリを作成し、Node.jsを初期化してffmpegをインストールし、基本的なExpressサーバーをセットアップします。

ターミナルを開き、プロジェクトの新しいディレクトリを作成することから始めます。

  1. mkdir ffmpeg-api

新しいディレクトリに移動します。

  1. cd ffmpeg-api

npm initを使用して、新しいpackage.jsonファイルを作成します。 -yパラメーターは、プロジェクトのデフォルト設定に満足していることを示します。

  1. npm init -y

最後に、npm installを使用して、APIのビルドに必要なパッケージをインストールします。 --saveフラグは、それらを依存関係としてpackage.jsonファイルに保存することを示します。

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

ffmpegをインストールしたので、Expressを使用してリクエストに応答するWebサーバーをセットアップします。

まず、server.mjsという新しいファイルをnanoまたは選択したエディターで開きます。

  1. nano server.mjs

このファイルのコードは、 cors ミドルウェアを登録し、異なるオリジンのWebサイトからのリクエストを許可します。 ファイルの先頭で、expressおよびcorsの依存関係をインポートします。

server.mjs
import express from 'express';
import cors from 'cors';

次に、Expressアプリを作成し、importステートメントの下に次のコードを追加して、ポート:3000でサーバーを起動します。

server.mjs
...
const app = express();
const port = 3000;

app.use(cors());

app.listen(port, () => {
    console.log(`[info] ffmpeg-api listening at http://localhost:${port}`)
});

次のコマンドを実行して、サーバーを起動できます。

  1. node server.mjs

次の出力が表示されます。

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

ブラウザにhttp://localhost:3000をロードしようとすると、Cannot GET /が表示されます。 これは、リクエストをリッスンしていることを示すExpressです。

Expressサーバーがセットアップされたら、ビデオをアップロードしてExpressサーバーにリクエストを送信するクライアントを作成します。

##ステップ2—クライアントの作成とサーバーのテスト

このセクションでは、ファイルを選択してAPIにアップロードして処理できるWebページを作成します。

client.htmlという名前の新しいファイルを開くことから始めます。

  1. nano client.html

client.htmlファイルで、ファイル入力とサムネイルの作成ボタンを作成します。 以下に、空の<div>要素を追加して、エラーと、APIが送り返すサムネイルを表示する画像を表示します。 <body>タグの最後に、client.jsというスクリプトをロードします。 最終的なHTMLテンプレートは次のようになります。

client.html
<!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>

各要素には一意のIDがあることに注意してください。 client.jsスクリプトの要素を参照するときに必要になります。 #thumbnail要素のスタイルは、画像が読み込まれたときに画像が画面に収まるようにするためのものです。

client.htmlファイルを保存し、client.jsを開きます。

  1. nano client.js

client.jsファイルで、作成したHTML要素への参照を格納する変数を定義することから始めます。

client.js
const fileInput = document.querySelector('#file-input');
const submitButton = document.querySelector('#submit');
const thumbnailPreview = document.querySelector('#thumbnail');
const errorDiv = document.querySelector('#error');

次に、クリックイベントリスナーをsubmitButton変数にアタッチして、ファイルを選択したかどうかを確認します。

client.js
...
submitButton.addEventListener('click', async () => {
    const { files } = fileInput;
}

次に、ファイルが選択されていないときにエラーメッセージを出力する関数showError()を作成します。 イベントリスナーの上にshowError()関数を追加します。

client.js
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 () => {
...

次に、APIにリクエストを送信し、動画を送信し、それに応じてサムネイルを受信する関数createThumbnail()を作成します。 client.jsファイルの先頭で、/thumbnailエンドポイントへの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');
...

Expressサーバーで/thumbnailエンドポイントを定義して使用します。

次に、showError()関数の下にcreateThumbnail()関数を追加します。

client.js
...
function showError(msg) {
    errorDiv.innerText = `ERROR: ${msg}`;
}

async function createThumbnail(video) {

}
...

Web APIは、JSONを頻繁に使用して、クライアントとの間で構造化データを転送します。 ビデオをJSONに含めるには、そのビデオをbase64でエンコードする必要があります。これにより、サイズが約30%増加します。 代わりにマルチパートリクエストを使用すると、これを回避できます。 マルチパートリクエストを使用すると、不要なオーバーヘッドなしで、バイナリファイルを含む構造化データをhttp経由で転送できます。 これは、 FormData()コンストラクター関数を使用して実行できます。

createThumbnail()関数内で、 FormData のインスタンスを作成し、ビデオファイルをオブジェクトに追加します。 次に、FormData()インスタンスを本体としてFetchAPIを使用して、APIエンドポイントに対してPOSTリクエストを作成します。 応答をバイナリファイル(またはblob)として解釈し、データURLに変換して、前に作成した<img>タグに割り当てることができるようにします。

createThumbnail()の完全な実装は次のとおりです。

client.js
...
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;
}
...

createThumbnail()の本体にはblobToDataURL()という機能があります。 これは、blobをデータURLに変換するヘルパー関数です。

createThumbnail()関数の上に、promiseを返す関数blobDataToURL()を作成します。

client.js
...
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 を使用して、バイナリファイルの内容を読み取り、データURLとしてフォーマットします。

createThumbnail()およびshowError()関数が定義されたら、これらを使用してイベントリスナーの実装を完了することができます。

client.js
...
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');
    }
});

ユーザーがボタンをクリックすると、イベントリスナーはファイルをcreateThumbnail()関数に渡します。 成功すると、前に作成した<img>要素にサムネイルが割り当てられます。 ユーザーがファイルを選択しない場合やリクエストが失敗した場合は、showError()関数を呼び出してエラーを表示します。

この時点で、client.jsファイルは次のようになります。

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

次のコマンドを実行して、サーバーを再起動します。

  1. node server.mjs

クライアントがセットアップされた状態で、ここにビデオファイルをアップロードすると、エラーメッセージが表示されます。 これは、/thumbnailエンドポイントがまだ構築されていないためです。 次のステップでは、Expressで/thumbnailエンドポイントを作成して、ビデオファイルを受け入れ、サムネイルを作成します。

##ステップ3—バイナリデータを受け入れるようにエンドポイントを設定する

このステップでは、/thumbnailエンドポイントに対してPOST要求を設定し、ミドルウェアを使用してマルチパート要求を受け入れます。

エディターでserver.mjsを開きます。

  1. nano server.mjs

次に、ファイルの先頭にあるmulterをインポートします。

server.mjs
import express from 'express';
import cors from 'cors';
import multer from 'multer';
...

Multerは、着信multipart/form-data要求を処理してから、エンドポイントハンドラーに渡すミドルウェアです。 本体からフィールドとファイルを抽出し、Expressのリクエストオブジェクトで配列として使用できるようにします。 アップロードされたファイルを保存する場所を構成し、ファイルのサイズと形式に制限を設定できます。

インポート後、次のオプションを使用してmulterミドルウェアを初期化します。

server.mjs
...
const app = express();
const port = 3000;

const upload = multer({
    storage: multer.memoryStorage(),
    limits: { fileSize: 100 * 1024 * 1024 }
});

app.use(cors());
...

storageオプションを使用すると、受信ファイルを保存する場所を選択できます。 multer.memoryStorage()を呼び出すと、ファイルをディスクに書き込むのではなく、Bufferオブジェクトをメモリに保持するストレージエンジンが初期化されます。 limitsオプションを使用すると、受け入れるファイルにさまざまな制限を定義できます。 fileSizeの制限を100MBに設定するか、ニーズとサーバーで使用可能なメモリの量に一致する別の数に設定します。 これにより、入力ファイルが大きすぎる場合にAPIがクラッシュするのを防ぐことができます。

注: WebAssemblyの制限により、ffmpeg.wasmは2GBを超えるサイズの入力ファイルを処理できません。

次に、POST /thumbnailエンドポイント自体を設定します。

server.mjs
...
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')呼び出しは、単一のファイルを含むマルチパート要求の本文を解析する、そのエンドポイントのみのミドルウェアをセットアップします。 最初のパラメーターはフィールド名です。 client.jsでリクエストを作成するときにFormDataに指定したものと一致する必要があります。 この場合はvideoです。 multerは、解析されたファイルをreqパラメーターに添付します。 ファイルの内容はreq.file.bufferの下にあります。

この時点で、エンドポイントは受信したデータに対して何もしません。 空の200応答を送信することにより、要求を確認します。 次のステップでは、受信したビデオデータからサムネイルを抽出するコードに置き換えます。

ステップ4—ffmpeg.wasmを使用したメディアの処理

この手順では、ffmpeg.wasmを使用して、POST /thumbnailエンドポイントが受信したビデオファイルからサムネイルを抽出します。

ffmpeg.wasm は、FFmpegの純粋なWebAssemblyおよびJavaScriptポートです。 その主な目標は、ブラウザで直接FFmpegを実行できるようにすることです。 ただし、Node.jsはV8(ChromeのJavaScriptエンジン)上に構築されているため、サーバー上のライブラリも使用できます。

ffmpegコマンドの上に構築されたラッパーよりもFFmpegのネイティブポートを使用する利点は、Dockerを使用してアプリをデプロイすることを計画している場合、以下を含むカスタムイメージを構築する必要がないことです。 FFmpegとNode.jsの両方。 これにより、時間を節約し、サービスのメンテナンス負担を軽減できます。

server.mjsの先頭に次のインポートを追加します。

server.mjs
import express from 'express';
import cors from 'cors';
import multer from 'multer';
import { createFFmpeg } from '@ffmpeg/ffmpeg';
...

次に、ffmpeg.wasmのインスタンスを作成し、コアのロードを開始します。

server.mjs
...
import { createFFmpeg } from '@ffmpeg/ffmpeg';

const ffmpegInstance = createFFmpeg({ log: true });
let ffmpegLoadingPromise = ffmpegInstance.load();

const app = express();
...

ffmpegInstance変数は、ライブラリへの参照を保持します。 ffmpegInstance.load()を呼び出すと、コアのメモリへの読み込みが非同期的に開始され、promiseが返されます。 コアがロードされたかどうかを確認できるように、promiseをffmpegLoadingPromise変数に格納します。

次に、fmpegLoadingPromiseを使用して、準備が整う前に最初のリクエストが到着した場合にコアがロードされるのを待機する次のヘルパー関数を定義します。

server.mjs
...
let ffmpegLoadingPromise = ffmpegInstance.load();

async function getFFmpeg() {
    if (ffmpegLoadingPromise) {
        await ffmpegLoadingPromise;
        ffmpegLoadingPromise = undefined;
    }

    return ffmpegInstance;
}

const app = express();
...

getFFmpeg()関数は、ffmpegInstance変数に格納されているライブラリへの参照を返します。 返却する前に、ライブラリの読み込みが完了したかどうかを確認します。 そうでない場合は、ffmpegLoadingPromiseが解決するまで待機します。 POST /thumbnailエンドポイントへの最初のリクエストが、ffmpegInstanceの使用準備が整う前に到着した場合、APIはそれを拒否するのではなく、可能な場合に待機して解決します。

次に、POST /thumbnailエンドポイントハンドラーを実装します。 関数の最後にあるres.sendStatus(200);getFFmpegの呼び出しに置き換えて、準備ができたらffmpeg.wasmへの参照を取得します。

server.mjs
...
app.post('/thumbnail', upload.single('video'), async (req, res) => {
    const videoData = req.file.buffer;

    const ffmpeg = await getFFmpeg();
});
...

ffmpeg.wasmは、メモリ内のファイルシステム上で機能します。 ffmpeg.FSを使用して読み書きできます。 FFmpeg操作を実行するときは、CLIツールを使用するときと同じように、仮想ファイル名を引数としてffmpeg.run関数に渡します。 FFmpegによって作成された出力ファイルはすべてファイルシステムに書き込まれ、取得できるようになります。

この場合、入力ファイルはビデオです。 出力ファイルは単一のPNG画像になります。 次の変数を定義します。

server.mjs
...
    const ffmpeg = await getFFmpeg();

    const inputFileName = `input-video`;
    const outputFileName = `output-image.png`;
    let outputData = null;
});
...

ファイル名は仮想ファイルシステムで使用されます。 outputDataは、準備ができたらサムネイルを保存する場所です。

ffmpeg.FS()を呼び出して、ビデオデータをメモリ内ファイルシステムに書き込みます。

server.mjs
...
    let outputData = null;

    ffmpeg.FS('writeFile', inputFileName, videoData);
});
...

次に、FFmpeg操作を実行します。

server.mjs
...
    ffmpeg.FS('writeFile', inputFileName, videoData);

    await ffmpeg.run(
        '-ss', '00:00:01.000',
        '-i', inputFileName,
        '-frames:v', '1',
        outputFileName
    );
});
...

-iパラメーターは、入力ファイルを指定します。 -ssは、指定された時間(この場合、ビデオの先頭から1秒)をシークします。 -frames:vは、出力に書き込まれるフレーム数を制限します(このシナリオでは1フレーム)。 最後のoutputFileNameは、FFmpegが出力を書き込む場所を示します。

FFmpegが終了したら、ffmpeg.FS()を使用してファイルシステムからデータを読み取り、入力ファイルと出力ファイルの両方を削除してメモリを解放します。

server.mjs
...
    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);
});
...

最後に、応答の本文で出力データをディスパッチします。

server.mjs
...
    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()を呼び出すと、応答ヘッドがディスパッチされます。 2番目のパラメーターには、カスタム httpヘッダー)と、それに続くリクエストの本文のデータに関する情報が含まれます。 res.end()関数は、最初の引数からのデータをリクエストの本文として送信し、リクエストを終了します。 outputData変数は、ffmpeg.FS()によって返されるバイトの生の配列です。 これをBuffer.from()に渡すと、 Buffer が初期化され、バイナリデータがres.end()によって正しく処理されるようになります。

この時点で、POST /thumbnailエンドポイントの実装は次のようになります。

server.mjs
...
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'));
});
...

アップロードの100MBのファイル制限を除いて、入力の検証やエラー処理はありません。 ffmpeg.wasmがファイルの処理に失敗すると、仮想ファイルシステムからの出力の読み取りに失敗し、応答が送信されなくなります。 このチュートリアルでは、エンドポイントの実装をtry-catchブロックでラップして、そのシナリオを処理します。

server.mjs
...
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は2つの要求を並行して処理できません。 サーバーを起動して、これを自分で試すことができます。

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

ffmpeg.wasmが機能するために必要なフラグに注意してください。 ライブラリは、WebAssemblyスレッドおよびバルクメモリ操作に依存しています。 これらは2019年からV8/Chromeに搭載されています。 ただし、Node.js v16.11.0の時点では、提案が完成する前に変更があった場合に備えて、WebAssemblyスレッドはフラグの後ろに残ります。 バルクメモリ操作では、古いバージョンのNodeでもフラグが必要です。 Node.js 15以下を実行している場合は、--experimental-wasm-bulk-memoryも追加します。

コマンドの出力は次のようになります。

Output
[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

Webブラウザでclient.htmlを開き、ビデオファイルを選択します。 サムネイルの作成ボタンをクリックすると、ページにサムネイルが表示されます。 舞台裏では、サイトはビデオをAPIにアップロードし、APIはそれを処理して画像で応答します。 ただし、ボタンをすばやく連続して繰り返しクリックすると、APIが最初のリクエストを処理します。 後続のリクエストは失敗します:

Output
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)

次のセクションでは、同時リクエストを処理する方法を学習します。

ステップ5—同時リクエストの処理

ffmpeg.wasmは一度に1つの操作しか実行できないため、入ってくるリクエストをシリアル化して一度に1つずつ処理する方法が必要になります。 このシナリオでは、プロミスキューが最適なソリューションです。 各リクエストの処理をすぐに開始するのではなく、処理される前に到着したすべてのリクエストが処理されると、キューに入れられて処理されます。

お好みのエディタでserver.mjsを開きます。

  1. nano server.mjs

server.mjsの上部にあるp-queueをインポートします。

server.mjs
import express from 'express';
import cors from 'cors';
import { createFFmpeg } from '@ffmpeg/ffmpeg';
import PQueue from 'p-queue';
...

次に、変数ffmpegLoadingPromiseの下のserver.mjsファイルの先頭に新しいキューを作成します。

server.mjs
...
const ffmpegInstance = createFFmpeg({ log: true });
let ffmpegLoadingPromise = ffmpegInstance.load();

const requestQueue = new PQueue({ concurrency: 1 });
...

POST /thumbnailエンドポイントハンドラーで、ffmpegへの呼び出しをキューに入れられる関数にラップします。

server.mjs
...
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);
    }
});
...

新しいリクエストが届くたびに、その前にキューがない場合にのみ処理が開始されます。 応答の最終的な送信は非同期で行われる可能性があることに注意してください。 ffmpeg.wasm操作の実行が終了すると、応答が送信されている間に別の要求の処理を開始できます。

すべてが期待どおりに機能することをテストするには、サーバーを再起動します。

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

ブラウザでclient.htmlファイルを開き、ファイルをアップロードしてみてください。

A screenshot of client.html with a thumbnail loaded

キューが設定されると、APIは毎回応答するようになります。 リクエストは、到着順に順番に処理されます。

結論

この記事では、ffmpeg.wasmを使用してビデオからサムネイルを抽出するNode.jsサービスを構築しました。 マルチパートリクエストを使用してブラウザからExpressAPIにバイナリデータをアップロードする方法と、外部ツールに依存したりディスクにデータを書き込んだりせずにNode.jsでFFmpegを使用してメディアを処理する方法を学びました。

FFmpegは非常に用途の広いツールです。 このチュートリアルの知識を使用して、FFmpegがサポートする機能を利用し、プロジェクトで使用することができます。 たとえば、3秒のGIFを生成するには、ffmpeg.run呼び出しをPOST /thumbnailエンドポイントでこれに変更します。

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
);
...

ライブラリは、元のffmpegCLIツールと同じパラメーターを受け入れます。 公式ドキュメントを使用して、ユースケースの解決策を見つけ、ターミナルですばやくテストできます。

ffmpeg.wasmは自己完結型であるため、ストックNode.jsベースイメージを使用してこのサービスをドッキングし、ロードバランサーの背後に複数のノードを保持することでサービスをスケールアップできます。 詳細については、チュートリアルDockerを使用してNode.jsアプリケーションを構築する方法に従ってください。

大きなビデオのトランスコードなど、より高価な操作を使用する必要がある場合は、それらを保存するのに十分なメモリを備えたマシンでサービスを実行するようにしてください。 WebAssemblyの現在の制限により、最大入力ファイルサイズは2GBを超えることはできませんが、これは将来変更される可能性があります。

さらに、ffmpeg.wasmは、元のFFmpegコードベースの一部のx86アセンブリ最適化を利用できません。 つまり、一部の操作は完了するまでに長い時間がかかる可能性があります。 その場合は、これがユースケースに適したソリューションであるかどうかを検討してください。 または、APIへのリクエストを非同期にします。 操作が終了するのを待つ代わりに、操作をキューに入れて、一意のIDで応答します。 クライアントが照会して処理が終了し、出力ファイルの準備ができているかどうかを確認できる別のエンドポイントを作成します。 RESTAPIの非同期要求/応答パターンとその実装方法の詳細をご覧ください。