mcpField
P
promo-video

promo-video

github.com/playableintelligence营销与增长350862 次安装22 周前更新
代码仓库

关于 promo-video

为 Phaser 游戏项目提供宣传视频录制流程,面向需要制作营销或社交媒体素材的开发者。它指导按游戏控制生成输入、避免游戏结束,并将录制素材转换为高帧率竖屏 MP4。

主要功能

  1. 01分析游戏代码并屏蔽失败逻辑
  2. 02按实际控制生成游戏输入
  3. 03以慢速录制并转换为 50 FPS
  4. 04输出竖屏 MP4 宣传视频

适用场景

  1. 01为社交媒体制作游戏实况片段
  2. 02在无需人工操作时录制连续玩法

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/promo-video确认已加载。

附带文件scripts/convert-highfps.sh

详细介绍

宣传视频录制

录制流畅、自主运行的 Phaser 游戏宣传素材,用于营销 / 社交媒体。输出为 50 FPS 的 MP4,竖屏手机比例(9:16)——可直接用于 TikTok、Reels、Moltbook 或 X。

技术方案

Playwright 的 recordVideo 帧率上限为 25 FPS,且没有配置选项。我们通过以下方式绕过限制:

  1. 将游戏速度降至 0.5×,方法是修改全部 5 个 Phaser 时间子系统
  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,请提醒用户并跳过宣传视频步骤(这不是阻塞项——没有 FFmpeg,游戏仍然可以运行)。

录制脚本 — 针对游戏进行适配

每个游戏都需要自定义的 scripts/capture-promo.mjs。子 Agent 必须读取游戏源文件,以确定:

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-600ms),间隔时间不固定,偶尔快速连按两次
平台跳跃 / FlappySpace有节奏地点按(按住 80-150ms),间隔时间不固定(200-800ms)
俯视角WASD / Arrows混合方向按住,采用“8”字形移动模式
射击游戏ArrowLeft/Right + Space移动与快速连射交替进行
点击 / 点按游戏鼠标点击 / 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-12s展示一个解谜过程

检查清单

开始录制前:

  • 开发服务器正在运行且响应正常
  • 系统已安装 FFmpeg
  • 已安装带有 Chromium 的 Playwright
  • 游戏启动后直接进入游戏(没有菜单阻挡)
  • 已确定并修补死亡/失败方式
  • 输入按键与游戏的实际控制方式一致
  • 已包含入场动画暂停(1-2s)
  • 输出目录已存在

录制后:

  • output/ 中存在原始 WebM 文件
  • FFmpeg 转换生成了有效的 MP4
  • 时长约为原始录制的一半(加速有效)
  • 帧率为 50 FPS
  • 视频展示的是游戏画面(不是黑屏)

评论

支持 Markdown