From social-media-creator
Create animated video posts from HTML/CSS animations rendered to MP4. Use when the user asks to "create a video", "make an animated post", "render a video for social media", "create a Reel", "make a TikTok video", "create an animated social media video", or wants video content from HTML/CSS animations.
How this skill is triggered — by the user, by Claude, or both
Slash command
/social-media-creator:create-animated-videoThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Create animated video posts using HTML/CSS animations captured frame-by-frame via Puppeteer and encoded to MP4 with ffmpeg.
Create animated video posts using HTML/CSS animations captured frame-by-frame via Puppeteer and encoded to MP4 with ffmpeg.
npm install puppeteer| Name | Width | Height | Use Case |
|---|---|---|---|
| 9x16 | 1080 | 1920 | Instagram Reels, TikTok, Stories |
| 4x5 | 1080 | 1350 | Instagram Feed |
| 1x1 | 1080 | 1080 | Facebook, Instagram Square |
| 16x9 | 1920 | 1080 | YouTube, Facebook Video |
Voiceover must be ~2 seconds shorter than video duration. If video is 25s, voiceover script should target ~23s of speech. This leaves breathing room at the end for the CTA/branding to linger without speech.
First animation must be FAST (within 0.3-0.5s). The voiceover starts speaking immediately — the first text/visual element must appear almost instantly so there is no dead silence with a blank screen. Scene 1 elements should have animation-delay: 0.3s to 0.5s, NOT 1-2 seconds.
No long silent gaps. Every scene transition should overlap slightly (0.5s) so the viewer always sees content while the voiceover is speaking. Dead air with no text visible = bad.
Video duration is the master clock. Design the video first, then write the voiceover script to FIT the video timing. If the voiceover is longer than (video - 2s), shorten the script — do NOT extend the video.
| Scene Position | First element delay | Animation duration |
|---|---|---|
| Scene 1 (Hook) | 0.3–0.5s | 0.5–0.7s (fast!) |
| Middle scenes | scene_start + 0.3s | 0.6–0.8s |
| Last scene (CTA) | scene_start + 0.3s | 0.6s |
Scene 1 (0–6s): dur=6s, Hook text appears at 0.3s — voiceover starts immediately
Scene 2 (6–13s): dur=7s, Content slides in at 6.3s — seamless from scene 1
Scene 3 (13–19s): dur=6s, Next point at 13.3s — seamless from scene 2
Scene 4 (19–23s): dur=4s, Key insight at 19.3s — seamless from scene 3
Scene 5 (23–25s): dur=2s, CTA + branding — voiceover ends here (~23s), branding lingers
Verify: 6+7+6+4+2 = 25s total. Each delay = previous delay + previous duration. No gaps.
Videos use a scene-based system. Each scene is a <div> that becomes visible at a specific time using CSS animation-delay. Getting this wrong causes scenes to overlap (both visible at once) which is the most common and destructive bug in video rendering.
Why this matters: Puppeteer renders videos frame-by-frame by setting document.getAnimations().forEach(a => a.currentTime = t). This means ALL animations exist simultaneously and their timing is controlled externally. If the base .scene class isn't set up correctly, multiple scenes can be visible at the same frame, creating ugly overlaps.
.scene class — MANDATORY patternThe base .scene class controls scene hiding/showing. It must:
opacity: 0 and visibility: hidden so scenes are invisible by default.scene-1, .scene-2, etc.) get the sceneVis animation/* Base scene — MUST have visibility:hidden and NO animation */
.scene {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
/* NO animation property here! */
}
/* Master scene visibility keyframe */
@keyframes sceneVis {
0%, 100% { opacity: 0; visibility: hidden; }
0.1%, 99.9% { opacity: 1; visibility: visible; }
}
/* Scene timing — only scene-specific classes get animation */
/* CRITICAL: Delays MUST be seamless — each delay = previous delay + previous duration */
.scene-1 { animation: sceneVis 7s ease forwards; animation-delay: 0s; } /* 0 + 7 = 7 */
.scene-2 { animation: sceneVis 8.5s ease forwards; animation-delay: 7s; } /* 7 + 8.5 = 15.5 */
.scene-3 { animation: sceneVis 8s ease forwards; animation-delay: 15.5s; } /* 15.5 + 8 = 23.5 */
.scene-4 { animation: sceneVis 7s ease forwards; animation-delay: 23.5s; } /* 23.5 + 7 = 30.5 */
.scene-5 { animation: sceneVis 4.5s ease forwards; animation-delay: 30.5s; }/* 30.5 + 4.5 = 35 = total */
CRITICAL: Seamless Scene Timing — No Gaps Allowed
Scene delays MUST form a continuous chain where each scene starts exactly when the previous one ends. Gaps between scenes create empty/dead screens with no visible content — the most common and destructive timing bug.
Rule: scene[N+1].delay = scene[N].delay + scene[N].duration
When scaling video duration (e.g., after generating voiceover), recalculate ALL scene timings:
new_dur = old_dur × (target_total / old_total)delay[0] = 0, delay[N] = delay[N-1] + dur[N-1]NEVER create gaps by adding arbitrary delays between scenes. If you see timing like:
/* WRONG — 4.3s gap between scene 1 (ends 11.6s) and scene 2 (starts 15.9s) */
.scene-1 { animation: sceneVis 11.6s ease forwards; animation-delay: 0s; }
.scene-2 { animation: sceneVis 11.6s ease forwards; animation-delay: 15.9s; }
Fix it to:
/* CORRECT — seamless transition */
.scene-1 { animation: sceneVis 11.6s ease forwards; animation-delay: 0s; }
.scene-2 { animation: sceneVis 11.6s ease forwards; animation-delay: 11.6s; }
NEVER use -shortest ffmpeg flag when combining video + voiceover. The video is intentionally ~2s longer than voiceover. Use:
ffmpeg -y -i video.mp4 -i voiceover.mp3 -c:v copy -c:a aac -b:a 192k final.mp4
Common mistakes that cause scene overlap:
animation: sceneVis 0.8s ease-out forwards; to the base .scene class — this makes ALL scenes briefly visible at loadvisibility: hidden on the base .scene class — scenes default to visiblevisibility: hidden toggling — opacity alone isn't enough because Puppeteer's frame-by-frame capture can catch intermediate states@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeSlideDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulseGlow {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
Inner elements (titles, cards, text blocks) within each scene must use ABSOLUTE animation-delays calculated from the video timeline start (time 0), NOT relative delays from the element's own load time.
Why this is critical: Puppeteer controls animation timing by setting currentTime on all animations simultaneously. A relative delay of 0.3s means the element's animation completes at 0.3s + duration — long before the parent scene becomes visible at, say, 14.5s. By the time the scene appears, the element's entrance animation has already finished, so it just pops into view instantly. Worse, during the brief crossfade window between scenes, elements from the NEXT scene can be visible because their animations already completed.
Formula: element_delay = scene_start_time + offset_within_scene
/* Scene 2 starts at 6.5s */
.scene.scene-2 { animation: sceneVis 8.5s ease forwards; animation-delay: 6.5s; }
/* Inner elements use ABSOLUTE delays: scene_start (6.5s) + offset */
.scene-2 .scene-title {
opacity: 0;
animation: fadeSlideDown 0.6s ease forwards;
animation-delay: 6.8s; /* 6.5 + 0.3 = title appears 0.3s after scene */
}
.scene-2 .card:nth-child(1) {
opacity: 0;
animation: fadeSlideUp 0.5s ease forwards;
animation-delay: 7.5s; /* 6.5 + 1.0 = first card at 1.0s into scene */
}
.scene-2 .card:nth-child(2) {
opacity: 0;
animation: fadeSlideUp 0.5s ease forwards;
animation-delay: 8.3s; /* 6.5 + 1.8 = second card at 1.8s into scene */
}
.scene-2 .card:nth-child(3) {
opacity: 0;
animation: fadeSlideUp 0.5s ease forwards;
animation-delay: 9.1s; /* 6.5 + 2.6 = third card at 2.6s into scene */
}
WRONG — relative delays (causes overlap and instant-pop):
/* DO NOT DO THIS — delays fire immediately at page load, not when scene appears */
.scene-2 .scene-title { animation-delay: 0.3s; }
.scene-2 .card:nth-child(1) { animation-delay: 0.5s; }
.scene-2 .card:nth-child(2) { animation-delay: 0.7s; }
.scene-2 .card:nth-child(3) { animation-delay: 0.9s; }
The video renders at a fixed pixel size (e.g. 1080×1920). There is no scrolling, no responsive resizing, and no overflow recovery. Content that doesn't fit is simply cut off.
All content must fit within the viewport. Test layouts mentally: if you have 3 cards in a row at 1080px wide with padding, each card gets roughly 280px. If cards need more space, stack them vertically instead.
No CSS media queries. The viewport is fixed at render time. Media queries like @media (max-width: 800px) are meaningless and can cause unexpected layout changes. Remove them entirely.
No overflow: scroll or overflow: auto. The viewer can't scroll a video. Use overflow: hidden if needed, but prefer designing content to fit.
Generous padding matters. Use padding: 80px 60px or similar on scene content containers to keep text away from edges. Mobile viewers crop differently than desktop.
Prefer vertical stacking. On 9:16 format (1080×1920), you have massive vertical space but limited horizontal space. When in doubt, stack elements vertically (flex-direction: column) rather than horizontally (grid-template-columns: repeat(3, 1fr)).
Test card/grid layouts against pixel budget:
Use clamp() with viewport-relative units for text sizing so the same HTML can work across 9:16, 1:1, and 16:9 formats. Do NOT use @media queries — the viewport is fixed at render time and media queries cause unpredictable layout issues.
ALL text in the video MUST be clearly readable on a mobile phone. No squinting, no tiny text.
| Text Role | Minimum Size (clamp) | Weight | Color | Notes |
|---|---|---|---|---|
| Headline / Hook | clamp(2.5rem, 8vh, 9rem) | 900 | #ffffff | Main attention-grabbing text |
| Scene Title | clamp(1.8rem, 5vh, 5rem) | 800 | #ffffff | Each scene's heading |
| Body / Detail text | clamp(1.2rem, 3.2vh, 3.5rem) | 600–700 | rgba(255,255,255,0.85) | Explanations, descriptions, supporting points |
| Small labels / Tags | clamp(0.9rem, 2.2vh, 2.5rem) | 600–700 | rgba(255,255,255,0.75) | Badges, captions, category labels |
| Badge text | clamp(0.8rem, 1.8vh, 2rem) | 700 | #ffffff | Inside gradient pill badges |
HARD RULES:
clamp(0.8rem, 1.8vh, 2rem) — anything smaller is unreadable on mobileclamp(1.2rem, 3.2vh, 3.5rem) — this is the most common violation, do NOT make body text smallerrgba(255,255,255, 0.75) minimum for any secondary text, rgba(255,255,255, 0.85) or higher preferred/* CORRECT example sizes */
.headline {
font-size: clamp(2.5rem, 8vh, 9rem);
font-weight: 900;
line-height: 0.95;
}
.scene-title {
font-size: clamp(1.8rem, 5vh, 5rem);
font-weight: 800;
}
.body-text {
font-size: clamp(1.2rem, 3.2vh, 3.5rem);
font-weight: 600;
color: rgba(255,255,255,0.85);
}
.label {
font-size: clamp(0.9rem, 2.2vh, 2.5rem);
font-weight: 700;
color: rgba(255,255,255,0.75);
}
Every video MUST include the "Start a Business" branding in the final scene (CTA/outro):
clamp() with viewport units for responsive sizing across formats<div class="footer" style="display:flex;flex-direction:column;align-items:center;gap:clamp(4px,0.8vh,8px);">
<div style="display:flex;align-items:center;gap:clamp(8px,1.2vw,14px);">
<div style="width:clamp(28px,4vh,44px);height:clamp(28px,4vh,44px);border-radius:9px;background:linear-gradient(135deg,ACCENT1,ACCENT2);display:flex;align-items:center;justify-content:center;">
<svg viewBox="0 0 20 20" fill="none" style="width:60%;height:60%;"><path d="M11.5 2L5 10.5H9L8 18L15 9H11Z" fill="white"/></svg>
</div>
<span style="color:rgba(255,255,255,0.5);font-size:clamp(0.9rem,1.8vh,1.6rem);font-weight:700;">Start a Business</span>
</div>
<span style="color:rgba(255,255,255,0.65);font-size:clamp(0.8rem,1.5vh,1.4rem);font-weight:700;letter-spacing:0.02em;">@michalvarys.eu</span>
</div>
Read references/render-pipeline.md for the complete rendering script.
// 1. Launch Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: [`--window-size=${width},${height}`, "--no-sandbox", "--disable-setuid-sandbox", "--hide-scrollbars"],
});
// 2. Load HTML and pause animations
const page = await browser.newPage();
await page.setViewport({ width, height, deviceScaleFactor: 1 });
await page.goto(`file://${htmlFile}`, { waitUntil: "networkidle0" });
await page.evaluate(() => {
document.getAnimations({ subtree: true }).forEach((a) => a.pause());
});
// 3. Spawn ffmpeg and pipe frames
const ffmpeg = spawn("ffmpeg", [
"-y", "-f", "image2pipe", "-framerate", "30", "-i", "-",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "slow",
"-vf", `scale=${width}:${height}`, "-r", "30", outputFile,
], { stdio: ["pipe", "inherit", "inherit"] });
// 4. Capture each frame
for (let i = 0; i < totalFrames; i++) {
await page.evaluate((t) => {
document.getAnimations({ subtree: true }).forEach((a) => { a.currentTime = t; });
}, i * (1000 / 30));
const png = await page.screenshot({ type: "png", encoding: "binary" });
ffmpeg.stdin.write(png);
}
// 5. Finalize
ffmpeg.stdin.end();
await browser.close();
To render a static PNG from the same animated HTML (e.g. for thumbnails or image posts):
await page.goto(`file://${htmlFile}`, { waitUntil: "networkidle0" });
await page.screenshot({ path: "output.png", type: "png" });
To add voiceover or music, add audio input to ffmpeg args:
const ffmpegArgs = [
"-y", "-f", "image2pipe", "-framerate", "30", "-i", "-",
"-i", audioFilePath, // Add audio input
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "slow",
"-c:a", "aac", "-b:a", "192k", "-shortest", // Audio encoding
"-vf", `scale=${width}:${height}`, "-r", "30", outputFile,
];
Every post MUST be organized into its own folder inside the outputs directory. Never dump all files into a flat outputs/ folder.
outputs/
dont-sell-your-time/
dont-sell-your-time-video.html
dont-sell-your-time-9x16.mp4
dont-sell-your-time-voiceover.mp3
dont-sell-your-time-bg-music.mp3
dont-sell-your-time-mixed-audio.m4a
dont-sell-your-time-final.mp4
dont-sell-your-time-captions.md
hormozi-money-models/
hormozi-money-models-video.html
...
Folder and file naming: Use kebab-case descriptive topic slugs (e.g., dont-sell-your-time, hormozi-money-models). NEVER use generic names like post15 or post_16. Name files by what the content IS about.
At the beginning of each new post creation:
mkdir -p outputs/{slug}/When generating multiple videos (e.g. e-learning lessons, video series), use a data-driven pipeline:
For e-learning or series content, structure data as:
Use a single HTML template with placeholder tokens like {{TITLE}}, {{SUBTITLE}}, {{SECTION_COLOR}}, {{ICON_SVG}}, {{KEY_POINTS_HTML}}. The generation script replaces these per lesson.
Key template features:
The render script (render-lesson-videos.mjs) does:
Render settings: 30fps, 30s duration = 900 frames, ~80s render time per video.
Important: The render script must be run on Mac host (needs Chrome + ffmpeg). Use a wrapper shell script with explicit nvm PATH: export PATH=$HOME/.nvm/versions/node/v20.19.5/bin:/opt/homebrew/bin:$PATH
For batch rendering of many videos, launch as background process via Python subprocess.Popen(start_new_session=True) and monitor /tmp/render_log.txt.
Each section/category should have a unique inline SVG icon. Do NOT use unicode emojis - they render as empty boxes in headless Chromium. Instead, create simple SVG icons (24x24 viewBox) with stroke-based designs. Store icon SVG strings in the JSON data and inject into templates.
Guides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.
npx claudepluginhub michalvarys/claude-plugins --plugin social-media-creator