[Rust #02] スクショ+録画アプリ「shotkey」を作り、画面を固めて範囲選択、システム音つきの録画まで
はじめに
スクリーンショットは毎日のように撮ります。長く使ってきたキャプチャソフトの使う機能は「撮って、フォルダに保存する」だけだったので、その部分だけを持った軽いものを自作しました。名前は shotkey です。
- ホットキーで撮って、指定のフォルダに保存する
- 撮れるのは、デスクトップ全体 / マウスのあるモニタ / 手前のウィンドウ / 自由サイズの範囲 / 数秒後のタイマー
- 範囲は、画面を先に固めてから選ぶので、右クリックメニューのような「撮ろうとすると消えるもの」も撮れる
- 録画(MP4)もできる。ウィンドウ / モニタ / 範囲。パソコンの音(システム音)も入る
- 録画中は、画面の上に小窓(● REC・経過時間・停止ボタン)が出る。この窓は録画に写らない
編集はしません。編集は、画像ビューアの Lookover の役目にして、shotkey は「撮る」だけにしました。
言語は Rust です。画面(設定とオーバーレイ)だけ Tauri v2 と素のJavaScriptで、撮影と録画はすべてRustです。対象は Windowsだけにしました。
スクリーンショット
リポジトリ:
GitHub - fixtan/shotkey: Windows screenshot & screen recorder, hotkey-driven
ホットキーで撮って保存するだけの、Rust + Tauri v2製のスクショ&録画アプリ。
github.com前回の記事(Rustで作ったアーカイバの話):
[Rust #01] WinRAR置き換えのアーカイバ「Tote」を作り、7z・ISO・パスワード付き書庫まで
WinRARの代わりに使う自作アーカイバ「Tote」を、Rustで作りました。
lain-lab.com1. 構成:計算は core、Windowsの呼び出しは src-tauri
core/ shotkey-core(ライブラリ)。座標の計算、ファイル名の連番、画素の切り出し。OSも画面も知らない
src-tauri/ shotkey(exe)。GDIでの撮影、ホットキー、録画、トレイ、窓
src/ 画面(設定、範囲選択の重ね画面、録画の小窓)。素のHTML / JS
座標の計算(モニタの並び、範囲の切り出し、はみ出しの補正)と、ファイル名の連番は、core にまとめました。Windowsを呼ばない純粋な処理なので、Linuxのサンドボックスでも cargo test が回ります。Windowsでしか確かめられないのは、GDIでの撮影と、録画と、窓の見た目だけです。
撮る流れは、次のとおりです。
ホットキー
→ (ウィンドウなら) 手前のウィンドウの範囲を先に調べる
→ 画面全体 (仮想デスクトップ) を GDI で 1 枚に固める
→ 全体 / モニタ / ウィンドウ: 固めた画素から切り出して保存
→ 範囲: モニタごとに重ね画面を出し、その上でドラッグして選ぶ
撮影は別のスレッドでやります。撮影中(範囲選択中を含む)のホットキーは受けないよう、busy の旗を1つ持っています。
2. 先に画面を「固める」:右クリックメニューを撮る
範囲を選ぶアプリは、ふつう「ホットキー → 画面を暗くして範囲を選ぶ → 撮る」です。ところが、この順だと、右クリックメニューやツールチップは撮れません。範囲を選ぶ窓を出した瞬間に、フォーカスが移って、メニューが消えるからです。
そこで、順番を逆にしました。ホットキーを押した瞬間に、画面全体を1枚の画像として固めてから、その画像を使って範囲を選びます。
固め方は、GDIです。
// 仮想デスクトップ全体 (全モニタをつないだ矩形) を、メモリ上のビットマップへコピーする
let blt = BitBlt(mem, 0, 0, w, h, screen, x, y, SRCCOPY | CAPTUREBLT);
// 上の行から並んだ BGRA で読み出す
let lines = GetDIBits(mem, bmp, 0, h as u32, Some(data.as_mut_ptr() as *mut c_void), &mut bmi, DIB_RGB_COLORS);
CAPTUREBLT を付けるのがポイントです。これを付けないと、半透明のウィンドウ(右クリックメニューの影など)が写りません。
固めたあとは、すべてメモリ上の画素からの切り出しです。全体 / モニタ / ウィンドウは、その場で切り出して保存します。範囲のときだけ、固めた画像を、各モニタの重ね画面へ渡します。
3. 座標はすべて物理ピクセル
複数のモニタで、拡大率(100% / 125% / 150%…)が違うと、座標の扱いが一気にややこしくなります。そこで、座標はすべて物理ピクセルにそろえました。原点は主モニタの左上で、副モニタが左や上にあれば、座標は負になります。
アプリは「モニタごとの DPI 対応」で動きます(Tauriのマニフェストの設定)。これで、Win32から取れる値が、拡大率で誤魔化されません。
範囲選択の重ね画面(次の節)の canvas も、物理ピクセルの大きさで作ります。窓の見かけの大きさとの比で、マウスの位置を換算します。
// canvas の物理ピクセル / 窓の見かけの大きさ = 拡大率
const scale = canvas.width / window.innerWidth;
const px = Math.round(e.clientX * scale);
こうしておくと、拡大率が何であっても、選んだ範囲と、切り出す画素の位置が一致します。
4. 範囲選択:モニタごとに、枠なしの窓を重ねる
範囲選択は、モニタごとに1枚ずつ、枠なし・常に最前面の窓(ov0, ov1…)を出します。窓の中に canvas を置き、固めた画素を描いて、その上をドラッグして選びます。
窓の位置と大きさは、物理ピクセルで指定します。ここに、1つ罠がありました。モニタの拡大率が違うと、窓を動かした直後に、大きさが変わることがあるのです。そこで、「位置 → 大きさ → 位置」の順に設定しています。
let pos = PhysicalPosition::new(m.x, m.y);
let size = PhysicalSize::new(m.w as u32, m.h as u32);
let _ = win.set_position(pos);
let _ = win.set_size(size);
let _ = win.set_position(pos); // 大きさを変えたあとで、もう一度
画素の渡し方も工夫しました。固めた画面は、4Kなら30MB以上あります。JSONにすると重いので、Tauriの tauri::ipc::Response で生のバイト列として渡します。先頭の8バイトに幅と高さ(u32 のリトルエンディアン)、続きがRGBAです。
#[tauri::command]
fn get_snap(m: usize, state: State<AppState>) -> Result<Response, String> {
// ...
let mut out = Vec::with_capacity(8 + part.data.len());
out.extend_from_slice(&(part.w as u32).to_le_bytes());
out.extend_from_slice(&(part.h as u32).to_le_bytes());
out.extend_from_slice(&part.to_rgba());
Ok(Response::new(out))
}
Escキーと右クリックで取り消せます。選び終わったら、そのモニタの左上からの物理ピクセルで、finish_region に範囲が戻ります。
なお、複数のモニタをまたぐ範囲は、まだ選べません(モニタごとの窓なので)。また、複数モニタで重ね画面の位置が合っているかは、まだ実機で確かめていません。
5. ウィンドウは「見えている枠だけ」
手前のウィンドウは、DwmGetWindowAttribute の DWMWA_EXTENDED_FRAME_BOUNDS で範囲を取ります。GetWindowRect だと、Windows 10 / 11 では、見えない影の分だけ大きい値が返るためです。
let ok = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &mut rect as *mut _ as *mut c_void, size_of::<RECT>() as u32);
ウィンドウの撮影も、固めた画面から切り出すので、ほかのウィンドウが手前に重なっていたら、それも写ります。「そのウィンドウだけを、隠れていても撮る」機能ではなく、「いま見えているウィンドウの枠を切り出す」機能です。
6. 保存:連番、PNGの速さ、クリップボード、シャッター音
ファイル名と連番
ファイル名は、設定のテンプレート({n}、{date}、{time})で決めます。連番は、「保存先にある、一番大きい番号 + 1」にしました。件数を数える方式にすると、途中のファイルを消したときに、番号が重なってしまうからです。拡張子(PNG / JPEG)は無視して数えるので、形式を途中で変えても、番号は続きます。
PNGは、速さを優先する
PNGの圧縮は、標準だと遅く、撮ったあとにもたつきを感じました(計測はしていません)。圧縮を Fast、フィルターを Sub にして、速さを優先しました。サイズは少し増えますが、撮ったあとの待ち時間のほうが、使っていて気になります。
撮ったあと、設定でクリップボードにも画像を入れられます(arboard)。
シャッター音
以前使っていたキャプチャソフトの「カシャ」という音が気に入っていたので、音を付けました。最初は MessageBeep で鳴らしましたが、Windowsの**警告音(エラー音)**が鳴り、しかも保存のあとに鳴るので遅れました。
結局、効果音ラボの素材を、WAVにして、exeに埋め込む形にしました。
static SHUTTER: &[u8] = include_bytes!("../assets/shutter.wav");
// SND_MEMORY: メモリ上の WAV を鳴らす
// SND_ASYNC: 鳴らし終わるのを待たない
// SND_NODEFAULT: 失敗したときに、Windowsの警告音を鳴らさない
PlaySoundW(PCWSTR(SHUTTER.as_ptr() as *const u16), None, SND_MEMORY | SND_ASYNC | SND_NODEFAULT);
PlaySoundW はWAVしか鳴らせないので、配布されているMP3から変換して使っています。音を鳴らす処理は、エンコードより前に呼ぶことで、撮った瞬間に鳴るようにしました。効果音ラボの規約は、商用利用が無料でクレジットも不要、アプリの操作音として組み込むのは再配布に当たらない、という内容です。READMEに素材の出典を書いています。
7. ホットキーとトレイ
ホットキーは、tauri-plugin-global-shortcut で登録します。初期値は Ctrl+Alt+1 から Ctrl+Alt+0 までに割り当てました。
| キー | 動作 |
|---|---|
| 1 / 2 / 3 / 4 | 全体 / モニタ / ウィンドウ / 範囲 |
| 5 / 6 / 7 | タイマー:ウィンドウ / モニタ / 全体 |
| 8 / 9 / 0 | 録画:ウィンドウ / モニタ / 範囲 |
ほかのアプリと重なって登録できなかったキーは、設定画面に、理由つきで出します。設定画面のキー入力は、キーの位置(e.code)で名前を作ります。日本語配列でも、英語配列でも、同じ名前になります。
アプリは、窓を閉じてもトレイに残ります。終わるのは、トレイの「終了」からです。二重起動は tauri-plugin-single-instance で防いでいます。
タイマーは、「ウィンドウ」「モニタ」「全体」の3つです。一番使うのは、手前のウィンドウだと思います。メニューを開いてから撮りたいときなどに、数秒待ってから、そのときの手前のウィンドウを撮ります。
8. 録画:windows-capture と H.264
録画は、windows-capture クレートを使いました。WindowsのGraphics Capture APIで画面を取り、Media Foundationのエンコーダーで、MP4にまで書き出してくれます。ffmpegは要りません。
つまずいた所が、いくつかあります。
標準のコーデックは HEVC
標準のコーデックは **HEVC(H.265)**です。MP4に入れても、再生できない環境が多いので、H.264を指定しました。
VideoSettingsBuilder::new(w, h)
.sub_type(VideoSettingsSubType::H264)
.bitrate(self.opts.bitrate)
.frame_rate(self.opts.fps)
エンコーダーは、最初のフレームが来てから作る
動画の大きさは、最初に届いたフレームの大きさで決めます。窓を録るとき、窓の大きさは、始める時点では分からないためです。H.264は、幅と高さが偶数でないといけないので、w & !1 のように切り下げます。
範囲録画は、行の向きが逆
全体(ウィンドウ / モニタ全体)は、GPUのまま send_frame に渡せるので、速く済みます。
範囲だけを録るときは、buffer_crop で範囲を切り出して、CPUに降ろします。ここで、send_frame_buffer は「下の行から並んだ BGRA」を要求する、という罠がありました。上の行から並べて渡すと、映像が上下逆になります。そこで、行の順を逆にして渡しています。
let mut flipped = Vec::with_capacity(data.len());
for row in (0..bh).rev() {
flipped.extend_from_slice(&data[row * bw * 4..(row + 1) * bw * 4]);
}
encoder.send_frame_buffer(&flipped, ts)?;
CPUで1枚ずつコピーするので、範囲録画は、全体より重くなります。
止めるときの順番
停止は、撮影を止めてから、エンコーダーを閉じる順です。逆にすると、MP4が壊れます。トレイの「終了」のときも、録画中なら、先に停止してから終わります。
let stopped = control.stop(); // 先に、撮影を止める
audio_stop.store(true, SeqCst); // 音のスレッドも止める
let finished = finish(); // 最後に、エンコーダーを閉じる
フレームの間引き
画面が変わるたびにフレームを全部受けると、重くなります。MinimumUpdateIntervalSettings::Custom に 1000 / fps ミリ秒を渡して、fps に合わせて間引きました。
9. 音:WASAPIのループバックと、無音の埋め方
パソコンから出ている音(システム音)は、WASAPIのループバックで録ります。再生デバイスを、「録音の向き」で開くと、再生している音が、そのまま取れます。wasapi クレートを使いました。
let device = enumerator.get_default_device(&Direction::Render)?; // 再生デバイスを
let mut client = device.get_iaudioclient()?;
let fmt = WaveFormat::new(16, 16, &SampleType::Int, 48_000, 2, None);
// 再生デバイスを Capture の向きで開く = ループバック
client.initialize_client(&fmt, &Direction::Capture, &mode)?;
形式は、エンコーダーの標準(48kHz / 16bit / 2ch)に合わせています。AACへの変換は、エンコーダーがやります。
何も鳴っていないとき、WASAPIは何も返さない
ここが一番の罠でした。ループバックは、何も鳴っていないとき、データを返しません。エンコーダーは、音の時刻を「これまでに送ったサンプル数」で決めるので、無音のあいだ何も送らないと、音が前へ詰まって、映像とずれます。
対策は、経った時間と送ったサンプル数を比べて、足りない分を無音で埋めることです。30ms以上遅れたときだけ、埋めます。
let expected = t0.elapsed().as_micros() as u64 * RATE / 1_000_000; // 経った時間ぶんのサンプル数
if sent + pending + RATE * 30 / 1000 < expected {
let gap = (expected - sent - pending) as usize;
queue.extend(std::iter::repeat(0u8).take(gap * FRAME)); // 足りない分は無音
}
映像の開始と、音の開始をそろえる
もう1つの罠が、開始の時刻です。映像は「最初のフレームが届いた時点」が0です。一方、音のスレッドは、それより少し前から動き始めます。そのままだと、音が映像より先に始まります。
そこで、エンコーダーができたら started の旗を立てて、音のスレッドは、旗が立つまでの音を捨てて、時計を取り直す形にしました。
音のスレッドからは、チャンネルで、PCMを映像のコールバックへ渡します。映像のフレームが届くたびに、たまった音をエンコーダーへ流し、停止のときは、残りを閉じる前に流します。
音のデバイスが開けなかったときは、音なしで録画を続けて、理由をトレイの説明に出します。なお、音は「PC全体の音」で、ウィンドウを録るときも、そのウィンドウだけの音ではありません。マイクは、まだ録っていません。
10. 録画中の小窓:録画に写らない窓
録画中かどうかが、画面から分かりません。そこで、録画中だけ、画面の上の真ん中に小窓を出します。● REC・経過時間・停止ボタンで、自動で止める設定のときは、00:12 / 01:00 のように残りも出ます。
ただ、この窓が録画に写ると、邪魔です。そこで、Tauriの content_protected(true) を使いました。Windowsでは、窓を画面キャプチャから除外する設定(SetWindowDisplayAffinity の WDA_EXCLUDEFROMCAPTURE)になります。
WebviewWindowBuilder::new(app, "rec", WebviewUrl::App("rec.html".into()))
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.focused(false) // 録画中の操作を邪魔しない
.content_protected(true) // 録画・スクショに写さない
.build()
効果は、録画だけでなく、画面を取るもの全般に及びます。shotkey自身のスクショや、Win+Shift+S、OBSでも、この窓は写りません。目で見えるのに、取ると消える窓になります。録画中の表示としては狙いどおりですが、「見た目を確かめるためにスクショを撮る」ことはできなくなります。
自動停止は、設定で時間(30秒〜1時間)を選びます。別のスレッドが1秒ごとに見て、時間になっても、その録画がまだ続いていれば止めます。手で止められていたら、何もしません。
11. つまずいたこと
Windows専用のコードは、Linuxでは型チェックできない
windows クレートを使うコードは、Linuxのサンドボックスでは、型の検査ができません。関数のシグネチャは、クレートのソースを読んで確かめましたが、実機のビルドで見つかる誤りがありました。MessageBeep が、想定していた場所にありませんでした(Win32_System_Diagnostics_Debug の機能を足す必要がありました)。
そこで、Win32の関数名を全部洗い直し、ほかは問題ありませんでした。今は、MessageBeep をやめて PlaySoundW(Win32_Media_Audio)を使っています。
アイコンと音が、変わらない
アプリのアイコンは、コンパイルのときに、exeへ埋め込まれます。差し替えても、開発中のビルドに反映されないことがあり、cargo clean -p shotkey をしてから、起動し直すと変わりました。音がおかしかったときも、同じキャッシュが原因でした。
リリースの「下書き」を、見落とした
配布は、タグをpushすると、GitHub Actionsが、Windowsのインストーラー(NSIS)を作って、下書きのリリースに置く形です。
最初のリリースで、「インストーラーが付いていない」と思い込みました。実際は、下書きのリリースは、タグのページには出ません。/releases の一覧を見ると、インストーラー付きの下書きがありました。自分の見落としで、原因を決めつけて、ワークフローを書き換えようとしたのは、余計でした。確認するときは、タグのページではなく、リリースの一覧を見ます。
まとめ:固める、物理ピクセル、そして無音に気を付ける
- 範囲選択で右クリックメニューも撮るには、先に画面を固めてから範囲を選ぶ
- 座標はすべて物理ピクセル。モニタごとのDPI対応にして、canvasとの比で換算する
- 画素は、JSONではなく、
tauri::ipc::Responseの生のバイト列で渡す - 録画は、H.264を指定する。範囲録画は、下の行から並んだBGRAで渡す。停止は、撮影を止めてから、エンコーダーを閉じる
- WASAPIのループバックは、無音のときに何も返さない。時間を見て、無音で埋める。映像の開始と、音の開始をそろえる
content_protectedで、録画に写らない小窓を作れる。ただし、ほかのキャプチャにも写らない- 下書きのリリースは、タグのページには出ない
動作確認は、手元のWindows 11が中心です。録画と、小窓と、音は、動いています。複数のモニタでの範囲選択の位置と、管理者権限で動いているウィンドウへのホットキーは、まだ確かめていません。今後は、ウィンドウをクリックで選ぶ撮影、マイクの録音、Windows起動時の自動起動を考えています。
Releases · fixtan/shotkey
Windows用インストーラー(shotkey_x.y.z_x64-setup.exe)。署名なし。
github.com