
Content Creator
- 6 installs
- 42 repo stars
- Updated April 21, 2026
- video-db/agentic-streams
Helps with marketing & seo tasks.
About
content-creator is a Claude Code skill for marketing & seo. It helps solo builders move faster with AI-assisted development.
- content-creator
- Marketing & SEO
- AI-coding skill
Content Creator by the numbers
- 6 all-time installs (skills.sh)
- Ranked #1,557 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/video-db/agentic-streams --skill content-creatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 42 |
| Last updated | April 21, 2026 |
| Repository | video-db/agentic-streams ↗ |
What it does
Helps with marketing & seo tasks.
Files
Content Creator
Configuration
MAX_ITERATIONS: 5
DEFAULT_RESOLUTION: 1280x720
DEFAULT_FORMAT: landscape
ASSET_COLLECTION_NAME: content_creator_assetsFormat Selection
Each video format is defined in its own recipe file under reference/formats/. Read the selected format file before Phase 2 — it defines persona, pacing, narrative arc, sourcing strategy, and quality criteria.
| User asks for... | Format file |
|---|---|
| "briefing", "summary", "quick take", "60 seconds on" | formats/briefing.md |
| "explainer", "breakdown", "deep dive", "Fireship-style", "tech video" | formats/fireship-explainer.md |
If ambiguous, default to briefing for short requests and fireship-explainer for anything that implies depth or multiple topics.
Production Method Diversity (CRITICAL)
No single production method may dominate. AI image generation (generate_image) is the easiest tool to reach for, but using it for every segment produces a narrated slideshow.
Hard Rules (all formats)
1. No single production method may be used for more than 50% of segments. 2. At least 3 different production methods per video (from: real-sourced assets, Remotion, Playwright, AI video, AI image, TextAsset). 3. `code_editor` MUST NOT use `generate_image`. AI hallucinates code. Use Remotion or Playwright (local HTML+highlight.js). 4. `diagram_animation` MUST NOT use `generate_image`. No animation. Use Remotion SVG. 5. `split_screen` MUST NOT use `generate_image`. Layout unreliable. Use Remotion. 6. `kinetic_text` MUST use VideoDB `TextAsset`. Don't generate an image of text. 7. Code screenshots MUST use local HTML+highlight.js (see browser-recording.md capture_code_display). Never ray.so/carbon.sh URL encoding.
Recommended Production Method Per Visual Type
| Visual Type | Primary Method | Fallback | NEVER use |
|---|---|---|---|
code_editor | Remotion | Playwright (local HTML+highlight.js) | generate_image, ray.so URL encoding |
diagram_animation | Remotion SVG | generate_image (static only, rename to image_scene) | — |
logo_composition | Real logos (Simple Icons CDN) | generate_image | — |
split_screen | Remotion | Playwright (side-by-side screenshots) | generate_image |
data_viz | Remotion | generate_image (static chart fallback) | — |
motion_scene | Stock footage (Pexels) | generate_video | — |
browser_capture | Playwright recording | Playwright screenshot | generate_image |
kinetic_text | VideoDB TextAsset | Remotion text component | generate_image |
meme_insert | Real meme template (Imgflip/GIPHY) | generate_image | — |
image_scene | Stock photo (Pexels) | generate_image | — |
stock_footage | Pexels/Pixabay API | generate_video | — |
The self-review phase will automatically fail any video where >50% of segments use a single production method.
Asset Sourcing Hierarchy
Before producing any visual, follow this priority. See recipes/sourcing/asset-library.md for details.
1. SEARCH the asset library collection (instant, free, pre-indexed)
↓ not found
2. SOURCE from external APIs (Pexels, Imgflip, GIPHY, Simple Icons, etc.)
↓ not available
3. GENERATE with AI (generate_image, generate_video, TextAsset)
↓ after sourcing/generating
4. UPLOAD to asset library for future reuseEach sourcing channel has its own recipe:
| Sourcing Recipe | File | Used for |
|---|---|---|
| Asset Library | sourcing/asset-library.md | Cache search (always first) |
| Stock Footage | sourcing/stock-footage.md | stock_footage, motion_scene, image_scene |
| Meme Sourcing | sourcing/meme-sourcing.md | meme_insert |
| Logo Sourcing | sourcing/logo-sourcing.md | logo_composition, logo overlays |
| GIF Reactions | sourcing/gif-reactions.md | PiP overlays, reaction clips |
Parallel Execution
This skill supports parallel runs. Every run must generate a unique slug and namespace all local temp files under it.
Slug Generation
import uuid
SLUG = uuid.uuid4().hex[:8]
WORK_DIR = f"/tmp/vb_{SLUG}"What the slug isolates
| Resource | Path pattern |
|---|---|
| Browser capture videos | {WORK_DIR}/captures/ |
| Screenshots | {WORK_DIR}/screenshots/ |
| Downloaded review frames | {WORK_DIR}/frames/ |
| Any temp scripts | {WORK_DIR}/scripts/ |
| Remotion projects | {WORK_DIR}/remotion/ |
Rules
1. Never use bare `/tmp/` paths. Always use {WORK_DIR}/.... 2. Create `WORK_DIR` early. Run Path(WORK_DIR).mkdir(parents=True, exist_ok=True) in Phase 0. 3. Pass `WORK_DIR` to all functions. 4. Clean up on completion. shutil.rmtree(WORK_DIR, ignore_errors=True). 5. Log the slug. print(f"Video run: {SLUG}").
Dependencies
Required: VideoDB — handles all media generation (images, video, voice, music, sound effects), timeline composition, indexing, search, and streaming. Install: pip install videodb python-dotenv. Get a free API key at console.videodb.io.
Optional tools (use if available, fall back gracefully if not):
- Remotion — code-rendered animations: charts, code editors, diagrams, logos, split-screen. Fallback: Playwright or AI generation.
- Playwright — browser recording and screenshots. Navigate to real websites, capture code displays. Install:
pip install playwright && playwright install chromium. See browser-recording.md.
Optional API keys (set in environment for richer sourcing):
PEXELS_API_KEY— stock footage and photos from PexelsGIPHY_API_KEY— reaction GIFs and animated memes from GIPHYPIXABAY_API_KEY— stock footage fallback from PixabayIMGFLIP_USERNAME/IMGFLIP_PASSWORD— captioned meme generation via Imgflip
If any key is missing, that sourcing channel is skipped gracefully. The video will still work — just with fewer real-world assets.
Visual Types
The skill supports 11 visual types. Pick based on what communicates best:
| Visual Type | Use For | Primary Source |
|---|---|---|
data_viz | Animated charts, bar graphs, counters | Remotion |
motion_scene | Atmospheric b-roll, hooks, transitions | Stock video (Pexels) / AI video |
browser_capture | Citing sources, showing evidence | Playwright |
kinetic_text | Key quotes, stat highlights, punchy declarations | VideoDB TextAsset |
image_scene | Abstract concepts, mood backgrounds | Stock photo (Pexels) / AI image |
code_editor | Code with syntax highlighting | Remotion / Playwright HTML |
meme_insert | Humor beats, joke images/clips | Real memes (Imgflip/GIPHY) |
diagram_animation | Architecture diagrams, data flows | Remotion SVG |
logo_composition | Tech logo grids, comparisons | Real logos (Simple Icons) |
split_screen | Side-by-side comparisons | Remotion |
stock_footage | Real-world b-roll clips | Pexels/Pixabay API |
Pipeline
0. Pre-flight → verify tools, bootstrap asset collection, read format file
1. Research → parallel subagents investigate the topic
2. Script → structure findings into a video script (format-driven)
3. Asset Production → source/create visuals (search → source → generate)
4. Composition → arrange on timeline, generate stream
4.5 Captions → (optional, user-requested) index spoken words, add auto-synced animated captions
5. Self-Review → index draft, critique, identify fixes
6. Iterate/Deliver → fix and recompose, or deliver final URLPhase 0: Pre-flight Checks
1. Generate slug and create WORK_DIR (with subdirs: captures, screenshots, frames, scripts, remotion, sourced, review_frames). 2. Connect to VideoDB. Find or create the content_creator_assets collection (see sourcing/asset-library.md). 3. Check and install Playwright if missing (pip install playwright && playwright install chromium). 4. Set up Remotion (MANDATORY ATTEMPT). Follow recipes/remotion-setup.md for full bootstrap instructions, component library, and test render. Set REMOTION_AVAILABLE = True/False based on result. 5. Check which API keys are available (PEXELS_API_KEY, GIPHY_API_KEY, IMGFLIP_USERNAME, IMGFLIP_PASSWORD, PIXABAY_API_KEY). 6. Read the selected format file (reference/formats/{format}.md). 7. Print capability summary including REMOTION_AVAILABLE, PLAYWRIGHT_AVAILABLE, and which API keys are set.
Remotion Bootstrap (Phase 0, Step 4)
Remotion enables animated code, diagrams, transitions, split-screens, data viz, and kinetic text — visual types that are static/broken without it. You MUST attempt this setup. Only skip if it fails after the retry.
Full setup instructions, component library (CodeEditor, KineticText, DiagramFlow, BarChart, SplitCompare, WhipTransition, CounterReveal), and rendering commands are in recipes/remotion-setup.md.
Quick summary: 1. Check node --version && npm --version 2. Scaffold project: npm init -y && npm install remotion @remotion/cli react react-dom 3. Write Root.tsx and component files from the recipe 4. Test render: npx remotion still src/Root.tsx CodeEditor test.png --props='{"code":"const x=1;","language":"javascript"}' --frame=0 5. If test render succeeds → REMOTION_AVAILABLE = True
REMOTION_AVAILABLE = False # set to True after successful test render
REMOTION_DIR = f"{WORK_DIR}/remotion"If `REMOTION_AVAILABLE = False`: visual types diagram_animation, split_screen, and data_viz MUST be downgraded to Playwright-rendered alternatives or replaced with a different visual type in the script. Do NOT silently fall back to generate_image for these types.
Phase 1: Research
Launch 3-5 parallel subagents to investigate different angles of the topic.
See reference/research.md.
Phase 2: Script
Convert research into a video script. Follow the narrative arc and pacing defined in the format file.
See reference/scriptwriting.md for the JSON schema and visual type guidance.
Phase 3: Asset Production
For each segment, follow the sourcing hierarchy: search asset library, source from external APIs, then generate.
See reference/asset-production.md for the visual type → recipe router. Each visual type has a dedicated recipe file. Each sourcing channel has a dedicated recipe under recipes/sourcing/.
Also generate per-segment narration audio and one background music track using the videodb skill.
Phase 4: Composition
Arrange all assets on a multi-track timeline. Follow the pacing and transition rules defined in the format file.
See reference/composition.md.
Present the stream URL: https://console.videodb.io/player?url={STREAM_URL}
Phase 4.5: Add Captions (Optional)
If the user explicitly requests captions, add speech-synced animated captions as a post-processing pass after the main stream is composed. Upload the stream, index spoken words, and recompose with a CaptionAsset(src="auto") overlay.
See reference/recipes/captions.md for the full caption config, animation styles, and color presets. Only run this phase if the user asks for captions.
Phase 5: Self-Review (mandatory)
Always runs at least once. Upload the draft, index it, compare intended vs actual. Critique as a director.
See reference/self-review.md.
Phase 6: Iterate or Deliver
If issues remain and under MAX_ITERATIONS, fix and recompose. Otherwise deliver the final stream URL and player URL with a segment breakdown.
Quick Reference
| Phase | Key Reference |
|---|---|
| Format Selection | formats/briefing.md or formats/fireship-explainer.md |
| Research | research.md |
| Script | scriptwriting.md |
| Asset Production | asset-production.md |
| Composition | composition.md |
| Self-Review | self-review.md |
| Media APIs | videodb skill |
Visual Recipe Files
| Recipe | Path |
|---|---|
| Remotion Setup | recipes/remotion-setup.md |
| Code Slide | recipes/code-slide.md |
| Meme & Humor | recipes/meme-humor.md |
| Kinetic Text | recipes/kinetic-text.md |
| Diagram Flow | recipes/diagram-flow.md |
| Data Viz | recipes/data-viz.md |
| Split Compare | recipes/split-compare.md |
| Overlay Techniques | recipes/overlay-techniques.md |
| Captions | recipes/captions.md |
Sourcing Recipe Files
| Recipe | Path |
|---|---|
| Asset Library | sourcing/asset-library.md |
| Stock Footage | sourcing/stock-footage.md |
| Meme Sourcing | sourcing/meme-sourcing.md |
| Logo Sourcing | sourcing/logo-sourcing.md |
| GIF Reactions | sourcing/gif-reactions.md |
Content Creator — Self-Reviewing Video Agent
Autonomous video briefing agent that reviews its own output using VideoDB's See + Understand capabilities — research any topic, produce a broadcast-quality video, then verify visual-narration alignment frame by frame before delivering.

---
What Makes This Different
Most video agents produce a video and hand it over. This one watches its own rough cut.
After composing the first draft, the agent uploads the rendered stream back into VideoDB and uses two core capabilities to inspect it:
See — Extract scenes at 1-second intervals with frame images. The agent downloads and looks at actual frames from the rendered video, checking whether each second shows the intended visual (a chart, a browser capture, kinetic text, an AI-generated clip).
Understand — Index scenes with AI-powered descriptions and index spoken words for a full transcript. The agent reads back what the AI sees at every timestamp and what was actually spoken, then compares both against the original script.
Act — Build a mismatch report, fix broken assets, swap visual types, adjust timing, recompose the timeline, and generate a new stream. Repeat up to MAX_ITERATIONS times until the output passes review.
Research → Script → Produce Assets → Compose Timeline → See the Draft → Understand What's There → Fix & Recompose
↑_______________________________________________|
self-review loopThis is the same See + Understand pipeline you'd use for video search, monitoring, or content moderation — applied to quality assurance on the agent's own output.
---
The Self-Review Loop
The agent treats its own video exactly like any other video in VideoDB:
1. Upload the draft stream
review_video = coll.upload(url=stream_url)2. Index spoken words (what was said)
review_video.index_spoken_words()
transcript = review_video.get_transcript()3. Extract scenes with frames (what's on screen)
scene_collection = review_video.extract_scenes(
extraction_type=SceneExtractionType.time_based,
extraction_config={"time": 1, "select_frames": ["first", "last"]},
)4. Index scenes with AI descriptions
scene_index_id = review_video.index_scenes(
extraction_type=SceneExtractionType.time_based,
extraction_config={"time": 1, "select_frames": ["first", "last"]},
prompt="Describe the visual content: type, what it shows, visible text, motion vs static.",
)
scene_records = review_video.get_scene_index(scene_index_id)5. Compare intended vs actual
The agent builds a timeline of what should be at each second (from the script and composition data) and aligns it against what actually appears (from scene descriptions and frame images). Mismatches get flagged:
| Intended | Actual | Verdict |
|---|---|---|
browser_capture — Reddit top posts | Error/block page | Mismatch — regenerate as AI image |
kinetic_text — "73% stat" | Large bold "73%" on screen | Match |
motion_scene — drone shot | Static photo, no motion | Mismatch — swap visual type |
6. Fix and recompose
Regenerate only what's broken. Recompose the timeline. Generate a new stream. Review again.
---
How It Works (Full Pipeline)
Phase 0: Pre-flight → Verify tools (Playwright, VideoDB SDK)
Phase 1: Research → Parallel subagents investigate different angles
Phase 2: Script → Narrative arc: hook → context → insights → takeaway → close
Phase 3: Asset Production → AI video, AI images, browser captures, kinetic text, voiceover, music
Phase 4: Composition → Multi-track timeline with narration, visuals, callouts, music
Phase 5: Self-Review → Upload draft → See (frames) → Understand (descriptions + transcript) → Critique
Phase 6: Iterate → Fix issues → Recompose → Review again → DeliverVisual variety across segments
The agent mixes 5 production approaches and never repeats the same type consecutively:
| Approach | Use Case |
|---|---|
| AI-Generated Video | Hooks, transitions, atmospheric b-roll |
| AI-Generated Images | Abstract concepts, mood backgrounds |
| Browser Capture | Citing sources, showing real web content |
| Kinetic Text | Key stats, quotes, punchy declarations |
| Data Viz (Remotion) | Animated charts, counters, comparisons |
---
Quick Start
Prerequisites
1. VideoDB API Key (get free at console.videodb.io):
export VIDEO_DB_API_KEY=your_key_here2. VideoDB SDK:
pip install videodb python-dotenv3. Playwright (optional, for browser captures):
pip install playwright && playwright install chromiumUsage
Give the agent a topic:
Create a video briefing about "r/ClaudeAI top posts this week"Make a video report on "the state of AI coding assistants in 2026"The agent autonomously researches, scripts, produces, composes, reviews, and delivers a playable stream URL.
---
File Structure
content-creator/
├── README.md # This file
├── SKILL.md # Main skill definition (agents read this)
└── reference/
├── research.md # How to decompose topics and run parallel research
├── scriptwriting.md # Narrative arc, visual types, script schema
├── asset-production.md # 5 production approaches and when to use each
├── browser-recording.md # Playwright patterns for web capture
├── composition.md # Timeline architecture, pacing, audio mixing
└── self-review.md # The See + Understand review loop---
Example Output
r/ClaudeAI Weekly Recap — Top posts, memes, drama, and builds from the Claude subreddit: ▶ Watch Video
00:00 - 00:16 Hook (motion_scene) — "11,400 upvotes for teaching Claude to talk like a caveman"
00:16 - 00:29 Context (image) — Reddit-style feed showing top posts
00:29 - 00:49 The Memes (kinetic_text) — "14 limit tracker posts today, 6 in the last hour"
00:49 - 01:12 The Drama (image) — Claude Code 512K line source leak
01:12 - 01:40 The Builds (image) — Windows 98 rebuild, job search system, wholesome friendship post
01:40 - 01:55 Takeaway (kinetic_text) — "Roast it at noon. Build with it by five. Love letter by midnight."
01:55 - 01:59 Outro---
Why VideoDB
This agent demonstrates VideoDB as infrastructure for the full video lifecycle — not just assembly, but understanding:
| Capability | How It's Used |
|---|---|
| Generate (images, video, voice, music) | Produce all visual and audio assets from text prompts |
| Compose (multi-track timeline) | Arrange visuals, narration, callouts, and music on layered tracks |
| See (scene extraction + frames) | Extract frame images from the rendered draft at every second |
| Understand (scene indexing + spoken word indexing) | Get AI descriptions of what's on screen and transcribe what was said |
| Stream (instant playback) | Deliver a playable HLS URL immediately after composition |
The self-review loop closes the gap between "I told the system to put a chart at t=30" and "there's actually a chart at t=30." That gap is where most automated video pipelines silently fail.
---
Community & Support
- Docs: docs.videodb.io
- Issues: GitHub Issues
- Discord: Join community
- Console: Get API key
---
<p align="center">Made with ❤️ by the <a href="https://videodb.io">VideoDB</a> team</p>
Asset Production
For each segment in the script, create a visual asset using the best approach for its visual_type. You are the host — you're producing footage for your show, not generating pretty pictures.
Universal Sourcing Hierarchy
Before producing ANY visual, follow this order:
1. Search asset library collection (recipes/sourcing/asset-library.md)
↓ not found (score < 0.5)
2. Source from external API (appropriate sourcing recipe below)
↓ API unavailable or no results
3. Generate with AI (generate_image, generate_video, TextAsset)
↓ after sourcing/generating
4. Upload to asset library for future reuseSourcing Recipe Router
Determines which sourcing recipe to read for each visual type:
| Visual Type | Sourcing Recipe | Then Visual Recipe |
|---|---|---|
stock_footage | stock-footage.md | (asset IS the visual) |
motion_scene | stock-footage.md (video) | Fallback: generate_video |
image_scene | stock-footage.md (photo) | Fallback: generate_image |
meme_insert | meme-sourcing.md | meme-humor.md |
logo_composition | logo-sourcing.md | (compose on timeline with scale/position) |
code_editor | (none — always rendered) | code-slide.md |
diagram_animation | (none — always rendered) | diagram-flow.md |
browser_capture | (none — always live) | browser-recording.md |
kinetic_text | (none — TextAsset) | kinetic-text.md |
split_screen | (none — always rendered) | split-compare.md |
data_viz | (none — always rendered) | data-viz.md |
For overlays, read:
- gif-reactions.md — PiP reactions and animated clips
- logo-sourcing.md — logo badges in corners
- stock-footage.md — ambient loop backgrounds
Visual Type → Recipe Files
Each visual type has a dedicated recipe file with detailed production instructions, style guides, code examples, and fallback chains. Read the recipe before producing each visual type. For Remotion-rendered visual types, also read remotion-setup.md for component definitions and rendering commands.
| Visual Type | Recipe File | Primary Method |
|---|---|---|
code_editor | code-slide.md | Remotion → Playwright HTML |
meme_insert | meme-humor.md | Real memes (Imgflip/GIPHY) → generate_image |
kinetic_text | kinetic-text.md | VideoDB TextAsset |
diagram_animation | diagram-flow.md | Remotion SVG → Mermaid.js |
data_viz | data-viz.md | Remotion → Chart.js |
split_screen | split-compare.md | Remotion → Playwright dual HTML |
overlay (PiP) | overlay-techniques.md | Real GIFs (GIPHY) / real logos (Simple Icons) |
motion_scene | (below) | Stock video (Pexels) → generate_video |
browser_capture | browser-recording.md | Playwright |
image_scene | (below) | Stock photo (Pexels) → generate_image |
logo_composition | (below) | Real logos (Simple Icons CDN) |
stock_footage | (below) | Pexels/Pixabay API |
Banned Production Approaches
These combinations produce broken results. The self-review will reject them.
| Visual Type | BANNED Method | Why |
|---|---|---|
code_editor | generate_image | AI hallucinates code — garbled syntax, made-up variables |
code_editor | generate_video | Same problem, animated garbage |
code_editor | ray.so/carbon.sh via URL encoding | urllib.parse.quote() corrupts multi-line code |
diagram_animation | generate_image (for animation) | No animation possible |
split_screen | generate_image | Layout unreliable — panels misaligned |
kinetic_text | generate_image | Use TextAsset — faster, pixel-perfect |
browser_capture | generate_image | The whole point is showing REAL content |
logo_composition | generate_image | Logos will be wrong. Use real logos from CDN |
Simple Visual Types (No Dedicated Recipe)
Motion Scene
Atmospheric b-roll, hooks, transitions.
Primary: Search Pexels for cinematic video clips (see stock-footage.md).
Fallback: AI video generation:
video_clip = coll.generate_video(
prompt="Drone shot pushing forward over a city at night, neon lights reflecting, cinematic",
)- Duration: 5-8 seconds. Always mute (
volume=0). - Good for: hooks, transitions between major sections, emotional beats.
Image Scene
Abstract concepts, mood backgrounds, establishing shots.
Primary: Search Pexels for matching photos (see stock-footage.md).
Fallback: AI image generation:
bg_img = coll.generate_image(
prompt="Abstract dark technology background with subtle blue gradient and digital particles, minimalist",
aspect_ratio="16:9",
)- Keep segments under 5 seconds (static images over 5s feel like a slideshow).
- Pair with ambient loop overlay for segments >4s.
Logo Composition
Tech logo grids, framework comparisons, ecosystem overviews.
Primary: Source real logos from Simple Icons CDN (see logo-sourcing.md). Compose multiple logos on a dark background using VideoDB timeline positioning.
Fallback (Remotion): Animate logos appearing with spring physics and stagger.
Stock Footage
Real-world b-roll from stock APIs.
Primary: Pexels/Pixabay (see stock-footage.md).
Fallback: generate_video with a similar prompt.
- Keep to 3-5 seconds, mute audio, prefer clips with camera movement.
Design Standards for Text Overlays
Hard minimums for any text rendered on screen (all sizes at 1280x720):
| Text Role | Font Size | Frame Width Coverage |
|---|---|---|
| Hero stat / section header | 52-72px | 60%+ |
| Data callout (lower-third) | 36-48px | 40%+ |
| Title card | 48-60px | 60%+ |
| Subtitle / attribution | 24-32px | — |
Every text overlay MUST have a semi-opaque background bar (opacity 0.6-0.8).
CRITICAL — No TextAsset on Remotion segments: Remotion-rendered videos (CodeEditor, KineticText, DiagramFlow, BarChart, SplitCompare, CounterReveal) already contain text baked into their frames. Do NOT add any TextAsset overlay on top. This causes ugly double-text. Remotion segments are visually self-contained and provide their own text through animation.
Known gotcha: TextAsset at Position.center with Background may render invisibly. Use Position.bottom with Offset(y=-0.3) to simulate center placement.
Production Order
1. Narration audio first. Generate voiceover for each segment. Always cast float(voice.length) — it may return a string. Truncate with math.floor(length * 100) / 100. 2. Background music second. Generate one track. Check music.length — loop if needed. 3. SFX third (if format requires it). Whooshes, pops, comedic stings. 4. Visuals: source before generate. Search asset library and external APIs in parallel where possible. Generate AI assets only for what can't be sourced. 5. Overlay assets (explainer format — MANDATORY). After all main visuals are produced, make a second pass and produce overlay assets. See below.
Critical: AI-generated videos are 5-8 seconds max. Never set clip duration beyond actual .length.
Overlay Asset Production (Phase 3, Step 5)
This step is MANDATORY for fireship-explainer format. The self-review will FAIL any explainer video with fewer than 3 overlay clips. Produce overlay assets AFTER main visuals so you know which segments need them.
Planning pass
Walk through the segment list and assign overlays:
overlay_plan = []
for seg in script["segments"]:
vtype = seg["visual_type"]
# Skip segments where overlays are banned
if vtype in ("kinetic_text", "meme_insert"):
continue
if vtype == "code_editor":
# Code slides → logo badge for the language/framework
lang = seg.get("language", "javascript")
overlay_plan.append({
"segment_id": seg["id"],
"type": "logo_badge",
"source": f"simpleicons_{lang}",
"position": "top_right",
"scale": 0.08,
})
elif vtype == "diagram_animation" and seg.get("mentions_tech"):
# Diagrams mentioning a tech → logo badge
overlay_plan.append({
"segment_id": seg["id"],
"type": "logo_badge",
"source": f"simpleicons_{seg['mentions_tech']}",
"position": "top_right",
"scale": 0.08,
})
elif vtype in ("image_scene", "data_viz") and seg.get("duration", 5) > 4:
# Static visuals >4s → PiP reaction or ambient loop
overlay_plan.append({
"segment_id": seg["id"],
"type": "pip_reaction",
"query": "developer surprised",
"position": "bottom_right",
"scale": 0.20,
})
# HARD CHECK
if len(overlay_plan) < 3:
# Force-add overlays to the longest code_editor segments
code_segs = sorted(
[s for s in script["segments"] if s["visual_type"] == "code_editor"],
key=lambda s: s.get("duration", 5), reverse=True
)
for cs in code_segs:
if len(overlay_plan) >= 3:
break
if not any(o["segment_id"] == cs["id"] for o in overlay_plan):
overlay_plan.append({
"segment_id": cs["id"],
"type": "logo_badge",
"source": "simpleicons_" + cs.get("language", "javascript"),
"position": "top_right",
"scale": 0.08,
})
print(f"Overlay plan: {len(overlay_plan)} overlays")
assert len(overlay_plan) >= 3, f"FAIL: Only {len(overlay_plan)} overlays — need at least 3"Producing overlay assets
| Overlay type | How to produce | Upload to |
|---|---|---|
logo_badge | Download SVG from https://cdn.simpleicons.org/{name} → convert to PNG with Playwright screenshot → upload | asset_coll |
pip_reaction | GIPHY API search → download MP4. Fallback: generate_image with reaction prompt | asset_coll |
ambient_loop | Search asset library for existing ambient clips. Fallback: generate_video("subtle particles dark background") | asset_coll |
stat_counter | No asset needed — use TextAsset directly during composition | N/A |
# Example: download logo as PNG via Playwright
def download_logo_png(icon_name: str, work_dir: str, size: int = 128) -> str:
"""Download a Simple Icons SVG and render to PNG."""
from playwright.sync_api import sync_playwright
svg_url = f"https://cdn.simpleicons.org/{icon_name}/white"
html = f'''<html><body style="margin:0;background:#0d1117;display:flex;
align-items:center;justify-content:center;width:{size}px;height:{size}px;">
<img src="{svg_url}" width="{size-16}" height="{size-16}"></body></html>'''
html_path = f"{work_dir}/screenshots/logo_{icon_name}.html"
png_path = f"{work_dir}/screenshots/logo_{icon_name}.png"
Path(html_path).write_text(html)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": size, "height": size})
page.goto(f"file://{html_path}")
page.wait_for_load_state("networkidle")
page.screenshot(path=png_path)
browser.close()
return png_pathSave the overlay plan to {WORK_DIR}/scripts/overlay_plan.json for use during composition.
Mixing Approaches
Briefing format (data-heavy topic):
hook: motion_scene → context: browser_capture → insight 1: data_viz →
insight 2: kinetic_text → takeaway: data_viz → close: kinetic_textFireship explainer format:
hook: kinetic_text → topic1_intro: kinetic_text → topic1_code: code_editor →
topic1_meme: meme_insert → topic2_intro: kinetic_text → topic2_code: code_editor →
topic2_compare: split_screen → topic2_meme: meme_insert →
topic3_intro: kinetic_text → topic3_code: code_editor →
topic3_data: data_viz → takeaway: kinetic_text → cta: logo_compositionKey principle: Visual type should change every 2-3 segments. If you find 3 consecutive code_editor segments, break them up with a meme_insert, diagram_animation, or kinetic_text.
Browser Recording with Playwright
Playwright Python API patterns for capturing browser navigation as video or screenshots. The agent writes and executes these scripts directly — no external skills or CLIs needed.
All paths must use the run's `WORK_DIR` (see SKILL.md "Parallel Execution"). Never use bare /tmp/ paths.
Setup
Install before first use:
pip install playwright && playwright install chromiumRuntime availability check:
try:
from playwright.sync_api import sync_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = FalseIf PLAYWRIGHT_AVAILABLE is False, fall back to kinetic text (see asset-production.md).
Video Recording
Playwright records everything a browser context does into a .webm file.
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
capture_dir = Path(WORK_DIR) / "captures"
capture_dir.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(capture_dir),
record_video_size={"width": 1280, "height": 720},
)
page = context.new_page()
page.goto("https://example.com")
page.wait_for_load_state("networkidle")
time.sleep(2)
for _ in range(5):
page.evaluate("window.scrollBy(0, 300)")
time.sleep(1)
video_path = page.video.path()
context.close()
browser.close()
print(f"Recorded: {video_path}")Key rules:
- Call
page.video.path()beforecontext.close() - Call
context.close()to flush the video record_video_sizeshould matchviewport- Use 1280x720 for landscape, 720x1280 for vertical
- Videos are
.webmformat — upload directly to VideoDB
Screenshots
Full-page screenshot
screenshot_dir = Path(WORK_DIR) / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
page.goto("https://example.com/article")
page.wait_for_load_state("networkidle")
page.screenshot(path=str(screenshot_dir / "article_full.png"), full_page=True)Viewport screenshot
page.screenshot(path=str(screenshot_dir / "article_viewport.png"))Element screenshot
element = page.locator("article[data-testid='tweet']")
element.wait_for(state="visible", timeout=10000)
element.screenshot(path=str(screenshot_dir / "tweet.png"))Navigation Patterns
Slow deliberate scroll (for recording)
scroll_distance = 300
pause = 1.0
steps = 6
for _ in range(steps):
page.evaluate(f"window.scrollBy(0, {scroll_distance})")
time.sleep(pause)Wait for dynamic content
page.wait_for_load_state("networkidle")
page.wait_for_selector("article", state="visible", timeout=15000)Dismiss cookie banners and popups
dismiss_selectors = [
"button:has-text('Accept')",
"button:has-text('Got it')",
"button:has-text('Close')",
"[aria-label='Close']",
".cookie-banner button",
]
for sel in dismiss_selectors:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=2000):
btn.click()
time.sleep(0.5)
break
except Exception:
continueZoom for readability
page.evaluate("document.body.style.zoom = '1.25'")Type into a search box
page.goto("https://www.google.com")
page.wait_for_selector("textarea[name='q']", state="visible")
page.locator("textarea[name='q']").type("AI trends 2026", delay=80)
time.sleep(0.5)
page.keyboard.press("Enter")
page.wait_for_load_state("networkidle")Zoom and Crop Patterns (Explainer Format)
For explainer-style videos, browser captures should feel focused and punchy. Use these techniques:
Zoomed viewport for code/docs
Capture at a higher zoom level so code or documentation fills the frame:
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(capture_dir),
record_video_size={"width": 1280, "height": 720},
device_scale_factor=1.5, # renders at higher DPI
)
page = context.new_page()
page.goto("https://github.com/user/repo")
page.wait_for_load_state("networkidle")
# Zoom into the content area
page.evaluate("document.body.style.zoom = '1.4'")
time.sleep(1)Focus on specific element (crop effect)
Navigate to a page then scroll/zoom so only the relevant element fills the viewport:
# Scroll to specific element
element = page.locator(".readme-content h2:has-text('Installation')")
element.scroll_into_view_if_needed()
time.sleep(0.5)
# Zoom in so it fills the viewport
page.evaluate("document.body.style.zoom = '1.5'")
time.sleep(2) # hold for viewerGitHub repo showcase (3-5 second clip)
def record_github_repo(repo_url: str, work_dir: str = WORK_DIR) -> str:
video_dir = Path(work_dir) / "captures"
video_dir.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(video_dir),
record_video_size={"width": 1280, "height": 720},
)
page = context.new_page()
page.goto(repo_url)
page.wait_for_load_state("networkidle")
# Zoom into repo header area
page.evaluate("document.body.style.zoom = '1.3'")
time.sleep(2)
# Scroll to show stars/description
page.evaluate("window.scrollBy(0, 200)")
time.sleep(2)
video_path = page.video.path()
context.close()
browser.close()
return str(video_path)Code file display (for code_editor fallback)
When Remotion is unavailable, render code locally using a self-contained HTML file with highlight.js. Do NOT use ray.so or carbon.sh URL fragment encoding — multi-line code with special characters gets corrupted by urllib.parse.quote() and produces garbled screenshots.
def capture_code_display(code: str, language: str, work_dir: str = WORK_DIR) -> str:
"""Render code as a styled HTML page and screenshot it with Playwright."""
import html as html_module
screenshot_dir = Path(work_dir) / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
output_path = str(screenshot_dir / f"code_{uuid.uuid4().hex[:6]}.png")
html_path = str(screenshot_dir / f"code_{uuid.uuid4().hex[:6]}.html")
escaped_code = html_module.escape(code)
html_content = f"""<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/{language}.min.js"></script>
<style>
body {{
margin: 0; padding: 40px 50px;
background: #0d1117;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; box-sizing: border-box;
}}
pre {{
margin: 0; width: 100%;
border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}}
code {{
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace !important;
font-size: 18px !important;
line-height: 1.6 !important;
padding: 30px 35px !important;
}}
.hljs {{ background: #161b22 !important; }}
</style>
</head>
<body>
<pre><code class="language-{language}">{escaped_code}</code></pre>
<script>hljs.highlightAll();</script>
</body>
</html>"""
Path(html_path).write_text(html_content)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 720})
page.goto(f"file://{html_path}")
page.wait_for_load_state("networkidle")
time.sleep(1)
page.screenshot(path=output_path)
browser.close()
return output_pathWhy this works: The code is written directly to a local HTML file as escaped HTML text — no URL encoding, no fragment parsing, no third-party service dependency. highlight.js provides real syntax highlighting via CDN. The screenshot is pixel-perfect every time.
Recipes
Record a tweet
def record_tweet(tweet_url: str, work_dir: str = WORK_DIR) -> str:
video_dir = Path(work_dir) / "captures"
video_dir.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(video_dir),
record_video_size={"width": 1280, "height": 720},
)
page = context.new_page()
page.goto(tweet_url)
page.wait_for_load_state("networkidle")
try:
page.wait_for_selector("article", state="visible", timeout=10000)
except Exception:
page.wait_for_load_state("domcontentloaded")
time.sleep(3)
time.sleep(4)
video_path = page.video.path()
context.close()
browser.close()
return str(video_path)Record a search journey
def record_search_journey(query: str, work_dir: str = WORK_DIR) -> str:
video_dir = Path(work_dir) / "captures"
video_dir.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(video_dir),
record_video_size={"width": 1280, "height": 720},
)
page = context.new_page()
page.goto("https://www.google.com")
page.wait_for_selector("textarea[name='q']", state="visible")
page.locator("textarea[name='q']").type(query, delay=80)
time.sleep(0.5)
page.keyboard.press("Enter")
page.wait_for_load_state("networkidle")
time.sleep(2)
for _ in range(3):
page.evaluate("window.scrollBy(0, 400)")
time.sleep(1.5)
try:
first_result = page.locator("h3").first
first_result.click()
page.wait_for_load_state("networkidle")
time.sleep(3)
except Exception:
time.sleep(2)
video_path = page.video.path()
context.close()
browser.close()
return str(video_path)Screenshot a news headline
def screenshot_headline(article_url: str, work_dir: str = WORK_DIR) -> str:
screenshot_dir = Path(work_dir) / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
output_path = str(screenshot_dir / f"headline_{uuid.uuid4().hex[:6]}.png")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 720})
page.goto(article_url)
page.wait_for_load_state("networkidle")
time.sleep(1)
page.screenshot(path=output_path)
browser.close()
return output_pathMulti-page recording
def record_multi_page(urls: list[str], work_dir: str = WORK_DIR) -> str:
video_dir = Path(work_dir) / "captures"
video_dir.mkdir(parents=True, exist_ok=True)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
record_video_dir=str(video_dir),
record_video_size={"width": 1280, "height": 720},
)
page = context.new_page()
for url in urls:
page.goto(url)
page.wait_for_load_state("networkidle")
time.sleep(2)
for _ in range(3):
page.evaluate("window.scrollBy(0, 300)")
time.sleep(0.8)
video_path = page.video.path()
context.close()
browser.close()
return str(video_path)Headless vs Headed
Default: headless. Chromium's modern headless mode supports video recording natively.
browser = p.chromium.launch(headless=True)If video recording produces blank frames in headless Linux, use xvfb-run:
xvfb-run python capture_script.pyOn macOS, headless video recording works out of the box.
Output Files
- Video:
.webmfiles inrecord_video_dir. Upload directly to VideoDB. - Screenshots:
.pngfiles at specified path.
Cleanup handled at run level — shutil.rmtree(WORK_DIR) in Phase 6.
Error Handling
def safe_capture(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception as e:
print(f"Browser capture failed: {e}")
return None # caller falls back to kinetic textExplainer Format Tips
- Keep captures short: 4-6 seconds max for explainer (vs 10-15 for briefing)
- Zoom aggressively: Use 1.3-1.5x zoom so content fills the frame at 720p
- Focus on one thing: Don't show an entire page — zoom into the specific headline, code block, or metric
- Speed up navigation: Reduce
time.sleep()pauses to 0.5-1s between actions - Skip the journey: For explainer, go directly to the target content — no typing-in-search-box preamble unless that's the point of the segment
Composition
How to arrange assets on the timeline. These are editorial rules — what makes a video feel produced, not assembled. Use the videodb skill for all timeline API operations. Read your format file for format-specific pacing and transition rules.
Track Architecture
Build the video on multiple tracks, layered bottom to top:
Track N (top) → Overlay clips: PiP memes, reaction videos, logo badges (scale 0.15-0.25)
Track 6 → SFX (AudioAsset: whooshes, pops, comedic stings) [explainer only]
Track 5 → Data call-outs, lower thirds (TextAsset)
Track 4 → Captions / subtitles (CaptionAsset)
Track 3 → Title card, outro card (TextAsset)
Track 2 → Narration audio (AudioAsset, volume=1.0)
Track 1 (bottom) → Visual content (VideoAsset / ImageAsset per segment)
Track 0 → Ambient loops (VideoAsset, opacity=0.15-0.25) [optional, under main visual]
+ Separate track → Background music (AudioAsset, volume=0.1-0.25)Visual content is the base layer. Everything else overlays on top. Audio tracks (narration + music + SFX) mix together — narration at full volume, music underneath, SFX as punctuation.
Transition Types
| Type | Effect | Use When |
|---|---|---|
hard_cut | Instant switch, no blend | Default for fireship-explainer. High energy. Between any two rapid segments. |
fade | 0.5s cross-dissolve | Default for briefing. Between visually different segments that need smoothing. |
whip | Fast horizontal wipe (0.2-0.3s) | Topic switches in explainer. Energy spikes. Before/after meme beats. |
graphic | Custom graphic transition | Section breaks. Between major topics in explainer. |
Transition defaults are defined in your format file. Read it for format-specific transition rules.
Implementation: Use Transition(in_="fade", out="fade", duration=0.5) for fades. For hard_cut, simply don't add a transition (clips abut directly). For whip/graphic transitions, use a pre-rendered Remotion clip:
# Pre-render whip transitions during asset production (one per direction is enough)
# See recipes/remotion-setup.md for the WhipTransition component
if REMOTION_AVAILABLE:
# Render a 0.5s whip transition clip
os.system(f'cd {REMOTION_DIR} && npx remotion render src/Root.tsx WhipTransition '
f'{REMOTION_DIR}/output/whip_right.mp4 '
f'--props=\'{{"direction":"right","color":"#3498DB"}}\' '
f'--width=1280 --height=720')
whip_clip = coll.upload(file_path=f"{REMOTION_DIR}/output/whip_right.mp4")
# Insert whip between topic segments on an overlay track
transition_track.add_clip(topic_switch_time, Clip(
asset=VideoAsset(id=whip_clip.id, volume=0),
duration=math.floor(float(whip_clip.length) * 100) / 100,
opacity=0.8,
))Structure Rules
Read your format file (reference/formats/*.md) for format-specific structure rules (opening, between segments, outro). Here's the universal guidance:
All Formats
Opening:
- Start with visual before narration begins (1-3s visual-only lead).
- Title card overlays the hook visual.
Outro:
- Last visual has clean ending.
- Outro text card appears after final narration ends (3-5 seconds).
- Music fades out over final 3 seconds.
Format-Specific (read your format file for full details)
Briefing: Fades between segments, 2-3s visual lead before narration, smooth transitions.
Fireship Explainer: Hard cuts everywhere, segments slam into each other, memes snap in/out, SFX sting on humor beats, 0-1s before narration starts.
Pacing Rules
Full pacing tables are in your format file. Here's the universal guidance:
Universal Rules
| Rule | Value |
|---|---|
| Clip duration | Never exceed source asset .length |
| Duration truncation | math.floor(length * 100) / 100 |
| Dead air | Minimize (briefing: 1s max; explainer: never) |
| Static image hold | Max 5 seconds (split, add motion, or cut) |
Format-Specific Summary
Briefing: 8-20s segments, sweet spot 12-15s. Vary lengths: short-long-short. 2-3s visual before narration.
Fireship Explainer: 2-10s segments, sweet spot 3-5s. Fast-fast-breathe rhythm. One new visual every 4-7 seconds. If any visual lingers beyond 10 seconds, the viewer feels the pace drop.
Static image maximum hold time (all formats): No single static image may appear on screen for more than 5 seconds. If narration is longer:
- Split into two visual segments
- Use
generate_video(inherent motion) - Use Remotion (animation built in)
- Add an ambient loop overlay at 0.2 opacity
Audio Mixing
Narration (Track 2):
- Volume: 1.0 (full). Primary audio.
- Start timing depends on format (read format file).
- End narration 2-3 seconds before video ends (outro breathes).
Background music (separate track):
- Briefing volume: 0.1-0.2
- Explainer volume: 0.15-0.25 (slightly higher energy)
- Spans full video duration. Loop if
generate_music()returned shorter audio. - Fades out over last 3 seconds.
SFX (explainer formats only):
- Volume: 0.4-0.7 (louder than music, softer than narration)
- Types:
- Whoosh: on hard_cut transitions between topics (0.3-0.5s)
- Pop/click: on stat reveals and data callouts (0.2s)
- Comedic sting: on meme_insert beats (0.5-1s)
- Rise/swell: building tension before a reveal (1-2s)
- Each SFX is a separate AudioAsset placed at the exact timestamp
Visual assets with audio:
- AI-generated video clips: mute (volume=0)
- Browser captures: mute unless audio is intentionally part of the story
- Stock footage: always mute
Zoom and Pan (Explainer Format)
VideoDB's timeline editor doesn't support zoom/pan natively. To achieve zoom or pan effects:
1. Pre-process with Remotion: Render the zoom/pan as part of the Remotion component (e.g., code editor that zooms into a function). The output video already contains the motion. 2. Crop in browser capture: Use Playwright's viewport settings to capture a zoomed-in portion of the screen. 3. Generate zoomed variants: For AI images, generate two versions (wide and zoomed) and cut between them.
Never try to apply zoom/pan at the timeline level — it's not supported.
Format Rules
Landscape (default, 16:9):
- Resolution:
1280x720 - Text sizes: title 48-60px, data call-outs 36-48px, captions 30px
Vertical (9:16):
- Resolution:
608x1080 - Text sizes: increase by 20% from landscape values
Square (1:1):
- Resolution:
1080x1080 - Compromise between landscape and vertical rules
Timeline Gotchas
These are hard constraints from the VideoDB Editor API. Violating them causes InvalidRequestError.
Clip duration must never exceed source asset length:
Clip(asset=VideoAsset(id=v.id), duration=15)will fail if the video is only 8s.- Always store exact
.lengthand use it as a ceiling.
Always floor durations, never round:
- Use
math.floor(length * 100) / 100to truncate to 2 decimal places.
`.length` may return a string:
- Always cast:
float(asset.length)before using in arithmetic.
`Track.add_clip(start, clip)` — start is an integer:
- Plan segment boundaries at whole-second marks.
`generate_music()` may return shorter audio than requested:
- Always check
music.lengthafter generation. Loop if shorter than video.
AI-generated videos are 5-8 seconds max:
- Never set clip duration beyond video's actual length.
Remotion-Rendered Segments: No TextAsset Overlays
CRITICAL RULE: When a segment's main visual is a Remotion-rendered video (CodeEditor, KineticText, DiagramFlow, BarChart, SplitCompare, CounterReveal), that video already contains text baked into its frames. Do NOT add any `TextAsset` overlay (data callout, emphasis text, subtitle) on top of it. Doing so produces ugly double-text where VideoDB's TextAsset renders on top of Remotion's baked-in text.
# During composition, track which segments used Remotion
REMOTION_VISUAL_TYPES = {"code_editor", "kinetic_text", "diagram_animation", "data_viz", "split_screen"}
def segment_is_remotion(seg):
"""Check if this segment's visual was produced by Remotion."""
return (seg.get("production_method") == "remotion" or
(REMOTION_AVAILABLE and seg.get("visual_type") in REMOTION_VISUAL_TYPES))
# When placing text overlays / data callouts:
for seg in script["segments"]:
if segment_is_remotion(seg):
# SKIP text overlays — Remotion video already has text baked in
continue
# ... add TextAsset clips only for non-Remotion segments ...What IS allowed on Remotion segments:
- Logo badges (small, corner-positioned, don't overlap text) —
scale 0.08, Position.top_right - Ambient loops underneath (they render behind, not on top)
- SFX audio clips (audio doesn't overlap visually)
What is BANNED on Remotion segments:
TextAssetdata calloutsTextAssetemphasis/hero textTextAssettitle overlays- Any
TextAssetthat would render text on top of the video's existing text
Narration Audio Continuity
CRITICAL RULE: Narration audio must play continuously across the video with no gaps or overlaps at segment boundaries. Audio drops during transitions are caused by:
1. Gaps between narration clips: Segment N's narration ends at t=10.2s but segment N+1's narration starts at t=11.0s → 0.8s of silence. 2. Overlapping narration clips: Two narration clips placed at overlapping timestamps → one gets suppressed.
How to prevent audio drops
# Build narration as a continuous chain — each clip starts exactly where the previous one ended.
narration_track = Track()
narration_cursor = 0 # running position in seconds
for seg in script["segments"]:
voice = voice_assets[seg["id"]]
voice_len = math.floor(float(voice.length) * 100) / 100
# Place narration at the current cursor position (integer start)
start = int(narration_cursor)
narration_track.add_clip(start, Clip(
asset=AudioAsset(id=voice.id),
duration=voice_len,
))
# Advance cursor by the exact voice duration (not segment visual duration)
narration_cursor = start + voice_len
# The visual track should be aligned to the same segment boundaries.
# Use narration_cursor positions as the authoritative timeline for visuals too.Key rules:
- Narration is the master clock. Segment boundaries are defined by narration timing, not visual asset lengths.
- No gaps: Each narration clip starts exactly at
int(previous_end). If previous ends at 10.3s, next starts at 10. - No overlaps: Never place two narration clips that would overlap in time.
- Visual clips fill to match: Each visual segment's duration is set to match its narration duration (or slightly exceed it for visual breathing room at the end of the video).
Overlay Composition (Picture-in-Picture)
VideoDB supports simultaneous clips on different tracks with scale, position, offset, and opacity. This enables picture-in-picture overlays — small visual elements that play on top of the main visual.
Track architecture with overlays
Track 8 (top) → Overlay clips (small images/videos, scaled down)
Track 7 → SFX (AudioAsset: whooshes, pops, stings)
Track 6 → Data call-outs, lower thirds (TextAsset)
Track 5 → Captions / subtitles (CaptionAsset)
Track 4 → Title card, outro card (TextAsset)
Track 3 → Narration audio (AudioAsset, volume=1.0)
Track 2 → Ambient loops (VideoAsset, full-screen, opacity=0.15-0.25)
Track 1 (bottom) → Main visual content (VideoAsset / ImageAsset per segment)
+ Separate track → Background music (AudioAsset, volume=0.1-0.25)Overlay placement rules
Position: Use the 9-zone grid (Position.top_left through Position.bottom_right). Most overlays go to bottom_right or top_right — out of the way of the main content.
Scale: 0.15-0.25 for most overlays. The overlay should be noticeable but not competing with the main visual. Think YouTube face-cam size.
Offset: Use Offset(x=-0.05, y=-0.05) to add slight inward padding from the edge. Clips flush against the frame edge look accidental.
Opacity: 0.85-0.95 for PiP memes and reactions. 0.15-0.25 for ambient loops (they should be felt, not seen).
Duration: Overlays are transient — 2-4 seconds. They pop in, add visual interest, and disappear. Exception: ambient loops and logo badges which can span the full segment.
Volume: Always volume=0 on video overlays. Only narration, music, and SFX produce sound.
Overlay timing
# Overlay appears 2 seconds into the segment, lasts 3 seconds
seg_start = 10 # segment starts at t=10
overlay_start = seg_start + 2 # overlay appears at t=12
overlay_duration = 3 # disappears at t=15
overlay_track.add_clip(overlay_start, Clip(
asset=VideoAsset(id=reaction.id, volume=0),
duration=min(overlay_duration, float(reaction.length)),
scale=0.2,
position=Position.bottom_right,
offset=Offset(x=-0.05, y=-0.05),
opacity=0.9,
))Looping ambient animations
Short AI videos (2-3s) can be looped to create continuous subtle motion behind static images:
ambient_track = Track()
anim_len = math.floor(float(ambient.length) * 100) / 100
seg_duration = 8 # seconds this segment lasts
loops = math.ceil(seg_duration / anim_len)
for i in range(loops):
loop_start = seg_start + int(i * anim_len)
remaining = (seg_start + seg_duration) - loop_start
if remaining > 0:
ambient_track.add_clip(loop_start, Clip(
asset=VideoAsset(id=ambient.id, volume=0),
duration=min(anim_len, remaining),
opacity=0.2, # very subtle — viewer feels motion, doesn't see it
))Place ambient_track BELOW the main visual track so it renders behind it. If the main visual is a static image, the ambient video's motion will bleed through at low opacity, giving the frame a living quality.
When to use overlays
| Situation | Overlay technique |
|---|---|
| Static code screenshot on screen for 8+ seconds | Add ambient_loop underneath + pip_meme reaction at second 4 |
| Narration mentions a specific tech while diagram is on screen | logo_badge of that tech in corner |
| Punchline during a code segment (don't want to cut away) | pip_meme in bottom-right for 2 seconds |
| Stat mentioned but main visual is a browser capture | stat_counter TextAsset in top-left |
| Any segment with a static image longer than 5 seconds | ambient_loop at opacity 0.2 to add subtle motion |
Overlay limits
- Max 2 overlays per segment. More than that clutters the frame.
- 3-5 total overlays per explainer video. They're spice, not the meal.
- Never overlay TextAsset on Remotion-rendered segments. Remotion videos have text baked in — adding TextAsset creates ugly double-text. Logo badges (small, corner) are OK.
- Never overlay on top of kinetic_text segments. The text IS the visual — don't compete with it.
- Never overlay on meme_insert segments. The meme IS the content.
Building the overlay track from the plan (Phase 4)
During composition, read {WORK_DIR}/scripts/overlay_plan.json (produced in Phase 3) and place each overlay on the overlay track:
import json
with open(f"{WORK_DIR}/scripts/overlay_plan.json") as f:
overlay_plan = json.load(f)
with open(f"{WORK_DIR}/scripts/overlay_assets.json") as f:
overlay_assets = json.load(f) # {segment_id: {id, type, length}}
overlay_track = Track()
overlay_count = 0
for entry in overlay_plan:
sid = entry["segment_id"]
seg_start = segment_starts[sid] # from your segment timing dict
seg_dur = segment_durations[sid]
asset_info = overlay_assets.get(sid)
if not asset_info:
continue
if entry["type"] == "logo_badge":
overlay_track.add_clip(seg_start, Clip(
asset=ImageAsset(id=asset_info["id"]),
duration=min(seg_dur, 8),
scale=entry.get("scale", 0.08),
position=Position.top_right,
offset=Offset(x=-0.03, y=0.03),
opacity=0.85,
))
overlay_count += 1
elif entry["type"] == "pip_reaction":
delay = min(2, seg_dur // 3)
if asset_info.get("is_video"):
overlay_track.add_clip(seg_start + delay, Clip(
asset=VideoAsset(id=asset_info["id"], volume=0),
duration=min(3, math.floor(float(asset_info["length"]) * 100) / 100),
scale=entry.get("scale", 0.20),
position=Position.bottom_right,
offset=Offset(x=-0.05, y=-0.05),
opacity=0.9,
))
else:
overlay_track.add_clip(seg_start + delay, Clip(
asset=ImageAsset(id=asset_info["id"]),
duration=3,
scale=entry.get("scale", 0.20),
position=Position.bottom_right,
offset=Offset(x=-0.05, y=-0.05),
opacity=0.9,
))
overlay_count += 1
elif entry["type"] == "stat_counter":
overlay_track.add_clip(seg_start + 1, Clip(
asset=TextAsset(
text=entry["text"],
font=Font(family="Clear Sans", size=36, color=entry.get("color", "#61DAFB")),
background=Background(color="#000000", opacity=0.7, width=250, height=60),
),
duration=min(4, seg_dur - 1),
position=Position.top_left,
offset=Offset(x=0.05, y=0.05),
))
overlay_count += 1
# HARD CHECK before generating stream
assert overlay_count >= 3, f"FAIL: Only {overlay_count} overlays placed — need at least 3"
print(f"Placed {overlay_count} overlays on overlay track")
# Add overlay track AFTER main visual track (renders on top)
timeline.add_track(overlay_track)Phase 4.5: Add Captions (Optional — User-Requested Only)
Captions are OFF by default. Only run this phase if the user explicitly asks for captions (e.g., "add captions", "with subtitles", "include captions").
After the main timeline stream is generated, add speech-synced captions as a final pass. See recipes/captions.md for full config, animation styles, and color presets.
from videodb.editor import (
CaptionAsset, FontStyling, BorderAndShadow, Positioning,
CaptionAlignment, CaptionAnimation,
)
captioned_source = coll.upload(url=stream_url)
captioned_source.index_spoken_words(force=True)
transcript = captioned_source.get_transcript()
print(f"Indexed {len(transcript)} transcript segments for captions")
caption_timeline = Timeline(conn)
caption_timeline.resolution = "1280x720"
cap_track = Track()
cap_duration = int(captioned_source.length)
cap_track.add_clip(0, Clip(
asset=VideoAsset(id=captioned_source.id, start=0),
duration=cap_duration,
))
cap_track.add_clip(0, Clip(
asset=CaptionAsset(
src="auto",
font=FontStyling(name="Clear Sans", size=42, bold=True),
primary_color="&H00FFFFFF",
secondary_color="&H00008000",
back_color="&H00000000",
border=BorderAndShadow(outline=2, outline_color="&H00000000"),
position=Positioning(alignment=CaptionAlignment.bottom_center, margin_v=40),
animation=CaptionAnimation.box_highlight,
),
duration=cap_duration,
))
caption_timeline.add_track(cap_track)
captioned_stream = caption_timeline.generate_stream()
stream_url = captioned_stream # replace with captioned versionCaptions are off by default. Only add them when the user explicitly requests captions/subtitles.
Checklist Before Generating Stream
Before calling timeline.generate_stream(), verify:
- [ ] No clip duration exceeds its source asset's actual
.length(floor, don't round) - [ ] Transitions match the format (hard_cut for explainer, fade for briefing)
- [ ] No two consecutive segments use the same visual type (briefing) or more than 3 in a row (explainer)
- [ ] Data call-outs appear after narration mentions them, not before
- [ ] Music track spans full duration at correct volume (loop if needed)
- [ ] All video/capture assets are muted (volume=0)
- [ ] Narration ends before outro card appears
- [ ] Outro card is 3-5 seconds
- [ ] Total duration within TARGET_DURATION range for format
- [ ] All
add_clip(start, ...)start values are integers - [ ] All text overlays meet minimum font size (36px callouts, 52px hero stats)
- [ ] No
Position.center+Backgroundcombo on TextAssets - [ ] Every callout has semi-opaque background bar (opacity 0.6+)
- [ ] SFX placed at correct timestamps (explainer only)
- [ ] Meme segments are exactly 2-3 seconds with hard_cut in/out
- [ ] Meme segments use
Fit.contain(NOTFit.crop) — memes have non-16:9 aspect ratios - [ ] Meme segments have caption text (TextAsset overlay) if raw template was used without API captioning
- [ ] Overlay clips use
scale0.15-0.25 (not full screen unless ambient) - [ ] All overlay video clips are muted (volume=0)
- [ ] Overlay track added AFTER main visual track (renders on top)
- [ ] Ambient loop track added BEFORE main visual track (renders behind)
- [ ] No more than 2 overlays per segment, 3-5 per video total
- [ ] At least 3 overlay clips placed (logo badges, PiP memes, ambient loops) for explainer format
- [ ] Meme segments have caption text if raw template was used
- [ ] NO TextAsset placed on any Remotion-rendered segment (code_editor, kinetic_text, diagram, data_viz, split_screen) — Remotion videos already contain baked-in text
- [ ] Narration clips chain continuously — no gaps or overlaps between consecutive narration clips
- [ ] Visual segment durations match narration durations (narration is the master clock)
- [ ] Captions (Phase 4.5) — only if user requested:
index_spoken_words+CaptionAsset(src="auto")withbox_highlightanimation
Briefing Format
A short, produced segment (60-120 seconds) from a modern news or tech channel. Data-driven, credible, well-sourced.
Persona
You are the host and presenter of this briefing. You are a journalist presenting research — not narrating over a slideshow. You show your sources, visualize your data, and walk the viewer through your findings. Professional, data-driven, credible.
Voice characteristics:
- "According to the latest data..."
- "What's particularly interesting is..."
- "Here's what this actually means for the industry..."
Duration & Segments
| Setting | Value |
|---|---|
| Duration | 60-120 seconds |
| Segments | 4-6 |
| Shot duration | 8-20 seconds |
Narrative Arc
HOOK (5-10s) → Grab attention with the most surprising finding
CONTEXT (10-15s) → Why this topic matters, set the stage
INSIGHTS (30-60s)→ 2-3 key findings, each its own segment
TAKEAWAY (10-15s)→ What this means, the "so what"
CLOSE (5s) → Clean ending, optional CTAThe hook is not an intro. Don't open with "today we'll talk about..." — open with the most striking data point or the most unexpected fact.
Pacing
| Rule | Value |
|---|---|
| Minimum segment | 8 seconds |
| Maximum segment | 20 seconds |
| Sweet spot | 12-15 seconds |
| Hook visual-only | 2-3 seconds before narration |
| Browser cap maximum | 10-15 seconds |
| Dead air max | 1 second |
Rhythm: Vary segment lengths. Short-long-short creates energy. Most important insight gets longest segment.
Transitions
Default: fade (0.5s cross-dissolve) between all segments.
Exception: kinetic_text → kinetic_text can use hard_cut.
Never hard-cut between visually different segments in briefing format.
Visual Variety
- Never use the same visual type for two consecutive segments.
- Maximum 2
image_scenesegments in the entire video. - Mix at least 3 different visual types per video.
Music & Audio
| Track | Volume |
|---|---|
| Narration | 1.0 |
| Background music | 0.1-0.2 |
| SFX | None (briefing doesn't use SFX) |
Music direction: ambient, professional, not distracting. Think: background scoring for a news segment.
Music starts at t=0, fades out over last 3 seconds. Narration starts 2-3 seconds after video begins.
Structure Rules
Opening (first 3 seconds):
- Start with motion before narration begins.
- If hook is
motion_scene, let it play 2-3s before narration starts. - Title card fades in over the hook visual.
Outro:
- Last visual has fade-out.
- Outro text card appears after final narration ends.
- Keep to 3-5 seconds.
Sourcing Strategy
Briefing format does not require aggressive external sourcing. The priority is: 1. browser_capture for showing real sources (articles, reports, data) 2. data_viz for presenting findings 3. Stock photos (Pexels) for mood/context shots 4. AI images acceptable for abstract backgrounds
No meme requirement. No overlay requirement (optional but not mandatory).
Fireship Explainer Format
A fast-cut, information-dense tech explainer (3-7 minutes) in the style of top YouTube tech channels. Every frame carries information or humor. Dense, witty, visual, and packed with real-world assets.
Persona
You are a fast-talking, opinionated tech creator. Think: dense information at pace with wit, visual variety every 2-5 seconds, code on screen, memes for comedic relief, and zero filler. Direct address the viewer.
Voice characteristics:
- Use "you" frequently — "Here's why you should care"
- Short punchy sentences — "Wrong." / "Nope." / "Here's the thing."
- Rhetorical questions — "But does that actually matter?"
- Hot takes — "Clean code is overrated." / "Most design patterns are cope."
- Specific over vague — "React 19 uses a compiler" not "modern frameworks have improvements"
- Technical but accessible — assume the viewer codes but doesn't PhD
NEVER say:
- "In this video, we will explore..."
- "Let me explain..."
- "As we can see..."
- "Moving on to our next topic..."
- "Without further ado..."
Duration & Segments
| Setting | Value |
|---|---|
| Duration | 180-420 seconds (3-7 min) |
| Segments | 15-30+ |
| Shot duration | 2-5 seconds (rapid-fire) |
| Humor beat interval | Every 60-90 seconds |
Narrative Arc
HOOK (3-5s) → One-liner that stops the scroll. Bold claim, surprising stat, or hot take.
TOPIC 1 (15-30s) → 3-6 rapid segments covering the first point
TOPIC 2 (15-30s) → 3-6 rapid segments covering the second point
...
TOPIC N (15-30s) → 3-6 rapid segments covering the Nth point
TAKEAWAY (5-10s) → "Here's what actually matters"
CTA (3-5s) → Subscribe, check links, etc.Each topic block alternates between narration-heavy segments (code, explanation) and visual punches (memes, diagrams, logos). Never 2+ consecutive "talking head" segments without a visual interrupt.
Pacing
| Rule | Value |
|---|---|
| Minimum segment | 2 seconds |
| Maximum segment | 10 seconds |
| Sweet spot | 3-5 seconds |
| Hook visual-only | 0-1 seconds (immediate) |
| Browser cap maximum | 6-8 seconds |
| Code editor maximum | 8-10 seconds |
| Meme duration | 2-3 seconds |
| Dead air max | 0 seconds (NEVER) |
Rhythm: Fast-fast-breathe. Two rapid segments (2-4s) followed by one slightly longer (5-8s).
Static image maximum hold time: No single static image may appear for more than 5 seconds. If narration is longer:
- Split into two visual segments (main image 3-4s, then zoomed/cropped variant)
- Or use
generate_video(inherent motion) - Or use Remotion (animation built in)
Transitions
hard_cutfor 80% of transitions. Segments slam into each other.whipfor topic switches and energy spikes.fadesparingly — only for emotional beats or atmospheric segments.- Never fade into or out of a meme — memes snap with hard_cut.
Music & Audio
| Track | Volume |
|---|---|
| Narration | 1.0 |
| Background music | 0.15-0.25 (higher energy) |
| SFX | 0.4-0.7 |
Music direction: energetic, electronic, with peaks at topic transitions. Think: late-night coding session soundtrack.
SFX types:
- Whoosh: on hard_cut transitions between topics (0.3-0.5s)
- Pop/click: on stat reveals and data callouts (0.2s)
- Comedic sting: on meme_insert beats (0.5-1s)
- Rise/swell: building tension before a reveal (1-2s)
Layering Mandate
Every segment MUST have a base visual + at least one element of visual density. A bare image on screen with voiceover is a slideshow, not a video. Achieve density via:
- Overlay (PiP meme, logo badge, ambient loop)
- Text element (data callout, emphasis word) — ONLY on non-Remotion segments
- Motion (Remotion animation, video clip, ambient loop behind static image)
- Composition (multiple logos/elements arranged on screen)
CRITICAL — No TextAsset on Remotion segments: When a segment uses a Remotion-rendered video (CodeEditor, KineticText, DiagramFlow, BarChart, SplitCompare, CounterReveal), the video already contains text baked into its frames. Adding a TextAsset overlay produces ugly double-text. Remotion visuals are self-contained — they provide their own visual density through animation. The only overlays allowed on Remotion segments are small logo badges in corners and ambient loops underneath.
Minimum overlays per video: 3 (PiP memes, reaction clips, logo badges, or ambient loops).
How to Plan Overlays (Phase 3)
During asset production, after producing all main visuals, make a second pass through the segment list and assign overlays:
overlay_plan = []
for seg in script["segments"]:
overlay = None
if seg["visual_type"] == "code_editor":
# Code slides benefit from logo badges (language/framework logo in corner)
overlay = {"type": "logo_badge", "logo": seg.get("language", "javascript"), "position": "top_right"}
elif seg["visual_type"] in ("image_scene", "kinetic_text") and seg.get("duration", 5) > 4:
# Static images >4s need ambient motion underneath
overlay = {"type": "ambient_loop", "position": "behind"}
elif seg.get("humor_beat") and seg["visual_type"] != "meme_insert":
# Humor beat during non-meme segment → PiP reaction in corner
overlay = {"type": "pip_reaction", "query": "developer reaction", "position": "bottom_right"}
if overlay:
overlay_plan.append({"segment_id": seg["id"], **overlay})
# HARD CHECK: must have at least 3 overlays planned
assert len(overlay_plan) >= 3, f"Only {len(overlay_plan)} overlays planned — need at least 3"Then produce overlay assets (logo PNGs from Simple Icons CDN, ambient loops from asset library/Pexels/generate_video, reaction GIFs from GIPHY) and place them on the overlay track during composition.
Text Style
All text on screen follows these rules:
- Font: Impact, Montserrat Black, or similar thick bold sans-serif
- Weight: Always bold/black weight
- Shadow: Drop shadow (2px black) for legibility over any background
- Optional tilt: 2-5 degree rotation on emphasis words for energy
- Colors: White primary, accent color (blue/green/orange) for emphasis words
- Size: Hero text 52-72px, callouts 36-48px (at 1280x720)
No Reused Backgrounds
Every segment must be visually distinct from the one before it. Hard rules:
- No two segments may share the same background image/video asset ID
- If using AI-generated backgrounds, use a unique prompt per segment — vary subject, color palette, and composition. Do NOT use a generic "dark tech background" prompt for multiple segments.
- Stock footage clips must be from different sources per segment
- Exception: ambient loops can repeat (they're at 20% opacity and invisible to casual viewing)
Self-review enforcement: During self-review, collect all visual asset IDs. If any non-ambient asset ID appears for more than one segment, that's an automatic FAIL.
# During composition, track all visual asset IDs
visual_ids = {}
for seg_id, vis in visuals.items():
aid = vis.get("id")
if aid:
if aid in visual_ids:
print(f"FAIL: Asset {aid} reused in {visual_ids[aid]} AND {seg_id}")
visual_ids[aid] = seg_idVisual Sourcing Strategy
This is what makes Fireship-style videos look professional: real-world assets dominate over AI-generated ones, and Remotion animations provide motion that VideoDB's timeline can't.
Why Remotion is Critical for This Format
Without Remotion, you get static images with voiceover. With Remotion:
- Code types itself on screen (CodeEditor component)
- Diagrams build themselves node by node (DiagramFlow)
- Stats count up from zero (CounterReveal)
- Text springs in with physics (KineticText)
- Bars race to their values (BarChart)
- Topics whip into each other (WhipTransition)
- Comparisons wipe reveal (SplitCompare)
Read recipes/remotion-setup.md for all components. If REMOTION_AVAILABLE = True, you MUST use Remotion for at least: code_editor segments, one kinetic_text hero moment (hook or takeaway), and any diagram/data_viz/split_screen segments.
Phase 3 (Asset Production) flow:
For each segment:
1. ALWAYS read recipes/sourcing/asset-library.md → search the collection
2. Based on visual_type:
- stock_footage / motion_scene / image_scene → read recipes/sourcing/stock-footage.md
- meme_insert → read recipes/sourcing/meme-sourcing.md
- logo_composition → read recipes/sourcing/logo-sourcing.md
- code_editor → read recipes/code-slide.md (always rendered, no external sourcing)
- browser_capture → read reference/browser-recording.md
- kinetic_text → read recipes/kinetic-text.md
- diagram_animation → read recipes/diagram-flow.md
- data_viz → read recipes/data-viz.md
- split_screen → read recipes/split-compare.md
3. For overlays → read recipes/sourcing/gif-reactions.md + logo-sourcing.md
4. After producing from external source → upload to asset collectionSourcing Budget
Target for the entire video:
| Source type | Target % | Hard limit |
|---|---|---|
| Real-sourced (stock, memes, logos, screenshots) | >50% | Minimum 40% |
| AI generated (generate_image, generate_video) | <30% | Maximum 40% |
| Rendered (code, text, diagrams) | <20% | Maximum 30% |
If your video has 20 visual segments, at least 10 should use real-sourced assets (Pexels footage, Imgflip memes, GIPHY reactions, Simple Icons logos, browser captures of real websites).
Visual type → sourcing recipe mapping
| Visual Type | First try | Then | Last resort |
|---|---|---|---|
stock_footage | Asset library → Pexels video | Pixabay video | generate_video |
motion_scene | Asset library → Pexels video | generate_video | — |
image_scene | Asset library → Pexels photo | generate_image | — |
meme_insert | Asset library → Imgflip/GIPHY | Memegen.link/KYM | generate_image |
logo_composition | Simple Icons CDN / devicon | Asset library | Playwright screenshot |
code_editor | (always rendered) Remotion | Playwright HTML | — |
browser_capture | (always live) Playwright | — | — |
kinetic_text | (always TextAsset) | — | — |
diagram_animation | (always rendered) Remotion | Playwright Mermaid | — |
data_viz | (always rendered) Remotion | Playwright Chart.js | — |
split_screen | (always rendered) Remotion | Playwright dual | — |
Humor Rules
- Meme every 60-90 seconds. Never exceed 90s without a humor beat.
- Memes are 2-3 seconds with hard cut in/out.
- Use REAL meme templates when possible (Imgflip, GIPHY) — they land better than AI illustrations.
- SFX comedic sting plays on meme appear.
- Memes must be contextually relevant to what was just said.
- A developer should find it funny.
Explainer-Specific Visual Variety
- Same visual type OK for back-to-back if content differs (e.g., Python code → JS code).
- Never more than 3 consecutive segments of same type.
- Aim for type change every 2 segments.
- Minimum 5 different visual types in a full explainer.
Good sequence example:
hook: kinetic_text → topic1_explain: code_editor → topic1_punch: meme_insert →
topic1_evidence: browser_capture → topic2_explain: diagram_animation →
topic2_code: code_editor → topic2_meme: meme_insert → topic3_compare: split_screen →
topic3_evidence: browser_capture → takeaway: kinetic_text → cta: logo_compositionSelf-Review Additions (Fireship-specific)
During self-review, additionally check:
- Sourcing budget met? Count real-sourced vs AI-generated. Flag if AI > 40%.
- Humor timing: Memes land on punchlines? Gap never exceeds 90s?
- Code readability: Font readable at 720p? Syntax highlighting works? Characters correct?
- Layering check: Does every segment have at least base visual + one density element?
- Visual variety score: At least 5 different types used?
- Static hold time: No image on screen > 5 seconds?
- Energy consistency: One new visual every 5-7 seconds baseline maintained?
- SFX placement: Whooshes on transitions, pops on reveals, stings on memes?
Captions (Auto-Synced Speech Captions)
Adds word-synced animated captions to the final video. Captions are the last visual layer added — they go on after all other composition is done.
Prerequisites
The final composed video must have its spoken words indexed before captions can be applied:
video.index_spoken_words(force=True)
transcript = video.get_transcript()
assert len(transcript) > 0, "No transcript — cannot add captions"src="auto" pulls timing from the spoken-word index. No manual subtitle file needed.
Caption Design: Fireship / Explainer Style
from videodb.editor import (
CaptionAsset, FontStyling, BorderAndShadow, Positioning,
CaptionAlignment, CaptionAnimation,
)
caption_asset = CaptionAsset(
src="auto",
font=FontStyling(name="Clear Sans", size=42, bold=True),
primary_color="&H00FFFFFF", # white text
secondary_color="&H00008000", # green highlight box
back_color="&H00000000", # transparent background
border=BorderAndShadow(
outline=2,
outline_color="&H00000000", # black outline for readability
),
position=Positioning(
alignment=CaptionAlignment.bottom_center,
margin_v=40,
),
animation=CaptionAnimation.box_highlight,
)Why this config works
| Setting | Value | Rationale |
|---|---|---|
| Font size 42 | Large enough for 720p, not so large it competes with hero text | |
| Bold | Legibility over busy backgrounds (code slides, diagrams) | |
| Black outline (2px) | Guarantees readability on both light and dark backgrounds | |
box_highlight animation | Highlights the currently spoken word with a colored box — gives a karaoke feel that adds energy to fast narration | |
| Green secondary | The highlight box color; green pops against most video backgrounds without clashing with common UI colors | |
| Bottom-center, 40px margin | Standard subtitle zone; clears the lower-third area used by some overlays | |
| Transparent back_color | No opaque strip — the outline handles readability, keeping the frame open |
Animation Styles Reference
Pick the animation that matches the video's energy:
| Animation | Effect | Best for |
|---|---|---|
CaptionAnimation.box_highlight | Colored box around the current word | Fireship / explainer — fast-paced, high-energy |
CaptionAnimation.color_highlight | Current word changes color | Subtle, professional — briefings, interviews |
CaptionAnimation.reveal | Words appear progressively | Slower narration, documentary feel |
CaptionAnimation.karaoke | Word-by-word fill | Music videos, lyric-style |
CaptionAnimation.impact | Emphasis pop on current word | Dramatic reveals, trailers |
CaptionAnimation.supersize | Current word scales up | Comedy, meme-style content |
Color Format
All colors use ASS &HAABBGGRR format (alpha, blue, green, red — note the reversed channel order):
&H00FFFFFF → fully opaque white
&H00000000 → fully opaque black
&H00008000 → fully opaque green (R=0, G=128, B=0)
&H000000FF → fully opaque red
&H00FF0000 → fully opaque blue
&H0000FFFF → fully opaque yellow (R=255, G=255, B=0)
&H80000000 → 50% transparent blackColor presets by format
| Format | secondary_color (highlight) | Vibe |
|---|---|---|
| Fireship explainer | &H00008000 (green) | Terminal / hacker energy |
| Briefing | &H00FFAA00 (light blue) | Clean, professional |
| Dark / cyberpunk | &H000000FF (red) | Dramatic, intense |
| Warm / creative | &H0000CCFF (orange) | Friendly, approachable |
Integration into Timeline
Captions go on a dedicated caption track layered above the main visual and below overlays:
from videodb.editor import Timeline, Track, Clip, VideoAsset
clip_duration = int(video.length)
timeline = Timeline(conn)
timeline.resolution = "1280x720"
# Track 1: main video
main_track = Track()
main_track.add_clip(0, Clip(
asset=VideoAsset(id=video.id, start=0),
duration=clip_duration,
))
timeline.add_track(main_track)
# Caption track: same duration, same track works too
caption_track = Track()
caption_track.add_clip(0, Clip(
asset=caption_asset,
duration=clip_duration,
))
timeline.add_track(caption_track)Captions can also be placed on the same track as the video clip — both approaches work. Separate tracks give more control for debugging.
Two-Pass Workflow (Recommended for Content-Creator Pipeline)
The caption step runs after the main video is fully composed and streamed (Phase 4 composition is done). It's a post-processing pass:
# --- Phase 4 completes: stream_url is the composed video without captions ---
# Phase 4.5: Add Captions (post-composition)
print("Adding captions to final video...")
# 1. Upload the composed stream back as a single video
captioned_source = coll.upload(url=stream_url)
# 2. Index its spoken words
captioned_source.index_spoken_words(force=True)
transcript = captioned_source.get_transcript()
print(f"Indexed {len(transcript)} transcript segments")
# 3. Compose with captions
caption_timeline = Timeline(conn)
caption_timeline.resolution = "1280x720"
cap_track = Track()
cap_duration = int(captioned_source.length)
cap_track.add_clip(0, Clip(
asset=VideoAsset(id=captioned_source.id, start=0),
duration=cap_duration,
))
cap_track.add_clip(0, Clip(
asset=CaptionAsset(
src="auto",
font=FontStyling(name="Clear Sans", size=42, bold=True),
primary_color="&H00FFFFFF",
secondary_color="&H00008000",
back_color="&H00000000",
border=BorderAndShadow(outline=2, outline_color="&H00000000"),
position=Positioning(
alignment=CaptionAlignment.bottom_center,
margin_v=40,
),
animation=CaptionAnimation.box_highlight,
),
duration=cap_duration,
))
caption_timeline.add_track(cap_track)
captioned_stream = caption_timeline.generate_stream()
print(f"Captioned stream: {captioned_stream}")This approach keeps the main composition pipeline clean and adds captions as an optional final pass.
When to Add Captions
Captions are OFF by default. Only add them when the user explicitly requests it (e.g., "add captions", "with subtitles", "include captions").
| Scenario | Add captions? |
|---|---|
| User explicitly requests captions | Yes |
| User says "with subtitles" or "captioned" | Yes |
| User does not mention captions | No — skip Phase 4.5 |
| Music-only / no narration | No |
Vertical / Square Format Adjustments
| Setting | Landscape (1280x720) | Vertical (608x1080) | Square (1080x1080) |
|---|---|---|---|
| Font size | 42 | 50 | 46 |
| Margin V | 40 | 60 | 50 |
| Outline | 2 | 3 | 2 |
Quality Checks
After generating the captioned stream, verify:
- [ ] Captions are visible and readable at multiple timestamps (extract 3+ frames)
- [ ]
box_highlightanimation is active (current word has colored background) - [ ] Captions don't overlap with Remotion-baked text in center of frame (captions are bottom-positioned, so this should be safe)
- [ ] Font size is appropriate for the output resolution
- [ ] No captions appear during silent/music-only segments (auto handles this)
Code Slide
Show code with syntax highlighting, readable at 720p. This is the primary visual type for explainer format — every topic involving code should use this.
The Goal
A dark-themed code editor frame showing real, accurate code with proper syntax highlighting. Optionally animated with typing, line-by-line reveals, zoom effects, and cursor blinks. Think VS Code screenshot but cinematic.
Approach 1: Remotion (Best — Animated)
Requires: REMOTION_AVAILABLE = True (set during Phase 0 bootstrap). If Phase 0 set this to True, you MUST use Remotion for code slides, not Playwright. Playwright is a fallback only.
Write a CodeEditor.tsx component into the scaffolded Remotion project:
interface CodeEditorProps {
code: string;
language: string;
highlightLines?: number[];
typingSpeed?: number; // characters per frame
theme?: "dark" | "monokai" | "dracula";
fileName?: string; // shown in tab bar
zoomToLine?: number; // zoom into this line mid-animation
}Animation patterns
| Pattern | When to use | Duration |
|---|---|---|
| Typing reveal | Short snippets (< 8 lines) | 3-6s |
| Line-by-line appear | Medium snippets (8-15 lines) | 4-8s |
| Instant + highlight | Comparison (old vs new) | 3-5s |
| Zoom to function | Long file with key area | 5-8s |
Rendering
# Animated video (typing effect)
npx remotion render src/Root.tsx CodeEditor \
"{REMOTION_DIR}/output/code_{segment_id}.mp4" \
--props='{"code":"...","language":"javascript","typingSpeed":3}' \
--width=1280 --height=720
# Static still (faster, no animation)
npx remotion still src/Root.tsx CodeEditor \
"{REMOTION_DIR}/output/code_{segment_id}.png" \
--props='{"code":"...","language":"javascript"}' \
--frame=60Upload the output to VideoDB as VideoAsset (animated) or ImageAsset (still).
Approach 2: Playwright Local HTML (Fallback — Static)
Use ONLY when `REMOTION_AVAILABLE = False`. Renders code as a self-contained HTML page with highlight.js and screenshots it. Produces a static image (no animation), but guarantees accurate, syntax-highlighted code.
def capture_code_display(code: str, language: str, work_dir: str = WORK_DIR) -> str:
"""Render code as a styled HTML page and screenshot it with Playwright."""
import html as html_module
screenshot_dir = Path(work_dir) / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
output_path = str(screenshot_dir / f"code_{uuid.uuid4().hex[:6]}.png")
html_path = str(screenshot_dir / f"code_{uuid.uuid4().hex[:6]}.html")
escaped_code = html_module.escape(code)
html_content = f"""<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/{language}.min.js"></script>
<style>
body {{
margin: 0; padding: 40px 50px;
background: #0d1117;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; box-sizing: border-box;
}}
pre {{
margin: 0; width: 100%;
border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}}
code {{
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace !important;
font-size: 18px !important;
line-height: 1.6 !important;
padding: 30px 35px !important;
}}
.hljs {{ background: #161b22 !important; }}
</style>
</head>
<body>
<pre><code class="language-{language}">{escaped_code}</code></pre>
<script>hljs.highlightAll();</script>
</body>
</html>"""
Path(html_path).write_text(html_content)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1280, "height": 720})
page.goto(f"file://{html_path}")
page.wait_for_load_state("networkidle")
time.sleep(1)
page.screenshot(path=output_path)
browser.close()
return output_pathUpload the PNG to VideoDB as an ImageAsset.
Customizing the theme
Swap the highlight.js stylesheet URL to change theme:
| Theme | CDN path |
|---|---|
| GitHub Dark (default) | styles/github-dark.min.css |
| Monokai | styles/monokai.min.css |
| Dracula | styles/dracula.min.css |
| One Dark | styles/atom-one-dark.min.css |
| Nord | styles/nord.min.css |
Adding a window chrome (macOS dots)
For a more polished look, add a title bar to the HTML:
<div style="background:#161b22; border-radius:12px 12px 0 0; padding:10px 16px; display:flex; gap:8px; align-items:center;">
<div style="width:12px;height:12px;border-radius:50%;background:#ff5f57;"></div>
<div style="width:12px;height:12px;border-radius:50%;background:#febc2e;"></div>
<div style="width:12px;height:12px;border-radius:50%;background:#28c840;"></div>
<span style="color:#8b949e;font-size:13px;margin-left:12px;font-family:system-ui;">{fileName}</span>
</div>Insert this div before the <pre> tag and remove the border-radius from pre.
BANNED Approaches
| Method | Why it fails |
|---|---|
generate_image | AI halluccinates code — garbled syntax, made-up variable names, unreadable text |
generate_video | Same problem as images, but animated garbage |
| ray.so / carbon.sh via URL encoding | urllib.parse.quote() corrupts multi-line code with special characters in URL fragments — produces mojibake |
Style Guide
| Property | Value |
|---|---|
| Background | #0d1117 or #1e1e1e |
| Font family | JetBrains Mono, Fira Code, Cascadia Code, Consolas |
| Font size | 18-24px (must be readable at 720p) |
| Line height | 1.6 |
| Max visible lines | 15-20 (scroll/zoom for longer) |
| Syntax highlighting | Always on — never show plain white text |
| Comments | Muted grey (#8b949e) |
| Strings | Soft green or orange |
| Keywords | Purple or blue |
Segment Duration
| Scenario | Duration |
|---|---|
| Short snippet (3-5 lines) | 3-5s |
| Medium snippet (6-12 lines) | 5-8s |
| Long snippet with explanation | 8-10s (split if longer) |
| Before/after comparison | Show "before" 3-4s, hard_cut to "after" 3-4s |
If narration for a code segment exceeds 10s, split into two visual segments: show the first half of the code, then cut to a zoomed/highlighted view of the key lines.
SFX Pairing
- Pop/click when the code first appears (0.2s)
- Typing sounds if using Remotion typing animation (subtle, low volume)
- Whoosh if zooming into a specific function
Combining with Overlays
Code slides benefit from overlays because they're static images held for several seconds:
- Logo badge in top-right corner showing the language/framework logo
- PiP reaction meme at second 3-4 of a long code segment
- Ambient loop (subtle particles) at
opacity=0.15underneath for static screenshots
See overlay-techniques.md for placement rules.
Data Visualization
Animated charts, bar graphs, counters, comparisons, and infographics. Numbers come alive — bars race to their values, lines draw themselves, counters tick up from zero.
The Goal
Data presented visually with animation that makes numbers intuitive. The viewer should understand the data point within 2 seconds of it appearing, and the animation should emphasize the key insight.
When to Use
- Statistics: "73% of developers use AI tools" → animated bar or counter
- Comparisons: "React vs Vue vs Angular adoption" → racing bar chart
- Trends: "Growth over the last 5 years" → line chart drawing itself
- Rankings: "Top 5 frameworks by popularity" → horizontal bar chart
- Proportions: "Where the budget goes" → pie chart with slices appearing
Approach 1: Remotion (Best — Animated)
Requires: REMOTION_AVAILABLE = True (set during Phase 0). If True, you MUST use Remotion for data viz — animated charts are dramatically more engaging than static images. See remotion-setup.md for bootstrap and the BarChart/CounterReveal components.
Bar Chart
interface BarChartProps {
bars: Array<{ label: string; value: number; color: string }>;
title?: string;
maxValue?: number; // auto-calculated if omitted
animationStyle: "grow" | "race" | "cascade";
showValues: boolean; // display number on/above each bar
unit?: string; // "%" or "M" or "$"
}Animation patterns:
grow: All bars grow simultaneously from 0 to their valuerace: Bars grow one at a time, left to right, with staggercascade: Bars drop in from the top, one by one
Line Chart
interface LineChartProps {
points: Array<{ x: string; y: number }>;
lineColor: string;
areaFill?: boolean; // fill under the line
drawDuration: number; // frames to complete the line
highlightPoint?: number; // index of point to emphasize
}Counter
interface CounterProps {
targetValue: number;
prefix?: string; // "$" or ""
suffix?: string; // "%" or "M" or "users"
duration: number; // frames to count up
fontSize: number;
color: string;
}Rendering
npx remotion render src/Root.tsx BarChart \
"{REMOTION_DIR}/output/chart_{segment_id}.mp4" \
--props='{"bars":[{"label":"React","value":73,"color":"#61DAFB"}],...}' \
--width=1280 --height=720Approach 2: AI Image (Acceptable — Static)
When Remotion is unavailable, generate a static chart image. Acceptable for simple bar charts and comparisons, but no animation.
chart_img = coll.generate_image(
prompt="Clean bar chart on dark background showing: React 73%, Vue 42%, Angular 31%, Svelte 18%. Blue bars, white labels, minimal professional style, data visualization",
aspect_ratio="16:9",
)Prompt tips:
- Include exact numbers in the prompt
- Specify "dark background" and "minimal professional style"
- Name the chart type (bar chart, pie chart, line chart)
- Keep to 4-6 data points for readability
- AI-generated charts may have inaccurate proportions — acceptable for mood, not for precision
Approach 3: Playwright HTML (Good — Static but Precise)
For accurate charts, render with Chart.js in a local HTML file:
def capture_chart(chart_config_json: str, work_dir: str = WORK_DIR) -> str:
screenshot_dir = Path(work_dir) / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
output_path = str(screenshot_dir / f"chart_{uuid.uuid4().hex[:6]}.png")
html_path = str(screenshot_dir / f"chart_{uuid.uuid4().hex[:6]}.html")
html_content = f"""<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {{ margin:0; background:#0d1117; display:flex; align-items:center;
justify-content:center; height:100vh; }}
canvas {{ max-width:1100px; max-height:600px; }}
</style>
</head>
<body>
<canvas id="chart"></canvas>
<script>
Chart.defaults.color = '#e6edf3';
Chart.defaults.borderColor = '#30363d';
new Chart(document.getElementById('chart'), {chart_config_json});
</script>
</body>
</html>"""
Path(html_path).write_text(html_content)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={{"width": 1280, "height": 720}})
page.goto(f"file://{{html_path}}")
page.wait_for_load_state("networkidle")
time.sleep(1)
page.screenshot(path=output_path)
browser.close()
return output_pathStyle Guide
| Property | Value |
|---|---|
| Background | #0d1117 |
| Grid lines | #30363d (subtle) |
| Text color | #e6edf3 (light grey) |
| Font | System sans-serif, 14-18px for labels |
| Bar colors | Use the topic accent palette (see kinetic-text.md) |
| Max bars | 6-8 for readability |
| Value labels | Always show — the number is the point |
Duration
| Chart type | Duration |
|---|---|
| Single counter | 3-4s |
| Bar chart (3-5 bars) | 4-6s |
| Bar chart (6-8 bars) | 6-8s |
| Line chart | 5-7s |
| Pie chart | 4-6s |
| Comparison (2 items) | 3-5s |
SFX Pairing
- Tick/count sound during counter animations
- Pop when each bar reaches its value
- Rise/swell as a line chart draws upward
- Impact on the key data point (the highest bar, the crossing point)
Combining with Text Overlays
Pair data_viz with a TextAsset callout for the key takeaway:
# Main chart visual
visual_track.add_clip(seg_start, Clip(
asset=VideoAsset(id=chart_video.id, volume=0),
duration=6,
))
# Key stat callout appears 2 seconds into the chart
callout_track.add_clip(seg_start + 2, Clip(
asset=TextAsset(
text="73% adoption",
font=Font(family="Clear Sans", size=48, color="#61DAFB"),
background=Background(color="#000000", opacity=0.7, width=400, height=80),
),
duration=4,
position=Position.top_left,
offset=Offset(x=0.05, y=0.05),
))Diagram & Flow
Animated architecture diagrams, data flow charts, concept maps, and process flows. Boxes appear, arrows draw themselves, labels fade in — the diagram builds itself as the narration explains it.
The Goal
A dark-themed diagram that constructs itself piece by piece, synchronized with narration. The viewer sees the system come together rather than staring at a completed diagram trying to parse it.
When to Use
- Architecture overviews: "Here's how the system works" — microservices, data pipelines
- Process flows: "First X happens, then Y" — deployment pipelines, request lifecycle
- Concept maps: "These three concepts relate like this" — mental models
- Decision trees: "If X then Y, otherwise Z" — algorithm explanation
- Comparison flows: "Old way vs new way" — showing improvement
Approach 1: Remotion SVG (Best — Animated)
Requires: REMOTION_AVAILABLE = True (set during Phase 0). If True, you MUST use Remotion for diagrams — animated build-up is what makes diagrams effective. See remotion-setup.md for bootstrap and the DiagramFlow component.
Build animated SVG-based diagrams that construct themselves piece by piece.
Component structure
Do NOT pass raw pixel coordinates for node positions. The component in remotion-setup.md auto-spaces nodes evenly across the 1280x720 canvas. Just pass nodes in order with id, label, and color — the layout is computed automatically.
interface DiagramFlowProps {
nodes: Array<{
id: string;
label: string;
color?: string; // border + glow color, defaults to #3498DB
}>;
edges: Array<{
from: string;
to: string;
label?: string;
}>;
title?: string; // shown at top center
}Animation sequence
1. Background appears (dark gradient) 2. First node fades in with slight scale overshoot (spring physics) 3. Connection line draws from first node toward second node (SVG path animation) 4. Second node fades in 5. Label on the connection fades in 6. Repeat until diagram is complete 7. Optional: highlight the "key path" with a glow or color change
Style guide
| Property | Value |
|---|---|
| Background | #0d1117 or dark gradient |
| Node shape | Rounded rectangles (rx=12) |
| Node fill | Semi-transparent with border (fill-opacity: 0.15, stroke-width: 2) |
| Node colors | Limit to 3-4 from palette |
| Edge style | Smooth bezier curves, animated dash or draw-in |
| Labels | White text, 16-20px, appear after their element |
| Arrow heads | Small, filled, matching edge color |
| Spring physics | Slight overshoot on node appear (makes it feel alive) |
Color palette
Primary: #3498DB (blue) — main flow
Secondary: #2ECC71 (green) — success path
Warning: #E74C3C (red) — error/failure path
Neutral: #95A5A6 (grey) — background connections
Accent: #F39C12 (orange) — highlighted elementRendering
npx remotion render src/Root.tsx DiagramFlow \
"{REMOTION_DIR}/output/diagram_{segment_id}.mp4" \
--props='{"nodes":[...],"edges":[...]}' \
--width=1280 --height=720Approach 2: AI Image (Acceptable — Static)
When Remotion is unavailable, generate a static diagram image. Rename the visual type to image_scene in your internal tracking (since there's no animation).
diagram_img = coll.generate_image(
prompt="Clean architecture diagram showing [describe the flow], dark background, rounded boxes with labels, arrows connecting them, professional technical diagram style, blue and green color scheme, minimal",
aspect_ratio="16:9",
)Prompt tips for diagram images:
- Always specify "dark background"
- Describe each box and connection explicitly
- Say "professional technical diagram" not "flowchart"
- Limit to 4-6 boxes for readability at 720p
- Specify colors and shapes
Limitations: No animation, no build-up effect, and AI may misplace labels or connections. Acceptable for simple diagrams (3-4 boxes), unreliable for complex ones.
Approach 3: Playwright HTML (Good — Static but Accurate)
Render a diagram using Mermaid.js in a local HTML file:
def capture_diagram(mermaid_code: str, work_dir: str = WORK_DIR) -> str:
import html as html_module
screenshot_dir = Path(work_dir) / "screenshots"
screenshot_dir.mkdir(parents=True, exist_ok=True)
output_path = str(screenshot_dir / f"diagram_{uuid.uuid4().hex[:6]}.png")
html_path = str(screenshot_dir / f"diagram_{uuid.uuid4().hex[:6]}.html")
html_content = f"""<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {{ margin:0; background:#0d1117; display:flex; align-items:center;
justify-content:center; min-height:100vh; }}
</style>
</head>
<body>
<pre class="mermaid">
{mermaid_code}
</pre>
<script>
mermaid.initialize({{ theme: 'dark', startOnLoad: true }});
</script>
</body>
</html>"""
Path(html_path).write_text(html_content)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={{"width": 1280, "height": 720}})
page.goto(f"file://{{html_path}}")
page.wait_for_load_state("networkidle")
time.sleep(2)
page.screenshot(path=output_path)
browser.close()
return output_pathMermaid syntax is straightforward:
graph LR
A[Client] --> B[Load Balancer]
B --> C[Server 1]
B --> D[Server 2]
C --> E[(Database)]
D --> ELayout Patterns
Linear flow (left to right)
[Step 1] → [Step 2] → [Step 3] → [Result]Best for: processes, pipelines, request lifecycle.
Hub and spoke
[Service B]
↑
[Service A] ← [API Gateway] → [Service C]
↓
[Service D]Best for: microservices, API architecture.
Before/After (top and bottom)
[Old Way] ──→ [Problem] ──→ [Pain]
[New Way] ──→ [Solution] ──→ [Win]Best for: showing improvement, migration.
Duration
| Complexity | Nodes | Duration |
|---|---|---|
| Simple | 3-4 | 4-6s |
| Medium | 5-7 | 6-8s |
| Complex | 8+ | 8-10s (split if longer) |
For animated Remotion diagrams, the build-up takes 60-80% of the duration, then the completed diagram holds for the remaining 20-40%.
SFX Pairing
- Pop/click each time a node appears
- Whoosh when an arrow draws between nodes
- Rise/swell when the final "key path" highlights
Kinetic Text
Bold text compositions that fill the screen — stat highlights, section headers, punchy declarations, and takeaway moments. The text IS the visual.
The Goal
Large, impactful text on a stylized background that communicates one idea instantly. Think YouTube thumbnail text but animated into a video frame. The viewer should read it in under 1 second.
When to Use
- Section headers: "#1 structuredClone()" — topic intros in explainer format
- Hook text: "5 Features You're Missing" — opening statement
- Stat highlights: "73% of developers" — a number that deserves the full screen
- Takeaways: "Zero npm installs. All built-in." — closing punch
- Quotes: Key quote from research, centered and bold
Production Approach
Hero Moments: Remotion KineticText Component (Best — Animated)
Use for: hook text, section headers, takeaways, stat highlights — any text that deserves the full screen.
If REMOTION_AVAILABLE = True, use the KineticText component from remotion-setup.md. It supports scale-in, slide-up, spring, and word-by-word animations. Animated text is dramatically more impactful than a TextAsset that just appears.
IMPORTANT: When using Remotion KineticText, the rendered video already contains all text baked into its frames. Do NOT also add a TextAsset overlay for the same segment — this causes ugly double-text where VideoDB's text renders on top of Remotion's. The Remotion video IS the complete visual — no supplementary TextAsset is needed.
npx remotion render src/Root.tsx KineticText \
"{REMOTION_DIR}/output/kinetic_{segment_id}.mp4" \
--props='{"lines":["5 JS Features","You Don'\''t Know"],"animation":"spring","accentColor":"#FFD700"}' \
--width=1280 --height=720Upload the .mp4 to VideoDB as a VideoAsset (mute with volume=0).
Quick Callouts / Lower Thirds: VideoDB TextAsset (Fast — Static)
Use for: data callouts, stat overlays, subtitle-style lower thirds, any text that supplements a main visual (not the main visual itself). Also use when REMOTION_AVAILABLE = False.
Fast, pixel-perfect, composable on the timeline. No rendering step needed.
text_overlay_track.add_clip(seg_start, Clip(
asset=TextAsset(
text="5 JS Features\nYou're Missing",
font=Font(family="Clear Sans", size=60, color="#FFFFFF"),
background=Background(
color="#000000",
opacity=0.7,
width=900,
height=200,
),
),
duration=clip_dur,
position=Position.bottom,
offset=Offset(y=-0.3),
))Background Layer
Kinetic text needs a background image behind it. Options:
AI-generated atmosphere (recommended):
bg_img = coll.generate_image(
prompt="Abstract dark technology background with subtle blue gradient, digital particles, minimalist",
aspect_ratio="16:9",
)
visual_track.add_clip(seg_start, Clip(
asset=ImageAsset(id=bg_img.id),
duration=clip_dur,
fit=Fit.crop,
))Solid dark background (fallback): Set timeline.background = "#0d1117" and don't add a visual clip — the text renders over the dark canvas.
Reuse backgrounds: For TextAsset kinetic text, you can reuse the same dark background if the text overlay is the star — the background is secondary. But for Remotion-rendered kinetic text, the background is baked into the video, so vary it per segment.
Additional Animations: Custom Remotion Component
For more complex animations beyond what KineticText provides (3D rotation, morphing, particle backgrounds), write a custom Remotion component. See remotion-setup.md for how to add new components.
interface KineticTextProps {
lines: string[];
fontSize: number;
staggerDelay: number; // ms between lines appearing
animation: "fade-up" | "scale-in" | "slide-left" | "spring";
}Style Guide
Font Sizing (at 1280x720)
| Text role | Font size | Max words | Lines |
|---|---|---|---|
| Hero stat / section header | 52-72px | 6-8 per line | 1-2 |
| Takeaway / declaration | 48-60px | 8-10 per line | 2-3 |
| Quote | 36-48px | 10-12 per line | 2-4 |
| Subtitle / attribution | 24-32px | unlimited | 1 |
Background Bar
Every kinetic text MUST have a semi-opaque background bar:
| Parameter | Value |
|---|---|
| Opacity | 0.6-0.8 |
| Width | At least 50% of frame width (900px+ at 1280w) |
| Height | Sized to text + padding |
| Color | #000000 (black) |
Color Accents
Use accent colors AND background colors to differentiate topics. Never use the same `bgColor` for all kinetic text segments — it creates visual monotony across the video.
Topic 1: accentColor "#FFD700" (golden) bgColor "#1a0a00" (warm dark brown)
Topic 2: accentColor "#FF4757" (red) bgColor "#1a0005" (dark crimson)
Topic 3: accentColor "#3498DB" (blue) bgColor "#000a1a" (deep navy)
Topic 4: accentColor "#9B59B6" (purple) bgColor "#0d001a" (dark purple)
Topic 5: accentColor "#2ECC71" (green) bgColor "#001a0a" (dark forest)
Hook: accentColor "#FF4757" (red) bgColor "#0d1117" (neutral dark)
Takeaway: accentColor "#FFFFFF" (white) bgColor "#0d1117" (neutral dark)When rendering KineticText with Remotion, pass a different bgColor per topic:
topic_palettes = [
{"accent": "#FFD700", "bg": "#1a0a00"},
{"accent": "#FF4757", "bg": "#1a0005"},
{"accent": "#3498DB", "bg": "#000a1a"},
{"accent": "#9B59B6", "bg": "#0d001a"},
{"accent": "#2ECC71", "bg": "#001a0a"},
]
for i, seg in enumerate(kinetic_text_segments):
palette = topic_palettes[i % len(topic_palettes)]
props = {
"lines": seg["lines"],
"animation": "spring",
"accentColor": palette["accent"],
"bgColor": palette["bg"],
}
# render with Remotion...Position
Known gotcha: TextAsset at Position.center with Background may render invisibly. Always use:
position=Position.bottom,
offset=Offset(y=-0.3), # simulates center-screen placementMaking It Dynamic
Static text for 8-10 seconds feels like a PowerPoint slide. Break it up:
Staggered lines
Show line 1 at t=0, line 2 at t=0.5s (use separate TextAsset clips offset by 0.5-1s).
Multiple text frames
For a takeaway with 5 points, show each point one at a time (5 separate 1-2s segments) rather than all at once.
Background variety
Alternate between 2-3 different background images across kinetic text segments.
Pair with SFX
- Whoosh on section headers (topic intros)
- Rise/swell on stat reveals
- No SFX on takeaway (let the narration carry it)
Duration
| Scenario | Duration |
|---|---|
| Section header ("#1 Feature Name") | Match narration length (typically 5-9s) |
| Hook | Match narration (typically 8-15s, can be longer for hooks) |
| Stat highlight | 3-5s (short, punchy) |
| Takeaway | Match narration (typically 6-10s) |
NEVER Use
generate_imagefor text — TextAsset is faster, guaranteed readable, and composable- Tiny font sizes (below 36px for callouts, below 52px for hero stats)
- White text on a light background (always dark bg or opaque bar)
- More than 3 lines of text (if you need more, it's not kinetic text — it's something else)
Meme & Humor Beats
Inject comedic relief between dense content segments. Memes are 2-4 second reaction images or clips that land on a punchline — they're the pressure release valve in a fast-paced explainer.
The Goal
A full-frame funny image or short clip that makes a developer laugh. Timed precisely to the narration's punchline. Snaps in and out with hard cuts — memes never fade.
When to Use
- After every feature/topic explanation (the reward after learning)
- On a punchline in the narration (
humor_beat: "punchline") - As a setup for a contrasting point (
humor_beat: "setup") - Every 60-90 seconds in explainer format (never let humor drought exceed 90s)
Sourcing Priority (try in order)
Always try real memes first. Real templates land better with developer audiences because they recognize the format.
1. Search asset library → recipes/sourcing/asset-library.md
2. Imgflip / Memegen.link / GIPHY / KYM → recipes/sourcing/meme-sourcing.md
3. AI generate_image (last resort — custom humor only)Read recipes/sourcing/meme-sourcing.md for full API integration details.
When Real Memes Work Best
- Narration maps to a well-known meme format (Drake, This Is Fine, Surprised Pikachu)
- Reaction beat (facepalm, mind blown, celebration) — use GIPHY animated GIFs
- Classic developer humor with recognizable templates
When AI Generation is OK
- Custom/unique humor concept that doesn't map to any existing template
- Very specific scene description unique to the video's topic
- All external APIs failed or returned no results
AI Generation (Last Resort)
meme_img = coll.generate_image(
prompt=seg["visual_direction"],
aspect_ratio="16:9",
)Prompt engineering for AI memes:
[Character/subject] [doing what] [in what context], [art style], [mood/emotion]| Good prompt | Why it works |
|---|---|
| "A confused developer staring at a screen full of legacy code, comic cartoon style, exaggerated expression" | Specific character, action, style |
| "A skeleton sitting on a park bench with a sign 'waiting for IE support to end', dark humor comic illustration" | Classic format adapted |
| Bad prompt | Why it fails |
|---|---|
| "funny meme about JavaScript" | Too vague |
| "drake meme" | AI can't reproduce copyrighted formats |
| "a meme" | Zero specificity |
Fallback: Text-Based Meme
If all sourcing and generation fails, use kinetic text styled as a meme:
text_asset = TextAsset(
text="Lodash?\nNever heard of her.",
font=Font(family="Impact", size=64, color="#FFFFFF"),
background=Background(color="#000000", opacity=0.9, width=900, height=300),
)Timing Rules
humor_beat value | Timing | Effect |
|---|---|---|
"setup" | Show meme as the setup line plays | Viewer sees the joke building |
"punchline" | Flash meme exactly when the punchline lands | Maximum comedic impact |
None (reaction) | Show 1-2s after the statement it reacts to | "Can you believe this?" energy |
Composition Rules
- Duration: 2-3 seconds for images, 3-4 seconds for video memes
- Transition IN: Always
hard_cut— memes snap onto screen - Transition OUT: Always
hard_cut— memes snap off - Never fade into or out of a meme
- Full frame — memes don't share screen space with other visuals
- Fit mode:
Fit.contain(NOTFit.crop) — memes have specific aspect ratios (often square or 4:3) that must not be cropped. Use a dark background to fill the 16:9 letterbox. - SFX: Pair with a comedic sting sound effect at the exact moment the meme appears
# Meme segment composition — use Fit.contain to preserve aspect ratio
visual_track.add_clip(seg_start, Clip(
asset=ImageAsset(id=meme_img.id),
duration=clip_dur, # 2-3 seconds
fit=Fit.contain,
))
sfx_track.add_clip(seg_start, Clip(
asset=AudioAsset(id=comedy_sting.id, volume=0.6),
duration=min(1.0, float(comedy_sting.length)),
))Meme Caption Overlay (when raw template has no captions)
If you sourced a raw meme template (no Imgflip/Memegen.link captioning available), you MUST add caption text via a TextAsset overlay. Uncaptioned memes are contextless and the joke won't land.
# Top caption (meme-style Impact font)
text_track.add_clip(seg_start, Clip(
asset=TextAsset(
text=top_caption.upper(),
font=Font(family="Impact", size=48, color="#FFFFFF"),
background=Background(color="#000000", opacity=0.0, width=1000, height=80),
),
duration=clip_dur,
position=Position.top,
offset=Offset(y=0.05),
))
# Bottom caption
text_track.add_clip(seg_start, Clip(
asset=TextAsset(
text=bottom_caption.upper(),
font=Font(family="Impact", size=48, color="#FFFFFF"),
background=Background(color="#000000", opacity=0.0, width=1000, height=80),
),
duration=clip_dur,
position=Position.bottom,
offset=Offset(y=-0.05),
))Caption priority: Imgflip caption API → Memegen.link → raw template + TextAsset overlay. Never show an uncaptioned template.
Meme Spacing
- Minimum gap: 15 seconds between memes (closer feels like a comedy show, not an explainer)
- Maximum gap: 90 seconds (audience fatigue sets in without humor relief)
- Sweet spot: One meme every 25-40 seconds in explainer format
- For a 3-minute video with 5 topics, that's 5 memes (one per topic, after the explanation)
Quality Checklist
- [ ] Meme is contextually relevant to what was just said
- [ ] A developer would find it funny (not generic humor)
- [ ] Image is clear at 720p — no tiny details
- [ ] Duration is 2-3 seconds (not lingering)
- [ ] Hard cut in and out (no fades)
- [ ] Comedic sting SFX plays on appear
- [ ] Not placed back-to-back with another meme (minimum 15s gap)
Meme as Overlay (PiP Meme)
Sometimes you want humor WITHOUT cutting away from the main visual (e.g., during a long code segment). Use a PiP meme overlay instead:
overlay_track.add_clip(seg_start + 3, Clip(
asset=ImageAsset(id=reaction_img.id),
duration=2,
scale=0.2,
position=Position.bottom_right,
offset=Offset(x=-0.05, y=-0.05),
opacity=0.95,
))See overlay-techniques.md for full PiP rules.