[Rust #02] スクショ+録画アプリ「shotkey」を作り、画面を固めて範囲選択、システム音つきの録画まで

[Rust #02] スクショ+録画アプリ「shotkey」を作り、画面を固めて範囲選択、システム音つきの録画まで

はじめに

スクリーンショットは毎日のように撮ります。長く使ってきたキャプチャソフトの使う機能は「撮って、フォルダに保存する」だけだったので、その部分だけを持った軽いものを自作しました。名前は shotkey です。

  • ホットキーで撮って、指定のフォルダに保存する
  • 撮れるのは、デスクトップ全体 / マウスのあるモニタ / 手前のウィンドウ / 自由サイズの範囲 / 数秒後のタイマー
  • 範囲は、画面を先に固めてから選ぶので、右クリックメニューのような「撮ろうとすると消えるもの」も撮れる
  • 録画(MP4)もできる。ウィンドウ / モニタ / 範囲。パソコンの音(システム音)も入る
  • 録画中は、画面の上に小窓(● REC・経過時間・停止ボタン)が出る。この窓は録画に写らない

編集はしません。編集は、画像ビューアの Lookover の役目にして、shotkey は「撮る」だけにしました。

言語は Rust です。画面(設定とオーバーレイ)だけ Tauri v2 と素のJavaScriptで、撮影と録画はすべてRustです。対象は Windowsだけにしました。

スクリーンショット

[Rust #02] shotkey 設定画面 [Rust #02] shotkey 設定画面

リポジトリ:

前回の記事(Rustで作ったアーカイバの話):

1. 構成:計算は 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起動時の自動起動を考えています。