自作Webツールに操作デモGIFを入れる - Playwright + ffmpegで自動生成した

Playwright / ffmpeg / Astro / GIF / 自動化


電気工事士の試験勉強用に学習ビューワーを3本作りました。Webツールとしては動くようになったのですが、紹介ページが文字だけだと少し寂しい状態でした。

「何ができるツールなのか」をテキストで説明しても、実物を開かない人には伝わりません。かといって動画を別サービスに上げて埋め込むほど大げさな話でもありません。短い操作デモGIFで、実際の動きを見せたい。これがやりたいことでした。

最終的には、Playwrightでブラウザを自動操作し、画面を連番PNGとして保存し、それをffmpegでGIFに変換するスクリプトにしました。

ヒーロー画像:3つのビューワーのスクリーンショット

何を作ったか

3本のビューワーは、どれも第二種電気工事士の技能試験対策で、スマホで素早く見返すことを想定しています。

ビューワー機能紹介ページ
電工2種 技能試験 埋込連用枠ビューワー13問の埋込連用枠を表面・裏面で切り替え/umekomi/
電工2種 技能試験候補問題ビューワー材料・単線図・概念図・完成例を切替、材料は拡大表示対応/ginou/
電工2種 技能公表問題 年度別比較ビューワー2015〜2025年度を縦スクロールで一覧比較/kouhyoumondai/

紹介ページの「ビューワーを開く」ボタンの近くに、これらの操作デモGIFを置きました。

全体の構成

現在のスクリプトは scripts/record-demo.cjs です。npm run record:demo から実行します。

やっていることは、ざっくり言うと次の流れです。

  1. Playwrightでブラウザを開く
  2. 対象ビューワーのページを開く
  3. ボタン操作やスクロールを自動で行う
  4. 操作の途中で画面をPNGとして何枚も保存する
  5. 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'

中では completenaturalWidth を見ています。画像が壊れている場合、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にも裏面表示を入れています。

umekomi-viewerの代表フレーム(裏面表示中)

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

ginou-viewerの代表フレーム(拡大表示中)

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

kouhyoumondai-viewerの代表フレーム(スクロール中)

デモ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出力幅撮影範囲
埋込連用枠8360px.app
技能試験候補問題8360px.app
技能公表問題 年度別比較8360pxviewport

Astroページへの埋め込み

紹介ページはAstroで作っています。GIFは public/ 配下に置き、紹介ページ側では通常の <img> として埋め込んでいます。

<img class="demo-gif" src="/ginou-viewer/demo.gif" alt="ビューワー操作デモ" loading="lazy">

GIFは重くなりやすいので、loading="lazy" は入れています。説明ページではGIFが主役になりすぎないように、本文とボタンの近くに小さめに置いています。

まとめ

今回の構成は、動画ファイルを録画するのではなく、Playwrightで連番スクリーンショットを作り、ffmpegでGIFにまとめる方式です。

やってみて大事だったのは、このあたりでした。

  1. GIFは公開ファイルへ直接出さず、まず .tmp に生成して確認する
  2. スマホ幅はCSSのブレイクポイントを見て決める
  3. 固定UIは .app を撮り、スクロールUIはviewportを撮る
  4. 画像はDOMの存在だけでなく、読み込み完了まで待つ
  5. デモGIFには、そのツール固有の価値を入れる
  6. ffmpegはパレット2パス方式にする

スクリプトは scripts/record-demo.cjs にまとまりました。3本分の設定とシナリオを持っているので、今後GIFを差し替えたい時も、まず .tmp でテスト生成して、問題なければ --write-public で公開用に書き出せます。

紹介ページに短い操作デモがあるだけで、「どういうツールなのか」はかなり伝わりやすくなります。テキストだけでは届きにくい部分を、GIFが補ってくれる感じです。