mcpField
P
promo-video

promo-video

github.com/playableintelligenceマーケティング・グロース350862 回インストール22 週間前に更新
リポジトリ

promo-video について

Phaser ゲームのマーケティングや SNS 向け映像を作る開発者のための録画手順です。ゲームに合わせた入力と失敗処理の調整を行い、高フレームレートの縦型 MP4 に変換します。

主な機能

  1. 01ゲームコードを調べ失敗処理を無効化
  2. 02ゲーム操作に合わせた入力を生成
  3. 03スローモーション録画を 50 FPS に変換
  4. 04縦型 MP4 の宣伝映像を作成

活用シーン

  1. 01SNS 向けのゲームプレイ動画制作
  2. 02手動操作なしで連続プレイを録画

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/playableintelligence/game-creator --skill promo-video

インストール後、Claude Code で/promo-videoを入力して読み込まれたか確認します。

同梱ファイルscripts/convert-highfps.sh

詳細

プロモーション動画の録画

Phaser ゲームのマーケティングやソーシャルメディア向けに、滑らかで自律的なプロモーション映像を録画します。出力はモバイル縦向き(9:16)の 50 FPS MP4 で、TikTok、Reels、Moltbook、X にそのまま投稿できます。

手法

Playwright の recordVideo は設定オプションがなく、25 FPS が上限です。そこで、次の方法で回避します。

  1. Phaser の時間サブシステムを 5 つすべてパッチして、ゲームを 0.5 倍速にします
  2. Playwright の標準速度である 25 FPS で、希望する時間の 2 倍の長さを録画します
  3. FFmpeg で速度を 2 倍にします → 出力は実質 50 FPS になります
パラメーターデフォルト効果
SLOW_MO_FACTOR0.5ゲームが半分の速度で動作し、出力は 50 FPS になります
WALL_CLOCK_DURATIONDESIRED_GAME_DURATION / SLOW_MO_FACTORゲーム時間を正確にするため、2 倍の長さを録画します
VIEWPORT{ width: 1080, height: 1920 }9:16 のモバイル縦向き(ユーザーが別の指定をしない限り、常にデフォルトです)
DESIRED_GAME_DURATION13000(ms)ゲーム時間約 13 秒 → プロモーションクリップ約 6.5 秒

前提条件

  • Playwright — インストールされている必要があります(npm install -D @playwright/test && npx playwright install chromium)
  • FFmpeg — PATH 上で利用できる必要があります(macOS の場合は brew install ffmpeg)
  • 開発サーバーの起動 — ゲームを localhost で配信する必要があります

開始前に両方を確認してください。

npx playwright --version
ffmpeg -version | head -1

FFmpeg が見つからない場合はユーザーに警告し、プロモーション動画の手順をスキップしてください(この手順はブロッキングではなく、動画がなくてもゲームは動作します)。

キャプチャスクリプト — ゲームごとの調整

ゲームごとに、専用の scripts/capture-promo.mjs を作成します。サブエージェントは、次の項目を特定するためにゲームのソースファイルを読む必要があります。

1. 死亡/失敗処理のパッチ(重要)

動画にはゲームオーバーにならない、途切れのないゲームプレイを映す必要があります。GameScene.js(または同等のファイル)を読み、死亡/失敗時のメソッドを見つけてモンキーパッチで無効化してください。

見つけ方:衝突/死亡時に呼び出されるメソッドを検索します。よくあるパターンは次のとおりです。

  • this.triggerGameOver() — 回避ゲーム
  • this.takeDamage() → this.lives <= 0 — ライフ制のゲーム
  • this.gameOver() — 直接呼び出し
  • eventBus.emit(Events.PLAYER_HIT) / eventBus.emit(Events.GAME_OVER) — イベント駆動型

パッチのテンプレート(ゲームに合わせて調整してください)。

await page.evaluate(() => {
  const scene = window.__GAME__.scene.getScene("GameScene");
  if (scene) {
    // Patch ALL paths to game over
    scene.triggerGameOver = () => {};
    scene.onPlayerHit = () => {};
    // For multi-life games, also prevent damage:
    // scene.takeDamage = () => {};
    // scene.playerDied = () => {};
  }
});
2. 入力シーケンスの生成

動画には、動きのある自然なゲームプレイを映す必要があります。ゲームの入力処理を読み、次の項目を特定してください。

  • 使用するキー — ArrowLeft/ArrowRight?Space?WASD?マウスクリック?
  • 入力スタイル — 押し続ける(移動)、タップ(ジャンプ/射撃)、またはその両方?
  • 動きのパターン — プレイヤーは画面を横切って動くべきですか、状況に応じて回避すべきですか、リズミカルにジャンプすべきですか?

ゲームの種類ごとの入力パターン:

ゲームの種類入力キーパターン
横スクロール回避ゲームArrowLeft, ArrowRight150~600 ms の長さで交互に押し、間隔に変化をつけ、ときどき素早く 2 回タップします
プラットフォーマー/FlappySpace80~150 ms 押すリズミカルなタップと、間隔に変化をつけた休止(200~800 ms)
見下ろし型WASD / Arrows複数の方向を組み合わせて押し、8 の字を描くパターン
シューティングゲームArrowLeft/Right + Space移動と連射を織り交ぜます
クリッカー/タッパーMouse click / Space短い休止を挟みながら素早く連打します

ロボットのような動きに見えないよう、タイミングをランダム化します。

const holdMs = 150 + Math.floor(Math.random() * 450);
const pauseMs = 50 + Math.floor(Math.random() * 250);

開始時に一時停止を入れて(1~2 秒)、オープニングアニメーションを再生させます。これがフックになります。

3. ゲームの起動検出

/viral-game または /make-game パイプラインでビルドされたすべての Phaser ゲームは、以下のグローバル変数を公開します。

  • window.__GAME__ — Phaser.Game インスタンス
  • window.__GAME_STATE__ — GameState シングルトン
  • window.__EVENT_BUS__ — EventBus シングルトン

起動とゲームプレイの開始の両方を待機します。

await page.waitForFunction(() => window.__GAME__?.isBooted, { timeout: 15000 });
await page.waitForFunction(() => window.__GAME_STATE__?.started, {
  timeout: 10000,
});
4. 時間スケーリングの注入

録画中は、Phaser の時間サブシステムを5つすべて低速化します。

await page.evaluate(
  ({ factor }) => {
    const game = window.__GAME__;
    const scene = game.scene.getScene("GameScene");

    // 1. Update delta — slows frame-delta-dependent logic
    const originalUpdate = scene.update.bind(scene);
    scene.update = function (time, delta) {
      originalUpdate(time, delta * factor);
    };

    // 2. Tweens — slows all tween animations
    scene.tweens.timeScale = factor;

    // 3. Scene timers — slows scene.time.addEvent() timers
    scene.time.timeScale = factor;

    // 4. Physics — slows Arcade/Matter physics
    // NOTE: Arcade physics timeScale is INVERSE (higher = slower)
    if (scene.physics?.world) {
      scene.physics.world.timeScale = 1 / factor;
    }

    // 5. Animations — slows sprite animation playback
    if (scene.anims) {
      scene.anims.globalTimeScale = factor;
    }
  },
  { factor: SLOW_MO_FACTOR },
);

5つのサブシステム:

  1. 更新デルタ — scene.update(time, delta * factor) により、フレームデルタに依存するロジックを低速化します
  2. トゥイーン — scene.tweens.timeScale により、すべてのトゥイーンアニメーションを低速化します
  3. シーンタイマー — scene.time.timeScale により、scene.time.addEvent() タイマーを低速化します
  4. 物理演算 — scene.physics.world.timeScale により、Arcade/Matter 物理演算を低速化します(逆数 1/factor を使用します)
  5. アニメーション — scene.anims.globalTimeScale により、スプライトアニメーションの再生を低速化します
5. 動画の最終処理
const video = page.video();
await context.close(); // MUST close context to finalize the video file
const videoPath = await video.path();

録画スクリプトの完全なテンプレート

import { chromium } from "playwright";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_DIR = path.resolve(__dirname, "..");

// --- Config ---
const args = process.argv.slice(2);
function getArg(name, fallback) {
  const i = args.indexOf(`--${name}`);
  return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
}

const PORT = getArg("port", "3000");
const GAME_URL = `http://localhost:${PORT}/`;
const VIEWPORT = { width: 1080, height: 1920 }; // 9:16 mobile portrait
const SLOW_MO_FACTOR = 0.5;
const DESIRED_GAME_DURATION = parseInt(getArg("duration", "13000"), 10);
const WALL_CLOCK_DURATION = DESIRED_GAME_DURATION / SLOW_MO_FACTOR;
const OUTPUT_DIR = path.resolve(PROJECT_DIR, getArg("output-dir", "output"));
const OUTPUT_FILE = path.join(OUTPUT_DIR, "promo-raw.webm");

// <ADAPT: Generate game-specific input sequence>
function generateInputSequence(totalMs) {
  const sequence = [];
  let elapsed = 0;

  // Pause for entrance animation
  sequence.push({ key: null, holdMs: 0, pauseMs: 1500 });
  elapsed += 1500;

  // <ADAPT: Replace with game-specific keys and timing>
  const keys = ["ArrowLeft", "ArrowRight"];
  let keyIdx = 0;

  while (elapsed < totalMs) {
    const holdMs = 150 + Math.floor(Math.random() * 450);
    const pauseMs = 50 + Math.floor(Math.random() * 250);

    // Occasional double-tap for variety
    if (Math.random() < 0.15) {
      sequence.push({ key: keys[keyIdx], holdMs: 100, pauseMs: 60 });
      elapsed += 160;
    }

    sequence.push({ key: keys[keyIdx], holdMs, pauseMs });
    elapsed += holdMs + pauseMs;

    // Alternate direction (with occasional same-direction repeats)
    if (Math.random() < 0.75) keyIdx = 1 - keyIdx;
  }

  return sequence;
}

async function captureGameplay() {
  console.log("Capturing promo video...");
  console.log(
    `  URL: ${GAME_URL} | Viewport: ${VIEWPORT.width}x${VIEWPORT.height}`,
  );
  console.log(
    `  Game duration: ${DESIRED_GAME_DURATION}ms | Wall clock: ${WALL_CLOCK_DURATION}ms`,
  );

  fs.mkdirSync(OUTPUT_DIR, { recursive: true });

  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext({
    viewport: VIEWPORT,
    recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT },
  });

  const page = await context.newPage();
  await page.goto(GAME_URL, { waitUntil: "networkidle" });

  // Wait for game boot + gameplay active
  await page.waitForFunction(() => window.__GAME__?.isBooted, {
    timeout: 15000,
  });
  await page.waitForFunction(() => window.__GAME_STATE__?.started, {
    timeout: 10000,
  });
  await page.waitForTimeout(300);
  console.log("  Game active.");

  // <ADAPT: Patch out death — find the actual methods from GameScene.js>
  await page.evaluate(() => {
    const scene = window.__GAME__.scene.getScene("GameScene");
    if (scene) {
      scene.triggerGameOver = () => {};
      scene.onPlayerHit = () => {};
    }
  });
  console.log("  Death patched.");

  // Slow all 5 Phaser time subsystems
  await page.evaluate(
    ({ factor }) => {
      const game = window.__GAME__;
      const scene = game.scene.getScene("GameScene");
      const originalUpdate = scene.update.bind(scene);
      scene.update = function (time, delta) {
        originalUpdate(time, delta * factor);
      };
      scene.tweens.timeScale = factor;
      scene.time.timeScale = factor;
      if (scene.physics?.world) scene.physics.world.timeScale = 1 / factor;
      if (scene.anims) scene.anims.globalTimeScale = factor;
    },
    { factor: SLOW_MO_FACTOR },
  );
  console.log(`  Slowed to ${SLOW_MO_FACTOR}x.`);

  // Execute input sequence
  const sequence = generateInputSequence(WALL_CLOCK_DURATION);
  console.log(
    `  Playing ${sequence.length} inputs over ${WALL_CLOCK_DURATION}ms...`,
  );

  for (const seg of sequence) {
    if (!seg.key) {
      await page.waitForTimeout(seg.pauseMs);
      continue;
    }
    await page.keyboard.down(seg.key);
    await page.waitForTimeout(seg.holdMs);
    await page.keyboard.up(seg.key);
    if (seg.pauseMs > 0) await page.waitForTimeout(seg.pauseMs);
  }

  console.log("  Input complete.");

  // Finalize video
  const video = page.video();
  await context.close();
  const videoPath = await video.path();

  if (videoPath !== OUTPUT_FILE) {
    fs.renameSync(videoPath, OUTPUT_FILE);
  }

  await browser.close();
  console.log(`  Raw recording: ${OUTPUT_FILE}`);
  console.log("Done.");
}

captureGameplay().catch((err) => {
  console.error("Capture failed:", err);
  process.exit(1);
});

FFmpeg による変換

録画後、スローモーションの WebM を高フレームレートの MP4 に変換します。convert-highfps.sh スクリプトは、このスキルの skills/promo-video/scripts/convert-highfps.sh に含まれています。

# Copy to project (orchestrator does this)
cp <plugin-root>/skills/promo-video/scripts/convert-highfps.sh <project-dir>/scripts/

# Run conversion
bash scripts/convert-highfps.sh output/promo-raw.webm output/promo.mp4 0.5

このスクリプトは次の処理を行います。

  • setpts を適用して、動画を 1/factor 倍速にします
  • 出力フレームレートを 25 / factor に設定します(0.5×スローモーションの場合は 50 FPS)
  • crf 23、yuv420p、faststart を使って H.264 でエンコードします
  • 出力の長さ、フレームレート、ファイルサイズを検証します

ビューポートのデフォルト設定

ユーザーが明示的に別の形式を指定しない限り、常にモバイル縦向き(9:16)で録画してください。 理由:

  • ゲームはスマートフォンでプレイされるため、プロモーション映像には実際のモバイル体験を映す必要があります
  • 9:16 は TikTok、Instagram Reels、YouTube Shorts に適したネイティブ形式です
  • 1080×1920 は標準的な解像度です
アスペクト比ビューポート用途
9:16(デフォルト)1080 × 1920モバイル縦向き — TikTok、Reels、Shorts、Moltbook
1:11080 × 1080正方形 — Instagram フィード、X の投稿
16:91920 × 1080横向き — YouTube、予告編、デスクトップゲーム

長さのガイドライン

ゲームの種類推奨時間理由
アーケード/回避ゲーム10-15sスピーディーなアクションと複数回の回避サイクルを見せます
プラットフォーマー15-20sジャンプのタイミングとレベルの進行を見せます
シューティング12-18s照準合わせと敵のウェーブを見せます
パズル8-12s1回の解答シーケンスを見せます

チェックリスト

録画を実行する前に:

  • 開発サーバーが起動し、応答している
  • FFmpeg がシステムにインストールされている
  • Playwright が Chromium とともにインストールされている
  • ゲームが、進行を妨げるメニューなしでゲームプレイに直接入る
  • 死亡/失敗させる方法を特定し、パッチを適用している
  • 入力キーがゲームの実際の操作に合っている
  • 導入アニメーションの停止時間(1~2秒)が含まれている
  • 出力ディレクトリが存在する

録画後:

  • 出力先に元の WebM が存在する
  • FFmpeg の変換で有効な MP4 が生成される
  • 長さが元の録画のおよそ半分になっている(高速化が機能している)
  • フレームレートが 50 FPS である
  • 動画にゲームプレイが映っている(黒い画面ではない)

コメント