自作Webツールに操作デモGIFを入れる - Playwright + ffmpegで自動生成した
Playwright / ffmpeg / Astro / GIF / 自動化
電気工事士の試験勉強用に学習ビューワーを3本作りました。Webツールとしては動くようになったのですが、紹介ページが文字だけだと少し寂しい状態でした。
「何ができるツールなのか」をテキストで説明しても、実物を開かない人には伝わりません。かといって動画を別サービスに上げて埋め込むほど大げさな話でもありません。短い操作デモGIFで、実際の動きを見せたい。これがやりたいことでした。
最終的には、Playwrightでブラウザを自動操作し、画面を連番PNGとして保存し、それをffmpegでGIFに変換するスクリプトにしました。

何を作ったか
3本のビューワーは、どれも第二種電気工事士の技能試験対策で、スマホで素早く見返すことを想定しています。
| ビューワー | 機能 | 紹介ページ |
|---|---|---|
| 電工2種 技能試験 埋込連用枠ビューワー | 13問の埋込連用枠を表面・裏面で切り替え | /umekomi/ |
| 電工2種 技能試験候補問題ビューワー | 材料・単線図・概念図・完成例を切替、材料は拡大表示対応 | /ginou/ |
| 電工2種 技能公表問題 年度別比較ビューワー | 2015〜2025年度を縦スクロールで一覧比較 | /kouhyoumondai/ |
紹介ページの「ビューワーを開く」ボタンの近くに、これらの操作デモGIFを置きました。
全体の構成
現在のスクリプトは scripts/record-demo.cjs です。npm run record:demo から実行します。
やっていることは、ざっくり言うと次の流れです。
- Playwrightでブラウザを開く
- 対象ビューワーのページを開く
- ボタン操作やスクロールを自動で行う
- 操作の途中で画面をPNGとして何枚も保存する
- ffmpegで連番PNGをGIFに変換する
WebM動画を録画してからGIFに変換しているわけではありません。実装としては、パラパラ漫画の素材をPlaywrightで作り、それをffmpegでGIFにまとめています。
スクリプト内には、3本分の設定を targets として持たせています。共通の処理は1つで、ビューワーごとに違うのは、画面サイズ、撮影範囲、待つセレクタ、操作シナリオです。
const targets = {
umekomi: {
viewerDir: 'umekomi-viewer',
viewport: { width: 360, height: 780 },
capture: 'app',
waitFor: '#mainImg.loaded',
scenario: async ({ page, snap }) => {
await snap(10, 120);
await page.locator('.image-wrap').click();
await snap(8, 120);
},
},
};
撮影は snap() に集約しています。固定UIでは .app 要素だけを撮り、スクロールするUIではviewport全体を撮ります。
const snap = async (count = 1, gap = 120) => {
const locator = config.capture === 'app' ? page.locator('.app') : null;
for (let i = 0; i < count; i++) {
const out = path.join(tmpDir, `frame${String(frameIdx++).padStart(4, '0')}.png`);
if (locator) {
await locator.screenshot({ path: out });
} else {
await page.screenshot({ path: out });
}
if (i < count - 1) {
await page.waitForTimeout(gap);
}
}
};
公開GIFをいきなり上書きしない
最初に作ったスクリプトは、公開用の public/*-viewer/demo.gif を直接書き換える作りでした。ただ、後から考えるとこれは危ないです。
GIFは作ってみないとテンポや見え方が分かりません。画像が読み込めていなかったり、スクロール位置が変だったりすることもあります。確認前のファイルで公開済みGIFを上書きしてしまうと、戻すのが面倒です。
今のスクリプトでは、通常実行時は .tmp/demo-gif/ にだけ出力します。
npm run record:demo
特定のビューワーだけ試す場合は --target を指定します。
npm run record:demo -- --target=kouhyoumondai
公開用の public/*-viewer/demo.gif を更新するのは、明示的に --write-public を付けた時だけです。
npm run record:demo -- --target=kouhyoumondai --write-public
この形にしておくと、将来GIFを差し替える時も、まず .tmp で事前確認してから公開ファイルを更新できます。
ローカル配信と本番URLの切り替え
ビューワー本体は public/ 配下に置いてあります。
public/
├── ginou-viewer/
│ ├── index.html
│ ├── images/
│ └── demo.gif
├── kouhyoumondai-viewer/
└── umekomi-viewer/
HTMLを file:// で直接開くこともできますが、相対パスや画像読み込みの確認を考えると、HTTPで開くほうが本番に近くなります。そこでスクリプト内に小さな静的ファイルサーバーを持たせ、通常は public/ を http://127.0.0.1:ポート番号/ で配信して撮影します。
一方で、ローカルの画像ファイルがOneDriveのプレースホルダー状態になっていると、スクリプトから読めないことがあります。その場合は --base-url で本番URLを指定して、公開済みのビューワーを元に .tmp へテスト生成できます。
npm run record:demo -- --target=kouhyoumondai --base-url=https://jisaku.net
この指定でも、--write-public を付けない限り公開GIFは上書きされません。
ハマりポイント 1:スマホ幅の違いで見た目が変わる
最初は「スマホっぽい幅なら何でもいい」と思っていました。でも実際には、viewport幅が少し違うだけでレイアウトが変わります。
たとえば電工2種 技能公表問題 年度別比較ビューワーは、スマホ幅で2列表示になるように調整しています。iPhone系の幅だと400px以下になり、別のレイアウトに入ることがあります。一方、Android実機では412px前後の幅で見られることが多いです。
そのため、kouhyoumondai だけは viewport: { width: 412, height: 915 } にしています。
kouhyoumondai: {
viewerDir: 'kouhyoumondai-viewer',
viewport: { width: 412, height: 915 },
capture: 'viewport',
}
GIFは「実際に使う人が見る画面」に近くないと、説明としてズレます。録画する前にCSSのブレイクポイントを見るのは大事でした。
ハマりポイント 2:固定UIとスクロールUIで撮り方が違う
電工2種 技能試験 埋込連用枠ビューワーと電工2種 技能試験候補問題ビューワーは、アプリ本体が画面内に収まる固定UIです。この場合は .app 要素だけを撮ると、余計な余白が入りません。
capture: 'app'
一方、電工2種 技能公表問題 年度別比較ビューワーは縦にスクロールして比較するツールです。特定の要素だけを切り出すより、viewport全体を撮ってスクロールの動きを見せたほうが自然でした。
capture: 'viewport'
同じGIF生成でも、「どこを撮るか」はUIの性質で変わります。
ハマりポイント 3:画像が読み込めていないままGIFになる
Playwrightで #grid img を待つだけだと、「imgタグがDOMに存在する」ことしか確認できません。画像ファイルの読み込みに失敗していても、タグ自体は存在します。その状態で撮ると、壊れた画像アイコン入りのGIFができてしまいます。
そこで、kouhyoumondai では画像の読み込み完了まで待つようにしました。
waitForImages: '#grid img'
中では complete と naturalWidth を見ています。画像が壊れている場合、naturalWidth が0になるので失敗として扱えます。
await page.waitForFunction((imageSelector) => {
const images = Array.from(document.querySelectorAll(imageSelector));
return images.length > 0 && images.every((img) => img.complete && img.naturalWidth > 0);
}, selector, { timeout: 10000 });
このチェックを入れたことで、画像が表示されていないGIFをうっかり作る可能性を減らせました。
ハマりポイント 4:ビューワー固有の価値を映す
最初に作ったGIFは、どれも「問題番号を切り替えるだけ」の動きになりがちでした。これだと3本の違いが伝わりません。
電工2種 技能試験 埋込連用枠ビューワーは、表面と裏面をタップで切り替えられることが価値です。だからGIFにも裏面表示を入れています。

電工2種 技能試験候補問題ビューワーは、材料画像を拡大して細部を見られることが価値です。ここはPlaywrightからビューワー内の拡大状態を変えて、ピンチ操作に近い見た目を作っています。

電工2種 技能公表問題 年度別比較ビューワーは、年度別の問題を並べて比較できることが価値です。GIFでは縦スクロールと問題番号の切り替えを入れています。

デモGIFは、単に画面が動いていればいいわけではありません。そのツールで一番見せたい動きを入れないと、紹介として弱くなります。
拡大表示は内部状態を動かした
電工2種 技能試験候補問題ビューワーの拡大表示は、実機ではピンチ操作で行います。ただ、Playwrightで2本指のピンチを自然に再現するのは手間でした。
今回は自分で作ったツールなので、拡大状態を管理している変数が分かっています。そこで、Playwrightの page.evaluate() から内部状態を段階的に変えて、拡大しているように見せました。
await page.evaluate(async () => {
const steps = 14;
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const ease = t < 0.5 ? 4 * t ** 3 : 1 - Math.pow(-2 * t + 2, 3) / 2;
window.eval(`_zs=${1 + (2.2 - 1) * ease}; _zx=${-45 * ease}; _zy=${-22 * ease}; _applyZ();`);
await new Promise((resolve) => setTimeout(resolve, 80));
}
});
汎用的な方法ではありませんが、自作ツールのデモならこういう割り切りもありでした。目的はピンチ操作そのものをテストすることではなく、「拡大して細部を見られる」という価値を伝えることだからです。
ffmpegは2パスのパレット方式
連番PNGからGIFにするときは、ffmpegの2パスのパレット方式を使っています。
const vf = `fps=${config.fps},scale=${config.scaleWidth}:-1:flags=lanczos`;
run('ffmpeg', [
'-y',
'-framerate',
String(config.fps),
'-i',
framePattern,
'-vf',
`${vf},palettegen=stats_mode=diff`,
palette,
]);
run('ffmpeg', [
'-y',
'-framerate',
String(config.fps),
'-i',
framePattern,
'-i',
palette,
'-lavfi',
`${vf} [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`,
outputPath,
]);
単純にGIFへ変換するより、色の破綻やちらつきが減ります。幅は360pxに縮小し、fpsは各ビューワーの設定で決めています。
現在の設定はこうです。
| ビューワー | fps | 出力幅 | 撮影範囲 |
|---|---|---|---|
| 埋込連用枠 | 8 | 360px | .app |
| 技能試験候補問題 | 8 | 360px | .app |
| 技能公表問題 年度別比較 | 8 | 360px | viewport |
Astroページへの埋め込み
紹介ページはAstroで作っています。GIFは public/ 配下に置き、紹介ページ側では通常の <img> として埋め込んでいます。
<img class="demo-gif" src="/ginou-viewer/demo.gif" alt="ビューワー操作デモ" loading="lazy">
GIFは重くなりやすいので、loading="lazy" は入れています。説明ページではGIFが主役になりすぎないように、本文とボタンの近くに小さめに置いています。
まとめ
今回の構成は、動画ファイルを録画するのではなく、Playwrightで連番スクリーンショットを作り、ffmpegでGIFにまとめる方式です。
やってみて大事だったのは、このあたりでした。
- GIFは公開ファイルへ直接出さず、まず
.tmpに生成して確認する - スマホ幅はCSSのブレイクポイントを見て決める
- 固定UIは
.appを撮り、スクロールUIはviewportを撮る - 画像はDOMの存在だけでなく、読み込み完了まで待つ
- デモGIFには、そのツール固有の価値を入れる
- ffmpegはパレット2パス方式にする
スクリプトは scripts/record-demo.cjs にまとまりました。3本分の設定とシナリオを持っているので、今後GIFを差し替えたい時も、まず .tmp でテスト生成して、問題なければ --write-public で公開用に書き出せます。
紹介ページに短い操作デモがあるだけで、「どういうツールなのか」はかなり伝わりやすくなります。テキストだけでは届きにくい部分を、GIFが補ってくれる感じです。