Reactからffmpeg.wasmを使って動画にウォーターマークを埋め込んでみる

## 概要
Reactでffmpeg.wasmを使って、選択した動画ファイルに対してウォーターマーク（透かし）を埋め込むツールを作ります。

## ffmpeg.wasmとは
[ffmpeg.wasm](https://github.com/ffmpegwasm/ffmpeg.wasm)は、FFmpegの純粋なWebassembly/Javascript移植版です。ブラウザ上でビデオやオーディオの録画、変換、ストリーミングを行うことができます。Webassemblyなので従来はサーバサイドで行っていた動画リソースに対するいろいろな処理をクライアントで処理することが可能になります。

## Reactのプロジェクトを用意する
予めNode.jsのv16以上を使えるようにしてください。
```bash
# バージョンをチェック
$ node -v
```
### インストール
今回は[Vite](https://ja.vitejs.dev/)を使います。
```bash
$ npm create vite@latest
```
**Select a framework**で`React`を選択します。
<br>
**Select a variant**はお好みですが今回は`TypeScript`を選択しました。

プロジェクトが生成されたらプロジェクトディレクトリへ移動してffmpeg.wasmをインストールしてください。

```bash
$ npm install @ffmpeg/core @ffmpeg/ffmpeg
```

## 実装の前準備
メインの実装に取り掛かる前に、必要なアセットやスタイルシートなどを整備しておきます。

### ウォーターマーク用の透過PNG画像を用意する
今回はフリーアイコンのPIN画像を使用します。
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2221418/08af7f25-0cdd-f8a8-6495-403cd3c1cd86.png)

ファイルは **/public** ディレクトリに **cat_line.png** という名前で保存しておきます。

### スタイルシートを修正する
index.css と App.css をそれぞれ以下のように修正します。

<details>
<summary>index.css</summary>

```css
:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

button {
  border-radius: 0.15rem;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}
button:hover {
  border-color: #646cff;
}
button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}

```
</details>

<details>
<summary>App.css</summary>

```css
#root {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

.app {
  display: flex;
  flex-flow: column nowrap;
  align-items: center;
}

.video {
  margin-bottom: 2rem;;
}

.progress-bar {
  width: 300px;
  background-color: #444;
  height: 2rem;
  position:relative;
  border-radius: 0.15rem;
  overflow: hidden;
}

.progress {
  background-color: #1D9BF0;
  height: 2rem;
}

.progress-par {
  position: absolute;
  top: 0px;
  bottom: 0px;
  left:0px;
  right: 0px;
  text-align: center;
  line-height: 2rem;
}
```
</details>

## 実装
既存のApp.tsxを修正していきます。
完成したソースはこちら。
<details>
<summary>App.tsx</summary>

```tsx
import { useState, Suspense, useCallback } from 'react';
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
import reactLogo from './assets/react.svg';
import './App.css';

function App() {
  const [videoSrc, setVideoSrc] = useState('');
  const [isLoading, setLoading] = useState(false);
  const [progress, setProgress] = useState(0);

  const ffmpeg = createFFmpeg({
    log: true,
    progress: (p: any) => {
      setProgress(Math.round(p.ratio * 100));
    },
  });

  const hanfleClear = () => {
    setVideoSrc('');
    setProgress(0);
  };

  const handleSelectFile = async ({ target: { files } }: React.ChangeEvent<HTMLInputElement>) => {
    if (!!!files || files.length === 0) return;

    try {
      setLoading(true);

      const file = files[0];
      const { name } = file;

      if (!ffmpeg.isLoaded()) await ffmpeg.load();

      ffmpeg.FS('writeFile', 'watermark.png', await fetchFile(`${window.location.origin}/cat_line.png`));
      ffmpeg.FS('writeFile', name, await fetchFile(file));

      await ffmpeg.run('-i', name, '-i', 'watermark.png', '-filter_complex', "[1:v]lut=a='val*0.4',[0:v]overlay", 'output.mp4');

      const data = ffmpeg.FS('readFile', 'output.mp4');
      setVideoSrc(URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' })));
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="app">
      <h1>Embed a Watermark</h1>
      {isLoading ? (
        <div className="progress-bar">
          <div className="progress" style={{ width: `${progress}%` }}></div>
          <div className="progress-par">Encoding... {progress}%</div>
        </div>
      ) : (
        <>
          {videoSrc ? (
            <div>
              <video className="video" src={videoSrc} controls autoPlay muted></video>
              <div>
                <button onClick={hanfleClear}>Clear</button>
              </div>
            </div>
          ) : (
            <div>
              <input type="file" id="uploader" onChange={handleSelectFile} />
            </div>
          )}
        </>
      )}
    </div>
  );
}

export default App;

```
</details>

ファイル選択など一般的なReactの実装は割愛して、ffmpeg.wasmを使用する部分に絞って解説していきます。

### ffmpegインスタンスの生成
ffmpegのインスタンスはcreateFFmpegで生成します。
<br>
プログレスバーを表示するために進捗をuseStateで管理します。

```ts
/** 進捗 */
const [progress, setProgress] = useState(0);

const ffmpeg = createFFmpeg({
  log: true, // trueにするとcondoleにffmpegのログが表示されるようになる
  progress: (p: any) => { // 進捗のcallback
    setProgress(Math.round(p.ratio * 100));
  },
});
```

### ffmpeg.wasmのcoreをロードする
ffmpeg.wasmのcoreをロードします。1度だけ実行すれば良いので```isLoaded()```でロード状態を判定して実行します。

```ts
if (!ffmpeg.isLoaded()) await ffmpeg.load();
```


### ウォーターマーク画像をMEMFSに保存する
ffmpeg.wasmで入出力するファイルはMEMFSで扱います。

**/public**に配置したcat_line.pngを```watermark.png```というファイル名でMEMFSに保存します。
```ts
ffmpeg.FS('writeFile', 'watermark.png', await fetchFile(`${window.location.origin}/cat_line.png`));
```

### 選択された動画をMEMFSに保存する
選択された動画もウォーターマーク画像と同様にMEMFSへ書き込みます。

```ts
ffmpeg.FS('writeFile', name, await fetchFile(file));
```

### ffmpegによるエンコード処理
ffmpegのオプションがそのまま指定できるので、```filter_complex```オプションを使用して透過度0.4でウォーターマークを左上に埋め込みます。
```ts
await ffmpeg.run('-i', name, '-i', 'watermark.png', '-filter_complex', "[1:v]lut=a='val*0.4',[0:v]overlay", 'output.mp4');
```

### エンコードされた動画をvideoタグに読み込む
エンコードされた動画ファイルをMEMFSから取得して、ObjectURL形式に変換してvideoタグに読み込ませます。

videoタグには```autoPlay```と```muted```を設定しているので自動的に動画が再生されます。

```ts
/** videoのsrc */
const [videoSrc, setVideoSrc] = useState('');

const data = ffmpeg.FS('readFile', 'output.mp4');
setVideoSrc(URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' })));
```
```tsx
<video className="video" src={videoSrc} controls autoPlay muted></video>
```

## 動かしてみる
早速作ったアプリを動かしてみます。

```bash
$ npm run dev
```

動画ファイルを選択します。
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2221418/03f01ced-5c48-a51b-6fd3-17a5965d007f.png)


選択後すぐさまエンコード処理が実行されます。進捗はプログレスバーで表示されています。
![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2221418/1e0bd25b-8f98-a356-267a-30640cd6c484.png)


エンコードが完了すると動画が再生されます。<br>ちゃんと左上にウォーターマークが埋め込まれていますね！

![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2221418/91f3a365-1c0f-72ce-5200-4c6c0eb70952.png)


気になるエンコーディングのパフォーマンスですが、CLIのffmpegでエンコードした場合に比べて数倍遅くなります。そこはブラウザ内で実行しているので仕方がないですね。

## まとめ

今回はWebAssemblyで実装されたffmpegを使ってみましたが、大きい動画でなければ十分実用的なパフォーマンスだと思いました。
<br>
使い方も簡単で、ffmpegのコマンドがそのまま使えるのもポイントだと思います。

## 参考

https://ffmpegwasm.netlify.app/

https://developer.mozilla.org/ja/docs/WebAssembly

https://ja.vitejs.dev/