mcpField
V
vid-product-launch

vid-product-launch

github.com/varnan-tech创意与媒体68747 次安装2 周前更新
代码仓库

关于 vid-product-launch

根据产品描述和发布背景生成叙事式产品发布 MP4 视频。流程使用 HTML/CSS 动画、Playwright 驱动的无头 Chromium 逐帧捕获,并由 FFmpeg 编码。

主要功能

  1. 01生成叙事式发布视频
  2. 02使用 HTML 和 CSS 制作动画
  3. 03通过 Playwright 捕获帧
  4. 04用 FFmpeg 编码 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 个词概括产品的全部承诺。如果用户没有提供标语,就根据描述拟定一个——要简练有力,采用主动语态。绝不要写「[此处填写标语]」,也不要留空。

  2. 揭晓时刻必须水到渠成。 铺垫和递进环节是为了营造紧张感。如果产品名称在视频前 20% 就出现,叙事就会崩塌。揭晓环节之前,绝不要透露产品名称。

  3. product_name 字号:16:9 最小 120px,9:16 最小 80px。 揭晓时,名称必须是画面中最大的元素。如果不够醒目,揭晓就会失败。

  4. 只用一个证明数据,不要列清单。 如果用户提供了多个数据,选出最有力的一个。列出 5 个数字会削弱冲击力,一个超大数字才能带来冲击力。

  5. 使用 window.renderFrame(t)——场景转场不要用 CSS @keyframes。 在 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. 胶片颗粒画布必须是 240×135,而不是 W×H。 在 canvas 元素上设置 width="240" height="135",然后使用 style="width:[W]px;height:[H]px" 拉伸。1920×1080 全分辨率颗粒的计算速度慢 64 倍——每帧占用 8MB ImageData——会让 1800 帧的导出耗时数小时。

  14. 无论基调如何,铺垫环节都必须是暗色(#000 或近黑色)。 铺垫环节使用白色背景会像演示幻灯片,而不像产品发布视频。暗到亮的叙事弧线(暗色铺垫 → 暗色递进 → 产品揭晓)正是发布视频营造戏剧感的方式。即使是 minimal 和 energetic 预设,铺垫问题和紧张递进场景也应使用 background: #000。

  15. 铺垫中的每个词都必须单独成拍,字号至少 200px。 将词语逐个放在同一行,冲击力不够。每个问题词(例如「研究。」/「写作。」/「外联。」/「重复。」)都应单独居中显示 1500–1800ms,使用 font-size: 200px; font-weight: 900。参见 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+ 团队”“快 10×”“节省 $2M”)
ctaautoURL 或行动短语(例如“opendirectory.dev”“加入候补名单”)
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,请原样询问:

“要制作发布视频,我需要两项信息:

  1. 产品名称 — 产品叫什么?
  2. 产品描述 — 2–3 句话:它有什么功能、面向谁、核心优势是什么?

可选:标语(4–6 个词)、证明数据(一个数字)、CTA(URL 或行动短语)、基调(cinematic / energetic / minimal / emotional)、时长(30 / 60 / 90 秒)。”

如果两项信息都已提供 → 立即进入第 2 步。


第 2 步:内部架构(绝不向用户展示)

1. 补全缺失参数:

  • tagline(如果未提供):根据 product_description 撰写一句。4–6 个词。使用主动语态。不加赘词(“未来的……”“隆重推出……”)。提炼出最有力的承诺。
  • problem_statement(如果未提供):根据 product_description 推断。用 1 句话描述痛点。绝不提及产品名称。措辞应让受众产生共鸣。
  • proof_stat(如果未提供):根据 product_description 推断。如果没有可用数据,则使用可信的规模指标(“深受 500+ 团队信赖”“从几天缩短到几分钟”)。
  • cta(如果未提供):如果可根据 product_name 推断产品域名,则使用该域名;否则使用“了解更多”作为行动短语。

2. 根据 duration 计算各部分的时长:

部分30 秒60 秒90 秒开始时间公式
悬念0–5 秒0–10 秒0–15 秒0 毫秒
铺垫5–12 秒10–30 秒15–40 秒悬念结束时间
揭晓12–20 秒30–45 秒40–60 秒铺垫结束时间
证明20–25 秒45–55 秒60–75 秒揭晓结束时间
CTA25–30 秒55–60 秒75–90 秒证明结束时间

将每个边界转换为毫秒。赋值给常量:

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(1500 毫秒)开场。然后使用 tease-words(每个问题关键词单独呈现,每段 1600–1800 毫秒,字号不小于 200px)。如果提供了 launch_date,可在悬念结尾处添加 countdown-card。
  • 铺垫: 使用 terminal-card 序列(3 张卡片,展示手动输入任务的过程)+ 可选的收尾强调语。时长 ≥ 30 秒时使用全部 3 张卡片。时长 = 30 秒时使用 2 张卡片。纯粒子画面不得超过 5 秒 — 屏幕必须展示能讲述问题的内容。
  • 揭晓: reveal-hero — 始终作为本部分的第一个场景。时长 ≥ 60 秒时,可在后面加上 tagline-card。
  • 证明: 有可用的数字数据时使用 proof-stat。否则使用 feature-bullet。
  • CTA: 始终使用 cta-card,并且始终放在最后。

典型场景数量:30 秒 = 4 个场景,60 秒 = 6–7 个场景,90 秒 = 7–8 个场景。

4. 计算每个场景的时长: 在每个部分内,将时长平均分配给该部分的场景。 例外:blackout-opener 始终占用悬念部分开头的 1500 毫秒。

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:胶片颗粒=开启(画布 240×135,不透明度 0.025)、暗角=开启、漏光=开启(暖金色)、暗场景启用点阵网格
  • energetic:胶片颗粒=关闭、暗角=关闭、点阵网格=开启,揭示时闪白
  • minimal:胶片颗粒=开启(画布 240×135,不透明度 0.022)、暗角=开启(微妙,0.5 径向)、点阵网格=开启、无漏光——强调色 = #4B9FFF(电光蓝)
  • emotional:胶片颗粒=开启(画布 240×135,不透明度 0.018,混合暖色调)、暗角=开启(柔和)、漏光=开启(暖铜色)、点阵网格=关闭

所有预设:铺垫和构建场景始终使用深色背景(#000 或接近黑色),不受预设影响。

8. 在 HTML 中嵌入 window.__sfxTimeline(始终如此,即使未提供音乐):

导出脚本会读取此数组,使用 FFmpeg aevalsrc/anoisesrc 原生合成每种 SFX 类型,并通过 adelay 将事件放置在精确的毫秒偏移处。无需外部音频文件。

SFX 类型参考:

类型声音时长备注
word-hit低频重击(50Hz)+ 瞬态点击声(2.2kHz)+ 噪声爆发180ms每个铺垫词一个——通过 amix 混合 3 个图层
type-sequence15Hz 机械键盘点击声(sin^30 脉冲包络)1.9s每张卡片一个——持续整个 TYPE_DUR
whoosh双频段噪声扫频(1.1kHz 主体 + 4–8kHz 空气感)700ms在 BUILD_CLOSE 转场时
tension-riser低频轰鸣(250Hz)+ 随 t/2.8 渐强的低音2.9s从 BUILD_CLOSE + 700ms 开始——在 REVEAL_START 达到峰值
reveal-boom低音(45Hz)+ 主体(90Hz)+ 闪烁高音(4.5–11kHz)+ 85ms 回声900ms恰好在 REVEAL_START——关键重击
counter-tick谐波点击声(880Hz + 440Hz + 1760Hz + 2640Hz)80ms3 拍——渐弱至 0.32 → 0.26 → 0.20
cta-chimeA 大调和弦(440 + 554 + 659 + 880Hz)+ aecho 铃声闪烁音1.2s恰好在 CTA_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。不可妥协。
  • 标题字距:始终使用 var(--tracking-tight)。
  • 每个场景突出显示一种强调色。
  • 每个使用 transform 的元素都设置 transform-origin: center center。
  • .scene 内部边距:至少 80px——文本不得贴近视口边缘。
  • 电影预设:所有颜色均来自 -- 令牌变量。除画布 fillStyle 外不得使用自由十六进制色值(将 [ACCENT_HEX_FROM_PRESET] 替换为预设中的字面十六进制值)。
  • 绝不编写占位文本(“在此填写你的标语”、“待定”、“[INSERT STAT]”)。

步骤 4:自检(修复所有失败项后再进行步骤 5)

叙事结构:

  • product_name 不会在 REVEAL_START_MS 之前出现
  • 标语是一个由 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 重新设定随机种子(每帧颗粒效果不同)

就绪信号:

  • 在 fonts.ready 之前声明 window.__videoReady = false
  • 仅在 document.fonts.ready.then(...) 内设置 window.__videoReady = true
  • 在设置 __videoReady = true 之前,于 fonts.ready 内调用 window.renderFrame(0)

布局:

  • 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 之前不会触发任何音效(黑屏静音必须是真正的静音)
  • 最后一个 tease 词的 vol: 0.65(比其他词的 0.55 更响)

步骤 5:导出

根据 product_name 确定 slug(kebab-case,≤30 个字符):

mkdir -p launch/[slug]

保存 HTML:launch/[slug]/product-launch.html

打开浏览器预览:

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

运行导出(将 [skill-root] 替换为此 skill 目录的路径):

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 生成。"

"揭晓时刻至关重要。在此之前的一切都在营造紧张感;揭晓必须让人觉得水到渠成。"

"证明环节只展示一个优势。试图展示 5 个功能会破坏发布视频的节奏。"

"根据市场匹配基调:面向高端 B2C / Series A+,采用电影感;面向开发者工具和 SaaS,采用充满活力的风格;面向注重设计的产品,采用简约风格;面向消费类 / 使命驱动型产品,采用情感化风格。"

示例:"产品发布视频,60 秒。产品:OpenDirectory。描述:为 Claude、Codex 和 Gemini 提供预构建 AI Agent 技能的库。涵盖 GTM、内容、研究和开发者工具。标语:'AI 技能,即装即用。' 基调:简约。证明:'52+ 项技能,零配置。' CTA:'访问 opendirectory.dev 浏览技能。' 画幅:16:9。"

❌ 错误:"为我们的新产品制作发布视频"

评论

支持 Markdown