promo-video について
Phaser ゲームのマーケティングや SNS 向け映像を作る開発者のための録画手順です。ゲームに合わせた入力と失敗処理の調整を行い、高フレームレートの縦型 MP4 に変換します。
主な機能
- 01ゲームコードを調べ失敗処理を無効化
- 02ゲーム操作に合わせた入力を生成
- 03スローモーション録画を 50 FPS に変換
- 04縦型 MP4 の宣伝映像を作成
活用シーン
- 01SNS 向けのゲームプレイ動画制作
- 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 が上限です。そこで、次の方法で回避します。
- Phaser の時間サブシステムを 5 つすべてパッチして、ゲームを 0.5 倍速にします
- Playwright の標準速度である 25 FPS で、希望する時間の 2 倍の長さを録画します
- FFmpeg で速度を 2 倍にします → 出力は実質 50 FPS になります
| パラメーター | デフォルト | 効果 |
|---|---|---|
SLOW_MO_FACTOR | 0.5 | ゲームが半分の速度で動作し、出力は 50 FPS になります |
WALL_CLOCK_DURATION | DESIRED_GAME_DURATION / SLOW_MO_FACTOR | ゲーム時間を正確にするため、2 倍の長さを録画します |
VIEWPORT | { width: 1080, height: 1920 } | 9:16 のモバイル縦向き(ユーザーが別の指定をしない限り、常にデフォルトです) |
DESIRED_GAME_DURATION | 13000(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, ArrowRight | 150~600 ms の長さで交互に押し、間隔に変化をつけ、ときどき素早く 2 回タップします |
| プラットフォーマー/Flappy | Space | 80~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つのサブシステム:
- 更新デルタ —
scene.update(time, delta * factor)により、フレームデルタに依存するロジックを低速化します - トゥイーン —
scene.tweens.timeScaleにより、すべてのトゥイーンアニメーションを低速化します - シーンタイマー —
scene.time.timeScaleにより、scene.time.addEvent()タイマーを低速化します - 物理演算 —
scene.physics.world.timeScaleにより、Arcade/Matter 物理演算を低速化します(逆数1/factorを使用します) - アニメーション —
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:1 | 1080 × 1080 | 正方形 — Instagram フィード、X の投稿 |
| 16:9 | 1920 × 1080 | 横向き — YouTube、予告編、デスクトップゲーム |
長さのガイドライン
| ゲームの種類 | 推奨時間 | 理由 |
|---|---|---|
| アーケード/回避ゲーム | 10-15s | スピーディーなアクションと複数回の回避サイクルを見せます |
| プラットフォーマー | 15-20s | ジャンプのタイミングとレベルの進行を見せます |
| シューティング | 12-18s | 照準合わせと敵のウェーブを見せます |
| パズル | 8-12s | 1回の解答シーケンスを見せます |
チェックリスト
録画を実行する前に:
- 開発サーバーが起動し、応答している
- FFmpeg がシステムにインストールされている
- Playwright が Chromium とともにインストールされている
- ゲームが、進行を妨げるメニューなしでゲームプレイに直接入る
- 死亡/失敗させる方法を特定し、パッチを適用している
- 入力キーがゲームの実際の操作に合っている
- 導入アニメーションの停止時間(1~2秒)が含まれている
- 出力ディレクトリが存在する
録画後:
- 出力先に元の WebM が存在する
- FFmpeg の変換で有効な MP4 が生成される
- 長さが元の録画のおよそ半分になっている(高速化が機能している)
- フレームレートが 50 FPS である
- 動画にゲームプレイが映っている(黒い画面ではない)