mcpField
V
vid-product-launch

vid-product-launch

github.com/varnan-techクリエイティブ・メディア68747 回インストール2 週間前に更新
リポジトリ

vid-product-launch について

製品の説明と公開時の情報から、ストーリー仕立ての製品ローンチ動画を作るスキルです。HTML/CSSのアニメーションをPlaywright経由のヘッドレスChromiumでフレーム撮影し、FFmpegでMP4にエンコードします。

主な機能

  1. 01ストーリー仕立てのローンチ動画生成
  2. 02HTMLとCSSによるシーンアニメーション
  3. 03Playwrightによるフレーム撮影
  4. 04FFmpegによるH.264 MP4エンコード
  5. 05長さとアスペクト比の設定
  6. 06トーンと任意の音声設定

活用シーン

  1. 01製品ローンチ動画の作成
  2. 02縦型ローンチ動画の出力
  3. 03指定時間のローンチ動画制作

インストール

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

$npx skills add https://github.com/varnan-tech/opendirectory --skill vid-product-launch

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

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
references/
scene-library.md
style-presets.md
scripts/
capture-frames.mjs
export-video.sh

詳細

vid-product-launch

製品の説明とローンチの背景情報から、ストーリー性のある MP4 製品ローンチ動画を生成します。 パイプライン:HTML/CSS アニメーション → ヘッドレス Chromium(Playwright、フレーム単位)→ FFmpeg(H.264 MP4)。 React は不要です。AI 動画 API は不要です。Python は不要です。Playwright と FFmpeg 以外の実行時コストはゼロです。


重要なルール(生成する前に毎回お読みください)

  1. タグラインは必須です。省略しないでください。 4~6語で製品の価値を伝えるものです。ユーザーから提供されていない場合は、説明から、鋭く、能動態で要点を凝縮したタグラインを導き出してください。「[tagline here]」と書いたり、空欄のままにしたりしないでください。

  2. 製品の登場は、そこに至る流れにふさわしいものにしてください。 ティーズとビルドのセクションは緊張感を生み出すためにあります。動画の最初の 20% で製品名が表示されると、ストーリーが崩れてしまいます。リビールセクションより前に製品名を明かさないでください。

  3. product_name のフォントサイズ:16:9 では最低 120px、9:16 では最低 80px。 リビールの瞬間には、製品名を最も大きな要素にしてください。製品名が際立っていなければ、リビールは失敗です。

  4. 実績を示す数値は 1 つだけにしてください。羅列しないでください。 ユーザーが複数の数値を提示した場合は、最も強いものを選んでください。数字を 5 つ並べるとインパクトが失われます。数字を 1 つ大きく表示すれば、インパクトが生まれます。

  5. シーンの切り替えに CSS @keyframes を使わず、window.renderFrame(t) を使ってください。 Chromium では CSS currentTime のシークは、後ろ向きのシークでは黙って無視されます。renderFrame 関数はミリ秒単位で要素のスタイルを直接計算します。Playwright はフレームごとにこの関数を呼び出します。

  6. どの要素にも animation-delay を設定しないでください。 renderFrame を使う場合、必要ありません。animation-delay を書いてしまったら、止めてください。アーキテクチャの選択が間違っています。

  7. window.__videoReady = true を設定するのは、document.fonts.ready.then(...) の内部だけにしてください。 同期的に設定しないでください。

  8. window.__stopPreview() を公開してください。 rAF プレビューループと Playwright の evaluate/スクリーンショット呼び出しが競合します。キャプチャスクリプトはフレームループの前に __stopPreview() を呼び出します。

  9. シーン境界のチェックには t <= startMs ではなく t < startMs を使ってください。 フレーム 0 で t <= 0 とすると、シーン 1 が黒くなります。

  10. Body は正確なピクセル寸法にしてください。 幅と高さは整数にします。%、vw/vh、rem は使わないでください。

  11. HTML を生成する前に、必ず references/scene-library.md と references/style-presets.md を読んでください。

  12. HTML をチャットに出力しないでください。 ファイルに保存し、概要だけを表示してください。

  13. フィルムグレイン用の canvas は、W×H ではなく必ず 240×135 にしてください。 canvas 要素に width="240" height="135" を設定し、style="width:[W]px;height:[H]px" で引き伸ばしてください。1920×1080 のフル解像度でグレインを生成すると 64 倍遅くなり、1 フレームあたり 8MB の ImageData が必要になります。その結果、1800 フレームの書き出しに何時間もかかります。

  14. ティーズセクションは、トーンにかかわらず暗く(#000 またはそれに近い黒に)してください。 ティーズセクションの白い背景は、製品ローンチ動画ではなくデモスライドのように見えます。暗いティーズ → 暗いビルド → 製品のリビールという暗から明へのストーリー展開によって、ローンチ動画にドラマが生まれます。minimal と energetic のプリセットでも、tease-problem と tension-build のシーンでは background: #000 を使ってください。

  15. ティーズの各単語は、200px 以上のフォントサイズで、それぞれ独立したビートにしてください。 1 行に単語を順番に表示するだけでは、十分なインパクトがありません。各問題の単語(例:「Research.」/「Write.」/「Outreach.」/「Repeat.」)を、font-size: 200px; font-weight: 900 で中央に 1 つずつ表示し、それぞれ 1500~1800ms の画面表示時間を設けてください。scene-library.md の tease-words パターンを参照してください。

  16. ビルドセクションでは、粒子を表示するだけでなく、問題の流れが伝わる内容を表示してください。 terminal-card パターンを使い、手作業で行う作業をタイプライターアニメーションで表示するカードを 3 枚、順番に出してください。カードには border: 1px solid rgba(255,255,255,0.07)、青いドットのアクセント、等幅フォントのテキストを使います。20 秒間、粒子だけを表示しても画面の時間が空虚になります。

  17. 暗いシーンにはドットグリッドの CSS 背景を使ってください。 build、reveal、proof、CTA の各シーンに background-image: radial-gradient(circle, rgba(255,255,255,0.035) 1px, transparent 1px); background-size: 60px 60px; を追加してください。計算コストはゼロで、奥行きが加わります。


ステップ 1:情報の収集

必須:

  • product_name — リリースする製品または機能の名前
  • product_description — 2~3 文:機能、対象ユーザー、主なメリット

任意のパラメーターとデフォルト値:

パラメーターデフォルト値説明
taglineauto4~6 語 — 製品の中心となる約束
problem_statementauto-inferredティーザーセクション用の 1 文(製品が解決する課題)
proof_statauto-inferred単一の指標(例:「500+ teams」「10× faster」「$2M saved」)
ctaautoURL または行動を促すフレーズ(例:「opendirectory.dev」「Join the waitlist」)
launch_date—ISO 日付文字列(カウントダウンカードのシーンが有効になります)
tonecinematiccinematic / energetic / minimal / emotional
duration6030 / 60 / 90(秒)
aspect_ratio16:916:9(1920×1080)/ 9:16(1080×1920)
letterboxfalseシネマスコープ 2.35:1 の黒帯 — 16:9 のみ
music—音声ファイルへのパス(mp3/m4a/wav)
fps30フレームレート(24、30、または 60)

product_name または product_description が不足している場合は、必ず次の文面で質問してください。

"ローンチ動画を作成するには、次の 2 点が必要です。

  1. 製品名 — 製品の名前は何ですか?
  2. 製品の説明 — 2~3 文で、機能、対象ユーザー、主なメリットを教えてください。

任意:タグライン(4~6 語)、実績指標(数値 1 つ)、CTA(URL または行動を促すフレーズ)、トーン(cinematic / energetic / minimal / emotional)、長さ(30 / 60 / 90s)。"

両方が入力されている場合は、ただちにステップ 2 に進みます。


ステップ 2:内部アーキテクチャ(ユーザーには表示しない)

1. 不足しているパラメーターを導出します。

  • tagline(未入力の場合):product_description から作成します。4~6 語。能動態。「The future of…」「Introducing…」のような不要な語句は使いません。可能な限り明確な約束を導き出します。
  • problem_statement(未入力の場合):product_description から推測します。課題を示す 1 文にします。製品名は含めません。視聴者が共感できる表現にします。
  • proof_stat(未入力の場合):product_description から推測します。数値がない場合は、信頼できる規模を示す表現を使います(「Trusted by 500+ teams」「From days to minutes」)。
  • cta(未入力の場合):product_name からドメインを推測できる場合はそのドメインを使い、できない場合は行動を促すフレーズとして「Learn more」を使います。

2. duration に基づいてセクションのタイミングを計算します。

セクション30s60s90s開始位置の式
ティーザー0–5s0–10s0–15s0ms
展開5–12s10–30s15–40sティーザーの終了位置
発表12–20s30–45s40–60s展開の終了位置
実績20–25s45–55s60–75s発表の終了位置
CTA25–30s55–60s75–90s実績の終了位置

すべての境界をミリ秒に変換します。定数に割り当てます。

TEASE_START_MS, TEASE_END_MS
BUILD_START_MS, BUILD_END_MS
REVEAL_START_MS, REVEAL_END_MS
PROOF_START_MS, PROOF_END_MS
CTA_START_MS, CTA_END_MS

3. 各セクションのシーンを選択します。

  • ティーザー: 必ず blackout-opener(1500ms)から開始します。続けて tease-words を配置します(問題を表す各キーワードを、それぞれ 1600~1800ms、200px 以上で表示)。launch_date が指定されている場合は、ティーザーの最後に countdown-card を追加できます。
  • 展開: terminal-card のシーケンス(手作業のタスクを入力する様子を示すカード 3 枚)と、任意の締めくくりのアクセントラインを使用します。長さが 30s 以上の場合は 3 枚すべてのカードを使います。長さが 30s の場合は 2 枚のカードを使います。粒子だけの画面を 5s を超えて使ってはいけません。問題を描写するコンテンツを画面に表示する必要があります。
  • 発表: reveal-hero を必ずこのセクションの最初に配置します。長さが 60s 以上の場合は、その後に tagline-card を追加できます。
  • 実績: 数値指標がある場合は proof-stat を使用します。それ以外の場合は feature-bullet を使用します。
  • CTA: cta-card を必ず使用し、必ず最後に配置します。

シーン数の目安:30s = 4 シーン、60s = 6~7 シーン、90s = 7~8 シーン。

4. シーンごとのタイミングを計算します。 各セクション内で、そのセクションのシーン数に応じて時間を均等に分割します。 例外:blackout-opener は、ティーザーの最初の 1500ms を必ず割り当てます。

5. ピクセル寸法を決定します。

  • 16:9 → W=1920、H=1080
  • 9:16 → W=1080、H=1920 6. レターボックスの計算(有効な場合、16:9 のみ):
  • 2.35:1 のコンテンツの高さ = W / 2.35 = 1920 / 2.35 ≈ 817px
  • バーの高さ = (H - 817) / 2 = (1080 - 817) / 2 ≈ 132px
  • 上部のバー:top: 0; height: 132px
  • 下部のバー:bottom: 0; height: 132px

7. シネマティック効果のフラグ(プリセットごと):

  • cinematic:フィルムグレイン=ON(キャンバス 240×135、不透明度 0.025)、ビネット=ON、ライトリーク=ON(ウォームゴールド)、ドットグリッド=暗いシーンで ON
  • energetic:フィルムグレイン=OFF、ビネット=OFF、ドットグリッド=ON、リビール時に白フラッシュ
  • minimal:フィルムグレイン=ON(キャンバス 240×135、不透明度 0.022)、ビネット=ON(控えめ、0.5 のラジアル)、ドットグリッド=ON、ライトリークなし — アクセントカラー = #4B9FFF(エレクトリックブルー)
  • emotional:フィルムグレイン=ON(キャンバス 240×135、不透明度 0.018、暖色系の色味をブレンド)、ビネット=ON(ソフト)、ライトリーク=ON(温かみのあるコッパー)、ドットグリッド=OFF

すべてのプリセットで、ティーズシーンとビルドシーンはプリセットに関係なく、常に暗い背景(#000 または黒に近い色)を使用します。

8. HTML に window.__sfxTimeline を埋め込みます(常に — 音楽が指定されていない場合も含む):

エクスポートスクリプトはこの配列を読み込み、FFmpeg の aevalsrc/anoisesrc を使って各 SFX タイプをネイティブに合成し、adelay を使ってイベントをミリ秒単位の正確なオフセットに配置します。外部のオーディオファイルは不要です。

SFX タイプのリファレンス:

タイプサウンド長さ備考
word-hitサブパンチ(50Hz)+トランジェントクリック(2.2kHz)+ノイズバースト180msティーズの単語ごとに 1 回 — amix で 3 層
type-sequence15Hz のメカニカルキーボードクリック(sin^30 パルスエンベロープ)1.9sカードごとに 1 回 — TYPE_DUR 全体で再生
whoosh2 バンドのノイズスイープ(1.1kHz のボディ+4–8kHz のエアー)700msBUILD_CLOSE のトランジション時
tension-riser低音のランブル(250Hz)+t/2.8 のランプで大きくなるサブトーン2.9sBUILD_CLOSE + 700ms で開始 — REVEAL_START でピーク
reveal-boomサブ(45Hz)+ボディ(90Hz)+シマー(4.5–11kHz)+85ms のエコー900msREVEAL_START ちょうど — 決定的なヒット
counter-tick倍音クリック(880Hz + 440Hz + 1760Hz + 2640Hz)80ms3 拍 — デクレッシェンド 0.32 → 0.26 → 0.20
cta-chimeA メジャーコード(440 + 554 + 659 + 880Hz)+aecho のベルシマー1.2sCTA_START ちょうど

SFX タイミングマップ — ms の値はセクション定数から計算し、ハードコードしないでください:

window.__sfxTimeline = [
  // ── TEASE: one word-hit per word beat, last louder ───────────────────────
  { ms: WORD_BEATS[0].start,   sfx: 'word-hit',      vol: 0.55 },
  { ms: WORD_BEATS[1].start,   sfx: 'word-hit',      vol: 0.55 },
  { ms: WORD_BEATS[2].start,   sfx: 'word-hit',      vol: 0.55 },
  { ms: WORD_BEATS[N].start,   sfx: 'word-hit',      vol: 0.65 },  // last word louder

  // ── BUILD: type-sequence per card (1.9s, matches TYPE_DUR 1800ms) ────────
  { ms: CARDS[0].start + 180,  sfx: 'type-sequence', vol: 0.28 },
  { ms: CARDS[1].start + 180,  sfx: 'type-sequence', vol: 0.28 },
  { ms: CARDS[2].start + 180,  sfx: 'type-sequence', vol: 0.28 },

  // ── TRANSITION: whoosh then 2.9s riser peaking at REVEAL_START ───────────
  { ms: BUILD_CLOSE,           sfx: 'whoosh',        vol: 0.50 },
  { ms: BUILD_CLOSE + 700,     sfx: 'tension-riser', vol: 0.35 },

  // ── REVEAL: the cinematic hit ─────────────────────────────────────────────
  { ms: REVEAL_START,          sfx: 'reveal-boom',   vol: 0.88 },

  // ── PROOF: harmonic ticks, decrescendo ───────────────────────────────────
  { ms: PROOF_START,           sfx: 'counter-tick',  vol: 0.32 },
  { ms: PROOF_START + 2200,    sfx: 'counter-tick',  vol: 0.26 },
  { ms: PROOF_START + 4400,    sfx: 'counter-tick',  vol: 0.20 },

  // ── CTA: A major chord landing ────────────────────────────────────────────
  { ms: CTA_START,             sfx: 'cta-chime',     vol: 0.62 },
];

このブロックは、window.renderFrame の後、プレビューのループの直前に配置します。


ステップ 3:HTML の生成

コードを書く前に、references/scene-library.md と references/style-presets.md を読んでください。 それらのファイルにある正確な CSS クラス名、HTML 構造、renderFrame パターンを使用してください。

必須の HTML スケルトン:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
[font CDN link from style preset]
<style>
:root {
  [all CSS tokens from style preset]
}

*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
  width: [W]px; height: [H]px;
  overflow: hidden;
  background: var(--bg);
  font-family: var(--font-body);
  position: relative;
}

.scene {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 80px;
  opacity: 0;
  will-change: opacity, transform;
}
.scene-inner {
  width: 100%;
  max-width: 1200px;
}

[paste CSS from each scene type in scene-library.md]
</style>
</head>
<body>


[if film-grain: ON]
<canvas id="grain-overlay"
  width="[W]" height="[H]"
  style="position:fixed;inset:0;pointer-events:none;opacity:0.025;mix-blend-mode:overlay;z-index:50"></canvas>
[end if]

[if vignette: ON]
<div id="vignette-overlay"
  style="position:fixed;inset:0;background:radial-gradient(ellipse at center,transparent 35%,rgba(0,0,0,0.65) 100%);pointer-events:none;z-index:51"></div>
[end if]

[if light-leak: ON]
<div id="light-leak"
  style="position:fixed;inset:0;background:linear-gradient(135deg,rgba(255,220,140,0.5) 0%,rgba(255,255,255,0.3) 45%,transparent 70%);opacity:0;pointer-events:none;z-index:52"></div>
[end if]

[if letterbox: ON]
<div id="lbox-top" style="position:fixed;top:0;left:0;width:[W]px;height:132px;background:#000;z-index:100"></div>
<div id="lbox-bot" style="position:fixed;bottom:0;left:0;width:[W]px;height:132px;background:#000;z-index:100"></div>
[end if]


[scene HTML from scene-library.md templates, one per selected scene]

<script>
window.__videoReady = false;
window.TOTAL_DURATION_MS = [duration * 1000];

// ── Section timing constants ──────────────────────────────────────────────────
const TEASE_START_MS  = [N];
const TEASE_END_MS    = [N];
const BUILD_START_MS  = [N];
const BUILD_END_MS    = [N];
const REVEAL_START_MS = [N];
const REVEAL_END_MS   = [N];
const PROOF_START_MS  = [N];
const PROOF_END_MS    = [N];
const CTA_START_MS    = [N];
const CTA_END_MS      = [N];

// ── Animation helpers ─────────────────────────────────────────────────────────
function lerp(a, b, p) { return a + (b - a) * p; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3); }
function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; }

function sceneState(t, startMs, endMs) {
  // Standard scene envelope: fade-in, hold, fade-out
  if (t < startMs || t >= endMs) return { opacity: 0, ty: 0 };
  const prog = (t - startMs) / (endMs - startMs);
  if (prog < 0.10) {
    const p = easeOutCubic(prog / 0.10);
    return { opacity: p, ty: lerp(20, 0, p) };
  }
  if (prog < 0.88) return { opacity: 1, ty: 0 };
  // CTA scene: no exit fade (holds until end)
  if (startMs === CTA_START_MS) return { opacity: 1, ty: 0 };
  const p = (prog - 0.88) / 0.12;
  return { opacity: 1 - easeOutCubic(p), ty: lerp(0, -10, p) };
}

function applySceneState(el, state) {
  el.style.opacity = state.opacity.toFixed(3);
  el.style.transform = state.ty !== 0 ? `translateY(${state.ty.toFixed(2)}px)` : '';
}

// ── Film grain (if enabled) ───────────────────────────────────────────────────
[if film-grain: ON]
const grainCanvas = document.getElementById('grain-overlay');
const grainCtx = grainCanvas ? grainCanvas.getContext('2d') : null;
const W_GRAIN = [W], H_GRAIN = [H];
function renderGrain(t) {
  if (!grainCtx) return;
  const d = grainCtx.createImageData(W_GRAIN, H_GRAIN);
  const buf = d.data;
  let seed = (Math.floor(t * 37) ^ 0x5E3779B9) >>> 0;
  for (let i = 0; i < buf.length; i += 4) {
    seed = (seed ^ (seed >>> 13)) >>> 0;
    seed = (seed * 1664525 + 1013904223) >>> 0;
    const n = seed >>> 24;
    buf[i] = buf[i+1] = buf[i+2] = n;
    buf[i+3] = 255;
  }
  grainCtx.putImageData(d, 0, 0);
}
[end if]

// ── Light leak (if enabled) ───────────────────────────────────────────────────
[if light-leak: ON]
const lightLeakEl = document.getElementById('light-leak');
function renderLightLeak(t) {
  if (!lightLeakEl) return;
  const leakStart = REVEAL_START_MS;
  const leakDur = 500; // ms
  const lt = t - leakStart;
  if (lt < 0 || lt > leakDur) { lightLeakEl.style.opacity = 0; return; }
  const p = lt / leakDur;
  const v = p < 0.35 ? p / 0.35 : 1 - (p - 0.35) / 0.65;
  lightLeakEl.style.opacity = (v * 0.55).toFixed(3);
}
[end if]

// ── Main render function ──────────────────────────────────────────────────────
window.renderFrame = function(t) {

  // Cinematic overlays
  [if film-grain: ON] renderGrain(t); [end if]
  [if light-leak: ON] renderLightLeak(t); [end if]

  // ── TEASE SECTION ──
  // blackout-opener
  applySceneState(document.querySelector('.blackout-opener'), sceneState(t, TEASE_START_MS, TEASE_START_MS + 1500));
  // Opener line appears at 40% of its scene
  const openerLine = document.querySelector('.opener-line');
  if (openerLine) {
    const opP = clamp((t - (TEASE_START_MS + 600)) / 500, 0, 1);
    openerLine.style.opacity = easeOutCubic(opP).toFixed(3);
    openerLine.style.transform = `translateY(${lerp(8, 0, easeOutCubic(opP)).toFixed(2)}px)`;
  }

  // tease-problem
  const tpScene = document.querySelector('.tease-problem');
  const tpStart = TEASE_START_MS + 1500;
  const tpEnd = TEASE_END_MS;
  applySceneState(tpScene, sceneState(t, tpStart, tpEnd));
  const words = tpScene ? tpScene.querySelectorAll('.word') : [];
  if (words.length) {
    const stagger = 180, revealStart = tpStart + (tpEnd - tpStart) * 0.15;
    words.forEach((w, i) => {
      const ws = revealStart + i * stagger;
      const wp = clamp((t - ws) / 350, 0, 1);
      w.style.opacity = easeOutCubic(wp).toFixed(3);
      w.style.transform = `translateY(${lerp(20, 0, easeOutCubic(wp)).toFixed(2)}px)`;
    });
    const subLine = tpScene.querySelector('.sub-line');
    if (subLine) {
      const slStart = revealStart + words.length * stagger + 200;
      const slP = clamp((t - slStart) / 400, 0, 1);
      subLine.style.opacity = easeOutCubic(slP).toFixed(3);
    }
  }

  [if countdown-card exists in this video]
  // countdown-card (in tease)
  const cdScene = document.querySelector('.countdown-card');
  if (cdScene) {
    applySceneState(cdScene, sceneState(t, [CD_START_MS], [CD_END_MS]));
    const cdLabel = document.getElementById('cd-label');
    const cdGrid  = document.getElementById('cd-grid');
    const cdDate  = document.getElementById('cd-date');
    if (cdLabel) cdLabel.style.opacity = easeOutCubic(clamp((t - [CD_START_MS]) / 300, 0, 1)).toFixed(3);
    if (cdGrid)  cdGrid.style.opacity  = easeOutCubic(clamp((t - ([CD_START_MS] + 200)) / 400, 0, 1)).toFixed(3);
    if (cdDate)  cdDate.style.opacity  = easeOutCubic(clamp((t - ([CD_START_MS] + 600)) / 400, 0, 1)).toFixed(3);
  }
  [end if]

  // ── BUILD SECTION ──
  // tension-build
  const tbScene = document.querySelector('.tension-build');
  applySceneState(tbScene, sceneState(t, BUILD_START_MS, BUILD_END_MS));
  if (tbScene) {
    // Particle canvas — agent replaces accent hex with literal preset color
    const tbCanvas = document.getElementById('tension-canvas');
    if (tbCanvas && t >= BUILD_START_MS && t < BUILD_END_MS) {
      const tbCtx = tbCanvas.getContext('2d');
      if (!window.__tensionParticles) {
        window.__tensionParticles = Array.from({length: 60}, () => ({
          x: Math.random() * [W], y: Math.random() * [H],
          tx: [W]/2 + (Math.random()-0.5)*60, ty: [H]/2 + (Math.random()-0.5)*60,
          r: Math.random() * 2.5 + 1,
        }));
      }
      const prog = clamp((t - BUILD_START_MS) / (BUILD_END_MS - BUILD_START_MS), 0, 1);
      tbCtx.clearRect(0, 0, [W], [H]);
      window.__tensionParticles.forEach(p => {
        const px = lerp(p.x, p.tx, easeInOutCubic(prog));
        const py = lerp(p.y, p.ty, easeInOutCubic(prog));
        tbCtx.beginPath();
        tbCtx.arc(px, py, p.r, 0, Math.PI * 2);
        tbCtx.fillStyle = '[ACCENT_HEX_FROM_PRESET]'; // replace with literal hex
        tbCtx.globalAlpha = easeOutCubic(prog) * 0.55;
        tbCtx.fill();
        tbCtx.globalAlpha = 1;
      });
    }
    // Build counter (optional label)
    const buildCounter = document.getElementById('build-counter');
    const buildLabel = tbScene.querySelector('.build-label');
    if (buildCounter) {
      const cp = clamp((t - BUILD_START_MS) / ((BUILD_END_MS - BUILD_START_MS) * 0.65), 0, 1);
      buildCounter.style.opacity = easeOutCubic(clamp((t - BUILD_START_MS) / 300, 0, 1)).toFixed(3);
    }
    if (buildLabel) {
      buildLabel.style.opacity = easeOutCubic(clamp((t - (BUILD_START_MS + 600)) / 400, 0, 1)).toFixed(3);
    }
  }

  // ── REVEAL SECTION ──
  // reveal-hero
  const rhScene = document.querySelector('.reveal-hero');
  const rhStart = REVEAL_START_MS;
  const rhEnd   = [REVEAL_END_MS — full section if no tagline-card, else midpoint];
  applySceneState(rhScene, sceneState(t, rhStart, rhEnd));
  if (rhScene) {
    const flash   = document.getElementById('reveal-flash');
    const nameEl  = document.getElementById('product-name-el');
    const tagEl   = document.getElementById('tagline-el');

    // Flash: triangle wave over 350ms at reveal start
    if (flash) {
      const fp = clamp((t - rhStart) / 350, 0, 1);
      const fv = fp < 0.4 ? fp / 0.4 : 1 - (fp - 0.4) / 0.6;
      flash.style.opacity = (fv * 0.65).toFixed(3);
    }
    // Product name: materialise (cinematic/emotional) or slam (energetic) or fade (minimal)
    if (nameEl) {
      const nameStart = rhStart + 220;
      const nameDur = [700 for cinematic/emotional | 120 for energetic | 450 for minimal];
      const np = clamp((t - nameStart) / nameDur, 0, 1);
      nameEl.style.opacity = easeOutCubic(np).toFixed(3);
      // cinematic/emotional: blur
      // [if cinematic or emotional]
      nameEl.style.filter = `blur(${lerp(10, 0, easeOutCubic(np)).toFixed(2)}px)`;
      // [if energetic]
      // nameEl.style.transform = `scale(${lerp(1.12, 1, easeOutCubic(np)).toFixed(4)})`;
    }
    // Tagline: after name
    if (tagEl) {
      const tagStart = rhStart + 220 + [nameDur] + 150;
      const tp = clamp((t - tagStart) / 500, 0, 1);
      tagEl.style.opacity = easeOutCubic(tp).toFixed(3);
    }
  }

  [if tagline-card exists]
  // tagline-card (second beat of Reveal)
  const tcScene = document.querySelector('.tagline-card');
  applySceneState(tcScene, sceneState(t, [TC_START_MS], REVEAL_END_MS));
  if (tcScene) {
    const tcMain = document.getElementById('tagline-main');
    const tcBar  = document.getElementById('tagline-bar');
    if (tcMain) tcMain.style.opacity = easeOutCubic(clamp((t - [TC_START_MS]) / 500, 0, 1)).toFixed(3);
    if (tcBar)  tcBar.style.transform = `scaleX(${easeOutCubic(clamp((t - ([TC_START_MS]+600)) / 200, 0, 1)).toFixed(3)})`;
  }
  [end if]

  // ── PROOF SECTION ──
  [if proof-stat]
  const psScene = document.querySelector('.proof-stat');
  applySceneState(psScene, sceneState(t, PROOF_START_MS, PROOF_END_MS));
  if (psScene) {
    const statEl    = psScene.querySelector('.stat-value');
    const counterEl = document.getElementById('stat-counter');
    const labelEl   = document.getElementById('stat-label');
    const TARGET_NUM = [numeric value — agent fills this in];

    const ap = clamp((t - PROOF_START_MS) / 200, 0, 1);
    if (statEl) statEl.style.opacity = easeOutCubic(ap).toFixed(3);

    const countDur = (PROOF_END_MS - PROOF_START_MS) * 0.60;
    const cp = clamp((t - PROOF_START_MS) / countDur, 0, 1);
    if (counterEl) counterEl.textContent = Math.round(easeOutCubic(cp) * TARGET_NUM).toLocaleString();

    const labelStart = PROOF_START_MS + countDur + 80;
    if (labelEl) labelEl.style.opacity = easeOutCubic(clamp((t - labelStart) / 400, 0, 1)).toFixed(3);
  }
  [end if]

  [if feature-bullet]
  const fbScene = document.querySelector('.feature-bullet');
  applySceneState(fbScene, sceneState(t, PROOF_START_MS, PROOF_END_MS));
  if (fbScene) {
    const iconEl = document.getElementById('bullet-icon');
    const mainEl = document.getElementById('bullet-main');
    const subEl  = document.getElementById('bullet-sub');
    if (iconEl) iconEl.style.transform  = `scaleX(${easeOutCubic(clamp((t - PROOF_START_MS) / 200, 0, 1)).toFixed(3)})`;
    if (mainEl) mainEl.style.opacity    = easeOutCubic(clamp((t - (PROOF_START_MS + 200)) / 500, 0, 1)).toFixed(3);
    if (subEl)  subEl.style.opacity     = easeOutCubic(clamp((t - (PROOF_START_MS + 700)) / 400, 0, 1)).toFixed(3);
  }
  [end if]

  // ── CTA SECTION ──
  const ctaScene = document.querySelector('.cta-card');
  applySceneState(ctaScene, sceneState(t, CTA_START_MS, CTA_END_MS));
  if (ctaScene) {
    const actionEl = document.getElementById('cta-action');
    const urlEl    = document.getElementById('cta-url');
    const accentEl = document.getElementById('cta-accent');
    const subEl    = document.getElementById('cta-sub');
    if (actionEl) actionEl.style.opacity = easeOutCubic(clamp((t - CTA_START_MS) / 350, 0, 1)).toFixed(3);
    if (urlEl) {
      const up = clamp((t - (CTA_START_MS + 250)) / 500, 0, 1);
      urlEl.style.opacity = easeOutCubic(up).toFixed(3);
      // cinematic/emotional: blur reveal
      urlEl.style.filter = `blur(${lerp(6, 0, easeOutCubic(up)).toFixed(2)}px)`;
    }
    if (accentEl) accentEl.style.transform = `scaleX(${easeOutCubic(clamp((t-(CTA_START_MS+800))/200,0,1)).toFixed(3)})`;
    if (subEl) subEl.style.opacity = easeOutCubic(clamp((t-(CTA_START_MS+1050))/400,0,1)).toFixed(3);
  }
};

// ── Preview loop (stopped by Playwright before frame capture) ─────────────────
let __previewActive = false;
let __previewRafId  = null;
window.__stopPreview = function() {
  __previewActive = false;
  if (__previewRafId !== null) { cancelAnimationFrame(__previewRafId); __previewRafId = null; }
};

document.fonts.ready.then(() => {
  window.renderFrame(0);
  window.__videoReady = true;
  __previewActive = true;
  const startTime = performance.now();
  function tick() {
    if (!__previewActive) return;
    const elapsed = performance.now() - startTime;
    if (elapsed < window.TOTAL_DURATION_MS) {
      window.renderFrame(elapsed);
      __previewRafId = requestAnimationFrame(tick);
    } else {
      window.renderFrame(window.TOTAL_DURATION_MS - 1);
      __previewActive = false;
    }
  }
  __previewRafId = requestAnimationFrame(tick);
});
</script>
</body>
</html>

デザイン品質のルール:

  • product_name のフォントサイズ:16:9 では ≥120px、9:16 では ≥80px。必ず守ってください。
  • 見出しの letter-spacing:常に var(--tracking-tight) を使用します。
  • 各シーンにつきアクセントカラーのハイライトは 1 つにします。
  • transform を使用するすべての要素に transform-origin: center center を指定します。
  • .scene 内のパディング:最小 80px — テキストがビューポートの端に触れないようにします。
  • シネマティックプリセット:すべての色に -- トークン変数を使用します。キャンバスの fillStyle 以外で、直接の 16 進数カラーを使用しないでください([ACCENT_HEX_FROM_PRESET] はプリセットのリテラルな 16 進数カラーに置き換えます)。
  • プレースホルダーのテキスト(「キャッチコピーを入力」「TBD」「[INSERT STAT]」)は絶対に書かないでください。

ステップ 4:セルフ QA(ステップ 5 に進む前にすべての失敗を修正)

ナラティブ構成:

  • REVEAL_START_MS より前に product_name が表示されない
  • タグラインは 4〜6 語の自然なフレーズであり、プレースホルダーやありきたりな文句ではない
  • reveal-hero シーンが REVEAL_START_MS から REVEAL_END_MS の範囲内にある
  • cta-card が最後のシーンであり、CTA_START_MS から CTA_END_MS までをカバーしている
  • CTA に実際の URL またはアクションを示すフレーズが含まれており、「[your URL here]」ではない

renderFrame の正確性:

  • window.renderFrame(t) は純粋関数であり、スタイルの書き込み以外の副作用がない
  • シーンの境界判定に t < startMs を使っている(t <= startMs ではない)
  • シーン遷移に animation-delay または @keyframes を使用していない
  • window.__stopPreview() が公開され、__previewActive フラグを確認する
  • フィルムグレインのキャンバスが各フレームで t を使って再シードされる(フレームごとに異なるグレイン)

準備完了シグナル:

  • window.__videoReady = false が fonts.ready より前に宣言されている
  • window.__videoReady = true は document.fonts.ready.then(...) の内部でのみ設定される
  • window.renderFrame(0) が fonts.ready の内部で、__videoReady = true の設定前に呼び出される

レイアウト:

  • html, body に正確なピクセル寸法([W]px、[H]px)を指定している
  • body の幅と高さに %、vw、vh、rem を使用していない
  • html, body に overflow: hidden を指定している
  • すべてのシーンに position: absolute; inset: 0 を指定している

シネマティックエフェクト:

  • フィルムグレインのキャンバス:id="grain-overlay"、position: fixed、すべてのシーンより前面
  • ビネットの div:id="vignette-overlay"、position: fixed、すべてのシーンより前面
  • ライトリークの div:id="light-leak"、初期値は opacity: 0、REVEAL_START_MS で発動
  • レターボックス(有効な場合):2.35:1 の比率に対するバーの高さが正確で、z-index: 100

デザイン:

  • product_name のフォントサイズが 120px 以上(16:9)または 80px 以上(9:16)
  • すべての色がプリセットのトークン変数から指定されている
  • すべてのフォントがプリセットの CDN リンクから指定されている
  • プレースホルダーのテキストが一切ない

効果音:

  • プレビューのループ開始前に window.__sfxTimeline が存在する
  • ms の値は実際のタイミング定数から導出し、推測でハードコードしない
  • reveal-boom が REVEAL_START で正確に再生される
  • cta-chime が CTA_START で正確に再生される
  • 1000ms より前に効果音イベントが再生されない(ブラックアウト中は完全な無音にする)
  • 最後のティーズの単語は vol: 0.65 にする(ほかの単語の 0.55 より大きくする)

ステップ 5:エクスポート

product_name からスラッグを決定します(ケバブケース、30 文字以下):

mkdir -p launch/[slug]

HTML を保存します:launch/[slug]/product-launch.html

ブラウザーでのプレビュー用に開きます:

open launch/[slug]/product-launch.html

エクスポートを実行します([skill-root] をこのスキルのディレクトリへのパスに置き換えてください):

bash [skill-root]/scripts/export-video.sh \
  launch/[slug]/product-launch.html \
  launch/[slug]/product-launch.mp4 \
  --duration [total_duration] \
  --fps [fps] \
  --width [W] \
  --height [H] \
  [--music path/to/audio.mp3]

初回実行時に Playwright(約 200MB の Chromium。初回使用後はキャッシュされます)をインストールし、FFmpeg を検証します。


ステップ 6:出力の概要

## Launch Video: [product_name]
Date: [YYYY-MM-DD] | Tone: [tone] | Duration: [N]s | Aspect: [ratio]
Sections: Tease [0–Ns] → Build [N–Ns] → Reveal [N–Ns] → Proof [N–Ns] → CTA [N–Ns]

Narrative
  Tease:  [problem_statement — 1 sentence]
  Reveal: [product_name] — "[tagline]"
  Proof:  [proof_stat]
  CTA:    [cta]

Files
  Source: launch/[slug]/product-launch.html
  Output: launch/[slug]/product-launch.mp4

Checklist
- [ ] Product name does not appear before reveal section
- [ ] Reveal moment feels distinct — flash/materialise visible
- [ ] Tagline is present and legible
- [ ] Proof stat counter animates
- [ ] CTA URL readable at final frame
- [ ] No blank frames

プロンプトのヒント(ユーザーがガイダンスを求めたときに表示)

「タグラインは自分で書いてください。動画の中で最も重要な 4〜6 語です。手を抜いて AI に生成させないでください。」

「リビールの瞬間がすべてです。それまでのすべての要素で緊張感を高め、リビールにふさわしい展開にしてください。」

「実績のセクションでは、メリットを 1 つだけ示してください。5 つの機能を見せようとすると、ローンチ動画のテンポが崩れます。」

「市場に合わせてトーンを選んでください:B2C のプレミアム製品やシリーズ A 以降にはシネマティック、開発者向けツールや SaaS にはエネルギッシュ、デザイン重視の製品にはミニマル、消費者向けまたはミッション主導の製品にはエモーショナルなトーンが適しています。」

良い例:「60 秒の製品ローンチ動画。製品:OpenDirectory。説明:Claude、Codex、Gemini 向けの、すぐに使える AI エージェントスキルのライブラリです。GTM、コンテンツ、リサーチ、開発者向けツールをカバーします。タグライン:「AI スキルを、すぐにインストール。」トーン:ミニマル。実績:「52 以上のスキル、セットアップ不要。」CTA:「opendirectory.dev でスキルを見る。」アスペクト比:16:9。」

❌ 悪い例:「新製品のローンチ動画」

コメント