15
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-12-06

はじめに

JavaScriptベースの動画編集サービスを作ってます」という記事でQiitaにはじめて投稿しました。

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

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

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

簡単なサービスのご紹介

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

動作イメージ

ドラッグ&ドロップで素材をタイムラインに追加
open video and put to timeline.gif

エフェクトの追加(回転)
apply effect (rotate).gif

エフェクトの追加(マスク)
apply effect (mask).gif

ソリッドレイヤーの追加とブレンドモード
add new solid and brend mode.gif

文字
apply effect (text).gif

サービスの構成

Double TONEは大きく以下の構成にわかれています。

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

動画編集(タイムライン、エフェクト)

タイムラインへの追加

タイムラインに追加したオブジェクトをレイヤーとして管理するために以下の様な情報を付加しています。

タイムラインへの追加
// 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 UIdraggableが慣れていたので、これで実装しています。

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

timeline drag.gif

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

$(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 UIresizableを適用しています。

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

timeline resize ew.gif

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

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

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

レンダリング

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

フレームの作成

最初のステップのフレームの作成では、タイムライン上にあるビデオ等を取得して、1フレームごとに画像ファイルを作成しています。作成した画像はEmscriptenFile System APIを使って、MEMFS上に保存しています(このため、メモリ消費が大きいです)。
参考: Emscripten - File Sytstem API

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

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;
};
フレームの生成の概略
// 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));
// 1フレームごとに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();

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

    // レイヤーごとにバッファー用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につなげてます。

オーディオバッファーをファイルに変換
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;
};
オーディオのマージの概略
// オーディオソースの取得
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つの動画ファイルに仕上げていきます。

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
参考:動画 → GIFアニメーションをサクッと作成

スクリーンショット

screenshot_122842.png

SNSへのアップロード

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

今後の拡張予定機能

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?