1386 文字
7 分
VRChat 内で LT をするときのスライド動画生成を自動化する

VRChat 内で​ LT を​する​場合,​スライドを​書き出した​ PDF 内の​ページを​2秒ずつ送る​動画形式に​する​必要が​ある.

これは​ VRChat 内の​スライドシステムと​して​実装されている​アセットの​ほとんどが​動画プレイヤーの​仕組みを​用いており,​スライド送りの機能は,​オフセットを​2秒ずつ動かすと​いう​方法で​実装されている​ためである.

PDF を​突っ込めば​スライドシステムで​読み込める​形式の​動画を​生成し,​リンクを​発行してくれる​ Web サービスも​あるには​あるが,​以下の​理由から​後述の​ ffmpegを​用いた​方​法で​スライド動画を​生成した.

  • 画質の​劣化が​激しい
  • 生成される​ URL が​長い
  • たまに​生成が​失敗するなど,​動作が​安定していない
    • 開発者の​ことは​知っては​いるが,​ファイルを​入れるのは​どことなく​不安

手順1. PDF ファイルを​用意する

まずは​スライドの​内容を​書き出した​ PDF ファイルを​用意する.

私は​ Marp を​使っている​ため,​Marp を​用いて​ PDF を​生成した.

手順2. PDF ファイルを​動画に​変換する#

スライドの​ PDF ファイル名が​ slide.pdfであると​して,​以下の​コマンドを​実行する.

コマンドの​内容を​少し​解説すると,pdftoppmは​ PDF ファイルの​ページの​1枚1枚を​ PNG ファイルと​して​書き出すコマンド.​解像度は​スライドシステム内で​綺麗に​表示する​ために​ 4K で​書き出している.

ffmpegは​その​生成された​ PNG ファイルを​1枚あたり2秒表示する​動画に​変換している.

pdftoppm -png -scale-to-x 3840 -scale-to-y 2160 slide.pdf slide
ffmpeg -framerate 1/2 -i slide-%02d.png -s 3840x2160 -r 60 -c:v libx264 -pix_fmt yuv420p -profile:v baseline slide.mp4

手順3. オブジェクトストレージに​突っ込む#

あとは​これで​生成された​動画ファイルを​オブジェクトストレージに​格納し,​URL を​得る.

今回の​用途では​ Cloudflare R2 が​ 10GB 分の​ストレージを​持っている​ため,​これを​利用する​ことに​した.

自分で​ファイル名を​決めるので,​衝突の​心配が​ない.また,​R2 に​独自ドメインを​当てる​ことで,​スライドを​表示する​ための​ URL は​とても​短くなる.

手順4. ここまでの​手順を​ GitHub Actions で​自動化する#

あとは​ここまでの​手順を​ GitHub Actions に​やらせる​ことで,​ Markdown を​ main に​ push するだけで​スライドデータが​用意できるようになる.

この​仕組み自体は​ GitHub Actions と​ Cloudflare R2 を​用いた​簡易的な​ものである​ため,​使ってみたい​場合は​ご自身の​アカウントで​同じような​環境を​構築し,​ワークフローを​書く​ことで​実現できる.

以下,​Claude に​以上の​要件を​伝えた上で​書いて​もらった​ワークフローを​貼っておく.

*.mdに​変更が​あった​場合と,​手動で​実行できるようになっている.

*.mdの​方は​差分更新で,​手動で​実行した​場合は​全ての​スライドを​コンパイルするので,​枚数が​増えてくると​ ffmpegの​実行に​そこそこ時間が​かかりそうな​雰囲気が​ある.

Secrets は​適宜自分の​ S3 互換ストレージの​ものに​置き換える​などして​ほしい.

name: Build and Deploy Slides

on:
  push:
    branches: [main]
    paths:
      - "**/*.md"
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Determine target directories
        id: targets
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            dirs=$(ls -d */index.md 2>/dev/null | xargs -I{} dirname {})
          else
            # HEAD コミットで変更された .md ファイルの親ディレクトリだけ対象
            dirs=$(git diff-tree --no-commit-id --name-only -r HEAD -- '*.md' \
              | xargs -I{} dirname {} | sort -u \
              | while read -r dir; do [ -f "$dir/index.md" ] && echo "$dir"; done)
          fi

          if [ -z "$dirs" ]; then
            echo "No slide directories to build."
            echo "skip=true" >> "$GITHUB_OUTPUT"
          else
            echo "skip=false" >> "$GITHUB_OUTPUT"
          fi

          {
            echo "dirs<<EOF"
            echo "$dirs"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"

      - uses: actions/setup-node@v4
        if: steps.targets.outputs.skip == 'false'
        with:
          node-version: "22"

      - name: Install dependencies
        if: steps.targets.outputs.skip == 'false'
        run: |
          sudo apt-get update
          sudo apt-get install -y poppler-utils ffmpeg
          npm install -g @marp-team/marp-cli

      - name: Build and convert slides
        if: steps.targets.outputs.skip == 'false'
        run: |
          while IFS= read -r dir; do
            [ -z "$dir" ] && continue
            project=$(basename "$dir")
            echo "::group::Building $project"

            # Marp to PDF
            marp --theme-set .styles/index.css --allow-local-files --pdf "$dir/index.md" -o "$dir/${project}.pdf" --verbose

            # PDF to PNG (3840x2160)
            pushd "$dir"
            pdftoppm -png -scale-to-x 3840 -scale-to-y 2160 "${project}.pdf" slide

            # Rename to ensure 2-digit zero-padded numbering for ffmpeg
            for f in slide-*.png; do
              num=$(echo "$f" | sed 's/slide-0*\([0-9]*\)\.png/\1/')
              dest=$(printf 'slide-%02d.png' "$num")
              [ "$f" != "$dest" ] && mv "$f" "$dest"
            done

            # PNG to MP4
            ffmpeg -framerate 1/2 -i slide-%02d.png -s 3840x2160 -r 60 -c:v libx264 -pix_fmt yuv420p -profile:v baseline "${project}.mp4"

            rm -f slide-*.png
            popd

            echo "::endgroup::"
          done <<< "${{ steps.targets.outputs.dirs }}"

      - name: Upload to Cloudflare R2
        if: steps.targets.outputs.skip == 'false'
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
          R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
        run: |
          while IFS= read -r dir; do
            [ -z "$dir" ] && continue
            project=$(basename "$dir")
            aws s3 cp "$dir/${project}.pdf" "s3://${R2_BUCKET}/slides/${project}.pdf" --endpoint-url "$R2_ENDPOINT"
            aws s3 cp "$dir/${project}.mp4" "s3://${R2_BUCKET}/slides/${project}.mp4" --endpoint-url "$R2_ENDPOINT"
          done <<< "${{ steps.targets.outputs.dirs }}"

見た​目の​違いに​関して#

実際どれぐらい​見た​目が​違うのか​実際に​検証した.​ちなみに​私は​画像処理系に​関しては​門外漢なので,​主観ベースでしか​お話できない​ことを​予め断っておく.

左は​ Web Screen を​用いて​ PDF を​動画に​変換した​もの,​右は​今回の​手法で​動画に​変換した​もの.

比較

今回の​手法で​出力した​場合の​ほうが,​全体​的に​文字の​ぼやけが​なく,​クリアに​表示されている​ことがわかる.

謝辞#

えーさんから​手順2で​示した​動画化コマンドと​アイデアを​頂きました.​ありがとう​ございました.

GitHubで編集を提案