[Electron] #05 Frameshot — 固定サイズの枠でWebページを撮る。静止画・動画・音声をゼロから作って、3 OSで配布するまで
はじめに
Electron の実装記事の #05 です。#01〜#04 は、自作の Live2D デスクトップマスコットの記録でした。今回は別のアプリで、撮影ツールを作ります。
[Electron] #04 Live2Dデスクトップマスコット
キャラごとの声、右クリックでの個別設定、サムネ付きのモデル一覧
lain-lab.comきっかけは、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 にまとめています。この記事は、その間に分かったことの記録です。
スクリーンショット
動画の設定メニュー
録画中の画面。枠の見える範囲だけが表示
リポジトリ・ダウンロード
fixtan/Frameshot
固定サイズの枠にWebページを入れて、静止画・動画を撮る Electron 製ツール(Windows / macOS / Linux)
github.comRelease v1.0.1 · fixtan/Frameshot
Windows(インストーラー/ポータブル)・macOS(Apple Silicon / Intel)・Linux(AppImage / deb)
github.com1. なぜ作ったか:ブラウザ版の撮影ツールの限界
撮影ツールは、すでに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つでした。
- 実寸の emulation に切り替えれば、枠が画面からはみ出していても、screencast は全面を返す。view は小さいままでいい
- 動画は 1x のみ。2x の動画は取れない
- 録画中は 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 の場所は、次の順で探します。
- 設定の
ffmpegPath(自分で入れた ffmpeg を使いたい人用) - 同梱のもの(パッケージ版では
app.asar.unpacked側) - 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 も、更新があれば番号を増やして追記していきます。