🧊

動画切り出しアプリをElectronで作った

in 弊社の開発合宿。

発表資料こちら。

動画切り出しアプリをElectronで作った

ここに載せてない細かいことを、忘れないようにメモっておきます。

作ったもの

GitHub - leader22/movie-slicer: Slice your favorite scenes from movie file.

動画ファイルから、任意の秒数を切り出して、新たに動画ファイルを作成するアプリです。

別途インストールされてる前提の`ffmpeg`をElectronから呼び出してるので、いわば豪華な`ffmpeg`ラッパーです。

完全に自分用で作ったので、用途としてはいわゆる`mp4`を`mp4`に切り出すだけに限定してます。
他のフォーマットも`ffmpeg`が対応してれば対応できると思うけど、手元に素材もなかったのでやってません。

Electronを使っててパッケージングもちゃんとやったので、今も手元で元気にアプリとして動いてます。

というわけで、せっかくなのでElectron開発Tipsをいくつかメモっておく。

Mainプロセス

スペース入りのパス

ElectronというかNode.jsでファイルパスを扱うときの問題かな?
`ffmpeg`を`child_process.exec`するときにハマった。

たとえば、`/Users/leader22/Desktop/Sample directory/テスト.mp4`みたく、パスの中にスペースが含まれる時。

const path = '/Users/leader22/Desktop/Sample directory/テスト.mp4';
exec(`ffprobe -i ${path}`, (err, stdout, stderr) => {});

これだとダメ。

const path = '/Users/leader22/Desktop/Sample directory/テスト.mp4';
exec(`ffprobe -i "${path}"`, (err, stdout, stderr) => {});

手っ取り早いのは、`"`で囲っちゃう。
こうするとエスケープがいい感じに回避できる。

ipcMain経由でchild_process.exec

Rendererプロセスとやり取りする時に使うコレで、シェルを叩いて結果を得る一連の流れ。

ipcMain.on(evName, ({ sender }, arg) => {
  const cmd = getCommandByEvName(evName, arg);
  exec(cmd, (err, stdout, stderr) => {
    if (err || stderr) {
      return sender.send(`${evName}:result`, {
        type: 'err',
        payload: err || stderr,
      });
    }

    return sender.send(`${evName}:result`, {
      type: 'ok',
      payload: stdout,
    });
  });
});

てな感じの関数を用意しておいて、コマンドを実行して結果を返すまでを一連の流れにしておくと便利だった。
Renderer側の流れは後述。

menuのクセ: submenu編

どうせElectronでアプリ作るなら、メニューもちゃんと作りたい。
というわけであれこれ眺めてたけど、この`Menu`のテンプレートの書式のクセを把握するのに時間がかかった。

// このmenuTemplateがクセもの
const menu = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menu);

オブジェクトの配列で構成されてて、`label`と`submenu`が必要。

const menuTemplate = [
  {
    label: 'メニュー1',
    submenu: [],
  },
  {
    label: 'メニュー2',
    submenu: [
      {
        label: 'メニュー2-1',
      }
    ],
  },
  // ...
];

で、このように独自で`label`を指定する他にも、macの基本的なメニュー機能を拝借できる便利なのがある。

例えばこういうの。

const menuTemplate = [
  {
    label: app.getName(),
    submenu: [
      {role: 'about'},
      {type: 'separator'},
      {role: 'quit'}
    ],
  },

  // ...
];

`role: 'about'`で、「このアプリについて」が出るようになるし、`role: 'quit'`で「終了」が使えるようになる。
しかもキーボードショートカット付きで。

`type: 'separator'`はその名の通り、仕切り線が出せる。

もちろんメニュー出すだけじゃ意味なくて、選択された時にハンドラを指定することになるけどそれと併せてやりたいことがあるので次へ。

menuのクセ: キーボードショートカット編

Electronでキーボードショートカット対応するには2パターンやり方がある。

  • Rendererプロセス側で、`addEventListener('keydown')`とかする
  • Mainプロセス側で、メニュー経由で仕込む

個人的には全部Main側に寄せたほうが良いかなーと思った。アプリなので。

実装はめっちゃ簡単で、さっきのメニューのテンプレにそれ用の記述を足すだけ。

const menuTemplate = [
  {
    label: app.getName(),
    submenu: [
      {role: 'about'},
      {
        label: 'Preferences...',
        click() { win.webContens.send('shortcut:openSettings'); },
        accelerator: 'CommandOrControl+,',
      },
      {type: 'separator'},
      {role: 'quit'}
    ],
  },

  // ...
];

`accelerator`と`click`がソレ。
キーボードショートカットいらないなら`click`だけでいい。

さてさてキーボードショートカットが押されたことをRenderer側に伝えるためにイベントを通知したいけど、`ipcMain.send()`なんてメソッドは存在しない。
なのでRendererにメッセージを送りたい場合は、`webContents.send()`する必要がある。

本当は、Renderer側の状態にあわせてファイルメニュー自体を非活性にしたりするべき。
今回はそれに気付いてなくて、Main -> Rendererの一方向的な実装にしちゃったのが少し反省点。

Rendererプロセス

onloadを待たないと何も出ない

window.addEventListener('load', () => {});

普段`body`の最後に`script`タグを書いてる身としては、コレいらんやろ的な気持ちで省いてたけど、Electronだと必要だった。

ipcRenderer.once -> send

先述した`ipcMain`へイベントを送るための仕組み。

export function execCommand(name, options) {
  return new Promise((resolve, reject) => {
    ipcRenderer.once(`${name}:result`, (_ev, { type, payload }) => {
      if (type === 'err') {
        return reject(payload);
      }
      resolve(payload);
    });

    ipcRenderer.send(name, options);
  });
}

`once`で一度きり実行結果を待つようにして、`ipcRenderer.send()`する流れを`Promise`でラップしておく。

すると、シェルの実行を`await`できてコードの見通しが大変よろしい感じになった。

開発環境

sindresorhus/electron-reloader が惜しい

if (process.env.NODE_ENV === 'development') {
  try {
    require('electron-reloader')(module, {
      ignore: [`${__dirname}/src/renderer`],
    });
  } catch (err) {
    err;
  }
}

根っこのモジュールで呼んでおくと、依存ファイルに変更があった場合によしなにしてくれる。

  • Renderer関連の場合はリロード
  • Main関連の場合はアプリごと再起動

という感じで最高。

裏で`webpack -w`しておけば、Renderer関連はバンドルされたファイルが吐かれる度にリロードしてくれる。
元ソースは無視するように指定しておけばいい。最高。

ただ一つ惜しい点を上げると、Main関連に変更があってアプリが再起動すると、そのプロセスが行方不明になるところ・・。(うちの環境だけ?)

なのでMainプロセス側をデバッグする時に`console.log`とかしても、再起動されるとプロセスがどっか行って見えなくなって困るw
結局Mainプロセスのデバッグ時には手動でアプリを再起動してた・・。

webpack

`target`に`electron-renderer`を指定しないと、`ipcRenderer`とかそういうモジュールがバンドルされなくて色々困る。
むしろコレさえ指定すれば、他はまったく普段の開発と同じにできるので、他に何も必要なくなる。

`electron-main`も指定できるけど、バンドルしたところでなぁって感じ。

electron-userland/electron-packager

ほんと便利。
アプリにした時のサイズを減らすために、`ignore`の指定をきちんとするのが面倒なくらい。

electron-packager ./ MovieSlicer --platform=darwin --arch=x64 --icon=./assets/app_icon/MovieSlicer.icns --overwrite --ignore=\"^/node_modules\" --ignore=\"^/assets\" --ignore=\"^/src/renderer\"

`node_modules`は、デフォルトで`devDeps`にあるのだけは無視してくれるっぽい。
ただ今回はRendererプロセスで使ってるやつはwepackでビルドするので、`deps`も不要なため無視するように。

Mainで使ってるやつまで無視されたら困るのでは?と最初思ったけど、パッケージ化の際によしなにしてくれてるぽく、`node_modules`自体いらない感じになった。

総じて

Electron良かった。
特にハマりどころもなく、割と思い通りに作りたいものを作れたので不満はないかなーという感じ。

また違うものを作ったときに違う不満が出て来ることはあると思うけど・・。(Windowsでも動くようにとか)