Generates a cinematic product launch video (MP4) from a product description and launch context. 5-section narrative arc — Tease, Build, Reveal, Proof, CTA — rendered as HTML/CSS in headless Chromium via Playwright, assembled with FFmpeg. 4 tone presets. 30/60/90 second durations. Trigger when user says "product launch video", "launch video", "product reveal video", "cinematic product video", "product announcement video", or "launch day video".
npx skills add https://github.com/Varnan-Tech/opendirectory --skill vid-product-launch
Generates a narrative MP4 product launch video from a product description and launch context.
Pipeline: HTML/CSS animations → headless Chromium (Playwright, frame-by-frame) → FFmpeg (H.264 MP4).
No React. No AI video APIs. No Python. Zero runtime cost beyond Playwright + FFmpeg.
window.renderFrame(t) — no CSS @keyframes for scene transitions. CSS currentTime seeking is silently ignored for backward seeks in Chromium. The renderFrame function computes element styles directly from milliseconds. Playwright calls it once per frame.animation-delay on ANY element. Not needed with renderFrame. If you write animation-delay, stop — you are using the wrong architecture.window.__videoReady = true only inside document.fonts.ready.then(...). Never set synchronously.window.__stopPreview(). The rAF preview loop races with Playwright's evaluate/screenshot calls. The capture script calls __stopPreview() before the frame loop.t < startMs (not t <= startMs) in scene boundary checks. t <= 0 at frame 0 makes scene 1 black.10. Body = exact pixel dimensions. Width and height are integers. No %, vw/vh, rem.
11. Read references/scene-library.md AND references/style-presets.md before generating ANY HTML.
12. Never dump HTML in chat. Save to file. Show summary only.
13. Film grain canvas MUST be 240×135, not W×H. Set width="240" height="135" on the canvas element, then stretch with style="width:[W]px;height:[H]px". Full-resolution grain at 1920×1080 is 64× slower — 8MB of ImageData per frame — and will make 1800-frame exports take hours.
14. The tease section MUST be dark (#000 or near-black), regardless of tone. White backgrounds in the tease section read as demo slides, not product launch videos. The dark-to-light narrative arc (dark tease → dark build → product reveal) is how launch videos create drama. Even the minimal and energetic presets should use background: #000 for tease-problem and tension-build scenes.
15. Each tease word must be its own beat at 200px+ font size. Word-by-word on a single line is not punchy enough. Each problem word (e.g. "Research." / "Write." / "Outreach." / "Repeat.") gets 1500–1800ms of screen time at font-size: 200px; font-weight: 900 centered, one at a time. See scene-library.md tease-words pattern.
16. Build section must show content that narrates the problem — not just particles. Use the terminal-card pattern: 3 cards appear sequentially with a typewriter animation showing the manual work being done. Cards have border: 1px solid rgba(255,255,255,0.07), blue dot accent, monospace text. Particles alone for 20 seconds is empty screen time.
17. Dot-grid CSS background on dark scenes. Add background-image: radial-gradient(circle, rgba(255,255,255,0.035) 1px, transparent 1px); background-size: 60px 60px; to build, reveal, proof, and CTA scenes. It costs zero compute and adds depth.
Required:
product_name — the name of the product or feature being launchedproduct_description — 2–3 sentences: what it does, who it's for, key benefitOptional parameters and defaults:
| Parameter | Default | Description |
|---|---|---|
| tagline | auto | 4–6 words — the product's core promise |
| problem_statement | auto-inferred | 1 sentence for tease section (the pain the product solves) |
| proof_stat | auto-inferred | Single metric (e.g. "500+ teams", "10× faster", "$2M saved") |
| cta | auto | URL or action phrase (e.g. "opendirectory.dev", "Join the waitlist") |
| launch_date | — | ISO date string (enables countdown-card scene) |
| tone | cinematic | cinematic / energetic / minimal / emotional |
| duration | 60 | 30 / 60 / 90 (seconds) |
| aspect_ratio | 16:9 | 16:9 (1920×1080) / 9:16 (1080×1920) |
| letterbox | false | Cinematic 2.35:1 black bars — 16:9 only |
| music | — | Path to audio file (mp3/m4a/wav) |
| fps | 30 | Frames per second (24, 30, or 60) |
If product_name or product_description is missing, ask exactly:
> "To create the launch video, I need two things:
>
> 1. Product name — what is the product called?
> 2. Product description — 2–3 sentences: what does it do, who is it for, what is the key benefit?
>
> Optional: tagline (4–6 words), proof stat (one number), CTA (URL or action phrase), tone (cinematic / energetic / minimal / emotional), duration (30 / 60 / 90s)."
If both are present → proceed to Step 2 immediately.
1. Derive missing params:
2. Calculate section timing from duration:
| Section | 30s | 60s | 90s | Start formula |
|---|---|---|---|---|
| Tease | 0–5s | 0–10s | 0–15s | 0ms |
| Build | 5–12s | 10–30s | 15–40s | Tease end |
| Reveal | 12–20s | 30–45s | 40–60s | Build end |
| Proof | 20–25s | 45–55s | 60–75s | Reveal end |
| CTA | 25–30s | 55–60s | 75–90s | Proof end |
Convert every boundary to milliseconds. Assign to constants:
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. Select scenes per section:
blackout-opener (1500ms). Then tease-words (each problem keyword as its own 1600–1800ms beat at 200px+). If launch_date provided, optionally add countdown-card at end of tease.terminal-card sequence (3 cards showing manual tasks being typed in) + optional closing accent line. Duration ≥ 30s gets all 3 cards. Duration = 30s uses 2 cards. Never use bare particles-only for more than 5s — the screen must show content that narrates the problem.reveal-hero — always first in this section. Optionally followed by tagline-card if duration ≥ 60s.proof-stat if a numeric stat is available. Otherwise feature-bullet.cta-card — always, always last.Typical scene counts: 30s = 4 scenes, 60s = 6–7 scenes, 90s = 7–8 scenes.
4. Calculate per-scene timing:
Within each section, divide time equally across scenes in that section.
Exception: blackout-opener always gets exactly the first 1500ms of Tease.
5. Determine pixel dimensions:
16:9 → W=1920, H=10809:16 → W=1080, H=19206. Letterbox calculation (if enabled, 16:9 only):
top: 0; height: 132pxbottom: 0; height: 132px7. Cinematic effects flags (by preset):
cinematic: film-grain=ON (canvas 240×135, opacity 0.025), vignette=ON, light-leak=ON (warm gold), dot-grid=ON on dark scenesenergetic: film-grain=OFF, vignette=OFF, dot-grid=ON, white flash at revealminimal: film-grain=ON (canvas 240×135, opacity 0.022), vignette=ON (subtle, 0.5 radial), dot-grid=ON, no light-leak — accent color = #4B9FFF (electric blue)emotional: film-grain=ON (canvas 240×135, opacity 0.018, warm tint blend), vignette=ON (soft), light-leak=ON (copper warm), dot-grid=OFFAll presets: tease and build scenes ALWAYS use dark background (#000 or near-black), regardless of preset.
8. Embed window.__sfxTimeline in the HTML (always — even when no music is provided):
The export script reads this array, synthesizes each SFX type natively with FFmpeg aevalsrc/anoisesrc, and places events at exact millisecond offsets using adelay. No external audio files required.
SFX type reference:
| Type | Sound | Duration | Notes |
|---|---|---|---|
| word-hit | Sub punch (50Hz) + transient click (2.2kHz) + noise burst | 180ms | One per tease word — 3 layers via amix |
| type-sequence | Mechanical keyboard clicks at 15Hz (sin^30 pulse envelope) | 1.9s | One per card — runs for full TYPE_DUR |
| whoosh | Two-band noise sweep (1.1kHz body + 4–8kHz air) | 700ms | At BUILD_CLOSE transition |
| tension-riser | Low rumble (250Hz) + sub tone growing with t/2.8 ramp | 2.9s | Start at BUILD_CLOSE + 700ms — peaks at REVEAL_START |
| reveal-boom | Sub (45Hz) + body (90Hz) + shimmer (4.5–11kHz) + 85ms echo | 900ms | Exactly at REVEAL_START — THE hit |
| counter-tick | Harmonic click (880Hz + 440Hz + 1760Hz + 2640Hz) | 80ms | 3 beats — decrescendo 0.32 → 0.26 → 0.20 |
| cta-chime | A major chord (440 + 554 + 659 + 880Hz) + aecho bell shimmer | 1.2s | Exactly at CTA_START |
SFX timing map — compute ms values from section constants, never hardcode:
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 },
];
Place this block immediately before the preview loop, after window.renderFrame.
Read references/scene-library.md AND references/style-presets.md before writing any code.
Use the exact CSS class names, HTML structure, and renderFrame patterns from those files.
Required HTML skeleton:
<!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>
<!-- Cinematic overlay elements — rendered on top of all scenes -->
[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]
<!-- Scenes -->
[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>
Design quality rules:
product_name font size: ≥120px for 16:9, ≥80px for 9:16. Non-negotiable.var(--tracking-tight) — always.transform-origin: center center on every element that uses transform..scene: minimum 80px — text must never touch viewport edges.-- token variables. No free hex except on canvas fillStyle (replace [ACCENT_HEX_FROM_PRESET] with literal hex from preset).Narrative structure:
REVEAL_START_MSreveal-hero scene falls within REVEAL_START_MS to REVEAL_END_MScta-card is the final scene, covering CTA_START_MS to CTA_END_MSrenderFrame correctness:
window.renderFrame(t) is a pure function — no side effects outside style writest < startMs (not t <= startMs)animation-delay or @keyframes for scene transitionswindow.__stopPreview() exposed and checks __previewActive flagt each frame (different grain per frame)Readiness signal:
window.__videoReady = false declared before fonts.readywindow.__videoReady = true set ONLY inside document.fonts.ready.then(...)window.renderFrame(0) called inside fonts.ready BEFORE setting __videoReady = trueLayout:
html, body use exact pixel dimensions ([W]px, [H]px)%, vw, vh, rem on body width/heightoverflow: hidden on html, bodyposition: absolute; inset: 0Cinematic effects:
id="grain-overlay", position: fixed, above all scenesid="vignette-overlay", position: fixed, above all scenesid="light-leak", opacity: 0 initially, fires at REVEAL_START_MSz-index: 100Design:
product_name font size ≥ 120px (16:9) or ≥ 80px (9:16)Sound effects:
window.__sfxTimeline present before the preview loopms values derived from actual timing constants — never hardcoded guessesreveal-boom fires at exactly REVEAL_STARTcta-chime fires at exactly CTA_STARTvol: 0.65 (louder than others at 0.55)Determine slug from product_name (kebab-case, ≤30 chars):
mkdir -p launch/[slug]
Save HTML: launch/[slug]/product-launch.html
Open for browser preview:
open launch/[slug]/product-launch.html
Run export (replace [skill-root] with path to this skill's directory):
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]
First run installs Playwright (~200MB Chromium, cached after first use) and verifies FFmpeg.
## 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
> "Write the tagline yourself. It is the most important 4–6 words in the video — don't AI-generate it lazily."
>
> "The reveal moment is everything. Everything before it builds tension; the reveal must feel earned."
>
> "One benefit in the proof section. Trying to show 5 features kills launch video pacing."
>
> "Match tone to your market: cinematic for B2C premium / Series A+, energetic for dev tools and SaaS, minimal for design-forward products, emotional for consumer / mission-driven."
>
> Good: "Product launch video, 60 seconds. Product: OpenDirectory. Description: A library of pre-built AI agent skills for Claude, Codex, and Gemini. Covers GTM, content, research, and developer tools. Tagline: 'AI skills, ready to install.' Tone: minimal. Proof: '52+ skills, zero setup.' CTA: 'Browse skills at opendirectory.dev.' Aspect: 16:9."
>
> ❌ Bad: "launch video for our new product"
Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.
Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.
Improves the quality of images, especially screenshots, by enhancing resolution, sharpness, and clarity. Perfect for preparing images for presentations, documentation, or social media posts.
Downloads videos from YouTube and other platforms for offline viewing, editing, or archival. Handles various formats and quality options.
Lightweight WSI tile extraction and preprocessing. Use for basic slide processing tissue detection, tile extraction, stain normalization for H&E images. Best for simple pipelines, dataset preparation, quick tile-based analysis. For advanced spatial proteomics, multiplexed imaging, or deep learning pipelines use pathml.
Microscopy data management platform. Access images via Python, retrieve datasets, analyze pixels, manage ROIs/annotations, batch processing, for high-content screening and microscopy workflows.
Python library for working with DICOM (Digital Imaging and Communications in Medicine) files. Use this skill when reading, writing, or modifying medical imaging data in DICOM format, extracting pixel data from medical images (CT, MRI, X-ray, ultrasound), anonymizing DICOM files, working with DICOM metadata and tags, converting DICOM images to other formats, handling compressed DICOM data, or processing medical imaging datasets. Applies to tasks involving medical image analysis, PACS systems, radiology workflows, and healthcare imaging applications.
This skill should be used when working with pre-trained transformer models for natural language processing, computer vision, audio, or multimodal tasks. Use for text generation, classification, question answering, translation, summarization, image classification, object detection, speech recognition, and fine-tuning models on custom datasets.
Take varnan-tech/vid-product-launch from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.