本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

何の話か

デジタルアーカイブ向けに作っている macOS ネイティブアプリ(SwiftUI 製) について、使い方を説明するデモ動画をどう(なるべく自動で)撮るかをまとめます。題材は「素材フォルダから SIP(受入)と AIP(長期保存)を作る」ツールですが、手法自体は任意の native macOS / SwiftUI アプリに使えます。

ポイントは 2 つです。

  1. CLI の流れvhs完全自動(無人・CI 可)に mp4/gif 化できる
  2. GUI の操作は、外部からクリックを送るのではなく、アプリ自身に「自走デモモード」を組み込むと座標非依存で壊れにくく、screencapture で録るだけになる

そして native macOS アプリ特有の「ここでハマる」も書きます(一番効くのは、WindowGroup の自動ウィンドウ生成に頼らず、AppKit でウィンドウを明示生成する話です)。

前提:以前の「全自動録画」が native では使えない理由

同じワークフローの Docker 版(Linux GUI) では、デモを完全自動で録れていました。コンテナ内の X11 ディスプレイ :1ffmpeg x11grab で掴み、操作を xdotool で送る方式です(Playwright で noVNC を操作する派生もありました)。人もリアル画面も要らず、CI で回せます。

native macOS アプリにはこれが効きません。

  • X11 が無く、AppKit/SwiftUI のウィンドウは xdotool/Playwright で操作できない
  • 画面収録(ScreenCaptureKit / screencapture)には裏で動く実ログインセッション(Aqua)+「画面収録」権限が要る(SSH からでも launchctl bsexec で同セッションに入れば撮れるが、完全ヘッドレスやセッション無しでは不可)

なので「コンテナの仮想ディスプレイを掴んで叩く」アプローチは取れません。代わりに下記 A/B を使います。

A. CLI デモを vhs で完全自動化

vhs(Charm 製)は、.tape という台本から端末セッションを実行して、そのまま .mp4/.gif/.webmヘッドレスでレンダリングします。必須要件は ttydffmpeg。端末は ttyd(xterm.js)で描画し、フレーム取得には go-rod 経由のヘッドレス Chromium(初回は自動ダウンロード)を使い、ffmpeg で動画に変換します。画面も人も不要なので、CLI の使い方デモにはこれが最適です。

brew install vhs

実演そのものは、ナレーション付きのウォークスルー・スクリプト(# コメント を字幕の土台にできる)を 1 本用意し、.tape からそれを実行するだけにしておくと保守が楽です。

Output docs/media/cli-demo.mp4
Output docs/media/cli-demo.gif

Set FontSize 15
Set Width 1280
Set Height 820
Set Padding 18
Set Theme "Dracula"

Hide
Type "export DEMO_PAUSE=1.0 && clear" Enter
Show
Sleep 500ms
Type "./scripts/demo-walkthrough.zsh" Enter
Sleep 34s    # スクリプトの実時間 + 余韻。長めに取る

これで、素材 → SIP → AIP の一気通貫、フォーマット変換、メタデータ、検証までが約 30 秒の mp4/gif として出力されます。日本語の端末出力もそのまま映ります。

vhs のハマりどころ

  • Sleep はコマンド完了を待たない。vhs はタイムライン通りに進むだけなので、Type ... Enter のあとはスクリプトの実時間以上Sleep で確保する。事前に time ./script で測っておくとよい。
  • 日本語フォント。vhs の既定フォントは CJK を持たないので豆腐になることがある。手元(macOS)ではシステムのフォールバックで日本語が出たが、出ない環境では Set FontFamily "..." で CJK 等幅フォント(HackGen / Cica / Sarasa など)を指定する。短い .tape で 1 枚 PNG を出して確認するのが速い。
  • 依存vhsttyd とヘッドレス Chromium(go-rod)を起動する。ローカルポートやブラウザ起動が制限される環境(サンドボックス内など)では、ブラウザ起動失敗(Failed to launch the browser / No usable sandbox! など、ttyd への接続失敗)で落ちる。サンドボックス下では VHS_NO_SANDBOX=1 が要ることがある。

B. GUI ウォークスルーは「自走デモモード」をアプリに組み込む

GUI を見せる本番チュートリアルは、最初は「外から座標でクリックする」案(cliclick / AppleScript UI scripting)を考えました。が、native では脆く、特にファイル選択(NSOpenPanel)の自動操作が鬼門です。

発想を変えて、アプリ自身が自分の状態を操作して実演する --demo モードを足すと、外部クリックもダイアログ操作も消えて一気に堅くなります。録画は外側で screencapture -v を回すだけです。

要は「GUI のボタンが呼ぶのと同じ処理」を、アプリ起動直後にコードから順に叩くだけです。

// 起動引数 --demo <素材> <出力先> [--quit] を解析しておく(DemoLaunch)。
// アプリが自分の状態を順に操作するだけ(外部クリック・ダイアログ操作なし)。

@MainActor
enum DemoAutopilot {
    static func runSequence(root: RootState, full: FullAppState) async {
        guard let sample = DemoLaunch.sample, let out = DemoLaunch.out else { return }

        func beat(_ k: Double = 1) async {
            try? await Task.sleep(nanoseconds: UInt64(k * 1_400_000_000))
        }

        root.mode = .home;  await beat(1.0)     // 入り口(モード選択)を見せる
        root.mode = .full;  await beat(0.9)     // ③ 一気通貫へ
        full.inputURL  = sample;       await beat()   // 入力欄を順に埋める(ダイアログ無し)
        full.title     = "総務課 移管文書"; await beat(0.6)
        full.outputURL = out;          await beat()
        full.run()                              // GUI の「作成」ボタンと同じ run()

        while full.isRunning {                  // 進捗ログが流れる様子がそのまま映る
            try? await Task.sleep(nanoseconds: 200_000_000)
        }
        await beat(2.0)                          // 完了ダイアログ/結果の余韻
        // 終了は exit(0)。NSApp.terminate(nil) だと「ウィンドウ状態」を保存し、次回起動で
        // それを復元しようとしてウィンドウが生成されない事故につながる(後述)。
        if DemoLaunch.quitWhenDone { exit(0) }
    }
}

状態(RootState / 各画面の AppState)は static let shared のシングルトンにして、自走コードから直接触れるようにしておきます。

一番のハマりどころ:デモ用ウィンドウは AppKit で明示生成する

ここに辿り着くまでが一番長かったので、結論から書きます。

SwiftUIWindowGroup の自動ウィンドウ生成は、open -n で繰り返し起動する文脈で不安定でした。症状は「1〜2 回目は録れるが、それ以降はウィンドウが一切生成されない(録画に映らない/別の前面アプリが映る)」。ログを仕込むと、applicationDidFinishLaunching は毎回走るのに WindowGroup のコンテンツ View が appear せず(NSApp.windows.count == 0 のまま)、自走の起点に置いた .task も発火しない、という状態でした。

.taskapplicationDidFinishLaunching 起動に変える・最前面化する・状態復元を消す…と対症療法を重ねても、間欠的な不発は消えませんでした。

決定打は、自走デモのときは WindowGroup に頼らず、AppDelegateNSWindowNSHostingView を明示的に作ることでした。applicationDidFinishLaunching は(実ログインセッションへの GUI 起動であれば)起動文脈に依存せず必ず一度走るので、ここでウィンドウを作れば確実に存在します。

final class AppDelegate: NSObject, NSApplicationDelegate {
    var demoWindow: NSWindow?  // 保持しておく
    func applicationDidFinishLaunching(_ n: Notification) {
        NSApp.setActivationPolicy(.regular)
        NSApp.activate(ignoringOtherApps: true)
        guard DemoLaunch.isRequested else { return }   // 通常起動は WindowGroup に任せる

        // SwiftUI の View を AppKit のウィンドウに載せる(自動生成に頼らない=確実)。
        let content = RootView()
            .environmentObject(RootState.shared) /* …他の共有状態も… */
        let win = NSWindow(contentRect: .init(x: 0, y: 0, width: 1280, height: 800),
                           styleMask: [.titled, .closable, .miniaturizable, .resizable],
                           backing: .buffered, defer: false)
        win.contentView = NSHostingView(rootView: content)
        win.isReleasedWhenClosed = false
        win.center(); win.makeKeyAndOrderFront(nil); win.orderFrontRegardless()
        demoWindow = win
        // 録画スクリプトへ「このウィンドウだけ録れ」と CGWindowID を渡す。
        try? "\(win.windowNumber)".write(to: winidURL, atomically: true, encoding: .utf8)

        Task { @MainActor in
            await waitForGoSignal()              // 録画開始の合図(後述)
            await DemoAutopilot.runSequence(...) // 状態を順に操作して実演
        }
    }
}

WindowGroup 側はデモ時だけ空(Color.clear)にして、明示ウィンドウと二重にならないようにします。 こうすると、エージェントやバックグラウンドからの open -n でも、何度繰り返してもウィンドウが必ず生成され、録画が安定しました。

録画は「ウィンドウ単位」で録る

screencapture -v(画面全体)だと、別アプリが前面にあるとそれが映る・複数ディスプレイだと別画面を録る、という事故が起きます。screencapture -v -l <CGWindowID> で「そのウィンドウだけ」を録れば、z 順・前面アプリ・ディスプレイに一切影響されません(ウィンドウの描画内容を直接キャプチャするので、最前面化やフルスクリーン化も不要)。

注: screencapture の動画録画(-v)は macOS 14 Sonoma 以降(内部で ScreenCaptureKit を使う)。-v-l(ウィンドウ指定)の併用は man screencapture には明記されていませんが、Sonoma 以降では実機で動作します。-l に渡す id は CGWindowID で、これは後述のとおりアプリ側が NSWindow.windowNumber を書き出して受け渡します。

録画ラッパー(zsh)の骨子:

pkill -9 -f 'MyApp.app/Contents/MacOS'; while pgrep -f …; do sleep 0.5; done  # 残骸を完全に消す
open -n "MyApp.app" --args --demo "$SRC" "$OUT" --quit \
  --winid-out "$WINID" --go-signal "$GO" --demo-schedule … --cue-out …
until [[ -s "$WINID" ]]; do sleep 0.25; done                  # アプリが winid を書くのを待つ
WID="$(cat "$WINID")"
screencapture -v -l "$WID" out.mov &                          # ウィンドウ単位で録画
REC=$!; sleep 1; : > "$GO"                                    # 録画開始を合図 → 自走スタート
# 自走完了(cues 出力/アプリ終了)を待ってから…
kill -INT "$REC"

そのほかのハマりどころ

  • 録画開始は go-signal で合図するscreencapture は起動に数百ミリ秒〜数秒のラグがある。録画が確実に始まる前に自走を開始すると最初のカット(cue0)を録り逃す。そこで「アプリが winid を書く → 録画スクリプトが screencapture を起動 → 1 秒ほど待ってから合図ファイルを作る(: > $GO)→ アプリは合図を見てから自走を開始」という順序にする。後述の先頭トリム(contentStartMs)とも符合する。
  • 正常終了(NSApp.terminate)はウィンドウ状態を保存し、次回起動でそれを「復元」しようとする(state restoration)。デモ用に状態を作り込んでいると、これが「次回はウィンドウ無しで復元」→ ウィンドウ未生成、の遠因になり得る。自走の終了は exit(0)(通常終了の保存フローに乗らない) が安全。さらに録画前に defaults write <bundle-id> NSQuitAlwaysKeepsWindows -bool false~/Library/Saved Application State/<bundle-id>.savedState の削除で「保存させない+既存の保存を消す」二重防御にし、open -n(毎回新インスタンス)+起動前の pkill 徹底も合わせると再現性が上がる。
  • 長尺録画はディスプレイスリープで台無しになる。録画中は caffeinate -dimsu -w $$ を併走させてスリープ/スクリーンセーバを抑止する(-w $$ で録画スクリプトの寿命に紐づける)。
  • applicationDidFinishLaunching から自走 Task を起動するなら、ウィンドウを作った後にする。ウィンドウ生成より先にメインスレッドを占有すると、WindowGroup のウィンドウ生成を妨げる(明示ウィンドウ方式ならこの順序問題自体が消える)。
  • 「画面収録」権限が要る(システム設定 → プライバシーとセキュリティ → 画面収録 → 実行するターミナルを ON)。一度きりの許可。
  • 検証は録画と分ける--demo --quit で起動して出力(成果物ディレクトリ等)が出れば自走機構は正常、と判定できるようにしておくと、「コードが悪いのか環境が悪いのか」を切り分けられる。録画後は必ずフレームを 1 枚抜いて目視ffmpeg -ss <秒> -i out.mov -frames:v 1 frame.png)し、狙いの画が映っているか確認する(前面アプリ混入・ウィンドウ未生成を早期に検出できる)。
  • ファイル選択ダイアログや結果の Finder 表示は録画に映らない(ウィンドウ単位録画は対象ウィンドウだけを録るため)。変換後のフォルダ構造やファイル内容まで動画で見せたいなら、アプリ内に結果ビューア(出力のツリー+テキストプレビュー)を持たせ、自走から選択を切り替えて見せるのが確実。動画で見せる主役が XML(METS など)なら、簡易シンタックスハイライト(要素名・属性名・属性値・コメントを色分け。AttributedString で十分、外部ライブラリ不要)を入れると映像での可読性が段違いになる。
  • 入力素材は同じバイナリの --headless で決定的に用意する。アプリに --headless の CLI モードを持たせておくと、CLI デモの実演にも、別モードのデモの入力(例: AIP デモに渡す SIP)の事前生成にも同じバイナリを使い回せる。素材は実データを使わず、合成データ(ダミーの PostScript/PNG、example.invalid の架空のメール等)を都度生成すると安全かつ再現的。

C. ナレーション・字幕を付ける(音声と映像を同期させる)

自走デモは無音の映像です。ここに日本語ナレーション(TTS)と字幕を載せます。肝は音声と映像のタイミングを合わせること。固定の待ち時間で映像をゆっくりにしても、各ステップの「実際の表示時刻」が分からないと、後から音声・字幕を合わせるのが手作業になりズレます。

順番が逆だと破綻する:原稿 → 尺 → 録画

正しい順番は ①ナレーション原稿 → ②原稿の尺で録画 → ③映像に音声・字幕を載せる。録画を先に撮ってから原稿を当てると尺が合いません。具体的には:

  1. 原稿を書く。カット(cue)ごとに「字幕に出す表示テキスト(display)」と「TTS に読ませる読み(spoken、固有語は仮名)」を分けて持つ。
  2. TTS で各 cue を合成し、音声尺を測る。その音声尺+余裕を、自走デモの各ステップのホールド秒にする(schedule.json)。「映像のそのカットが、音声を読み切れる長さ出続ける」ことを保証する。
  3. 自走デモを schedule の尺で進め、各 cue の開始時刻を記録する。アプリ側に「ナレーションモード」を足し、各カット開始の壁時計 epoch(ミリ秒)を JSON に書き出す--cue-out)。
  4. 映像内の時刻に変換して合成startMs = カット開始 epoch − 録画開始 epoch。録画スクリプト側は screencapture 起動直前の epoch を保存しておく。この startMs を使って TTS 音声と字幕(VTT)を配置する。

同期のコツ(実装メモ)

  • 壁時計 epoch で揃える。アプリは各 cue の開始を Date().timeIntervalSince1970 * 1000 で記録、録画スクリプトは screencapture -v & の直前に同じ epoch を保存。差分が映像内の時刻になる。両者が同じ時計を見るのでズレない。
  • screencapture の録画開始ラグ(数百ミリ秒〜数秒)は、先頭を contentStartMs でトリムして吸収する(最初の cue の手前 0.5 秒くらいだけ残す)。
  • 音声が映像より長くなったら末尾フレームを伸ばすffmpegtpad(最終フレーム静止)で映像を音声尺まで伸ばせば、最後のナレーションが切れない。
  • 焼き込み字幕は libass 必須。手元の ffmpeg--enable-libass でなければ、標準の subtitles フィルタで字幕を焼けない。その場合はサイドカー VTT(YouTube の字幕として読み込ませる)にするか、字幕を PNG にして overlay で重ねる。
  • 字幕は spoken でなく display を出す。①で原稿を display(表示テキスト)と spoken(読み仮名)に分けた見返りはここにある。TTS には spoken(「エスアイピー」「メッツ」)を読ませ、字幕には displaySIP METS Archivematica)を出すと、音声は正しく読まれつつ字幕は読みやすい。サイドカー VTT を作るときは各 cue の display を本文に使う。
  • 音声合成は Azure Neural TTS、合成・連結・mux は ffmpeg。これらは録画方式に依存しないので、既存のナレーション・パイプラインをそのまま後段に流用できる。

CLI デモ(vhs)にも同じやり方でナレーションを載せられる。しかも vhs は固定タイムラインで進むので、GUI のような epoch ハンドシェイクが要らない。原稿の prerollSec + 各カットのホールド秒の累積から各 cue の開始時刻を決定的に計算し(録画開始は 0 とみなす)、台本側も同じホールド秒で sleep してペースを合わせるだけ。vhs 先頭の打鍵ぶんのズレは固定オフセット(SKEW_MS)で吸収する。「CLI デモは無音」と決めつけず、むしろ CLI こそ最も簡単にナレーションを付けられる。

つまり「アプリに cue の開始時刻を吐くナレーションモードを足す」ことが、native GUI 録画に音声・字幕をズレなく載せる鍵になる。これは Web を Playwright で録っていた頃に「シーン開始時刻を録画開始からの実時間で記録していた」のと同じ発想を、native の自走デモで再現したもの。

D. パートを分割して撮り、後から結合する(保守)

チュートリアルが長くなると「一本撮り」は破綻します。アプリの機能が一部変わるたびに全体を撮り直すのは現実的でないからです。そこで各パート(章)を独立ディレクトリで自己完結させます。1 パート=(原稿 script.json + 無音録画 mov + カット開始時刻 cues.json + 音声 + そのパートの最終 mp4)。

narration-<part>/
  script.ja.json        # 原稿(display / spoken / 自走アクション)
  videos/ch01.mov       # 無音の自走デモ録画
  cues.json             # 各カット開始 epoch(録画が生成)
  final-voice/...mp4    # このパートの最終動画(音声入り)+ .vtt

あとは指定順に各パートの最終 mp4 を連結するだけ。あるパートだけ録り直したら、結合スクリプトを再実行するだけで完全版が更新され、他パートは一切触りません(部分更新が安全)。

# part 名 → ディレクトリを引いて、順に連結
combine sip aip full intro …      # 並べ替え・取捨選択も引数で

解像度が違う動画を結合するときは scale+pad で揃える

ウィンドウ単位録画は実ピクセルで撮れるので、GUI 録画(Retina で 2560×1600 など)と vhs 端末(1280×800)は解像度もアスペクト比も違いがちです。そのまま concat すると失敗するので、共通キャンバスへアスペクト比を保ったままスケール+中央寄せパッドしてから連結します。

# 各入力をこのフィルタで前処理してから concat する
scale=2560:1600:force_original_aspect_ratio=decrease,pad=2560:1600:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30

字幕(VTT)も各パートの尺を累積オフセットして連結すれば、完全版の字幕が自動でできます。各パートの cue は display を本文に使い、時刻は「カット開始 − 先頭トリム」で最終動画に整合させます。

まとめ:使い分け

方式何を見せる自動化実行条件
A. CLI(vhs)コマンドの一連の流れ完全自動・無人・CI 可なし(ヘッドレス可)
B-1. GUI 自走デモ(--demo実アプリ画面が自分で実演完全自動(クリック・ダイアログ無し)実画面セッション+画面収録権限/自分の端末から起動
B-2. GUI 半自動実アプリのクリック操作準備・起動・録画開始は自動、クリックは人同上

native macOS アプリのデモは「外から操作する」より「アプリに自走モードを足す」方が、ファイルダイアログ問題も座標依存も消えて結局いちばん楽でした。起点を applicationDidFinishLaunching に置き、ウィンドウを明示生成して screencapture -l <CGWindowID> でウィンドウ単位に録る、という 2 点を押さえれば、GUI でも実用的な自動録画になります(最前面化・全画面化は不要)。

要点をまとめると、native macOS / SwiftUI アプリのデモ動画は次の三段で(半)自動化できます。

  1. CLI は vhs で全自動(無人・CI 可)
  2. GUI はアプリ側に --demo 自走モードを足して screencapture で録る(起点は applicationDidFinishLaunching、ウィンドウは AppKit で明示生成し、screencapture -l <CGWindowID> でウィンドウ単位に録る=最前面化・全画面化は不要)
  3. ナレーション・字幕は「原稿 → 尺 → 録画 → 合成」の順で、各カットの開始時刻(壁時計 epoch)を吐かせて音声・字幕を同期させる(TTS + ffmpeg

最初の数本は GUI の最前面化や .task の不発で手こずりましたが、要は「アプリ自身に、録画されることを前提にした自走モードを持たせる」のが native では一番堅い、というのが結論です。