ffmpeg
TypeScript
React
Electron

最高にシンプルなデスクトップ動画キャプチャツールを作った

とにかく自分好みの超シンプルなTwitter投稿用デスクトップ動画キャプチャが欲しかったので作りました。

https://github.com/happou31/cap-taro

Windows向けのみに zip で配布しています。

他のプラットフォームは手元に環境がないのでビルド出来てませんが、たぶん動くと思います。


デモ

t.gif


特徴


  • 完全に Electron と JavaScript で動いてるので移植性バツグン

  • 某GyazoとかWindows10標準(Shift+Win+Sで出来るやつ)に近い操作感

  • ffmpeg 利用による複数形式対応 (webm, mp4, gif)

  • gif, mp4 に関してはTwitterにも投稿出来る(重要)

  • マルチスクリーン対応(地味に大変だった)


使った技術


Electron

選定理由としては、UIが作りやすそうだと思ったからです。(あと、他のOSでも動くと楽しそうだなあと思った。)

今回のアプリのようなUIを作るには「画面上を半透明のウインドウで覆ってクリックした位置を起点にマウスの位置に半透明の四角形を描画する」というのを実現する必要がありますが、WPFとかでこれを実現しようとするとなかなか面倒だけどHTMLなら簡単に出来るだろう、という感じです。

あと、今回は画面の座標周りを扱うことになるので、DPI等は抽象化されていたほうがやりやすいかなと思ったというのも理由の一つです。

そのおかげで、このアプリではDPI設定の違う複数のスクリーンに対応することが出来ています。


React

hooks が使いたかった。以上。

…だけだと流石にアレなので小話を。

デモでも見えている選択位置に四角形を描画する部分は canvas で動いています。

おかげで非常になめらかな動きを実現出来てるのですが、最初はもちろんCSSでやろうかと思っていました。

ですが、なんだか動きが悪いので諦めました。

具体的には、 React で以下のように div に対して props で style プロパティに座標を渡してもうまい具合に更新してくれなかったです。


(うまく動かないコード)

// props で親から描画する四角形の左上と右下の座標を受け取っている

export default ({left, top, right, bottom}) => (
{/* 例なので簡略化してあります */}
<div style={{
position: "fixed",
backgroundColor: "gray"
}}>
<div style={{
position: "absolute",
left: left,
top: top,
width: right - left
height: bottom - top,
border: 1px solid;
}} />
</div>
);


調査する気が失せてしまったので、原因は分かっておりません…。


Electron.DesktopCapturer

今回の目玉その1です。

Electronの標準APIとしてデスクトップをキャプチャすることが出来る仮想カメラストリームを取得出来るものがあると思って頂ければ簡単かと思います。

以下の様な感じで利用します。


renderer.ts


import { desktopCapturer } from "electron";

let mediaStream: MediaStream | null = null;

desktopCapturer.getSources({ types: ["screen"] }).then(async sources => {
try {
// 何故か Electron 5.0.2 現在、型定義が間違っているので仕方なく as unknown as DesktopCapturerSource[] している
for (const source of (sources as unknown) as DesktopCapturerSource[]) {
if (source.name === 'Electron') { // マルチスクリーン対応をするとここを少し工夫する必要が出てくる
const stream = await navigator.mediaDevices.getUserMedia({
audio: false, // 今回はオーディオをキャプチャしない
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: source.id
}
} as any // ここも同じような理由で as any
});
mediaStream = stream;
}
}
} catch (e) {
console.error(e);
}
});


こうすることで、デスクトップ全体をキャプチャしている仮想カメラのストリームを取得することが出来ます。

このストリームを、 HTMLVideoElementsrcObject に突っ込みます。

const video = getElementById("videoElem") as HTMLVideoElement;

video.srcObject = mediaStream;
video.onloadedmetadata = () => video.play();

こうすることで、指定した video 要素にデスクトップ全体を映すことが出来ます。

しかし、ここまでだけでは画面全体が取得出来てしまうので、今回のように範囲を指定してデスクトップをキャプチャするには、もう一つ工夫する必要があります。

最も単純な方法は「一定間隔で video の画面を canvas へコピーする」という方法です。

canvas には drawImage という、img要素やvideo要素などを渡すと、その画面を canvas にコピーすることが出来るというとても便利なメソッドが存在します。

drawImage の TypeScript の型定義はこんな感じになっていました。


renderer.ts

type CanvasImageSource = HTMLOrSVGImageElement | HTMLVideoElement | HTMLCanvasElement | ImageBitmap | OffscreenCanvas;

interface CanvasDrawImage {
drawImage(image: CanvasImageSource, dx: number, dy: number): void;
drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
drawImage(image: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
}


今回は setInterval を使ってこんな感じにします。


renderer.ts

window.setInterval(() => {

const canvas = document.getElementById("desktopMirroredCanvas");
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(
video,
left, // video の切り取りたい矩形の一番左のx座標
top, // video の切り取りたい矩形の一番左のy座標
width, // video の切り取りたい矩形の横幅
height, // video の切り取りたい矩形の縦幅
0, // 切り取った画像を描画するx座標
0, // 切り取った画像を描画するy座標
width, // 切り取った画像を描画する横幅
height // 切り取った画像を描画する縦幅
);
}
}, 1000 / 15) // フレームレート 例えば15fpsぐらいにしておく

これで、デスクトップの指定した座標を切り取ることが出来ました。

次の項で、ここで得られた画像を保存する方法を紹介します。


MediaRecorder API

今回の目玉その2です。

これを使うには、まず MediaRecorder クラスを利用します。


renderer.ts

const steam = canvas.captureStream(); // なんと都合のいいことに、 canvas から MediaStream が取得できる!

const recorder = new MediaRecorder(stream, { // ここに渡す
mimeType: "video/webm;codecs=H264",
audioBitsPerSecond: 0, // 今回は音声に対応しないので0でOK
videoBitsPerSecond: 2500 * 1024 // 2.5Mbpsぐらいにしておく
});

// 録画が終わると dataavailable イベントが飛んでくる
recorder.addEventListener("dataavailable", async e => {
const a = document.getElementById("download") as HTMLAnchorElement;
a.href = window.URL.createObjectURL(e.data);
console.log("download available!");
});

recorder.start();

setTimeout(() => recorder.stop(), 5000); // 例えば5秒後に stop() を呼ぶ


なんと、これだけで保存が出来るようになります、素晴らしい。


ffmpeg

ところでさっきのは例なのでそう書きましたが、実際のアプリでは a.href = window.URL.createObjectURL(e.data); なんてしたくないので、 base64 に変換してIPC通信でmainプロセスに送りつけるということをしています。

さて、ここで得られるバイナリデータは実際には WebM という形式なので、そのままでは直接 Twitter に投稿できません!これでは承認欲求が手軽に得られず大変ストレスフルなので、出来ればもっと一般的な形式に変換したいものです。

というわけで、今回は node.js 上から ffmpeg を使うためのラッパとして fluent-ffmpeg を使いました。

こんな感じで使えます、お手軽。


main.ts

import ffmpeg from "fluent-ffmpeg";

ffmpeg("input.webm")
.videoCodec("libx264")
.addOption("-pix_fmt", "yuv420p") // 色空間は YUV420p じゃないと Twitter に投稿できない(ココ重要)
.output("/path/to/video.mp4"); // .output() しないとファイルが出てこない(これで30分ハマった…)



終わり

所感としては、ウェブブラウザの技術だけでここまで色んなことが出来ることに感動しました。

Electron も Chromium も偉大です。素晴らしい。

今後、オーディオもキャプチャ出来るようにしたり色々アップデートもしていきたいと思っています。

あと、今回は張り切ってロゴも作ってみました。

初Inkscapeです。

xxx.png

Take a screenshot. で です。安直。