WebAssembly + Web Audio API + Canvas で動画編集（Double TONEの紹介）

# はじめに
「[JavaScriptベースの動画編集サービスを作ってます](https://qiita.com/taukuma/items/eb898e749429e7ee850d)」という記事でQiitaにはじめて投稿しました。

その記事にて紹介していますが、**JavaScriptベースの動画編集サービス・[Double TONE - https://doubletone.jp](https://doubletone.jp)**を作っており、**2021年12月1日にオープンベータ版として、一般公開しました！**

このサービスは今のところ一人で開発しており、ツールとしてもサービスとしてもまだまだ未熟ではありますが、よろしくお願いいたします。

Qiitaでは開発メモを自分でも整理するために投稿していこうと思います。まずは、サービスの簡単なご紹介をしたいと思います

# 簡単なサービスのご紹介
* [Double TONE - https://doubletone.jp](https://doubletone.jp)
* サービス名：Double TONE（ダブルトーン）
* ブラウザ上で動作する動画編集サービスです
* 基本的な編集作業はブラウザ上で完結します
* 動画や写真などの素材もアップロード不要です
* レンダリングもブラウザ上でやります
* SNSに直接アップロードできます
* もちろん**無料**です

### 動作イメージ
ドラッグ＆ドロップで素材をタイムラインに追加
![open video and put to timeline.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/0c69b9c9-b7e8-b19d-1ea3-461ce3b87f39.gif)

エフェクトの追加（回転）
![apply effect (rotate).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/cd94e717-b4f0-f41b-2390-e40e7175c576.gif)

エフェクトの追加（マスク）
![apply effect (mask).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/c5748eb4-8ca5-7614-4f21-8ff14f134b1d.gif)

ソリッドレイヤーの追加とブレンドモード
![add new solid and brend mode.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/4ca4803b-c8fa-4e6e-e9b1-64472099fddc.gif)

文字
![apply effect (text).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/45275d83-6b2f-7857-eac8-cfe12784acb1.gif)

# サービスの構成
[Double TONE](https://doubletone.jp)は大きく以下の構成にわかれています。

* 動画編集(タイムライン、エフェクト)
* レンダリング
* ツール
* SNSアップロード

### 動画編集(タイムライン、エフェクト)
##### タイムラインへの追加
タイムラインに追加したオブジェクトをレイヤーとして管理するために以下の様な情報を付加しています。

```javascript:タイムラインへの追加
// layerに追加する情報
const PIXEL_PER_SEC = 30; // 30fps
let layer = {
    order: 0,
    layerId: '#layer-id',
    timelineId: '#timeline-id',
    mediaInformation: {
      url:       'blob:******************.mp4'
      height:    1080,
      width:     1920,
      startTime: 0,
      endTime:   10,
      duration:  10
    },
    positionInformation: {
      startPosition: 0,
      endPosition:   10 * PIXEL_PER_SEC,
      leftRemain:    null,
      rightRemain:   null
    },
    effects: [],
};
layers.push(layer);
```

##### タイムライン上の移動
動画等をどのタイミングで再生するか、をタイムラインの位置で決めています。タイムラインをドラッグして移動すると、その再生開始位置を変更できるようにタイムラインオブジェクトにドラッグイベントを追加します。わたしは`jQuery UI`の`draggable`が慣れていたので、これで実装しています。

:::note warn
jQuery UIはメンテナンスのみに移行していますのでご注意ください)。
:::

![timeline drag.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/687b69b1-d76f-065b-492d-410be47d41b9.gif)

```javascript:draggable
const TIMELINE_GRID_X = 1;  // x軸の移動単位 (px)
const TIMELINE_GRID_Y = 75; // y軸の移動単位 (px)
const SNAP_TOLERANCE = 10;  // 周囲１０px以内に他のオブジェクトがあったら、ピタッとくっつく 

$(layer.timelineId).draggable({
    grid: [TIMELINE_GRID_X, TIMELINE_GRID_Y],
    containment: "parent",  // 親要素を超えてドラッグはできない
    scroll: "false",        // ドラッグ中はスクロールさせない
    snap: true,
    snapMode: 'both',
    snapTolerance: SNAP_TOLERANCE,
    drag: (ev, ui) => {
        // ドラッグ中のイベント
    },
    stop: (ev, ui) => {
        // ドラッグ終了時のイベント
        // レイヤーオブジェクトの positionInformationを更新
        layer.positionInformation.startPosition = ui.position.left;
        layer.positionInformation.endPosition   = ui.position.left - ui.originalPosition.left;
    }
});
```

###### タイムライン上のリサイズ
長い動画の場合は任意のところから再生できたり、また終了できたりするために、タイムライン上で動画のスタートタイムとエンドタイムを変更できる必要があります。このため、タイムラインオブジェクトに`jQuery UI`の`resizable`を適用しています。

:::note warn
jQuery UIはメンテナンスのみに移行していますのでご注意ください)。
:::

![timeline resize ew.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/f33223c9-184d-8b6a-548a-c38797d7f6fc.gif)

```javascript:resizable
$(layer.timelineId).resizable({
    handles: 'e,w',        // 左右のみのリサイズ
    containment: "parent", // 親要素からはみ出さない
    resize: function(ev, ui) {
        // リサイズ中のイベント
    },
    stop: function (ev, ui) {
        let amount = (ui.originalSize.width - ui.size.width);
        let amountTime = amount / PIXEL_PER_SEC;
        let leftOriginal = ui.originalPosition.left;

        // 右からのリサイズの場合
        if (ui.originalPosition.left === ui.position.left) {
            // 終わりの位置を設定
            layer.positionInformation.rightRemain = layerObj.positionInformation.rightRemain + amount;
            layerObj.positionInformation.endPosition = ui.size.width;
            layerObj.mediaInformation.endtime = layer.mediaInformation.duration - (layer.positionInformation.rightRemain / PIXEL_PER_SEC);
        
        } 
        // 左からのリサイズの場合
        else {
            // 開始位置を設定
            layer.positionInformation.leftRemain = layer.positionInformation.leftRemain + amount;
            layer.positionInformation.startPosition = ui.position.left;
            layer.mediaInformation.starttime = layer.positionInformation.leftRemain / PIXEL_PER_SEC;
        }
    }
});
```

##### エフェクトの適用
各レイヤーに対して、エフェクトを適用するために、`effect`オブジェクトをレイヤーに追加します。
![apply effect (rotate).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/cd94e717-b4f0-f41b-2390-e40e7175c576.gif)

```javascript:effectオブジェクト
let effect = {
    type: 'rotate',
    value: 120,
};
layer.effects.push(effect);
$(layer.layerId).css({
    'transform': `rotate(${effect.value}deg)`
});
```

エフェクトによってはキーフレームを設定できるものがあり、キーフレームを設定すると、タイムラインの時間軸にあわせてアニメーションさせることができます。基本的には`css`の`keyframe`を動的に生成しプレビューエリアに適用しています。この話題は別記事にしようと思ってます。

### レンダリング
この記事でのメインパートです。レンダリング自体は`ffmpeg wasm`でやっていますが、そこに持っていくまでにいろいろと試行錯誤してます。ステップとしては、1) フレームの作成 2) オーディオのマージ 3) レンダリング の三段階に分けています。

##### フレームの作成
最初のステップのフレームの作成では、タイムライン上にあるビデオ等を取得して、１フレームごとに画像ファイルを作成しています。作成した画像は`Emscripten`の`File System API`を使って、MEMFS上に保存しています（このため、メモリ消費が大きいです）。
参考： [Emscripten - File Sytstem API](https://emscripten.org/docs/api_reference/Filesystem-API.html)

以下に、概略を紹介します。

```javascript:Canvasからイメージファイルを作成
let base64ToUint8Array = (base64, type) => {
    var bin = atob(base64.replace(/^.*,/, ''));
    var buffer = new Uint8Array(bin.length);
    for (var i = 0; i < bin.length; i++) {
        buffer[i] = bin.charCodeAt(i);
    }
    return buffer;
};
```
```javascript:フレームの生成の概略
// ffmpeg (https://github.com/ffmpegwasm/ffmpeg.wasm)
let ffmpeg = createFFmpeg({log: true});

// レンダリングの開始位置と位置と終了位置と取得
let renderStartPosition = layers
        .map(l => l.positionInformation.startPosition)
        .reduce((acc, curr) => Math.min(acc, curr));
let renderEndPosition = layers
        .map(l => l.positionInformation.endPosition)
        .reduce((acc, curr) => Math.max(acc, curr));
// １フレームごとにCanvasを生成
for (let i = 0; i < renderEndPosition - renderStartPosition; i++) {
    let targetCanvas = $(`<canvas id="target-canvas" width="1280" height="720"></canvas>`);
    let targetContext = targetCanvas.get(0).getContext("2d");
    targetContext.save();

    // タイムラインを１フレーム動かす
    // ...

    // レイヤーごとにバッファー用Canvasに転写
    for await (let layer of layers) {
        let bufferCanvas = $(`<canvas id="buffer-canvas" width="1280" height="720"></canvas>`);
        let bufferOfflineCanvas = bufferCanvas.get(0).transferControlToOffscreen();
        let bufferContext = bufferOfflineCanvas.getContext("2d");
        bufferContext.save();

        // ビデオからイメージを取得
        bufferContext.drawImage($(layer.layerId), 0, 0, 1280, 720);

        // エフェクトの適用など
        //...   
        
        // 転写
        targetContext.drawImage(bufferContext.canvas, 
            0, 0, bufferContext.canvas.width, bufferContext.canvas.height,
            0, 0, targetContext.canvas.width, targetContext.canvas.height);

        // MEMFSにCanvasのImageDataを保存
        ffmpeg.FS(
            'writeFile',
            `frame${i}.png`,
            base64ToUint8Array($(targetCanvas).get(0).toDataURL("image/png"), "image/png")
        )
    }

}
```

##### オーディオのマージ
タイムライン上には複数のオーディオソースがあるので、それをマージする必要があります。`Web Audio API`を仕様して、以下の様な処理フローでやっています。
`source(n) -> splitter(n*channel) -> merger -> destination`
※複数のオーディオソースをチャネルごとにSplitterで分けて、Mergerで一つにして、Destinationにつなげてます。

```javascript:オーディオバッファーをファイルに変換
let convertAudioBufferToArrayBuffer = (buffer, len) => {
    let numOfChan = abuffer.numberOfChannels,
    length = len * numOfChan * 2 + 44,
    buffer = new ArrayBuffer(length),
    view = new DataView(buffer),
    channels = [], i, sample,
    offset = 0,
    pos = 0;

    let setUint16 = (data) => {
        view.setUint16(pos, data, true);
        pos += 2;
    };

    let setUint32 = (data) => {
        view.setUint32(pos, data, true);
        pos += 4;
    };

    // WAVEファイルのヘッダー
    setUint32(0x46464952);
    setUint32(length - 8);
    setUint32(0x45564157);

    setUint32(0x20746d66);
    setUint32(16);
    setUint16(1);
    setUint16(numOfChan);
    setUint32(abuffer.sampleRate);
    setUint32(abuffer.sampleRate * 2 * numOfChan);
    setUint16(numOfChan * 2);
    setUint16(16);

    setUint32(0x61746164);
    setUint32(length - pos - 4);

    //データ
    for(i = 0; i < abuffer.numberOfChannels; i++)
        channels.push(abuffer.getChannelData(i));

    new Array(abuffer.length * abuffer.numberOfChannels).fill()
        .map((v, i) => {
            return {
                position: pos + i *2,
                offset: Math.floor(i / abuffer.numberOfChannels),
                channel: i % abuffer.numberOfChannels};
            })
        .forEach(v => {
            sample = channels[v.channel][v.offset]; // clamp
            sample *= 32768; // scale to 16-bit signed int
            view.setInt16(v.position, sample, true);          // write 16-bit sample
        })
    // create Blob
    return buffer;
};
```
```javascript:オーディオのマージの概略
// オーディオソースの取得
let audioSources = layers.map(l => {
    var response = await fetch(l.url);
    var audioBuffer = await response.arrayBuffer();
    var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    var source = null;
    await new Promise((resolve, reject) => {
        audioCtx.decodeAudioData(audioBuffer, (buffer) => resolve(buffer), (error) => reject());
    }).then(buffer => {            
        source = audioCtx.createBufferSource();
        source.buffer = buffer;
        source.connect(audioCtx.destination);
        source.loop = false;
    }).catch(e => {
        source = null;
    });
    return source;
});

const channelCount = audioSource
    .map(s => s.buffer.numberOfChannels)
    .reduce((acc, curr) => Math.max(acc, curr));
const sampleRate = 48000;

// オフラインオーディオコンテキストを作成 
const context = new OfflineAudioContext(channelCount, renderEndPosition / VIDEO.UI.Constants.PIXEL_PER_SEC * sampleRate, sampleRate);

// Mergerの作成
let merger = context.createChannelMerger();
let recorderSources = [];

for await (s of audioSources) {
    let recoderSource = context.createBufferSource();
    let splitter = context.createChannelSplitter(s.buffer.numberOfChannels);

    recoderSource.buffer = s.buffer;
    
    // source -> splitter
    recoderSource.connect(splitter);

    // splitter -> merger
    for (var channel = 0; channel < s.audioBuffer.numberOfChannels; channel++) {
        splitter.connect(merger, channel, channel);
    }
    
    recoderSources.push(recoderSource);
}
// merger -> destination
merger.connect(context.destination);

// start
recoderSources.forEach(s => s.start());

// レコーディング
context.startRendering().then((renderedBuffer) => {
    // bufferをwavファイルに変換
    let audioFile = new Uint8Array(convertAudioBufferToArrayBuffer(renderedBuffer, renderedBuffer.length))

    //MEMFSに保存
    ffmpeg.FS(
        'writeFile',
        'audio.wav',
        audioFile
    )
})
```
##### レンダリング
フレームの作成とオーディオのマージの手順で、必要な素材がすべてMEMFS上にある状態となります。

最後のレンダリングのステップでは、ffmpegコマンドを使って、1つの動画ファイルに仕上げていきます。

```javascript:
const FRAME_RATE = '30000/1001';
const AUDIO_CODEC = 'aac';
const PIXEL_FORMAT = 'yuv420p';
ffmpeg.run(
    '-r'      , FRAME_RATE,
    '-i'      , 'frame%d.png',
    '-i'      , 'audio.wav',
    '-c:a'    , AUDIO_CODEC,
    '-pix_fmt', PIXEL_FORMAT
    'output.mp4'
);
```
これで、MEMFS上にレンダリング結果（output.mpt）が作成されます。あとは、MEMFSからこのファイルを取り出して完了となります。
### ツール
Double TONEでは、動画編集の他に、現在のところ、トリミングツールとスクリーンショットツールがあります。2つとも、上記のレンダリングのプロセスの一部を使って実現しています。

##### トリミングツール
![triming_tool_122908.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/edbe5d1c-da18-77d2-c81a-264ab00d17c4.png)
参考:[動画 → GIFアニメーションをサクッと作成](https://qiita.com/taukuma/items/e48803a45fcc1aaa78ba)

##### スクリーンショット
![screenshot_122842.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2187642/4d0dd812-93e0-98dc-63d7-4a72bd720496.png)

### SNSへのアップロード
2021年12月1日現在は、Twitterへのアップロードのみ対応しています。こちらは`node.js`に`passport`を入れて実現しています。詳細はいい記事がたくさんあるのでそちらを参照ください。

# 今後の拡張予定機能
いつ実現できるかは、コミットできませんが、現在以下のような機能の追加を検討しています。

* テンプレート
* ビジュアルエフェクトの追加
* オーディオエフェクトの追加
* アップロードできるSNSの追加