[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CP2HGSBAJFT9P3RWC63WXB":3,"skill-same:01M6CP2HGSBAJFT9P3RWC63WXB":63},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":26,"publishedAt":37,"linkOnly":38,"markdownContent":39,"markdownBody":40,"frontMatter":41,"refName":42,"commitSha":43,"skillDirectory":44,"forksCount":14,"repositoryHtmlUrl":45,"repositoryHomepageUrl":46,"ownerHtmlUrl":47,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":48,"install":49,"resources":62},"01M6CP2HGSBAJFT9P3RWC63WXB","docs-to-video",null,"Turn documentation URLs into tutorial videos using the user's own React components and Remotion","Bhonar\u002Fdocs-to-video","Bhonar","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F80186009?v=4","01M6CP2CWZV0E77RF990PSGX7G","Bahar","01M6CP2D15J5Y6NX5NWVPGAD0D",0,"MIT","https:\u002F\u002Fgithub.com\u002FBhonar\u002Fdocs-to-video\u002Fblob\u002Fmain\u002Fskill\u002FSKILL.md",[18,23],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体","SCENARIO","使用场景",{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[27,28,29,30,31,32,33,34,35,36],"docs","Video","docs to video","components","react","react components","documentation","Remotion","user","tutorial","2026-11-10 10:12:30",false,"---\nname: docs-to-video\ndescription: Turn documentation URLs into tutorial videos using the user's own React components and Remotion\n---\n\n# Docs to Video\n\nGenerate tutorial and how-to videos from documentation URLs. The video uses the user's **own React components** (buttons, cards, layouts) so the tutorial looks like their actual product. Produces an MP4 with AI narration, background music, and animated scenes.\n\n**Target file:** `remotion\u002Fsrc\u002Fcompositions\u002FGenerated.tsx` (inside user's project, overwritten each run)\n\n---\n\n## Step 0: Setup Validation (run this FIRST)\n\nBefore starting the workflow, check if the MCP tools are available. Try calling any tool (e.g., `extract_docs_content`). If it works, skip to Step 1.\n\n**If tools are NOT available** (you get \"unknown tool\" or similar error), run the setup:\n\n```bash\nnpx docs-to-video setup\n```\n\nThis copies the MCP server, remotion template, and dependencies into the user's project. It also registers the MCP server and skill automatically.\n\nIf the user doesn't have API keys yet, tell them:\n> You need to add your API keys to `~\u002F.docs-to-video\u002F.env`:\n> - **TABSTACK_API_KEY** — get it at https:\u002F\u002Ftabstack.ai\u002Fdashboard\n> - **ELEVENLABS_API_KEY** — get it at https:\u002F\u002Felevenlabs.io\n\nThen **tell the user to restart Claude Code:**\n> Setup complete! Please restart Claude Code (quit and re-open) for the MCP tools to load, then run `\u002Fdocs-to-video \u003Curl>` again.\n\n**STOP here if you had to run setup.** The MCP tools only load at Claude Code startup, so the user must restart before proceeding to Step 1.\n\n---\n\n## 6-Step Workflow\n\n### Step 1: Scan User's Codebase\n\nBefore anything else, understand the user's project:\n\n1. **Find components** — scan `src\u002Fcomponents\u002F`, `src\u002Fui\u002F`, `components\u002F`, `app\u002Fcomponents\u002F` or wherever components live\n2. **Identify the design system** — look for:\n   - UI primitives: Button, Card, Input, Badge, Alert, Modal\n   - Layout components: Container, Grid, Stack, Flex, Sidebar\n   - Typography: Heading, Text, Label, Code\n   - Navigation: Tabs, Breadcrumb, Menu\n3. **Read the styling approach** — Tailwind? CSS Modules? Styled Components? Inline styles?\n4. **Note import paths** — e.g., `@\u002Fcomponents\u002Fui\u002Fbutton` or `..\u002F..\u002Fcomponents\u002FButton`\n5. **Check for a tailwind.config** or theme file — extract their color palette, fonts, spacing scale\n6. **Record the project root** — the absolute path to the user's project root (where `package.json` is). This is needed for `remotionProjectPath` in all tool calls.\n\n**What to save from this step:**\n- List of reusable components with their import paths and key props\n- The project's color palette \u002F design tokens\n- Font family names\n- The absolute path to the project root\n- The relative path from `remotion\u002Fsrc\u002Fcompositions\u002FGenerated.tsx` to the user's components\n\n---\n\n### Step 2: Scaffold Remotion (if needed)\n\n**IMPORTANT: This step MUST happen before Steps 3-4.** The extraction and audio tools save files to `remotion\u002Fpublic\u002F`. If `remotion\u002F` doesn't exist yet, those files would be lost.\n\nCheck if `remotion\u002F` directory exists in the user's project root. If not, create it and copy the template contents:\n\n```bash\nmkdir -p remotion\ncp -r remotion-template\u002F* remotion\u002F\n```\n\n> ⚠️ **DO NOT** use `cp -r remotion-template remotion` — if `remotion\u002F` already exists, this creates `remotion\u002Fremotion-template\u002F` (nested and broken). Always use `cp -r remotion-template\u002F* remotion\u002F` to copy the **contents**.\n\n**After copying, verify the structure is flat** — `remotion\u002Fpackage.json` must exist at the top level:\n\n```\nremotion\u002F\n├── src\u002F\n│   ├── Root.tsx\n│   ├── index.ts\n│   ├── style.css          (Tailwind entry — required)\n│   └── compositions\u002F\n│       └── Generated.tsx\n├── public\u002F\n│   ├── audio\u002F    (generated audio goes here)\n│   └── images\u002F   (downloaded logos go here)\n├── package.json\n├── tsconfig.json\n└── tailwind.config.js\n```\n\nIf you see `remotion\u002Fremotion-template\u002F` (nested), fix it immediately:\n```bash\nmv remotion\u002Fremotion-template\u002F* remotion\u002F && rm -rf remotion\u002Fremotion-template\n```\n\nNext:\n\n1. **Update Tailwind content paths** if the user's components aren't in `..\u002Fsrc\u002F`:\n   - Default: `'..\u002Fsrc\u002F**\u002F*.{ts,tsx}'`\n   - If user has `app\u002F` structure: add `'..\u002Fapp\u002F**\u002F*.{ts,tsx}'`\n   - If monorepo with `packages\u002F`: add `'..\u002Fpackages\u002Fui\u002F**\u002F*.{ts,tsx}'`\n   - Check where the user's components live and ensure `remotion\u002Ftailwind.config.js` includes them\n\n2. Run `cd remotion && npm install`\n\n3. Verify: `remotion\u002Fsrc\u002Findex.ts` exists and imports Root and `style.css`\n\n**If remotion\u002F already exists**, verify the structure is flat (no nested `remotion-template\u002F` folder) and that `tailwind.config.js` content paths include the user's component directories.\n\n---\n\n### Step 3: Extract Documentation\n\n**Call `extract_docs_content`** with:\n- `url` — the documentation URL\n- `remotionProjectPath` — absolute path to the `remotion\u002F` directory (e.g., `\u002FUsers\u002Fme\u002Fmy-app\u002Fremotion`)\n\nReturns:\n- `markdown` — full page content as clean markdown (code blocks, headings, lists preserved)\n- `metadata` — title, technology, docType, difficulty, prerequisites, sections\n- `branding` — logo (url + staticPath), colors, font, theme\n- `domain` — documentation domain\n- `warnings` — any extraction issues\n\n**Then analyze the documentation:**\n\n1. **Content Type** — from `metadata.docType` (one scene per step = more scenes):\n   - `quickstart` → 3-5 steps = 5-7 scenes (intro + steps + summary)\n   - `tutorial` → 5-8 steps = 7-10 scenes\n   - `api-reference` → endpoint showcases, 4-6 scenes\n   - `guide` → concept explanations + code, 6-10 scenes\n   - `how-to` → problem\u002Fsolution format, 5-8 scenes\n\n2. **Parse the markdown** to identify:\n   - Code blocks (language from fence markers)\n   - Numbered lists (step sequences)\n   - Headings hierarchy (scene structure)\n   - Blockquotes (tips, warnings — these become callout scenes)\n\n3. **Music Style** — pick a style that matches the tutorial energy:\n   - `pop` — upbeat, energetic (good default for most tutorials)\n   - `hip-hop` — modern, rhythmic (developer tools, CLI tutorials)\n   - `rock` — high energy (gaming, performance topics)\n   - `jazz` — smooth, sophisticated (enterprise, fintech)\n\n---\n\n### Step 4: Write Narration Script & Generate Audio\n\nWrite a **short, punchy** narration script. Each paragraph becomes one scene — **one step per paragraph, one paragraph per scene**:\n\n- **Intro** — 1 sentence. \"Let's set up [topic] with [technology].\"\n- **Step 1** — 1-2 sentences. \"Step 1. Run the install command to grab the Stripe SDK.\"\n- **Step 2** — 1-2 sentences. \"Step 2. Add your secret key to the environment — Stripe uses it to authenticate.\"\n- **Step 3** — 1-2 sentences. \"Step 3. Create a checkout session — this is where the magic happens.\"\n- ... (one paragraph per step, as many as the docs require)\n- **Summary** — 1-2 sentences. \"That's it! You've got Stripe installed, a checkout ready, and your key secured.\"\n\n> **⚠️ KEEP IT SHORT.** The narration is a voiceover for a visual tutorial, not a lecture.\n> The visuals do the heavy lifting — the narration just guides attention.\n> - **Most steps need only 1 sentence.** Say what to do, not how it works.\n> - **Maximum 2 sentences per step.** If you need 3+, split into two scenes.\n> - **Cut filler words.** No \"Now let's go ahead and...\" — just \"Install the SDK.\"\n> - **Total video: 30-60 seconds.** Aim for the shortest video that covers all steps.\n\n**⚠️ CRITICAL:** Do NOT combine multiple steps into a single paragraph. Each step gets its own narration paragraph and its own visual scene. If the docs have 5 substeps (like \"visit the site, click settings, create key, name it, copy it\"), write 5 separate paragraphs — the agent creates one scene per paragraph, so combining them forces multiple steps into one scene, which is the #1 quality failure.\n\n**Pacing — how much narration to write per step** (scene duration is derived from timecodes, not from this table):\n\n| Step complexity | Narration to write | Example |\n|----------------|-------------------|---------|\n| Simple command | 1 sentence (~10 words) | \"Step 1. Run the install to grab the Stripe SDK.\" |\n| Config\u002Fsetup | 1-2 sentences (~18 words) | \"Step 2. Add your secret key to the env file — Stripe needs this to authenticate.\" |\n| Code with explanation | 2 sentences (~22 words) | \"Step 3. Create the checkout session. The key part here is the line_items array — that's what the customer is buying.\" |\n| Multi-part concept | 2 sentences (~25 words) | \"Step 4. Set up the webhook endpoint. This listens for payment events so your app knows when a charge goes through.\" |\n\n**Guidelines:**\n- **Always say \"Step N\" at the start of each step's narration.** The viewer should hear \"Step 1\", \"Step 2\", etc. for clear audible navigation and pacing. The intro and summary scenes do NOT get step numbers.\n- **Narration should be conversational and add value beyond the visuals.** It's fine to reference what's on screen (\"run the install command\", \"add this to your config\"), but don't literally read code or commands verbatim. Describe actions naturally and add *why* it matters. BAD: \"Type npm install at sign stripe slash stripe dash js.\" GOOD: \"Step 1. Run the install command to pull in the Stripe library.\" The voice should feel like a knowledgeable friend guiding you, not a screen reader.\n- Short and direct — say what to do, let the visuals show how\n- Use \"we\" and \"let's\" sparingly — prefer imperative: \"Install the SDK\" over \"Let's go ahead and install the SDK\"\n- Mention specific function\u002Fclass names only when the viewer needs to see them\n- **Avoid abbreviations with periods** (\"Dr.\", \"vs.\") — the timecode system splits on `.!?` and abbreviations cause misaligned sentence counts\n- **No filler phrases:** \"Now let's go ahead and\", \"What we're going to do next is\", \"As you can see\" — cut all of these\n\n**Script structure for natural speech flow:**\n- Write as if you're a knowledgeable friend walking someone through the setup — warm, clear, and helpful\n- Each step paragraph MUST start with \"Step N.\" followed by a natural description of the action\n- It's fine to reference what's on screen (\"run the install\", \"add this config\"), but describe it naturally — never read code character by character\n- Add a brief *why* or *what it does* after the action — \"Step 2. Add your key to the env file — Stripe needs it to verify your account.\" This makes the narration valuable beyond just watching the screen\n- Write each paragraph as one or two flowing sentences — NOT bullet points or fragments\n- Avoid choppy fragments like \"Now. The key.\" — write \"Now add the API key to your environment.\"\n- Connect ideas within a paragraph: \"Install the SDK, then import it in your app.\"\n- Separate paragraphs with blank lines — these create natural pauses in the narration audio\n- Do NOT use SSML tags — ElevenLabs handles pacing from plain text and paragraph breaks\n\n**Call `generate_audio`** with:\n- `musicStyle` — `pop`, `hip-hop`, `rock`, or `jazz` (default to `pop` for most tutorials)\n- `narrationScript` — your script\n- `duration` — estimated total video length in seconds\n- `remotionProjectPath` — absolute path to the `remotion\u002F` directory\n\n**IMPORTANT: Always attempt music generation.** Do NOT skip it. Call the tool with `musicStyle` and let the API respond. Only fall back to narration-only if the API returns an actual error. Never preemptively skip music.\n\n**Save returned values:**\n- `audio.music.staticPath`\n- `audio.narration.staticPath`\n- `audio.narration.timecodes` — array of `{start: number, end: number, text: string}` per sentence, derived from **real audio alignment** (not estimated). **You MUST use these to time scene durations and visual reveals in Step 5.** Do NOT guess frame numbers.\n- `audio.beats`\n\n**Also save your narration paragraphs** as a list — you'll need them in Step 5 to map timecodes to scenes.\n\n---\n\n### Step 5: Design & Write Generated.tsx\n\nWrite the full composition at `remotion\u002Fsrc\u002Fcompositions\u002FGenerated.tsx`.\n\n#### Quick Reference — Every Step Scene Must Have:\n\n1. **`\u003CSafeZone>`** — every scene wraps content in SafeZone (centered 1200px column, consistent padding)\n2. **`\u003CStepIndicator>`** — progress dots showing \"Step N of M\" (top-right)\n3. **Staged reveals synced to timecodes** — each visual element appears at a `getRevealFrame()` value matching the narrator's sentence\n4. **Typing effect** — all code\u002Fcommands type character-by-character with blinking cursor\n5. **Code highlighting** — for multi-line code, spotlight the key line, dim the rest\n6. **Result scene after commands** — show `✓ Success` output after install\u002Fbuild commands\n7. **Callout scenes** — warnings\u002Ftips from docs get their own styled card scene\n8. **ONE step per scene** — never combine multiple steps, numbered lists, or actions into one scene\n\n---\n\n> **⚠️ SCENE SPLITTING — THE MOST COMMON MISTAKE (read this before writing ANY scene)**\n>\n> **If a scene contains a numbered list or multiple steps, it is WRONG. Stop and split it.**\n>\n> The #1 failure mode is cramming multiple steps into one scene. A scene titled \"Create Your API Key\"\n> that shows 5 substeps (visit site, navigate, click button, name it, copy key) is WRONG — those are 5 separate scenes.\n>\n> **Rules (violations = broken video):**\n> - ONE step, ONE idea, ONE scene. No exceptions.\n> - A numbered\u002Fbulleted list inside a scene is ALWAYS wrong.\n> - Maximum per scene: ONE title + ONE code block or ONE command + ONE supporting visual.\n> - If you catch yourself writing `1.`, `2.`, `3.` inside a single scene's JSX — STOP. Split into separate scenes.\n> - If a narration paragraph covers more than one action, split the paragraph AND the scene.\n> - A scene with a mock UI dashboard showing multiple actions is ALWAYS wrong — show one action per scene.\n>\n> **BAD — one scene with 5 steps (NEVER do this):**\n> ```jsx\n> \u002F\u002F WRONG: All 5 steps crammed into one scene\n> \u003CSafeZone>\n>   \u003Ch2>Create Your API Key\u003C\u002Fh2>\n>   \u003Col>\n>     \u003Cli>Visit console.tabstack.ai and sign in\u003C\u002Fli>\n>     \u003Cli>Navigate to API Keys\u003C\u002Fli>\n>     \u003Cli>Click \"Create New API Key\"\u003C\u002Fli>\n>     \u003Cli>Name it and click Create\u003C\u002Fli>\n>     \u003Cli>Copy the generated key\u003C\u002Fli>\n>   \u003C\u002Fol>\n>   \u003CMockDashboard \u002F>\n> \u003C\u002FSafeZone>\n> ```\n>\n> **GOOD — 5 separate scenes, one step each:**\n> ```jsx\n> \u002F\u002F Scene 3: Navigate to console\n> \u003CSafeZone>\u003CCard>\u003Ch2>Sign In to the Console\u003C\u002Fh2>\n>   \u003CMockBrowserBar url=\"console.tabstack.ai\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 4: Find API Keys section\n> \u003CSafeZone>\u003CCard>\u003Ch2>Navigate to API Keys\u003C\u002Fh2>\n>   \u003CMockSidebar activeItem=\"API Keys\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 5: Create the key\n> \u003CSafeZone>\u003CCard>\u003Ch2>Create a New API Key\u003C\u002Fh2>\n>   \u003CMockButton label=\"+ Create New API Key\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 6: Name the key\n> \u003CSafeZone>\u003CCard>\u003Ch2>Name Your Key\u003C\u002Fh2>\n>   \u003CMockInput value=\"Development\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 7: Copy the key\n> \u003CSafeZone>\u003CCard>\u003Ch2>Copy Your API Key\u003C\u002Fh2>\n>   \u003CMockKeyDisplay value=\"ts_live_xxxx...\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n> ```\n>\n> **Count your scenes.** If the documentation has N steps, you need at least N+2 scenes (intro + N steps + summary).\n\n---\n\n#### Reusable Components\n\nDefine these helper components at the top of Generated.tsx. They are used by the scene components below.\n\n**StepIndicator — progress dots (required on every step scene):**\n```typescript\nconst StepIndicator: React.FC\u003C{\n  current: number;\n  total: number;\n  colors: { primary: string; accent: string };\n}> = ({ current, total, colors }) => {\n  const frame = useCurrentFrame();\n  const slideIn = interpolate(frame, [0, 12], [-40, 0], { extrapolateRight: 'clamp' });\n  const opacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });\n\n  return (\n    \u003Cdiv style={{\n      position: 'absolute', top: 32, right: 40,\n      display: 'flex', alignItems: 'center', gap: 12,\n      opacity, transform: `translateY(${slideIn}px)`,\n    }}>\n      {Array.from({ length: total }, (_, i) => (\n        \u003Cdiv key={i} style={{\n          width: i + 1 === current ? 32 : 10, height: 10, borderRadius: 5,\n          background: i + 1 \u003C= current ? colors.primary : `${colors.primary}33`,\n        }} \u002F>\n      ))}\n      \u003Cspan style={{ fontSize: 14, fontWeight: 600, color: colors.primary, opacity: 0.8 }}>\n        {current}\u002F{total}\n      \u003C\u002Fspan>\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**TypingText — typewriter effect (required for all code\u002Fcommands):**\n```typescript\nconst TypingText: React.FC\u003C{\n  text: string;\n  startFrame: number;\n  charsPerFrame?: number;\n}> = ({ text, startFrame, charsPerFrame = 0.5 }) => {\n  const frame = useCurrentFrame();\n  const elapsed = Math.max(0, frame - startFrame);\n  const charsToShow = Math.min(Math.floor(elapsed * charsPerFrame), text.length);\n  const showCursor = charsToShow \u003C text.length;\n\n  return (\n    \u003Cspan style={{ fontFamily: 'monospace' }}>\n      {text.slice(0, charsToShow)}\n      {showCursor && (\n        \u003Cspan style={{ opacity: Math.floor(frame \u002F 15) % 2 === 0 ? 1 : 0 }}>▌\u003C\u002Fspan>\n      )}\n    \u003C\u002Fspan>\n  );\n};\n```\n\n**HighlightedCode — spotlight one line in a multi-line block:**\n```typescript\nconst HighlightedCode: React.FC\u003C{\n  lines: string[];\n  highlightLine: number;\n  startFrame: number;\n  colors: { primary: string };\n}> = ({ lines, highlightLine, startFrame, colors }) => {\n  const frame = useCurrentFrame();\n  const elapsed = Math.max(0, frame - startFrame);\n  const highlightProgress = interpolate(elapsed, [20, 35], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n\n  return (\n    \u003Cdiv style={{ background: '#1a1a2e', borderRadius: 8, padding: 20, fontFamily: 'monospace', fontSize: 16, lineHeight: 1.7 }}>\n      {lines.map((line, i) => {\n        const isHL = i === highlightLine;\n        return (\n          \u003Cdiv key={i} style={{\n            padding: '2px 8px',\n            opacity: isHL ? 1 : 1 - highlightProgress * 0.6,\n            background: isHL ? `${colors.primary}22` : 'transparent',\n            borderLeft: isHL ? `3px solid ${colors.primary}` : '3px solid transparent',\n          }}>\n            \u003Cspan style={{ color: '#666', marginRight: 16 }}>{i + 1}\u003C\u002Fspan>\n            \u003Cspan style={{ color: isHL ? '#fff' : '#aaa' }}>{line}\u003C\u002Fspan>\n          \u003C\u002Fdiv>\n        );\n      })}\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**CalloutCard — for warnings, tips, notes from docs:**\n```typescript\nconst CalloutCard: React.FC\u003C{\n  type: 'warning' | 'tip' | 'note';\n  message: string;\n}> = ({ type, message }) => {\n  const frame = useCurrentFrame();\n  const config = {\n    warning: { bg: '#fef3cd', border: '#f59e0b', icon: '⚠️', label: 'Warning' },\n    tip:     { bg: '#d1fae5', border: '#10b981', icon: '?', label: 'Pro Tip' },\n    note:    { bg: '#dbeafe', border: '#3b82f6', icon: '?', label: 'Note' },\n  }[type];\n\n  const slideX = interpolate(frame, [0, 18], [80, 0], { extrapolateRight: 'clamp' });\n  const opacity = interpolate(frame, [0, 12], [0, 1], { extrapolateRight: 'clamp' });\n\n  return (\n    \u003Cdiv style={{\n      opacity, transform: `translateX(${slideX}px)`,\n      background: config.bg, borderLeft: `4px solid ${config.border}`,\n      borderRadius: 8, padding: '24px 32px', maxWidth: '70%',\n    }}>\n      \u003Cdiv style={{ fontSize: 14, fontWeight: 700, color: config.border, marginBottom: 8 }}>\n        {config.icon} {config.label}\n      \u003C\u002Fdiv>\n      \u003Cdiv style={{ fontSize: 22, color: '#1a1a1a', lineHeight: 1.5 }}>{message}\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**ClickIndicator — cursor + click ripple (use when narration says \"click\", \"select\", \"tap\", \"press\"):**\n```typescript\nconst ClickIndicator: React.FC\u003C{\n  x: number;\n  y: number;\n  startFrame: number;\n  label?: string;\n}> = ({ x, y, startFrame, label }) => {\n  const frame = useCurrentFrame();\n  const rel = frame - startFrame;\n  if (rel \u003C 0 || rel > 36) return null;\n\n  \u002F\u002F Cursor SVG tip offset — the pointer tip in the SVG path is at (5,3) in a 24×24 viewBox,\n  \u002F\u002F rendered at 28×28px. Subtract this offset so the tip lands exactly at (x, y).\n  const tipOffsetX = 6;\n  const tipOffsetY = 4;\n\n  \u002F\u002F Phase 1: cursor slides in from top-left offset, tip lands exactly at (x, y)\n  const cursorX = interpolate(rel, [0, 12], [x - 50 - tipOffsetX, x - tipOffsetX], { extrapolateRight: 'clamp' });\n  const cursorY = interpolate(rel, [0, 12], [y - 40 - tipOffsetY, y - tipOffsetY], { extrapolateRight: 'clamp' });\n  const cursorOpacity = interpolate(rel, [0, 6, 28, 36], [0, 1, 1, 0], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n\n  \u002F\u002F Phase 2: click ripple expands from click point\n  const rippleScale = rel >= 14\n    ? interpolate(rel, [14, 26], [0, 2.5], { extrapolateRight: 'clamp' }) : 0;\n  const rippleOpacity = rel >= 14\n    ? interpolate(rel, [14, 26], [0.5, 0], { extrapolateRight: 'clamp' }) : 0;\n\n  return (\n    \u003Cdiv style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>\n      {\u002F* Cursor pointer SVG — positioned so the tip of the arrow lands at (x, y) *\u002F}\n      \u003Csvg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\"\n        style={{ position: 'absolute', left: cursorX, top: cursorY, opacity: cursorOpacity,\n          filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }}>\n        \u003Cpath d=\"M5 3l14 8-6.5 1.5L10 19z\" fill=\"white\" stroke=\"black\" strokeWidth=\"1.5\" \u002F>\n      \u003C\u002Fsvg>\n      {\u002F* Click ripple ring — centered exactly at the target point *\u002F}\n      \u003Cdiv style={{ position: 'absolute', left: x - 20, top: y - 20, width: 40, height: 40,\n        borderRadius: '50%', border: '3px solid rgba(255,255,255,0.8)',\n        transform: `scale(${rippleScale})`, opacity: rippleOpacity }} \u002F>\n      {\u002F* Optional label *\u002F}\n      {label && cursorOpacity > 0.5 && (\n        \u003Cdiv style={{ position: 'absolute', left: x + 18, top: y - 10,\n          background: 'rgba(0,0,0,0.75)', color: '#fff', fontSize: 12, fontWeight: 600,\n          padding: '4px 10px', borderRadius: 6, opacity: cursorOpacity, whiteSpace: 'nowrap' }}>\n          {label}\n        \u003C\u002Fdiv>\n      )}\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**SafeZone — content container (REQUIRED on every scene):**\n\nEvery scene component MUST wrap its content in `\u003CSafeZone>`. This constrains all content to a centered 1200px column with consistent padding. NEVER place content directly in `\u003CAbsoluteFill>` without SafeZone. Pass the scene background via the `background` prop.\n\n```typescript\nconst SafeZone: React.FC\u003C{\n  children: React.ReactNode;\n  background?: string;\n}> = ({ children, background }) => (\n  \u003CAbsoluteFill style={{\n    display: 'flex', alignItems: 'center', justifyContent: 'center',\n    background: background ?? 'transparent',\n  }}>\n    \u003Cdiv style={{\n      width: '100%', maxWidth: 1200,\n      padding: '60px 80px',\n      display: 'flex', flexDirection: 'column',\n      alignItems: 'center', justifyContent: 'center',\n      height: '100%',\n    }}>\n      {children}\n    \u003C\u002Fdiv>\n  \u003C\u002FAbsoluteFill>\n);\n```\n\n---\n\n#### Scene Design Rules\n\n> **⚠️ THE #1 RULE: Every sentence the narrator says must have a matching visual change on screen.**\n> The viewer should NEVER hear the narrator explain something while the screen stays static.\n> If the narration says \"install the package\", the screen must show the install command typing out at that moment.\n> If the narration says \"create a file\", the screen must show that file appearing at that moment.\n\n**Narration-to-visual sync using timecodes:**\n\n`audio.narration.timecodes` gives you `{start, end, text}` for every sentence. **Use it to drive ALL timing:**\n\n1. **Group timecodes by scene** — each narration paragraph = one scene. Gather the timecodes whose `.text` matches that paragraph's sentences.\n2. **Compute scene duration from timecodes:**\n   ```typescript\n   const sceneStart = sceneTimecodes[0].start;\n   const sceneEnd = sceneTimecodes[sceneTimecodes.length - 1].end;\n   const durationInFrames = Math.round((sceneEnd - sceneStart + 0.5) * fps);\n   ```\n3. **Time visual reveals to sentence boundaries:**\n   ```typescript\n   \u002F\u002F Inside a scene, frame 0 = scene start. Convert each sentence to a frame offset:\n   const revealFrame = Math.round((timecode.start - sceneStart) * fps);\n   ```\n4. Each sentence = a visual change. 3 sentences in a scene = 3 visual moments at 3 different `revealFrame` values.\n5. **Account for transitions** — `TransitionSeries.Transition` overlaps adjacent scenes. Use short transitions (8-10 frames) and make sure the first reveal in each scene starts at least 10 frames in (after the transition finishes).\n- **NEVER hardcode** `durationInFrames={5 * fps}` — always derive from timecodes\n- NEVER have a scene where the narrator talks while the screen stays static\n\n> **⚠️ SCENE SPLITTING (see full rules + BAD\u002FGOOD example at top of Step 5):**\n> - ONE step, ONE idea, ONE scene. If a scene has more than ONE command, ONE code block, or ONE concept — SPLIT it.\n> - A scene with a numbered list is ALWAYS wrong — each item becomes its own scene.\n> - A scene with a mock UI dashboard showing multiple actions is ALWAYS wrong — show one action per scene.\n> - If narration for a step is longer than 3 sentences, split it into two scenes.\n> - Maximum per scene: ONE title + ONE code block OR ONE command + ONE supporting visual.\n> - If you have more content, it belongs in the NEXT scene.\n\n**Whitespace, spacing, and safe zones.** Every scene must feel spacious, not cramped:\n- **Every scene MUST use `\u003CSafeZone>`** to contain its content — never place content directly in `\u003CAbsoluteFill>`\n- SafeZone constrains content to a centered 1200px column with 60px vertical \u002F 80px horizontal padding\n- Leave at least 30% of the scene area as empty space\n- Text should never touch the edges of containers\n- No element should extend beyond the SafeZone boundary — no mock UIs pushed to screen edges\n- If content feels tight, you have too much — split into two scenes\n\n**Staged reveals in every scene.** Time each reveal to a sentence's `revealFrame`:\n1. Scene start (frame 0): Background + container fade in\n2. Sentence 1 `revealFrame`: Title\u002Fheading appears\n3. Sentence 2 `revealFrame`: Main content appears (typing effect for code)\n4. Sentence 3 `revealFrame`: Highlight or result appears\n- If only 1-2 sentences, space reveals evenly across the scene duration\n- Fallback if no timecodes: space reveals 10-15 frames apart\n\n**Show the result after commands.** After typing `npm install`, show a result scene with `✓ Successfully installed`. After writing code, show mock output. Use result scenes for install\u002Fbuild\u002Frun commands. Skip them for config-only steps.\n\n**Click indicators for interactive steps.** When narration mentions clicking, selecting, tapping, or pressing a UI element:\n1. Add `\u003CClickIndicator>` inside the scene, OUTSIDE `\u003CSafeZone>` (it uses absolute positioning in the full 1920×1080 frame)\n2. **Calculate `x` and `y` precisely** — see coordinate math below\n3. Set `startFrame` to `getRevealFrame()` of the sentence mentioning the click\n4. Optionally set `label` to the button text (e.g., `label=\"Create API Key\"`)\n5. Auto-hides after 36 frames (~1.2s at 30fps)\n\n**⚠️ ACCURATE CLICK POSITIONING — coordinate calculation (MUST follow this):**\n\nThe video frame is 1920×1080. SafeZone creates a centered column:\n- SafeZone left edge: `(1920 - 1200) \u002F 2 = 360px` from left\n- SafeZone right edge: `360 + 1200 = 1560px` from left\n- SafeZone top padding: `60px`\n- SafeZone bottom: `1080 - 60 = 1020px`\n- Content padding inside SafeZone: `80px` left\u002Fright → content starts at `360 + 80 = 440px`, ends at `1560 - 80 = 1480px`\n- Usable content width: `1480 - 440 = 1040px`\n\n**To find the exact center of a button, work from the inside out:**\n\n1. **Identify the button's container** — is it inside a Card with padding? A flex row? Centered or left-aligned?\n2. **Calculate the button's horizontal center:**\n   - If the button is **centered** in SafeZone (default flex column + alignItems center): `x = 960` (frame center)\n   - If the button is **left-aligned**: `x = 440 + buttonPaddingLeft + (buttonWidth \u002F 2)`\n   - If the button is **right-aligned**: `x = 1480 - buttonPaddingRight - (buttonWidth \u002F 2)`\n   - If inside a **flex row with gap**: calculate each item's position based on flex layout\n   - If the button is inside a Card with its own padding (e.g., `p-8` = 32px): add that padding to the offset\n3. **Calculate the button's vertical center:**\n   - Count the elements above the button in the scene layout (titles, text, spacing, margins)\n   - SafeZone uses `justifyContent: 'center'`, so content is vertically centered in the frame\n   - Estimate total content height, then: `contentTop = (1080 - totalContentHeight) \u002F 2`\n   - Add heights of elements above the button + half the button's own height\n4. **Common button sizes to assume:** standard button ~40px tall, large button ~48px, small ~32px. Standard button width ~120-200px depending on text length.\n\n**Quick reference for common layouts:**\n\n| Layout | Button position | x | y (approx) |\n|--------|----------------|---|-------------|\n| Centered card, button at bottom | Center of card | 960 | 540 + (cardHeight\u002F2) - 20 |\n| Sidebar nav, menu item | Left column | 440 + sidebarWidth\u002F2 | itemTop + 20 |\n| Top-right action button | Right side | 1400-1480 | 120-160 |\n| Form with submit at bottom | Center | 960 | formBottom - 30 |\n| Mock browser, button inside content | Center of mock content area | 960 | mockContentY + buttonOffsetY |\n\n**Example — scene with a Card containing a title + text + centered button:**\n```\nCard padding: 32px (p-8). Card is centered in SafeZone.\nTitle: ~40px tall, marginBottom 16px\nDescription text: ~24px tall, marginBottom 24px\nButton: ~44px tall\n\nTotal content height ≈ 32 + 40 + 16 + 24 + 24 + 44 + 32 = 212px\nCard top = (1080 - 212) \u002F 2 ≈ 434\nButton center Y = 434 + 32 + 40 + 16 + 24 + 24 + 22 = 592\nButton center X = 960 (centered)\n\n→ \u003CClickIndicator x={960} y={592} ... \u002F>\n```\n\n**IMPORTANT: When writing scene JSX, give the target button a known size.** If the button's size is explicit (e.g., `style={{ width: 200, height: 44 }}`), coordinate calculation is much more reliable. Always set explicit `width` and `height` on clickable elements when using ClickIndicator.\n\n**When to use ClickIndicator:** narration contains \"click\", \"select\", \"tap\", \"press\", \"hit\", \"choose\" AND the scene shows a mock UI with a visible button or link.\n**When NOT to use:** terminal\u002Fcode scenes (no buttons), abstract concepts, intro\u002Fsummary scenes.\n\n**Callouts get their own scene.** Warnings, tips, and notes from the docs become separate 3-4s scenes using `CalloutCard`. Don't embed them inside code scenes.\n\n**Layouts.** Use at least 3 different layouts across scenes: centered, split (60\u002F40), stacked, grid, full-bleed.\n\n---\n\n#### Complete Generated.tsx Example\n\nThis shows how ALL the pieces compose together. **Key pattern: timecodes drive all timing.**\n\n```typescript\nimport React from 'react';\nimport {\n  AbsoluteFill, useCurrentFrame, useVideoConfig,\n  staticFile, interpolate, spring,\n} from 'remotion';\nimport { Audio } from '@remotion\u002Fmedia';\nimport { TransitionSeries, linearTiming } from '@remotion\u002Ftransitions';\nimport { fade } from '@remotion\u002Ftransitions\u002Ffade';\nimport { slide } from '@remotion\u002Ftransitions\u002Fslide';\nimport { loadFont } from '@remotion\u002Fgoogle-fonts\u002FInter';\nimport { TutorialVideoProps } from '..\u002FRoot';\n\n\u002F\u002F === Import USER's components (adjust paths per project) ===\nimport { Button } from '..\u002F..\u002F..\u002Fsrc\u002Fcomponents\u002Fui\u002Fbutton';\nimport { Card } from '..\u002F..\u002F..\u002Fsrc\u002Fcomponents\u002Fui\u002Fcard';\nimport { Badge } from '..\u002F..\u002F..\u002Fsrc\u002Fcomponents\u002Fui\u002Fbadge';\n\nconst { fontFamily } = loadFont('normal', { weights: ['400', '700'], subsets: ['latin'] });\n\n\u002F\u002F === Timecode helpers (REQUIRED — copy these into every Generated.tsx) ===\n\ntype Timecode = { start: number; end: number; text: string };\n\n\u002F** Group the flat timecodes array into per-scene groups by matching timecode text\n *  to narration paragraphs. Each timecode's `.text` is checked against each paragraph —\n *  if the paragraph contains that text, the timecode belongs to that scene.\n *  This avoids period-splitting issues (\"Dr.\", \"vs.\") since it uses the actual\n *  timecodes returned by the TTS engine. *\u002F\nfunction groupTimecodesByScene(timecodes: Timecode[], paragraphs: string[]): Timecode[][] {\n  const groups: Timecode[][] = paragraphs.map(() => []);\n  let paraIdx = 0;\n\n  for (const tc of timecodes) {\n    \u002F\u002F Find which paragraph this timecode belongs to by checking if its text\n    \u002F\u002F appears in the paragraph (starting from current paragraph, moving forward)\n    let placed = false;\n    for (let p = paraIdx; p \u003C paragraphs.length; p++) {\n      const normalizedPara = paragraphs[p].toLowerCase().replace(\u002F[^a-z0-9 ]\u002Fg, '');\n      const normalizedTc = tc.text.toLowerCase().replace(\u002F[^a-z0-9 ]\u002Fg, '');\n      if (normalizedPara.includes(normalizedTc) || normalizedTc.includes(normalizedPara.slice(0, 20))) {\n        groups[p].push(tc);\n        paraIdx = p;\n        placed = true;\n        break;\n      }\n    }\n    \u002F\u002F If timecode text didn't match any paragraph, assign to next paragraph that has no timecodes yet\n    if (!placed) {\n      \u002F\u002F Advance to next paragraph if current one already has timecodes and this tc starts later\n      if (paraIdx \u003C paragraphs.length - 1 && groups[paraIdx].length > 0 &&\n          tc.start > groups[paraIdx][groups[paraIdx].length - 1].end + 0.1) {\n        paraIdx++;\n      }\n      if (paraIdx \u003C groups.length) {\n        groups[paraIdx].push(tc);\n      }\n    }\n  }\n  return groups;\n}\n\n\u002F** Compute scene duration in frames from its timecodes *\u002F\nfunction getSceneDuration(group: Timecode[], fps: number): number {\n  if (group.length === 0) return 4 * fps;\n  return Math.round((group[group.length - 1].end - group[0].start + 0.5) * fps);\n}\n\n\u002F** Get the frame offset for a sentence relative to its scene start *\u002F\nfunction getRevealFrame(tc: Timecode, sceneStart: number, fps: number): number {\n  return Math.round((tc.start - sceneStart) * fps);\n}\n\n\u002F\u002F === Reusable helpers (SafeZone, StepIndicator, TypingText, HighlightedCode, CalloutCard) ===\n\u002F\u002F ... paste the helper components from above here ...\n\n\u002F\u002F === Scene Components (accept sceneTimecodes for timing) ===\n\nconst IntroScene: React.FC\u003C{\n  content: any; branding: any; height: number; sceneTimecodes: Timecode[];\n}> = ({ content, branding, height, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  \u002F\u002F Reveal 1: card fades in at scene start\n  const cardScale = spring({ frame, fps, from: 0.8, to: 1, config: { damping: 12 } });\n  const cardOpacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 2: title appears when sentence 1 starts (offset by 12 frames so it doesn't collide with card fade-in)\n  const titleFrame = Math.max(12, sceneTimecodes[0] ? getRevealFrame(sceneTimecodes[0], s0, fps) : 12);\n  const titleOpacity = interpolate(frame, [titleFrame, titleFrame + 13], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 3: badges appear when sentence 2 starts (or after title)\n  const badgeFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 25;\n  const badgeOpacity = interpolate(frame, [badgeFrame, badgeFrame + 13], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003CSafeZone background={`linear-gradient(135deg, ${branding.colors.primary}, ${branding.colors.secondary})`}>\n      \u003Cdiv style={{ transform: `scale(${cardScale})`, opacity: cardOpacity, width: '100%' }}>\n        \u003CCard className=\"p-8\">\n          \u003Cdiv style={{ opacity: titleOpacity }}>\n            \u003Ch1 style={{ fontSize: height * 0.07, fontWeight: 700 }}>{content.title}\u003C\u002Fh1>\n          \u003C\u002Fdiv>\n          \u003Cdiv className=\"flex gap-2 mt-4\" style={{ opacity: badgeOpacity }}>\n            \u003CBadge>Tutorial\u003C\u002FBadge>\n            \u003CBadge variant=\"outline\">{content.technology}\u003C\u002FBadge>\n          \u003C\u002Fdiv>\n        \u003C\u002FCard>\n      \u003C\u002Fdiv>\n    \u003C\u002FSafeZone>\n  );\n};\n\nconst CommandScene: React.FC\u003C{\n  title: string; command: string;\n  stepNumber: number; totalSteps: number;\n  branding: any; sceneTimecodes: Timecode[];\n}> = ({ title, command, stepNumber, totalSteps, branding, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const colors = branding.colors;\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  \u002F\u002F Reveal 1: card at scene start\n  const cardOpacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 2: title synced to sentence 1\n  const titleFrame = sceneTimecodes[0] ? getRevealFrame(sceneTimecodes[0], s0, fps) : 8;\n  const titleOpacity = interpolate(frame, [titleFrame, titleFrame + 12], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 3: terminal synced to sentence 2 (when narrator says the command)\n  const termFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 22;\n  const termOpacity = interpolate(frame, [termFrame, termFrame + 10], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003C>\n      \u003CSafeZone background={`linear-gradient(135deg, ${colors.primary}11, ${colors.secondary}11)`}>\n        \u003Cdiv style={{ opacity: cardOpacity, width: '100%' }}>\n          \u003CCard className=\"p-8\">\n            \u003Cdiv style={{ opacity: titleOpacity }}>\n              \u003CBadge className=\"mb-4\">Step {stepNumber}\u003C\u002FBadge>\n              \u003Ch2 style={{ fontSize: 32, fontWeight: 700, marginBottom: 16 }}>{title}\u003C\u002Fh2>\n            \u003C\u002Fdiv>\n            \u003Cdiv style={{ opacity: termOpacity, background: '#1a1a2e', borderRadius: 8, padding: 24,\n              fontFamily: 'monospace', fontSize: 20, color: '#00ff88' }}>\n              \u003Cspan style={{ color: '#888' }}>$ \u003C\u002Fspan>\n              \u003CTypingText text={command} startFrame={termFrame + 5} charsPerFrame={0.5} \u002F>\n            \u003C\u002Fdiv>\n          \u003C\u002FCard>\n        \u003C\u002Fdiv>\n      \u003C\u002FSafeZone>\n      \u003CStepIndicator current={stepNumber} total={totalSteps} colors={colors} \u002F>\n    \u003C\u002F>\n  );\n};\n\nconst CodeScene: React.FC\u003C{\n  title: string; lines: string[]; highlightLine: number;\n  stepNumber: number; totalSteps: number;\n  branding: any; sceneTimecodes: Timecode[];\n}> = ({ title, lines, highlightLine, stepNumber, totalSteps, branding, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const colors = branding.colors;\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  const cardOpacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });\n  \u002F\u002F Title synced to sentence 1\n  const titleFrame = sceneTimecodes[0] ? getRevealFrame(sceneTimecodes[0], s0, fps) : 8;\n  const titleOpacity = interpolate(frame, [titleFrame, titleFrame + 12], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  \u002F\u002F Code synced to sentence 2\n  const codeFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 22;\n  const codeOpacity = interpolate(frame, [codeFrame, codeFrame + 13], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003C>\n      \u003CSafeZone background={`linear-gradient(135deg, ${colors.primary}11, ${colors.secondary}11)`}>\n        \u003Cdiv style={{ opacity: cardOpacity, width: '100%' }}>\n          \u003Cdiv style={{ opacity: titleOpacity }}>\n            \u003CBadge className=\"mb-4\">Step {stepNumber}\u003C\u002FBadge>\n            \u003Ch2 style={{ fontSize: 28, fontWeight: 700, marginBottom: 16, color: '#fff' }}>{title}\u003C\u002Fh2>\n          \u003C\u002Fdiv>\n          \u003Cdiv style={{ opacity: codeOpacity }}>\n            \u003CHighlightedCode lines={lines} highlightLine={highlightLine} startFrame={codeFrame + 15} colors={colors} \u002F>\n          \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      \u003C\u002FSafeZone>\n      \u003CStepIndicator current={stepNumber} total={totalSteps} colors={colors} \u002F>\n    \u003C\u002F>\n  );\n};\n\nconst ResultScene: React.FC\u003C{ command: string; output: string; sceneTimecodes: Timecode[] }> = ({ command, output, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n  const outputFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 15;\n  const outputOpacity = interpolate(frame, [outputFrame, outputFrame + 15], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003CSafeZone background=\"#0d1117\">\n      \u003Cdiv style={{ width: '100%', background: '#161b22', borderRadius: 8, padding: 24, fontFamily: 'monospace', fontSize: 18 }}>\n        \u003Cdiv style={{ color: '#8b949e' }}>$ {command}\u003C\u002Fdiv>\n        \u003Cdiv style={{ color: '#3fb950', marginTop: 12, opacity: outputOpacity }}>{output}\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n    \u003C\u002FSafeZone>\n  );\n};\n\nconst CalloutScene: React.FC\u003C{ type: 'warning' | 'tip' | 'note'; message: string; colors: any }> = ({ type, message, colors }) => {\n  return (\n    \u003CSafeZone background={`${colors.primary}0a`}>\n      \u003CCalloutCard type={type} message={message} \u002F>\n    \u003C\u002FSafeZone>\n  );\n};\n\nconst SummaryScene: React.FC\u003C{ steps: string[]; branding: any; sceneTimecodes: Timecode[] }> = ({ steps, branding, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  return (\n    \u003CSafeZone background={`linear-gradient(135deg, ${branding.colors.primary}, ${branding.colors.secondary})`}>\n      \u003CCard className=\"p-8\" style={{ width: '100%' }}>\n        \u003Ch2 style={{ fontSize: 36, fontWeight: 700, marginBottom: 20 }}>What We Covered\u003C\u002Fh2>\n        {steps.map((step, i) => {\n          \u002F\u002F Each recap item synced to a sentence timecode\n          const revealAt = sceneTimecodes[i] ? getRevealFrame(sceneTimecodes[i], s0, fps) : 10 + i * 12;\n          const itemOpacity = interpolate(frame, [revealAt, revealAt + 12], [0, 1],\n            { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n          return (\n            \u003Cdiv key={i} style={{ opacity: itemOpacity, display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>\n              \u003Cspan style={{ color: '#3fb950', fontSize: 20 }}>✓\u003C\u002Fspan>\n              \u003Cspan style={{ fontSize: 20 }}>{step}\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          );\n        })}\n      \u003C\u002FCard>\n    \u003C\u002FSafeZone>\n  );\n};\n\n\u002F\u002F === Main Composition ===\n\nexport const Generated: React.FC\u003CTutorialVideoProps> = ({ content, branding, audio, duration }) => {\n  const { fps, height } = useVideoConfig();\n  const totalSteps = 3;\n\n  \u002F\u002F --- Timecode-driven timing ---\n  const timecodes: Timecode[] = audio.narration?.timecodes ?? [];\n  const narrationParagraphs = [\n    \"Let's set up Stripe payments in your app.\",\n    \"Step 1. Run the install command to grab the Stripe SDK.\",\n    \"And just like that, the package is ready to go.\",\n    \"Step 2. Create a checkout session. The key part is the line_items array — that defines what the customer is buying.\",\n    \"One important thing — never expose your secret key in client-side code.\",\n    \"Step 3. Drop your API key into the environment file so Stripe can authenticate your requests.\",\n    \"That's it! You've installed Stripe, created a checkout, and secured your key.\",\n  ];\n  const scenes = groupTimecodesByScene(timecodes, narrationParagraphs);\n\n  return (\n    \u003CAbsoluteFill style={{ background: '#000', fontFamily }}>\n      {audio.music?.staticPath && (\n        \u003CAudio\n          src={staticFile(audio.music.staticPath)}\n          volume={(f) => {\n            const duckFrame = getSceneDuration(scenes[0], fps);\n            return interpolate(f, [duckFrame, duckFrame + 15], [0.25, 0.12], {\n              extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n            });\n          }}\n        \u002F>\n      )}\n      {audio.narration?.staticPath && \u003CAudio src={staticFile(audio.narration.staticPath)} volume={1} \u002F>}\n\n      \u003CTransitionSeries>\n        {\u002F* Scene 1: Intro — duration from timecodes *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[0], fps)}>\n          \u003CIntroScene content={content} branding={branding} height={height} sceneTimecodes={scenes[0]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 2: Install command — reveals synced to sentences *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[1], fps)}>\n          \u003CCommandScene title=\"Install the SDK\" command=\"npm install @stripe\u002Fstripe-js\"\n            stepNumber={1} totalSteps={totalSteps} branding={branding} sceneTimecodes={scenes[1]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 8 })} presentation={slide()} \u002F>\n\n        {\u002F* Scene 3: Install result — output synced to sentence *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[2], fps)}>\n          \u003CResultScene command=\"npm install @stripe\u002Fstripe-js\" output=\"✓ Added 3 packages in 2.1s\"\n            sceneTimecodes={scenes[2]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 4: Code walkthrough — highlight synced to sentence 3 *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[3], fps)}>\n          \u003CCodeScene title=\"Create the checkout session\"\n            lines={[\n              \"import Stripe from 'stripe';\",\n              \"const stripe = new Stripe(process.env.STRIPE_KEY);\",\n              \"const session = await stripe.checkout.sessions.create({\",\n              \"  line_items: [{ price: 'price_xxx', quantity: 1 }],\",\n              \"  mode: 'payment',\",\n              \"  success_url: 'https:\u002F\u002Fexample.com\u002Fsuccess',\",\n              \"});\",\n            ]}\n            highlightLine={3}\n            stepNumber={2} totalSteps={totalSteps} branding={branding} sceneTimecodes={scenes[3]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 5: Warning callout *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[4], fps)}>\n          \u003CCalloutScene type=\"warning\" message=\"Never expose your secret key in client-side code.\"\n            colors={branding.colors} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 6: Config step *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[5], fps)}>\n          \u003CCommandScene title=\"Add your API key\" command=\"echo STRIPE_KEY=sk_test_... >> .env\"\n            stepNumber={3} totalSteps={totalSteps} branding={branding} sceneTimecodes={scenes[5]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 7: Summary — each recap item synced to a sentence *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[6], fps)}>\n          \u003CSummaryScene steps={['Installed the Stripe SDK', 'Created a checkout session', 'Configured the API key']}\n            branding={branding} sceneTimecodes={scenes[6]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n      \u003C\u002FTransitionSeries>\n    \u003C\u002FAbsoluteFill>\n  );\n};\n```\n\n**Adapt this for each video:**\n- Write `narrationParagraphs` to match your exact narration script from Step 4\n- **Always use `getSceneDuration(scenes[i], fps)`** for `durationInFrames` — never hardcode `N * fps`\n- **Always pass `sceneTimecodes={scenes[i]}` to scene components** — use `getRevealFrame()` for visual timing\n- Add or remove scenes to match the number of narration paragraphs (one paragraph = one scene)\n- Import different user components as needed\n- **Click indicators:** When a scene narration says \"click X\", add `\u003CClickIndicator x={buttonCenterX} y={buttonCenterY} startFrame={getRevealFrame(sceneTimecodes[0], sceneStart, fps) + 15} label=\"X\" \u002F>` as a sibling to `\u003CSafeZone>`, not inside it. **Calculate `x` and `y` precisely** using the coordinate math in the \"ACCURATE CLICK POSITIONING\" section — do NOT guess approximate values. Give the target button explicit `width` and `height` styles so you can calculate its center accurately.\n\n**Remotion rules:**\n- Audio: `import { Audio } from '@remotion\u002Fmedia'` — NOT from `remotion`\n- Audio src: `staticFile(audio.music.staticPath)` — NEVER raw paths\n- Conditional audio: `{audio.music?.staticPath && \u003CAudio ... \u002F>}`\n- Animations: ONLY `useCurrentFrame()` + `spring()` \u002F `interpolate()` — NO CSS animations\n- Images: `\u003CImg>` from `remotion` — NOT `\u003Cimg>`\n- Fonts: Load via `@remotion\u002Fgoogle-fonts` before use\n- Duration: always from `getSceneDuration(timecodes, fps)` — NEVER hardcode `N * fps`\n- Clamp: Always use `extrapolateRight: 'clamp'` on interpolate\n- SafeZone: every scene wraps content in `\u003CSafeZone>` — NEVER use bare `\u003CAbsoluteFill>` with ad-hoc padding\u002Fwidth\n- Music volume: use `volume={(f) => ...}` callback for ducking — 0.25 during intro, fades to 0.12 when steps begin. Use `getSceneDuration(scenes[0], fps)` as the duck point.\n\n---\n\n### Step 6: Validate & Render\n\n**Validation checklist (must pass all):**\n\n**Remotion basics:**\n1. Audio imported from `@remotion\u002Fmedia` (not `remotion`)\n2. Audio uses `staticFile()` with `staticPath`\n3. Conditional audio rendering\n4. Font loaded via `@remotion\u002Fgoogle-fonts`\n5. No CSS animations or Tailwind `animate-*` classes\n6. All animations use `useCurrentFrame()` + `spring`\u002F`interpolate`\n7. User's components are imported with correct relative paths\n8. User's components render without errors in Remotion context\n9. **Scene durations use `getSceneDuration()` from timecodes** — no hardcoded `N * fps`\n10. **Scene components receive `sceneTimecodes` prop** — reveals use `getRevealFrame()` not fixed frame offsets\n11. **Every scene uses `\u003CSafeZone>`** — no content placed directly in `\u003CAbsoluteFill>` without the SafeZone wrapper\n12. **Music uses volume ducking** — `volume={(f) => ...}` callback, NOT a flat number. 0.25 during intro, ducks to 0.12 when narration steps begin.\n\n**Scene structure:**\n13. Has intro scene and summary scene\n14. **Each scene explains exactly ONE step** — no scene combines two steps. If you see a numbered list (`1. ... 2. ... 3. ...`) in any scene's JSX, the video is BROKEN — split immediately\n15. **No numbered or bulleted lists in scenes** — if a scene renders `\u003Col>`, `\u003Cul>`, or manual numbering, it MUST be split into separate scenes. This is the #1 most common mistake.\n16. **Every scene has at least 30% empty space** — if it looks crowded, remove content or split\n17. **SafeZone constrains all content** — no element extends beyond the 1200px centered column. No mock UIs pushed to screen edges. No content touching the 1920px frame boundary.\n18. **Every narration paragraph has a matching visual scene** — no explanation without a visual\n19. **Every sentence in the narration has a matching visual change** — the screen NEVER stays static while the narrator keeps talking\n20. **No scene has more than 3 sentences of narration** — if it does, split into two scenes\n21. Narration content matches visual progression 1:1\n\n**Tutorial quality:**\n22. **All code\u002Fcommands use TypingText** — characters appear one by one with blinking cursor\n23. **Multi-line code uses HighlightedCode** — important line spotlighted, rest dimmed\n24. **Step scenes have StepIndicator** — progress dots showing \"Step N of M\" (skip on intro\u002Fsummary)\n25. **Elements appear in stages synced to timecodes** — each reveal at a `getRevealFrame()` value, never all at once\n26. **Command scenes are followed by ResultScene** — show `✓ Success` output after running a command\n27. **Warnings\u002Ftips from docs are CalloutScene** — distinct styled cards, not embedded in code scenes\n28. **Pacing driven by timecodes** — scene durations match narration length, not arbitrary guesses\n29. **Click indicators on interactive steps** — when narration says \"click\u002Fselect\u002Ftap\u002Fpress\", a `\u003CClickIndicator>` appears over the target button, synced to the sentence's `getRevealFrame()`\n\n**Call `render_video`** with:\n- `inputProps` — full props (content, branding, audio, metadata, duration)\n- `outputFileName` — descriptive name like `react-hooks-tutorial`\n- `remotionProjectPath` — absolute path to the `remotion\u002F` directory in the user's project\n\nDuration is automatically calculated from narration length via `calculateMetadata` (narration drives video length, since it IS the content).\n\n---\n\n## Component Compatibility Notes\n\nNot all React components work in Remotion's rendering context. Watch for:\n\n- **Event handlers** (onClick, onHover) — harmless, just won't fire in video\n- **CSS animations** — FORBIDDEN. If a user component uses `@keyframes` or `transition`, you must override those styles inline: `style={{ animation: 'none', transition: 'none' }}`\n- **Portals** — won't work in Remotion. Skip components that use `createPortal` (modals, tooltips)\n- **Client-side state** — `useState`\u002F`useEffect` for animation won't work. Replace with Remotion's `useCurrentFrame()`\n- **Dynamic imports** — avoid. Use static imports only\n- **Browser APIs** — `window`, `document` queries won't work during render\n\n**When a user's component won't work in Remotion**, build a visual replica inline using:\n- The same CSS classes (if Tailwind)\n- The same color tokens\n- The same border-radius, shadow, and spacing values\n- Read their component source to match the visual output\n","# Docs to Video\n\nGenerate tutorial and how-to videos from documentation URLs. The video uses the user's **own React components** (buttons, cards, layouts) so the tutorial looks like their actual product. Produces an MP4 with AI narration, background music, and animated scenes.\n\n**Target file:** `remotion\u002Fsrc\u002Fcompositions\u002FGenerated.tsx` (inside user's project, overwritten each run)\n\n---\n\n## Step 0: Setup Validation (run this FIRST)\n\nBefore starting the workflow, check if the MCP tools are available. Try calling any tool (e.g., `extract_docs_content`). If it works, skip to Step 1.\n\n**If tools are NOT available** (you get \"unknown tool\" or similar error), run the setup:\n\n```bash\nnpx docs-to-video setup\n```\n\nThis copies the MCP server, remotion template, and dependencies into the user's project. It also registers the MCP server and skill automatically.\n\nIf the user doesn't have API keys yet, tell them:\n> You need to add your API keys to `~\u002F.docs-to-video\u002F.env`:\n> - **TABSTACK_API_KEY** — get it at https:\u002F\u002Ftabstack.ai\u002Fdashboard\n> - **ELEVENLABS_API_KEY** — get it at https:\u002F\u002Felevenlabs.io\n\nThen **tell the user to restart Claude Code:**\n> Setup complete! Please restart Claude Code (quit and re-open) for the MCP tools to load, then run `\u002Fdocs-to-video \u003Curl>` again.\n\n**STOP here if you had to run setup.** The MCP tools only load at Claude Code startup, so the user must restart before proceeding to Step 1.\n\n---\n\n## 6-Step Workflow\n\n### Step 1: Scan User's Codebase\n\nBefore anything else, understand the user's project:\n\n1. **Find components** — scan `src\u002Fcomponents\u002F`, `src\u002Fui\u002F`, `components\u002F`, `app\u002Fcomponents\u002F` or wherever components live\n2. **Identify the design system** — look for:\n   - UI primitives: Button, Card, Input, Badge, Alert, Modal\n   - Layout components: Container, Grid, Stack, Flex, Sidebar\n   - Typography: Heading, Text, Label, Code\n   - Navigation: Tabs, Breadcrumb, Menu\n3. **Read the styling approach** — Tailwind? CSS Modules? Styled Components? Inline styles?\n4. **Note import paths** — e.g., `@\u002Fcomponents\u002Fui\u002Fbutton` or `..\u002F..\u002Fcomponents\u002FButton`\n5. **Check for a tailwind.config** or theme file — extract their color palette, fonts, spacing scale\n6. **Record the project root** — the absolute path to the user's project root (where `package.json` is). This is needed for `remotionProjectPath` in all tool calls.\n\n**What to save from this step:**\n- List of reusable components with their import paths and key props\n- The project's color palette \u002F design tokens\n- Font family names\n- The absolute path to the project root\n- The relative path from `remotion\u002Fsrc\u002Fcompositions\u002FGenerated.tsx` to the user's components\n\n---\n\n### Step 2: Scaffold Remotion (if needed)\n\n**IMPORTANT: This step MUST happen before Steps 3-4.** The extraction and audio tools save files to `remotion\u002Fpublic\u002F`. If `remotion\u002F` doesn't exist yet, those files would be lost.\n\nCheck if `remotion\u002F` directory exists in the user's project root. If not, create it and copy the template contents:\n\n```bash\nmkdir -p remotion\ncp -r remotion-template\u002F* remotion\u002F\n```\n\n> ⚠️ **DO NOT** use `cp -r remotion-template remotion` — if `remotion\u002F` already exists, this creates `remotion\u002Fremotion-template\u002F` (nested and broken). Always use `cp -r remotion-template\u002F* remotion\u002F` to copy the **contents**.\n\n**After copying, verify the structure is flat** — `remotion\u002Fpackage.json` must exist at the top level:\n\n```\nremotion\u002F\n├── src\u002F\n│   ├── Root.tsx\n│   ├── index.ts\n│   ├── style.css          (Tailwind entry — required)\n│   └── compositions\u002F\n│       └── Generated.tsx\n├── public\u002F\n│   ├── audio\u002F    (generated audio goes here)\n│   └── images\u002F   (downloaded logos go here)\n├── package.json\n├── tsconfig.json\n└── tailwind.config.js\n```\n\nIf you see `remotion\u002Fremotion-template\u002F` (nested), fix it immediately:\n```bash\nmv remotion\u002Fremotion-template\u002F* remotion\u002F && rm -rf remotion\u002Fremotion-template\n```\n\nNext:\n\n1. **Update Tailwind content paths** if the user's components aren't in `..\u002Fsrc\u002F`:\n   - Default: `'..\u002Fsrc\u002F**\u002F*.{ts,tsx}'`\n   - If user has `app\u002F` structure: add `'..\u002Fapp\u002F**\u002F*.{ts,tsx}'`\n   - If monorepo with `packages\u002F`: add `'..\u002Fpackages\u002Fui\u002F**\u002F*.{ts,tsx}'`\n   - Check where the user's components live and ensure `remotion\u002Ftailwind.config.js` includes them\n\n2. Run `cd remotion && npm install`\n\n3. Verify: `remotion\u002Fsrc\u002Findex.ts` exists and imports Root and `style.css`\n\n**If remotion\u002F already exists**, verify the structure is flat (no nested `remotion-template\u002F` folder) and that `tailwind.config.js` content paths include the user's component directories.\n\n---\n\n### Step 3: Extract Documentation\n\n**Call `extract_docs_content`** with:\n- `url` — the documentation URL\n- `remotionProjectPath` — absolute path to the `remotion\u002F` directory (e.g., `\u002FUsers\u002Fme\u002Fmy-app\u002Fremotion`)\n\nReturns:\n- `markdown` — full page content as clean markdown (code blocks, headings, lists preserved)\n- `metadata` — title, technology, docType, difficulty, prerequisites, sections\n- `branding` — logo (url + staticPath), colors, font, theme\n- `domain` — documentation domain\n- `warnings` — any extraction issues\n\n**Then analyze the documentation:**\n\n1. **Content Type** — from `metadata.docType` (one scene per step = more scenes):\n   - `quickstart` → 3-5 steps = 5-7 scenes (intro + steps + summary)\n   - `tutorial` → 5-8 steps = 7-10 scenes\n   - `api-reference` → endpoint showcases, 4-6 scenes\n   - `guide` → concept explanations + code, 6-10 scenes\n   - `how-to` → problem\u002Fsolution format, 5-8 scenes\n\n2. **Parse the markdown** to identify:\n   - Code blocks (language from fence markers)\n   - Numbered lists (step sequences)\n   - Headings hierarchy (scene structure)\n   - Blockquotes (tips, warnings — these become callout scenes)\n\n3. **Music Style** — pick a style that matches the tutorial energy:\n   - `pop` — upbeat, energetic (good default for most tutorials)\n   - `hip-hop` — modern, rhythmic (developer tools, CLI tutorials)\n   - `rock` — high energy (gaming, performance topics)\n   - `jazz` — smooth, sophisticated (enterprise, fintech)\n\n---\n\n### Step 4: Write Narration Script & Generate Audio\n\nWrite a **short, punchy** narration script. Each paragraph becomes one scene — **one step per paragraph, one paragraph per scene**:\n\n- **Intro** — 1 sentence. \"Let's set up [topic] with [technology].\"\n- **Step 1** — 1-2 sentences. \"Step 1. Run the install command to grab the Stripe SDK.\"\n- **Step 2** — 1-2 sentences. \"Step 2. Add your secret key to the environment — Stripe uses it to authenticate.\"\n- **Step 3** — 1-2 sentences. \"Step 3. Create a checkout session — this is where the magic happens.\"\n- ... (one paragraph per step, as many as the docs require)\n- **Summary** — 1-2 sentences. \"That's it! You've got Stripe installed, a checkout ready, and your key secured.\"\n\n> **⚠️ KEEP IT SHORT.** The narration is a voiceover for a visual tutorial, not a lecture.\n> The visuals do the heavy lifting — the narration just guides attention.\n> - **Most steps need only 1 sentence.** Say what to do, not how it works.\n> - **Maximum 2 sentences per step.** If you need 3+, split into two scenes.\n> - **Cut filler words.** No \"Now let's go ahead and...\" — just \"Install the SDK.\"\n> - **Total video: 30-60 seconds.** Aim for the shortest video that covers all steps.\n\n**⚠️ CRITICAL:** Do NOT combine multiple steps into a single paragraph. Each step gets its own narration paragraph and its own visual scene. If the docs have 5 substeps (like \"visit the site, click settings, create key, name it, copy it\"), write 5 separate paragraphs — the agent creates one scene per paragraph, so combining them forces multiple steps into one scene, which is the #1 quality failure.\n\n**Pacing — how much narration to write per step** (scene duration is derived from timecodes, not from this table):\n\n| Step complexity | Narration to write | Example |\n|----------------|-------------------|---------|\n| Simple command | 1 sentence (~10 words) | \"Step 1. Run the install to grab the Stripe SDK.\" |\n| Config\u002Fsetup | 1-2 sentences (~18 words) | \"Step 2. Add your secret key to the env file — Stripe needs this to authenticate.\" |\n| Code with explanation | 2 sentences (~22 words) | \"Step 3. Create the checkout session. The key part here is the line_items array — that's what the customer is buying.\" |\n| Multi-part concept | 2 sentences (~25 words) | \"Step 4. Set up the webhook endpoint. This listens for payment events so your app knows when a charge goes through.\" |\n\n**Guidelines:**\n- **Always say \"Step N\" at the start of each step's narration.** The viewer should hear \"Step 1\", \"Step 2\", etc. for clear audible navigation and pacing. The intro and summary scenes do NOT get step numbers.\n- **Narration should be conversational and add value beyond the visuals.** It's fine to reference what's on screen (\"run the install command\", \"add this to your config\"), but don't literally read code or commands verbatim. Describe actions naturally and add *why* it matters. BAD: \"Type npm install at sign stripe slash stripe dash js.\" GOOD: \"Step 1. Run the install command to pull in the Stripe library.\" The voice should feel like a knowledgeable friend guiding you, not a screen reader.\n- Short and direct — say what to do, let the visuals show how\n- Use \"we\" and \"let's\" sparingly — prefer imperative: \"Install the SDK\" over \"Let's go ahead and install the SDK\"\n- Mention specific function\u002Fclass names only when the viewer needs to see them\n- **Avoid abbreviations with periods** (\"Dr.\", \"vs.\") — the timecode system splits on `.!?` and abbreviations cause misaligned sentence counts\n- **No filler phrases:** \"Now let's go ahead and\", \"What we're going to do next is\", \"As you can see\" — cut all of these\n\n**Script structure for natural speech flow:**\n- Write as if you're a knowledgeable friend walking someone through the setup — warm, clear, and helpful\n- Each step paragraph MUST start with \"Step N.\" followed by a natural description of the action\n- It's fine to reference what's on screen (\"run the install\", \"add this config\"), but describe it naturally — never read code character by character\n- Add a brief *why* or *what it does* after the action — \"Step 2. Add your key to the env file — Stripe needs it to verify your account.\" This makes the narration valuable beyond just watching the screen\n- Write each paragraph as one or two flowing sentences — NOT bullet points or fragments\n- Avoid choppy fragments like \"Now. The key.\" — write \"Now add the API key to your environment.\"\n- Connect ideas within a paragraph: \"Install the SDK, then import it in your app.\"\n- Separate paragraphs with blank lines — these create natural pauses in the narration audio\n- Do NOT use SSML tags — ElevenLabs handles pacing from plain text and paragraph breaks\n\n**Call `generate_audio`** with:\n- `musicStyle` — `pop`, `hip-hop`, `rock`, or `jazz` (default to `pop` for most tutorials)\n- `narrationScript` — your script\n- `duration` — estimated total video length in seconds\n- `remotionProjectPath` — absolute path to the `remotion\u002F` directory\n\n**IMPORTANT: Always attempt music generation.** Do NOT skip it. Call the tool with `musicStyle` and let the API respond. Only fall back to narration-only if the API returns an actual error. Never preemptively skip music.\n\n**Save returned values:**\n- `audio.music.staticPath`\n- `audio.narration.staticPath`\n- `audio.narration.timecodes` — array of `{start: number, end: number, text: string}` per sentence, derived from **real audio alignment** (not estimated). **You MUST use these to time scene durations and visual reveals in Step 5.** Do NOT guess frame numbers.\n- `audio.beats`\n\n**Also save your narration paragraphs** as a list — you'll need them in Step 5 to map timecodes to scenes.\n\n---\n\n### Step 5: Design & Write Generated.tsx\n\nWrite the full composition at `remotion\u002Fsrc\u002Fcompositions\u002FGenerated.tsx`.\n\n#### Quick Reference — Every Step Scene Must Have:\n\n1. **`\u003CSafeZone>`** — every scene wraps content in SafeZone (centered 1200px column, consistent padding)\n2. **`\u003CStepIndicator>`** — progress dots showing \"Step N of M\" (top-right)\n3. **Staged reveals synced to timecodes** — each visual element appears at a `getRevealFrame()` value matching the narrator's sentence\n4. **Typing effect** — all code\u002Fcommands type character-by-character with blinking cursor\n5. **Code highlighting** — for multi-line code, spotlight the key line, dim the rest\n6. **Result scene after commands** — show `✓ Success` output after install\u002Fbuild commands\n7. **Callout scenes** — warnings\u002Ftips from docs get their own styled card scene\n8. **ONE step per scene** — never combine multiple steps, numbered lists, or actions into one scene\n\n---\n\n> **⚠️ SCENE SPLITTING — THE MOST COMMON MISTAKE (read this before writing ANY scene)**\n>\n> **If a scene contains a numbered list or multiple steps, it is WRONG. Stop and split it.**\n>\n> The #1 failure mode is cramming multiple steps into one scene. A scene titled \"Create Your API Key\"\n> that shows 5 substeps (visit site, navigate, click button, name it, copy key) is WRONG — those are 5 separate scenes.\n>\n> **Rules (violations = broken video):**\n> - ONE step, ONE idea, ONE scene. No exceptions.\n> - A numbered\u002Fbulleted list inside a scene is ALWAYS wrong.\n> - Maximum per scene: ONE title + ONE code block or ONE command + ONE supporting visual.\n> - If you catch yourself writing `1.`, `2.`, `3.` inside a single scene's JSX — STOP. Split into separate scenes.\n> - If a narration paragraph covers more than one action, split the paragraph AND the scene.\n> - A scene with a mock UI dashboard showing multiple actions is ALWAYS wrong — show one action per scene.\n>\n> **BAD — one scene with 5 steps (NEVER do this):**\n> ```jsx\n> \u002F\u002F WRONG: All 5 steps crammed into one scene\n> \u003CSafeZone>\n>   \u003Ch2>Create Your API Key\u003C\u002Fh2>\n>   \u003Col>\n>     \u003Cli>Visit console.tabstack.ai and sign in\u003C\u002Fli>\n>     \u003Cli>Navigate to API Keys\u003C\u002Fli>\n>     \u003Cli>Click \"Create New API Key\"\u003C\u002Fli>\n>     \u003Cli>Name it and click Create\u003C\u002Fli>\n>     \u003Cli>Copy the generated key\u003C\u002Fli>\n>   \u003C\u002Fol>\n>   \u003CMockDashboard \u002F>\n> \u003C\u002FSafeZone>\n> ```\n>\n> **GOOD — 5 separate scenes, one step each:**\n> ```jsx\n> \u002F\u002F Scene 3: Navigate to console\n> \u003CSafeZone>\u003CCard>\u003Ch2>Sign In to the Console\u003C\u002Fh2>\n>   \u003CMockBrowserBar url=\"console.tabstack.ai\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 4: Find API Keys section\n> \u003CSafeZone>\u003CCard>\u003Ch2>Navigate to API Keys\u003C\u002Fh2>\n>   \u003CMockSidebar activeItem=\"API Keys\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 5: Create the key\n> \u003CSafeZone>\u003CCard>\u003Ch2>Create a New API Key\u003C\u002Fh2>\n>   \u003CMockButton label=\"+ Create New API Key\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 6: Name the key\n> \u003CSafeZone>\u003CCard>\u003Ch2>Name Your Key\u003C\u002Fh2>\n>   \u003CMockInput value=\"Development\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n>\n> \u002F\u002F Scene 7: Copy the key\n> \u003CSafeZone>\u003CCard>\u003Ch2>Copy Your API Key\u003C\u002Fh2>\n>   \u003CMockKeyDisplay value=\"ts_live_xxxx...\" \u002F>\u003C\u002FCard>\u003C\u002FSafeZone>\n> ```\n>\n> **Count your scenes.** If the documentation has N steps, you need at least N+2 scenes (intro + N steps + summary).\n\n---\n\n#### Reusable Components\n\nDefine these helper components at the top of Generated.tsx. They are used by the scene components below.\n\n**StepIndicator — progress dots (required on every step scene):**\n```typescript\nconst StepIndicator: React.FC\u003C{\n  current: number;\n  total: number;\n  colors: { primary: string; accent: string };\n}> = ({ current, total, colors }) => {\n  const frame = useCurrentFrame();\n  const slideIn = interpolate(frame, [0, 12], [-40, 0], { extrapolateRight: 'clamp' });\n  const opacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });\n\n  return (\n    \u003Cdiv style={{\n      position: 'absolute', top: 32, right: 40,\n      display: 'flex', alignItems: 'center', gap: 12,\n      opacity, transform: `translateY(${slideIn}px)`,\n    }}>\n      {Array.from({ length: total }, (_, i) => (\n        \u003Cdiv key={i} style={{\n          width: i + 1 === current ? 32 : 10, height: 10, borderRadius: 5,\n          background: i + 1 \u003C= current ? colors.primary : `${colors.primary}33`,\n        }} \u002F>\n      ))}\n      \u003Cspan style={{ fontSize: 14, fontWeight: 600, color: colors.primary, opacity: 0.8 }}>\n        {current}\u002F{total}\n      \u003C\u002Fspan>\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**TypingText — typewriter effect (required for all code\u002Fcommands):**\n```typescript\nconst TypingText: React.FC\u003C{\n  text: string;\n  startFrame: number;\n  charsPerFrame?: number;\n}> = ({ text, startFrame, charsPerFrame = 0.5 }) => {\n  const frame = useCurrentFrame();\n  const elapsed = Math.max(0, frame - startFrame);\n  const charsToShow = Math.min(Math.floor(elapsed * charsPerFrame), text.length);\n  const showCursor = charsToShow \u003C text.length;\n\n  return (\n    \u003Cspan style={{ fontFamily: 'monospace' }}>\n      {text.slice(0, charsToShow)}\n      {showCursor && (\n        \u003Cspan style={{ opacity: Math.floor(frame \u002F 15) % 2 === 0 ? 1 : 0 }}>▌\u003C\u002Fspan>\n      )}\n    \u003C\u002Fspan>\n  );\n};\n```\n\n**HighlightedCode — spotlight one line in a multi-line block:**\n```typescript\nconst HighlightedCode: React.FC\u003C{\n  lines: string[];\n  highlightLine: number;\n  startFrame: number;\n  colors: { primary: string };\n}> = ({ lines, highlightLine, startFrame, colors }) => {\n  const frame = useCurrentFrame();\n  const elapsed = Math.max(0, frame - startFrame);\n  const highlightProgress = interpolate(elapsed, [20, 35], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n\n  return (\n    \u003Cdiv style={{ background: '#1a1a2e', borderRadius: 8, padding: 20, fontFamily: 'monospace', fontSize: 16, lineHeight: 1.7 }}>\n      {lines.map((line, i) => {\n        const isHL = i === highlightLine;\n        return (\n          \u003Cdiv key={i} style={{\n            padding: '2px 8px',\n            opacity: isHL ? 1 : 1 - highlightProgress * 0.6,\n            background: isHL ? `${colors.primary}22` : 'transparent',\n            borderLeft: isHL ? `3px solid ${colors.primary}` : '3px solid transparent',\n          }}>\n            \u003Cspan style={{ color: '#666', marginRight: 16 }}>{i + 1}\u003C\u002Fspan>\n            \u003Cspan style={{ color: isHL ? '#fff' : '#aaa' }}>{line}\u003C\u002Fspan>\n          \u003C\u002Fdiv>\n        );\n      })}\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**CalloutCard — for warnings, tips, notes from docs:**\n```typescript\nconst CalloutCard: React.FC\u003C{\n  type: 'warning' | 'tip' | 'note';\n  message: string;\n}> = ({ type, message }) => {\n  const frame = useCurrentFrame();\n  const config = {\n    warning: { bg: '#fef3cd', border: '#f59e0b', icon: '⚠️', label: 'Warning' },\n    tip:     { bg: '#d1fae5', border: '#10b981', icon: '?', label: 'Pro Tip' },\n    note:    { bg: '#dbeafe', border: '#3b82f6', icon: '?', label: 'Note' },\n  }[type];\n\n  const slideX = interpolate(frame, [0, 18], [80, 0], { extrapolateRight: 'clamp' });\n  const opacity = interpolate(frame, [0, 12], [0, 1], { extrapolateRight: 'clamp' });\n\n  return (\n    \u003Cdiv style={{\n      opacity, transform: `translateX(${slideX}px)`,\n      background: config.bg, borderLeft: `4px solid ${config.border}`,\n      borderRadius: 8, padding: '24px 32px', maxWidth: '70%',\n    }}>\n      \u003Cdiv style={{ fontSize: 14, fontWeight: 700, color: config.border, marginBottom: 8 }}>\n        {config.icon} {config.label}\n      \u003C\u002Fdiv>\n      \u003Cdiv style={{ fontSize: 22, color: '#1a1a1a', lineHeight: 1.5 }}>{message}\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**ClickIndicator — cursor + click ripple (use when narration says \"click\", \"select\", \"tap\", \"press\"):**\n```typescript\nconst ClickIndicator: React.FC\u003C{\n  x: number;\n  y: number;\n  startFrame: number;\n  label?: string;\n}> = ({ x, y, startFrame, label }) => {\n  const frame = useCurrentFrame();\n  const rel = frame - startFrame;\n  if (rel \u003C 0 || rel > 36) return null;\n\n  \u002F\u002F Cursor SVG tip offset — the pointer tip in the SVG path is at (5,3) in a 24×24 viewBox,\n  \u002F\u002F rendered at 28×28px. Subtract this offset so the tip lands exactly at (x, y).\n  const tipOffsetX = 6;\n  const tipOffsetY = 4;\n\n  \u002F\u002F Phase 1: cursor slides in from top-left offset, tip lands exactly at (x, y)\n  const cursorX = interpolate(rel, [0, 12], [x - 50 - tipOffsetX, x - tipOffsetX], { extrapolateRight: 'clamp' });\n  const cursorY = interpolate(rel, [0, 12], [y - 40 - tipOffsetY, y - tipOffsetY], { extrapolateRight: 'clamp' });\n  const cursorOpacity = interpolate(rel, [0, 6, 28, 36], [0, 1, 1, 0], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n\n  \u002F\u002F Phase 2: click ripple expands from click point\n  const rippleScale = rel >= 14\n    ? interpolate(rel, [14, 26], [0, 2.5], { extrapolateRight: 'clamp' }) : 0;\n  const rippleOpacity = rel >= 14\n    ? interpolate(rel, [14, 26], [0.5, 0], { extrapolateRight: 'clamp' }) : 0;\n\n  return (\n    \u003Cdiv style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}>\n      {\u002F* Cursor pointer SVG — positioned so the tip of the arrow lands at (x, y) *\u002F}\n      \u003Csvg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\"\n        style={{ position: 'absolute', left: cursorX, top: cursorY, opacity: cursorOpacity,\n          filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }}>\n        \u003Cpath d=\"M5 3l14 8-6.5 1.5L10 19z\" fill=\"white\" stroke=\"black\" strokeWidth=\"1.5\" \u002F>\n      \u003C\u002Fsvg>\n      {\u002F* Click ripple ring — centered exactly at the target point *\u002F}\n      \u003Cdiv style={{ position: 'absolute', left: x - 20, top: y - 20, width: 40, height: 40,\n        borderRadius: '50%', border: '3px solid rgba(255,255,255,0.8)',\n        transform: `scale(${rippleScale})`, opacity: rippleOpacity }} \u002F>\n      {\u002F* Optional label *\u002F}\n      {label && cursorOpacity > 0.5 && (\n        \u003Cdiv style={{ position: 'absolute', left: x + 18, top: y - 10,\n          background: 'rgba(0,0,0,0.75)', color: '#fff', fontSize: 12, fontWeight: 600,\n          padding: '4px 10px', borderRadius: 6, opacity: cursorOpacity, whiteSpace: 'nowrap' }}>\n          {label}\n        \u003C\u002Fdiv>\n      )}\n    \u003C\u002Fdiv>\n  );\n};\n```\n\n**SafeZone — content container (REQUIRED on every scene):**\n\nEvery scene component MUST wrap its content in `\u003CSafeZone>`. This constrains all content to a centered 1200px column with consistent padding. NEVER place content directly in `\u003CAbsoluteFill>` without SafeZone. Pass the scene background via the `background` prop.\n\n```typescript\nconst SafeZone: React.FC\u003C{\n  children: React.ReactNode;\n  background?: string;\n}> = ({ children, background }) => (\n  \u003CAbsoluteFill style={{\n    display: 'flex', alignItems: 'center', justifyContent: 'center',\n    background: background ?? 'transparent',\n  }}>\n    \u003Cdiv style={{\n      width: '100%', maxWidth: 1200,\n      padding: '60px 80px',\n      display: 'flex', flexDirection: 'column',\n      alignItems: 'center', justifyContent: 'center',\n      height: '100%',\n    }}>\n      {children}\n    \u003C\u002Fdiv>\n  \u003C\u002FAbsoluteFill>\n);\n```\n\n---\n\n#### Scene Design Rules\n\n> **⚠️ THE #1 RULE: Every sentence the narrator says must have a matching visual change on screen.**\n> The viewer should NEVER hear the narrator explain something while the screen stays static.\n> If the narration says \"install the package\", the screen must show the install command typing out at that moment.\n> If the narration says \"create a file\", the screen must show that file appearing at that moment.\n\n**Narration-to-visual sync using timecodes:**\n\n`audio.narration.timecodes` gives you `{start, end, text}` for every sentence. **Use it to drive ALL timing:**\n\n1. **Group timecodes by scene** — each narration paragraph = one scene. Gather the timecodes whose `.text` matches that paragraph's sentences.\n2. **Compute scene duration from timecodes:**\n   ```typescript\n   const sceneStart = sceneTimecodes[0].start;\n   const sceneEnd = sceneTimecodes[sceneTimecodes.length - 1].end;\n   const durationInFrames = Math.round((sceneEnd - sceneStart + 0.5) * fps);\n   ```\n3. **Time visual reveals to sentence boundaries:**\n   ```typescript\n   \u002F\u002F Inside a scene, frame 0 = scene start. Convert each sentence to a frame offset:\n   const revealFrame = Math.round((timecode.start - sceneStart) * fps);\n   ```\n4. Each sentence = a visual change. 3 sentences in a scene = 3 visual moments at 3 different `revealFrame` values.\n5. **Account for transitions** — `TransitionSeries.Transition` overlaps adjacent scenes. Use short transitions (8-10 frames) and make sure the first reveal in each scene starts at least 10 frames in (after the transition finishes).\n- **NEVER hardcode** `durationInFrames={5 * fps}` — always derive from timecodes\n- NEVER have a scene where the narrator talks while the screen stays static\n\n> **⚠️ SCENE SPLITTING (see full rules + BAD\u002FGOOD example at top of Step 5):**\n> - ONE step, ONE idea, ONE scene. If a scene has more than ONE command, ONE code block, or ONE concept — SPLIT it.\n> - A scene with a numbered list is ALWAYS wrong — each item becomes its own scene.\n> - A scene with a mock UI dashboard showing multiple actions is ALWAYS wrong — show one action per scene.\n> - If narration for a step is longer than 3 sentences, split it into two scenes.\n> - Maximum per scene: ONE title + ONE code block OR ONE command + ONE supporting visual.\n> - If you have more content, it belongs in the NEXT scene.\n\n**Whitespace, spacing, and safe zones.** Every scene must feel spacious, not cramped:\n- **Every scene MUST use `\u003CSafeZone>`** to contain its content — never place content directly in `\u003CAbsoluteFill>`\n- SafeZone constrains content to a centered 1200px column with 60px vertical \u002F 80px horizontal padding\n- Leave at least 30% of the scene area as empty space\n- Text should never touch the edges of containers\n- No element should extend beyond the SafeZone boundary — no mock UIs pushed to screen edges\n- If content feels tight, you have too much — split into two scenes\n\n**Staged reveals in every scene.** Time each reveal to a sentence's `revealFrame`:\n1. Scene start (frame 0): Background + container fade in\n2. Sentence 1 `revealFrame`: Title\u002Fheading appears\n3. Sentence 2 `revealFrame`: Main content appears (typing effect for code)\n4. Sentence 3 `revealFrame`: Highlight or result appears\n- If only 1-2 sentences, space reveals evenly across the scene duration\n- Fallback if no timecodes: space reveals 10-15 frames apart\n\n**Show the result after commands.** After typing `npm install`, show a result scene with `✓ Successfully installed`. After writing code, show mock output. Use result scenes for install\u002Fbuild\u002Frun commands. Skip them for config-only steps.\n\n**Click indicators for interactive steps.** When narration mentions clicking, selecting, tapping, or pressing a UI element:\n1. Add `\u003CClickIndicator>` inside the scene, OUTSIDE `\u003CSafeZone>` (it uses absolute positioning in the full 1920×1080 frame)\n2. **Calculate `x` and `y` precisely** — see coordinate math below\n3. Set `startFrame` to `getRevealFrame()` of the sentence mentioning the click\n4. Optionally set `label` to the button text (e.g., `label=\"Create API Key\"`)\n5. Auto-hides after 36 frames (~1.2s at 30fps)\n\n**⚠️ ACCURATE CLICK POSITIONING — coordinate calculation (MUST follow this):**\n\nThe video frame is 1920×1080. SafeZone creates a centered column:\n- SafeZone left edge: `(1920 - 1200) \u002F 2 = 360px` from left\n- SafeZone right edge: `360 + 1200 = 1560px` from left\n- SafeZone top padding: `60px`\n- SafeZone bottom: `1080 - 60 = 1020px`\n- Content padding inside SafeZone: `80px` left\u002Fright → content starts at `360 + 80 = 440px`, ends at `1560 - 80 = 1480px`\n- Usable content width: `1480 - 440 = 1040px`\n\n**To find the exact center of a button, work from the inside out:**\n\n1. **Identify the button's container** — is it inside a Card with padding? A flex row? Centered or left-aligned?\n2. **Calculate the button's horizontal center:**\n   - If the button is **centered** in SafeZone (default flex column + alignItems center): `x = 960` (frame center)\n   - If the button is **left-aligned**: `x = 440 + buttonPaddingLeft + (buttonWidth \u002F 2)`\n   - If the button is **right-aligned**: `x = 1480 - buttonPaddingRight - (buttonWidth \u002F 2)`\n   - If inside a **flex row with gap**: calculate each item's position based on flex layout\n   - If the button is inside a Card with its own padding (e.g., `p-8` = 32px): add that padding to the offset\n3. **Calculate the button's vertical center:**\n   - Count the elements above the button in the scene layout (titles, text, spacing, margins)\n   - SafeZone uses `justifyContent: 'center'`, so content is vertically centered in the frame\n   - Estimate total content height, then: `contentTop = (1080 - totalContentHeight) \u002F 2`\n   - Add heights of elements above the button + half the button's own height\n4. **Common button sizes to assume:** standard button ~40px tall, large button ~48px, small ~32px. Standard button width ~120-200px depending on text length.\n\n**Quick reference for common layouts:**\n\n| Layout | Button position | x | y (approx) |\n|--------|----------------|---|-------------|\n| Centered card, button at bottom | Center of card | 960 | 540 + (cardHeight\u002F2) - 20 |\n| Sidebar nav, menu item | Left column | 440 + sidebarWidth\u002F2 | itemTop + 20 |\n| Top-right action button | Right side | 1400-1480 | 120-160 |\n| Form with submit at bottom | Center | 960 | formBottom - 30 |\n| Mock browser, button inside content | Center of mock content area | 960 | mockContentY + buttonOffsetY |\n\n**Example — scene with a Card containing a title + text + centered button:**\n```\nCard padding: 32px (p-8). Card is centered in SafeZone.\nTitle: ~40px tall, marginBottom 16px\nDescription text: ~24px tall, marginBottom 24px\nButton: ~44px tall\n\nTotal content height ≈ 32 + 40 + 16 + 24 + 24 + 44 + 32 = 212px\nCard top = (1080 - 212) \u002F 2 ≈ 434\nButton center Y = 434 + 32 + 40 + 16 + 24 + 24 + 22 = 592\nButton center X = 960 (centered)\n\n→ \u003CClickIndicator x={960} y={592} ... \u002F>\n```\n\n**IMPORTANT: When writing scene JSX, give the target button a known size.** If the button's size is explicit (e.g., `style={{ width: 200, height: 44 }}`), coordinate calculation is much more reliable. Always set explicit `width` and `height` on clickable elements when using ClickIndicator.\n\n**When to use ClickIndicator:** narration contains \"click\", \"select\", \"tap\", \"press\", \"hit\", \"choose\" AND the scene shows a mock UI with a visible button or link.\n**When NOT to use:** terminal\u002Fcode scenes (no buttons), abstract concepts, intro\u002Fsummary scenes.\n\n**Callouts get their own scene.** Warnings, tips, and notes from the docs become separate 3-4s scenes using `CalloutCard`. Don't embed them inside code scenes.\n\n**Layouts.** Use at least 3 different layouts across scenes: centered, split (60\u002F40), stacked, grid, full-bleed.\n\n---\n\n#### Complete Generated.tsx Example\n\nThis shows how ALL the pieces compose together. **Key pattern: timecodes drive all timing.**\n\n```typescript\nimport React from 'react';\nimport {\n  AbsoluteFill, useCurrentFrame, useVideoConfig,\n  staticFile, interpolate, spring,\n} from 'remotion';\nimport { Audio } from '@remotion\u002Fmedia';\nimport { TransitionSeries, linearTiming } from '@remotion\u002Ftransitions';\nimport { fade } from '@remotion\u002Ftransitions\u002Ffade';\nimport { slide } from '@remotion\u002Ftransitions\u002Fslide';\nimport { loadFont } from '@remotion\u002Fgoogle-fonts\u002FInter';\nimport { TutorialVideoProps } from '..\u002FRoot';\n\n\u002F\u002F === Import USER's components (adjust paths per project) ===\nimport { Button } from '..\u002F..\u002F..\u002Fsrc\u002Fcomponents\u002Fui\u002Fbutton';\nimport { Card } from '..\u002F..\u002F..\u002Fsrc\u002Fcomponents\u002Fui\u002Fcard';\nimport { Badge } from '..\u002F..\u002F..\u002Fsrc\u002Fcomponents\u002Fui\u002Fbadge';\n\nconst { fontFamily } = loadFont('normal', { weights: ['400', '700'], subsets: ['latin'] });\n\n\u002F\u002F === Timecode helpers (REQUIRED — copy these into every Generated.tsx) ===\n\ntype Timecode = { start: number; end: number; text: string };\n\n\u002F** Group the flat timecodes array into per-scene groups by matching timecode text\n *  to narration paragraphs. Each timecode's `.text` is checked against each paragraph —\n *  if the paragraph contains that text, the timecode belongs to that scene.\n *  This avoids period-splitting issues (\"Dr.\", \"vs.\") since it uses the actual\n *  timecodes returned by the TTS engine. *\u002F\nfunction groupTimecodesByScene(timecodes: Timecode[], paragraphs: string[]): Timecode[][] {\n  const groups: Timecode[][] = paragraphs.map(() => []);\n  let paraIdx = 0;\n\n  for (const tc of timecodes) {\n    \u002F\u002F Find which paragraph this timecode belongs to by checking if its text\n    \u002F\u002F appears in the paragraph (starting from current paragraph, moving forward)\n    let placed = false;\n    for (let p = paraIdx; p \u003C paragraphs.length; p++) {\n      const normalizedPara = paragraphs[p].toLowerCase().replace(\u002F[^a-z0-9 ]\u002Fg, '');\n      const normalizedTc = tc.text.toLowerCase().replace(\u002F[^a-z0-9 ]\u002Fg, '');\n      if (normalizedPara.includes(normalizedTc) || normalizedTc.includes(normalizedPara.slice(0, 20))) {\n        groups[p].push(tc);\n        paraIdx = p;\n        placed = true;\n        break;\n      }\n    }\n    \u002F\u002F If timecode text didn't match any paragraph, assign to next paragraph that has no timecodes yet\n    if (!placed) {\n      \u002F\u002F Advance to next paragraph if current one already has timecodes and this tc starts later\n      if (paraIdx \u003C paragraphs.length - 1 && groups[paraIdx].length > 0 &&\n          tc.start > groups[paraIdx][groups[paraIdx].length - 1].end + 0.1) {\n        paraIdx++;\n      }\n      if (paraIdx \u003C groups.length) {\n        groups[paraIdx].push(tc);\n      }\n    }\n  }\n  return groups;\n}\n\n\u002F** Compute scene duration in frames from its timecodes *\u002F\nfunction getSceneDuration(group: Timecode[], fps: number): number {\n  if (group.length === 0) return 4 * fps;\n  return Math.round((group[group.length - 1].end - group[0].start + 0.5) * fps);\n}\n\n\u002F** Get the frame offset for a sentence relative to its scene start *\u002F\nfunction getRevealFrame(tc: Timecode, sceneStart: number, fps: number): number {\n  return Math.round((tc.start - sceneStart) * fps);\n}\n\n\u002F\u002F === Reusable helpers (SafeZone, StepIndicator, TypingText, HighlightedCode, CalloutCard) ===\n\u002F\u002F ... paste the helper components from above here ...\n\n\u002F\u002F === Scene Components (accept sceneTimecodes for timing) ===\n\nconst IntroScene: React.FC\u003C{\n  content: any; branding: any; height: number; sceneTimecodes: Timecode[];\n}> = ({ content, branding, height, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  \u002F\u002F Reveal 1: card fades in at scene start\n  const cardScale = spring({ frame, fps, from: 0.8, to: 1, config: { damping: 12 } });\n  const cardOpacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 2: title appears when sentence 1 starts (offset by 12 frames so it doesn't collide with card fade-in)\n  const titleFrame = Math.max(12, sceneTimecodes[0] ? getRevealFrame(sceneTimecodes[0], s0, fps) : 12);\n  const titleOpacity = interpolate(frame, [titleFrame, titleFrame + 13], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 3: badges appear when sentence 2 starts (or after title)\n  const badgeFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 25;\n  const badgeOpacity = interpolate(frame, [badgeFrame, badgeFrame + 13], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003CSafeZone background={`linear-gradient(135deg, ${branding.colors.primary}, ${branding.colors.secondary})`}>\n      \u003Cdiv style={{ transform: `scale(${cardScale})`, opacity: cardOpacity, width: '100%' }}>\n        \u003CCard className=\"p-8\">\n          \u003Cdiv style={{ opacity: titleOpacity }}>\n            \u003Ch1 style={{ fontSize: height * 0.07, fontWeight: 700 }}>{content.title}\u003C\u002Fh1>\n          \u003C\u002Fdiv>\n          \u003Cdiv className=\"flex gap-2 mt-4\" style={{ opacity: badgeOpacity }}>\n            \u003CBadge>Tutorial\u003C\u002FBadge>\n            \u003CBadge variant=\"outline\">{content.technology}\u003C\u002FBadge>\n          \u003C\u002Fdiv>\n        \u003C\u002FCard>\n      \u003C\u002Fdiv>\n    \u003C\u002FSafeZone>\n  );\n};\n\nconst CommandScene: React.FC\u003C{\n  title: string; command: string;\n  stepNumber: number; totalSteps: number;\n  branding: any; sceneTimecodes: Timecode[];\n}> = ({ title, command, stepNumber, totalSteps, branding, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const colors = branding.colors;\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  \u002F\u002F Reveal 1: card at scene start\n  const cardOpacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 2: title synced to sentence 1\n  const titleFrame = sceneTimecodes[0] ? getRevealFrame(sceneTimecodes[0], s0, fps) : 8;\n  const titleOpacity = interpolate(frame, [titleFrame, titleFrame + 12], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  \u002F\u002F Reveal 3: terminal synced to sentence 2 (when narrator says the command)\n  const termFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 22;\n  const termOpacity = interpolate(frame, [termFrame, termFrame + 10], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003C>\n      \u003CSafeZone background={`linear-gradient(135deg, ${colors.primary}11, ${colors.secondary}11)`}>\n        \u003Cdiv style={{ opacity: cardOpacity, width: '100%' }}>\n          \u003CCard className=\"p-8\">\n            \u003Cdiv style={{ opacity: titleOpacity }}>\n              \u003CBadge className=\"mb-4\">Step {stepNumber}\u003C\u002FBadge>\n              \u003Ch2 style={{ fontSize: 32, fontWeight: 700, marginBottom: 16 }}>{title}\u003C\u002Fh2>\n            \u003C\u002Fdiv>\n            \u003Cdiv style={{ opacity: termOpacity, background: '#1a1a2e', borderRadius: 8, padding: 24,\n              fontFamily: 'monospace', fontSize: 20, color: '#00ff88' }}>\n              \u003Cspan style={{ color: '#888' }}>$ \u003C\u002Fspan>\n              \u003CTypingText text={command} startFrame={termFrame + 5} charsPerFrame={0.5} \u002F>\n            \u003C\u002Fdiv>\n          \u003C\u002FCard>\n        \u003C\u002Fdiv>\n      \u003C\u002FSafeZone>\n      \u003CStepIndicator current={stepNumber} total={totalSteps} colors={colors} \u002F>\n    \u003C\u002F>\n  );\n};\n\nconst CodeScene: React.FC\u003C{\n  title: string; lines: string[]; highlightLine: number;\n  stepNumber: number; totalSteps: number;\n  branding: any; sceneTimecodes: Timecode[];\n}> = ({ title, lines, highlightLine, stepNumber, totalSteps, branding, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const colors = branding.colors;\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  const cardOpacity = interpolate(frame, [0, 10], [0, 1], { extrapolateRight: 'clamp' });\n  \u002F\u002F Title synced to sentence 1\n  const titleFrame = sceneTimecodes[0] ? getRevealFrame(sceneTimecodes[0], s0, fps) : 8;\n  const titleOpacity = interpolate(frame, [titleFrame, titleFrame + 12], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  \u002F\u002F Code synced to sentence 2\n  const codeFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 22;\n  const codeOpacity = interpolate(frame, [codeFrame, codeFrame + 13], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003C>\n      \u003CSafeZone background={`linear-gradient(135deg, ${colors.primary}11, ${colors.secondary}11)`}>\n        \u003Cdiv style={{ opacity: cardOpacity, width: '100%' }}>\n          \u003Cdiv style={{ opacity: titleOpacity }}>\n            \u003CBadge className=\"mb-4\">Step {stepNumber}\u003C\u002FBadge>\n            \u003Ch2 style={{ fontSize: 28, fontWeight: 700, marginBottom: 16, color: '#fff' }}>{title}\u003C\u002Fh2>\n          \u003C\u002Fdiv>\n          \u003Cdiv style={{ opacity: codeOpacity }}>\n            \u003CHighlightedCode lines={lines} highlightLine={highlightLine} startFrame={codeFrame + 15} colors={colors} \u002F>\n          \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      \u003C\u002FSafeZone>\n      \u003CStepIndicator current={stepNumber} total={totalSteps} colors={colors} \u002F>\n    \u003C\u002F>\n  );\n};\n\nconst ResultScene: React.FC\u003C{ command: string; output: string; sceneTimecodes: Timecode[] }> = ({ command, output, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n  const outputFrame = sceneTimecodes[1] ? getRevealFrame(sceneTimecodes[1], s0, fps) : 15;\n  const outputOpacity = interpolate(frame, [outputFrame, outputFrame + 15], [0, 1],\n    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    \u003CSafeZone background=\"#0d1117\">\n      \u003Cdiv style={{ width: '100%', background: '#161b22', borderRadius: 8, padding: 24, fontFamily: 'monospace', fontSize: 18 }}>\n        \u003Cdiv style={{ color: '#8b949e' }}>$ {command}\u003C\u002Fdiv>\n        \u003Cdiv style={{ color: '#3fb950', marginTop: 12, opacity: outputOpacity }}>{output}\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n    \u003C\u002FSafeZone>\n  );\n};\n\nconst CalloutScene: React.FC\u003C{ type: 'warning' | 'tip' | 'note'; message: string; colors: any }> = ({ type, message, colors }) => {\n  return (\n    \u003CSafeZone background={`${colors.primary}0a`}>\n      \u003CCalloutCard type={type} message={message} \u002F>\n    \u003C\u002FSafeZone>\n  );\n};\n\nconst SummaryScene: React.FC\u003C{ steps: string[]; branding: any; sceneTimecodes: Timecode[] }> = ({ steps, branding, sceneTimecodes }) => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const s0 = sceneTimecodes[0]?.start ?? 0;\n\n  return (\n    \u003CSafeZone background={`linear-gradient(135deg, ${branding.colors.primary}, ${branding.colors.secondary})`}>\n      \u003CCard className=\"p-8\" style={{ width: '100%' }}>\n        \u003Ch2 style={{ fontSize: 36, fontWeight: 700, marginBottom: 20 }}>What We Covered\u003C\u002Fh2>\n        {steps.map((step, i) => {\n          \u002F\u002F Each recap item synced to a sentence timecode\n          const revealAt = sceneTimecodes[i] ? getRevealFrame(sceneTimecodes[i], s0, fps) : 10 + i * 12;\n          const itemOpacity = interpolate(frame, [revealAt, revealAt + 12], [0, 1],\n            { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n          return (\n            \u003Cdiv key={i} style={{ opacity: itemOpacity, display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>\n              \u003Cspan style={{ color: '#3fb950', fontSize: 20 }}>✓\u003C\u002Fspan>\n              \u003Cspan style={{ fontSize: 20 }}>{step}\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          );\n        })}\n      \u003C\u002FCard>\n    \u003C\u002FSafeZone>\n  );\n};\n\n\u002F\u002F === Main Composition ===\n\nexport const Generated: React.FC\u003CTutorialVideoProps> = ({ content, branding, audio, duration }) => {\n  const { fps, height } = useVideoConfig();\n  const totalSteps = 3;\n\n  \u002F\u002F --- Timecode-driven timing ---\n  const timecodes: Timecode[] = audio.narration?.timecodes ?? [];\n  const narrationParagraphs = [\n    \"Let's set up Stripe payments in your app.\",\n    \"Step 1. Run the install command to grab the Stripe SDK.\",\n    \"And just like that, the package is ready to go.\",\n    \"Step 2. Create a checkout session. The key part is the line_items array — that defines what the customer is buying.\",\n    \"One important thing — never expose your secret key in client-side code.\",\n    \"Step 3. Drop your API key into the environment file so Stripe can authenticate your requests.\",\n    \"That's it! You've installed Stripe, created a checkout, and secured your key.\",\n  ];\n  const scenes = groupTimecodesByScene(timecodes, narrationParagraphs);\n\n  return (\n    \u003CAbsoluteFill style={{ background: '#000', fontFamily }}>\n      {audio.music?.staticPath && (\n        \u003CAudio\n          src={staticFile(audio.music.staticPath)}\n          volume={(f) => {\n            const duckFrame = getSceneDuration(scenes[0], fps);\n            return interpolate(f, [duckFrame, duckFrame + 15], [0.25, 0.12], {\n              extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n            });\n          }}\n        \u002F>\n      )}\n      {audio.narration?.staticPath && \u003CAudio src={staticFile(audio.narration.staticPath)} volume={1} \u002F>}\n\n      \u003CTransitionSeries>\n        {\u002F* Scene 1: Intro — duration from timecodes *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[0], fps)}>\n          \u003CIntroScene content={content} branding={branding} height={height} sceneTimecodes={scenes[0]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 2: Install command — reveals synced to sentences *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[1], fps)}>\n          \u003CCommandScene title=\"Install the SDK\" command=\"npm install @stripe\u002Fstripe-js\"\n            stepNumber={1} totalSteps={totalSteps} branding={branding} sceneTimecodes={scenes[1]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 8 })} presentation={slide()} \u002F>\n\n        {\u002F* Scene 3: Install result — output synced to sentence *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[2], fps)}>\n          \u003CResultScene command=\"npm install @stripe\u002Fstripe-js\" output=\"✓ Added 3 packages in 2.1s\"\n            sceneTimecodes={scenes[2]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 4: Code walkthrough — highlight synced to sentence 3 *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[3], fps)}>\n          \u003CCodeScene title=\"Create the checkout session\"\n            lines={[\n              \"import Stripe from 'stripe';\",\n              \"const stripe = new Stripe(process.env.STRIPE_KEY);\",\n              \"const session = await stripe.checkout.sessions.create({\",\n              \"  line_items: [{ price: 'price_xxx', quantity: 1 }],\",\n              \"  mode: 'payment',\",\n              \"  success_url: 'https:\u002F\u002Fexample.com\u002Fsuccess',\",\n              \"});\",\n            ]}\n            highlightLine={3}\n            stepNumber={2} totalSteps={totalSteps} branding={branding} sceneTimecodes={scenes[3]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 5: Warning callout *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[4], fps)}>\n          \u003CCalloutScene type=\"warning\" message=\"Never expose your secret key in client-side code.\"\n            colors={branding.colors} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 6: Config step *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[5], fps)}>\n          \u003CCommandScene title=\"Add your API key\" command=\"echo STRIPE_KEY=sk_test_... >> .env\"\n            stepNumber={3} totalSteps={totalSteps} branding={branding} sceneTimecodes={scenes[5]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n        \u003CTransitionSeries.Transition timing={linearTiming({ durationInFrames: 10 })} presentation={fade()} \u002F>\n\n        {\u002F* Scene 7: Summary — each recap item synced to a sentence *\u002F}\n        \u003CTransitionSeries.Sequence durationInFrames={getSceneDuration(scenes[6], fps)}>\n          \u003CSummaryScene steps={['Installed the Stripe SDK', 'Created a checkout session', 'Configured the API key']}\n            branding={branding} sceneTimecodes={scenes[6]} \u002F>\n        \u003C\u002FTransitionSeries.Sequence>\n      \u003C\u002FTransitionSeries>\n    \u003C\u002FAbsoluteFill>\n  );\n};\n```\n\n**Adapt this for each video:**\n- Write `narrationParagraphs` to match your exact narration script from Step 4\n- **Always use `getSceneDuration(scenes[i], fps)`** for `durationInFrames` — never hardcode `N * fps`\n- **Always pass `sceneTimecodes={scenes[i]}` to scene components** — use `getRevealFrame()` for visual timing\n- Add or remove scenes to match the number of narration paragraphs (one paragraph = one scene)\n- Import different user components as needed\n- **Click indicators:** When a scene narration says \"click X\", add `\u003CClickIndicator x={buttonCenterX} y={buttonCenterY} startFrame={getRevealFrame(sceneTimecodes[0], sceneStart, fps) + 15} label=\"X\" \u002F>` as a sibling to `\u003CSafeZone>`, not inside it. **Calculate `x` and `y` precisely** using the coordinate math in the \"ACCURATE CLICK POSITIONING\" section — do NOT guess approximate values. Give the target button explicit `width` and `height` styles so you can calculate its center accurately.\n\n**Remotion rules:**\n- Audio: `import { Audio } from '@remotion\u002Fmedia'` — NOT from `remotion`\n- Audio src: `staticFile(audio.music.staticPath)` — NEVER raw paths\n- Conditional audio: `{audio.music?.staticPath && \u003CAudio ... \u002F>}`\n- Animations: ONLY `useCurrentFrame()` + `spring()` \u002F `interpolate()` — NO CSS animations\n- Images: `\u003CImg>` from `remotion` — NOT `\u003Cimg>`\n- Fonts: Load via `@remotion\u002Fgoogle-fonts` before use\n- Duration: always from `getSceneDuration(timecodes, fps)` — NEVER hardcode `N * fps`\n- Clamp: Always use `extrapolateRight: 'clamp'` on interpolate\n- SafeZone: every scene wraps content in `\u003CSafeZone>` — NEVER use bare `\u003CAbsoluteFill>` with ad-hoc padding\u002Fwidth\n- Music volume: use `volume={(f) => ...}` callback for ducking — 0.25 during intro, fades to 0.12 when steps begin. Use `getSceneDuration(scenes[0], fps)` as the duck point.\n\n---\n\n### Step 6: Validate & Render\n\n**Validation checklist (must pass all):**\n\n**Remotion basics:**\n1. Audio imported from `@remotion\u002Fmedia` (not `remotion`)\n2. Audio uses `staticFile()` with `staticPath`\n3. Conditional audio rendering\n4. Font loaded via `@remotion\u002Fgoogle-fonts`\n5. No CSS animations or Tailwind `animate-*` classes\n6. All animations use `useCurrentFrame()` + `spring`\u002F`interpolate`\n7. User's components are imported with correct relative paths\n8. User's components render without errors in Remotion context\n9. **Scene durations use `getSceneDuration()` from timecodes** — no hardcoded `N * fps`\n10. **Scene components receive `sceneTimecodes` prop** — reveals use `getRevealFrame()` not fixed frame offsets\n11. **Every scene uses `\u003CSafeZone>`** — no content placed directly in `\u003CAbsoluteFill>` without the SafeZone wrapper\n12. **Music uses volume ducking** — `volume={(f) => ...}` callback, NOT a flat number. 0.25 during intro, ducks to 0.12 when narration steps begin.\n\n**Scene structure:**\n13. Has intro scene and summary scene\n14. **Each scene explains exactly ONE step** — no scene combines two steps. If you see a numbered list (`1. ... 2. ... 3. ...`) in any scene's JSX, the video is BROKEN — split immediately\n15. **No numbered or bulleted lists in scenes** — if a scene renders `\u003Col>`, `\u003Cul>`, or manual numbering, it MUST be split into separate scenes. This is the #1 most common mistake.\n16. **Every scene has at least 30% empty space** — if it looks crowded, remove content or split\n17. **SafeZone constrains all content** — no element extends beyond the 1200px centered column. No mock UIs pushed to screen edges. No content touching the 1920px frame boundary.\n18. **Every narration paragraph has a matching visual scene** — no explanation without a visual\n19. **Every sentence in the narration has a matching visual change** — the screen NEVER stays static while the narrator keeps talking\n20. **No scene has more than 3 sentences of narration** — if it does, split into two scenes\n21. Narration content matches visual progression 1:1\n\n**Tutorial quality:**\n22. **All code\u002Fcommands use TypingText** — characters appear one by one with blinking cursor\n23. **Multi-line code uses HighlightedCode** — important line spotlighted, rest dimmed\n24. **Step scenes have StepIndicator** — progress dots showing \"Step N of M\" (skip on intro\u002Fsummary)\n25. **Elements appear in stages synced to timecodes** — each reveal at a `getRevealFrame()` value, never all at once\n26. **Command scenes are followed by ResultScene** — show `✓ Success` output after running a command\n27. **Warnings\u002Ftips from docs are CalloutScene** — distinct styled cards, not embedded in code scenes\n28. **Pacing driven by timecodes** — scene durations match narration length, not arbitrary guesses\n29. **Click indicators on interactive steps** — when narration says \"click\u002Fselect\u002Ftap\u002Fpress\", a `\u003CClickIndicator>` appears over the target button, synced to the sentence's `getRevealFrame()`\n\n**Call `render_video`** with:\n- `inputProps` — full props (content, branding, audio, metadata, duration)\n- `outputFileName` — descriptive name like `react-hooks-tutorial`\n- `remotionProjectPath` — absolute path to the `remotion\u002F` directory in the user's project\n\nDuration is automatically calculated from narration length via `calculateMetadata` (narration drives video length, since it IS the content).\n\n---\n\n## Component Compatibility Notes\n\nNot all React components work in Remotion's rendering context. Watch for:\n\n- **Event handlers** (onClick, onHover) — harmless, just won't fire in video\n- **CSS animations** — FORBIDDEN. If a user component uses `@keyframes` or `transition`, you must override those styles inline: `style={{ animation: 'none', transition: 'none' }}`\n- **Portals** — won't work in Remotion. Skip components that use `createPortal` (modals, tooltips)\n- **Client-side state** — `useState`\u002F`useEffect` for animation won't work. Replace with Remotion's `useCurrentFrame()`\n- **Dynamic imports** — avoid. Use static imports only\n- **Browser APIs** — `window`, `document` queries won't work during render\n\n**When a user's component won't work in Remotion**, build a visual replica inline using:\n- The same CSS classes (if Tailwind)\n- The same color tokens\n- The same border-radius, shadow, and spacing values\n- Read their component source to match the visual output\n",{"name":5,"description":7},"main","91a9d45d312739c7b67e12215f2af6c3e2d5b71c","skill","https:\u002F\u002Fgithub.com\u002FBhonar\u002Fdocs-to-video","https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002Fdocs-to-video","https:\u002F\u002Fgithub.com\u002FBhonar","2026-02-20 04:16:31",{"source":8,"skillName":5,"commands":50,"prompt":60,"zipAvailable":61},[51,54,57],{"tool":52,"command":53},"npx","npx skills add Bhonar\u002Fdocs-to-video --skill docs-to-video",{"tool":55,"command":56},"bunx","bunx skills add Bhonar\u002Fdocs-to-video --skill docs-to-video",{"tool":58,"command":59},"pnpm","pnpm dlx skills add Bhonar\u002Fdocs-to-video --skill docs-to-video","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FBhonar\u002Fdocs-to-video\u002Fblob\u002Fmain\u002Fskill\u002FSKILL.md",true,[],[64],{"skillNo":65,"name":66,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":67,"categories":68,"keywords":69,"publishedAt":37},"01M6CP2EJP1G4JWSBCYR1K0GKP","docs-to-tutorial","https:\u002F\u002Fgithub.com\u002FBhonar\u002Fdocs-to-video\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fdocs-to-tutorial\u002FSKILL.md",[],[27,36,70],"docs to tutorial"]