はじめに
「JavaScriptベースの動画編集サービスを作ってます」という記事でQiitaにはじめて投稿しました。
その記事にて紹介していますが、**JavaScriptベースの動画編集サービス・Double TONE - https://doubletone.jp**を作っており、2021年12月1日にオープンベータ版として、一般公開しました!
このサービスは今のところ一人で開発しており、ツールとしてもサービスとしてもまだまだ未熟ではありますが、よろしくお願いいたします。
Qiitaでは開発メモを自分でも整理するために投稿していこうと思います。まずは、サービスの簡単なご紹介をしたいと思います
簡単なサービスのご紹介
- Double TONE - https://doubletone.jp
- サービス名:Double TONE(ダブルトーン)
- ブラウザ上で動作する動画編集サービスです
- 基本的な編集作業はブラウザ上で完結します
- 動画や写真などの素材もアップロード不要です
- レンダリングもブラウザ上でやります
- SNSに直接アップロードできます
- もちろん無料です
動作イメージ
サービスの構成
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 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
を動的に生成しプレビューエリアに適用しています。この話題は別記事にしようと思ってます。
レンダリング
この記事でのメインパートです。レンダリング自体は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つとも、上記のレンダリングのプロセスの一部を使って実現しています。
トリミングツール
スクリーンショット
SNSへのアップロード
2021年12月1日現在は、Twitterへのアップロードのみ対応しています。こちらはnode.js
にpassport
を入れて実現しています。詳細はいい記事がたくさんあるのでそちらを参照ください。
今後の拡張予定機能
いつ実現できるかは、コミットできませんが、現在以下のような機能の追加を検討しています。
- テンプレート
- ビジュアルエフェクトの追加
- オーディオエフェクトの追加
- アップロードできるSNSの追加