Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Generates motion graphics videos (MP4) from a content brief. Multi-scene HTML/CSS animations rendered frame-by-frame in headless Chromium via Playwright, assembled with FFmpeg. 1080×1080 default, 16:9 (1920×1080) and 9:16 (1080×1920) supported. 5 style presets. Trigger when user says "create a video", "motion graphic", "animated video", "make a reel", "create an explainer", "animated infographic", or "short video".
.claude/skills/varnan-tech-vid-motion-graphics/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 106% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 135% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 185% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 230% | 0% |
Generates multi-scene motion graphics as MP4. Renders HTML/CSS animations in headless Chromium via Playwright (Web Animations API frame-seeking), assembles PNG frames with FFmpeg. No React, no AI APIs, no Python — zero new dependencies beyond the graphic-gif family.
CDN fonts only. No external libraries in HTML.
window.renderFrame(t) — no CSS @keyframes for scene transitions. CSS animation currentTime seeking is silently ignored for backward seeks in Chromium. The renderFrame approach: a pure JS function computes opacity/transform directly from milliseconds. Playwright calls it once per frame. Deterministic, race-free.animation-delay on ANY element. Not needed with renderFrame. If you catch yourself writing animation-delay, stop — you're using the wrong architecture.window.__videoReady = true only inside document.fonts.ready.then(...). Never set synchronously — fonts must load before Playwright captures frame 1 or text renders with fallback fonts.window.__stopPreview(). The browser's rAF preview loop races with Playwright's evaluate/screenshot calls. capture-frames.mjs calls __stopPreview() before the frame loop. Always include it.t < startMs (not <=) in scene boundary checks. t <= 0 at frame 0 makes scene 1 black. The correct guard is if (t < startMs || t >= endMs) return hidden.1080px, 1920px). No %, vw/vh, or responsive units.opacity: 0 outside their renderFrame window.opacity only. No display toggle, no visibility — GPU-composited opacity is frame-perfect.references/scene-library.md before generating ANY HTML. Use exact HTML structure and CSS class names from that file.Required: content_brief
Optional parameters and defaults:
| Parameter | Default | Description | |---|---|---| | content_brief | — | Text describing what the video communicates (required) | | scenes | auto | Number of scenes (1–6). Auto = derived from brief. | | duration_per_scene | 3s | Duration per scene in seconds (1–8s) | | style | kinetic-dark | kinetic-dark / editorial-light / data-pulse / bold-type / minimal-clean | | aspect_ratio | 1:1 | 1:1 (1080×1080) / 16:9 (1920×1080) / 9:16 (1080×1920) | | fps | 30 | Frames per second (24, 30, or 60) | | music | none | Path to audio file for background track (mp3/m4a/wav) | | source | — | Source attribution shown in final frame footer |
If content_brief is missing, ask exactly:
> "To create the video, I need a content brief — what should the video communicate? > > Example: 'Show 3 reasons why Q4 revenue grew 85%: new enterprise deals, reduced churn, price increase. Use bold numbers. Style: data-pulse.' > > Optional: style (default: kinetic-dark), aspect ratio (default: 1:1), seconds per scene (default: 3s)"
If content_brief is present → proceed to Step 2 immediately.
1. Parse brief into scenes (max 6):
2. Read references/scene-library.md — choose scene type for each scene:
title-cardstat-revealbullet-listsplit-screenquote-cardcta-card3. Read references/style-presets.md — load CSS tokens + animation personality for chosen style.
4. Calculate timing:
totalDuration = sceneCount × duration_per_scene (seconds)
totalFrames = totalDuration × fpsEach scene occupies (100 / sceneCount)% of the total @keyframes range.
| Scene | Start % | End % | |---|---|---| | 1 | 0% | (100/N)% | | 2 | (100/N)% | (200/N)% | | … | … | … | | N | ((N-1)×100/N)% | 100% |
Within each scene's range:
5. Determine pixel dimensions:
1:1 → W=1080, H=108016:9 → W=1920, H=10809:16 → W=1080, H=1920Read references/scene-library.md AND references/style-presets.md before writing any code.
Required HTML structure:
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: 960px; } /* Scene-type CSS from scene-library.md */ [paste scene-type CSS here] </style> </head> <body> <div class="scene scene-1"> <div class="scene-inner"> [scene 1 HTML from scene-library.md template] </div> </div> [repeat for each scene] <script> window.__videoReady = false; window.TOTAL_DURATION_MS = [totalDuration * 1000]; // ── 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 sceneState(t, startMs, endMs) { 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(24, 0, p) }; } if (prog < 0.85) return { opacity: 1, ty: 0 }; const p = (prog - 0.85) / 0.15; return { opacity: 1 - p, ty: lerp(0, -12, p) }; } function applySceneState(el, state) { el.style.opacity = state.opacity; el.style.transform = state.ty !== 0 ? `translateY(${state.ty.toFixed(2)}px)` : ''; } // ── Main render function — called by Playwright once per frame ──────────────── window.renderFrame = function(t) { // Scene 1: 0ms – [D]ms applySceneState(document.querySelector('.scene-1'), sceneState(t, 0, [D])); // Scene 2: [D]ms – [2D]ms applySceneState(document.querySelector('.scene-2'), sceneState(t, [D], [2D])); // ... repeat per scene ... }; // ── 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 previewTick() { if (!__previewActive) return; const elapsed = performance.now() - startTime; if (elapsed < window.TOTAL_DURATION_MS) { window.renderFrame(elapsed); __previewRafId = requestAnimationFrame(previewTick); } else { window.renderFrame(window.TOTAL_DURATION_MS - 1); __previewActive = false; } } __previewRafId = requestAnimationFrame(previewTick); }); </script> </body> </html>
Design quality rules:
-0.02em to -0.04emrgba(255,255,255,0.10), never solidrgba(0,0,0,0.10), never solidtransform-origin: center center on every element that uses transform.scene: minimum 80px — never let text touch viewport edgesrenderFrame correctness:
window.renderFrame(t) defined — pure function, no side effects outside style writest < startMs (not t <= startMs) — avoids black frame 0animation-delay or @keyframes for scene transitionsopacity: 1 windows (except 10% enter overlap)window.__stopPreview() exposed and preview rAF loop checks __previewActiveReadiness signal:
window.__videoReady = false declared before document.fonts.readywindow.__videoReady = true set ONLY inside document.fonts.ready.then(...)window.renderFrame(0) called inside document.fonts.ready.then(...) before setting __videoReady = trueLayout:
html, body use exact pixel dimensions ([W]px, [H]px)%, vw, vh, rem units on body width/heightoverflow: hidden on html, bodyposition: absolute; inset: 0Design:
source param providedDetermine slug from brief content (kebab-case, ≤30 chars):
bashmkdir -p chart/[slug]
Save HTML: chart/[slug]/video.html
Browser preview:
bashopen chart/[slug]/video.html
Run export (replace [skill-root] with path to this skill's directory):
bashbash [skill-root]/scripts/export-video.sh \ chart/[slug]/video.html \ chart/[slug]/video.mp4 \ --duration [totalDuration] \ --fps [fps] \ --width [W] \ --height [H] \ [--music path/to/audio.mp3]
The script installs Playwright on first run (~200MB Chromium), captures all frames, then assembles MP4 with FFmpeg.
## Video: [title from brief]
Date: [YYYY-MM-DD] | Scenes: [N] | Style: [style] | Aspect: [ratio]
Duration: [N]s | FPS: [fps] | Frames: [N]
Files
Source: chart/[slug]/video.html
Output: chart/[slug]/video.mp4
Size: [X] MB
Checklist
- [ ] All scenes appear in sequence with no blank frames
- [ ] Text legible at mobile thumbnail size
- [ ] Scene transitions smooth (no jump cuts)
- [ ] Final scene includes CTA or closing message
- [ ] Source attribution present in final frame (if provided)> "Provide a content brief — bullet points of what each scene should say." > > "Name the insight directly. '3 reasons Q4 grew 85%' gives the video a spine." > > "Mention the style if you have a preference: kinetic-dark (default), editorial-light, data-pulse, bold-type, minimal-clean." > > "Specify aspect ratio for the platform: 1:1 for LinkedIn/Instagram feed, 9:16 for Stories/Reels, 16:9 for YouTube/presentations." > > ✅ Good: "Create a 9-second video. Q4 revenue hit $4.2M (85% growth). Drivers: enterprise deals, churn 1.2%, price increase. CTA: acme.com/q4. Style: data-pulse. Aspect: 1:1." > > ❌ Bad: "make a video about our company"
Other measured skills in the registry, with their headline benchmark lift.