Double TONE
Double TONEというブラウザ上で動作する動画編集サービスを作っています。本業はエンジニアではないため諸先輩方にご紹介するのは痴がましいですが、デザインも実装も一人で進めているこのサービスの紹介と、どのようにWebブラウザで動画編集を実現させているかを紹介しようと思います。
まだまだなサービスではありますが、今後もどんどんいろんな機能だったり使い勝手の向上をしてきますので、ぜひご意見を頂戴できれば幸いです。
Double TONEはサーバーサイド含めてすべてをJavaScriptで構成しています。UI制御はだいぶレガシーな作り(なんとjQuery
ベース)で、レンダリング部分はWeb Audio API
とffmpeg
で作り上げています。コードの一部はこの記事の後半に載せています。
サービス概要
- 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
(そのうち、大々的なリプレースをしないといけないという意識はあります)
サービスの動作イメージ
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);
重要なのが、mediaInformation
とpositionInformation
で、これによってタイムライン上のどの位置に素材があるのかを管理しています。UI上の動きはこれらの情報に合わせてDOMの位置を調整しているだけになります。
タイムライン上の移動
動画等をどのタイミングで再生するか、をタイムラインの位置で決めています。タイムラインをドラッグして移動すると、その再生開始位置を変更できるようにタイムラインオブジェクトにドラッグイベントを追加します。わたしはjQuery UI
のdraggable
が慣れていたので、これで実装しています。
jQuery UIはメンテナンスのみに移行していますのでご注意ください)。
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 UI
のresizable
を適用しています。
jQuery UIはメンテナンスのみに移行していますのでご注意ください)。
$(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
オブジェクトをレイヤーに追加します。
let effect = {
type: 'rotate',
value: 120,
};
layer.effects.push(effect);
$(layer.layerId).css({
'transform': `rotate(${effect.value}deg)`
});
エフェクトによってはキーフレームを設定できるものがあり、キーフレームを設定すると、タイムラインの時間軸にあわせてアニメーションさせることができます。基本的にはcss
のkeyframe
を動的に生成しプレビューエリアに適用しています。
レンダリング
Double TONEの機能の一番重要な部分です。この部分の実装が一番大変でした。
レンダリング自体はffmpegをWebAssembly化したffmpeg.wasmでやっていますが、そこに持っていくまでにいろいろと試行錯誤してます。ステップとしては、1) フレームの作成 2) オーディオのマージ 3) レンダリング の三段階に分けています。
フレームの作成
最初のステップのフレームの作成では、タイムライン上にあるビデオ等を取得して、1フレームごとに画像ファイルを作成しています。作成した画像はEmscripten
のFile System API
を使って、MEMFS上に保存しています(このため、メモリ消費が大きいです)。
参考: Emscripten - File Sytstem API
以下に、概略を紹介します。
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 「動画のトリミング/リサイズ」ツールを選択し、動画を開く
STEP 3 レンダリングモードを選択(MP4もしくは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アニメーションに変換する一番ベーシックな方法としては、以下の様な形になるかと思います。
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を選択肢の一つとして活用いただけると幸いです。