LoginSignup
55
41

More than 1 year has passed since last update.

動画の編集とレンダリング、Webブラウザでできます【JavaScript / Web Audio API / ffmpegで実装したサービスの紹介】

Last updated at Posted at 2022-07-04

Double TONE

Double TONEというブラウザ上で動作する動画編集サービスを作っています。本業はエンジニアではないため諸先輩方にご紹介するのは痴がましいですが、デザインも実装も一人で進めているこのサービスの紹介と、どのようにWebブラウザで動画編集を実現させているかを紹介しようと思います。

まだまだなサービスではありますが、今後もどんどんいろんな機能だったり使い勝手の向上をしてきますので、ぜひご意見を頂戴できれば幸いです。
Double TONE Logo

Double TONEはサーバーサイド含めてすべてをJavaScriptで構成しています。UI制御はだいぶレガシーな作り(なんとjQueryベース)で、レンダリング部分はWeb Audio APIffmpegで作り上げています。コードの一部はこの記事の後半に載せています。

サービス概要

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

Double TONEの設計思想

サーバー側はnode.jsを使ってますが、サービスのメインはフロントエンドになります。一度JSなどの読み込みをしてしまえば、サーバー側との通信は不要にしているためです。

多くのWebブラウザで動作する動画編集サービスは、素材を開く度にサーバー側に転送しているようでした。Double TONEの設計思想としては、全てをクライアント側で実現 というものです。そうすることで、たとえば社内の規定で外部に出せない素材を使った動画編集というのも、気軽にできるようにしたい、と考えているからです。もちろんその分デメリットもあります。

また、Webブラウザで動くように構成しているのは、何もインストールしなくても動画編集ができる という使い勝手を実現したかったからになります。

フロントエンドの構成

フロントエンドの構成としては、以下のようにレガシーなものを使っています(これは意図的なものではなく、当初は私一人が使うだけのツールを作るつもりで始めたためです。お恥ずかしい)。最新の技術を使ってみましたという方が本当はいいのでしょうが、とりあえず動くものを作るというのを第一としたため、私が慣れているものを使っています。

  • テンプレートエンジンにExpressとBootstrap
  • UI操作はほとんど全てjQuery

Node.js: https://nodejs.org/ja/
Express: https://expressjs.com/
Bootstrap: https://getbootstrap.jp/
jQuery: https://jquery.com/
ffmpeg.wasm: https://github.com/ffmpegwasm/ffmpeg.wasm

(そのうち、大々的なリプレースをしないといけないという意識はあります)

サービスの動作イメージ

ドラッグ&ドロップで素材をタイムラインに追加
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の構成

Double TONEはブラウザ上で動作する動画編集サービスで、基本的な編集作業はブラウザ上で完結します。UI部分はレガシーな作り(jQueryベース)をしているので、ご紹介するのが痴がましいですが、参考になればと思い公開します。

サービス全体としては以下の3つに分けて構成しています。

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

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

Double TONEのUIのメインです。動画や写真、音楽をタイムライン上に並べ、タイミングの調整やレイヤーの重ね合わせ、エフェクトの追加などができる機能です。ここで編集した内容をレンダリング機能に渡して最終的な出力を行います。

タイムラインへの追加

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

タイムラインへの追加
// 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);

重要なのが、mediaInformationpositionInformationで、これによってタイムライン上のどの位置に素材があるのかを管理しています。UI上の動きはこれらの情報に合わせてDOMの位置を調整しているだけになります。

タイムライン上の移動

動画等をどのタイミングで再生するか、をタイムラインの位置で決めています。タイムラインをドラッグして移動すると、その再生開始位置を変更できるようにタイムラインオブジェクトにドラッグイベントを追加します。わたしは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を動的に生成しプレビューエリアに適用しています。

レンダリング

Double TONEの機能の一番重要な部分です。この部分の実装が一番大変でした。

レンダリング自体はffmpegをWebAssembly化した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つとも、上記のレンダリングのプロセスの一部を使って実現しています。

トリミングツール

動画のトリミングだけであれば、タイムラインを使わずにできるツールを用意しています。トリミングだけでなく、動画からGIFアニメーションを作成することもできます。

トリミングツールの使い方

STEP 1 「動画のトリミング/リサイズ」ツールを選択し、動画を開く tool 01_open.gif

STEP 2 トリミングする(出力する範囲を選択する)
tool 02_trim.gif

STEP 3 レンダリングモードを選択(MP4もしくはGIFアニメーション)にして、サイズを選ぶ
tool 03_resize.gif

STEP 4 レンダリングをスタート、完成
tool 04_render.gif

このGIFアニメーションもDouble TONEで生成しました。

実現方法

レンダリング自体はffmpeg wasmでやっています。基本的な使い方は本家を参照ください。
ffmpeg wasm - https://github.com/ffmpegwasm/ffmpeg.wasm

Double TONEの「動画のトリミング・リサイズ」ツールでは以下のようなフローでGIFアニメーションを作成しています。

  • Web File APIでファイルを選択して読み込む
  • Emscripten File System APIで読み込んだファイルをMEMFS上に保存(ffmpeg wasmのFSコマンドで実現)
  • ffmpegコマンドで動画をGIFアニメーションに変換

ffmpegは、正直、黒魔術的な要素があると感じています。難しいです。パラメーターの使い方によっては、同じ様な見た目なのに、容量がぜんぜん違う、何てこともよくあります(勉強中)。

単純に動画をGIFアニメーションに変換する一番ベーシックな方法としては、以下の様な形になるかと思います。

ffmpeg_wasmで動画をGIFアニメーションに変換
const FRAME_RATE = 10;
const HEIGHT = 720;
const OUTPUT_FILENAME = 'output.gif'
ffmpeg.run(
    "-i", inputName,
    "-r", FRAME_RATE,
    "-vf", `scale=-2:${HEIGHT}`,
    "-f", "gif",
    OUTPUT_FILENAME
);

scale=-2:${HEIGHT}はHeightを指定し、Widthはアスペクト比固定で算出されます。サイズは偶数じゃないといけないので、-1ではなく-2としています。-1はアスペクト比を維持する、-2はアスペクト比を維持し偶数で丸める挙動となります。

最後に

Double TONEには、より簡単により身近にできる機能を計画と実装を進めています。動画編集は昨今とても敷居が低くなってきました。色々なツールやアプリがありますが、Double TONEを選択肢の一つとして活用いただけると幸いです。

55
41
3

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
55
41