Smart video factory that auto-generates Remotion-based demo/promo videos. Can analyze a GitHub repo (deep scan) OR use conversation context + Claude memory to auto-generate a video script with scenes, timing, visuals, and burned-in subtitles. Produces a self-contained TSX composition, registers it, and renders to mp4. Activate when: user wants to create a demo video, promo video, product showcase, explainer video, or any programmatic video using Remotion — especially when they share a GitHub repo URL or have been discussing a product in conversation.
Smart video factory: takes a GitHub repo OR conversation context, auto-generates a video script (scenes, timing, visuals, burned-in subtitles), and outputs a complete, renderable Remotion TSX composition.
The project must have a Remotion setup. If demo-video/ does not exist, scaffold it:
mkdir -p demo-video/src
cd demo-video
npm init -y
npm install remotion @remotion/cli @remotion/player react react-dom typescript @types/react
Create demo-video/src/index.ts:
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
Create demo-video/tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
The skill runs a 4-phase automated pipeline:
Phase 1: Gather Context → GitHub repo deep scan OR conversation context OR ask user
Phase 2: Generate Script → Auto-produce VideoScript with scenes + subtitles
Phase 3: Generate TSX → Build composition file with SubtitleLayer + scene components
Phase 4: Register & Render → Root.tsx, package.json script, tsc check, render mp4
If the user provides a manual video script (explicit scenes, timing, visuals), skip Phases 1–2 and go directly to Phase 3 — backward compatible with the old workflow.
Only ONE input source is needed. Pick the first available:
| Priority | Source | When |
|---|---|---|
| 1 | GitHub repo URL | User provides a repo link |
| 2 | Conversation context + Claude memory | Product has been discussed in chat |
| 3 | Ask the user | Neither of the above |
When the user provides a repo URL:
gh repo clone or git cloneProductProfile:interface ProductProfile {
name: string; // product/project name
tagline: string; // one-line description
techStack: string[]; // e.g. ["Next.js", "TypeScript", "Prisma"]
coreFeatures: string[]; // 3–6 key features
userFlows: string[]; // key user journeys detected from code
repoUrl: string;
}
What to scan in the repo:
README.md → product name, tagline, feature list, screenshotspackage.json / equivalent → name, description, dependencies (infer tech stack)src/ or app/ → route structure, key components, API handlers.env.example, docker-compose.yml) → infrastructure signalsdocs/ → additional context if availableWhen no repo URL but conversation has product context:
UsageStory:interface UsageStory {
productName: string;
steps: {
action: string; // what the user does
result: string; // what happens
wowFactor?: string; // why this is impressive
}[];
valueProp: string; // the core value proposition
beforeAfter?: {
before: string; // pain point / old way
after: string; // with the product
};
}
When neither A nor B is available, ask:
From ProductProfile + UsageStory, generate a VideoScript.
interface VideoScript {
title: string;
duration: number; // total seconds
fps: 30;
width: 1920;
height: 1080;
brand: {
name: string;
tagline: string;
url?: string;
};
scenes: Scene[];
subtitles: SubtitleEntry[];
}
interface Scene {
name: string;
archetype: string; // chat | dashboard | code | chart | flywheel | data | trading | finale
startFrame: number;
durationFrames: number;
story: string; // one-line narrative for this scene
visualSpec: Record<string, any>; // archetype-specific config (texts, data, layout details)
ticker: string; // bottom ticker text
}
interface SubtitleEntry {
startFrame: number;
endFrame: number;
text: string;
style?: "default" | "emphasis" | "brand";
}
Scene count & timing:
ceil(duration / 3) scenes, each 2–4 secondsScene ordering:
Subtitle pacing:
style: "brand"style: "emphasis" for key value propositions or "wow" momentsArchetype selection guide:
| Feature type | Best archetype |
|---|---|
| Chat / prompt / command | Chat/Prompt Scene |
| Research / document output | Data/Research Scene |
| Multi-metric view | Multi-Column Dashboard |
| Code gen / file output | Code Generation Scene |
| Feedback loop / iteration | Circular/Flywheel Scene |
| Financial / dense data | Trading/Terminal Scene |
| Comparison / revenue | Bar Chart / Revenue Scene |
| Brand close / CTA | Grand Finale |
Build a single TSX composition file containing everything.
All videos MUST use this consistent dark-mode design system. Never invent new base colors unless the user explicitly requests a different theme.
const BG = "#09090b"; // zinc-950 — main background
const BG_SURFACE = "#18181b"; // zinc-900 — cards, panels
const BORDER = "#27272a"; // zinc-800 — borders, dividers
const TEXT = "#f4f4f5"; // zinc-100 — primary text
const MUTED = "#71717a"; // zinc-500 — secondary text, labels
const GREEN = "#22c55e"; // success, positive, active
const BLUE = "#3b82f6"; // info, links, primary accent
const PURPLE = "#a855f7"; // AI/creative accent
const ORANGE = "#f97316"; // warning, trading accent
const RED = "#ef4444"; // error, negative, loss
const YELLOW = "#eab308"; // caution, medium-severity
const FONT = "system-ui, -apple-system, sans-serif";
const MONO = "'SF Mono', 'Fira Code', 'Cascadia Code', monospace";
background: BG_SURFACE, border: 1px solid ${BORDER}, borderRadius: 12borderRight: 1px solid ${BORDER}Every scene should have a bottom ticker — a centered pill at the bottom showing context.
function BottomTicker({ text, frame, delay = 10 }: { text: string; frame: number; delay?: number }) {
return (
<div style={{
position: "absolute", bottom: 30, left: 0, right: 0,
display: "flex", justifyContent: "center",
}}>
<div style={{
background: BG_SURFACE,
border: `1px solid ${BORDER}`,
borderRadius: 8,
padding: "8px 24px",
fontSize: 16,
fontFamily: MONO,
color: MUTED,
opacity: interpolate(frame, [delay, delay + 5], [0, 1], CLAMP),
}}>
{text}
</div>
</div>
);
}
Uppercase mono label for panel headers.
function SectionLabel({ text, frame, delay = 0 }: { text: string; frame: number; delay?: number }) {
return (
<div style={{
fontSize: 14, color: MUTED, fontFamily: MONO,
textTransform: "uppercase", letterSpacing: 2, marginBottom: 16,
opacity: interpolate(frame, [delay, delay + 5], [0, 1], CLAMP),
}}>
{text}
</div>
);
}
Counter that animates from 0 to a target value.
function AnimatingNumber({
frame, delay, duration, target,
prefix = "", suffix = "", decimals = 0,
fontSize = 28, color = TEXT,
}: {
frame: number; delay: number; duration: number; target: number;
prefix?: string; suffix?: string; decimals?: number;
fontSize?: number; color?: string;
}) {
const value = interpolate(frame, [delay, delay + duration], [0, target], CLAMP);
const formatted = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toLocaleString();
return (
<span style={{ fontSize, fontWeight: 700, color, fontFamily: MONO }}>
{prefix}{formatted}{suffix}
</span>
);
}
User/AI conversation bubble with typewriter effect.
function ChatBubble({ text, isUser, frame, delay = 0, typeSpeed = 2 }: {
text: string; isUser: boolean; frame: number; delay?: number; typeSpeed?: number;
}) {
const localFrame = frame - delay;
if (localFrame < 0) return null;
const displayText = useTypewriter(text, localFrame, typeSpeed);
const opacity = interpolate(localFrame, [0, 6], [0, 1], CLAMP);
const showCursor = localFrame < text.length * typeSpeed;
return (
<div style={{ opacity, display: "flex", justifyContent: isUser ? "flex-end" : "flex-start", marginBottom: 16 }}>
<div style={{
background: isUser ? BLUE : BG_SURFACE,
border: isUser ? "none" : `1px solid ${BORDER}`,
borderRadius: isUser ? "20px 20px 4px 20px" : "20px 20px 20px 4px",
padding: "14px 24px", maxWidth: 700,
fontSize: 28, color: TEXT, fontFamily: FONT, lineHeight: 1.5,
}}>
{displayText}
{showCursor && (
<span style={{
display: "inline-block", width: 2, height: 28,
background: isUser ? "rgba(255,255,255,0.8)" : GREEN,
marginLeft: 2, verticalAlign: "middle",
opacity: Math.sin(localFrame * 0.4) > 0 ? 1 : 0,
}} />
)}
</div>
</div>
);
}
function useTypewriter(text: string, frame: number, charDelay = 2): string {
const chars = Math.floor(frame / charDelay);
return text.slice(0, Math.min(chars, text.length));
}
const CLAMP = { extrapolateLeft: "clamp" as const, extrapolateRight: "clamp" as const };
Burned-in subtitle overlay that sits on top of all scenes. Spans the entire video duration.
const SUBTITLES: SubtitleEntry[] = [
// Generated from VideoScript.subtitles — example:
// { startFrame: 0, endFrame: 89, text: "Your product is live.", style: "default" },
// { startFrame: 90, endFrame: 164, text: "Users give feedback. AI iterates.", style: "emphasis" },
];
interface SubtitleEntry {
startFrame: number;
endFrame: number;
text: string;
style?: "default" | "emphasis" | "brand";
}
function SubtitleLayer() {
const frame = useCurrentFrame();
const current = SUBTITLES.find(
(s) => frame >= s.startFrame && frame < s.endFrame
);
if (!current) return null;
const fadeIn = interpolate(
frame,
[current.startFrame, current.startFrame + 8],
[0, 1],
CLAMP
);
const fadeOut = interpolate(
frame,
[current.endFrame - 8, current.endFrame],
[1, 0],
CLAMP
);
const opacity = Math.min(fadeIn, fadeOut);
const isEmphasis = current.style === "emphasis";
const isBrand = current.style === "brand";
return (
<div
style={{
position: "absolute",
bottom: 80,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 100,
}}
>
<div
style={{
opacity,
background: "rgba(0,0,0,0.7)",
backdropFilter: "blur(8px)",
borderRadius: 8,
padding: isBrand ? "12px 40px" : "8px 32px",
maxWidth: 1200,
textAlign: "center",
}}
>
<span
style={{
fontSize: isBrand ? 32 : isEmphasis ? 28 : 24,
fontWeight: isBrand ? 800 : isEmphasis ? 700 : 500,
color: isBrand ? GREEN : TEXT,
fontFamily: FONT,
letterSpacing: isBrand ? 1 : 0,
}}
>
{current.text}
</span>
</div>
</div>
);
}
Position: bottom: 80 places it above the BottomTicker (which is at bottom: 30).
Z-index: 100 ensures it renders above all scene content.
const opacity = interpolate(frame, [delay, delay + 8], [0, 1], CLAMP);
const slideX = interpolate(frame, [delay, delay + 8], [-200, 0], CLAMP);
// apply: transform: `translateX(${slideX}px)`
const scale = spring({
frame: Math.max(0, frame - delay),
fps,
config: { damping: 12, stiffness: 100 },
});
// apply: transform: `scale(${scale})`
const flash = interpolate(frame, [delay, delay + 3, delay + 8], [0, 1, 0.15], CLAMP);
// apply as background overlay opacity
const pulse = Math.sin(frame * 0.3) > 0 ? 1 : 0.3;
// apply to a small dot: opacity: pulse
const scrollY = interpolate(frame, [startFrame, endFrame], [0, -totalScroll], CLAMP);
// apply: transform: `translateY(${scrollY}px)`
// add gradient fade at bottom:
// background: `linear-gradient(transparent, ${BG})`
const value = interpolate(frame, [delay, delay + duration], [0, targetValue], CLAMP);
// display: Math.floor(value).toLocaleString()
These are proven scene patterns. Mix and match for any video.
User sends a message, AI responds. Good for "idea" or "command" moments.
Scrolling document or data output. Good for showing AI-generated analysis.
Show parallel information streams. Good for "distribution", "comparison", "monitoring".
File tree + code streaming + terminal output.
Show a cyclical process. Good for feedback loops, iterative processes.
Dense data display. Good for financial, analytics, monitoring.
Show comparative metrics across categories.
Cards converge, total counter, brand text.
Every video composition follows this pattern:
export const MyVideo: React.FC = () => {
const totalFrames = /* sum of all scene durations */;
return (
<AbsoluteFill style={{ background: BG }}>
{/* Scene sequences */}
<Sequence from={0} durationInFrames={90}>
<Scene1 />
</Sequence>
<Sequence from={90} durationInFrames={75}>
<Scene2 />
</Sequence>
{/* ... more scenes */}
{/* Subtitle layer spans entire video — always last so it renders on top */}
<Sequence from={0} durationInFrames={totalFrames}>
<SubtitleLayer />
</Sequence>
</AbsoluteFill>
);
};
Each scene is a standalone function component using useCurrentFrame() internally.
Frame 0 inside each scene is always the start of that scene (Sequence handles offset).
The SubtitleLayer is placed as the last Sequence so it renders above all scenes.
Its SUBTITLES array uses absolute frame numbers (not scene-local), matching
the VideoScript.subtitles entries directly.
Add import and <Composition> entry with correct id, frames, fps, dimensions:
import { Composition } from "remotion";
import { MyVideo } from "./MyVideo";
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="MyVideo"
component={MyVideo}
durationInFrames={totalFrames}
fps={30}
width={1920}
height={1080}
/>
</>
);
};
Add to demo-video/package.json:
"render:<name>": "npx remotion render src/index.ts <CompositionId> out/<name>.mp4"
npx tsc --noEmit — no type errorsnpm run preview — visual check in Remotion Studionpm run render:<name> — render to mp4bottom: 80 sits above BottomTicker at bottom: 30interpolate() and spring() — no CSS transitions or keyframesSequence for scene timing — not manual frame offset math in componentsBottomTicker — it's the visual signatureSubtitleLayer — burned-in subtitles spanning the full durationuseCurrentFrame() and useVideoConfig(), no cross-scene state