1. Qiita
  2. Items
  3. Electron

デスクトップを録画するアプリを書き直した

  • 20
    Like
  • 0
    Comment

Electron Advent Calendar 2016 19 日のエントリーです。

ちょうど 1 年前にデスクトップを録画するアプリを書いたというエントリーを投稿していました。以前書いたときは、ほぼ Photon を使ってみたいという動機だけでした。

中身

ほぼ変わっていません。desktopCapturer を使って <video> に書いたスクリーンを <canvas> に書き出して Whammy で webm に書き出しています。gifshot でアニメーション gif の書き出しに対応したのが新機能です。 Whammy の作者さんが作っていた jsgif も試してみたのですが、ぼくのやり方が良くなかったのか書き出しにものすごく時間がかかってしまい、gifshot にしました。まあでもそれなりな録画時間であれば書き出しに時間はかかってしまいます(いまさらながら child_process 使えばよかったなと思っています)。

QuickTime Player で録画して ffmpeg とかでアニメーション gif にしたりしていましたが、このアプリ一つでアニメーション gif が作れちゃいます。gifshot すごい。

React

去年はスクリーンの更新を、まあ小さなアプリだから append/removeChild で十分だよな、と自分に言い聞かせてシコシコやっていたのですが、やっぱり面倒。ということで、部分的に React のお世話になっています。ただ、三つしかないコンポーネント単位でファイル分割するのもなあということで、全部一つのファイルに詰め込みました。200 ステップ超えたあたりからちょっと後悔しています。

Flux は使っていません。大規模なアプリでしたら使うかもしれませんが、この程度であれば ipc で Browser/Renderer プロセスをやり取りするだけで OK かなと。

TypeScript

Flow でも良かったのですが、ここ最近お仕事で触っていて TypeScript がとても快感だったのもあり TypeScript にしました。気持ち良い。

型定義は package.json を見れるとわかりますが、入っているのは @types/electron@types/es6-promise@types/node@types/react@types/react-dom@types/webrtc の六つです。Whammy と gifshot は型定義がないので、もちろん any で逃げました。navigator.webkitGetUserMedia も怒られるのですが、let nav = navigator as any; とかして逃しています。any は麻薬や...。

また、(ちゃんと設計しないで)適当に書いているとコンポーネントの props/state がよくわからないうちに膨れ上がったりちゃいますが、interface で定義しておくと IDE(の Lint)に怒られまくるので、考え直すタイミングができてこれは良いなと。

コード

main.ts が Browser プロセスで、Renderer.tsx が Renderer プロセスになります。main.ts は https://github.com/electron/electron-quick-start にあるサンプルとほぼ同じです。TypeScript 化して、ipc のイベントを待ち受けているくらいの変更です。renderer.tsx は前述の通り、React のコンポーネントが三つあります。ツールバー(Toolbar)とサイドバーのスクリーンのリスト(Screen)、そして選択されたスクリーンの描画部分(Video)です。とてもシンプルですね。

各コンポーネントの state はこんな感じで interface を定義しています。FormatTypeCaputureSize は main.ts で使いたいので export しています。

type SizeType = '640 x 480' | '800 x 600' | '1280 x 800' | '1440 x 900' | '1680 x 1050';
export type FormatType = 'webm' | 'gif';

export interface CaptureSize {
  width: number;
  height: number;
}

interface ToolbarState {
  alwaysOnTop: boolean;
  size: SizeType;
  format: FormatType;
}

interface ScreenState {
  captureSources: Electron.DesktopCapturerSource[];
}

interface VideoState {
  isRecord: boolean;
  timer: string;
}

Toolbar クラスはこのアプリの最前面化・録画サイズの変更・ファイル書き出しのフォーマット変更・スクリーンのリフレッシュの四つの機能を持っています。各機能のイベントハンドラは、setState して、ipc でイベントを発火させているだけですね。

Screen クラスは Toolbar からのスクリーンリフレッシュイベントを拾って、electron.desktopCapturer から getSources しています。取得したスクリーンを state にセットすれば、あとは勝手に React がリストの更新を行ってくれるのでとっても楽々ですね。また、選択されたスクリーンを ipc でイベント発火させて Video クラスに渡しています。

const desktopCapturer = electron.desktopCapturer;
desktopCapturer.getSources({
  types: ['window', 'screen']
}, (error, sources) => {
  if (error) {
    return;
  }

  this.setState({
    captureSources: sources
  });
});

Video クラスは Screen クラスから受け取ったスクリーンの描画・ファイルの保存をおこなっています。描画から保存までの流れは、アニメーション gif を追加したくらいで去年書いたものとほぼ同じです。一点だけ、window.URL.createObjectURL したあとの window.URL.revokeObjectURL が抜けていたので追加してあります。録画→保存を繰り返していると、なーんか動作が重くなってくるなあと感じていたのですが、これが原因のようでした。

ファイルの保存は(Electron 的に考えて)本来であれば dialog.showSaveDialog を使って fs.writeFile とかするべきなのかもしれませんが、アンカータグを作っておいて MouseEvent を発火させるのが簡単でお気に入りです。

class Video extends React.Component<any, VideoState> {
  private downloader: HTMLAnchorElement;

  constructor() {
    super();

    this.downloader = document.createElement('a') as HTMLAnchorElement;
  }

  download(blob: Blob, format: FormatType): void {
    let clicker = document.createEvent('MouseEvent') as MouseEvent;
    clicker.initEvent('click', false, true);
    this.downloader.setAttribute('href', window.URL.createObjectURL(blob));
    this.downloader.setAttribute('download', `movie.${format}`);
    this.downloader.dispatchEvent(clicker);
  }
}

まとめ

ちょっとでもいまふうになりました。

  • 大規模になると ipc のイベント名は constants ファイルを作ったりして一括管理すると良いかもです。雑多になりがちですから
  • Photon は結局コピーしたりしているので devDependencies で良かったです
  • 力尽きたのでやっていませんが、ファイル書き出しはプログレスな画面を作るとグッと良くなりますね
  • https://github.com/k0sukey/Rec