序章

メディア資産の処理は、最新のバックエンドサービスの一般的な要件になりつつあります。 専用のクラウドベースのソリューションを使用すると、大規模な処理を行っている場合や、ビデオトランスコーディングなどの高価な操作を実行している場合に役立つことがあります。 ただし、必要なのがビデオからサムネイルを抽出するか、ユーザー生成コンテンツが正しい形式であることを確認することだけである場合、追加のコストと追加の複雑さを正当化するのは難しいかもしれません。 特に小規模では、メディア処理機能を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 ファイル。 The -y パラメータは、プロジェクトのデフォルト設定に満足していることを示します。

  1. npm init -y

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

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

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

まず、という新しいファイルを開きます server.mjsnano または選択した編集者:

  1. nano server.mjs

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

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

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

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

次に、関数を作成します createThumbnail() APIにリクエストを送信し、動画を送信して、それに応じてサムネイルを受信します。 あなたの上部に client.js ファイル、URLを使用して新しい定数を定義します /thumbnail 終点:

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

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

次に、 createThumbnail() あなたの下の機能 showError() 関数:

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

async function createThumbnail(video) {

}
...

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

内部 createThumbnail() 関数、 FormData のインスタンスを作成し、ビデオファイルをオブジェクトに追加します。 次に、 POST FetchAPIを使用してAPIエンドポイントにリクエストします。 FormData() 本体としてのインスタンス。 応答をバイナリファイル(または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() 関数、関数を作成します blobDataToURL() Promise を返します:

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 エンドポイントはまだ構築されていません。 次のステップでは、を作成します /thumbnail Expressのエンドポイントは、ビデオファイルを受け入れ、サムネイルを作成します。

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

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

開ける 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());
...

The storage オプションを使用すると、受信ファイルを保存する場所を選択できます。 呼び出し multer.memoryStorage() ファイルをディスクに書き込むのではなく、Bufferオブジェクトをメモリに保持するストレージエンジンを初期化します。 The 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}`)
});

The upload.single('video') callは、そのエンドポイントに対してのみミドルウェアをセットアップします。ミドルウェアは、単一のファイルを含むマルチパートリクエストの本文を解析します。 最初のパラメーターはフィールド名です。 それはあなたが与えたものと一致しなければなりません FormData でリクエストを作成するとき client.js. この場合、それは 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();
...

The ffmpegInstance 変数はライブラリへの参照を保持します。 呼び出し ffmpegInstance.load() コアのメモリへの非同期ロードを開始し、promiseを返します。 約束を保存します ffmpegLoadingPromise コアがロードされているかどうかを確認できるように変数。

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

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

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

    return ffmpegInstance;
}

const app = express();
...

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

The -i パラメータは入力ファイルを指定します。 -ss 指定された時間(この場合、ビデオの最初から1秒)までシークします。 -frames:v 出力に書き込まれるフレーム数を制限します(このシナリオでは単一フレーム)。 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ヘッダー)と、それに続くリクエストの本文のデータに関する情報が含まれます。 The res.end() 関数は、最初の引数からのデータをリクエストの本文として送信し、リクエストを終了します。 The 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

開ける client.html Webブラウザでビデオファイルを選択します。 サムネイルの作成ボタンをクリックすると、ページにサムネイルが表示されます。 舞台裏では、サイトはビデオを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

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

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

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

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 ブラウザでファイルを作成し、ファイルをアップロードしてみてください。

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

結論

この記事では、を使用してビデオからサムネイルを抽出するNode.jsサービスを構築しました ffmpeg.wasm. マルチパートリクエストを使用してブラウザから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
);
...

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

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

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

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