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秒表示する​動画に​変換している.

Terminal window
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で編集を提案