[Electron] #05 Frameshot — 固定サイズの枠でWebページを撮る。静止画・動画・音声をゼロから作って、3 OSで配布するまで

[Electron] #05 Frameshot — 固定サイズの枠でWebページを撮る。静止画・動画・音声をゼロから作って、3 OSで配布するまで

はじめに

Electron の実装記事の #05 です。#01〜#04 は、自作の Live2D デスクトップマスコットの記録でした。今回は別のアプリで、撮影ツールを作ります。

きっかけは、lain-lab.com の過去作品ギャラリーです。作品ごとに縦長のサムネと短い動画を並べたくて、撮影のための道具が要りました。ところが、道具のほうで行き詰まって、ギャラリーの作業を止めて、先にそのツールを作ることにしました。

名前は Frameshot です。できたものは次のとおりです。

  • URL を入れると、そのページが固定サイズの枠の中に出る。枠の大きさは数字かプリセットで決める
  • 静止画は PNG / JPG / WebP。倍率は 1x / 2x / 3x。クリップボードにもコピーできる
  • 動画は MP4(等倍)。枠が画面よりも大きくても、全体が録れる
  • 枠のページの音声も録れる(設定で ON / OFF)
  • ブックマーク、最近開いた URL、自分用のサイズプリセット
  • Windows・macOS・Linux 向けに配布(Mac と Linux は GitHub Actions でビルド)

設計をその日に決めて、同じ日のうちに v1.0.0 を公開しました。そのあとの確認で見つけたことを v1.0.1 にまとめています。この記事は、その間に分かったことの記録です。

スクリーンショット

Frameshot の画面。URL バー、サイズ・倍率・形式の設定、枠、撮影ボタン

動画の設定メニュー

動画の設定メニュー。フレームレート、画質、最大時間、枠の音声を録る

録画中の画面。枠の見える範囲だけが表示

録画中の画面。枠の見える範囲だけが表示される

リポジトリ・ダウンロード


1. なぜ作ったか:ブラウザ版の撮影ツールの限界

撮影ツールは、すでに1つ作っていました。ブラウザで動く capture-station(Region Capture 方式)です。実績もありました。枠 880×1320 で静止画を撮り、15 秒の動画(エンコードで 14MB が約 1MB)も作れました。

その代わり、使っていて限界が2つ見えました。

限界1:外部サイトを枠に入れられない

枠の中にページを出す方法は iframe ですが、相手サイトが X-Frame-Options や CSP の frame-ancestors で埋め込みを拒否していると、何も出ません。CORS の問題ではなく、埋め込みを許していないサイトの仕様です。撮りたいのは自分のサイトだけではないので、困ります。

限界2:縦長の枠は下が切れる

getDisplayMedia と cropTo で録画する方式は、「タブに今見えている範囲」を録っているだけです。枠がブラウザの表示領域からはみ出すと、はみ出した分は、映像の中に最初から存在しません。モニターの高さが、枠の高さの実質的な上限になっていました。縦長のサムネを撮りたいのに、これでは致命的です。

既存のアプリ

画面を録るタイプのツールは、画面に映っている範囲しか録れないので、2つ目の限界が同じように残ります。固定サイズの枠に、外部サイトを入れて、はみ出す縦長も欠けずに撮り、静止画と音声つきの動画を出す、という条件で探しましたが、調べた範囲では見つかりませんでした。

どちらの問題も「ブラウザの外」に出れば解決します。ページ自体を、自分でレンダリングのサイズを決めて撮れる土台が要るので、Electron にしました。

最初は C# の WPF と WebView2 も考えました。ところが、WebView2 も Windows Graphics Capture も Windows 専用で、Mac と Linux に出すときに作り直しになります。Electron なら、Windows で動いたものが他の OS でもほぼそのまま動くことは、マスコットで確かめてあります。


2. 構成:マスコットの作りを流用する

作り方は、live2d-desktop-mascot に揃えました。

  • 言語はプレーンな JavaScript(CommonJS)。ビルド工程は置かない
  • 型は JSDoc と // @ts-check で書き、npm run check(tsc --noEmit)で検査する
  • パッケージは electron-builder
  • 設定は userData の JSON に保存する
src/main/      main.js(ウィンドウ・IPC・メニュー)/ frame.js(枠)/ capture.js(静止画)
               record.js(動画)/ audio.js(音声)/ ffmpeg.js / output.js / store.js
src/preload/   preload.js(ツールバー UI への窓口)
src/renderer/  ツールバー UI とスタートページ
src/shared/    設定・URL・ファイル名・動画の引数など、Electron に依存しない純粋なロジック
test/          単体テスト(node --test)と、実 Chromium での検査

src/shared/ に純粋なロジックを集めたのは、テストのためです。ファイル名のテンプレート、URL の検査、ffmpeg に渡す引数、「録画開始から何ミリ秒で何フレーム書き終えているべきか」といった計算は、Electron も ffmpeg も起動せずに node --test で確かめられます。

マスコットと違えたところが1つあります。セキュリティです。

マスコットはローカルのファイルしか読み込まないので、nodeIntegration: true と contextIsolation: false で動いています。Frameshot は外部サイトを開くので、同じにしてはいけません。

// src/main/frame.js(抜粋)
// 外部サイトを開くので、Node も preload も与えず、サンドボックスで隔離する
this.view = new WebContentsView({
  webPreferences: {
    partition: opts.partition,
    contextIsolation: true,
    nodeIntegration: false,
    sandbox: true,
    webSecurity: true,
    spellcheck: false,
    // 隠れていても描画を止めない(撮影のため)
    backgroundThrottling: false,
  },
});

外部サイトを載せる側は、Node も preload も一切与えません。IPC を使うのは、ツールバー(自分の UI)だけです。ツールバー側は、サンドボックスの preload で contextBridge から窓口を公開します。


3. 画面に入らない枠を、欠けずに撮る

作るもののいちばん大事な部分です。

撮影サイズと表示を分ける

ページから見えるビューポートの大きさは、CDP(Chrome DevTools Protocol)の Emulation.setDeviceMetricsOverride で、実際のウィンドウとは無関係に指定できます。Electron では webContents.debugger から CDP を使えます。

枠がウィンドウに収まらないときは、表示だけを縮小します。ページのレイアウトは常に撮影サイズで行い、scale で全体を縮めて見せるので、プレビューと撮影結果のレイアウトが一致します。

// src/main/frame.js(抜粋)画面表示用(倍率 1・縮小あり)
await this.send('Emulation.setDeviceMetricsOverride', {
  width: this.width,
  height: this.height,
  deviceScaleFactor: 1,
  mobile: false,
  scale: this.fit,            // ← 表示の縮小率(1 以下)
  screenWidth: this.width,
  screenHeight: this.height,
});

view 自体の大きさも fit に合わせて小さくします。ページは縮んだ状態で、その view にぴったり収まります。

撮るときだけ、実寸に切り替える

撮影の瞬間だけ、scale を外し、deviceScaleFactor を指定の倍率にして、ページを撮影サイズでレンダリングし直します。撮り終えたらプレビューに戻します。

// src/main/frame.js(抜粋)
async runCapture(deviceScaleFactor, fn) {
  if (this.busy) throw new Error('撮影中です');
  this.busy = true;
  try {
    await this.send('Emulation.setDeviceMetricsOverride', {
      width: this.width,
      height: this.height,
      deviceScaleFactor,             // 1 / 2 / 3
      mobile: false,
      screenWidth: this.width,
      screenHeight: this.height,
    });
    await this.settle();             // 描画が落ち着くのを待つ
    return await fn((method, params) => this.send(method, params));
  } finally {
    this.busy = false;
    await this.applyBoundsAndPreview();   // プレビューに戻す
  }
}

画面の表示が撮影の瞬間にだけ一瞬ふくらむように見えるのは、この切り替えのためです。実際に使っていても、「撮影の瞬間に拡大される」と気になりました。撮影用の設定に切り替えて、また戻しているのが見えているだけで、撮れる絵には影響しません。

倍率とは何か

倍率は「ズーム」ではありません。deviceScaleFactor、つまり1 CSS ピクセルを何物理ピクセルで描くかです。880×1320 の枠を 2x で撮ると、レイアウトはそのまま、出力は 1760×2640 の画像になります。ページを拡大して撮るのではなく、同じレイアウトを高い解像度で描き直して撮るので、文字や線がくっきりします。

撮る

撮影そのものは Page.captureScreenshot です。

// src/main/capture.js(抜粋)
const png = await frame.runCapture(scale, async (send) => {
  const r = await send('Page.captureScreenshot', {
    format: 'png',
    fromSurface: true,
    captureBeyondViewport: true,
    clip: { x: 0, y: 0, width, height, scale: 1 },
  });
  return Buffer.from(r.data, 'base64');
});

CDP が返す PNG を、sharp で目的の形式に再エンコードします(JPG は透明を白で埋め、WebP は品質かロスレスを選べます)。クリップボードにコピーするときは、劣化のない PNG のまま渡します。

Electron 44 のクリップボードは、従来の clipboard.writeImage が無くなって、非同期の W3C 形式になっています。

// src/main/clipboard.js
async function copyPng(png) {
  const blob = new Blob([new Uint8Array(png)], { type: 'image/png' });
  await clipboard.write([new ClipboardItem({ 'image/png': blob })]);
}

出力が大きすぎるときは、撮る前に断ります。GPU のテクスチャの上限が片辺 16384px 前後で、WebP はさらに 16383px までだからです。

本当に欠けないか、確かめる

「縦長でも欠けない」は、作る前に想定していた結果です。確かめるために、仮想ディスプレイ(Xvfb)の上で、実際の Chromium を使う検査を書きました。画面の高さ 800px に対して、1536px の枠を 2x で撮り、2048×3072 の全面で、四辺とも欠けていないことを確かめます。枠の四辺に色の帯を置いたテストページを使い、出力の四辺の色を見る方式です。

Windows 実機でも、縦長の枠が下まで撮れていました。


4. 動画:取得方法をスパイクで選ぶ

静止画は CDP でうまくいきました。動画も同じ方向で行けそうでしたが、実装に入る前に、取得方法を比べるスパイク(捨てる前提の実験)を書きました。1280×800 のウィンドウに、画面からはみ出す 880×1320 の枠を置き、方法ごとに四辺の色で欠けを判定します。

方法結果
縮小プレビューのまま Page.startScreencast✕ 縮んだページが左上に入るだけで、下と右が欠ける
実寸の emulation に切り替えて screencast✅ 880×1320 の全面が写る。約 60fps
同上、view を画面の外(x=-4000)に置く✕ フレームが来ない
beginFrameSubscription(実寸 emulation)✅ 全面。ただし 30fps 止まり
オフスクリーン描画(別ウィンドウ)✅ 取れるが、別の webContents になるので不採用
2x の screencast✕ フレームは CSS px サイズのまま。2x の動画は取れない
Page.captureScreenshot の連打(2x)約 10fps。現実的でない

結論は3つでした。

  1. 実寸の emulation に切り替えれば、枠が画面からはみ出していても、screencast は全面を返す。view は小さいままでいい
  2. 動画は 1x のみ。2x の動画は取れない
  3. 録画中は emulation が実寸になるので、画面の表示は縮小ではなく「左上の切り抜き」になる

この3つ目が、画面の見え方に響きます。録画中は、枠のうち見える範囲だけが表示されて、録画は全体を録っている、という作りになりました。録画中は、サイズと倍率の変更と、静止画の撮影を受け付けません。

最新フレームを、一定の間隔で ffmpeg に流す

screencast のフレームは、ページが描き直されたときにだけ届きます。静止したページでは、最初の1枚しか来ません。そこで、届いた最新の1枚を持っておき、fps の間隔のタイマーで ffmpeg の stdin に書く形にしました。

// src/main/record.js(抜粋)
this.onMessage = (_e, method, params) => {
  if (method !== 'Page.screencastFrame') return;
  this.latestData = params.data;
  this.dirty = true;
  // 受け取ったらすぐ返す(返さないと次のフレームが来ない)
  dbg.sendCommand('Page.screencastFrameAck', { sessionId: params.sessionId }).catch(() => {});
};

// タイマーのたびに、書き終えているべき枚数まで最新フレームを書く
const due = dueFrames(elapsed, o.fps);
while (this.written < due) {
  this.written++;
  if (!stdin.write(bmp)) await drain;   // ffmpeg が遅れたら待つ
}
// src/shared/video.js(抜粋)
// 録画開始から elapsedMs 経った時点で、書き終えているべきフレーム数(最初の 1 枚は 0ms ちょうど)
function dueFrames(elapsedMs, fps) {
  return Math.floor((Math.max(0, elapsedMs) * fps) / 1000) + 1;
}

タイマーが多少遅れても、「経過時間から数えて、何枚目まで書くべきか」で決めるので、遅れた分は次の周期で追いつきます。止めるときは、録画時間ぴったりの総フレーム数になるように、足りない分を最後のフレームで埋めます。5 秒の録画なら、動画は 5.000 秒になります。静止ページでも同じです。

ffmpeg には、生のフレーム(BGRA)を stdin から渡します。

// src/shared/video.js(抜粋)
return [
  '-f', 'rawvideo', '-pix_fmt', 'bgra',
  '-s', `${o.width}x${o.height}`, '-r', String(o.fps),
  '-i', '-',
  '-vf', `crop=${out.width}:${out.height}:0:0`,   // H.264 は偶数サイズが必要
  '-an',
  '-c:v', 'libx264', '-crf', String(q.crf), '-preset', 'veryfast',
  '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
  '-f', 'mp4', o.outFile,
];

出力の書きかけは .mp4.part に書き、成功したら改名します。失敗や中断のときは残しません。画質は crf で、軽量(28)、標準(23)、高画質(18)の3つです。フレームレートは 10 / 15 / 24 / 30 から選びます。

最大録画時間(既定 2 分)で自動停止します。録画中にウィンドウを閉じようとしたら、録画を仕上げてから閉じます。


5. ffmpeg を同梱する

ffmpeg は別プロセスで起動します。同梱するか、初回起動時にダウンロードするか、利用者に入れてもらうかを考えて、同梱にしました。ダウンロードすると、オフラインで動かず、初回だけ失敗する原因が増えます。利用者に入れてもらうのは、ツールの敷居が上がります。

ffmpeg-static を依存に入れると、プラットフォームごとのバイナリが入ります。ただし、そのままだと electron-builder が asar の中に包んでしまい、実行ファイルとして起動できません。asarUnpack で展開させます。

"asarUnpack": [
  "node_modules/sharp/**/*",
  "node_modules/@img/**/*",
  "node_modules/ffmpeg-static/**/*"
]

sharp も、ネイティブのバイナリを持つので同じ指定が要ります。

ffmpeg の場所は、次の順で探します。

  1. 設定の ffmpegPath(自分で入れた ffmpeg を使いたい人用)
  2. 同梱のもの(パッケージ版では app.asar.unpacked 側)
  3. PATH 上の ffmpeg

ライセンスは、ffmpeg-static が配る ffmpeg が GPL のビルドです。別のプロセスとして起動するだけなので、Frameshot 本体まで GPL にする必要はない、というのが一般的な整理です(法的な助言ではありません)。THIRD_PARTY_LICENSES.md に、ffmpeg のライセンス文とソースの入手先を書いて、配布物に入れています。本体は MIT です。


6. 音声:枠のページの音だけを録る

動画に音を入れるかどうかは、最初は後回しにしていました。ところが「音は取れないよりは取れたほうが良い」ので、設定の ON / OFF として入れました。

システム全体の音は録らない

録りたいのは、枠のページの音です。デスクトップ全体の音(Windows の loopback)を録ると、他のアプリの音や通知音まで混ざります。OBS などはこの方式ですが、loopback が使えるのは Windows だけです。

Electron には、枠の webContents の音声だけを getDisplayMedia に渡す仕組みがあります。メインプロセスで setDisplayMediaRequestHandler を使うと、要求に対して「どのフレームの映像・音声を返すか」を決められます。

// src/main/audio.js(抜粋)
ses.setDisplayMediaRequestHandler((request, callback) => {
  const toolbar = getToolbar();
  const page = getPage();
  const from = request.frame ? webContents.fromFrame(request.frame) : null;
  // ツールバー以外からの要求(外部サイトなど)には、何も渡さない
  if (!toolbar || !page || page.isDestroyed() || from !== toolbar) {
    callback({});
    return;
  }
  // 録音中も、枠の音はそのまま聞こえるようにする(enableLocalEcho)
  callback({ video: page.mainFrame, audio: page.mainFrame, enableLocalEcho: true });
});

外部サイトのページ自身が getDisplayMedia を呼んでも、何も渡しません。渡すのは、ツールバーからの要求のときだけです。

録るのは、ツールバー UI 側の MediaRecorder です。映像のトラックは要らないので、受け取ってすぐに止めて捨てます(残すと、ずっとページの映像を取り続けます)。

getDisplayMedia はユーザー操作が要るので、メインプロセスから executeJavaScript(..., true)(userGesture を true)で呼びます。こうしておくと、ショートカットキーで録画を始めたときも動きます。

「音が変」の原因:3つの音声処理とモノラル

最初に音を録ったとき、「録れたけど、なんか変」と言われました。どう変なのか、言葉にしにくい音でした。最初に疑ったのはフレームレートと音質でしたが、どちらも違いました。

原因は、getDisplayMedia の既定の設定です。マイク向けの処理が掛かっていました。

  • エコーキャンセル(echoCancellation)
  • ノイズ抑制(noiseSuppression)
  • 自動音量調整(autoGainControl)
  • しかも、既定でモノラル

音楽や効果音が、声のように処理されて、削られたり、音量が勝手に上下したりしていました。制約で全部切って、ステレオにします。

// src/renderer/audio-capture.js(抜粋)
const stream = await navigator.mediaDevices.getDisplayMedia({
  video: true,
  audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 2 },
});

これで直りました。マイクの音を想定した既定値が、ページの音にも掛かっていた、という話です。

映像と音声を結合する

音声は別の一時ファイル(webm / opus、192kbps)に録っておき、止めたときに ffmpeg で映像と結合します。映像はそのままコピーして、音声だけを AAC にします。

// src/shared/video.js(抜粋)
return [
  '-i', o.videoFile,
  ...audioIn,                       // 開始時刻のずれの補正(-itsoffset か -ss)
  '-i', o.audioFile,
  '-map', '0:v:0', '-map', '1:a:0',
  '-c:v', 'copy',
  '-c:a', 'aac', '-b:a', '192k',
  '-af', 'apad',
  '-t', durationSec.toFixed(3),     // 映像の長さで終わる
  '-movflags', '+faststart',
  '-f', 'mp4', o.outFile,
];

録音と録画は、開始の時刻が少しずれます。音声が遅れて始まったら -itsoffset、先に始まっていたら -ss で補正します。

最初はこの引数に -shortest を使っていました。音声が映像より短いときに、映像の長さで切るためです。ところが、-c:v copy と -af apad と -shortest を組み合わせると、ffmpeg が終わらなくなります。実際にテストが止まりました。apad は無限に無音を足す指定で、映像をコピーしているとき、-shortest が止めるきっかけを見つけられないからです。映像の長さを自分で渡して -t で切る形に変えました。

それでも万一止まるときのために、結合の ffmpeg には 60 秒の時間切れを付けて、強制終了します。しかも、SIGTERM は ffmpeg に無視されることがあるので、SIGKILL で止めます。

音声が録れなかった、結合できなかったときは、録画自体は失敗にしません。映像だけを保存して、ステータスバーに警告を出します。


7. 自動で確かめてから、実機で確かめる

AI(Claude)にコードを書いてもらい、ZIP で受け取って、手元で試す流れで作りました。受け取る前に、AI の作業環境で、実際の Chromium と同梱の ffmpeg を使って自動検査をしています。ここは、マスコットのときと同じやり方です。

  • 単体テスト 23 件(ファイル名、URL、動画の引数、フレーム数の計算など)
  • 実 Chromium での検査(Xvfb 上)
    • 撮影:四辺の色で、欠けがないことを確かめる(1x / 2x、PNG / JPG / WebP)
    • 録画:四辺が全部写る、長さが録画時間どおり、中身が動く、静止ページ、奇数サイズ、すぐ止める、最大時間で自動停止、ffmpeg が死んだとき、録画後にプレビューに戻る
    • 音声:鳴っている枠の音が入る(440Hz のテスト音を左チャンネルだけに鳴らして、ステレオを確かめる)、無音でも失敗しない、音声を始められないときは映像だけ保存して警告
  • 実際のアプリを CDP でボタン操作する通し検査(⏺ → 録画中の表示 → ⏹ → 保存 → そのあと 📷)
  • パッケージ版(Linux の electron-builder --linux dir)を起動して、同じ通し検査

仮想ディスプレイ(ソフトウェア描画)では、GPU が有効な実機とは違う動きをするかもしれません。そこで、「画面からはみ出す枠の録画が、全面を返すか」「音が本当に入っているか」は、Windows 実機で確かめました。

検査では確かめられないこともあります。動画の設定の ▾ ボタンは、HTML のポップアップではなく、ネイティブのメニュー(Menu)にしました。ネイティブの WebContentsView は、HTML の要素より前面に出るので、ツールバーの HTML 側でポップアップを作ると、枠の下に潜ってしまうからです。


8. 配布

アイコン

アイコンは自分で作りました。ここで1つ、ひっかかりました。Windows の .ico は、256〜16px まで複数のサイズを入れる必要があります。ロゴに文字が入っていると、48px 以下では潰れて読めません。48px 以下だけ、文字なしの絵にしたものを入れました。macOS は、周りに余白を付けた別の PNG にしています。そうしないと、他のアプリのアイコンより大きく見えます。

署名なし

コード署名は、無料のツールには重いので、していません。Windows は SmartScreen の「発行元不明」、macOS は Gatekeeper の警告が出ます。README に、開き方を書きました。

  • Windows:「詳細情報」→「実行」
  • macOS:右クリック →「開く」

v0.1.0 ではなく v1.0.0

最初に作った package.json のバージョンは 0.1.0 でした。ただ、動くものを、Windows で確認して出すのだから、1.0.0 でいい、と自分で決めました。バグがあれば、直して修正版を出せばいい、という割り切りです。オープンソースなので、気になる人は自分で直して使ってもらってもかまいません。

小さなつまずき

  • リポジトリの .history/ が、そのままコミットされていました。VS Code の拡張が作る、ローカル履歴のフォルダです。git rm -r --cached .history で追跡から外して、.gitignore に足しました。過去のコミットには残っていますが、そのままにしました
  • README の表が崩れました。Markdown の表は、前に空行がないと表として扱われません

9. Mac と Linux:GitHub Actions でビルドする

Electron のアプリは、各 OS の上でしかビルドできません。マスコットでは、3台のマシンで同時にビルドして、Actions は見送りました。Cubism Core とサンプルモデルが再配布できないので、毎回どこかから取ってくる仕組みが要るからです。

Frameshot には、その制約がありません。リポジトリを clone して npm ci で入る依存だけで、全部そろいます。それで、今回は GitHub Actions を使いました。

# .github/workflows/build.yml(抜粋)
on:
  workflow_dispatch:
    inputs:
      tag:
        description: "アップ先のリリースタグ(例: v1.0.0。空なら成果物だけ作る)"

jobs:
  build:
    strategy:
      matrix:
        include:
          - { os: macos-13,      args: "--mac dmg zip --x64",   name: mac-intel }
          - { os: macos-latest,  args: "--mac dmg zip --arm64", name: mac-arm64 }
          - { os: ubuntu-latest, args: "--linux AppImage deb",  name: linux }
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npm run check
      - run: npx electron-builder ${{ matrix.args }} --publish never
      - if: ${{ inputs.tag != '' }}
        run: gh release upload "${{ inputs.tag }}" dist/*.dmg dist/*.AppImage dist/*.deb --clobber

自動で push ごとに走らせず、手動(workflow_dispatch)で、リリースのタグを入れたときだけ走らせます。リリースの画面で下書きを作っておき、タグを指定して実行すると、ビルドした dmg と AppImage と deb が、同じ下書きに添付されます。

Mac で見つかったこと

ここまで来て、Intel Mac で確かめたことが2つありました。

録画が始まらない

静止画は撮れるのに、動画を始めようとすると、こうなりました。

録画を始められません: 録画のフレームが撮影サイズと違います(1212×71…、想定 1280×720)

画面の下には「表示 95%」と出ていました。枠がウィンドウに収まらないので縮小表示になっている状態です。1212 は、1280 の約 95% です。Windows と Linux では、録画に届くフレームが、切り抜く前の全体(1280×720)の大きさで来ていました。Mac では、画面に見えている大きさ(切り抜き後)で来ているようです。

ウィンドウを大きくして、枠が全部入る(表示 100%)ようにすると、録画できました。つまり、Mac では、枠がウィンドウに収まらないと録画が始まらない、ということです。原因の細かいところ(Mac の描画が、画面の見えている大きさでフレームを作る理由)は、手元に Mac が Intel の1台しかなく、特定できていません。直すのは、v1.0.1 では見送りました。README に注意書きを足して、収まる大きさで録画してもらうことにしています。

重いページでカクつく

Intel Mac(MacBook Air 2020)で、Three.js を使った重いページを録画すると、カクつきました。ファンも全開で回りました。

最初に疑ったのは、エンコードの設定です。画質の「軽量」は crf を上げているだけで、エンコードの重さ(preset)は変わりません。コードを見ると、-preset medium のままでした。録画のようにリアルタイムで書き込む用途には重いので、veryfast に変えました。多少マシになった程度でした。

それでも、重いページは、Windows でもカクつきます。軽いページなら、問題なく録れます。ページの描画とエンコードを、同じ PC で同時にやるので、ページ自体が重ければ、映像が間に合いません。ここは、ツールの限界として、README に書くことにしました。

間に合わないのがページの描画なのか、エンコードなのかを切り分けるために、録画が終わったときに、ページから実際に届いたフレームの数を数えて、指定の fps の 8 割に満たなければ、ステータスバーに警告を出すようにしました。

// src/main/main.js(抜粋)
if (r.received != null && secs >= 2 && fps > 0 && r.received / secs < fps * 0.8) {
  warnText = `ページの描画が追いつかず、映像は約 ${Math.round(r.received / secs)}fps でしか届きませんでした(カクつきの原因)`;
}

Linux は軽かった

Debian 12(XFCE、リモートデスクトップ越し)では、YouTube の再生中のページが、カクつきなしで、音つきで録れました。Mac より動作が軽くて、少し笑いました。ただし、Linux 側は YouTube の画質を最低まで落としていたので、同じ条件の比較ではありません。

Apple Silicon の Mac なら、おそらく問題ないはずです。Intel の Mac は、買い替えを考える材料になりました。


まとめ

  • ブラウザの getDisplayMedia は、画面に見えている範囲しか録れない。画面からはみ出す枠を撮るなら、ブラウザの外に出て、ページ自体をレンダリングする。
  • Electron では、webContents.debugger(CDP)の Emulation.setDeviceMetricsOverride で、ビューポートの大きさと倍率を、ウィンドウと無関係に決められる。表示は scale で縮め、撮るときだけ実寸にする。
  • 倍率は deviceScaleFactor。ズームではなく、同じレイアウトを高い解像度で描き直す。
  • 動画の取得方式は、作る前にスパイクで比べる。実寸の emulation に切り替えれば、はみ出す枠も screencast で全面が取れる。2x の動画は取れない。
  • screencast は静止ページで1枚しか来ない。最新のフレームを一定間隔で ffmpeg の stdin に流し、「経過時間から数えて書き終えているべき枚数」で進めると、長さがぴったり合う。
  • ffmpeg は ffmpeg-static で同梱し、asarUnpack で展開する。sharp も同じ。
  • 枠のページの音だけを録るなら、setDisplayMediaRequestHandler で audio: page.mainFrame を返す。システム全体の音(loopback)と違い、他のアプリの音が混ざらず、Windows 以外でも使える見込み。
  • getDisplayMedia の既定の音声は、エコー除去・ノイズ抑制・自動音量とモノラルが掛かる。ページの音には、全部切ってステレオにする。
  • 映像をコピーするとき、-af apad と -shortest は組み合わせない。終わらなくなる。長さは -t で渡す。
  • 外部サイトを開くアプリは、Node も preload も与えず、サンドボックスで隔離する。自分の UI だけが IPC を使う。
  • ネイティブの WebContentsView は HTML より前面に出る。ポップアップは、HTML ではなくネイティブのメニューで作る。
  • 仮想ディスプレイ(Xvfb)での自動検査は、実機の代わりにはならないが、出す前に潰せる不具合は多い。GPU の有無や OS による違いは、実機で確かめる。
  • 重いページのカクつきは、ページの描画とエンコードを同じ PC でやる限り避けられない。届いたフレーム数を数えて、原因を切り分けられるようにしておく。
  • 制約のないリポジトリなら、GitHub Actions で Mac と Linux のビルドを手動で回し、リリースの下書きに添付できる。

次は、設定画面と、動画の目標サイズ(「1MB 以下」で画質を自動で決める)に手を付けます。ギャラリーのサムネを撮る、という最初の目的に、いよいよ戻れそうです。

Electron の実装記事は、マスコットも Frameshot も、更新があれば番号を増やして追記していきます。