cosmicstack-labs/github-repo-tour
Generate a 15-30 second scrolling video tour of any GitHub repository page with ElevenLabs AI narration and word-by-word subtitle sync. Captures a full-page mobile-viewport screenshot, scrolls top-to-bottom with GSAP, and burns synced subtitles onto the final MP4 using HyperFrames CLI.
npx skills add https://github.com/cosmicstack-labs/mercury-agent-skills --skill github-repo-tour
> Depends on: website-to-hyperframes — load it before starting. Its 6-step workflow and reference files apply here. This skill specializes that workflow for single-page scrolling tours with narration and visible subtitles.
A 1080x1920 portrait video (Instagram Reels format) that:
| Decision | Value | Why |
|----------|-------|-----|
| Dimensions | 1080x1920 | Instagram Reels primary format |
| Duration | 15–30 seconds | Sweet spot for Shorts/Reels engagement |
| Narration | Drives timing | Scroll speed, beat durations, and subtitle timing all derive from the audio |
| Subtitles | Word-by-word highlight | Each word lights up as the narrator says it — Karaoke-style captions |
| Phone viewport | Full-screen scroll, no bezel | The GitHub page fills the entire 1080x1920 canvas |
| Output folder | github-promos/<repo>-tour/ | Sibling to github-repo-promo projects |
github-promos/
<repo>-tour/
index.html ← main HyperFrames composition
package.json ← from hyperframes init
hyperframes.config.json ← HyperFrames config
SCRIPT.md ← narration script with timestamps
assets/
repo-page.png ← Full-page GitHub screenshot (dark mode, mobile viewport)
narration.mp3 ← ElevenLabs voiceover
renders/ ← generated MP4s
Use web-search or fetch_url to get repo metadata:
Then use the screenshot skill to capture a full-page, dark-mode, mobile-viewport screenshot:
use_skill("screenshot")
// URL: https://github.com/{owner}/{repo}
// Full page, dark mode
// Viewport: 390px wide (iPhone 14 mobile) — the screenshot should
// capture the full height of the page at mobile width
// Save as: github-promos/<repo>-tour/assets/repo-page.png
Get the screenshot dimensions (you need the pixel height for scroll math):
sips -g pixelHeight github-promos/<repo>-tour/assets/repo-page.png
sips -g pixelWidth github-promos/<repo>-tour/assets/repo-page.png
Write a 15–30 second script that describes what the viewer sees as the scroll progresses. The narrator walks the viewer through the page:
[0s–4s] "This is {repo}. {one-liner description}."
[4s–8s] "Here's the README — installation is one command."
[8s–13s] "Scrolling through the feature list — {mention 2-3 visible features}."
[13s–18s] "{stars} stars, {forks} forks, and a growing community."
[18s–23s] "Star it on GitHub. Link in bio."
[23s–25s] "Follow for more."
Rules for the script:
curl -s -X POST "https://api.elevenlabs.io/v1/text-to-speech/AZnzlk1XvdvUeBnXmlld" \
-H "xi-api-key: $ELEVENLABS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "FULL_SCRIPT_HERE",
"model_id": "eleven_multilingual_v2",
"voice_settings": {
"stability": 0.35,
"similarity_boost": 0.8,
"style": 0.25,
"use_speaker_boost": true
}
}' \
--output github-promos/<repo>-tour/assets/narration.mp3
Verify the file exists and has size > 0.
After generating audio, extract word-level timestamps:
Option A: ElevenLabs timestamps (returned with the API response if output_format: "mp3" is not the only output — check the API docs for aligned output).
Option B: Use ffmpeg + Whisper (local transcription with word timestamps):
# Install whisper if needed
pip install openai-whisper
# Transcribe with word timestamps
whisper github-promos/<repo>-tour/assets/narration.mp3 \
--model base \
--output_format json \
--output_dir github-promos/<repo>-tour/assets/ \
--word_timestamps True
The output gives you words[] with start and end timestamps for each word. Map these timestamps to the scroll positions.
Create index.html following the HyperFrames composition architecture:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=1080,height=1920"/>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
/* ── Root & Canvas ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
#root {
position: relative;
width: 1080px;
height: 1920px;
background: #0d1117;
font-family: 'Inter', sans-serif;
overflow: hidden;
}
/* ── Scrolling Screenshot ── */
.scroll-container {
position: absolute;
inset: 0;
width: 1080px;
height: 1920px;
overflow: hidden;
}
.scroll-content {
position: absolute;
top: 0;
left: 0;
width: 1080px;
height: VAR_IMG_H px; /* actual screenshot height */
}
/* ── Subtitle Overlay ── */
.subtitle-bar {
position: absolute;
bottom: 180px;
left: 50%;
transform: translateX(-50%);
width: 1000px;
text-align: center;
z-index: 100;
}
.subtitle-word {
display: inline-block;
font-size: 42px;
font-weight: 700;
color: rgba(255,255,255,0.35);
padding: 0 4px;
transition: color 0.08s, transform 0.08s;
}
.subtitle-word.active {
color: #FFFFFF;
transform: scale(1.08);
}
.subtitle-word.past {
color: rgba(255,255,255,0.65);
}
/* ── Scroll Progress Bar ── */
.scroll-progress-track {
position: absolute;
right: 16px;
top: 100px;
width: 5px;
height: 1720px;
border-radius: 3px;
background: rgba(255,255,255,0.1);
z-index: 50;
}
.scroll-progress-thumb {
position: absolute;
top: 0;
left: 0;
width: 5px;
height: VAR_THUMB_H px;
border-radius: 3px;
background: linear-gradient(180deg, #58a6ff, #3fb950);
z-index: 51;
}
/* ── CTA Overlay (Beat 6-7) ── */
.cta-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(13,17,23,0.92);
backdrop-filter: blur(8px);
z-index: 200;
opacity: 0;
}
.cta-title {
font-size: 56px;
font-weight: 800;
color: #e6edf3;
text-align: center;
}
.cta-subtitle {
font-size: 32px;
color: #8b949e;
margin-top: 16px;
}
.cta-button {
margin-top: 40px;
padding: 18px 48px;
background: #238636;
color: #e6edf3;
font-size: 28px;
font-weight: 700;
border-radius: 999px;
box-shadow: 0 0 30px rgba(35,134,54,0.5);
}
.follow-pill {
margin-top: 24px;
padding: 12px 28px;
background: rgba(22,27,34,0.8);
border: 1px solid #30363d;
border-radius: 999px;
font-size: 22px;
color: #e6edf3;
font-weight: 600;
}
</style>
</head>
<body>
<div id="root"
data-composition-id="{repo}-tour"
data-width="1080"
data-height="1920"
data-start="0"
data-duration="{total_seconds}">
<audio id="narration-audio"
src="assets/narration.mp3"
data-start="0"
data-duration="{total_seconds}"
preload="auto"></audio>
<!-- Scrolling screenshot (full-canvas) -->
<div class="scroll-container">
<img class="scroll-content" src="assets/repo-page.png" />
</div>
<!-- Scroll progress bar -->
<div class="scroll-progress-track">
<div class="scroll-progress-thumb" id="scroll-thumb"></div>
</div>
<!-- Subtitle overlay -->
<div class="subtitle-bar" id="subtitle-bar">
<!-- Words injected by JS, one <span class="subtitle-word"> per word -->
</div>
<!-- CTA overlay (final 5s) -->
<div class="cta-overlay" id="cta-overlay">
<div class="cta-title">Star on GitHub</div>
<div class="cta-subtitle">{repo description}</div>
<div class="cta-button">github.com/{owner}/{repo}</div>
<div class="follow-pill">@githubprojects</div>
</div>
</div>
<script>
// ── Scroll Math ──────────────────────────────────────────
var IMG_H = /* actual screenshot height */;
var CANVAS_H = 1920;
var MAX_SCROLL = IMG_H - CANVAS_H;
var THUMB_TRACK = 1720;
var THUMB_H = Math.round(THUMB_TRACK * (CANVAS_H / IMG_H));
var THUMB_TRAVEL = THUMB_TRACK - THUMB_H;
function thumbTop(scrollY) {
return (Math.abs(scrollY) / MAX_SCROLL) * THUMB_TRAVEL;
}
// ── Word Timestamps ─────────────────────────────────────
// Paste the word-level timestamps from Step 3 here.
// Format: [word, startSeconds, endSeconds]
var WORDS = [
// ["This", 0.0, 0.3],
// ["is", 0.3, 0.5],
// ["mercury-skills", 0.5, 1.0],
// ...
];
var TOTAL_DURATION = /* total seconds from audio */;
// ── Build Subtitle Spans ────────────────────────────────
var subtitleBar = document.getElementById("subtitle-bar");
var wordSpans = [];
WORDS.forEach(function(w, i) {
var span = document.createElement("span");
span.className = "subtitle-word";
span.textContent = w[0];
subtitleBar.appendChild(span);
wordSpans.push(span);
});
// ── GSAP Timeline ───────────────────────────────────────
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({paused: true});
// Phase 1: Scroll (0s to ~80% of total duration)
var scrollEnd = TOTAL_DURATION * 0.80;
var ctaStart = TOTAL_DURATION * 0.80;
// Scroll from top to bottom with easing
tl.to(".scroll-content", {
y: -MAX_SCROLL,
duration: scrollEnd,
ease: "none",
onUpdate: function() {
var progress = this.progress();
var thumbTop = progress * THUMB_TRAVEL;
gsap.set("#scroll-thumb", { top: thumbTop });
}
}, 0);
// Phase 2: Word-by-word subtitle highlighting
var currentWordIndex = -1;
WORDS.forEach(function(w, i) {
tl.call(function() {
// Deactivate previous word
if (currentWordIndex >= 0 && wordSpans[currentWordIndex]) {
wordSpans[currentWordIndex].classList.remove("active");
wordSpans[currentWordIndex].classList.add("past");
}
// Activate current word
wordSpans[i].classList.add("active");
currentWordIndex = i;
}, null, w[1]);
});
// Phase 3: CTA overlay fades in at 80%
tl.to("#cta-overlay", { opacity: 1, duration: 0.5, ease: "power2.out" }, ctaStart);
// Phase 4: Dim subtitle bar during CTA
tl.to("#subtitle-bar", { opacity: 0.3, duration: 0.5 }, ctaStart);
// Phase 5: Scroll progress bar fades out
tl.to(".scroll-progress-track", { opacity: 0, duration: 0.3 }, ctaStart);
window.__timelines["{repo}-tour"] = tl;
</script>
</body>
</html>
cd github-promos/<repo>-tour
# If no HyperFrames project config exists:
npx hyperframes init --example blank --non-interactive
# Lint
npx hyperframes lint
# Snapshot to verify visually
npx hyperframes snapshot
# Draft render
npx hyperframes render --quality draft
# Verify the output MP4 in renders/
ls -la renders/
# If everything looks good, render final
npx hyperframes render --quality standard
Send the final MP4 to the user. Ask if they want:
--quality high)The subtitle system is the core differentiator of this skill. Here's how word-by-word sync works:
The narration audio is transcribed with word-level timestamps (from ElevenLabs or Whisper). Each word gets a [word, startSeconds, endSeconds] entry in the WORDS array.
Every word in the subtitle bar has three CSS states:
| State | Class | Visual |
|-------|-------|--------|
| Upcoming | .subtitle-word | Dim white rgba(255,255,255,0.35) |
| Active | .subtitle-word.active | Bright white #FFFFFF, slightly scaled 1.08 |
| Past | .subtitle-word.past | Medium white rgba(255,255,255,0.65) |
The GSAP timeline calls a function at each word's startSeconds to transition currentWord → active and previousWord → past. This creates a Karaoke-style word-by-word highlight that's perfectly synced to the audio.
Words are laid out with display: inline-block and natural text wrapping. For longer scripts (20+ seconds), consider splitting into 2–3 line groups that appear/disappear between scroll segments:
// Group words by scroll segment
var SEGMENTS = [
{ start: 0, end: 5, words: [0, 12] }, // Beat 1: Hero
{ start: 5, end: 15, words: [13, 35] }, // Beat 2: Scroll
{ start: 15, end: 20, words: [36, 50] }, // Beat 3: Stats
{ start: 20, end: 25, words: [51, 65] }, // Beat 4: CTA
{ start: 25, end: 28, words: [66, 72] }, // Beat 5: Follow
];
Each segment fades in its words and fades out the previous segment's words, preventing subtitle overload during long scrolls.
The GitHub screenshot fills the entire 1080x1920 canvas — no phone bezel, no sidebar, no letterboxing. The viewer sees the repo page exactly as it appears on a mobile phone in dark mode.
The scroll is a single gsap.to() animation from y: 0 to y: -MAX_SCROLL over the first 80% of the video duration. This means:
The easing is "none" (linear) by default because the subtitles handle the pace perception — the narrator's word highlights create the feeling of "stopping" at interesting sections even though the scroll is continuous.
If you want variable-speed scroll (slower at interesting sections, faster at filler):
// Replace the single gsap.to with a timeline of segments:
tl.to(".scroll-content", { y: SCROLL_30PCT, duration: scrollEnd * 0.35, ease: "power2.out" }, 0);
tl.to(".scroll-content", { y: SCROLL_70PCT, duration: scrollEnd * 0.40, ease: "power1.out" }, scrollEnd * 0.35);
tl.to(".scroll-content", { y: -MAX_SCROLL, duration: scrollEnd * 0.25, ease: "power3.in" }, scrollEnd * 0.75);
Map the slow segments to the sections the narrator calls out by name.
The scroll speed and CTA timing are derived FROM the narration audio, not the other way around.
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 assets/narration.mp3WORDS[] with start/end per word.data-duration on #root to the measured total duration.data-duration on <audio> to the measured total duration.TOTAL_DURATION in JS to the measured total duration.TOTAL_DURATION:Never hard-code a duration and then try to fit the narration into it. The narration decides the duration.
| Token | Value |
|-------|-------|
| Background | #0d1117 |
| Text | #e6edf3 |
| Muted | #8b949e |
| Borders | #30363d |
| Blue accent | #58a6ff |
| Green accent | #3fb950 |
| CTA green | #238636 |
| Gold | #f9c513 |
Change data-width="1920" data-height="1080" and adjust:
.scroll-container to 1920x1080bottom: 80px, width: 1800pxAdd a second <audio> element with a low-volume ambient track (royalty-free). Set its data-start and data-duration to match the narration.
Create separate compositions per repo, then use npx hyperframes compose to concatenate them with transitions between.
ELEVENLABS_API_KEY environment variablenpm install -g hyperframes)ffmpeg in PATH (for duration measurement)whisper installed if using local transcription (Option B in Step 3)screenshot skill available (for capturing the GitHub page)| Issue | Fix |
|-------|-----|
| Screenshot too short for 15s scroll | Capture at a smaller viewport width (390px) — the mobile layout makes pages taller |
| Audio duration mismatch | Use ffprobe to get exact duration; set TOTAL_DURATION from the measured value |
| Subtitles out of sync | Re-transcribe with whisper --word_timestamps True; verify timestamps against audio playback |
| Scroll too fast | Reduce scroll duration percentage (e.g. 80% → 70%) and give more time to CTA |
| Scroll too slow | Increase scroll duration percentage or use variable-speed segments |
| Words overlap content | Add a semi-transparent bar behind the subtitle area: background: rgba(13,17,23,0.6) on .subtitle-bar |
| HyperFrames init fails | Delete hyperframes.config.json and re-run npx hyperframes init --example blank |
| CTA not visible | Check z-index: 200 on .cta-overlay and ensure opacity: 0 initial state is in CSS |
Required: website-to-hyperframes — load it before starting. Its 6-step framework (capture → brand → brief → storyboard → build → validate) governs this skill's workflow. This skill specializes it for scrolling repo tours with synced subtitles on a full-screen mobile viewport.
Suggested: screenshot skill — for capturing the full-page GitHub repository screenshot in dark mode at mobile viewport width.
Take cosmicstack-labs/github-repo-tour from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip, npm, npx.
Without those the skill loads but fails at the first command.