
Video To Landing Page
- 31 installs
- 262 repo stars
- Updated July 11, 2026
- hoodini/ai-agents-skills
Turn a video into an Apple-style scroll-driven landing page where scrolling advances extracted frames through a sticky hero above copy and a CTA.
About
Extracts N evenly-spaced frames via ffmpeg and builds a self-contained HTML page with a sticky hero that swaps frames on scroll plus sections below. A developer uses it to make a scroll-frame landing page from a video.
- ffmpeg frame extraction with count chosen by duration
- Sticky hero with requestAnimationFrame scroll listener; static-host ready
Video To Landing Page by the numbers
- 31 all-time installs (skills.sh)
- +5 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,450 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hoodini/ai-agents-skills --skill video-to-landing-pageAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 31 |
|---|---|
| repo stars | ★ 262 |
| Last updated | July 11, 2026 |
| Repository | hoodini/ai-agents-skills ↗ |
What it does
Turn a video into an Apple-style scroll-driven landing page where scrolling advances extracted frames through a sticky hero above copy and a CTA.
Files
Video → Scroll-Driven Landing Page
Build a cinematic landing page where the hero is a video playing on scroll instead of time — like Apple product pages or the GitHub Copilot landing. Frames are extracted from the source video and swapped as the user scrolls; below the sticky hero, normal-scroll sections carry copy and CTA.
When to invoke
- User drops a video and asks for a landing page, scrolling site, scroll-frame hero, or website from a video
- Mentions of "Apple-style scroll", "GitHub Copilot landing", "scrub-on-scroll", "frame on scroll"
- Hebrew equivalents: "דף נחיתה מסרטון", "אתר עם גלילה", "סרטון בגלילה"
Save location
Default: ~/Documents/yuv-projects/landings/<slug>/ — always save landing pages here so you can find them again. Create the parent directory if missing.
mkdir -p ~/Documents/yuv-projects/landings
cd ~/Documents/yuv-projects/landings
# Build <slug>/ here.Final path: ~/Documents/yuv-projects/landings/<slug>/. Tell the user where the landing lives at the end of every build.
---
Workflow (8 steps)
1. Probe the source — ffprobe for duration, fps, dimensions, audio. 2. Pick frame count — default 80. Floor 40 (choppy if lower), ceiling 160 (heavy page). Choose by duration: ≤10s → 50, 10–30s → 80, 30–60s → 120, >60s → 160. 3. Decide output directory — default ~/Documents/yuv-projects/landings/<slug>/. The <slug> is short, derived from the video name or the topic. Override only on explicit request. 4. Extract frames — run references/extract-frames.py <source-video> <output-dir> [count]. Outputs output-dir/frames/f_0001.jpg… with consistent JPG quality 82 and a max-width of 1920 (downscaled if source is larger). 5. Copy and customise references/landing-template.html into output-dir/index.html:
- Replace the
__FRAMES__placeholder with<img>tags for each frame (the build script does this for you when you pass--build-html). - Fill in
__HEADLINE__,__TAGLINE__,__CTA_TEXT__,__CTA_HREF__, sections. - For Hebrew content: set
<html lang="he" dir="rtl">and swap fonts to Rubik/Assistant (seereferences/design-notes.md).
6. Optimise frames — the build script already scales to 1920px max-width. If the page total weight is > 12 MB, drop quality to 75 or reduce frame count. 7. Preview — python -m http.server inside the output directory and open http://localhost:8000. Scroll feels buttery on a decent machine. 8. Deploy — drop the folder on Vercel/Netlify/Cloudflare Pages. No build step needed.
The scroll mechanic
- Outer scroller
.scroll-stageheight set in viewport units (default600vh= scroll 5 screens to traverse all frames). - Sticky hero
position: sticky; top: 0; height: 100vhcontaining all frames absolutely positioned and hidden except the active one. - Scroll listener computes
progress = scrolledIntoStage / scrollableHeightclamped 0–1. idx = floor(progress * (N - 1))— swap the.activeclass to that frame.- Wrap the listener in
requestAnimationFrameto keep 60fps.
Design rules
- If the project is YUV.AI-branded (Yuval personally, his portfolio, his brand): call
yuv-design-systemand lock it to a mode by medium — - Neon mode (DEFAULT for YUV.AI web): hot pink (
#FF1464) + neon cyan (#00E5FF) + white or rich black canvas. Anton + Inter (English) / Rubik + Assistant (Hebrew). 0px or 999px radii. - Warm Editorial mode: only for Hope, Marcus, bigcats.ai, practical.yuv.ai — pink + yellow + bone, off-white never pure
#fff. - Never use Decks mode (Fly High purple/yellow) for a landing page — that palette is reserved for slide decks only.
- If the project is not YUV.AI-branded: do not apply yuv-design-system. Pick a palette that fits the source video's mood. Anton + Inter remain reasonable defaults but aren't mandatory.
- Hero overlays should be subtle — a vignette and a single big headline; don't drown the frame.
- Below-hero sections: light canvas (white in Neon mode, off-white in Warm Editorial, bone if going generic-warm), large Anton heading, body copy in Inter at 18–22px, CTA as a sharp pill (
border-radius: 999px) — pink fill in Neon mode, brand-appropriate color otherwise. - For Hebrew: respect RTL, use Rubik 900 for headlines, Assistant 400/500 for body.
File references
| File | Purpose |
|---|---|
references/extract-frames.py | ffmpeg-based frame extractor; optionally builds the full HTML in one go |
references/landing-template.html | Self-contained landing-page skeleton with the scroll mechanic |
references/design-notes.md | Typography, palette, and motion defaults |
Design Notes — Scroll-Driven Landing Page
Defaults align with the yuv-design-system skill (Fly High purple by default). A project-specific brand system always wins — only fall back to these when no brand is specified.
Typography
| Language | Display / Headlines | Body / UI |
|---|---|---|
| English | Anton (400) | Inter (400 / 600 / 700) |
| Hebrew | Rubik (900) | Assistant (400 / 600) |
- Hero headline: 48px → 160px responsive (
clamp(48px, 8vw, 160px)). - Section headings: 40px → 110px (
clamp(40px, 5.4vw, 110px)). - Body: 17–22px responsive, line-height 1.6.
Palette
| Token | Hex | Use |
|---|---|---|
--pink | #ff3da6 | CTAs, primary accents, scroll-cue lines |
--yellow | #ffd24a | Section headlines, secondary accents |
--bone | #f5f0d0 | Body text on dark, hero headline |
--bg | #0a0a12 | Page background (near-black, not pure #000) |
--ink | #14141c | CTA text colour on pink |
Do NOT use #FFFFFF (use --bone), #000000 (use --bg), or 8–12px border radii (use 0 or 999px).
Hero overlay
- Subtle. The video frame IS the hero — text just labels it.
- Vignette:
radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,0.55) 100%)plus
top/bottom darken bands.
- Headline gets a soft text-shadow
0 10px 40px rgba(0,0,0,0.6)so it reads over any frame.
Motion
- Scroll is the primary motion device. Don't auto-play other animations in the hero — it
fights the scroll-frame.
- Scroll-cue line at the bottom of the hero: 1px line that scales 0.2 → 1 with a 1.8s easing
loop. Disappears after first scroll (opacity: 0 once scrolledIntoStage > 100).
- Below-hero sections: GSAP-style reveals are fine (scroll-triggered fade + 20px lift). Keep
durations 0.5–0.8s and easing soft (power3.out).
Layout
- Hero is full-bleed (
100vw × 100vh). - Below-hero sections: padding
18vh 8vw, max-width on text columns62ch. - Sharp lines between sections — a 1px
rgba(245, 240, 208, 0.08)divider.
Hebrew adjustments
<html lang="he" dir="rtl">body { font-family: "Assistant", sans-serif; }
.headline { font-family: "Rubik", sans-serif; font-weight: 900; }
.section h2 { font-family: "Rubik", sans-serif; font-weight: 900; }
.cta { font-family: "Rubik", sans-serif; font-weight: 900; }Keep the same palette + scroll mechanic.
"""Extract evenly-spaced frames from a video for the scroll-driven landing page.
Usage:
python extract-frames.py <video> <output_dir> [count]
python extract-frames.py <video> <output_dir> [count] --build-html
Examples:
python extract-frames.py demo.mp4 landing-demo
python extract-frames.py demo.mp4 landing-demo 120 --build-html
"""
import json, os, subprocess, sys, shutil
if len(sys.argv) < 3:
print(__doc__)
sys.exit(1)
VIDEO = sys.argv[1]
OUT_DIR = sys.argv[2]
COUNT = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else 80
BUILD_HTML = "--build-html" in sys.argv
# Probe duration
probe = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
VIDEO,
],
capture_output=True, text=True, check=True,
)
duration = float(probe.stdout.strip())
frames_dir = os.path.join(OUT_DIR, "frames")
os.makedirs(frames_dir, exist_ok=True)
# Extract N evenly-spaced frames at quality 2 (JPG ~q82), downscaled to max 1920 wide.
print(f"Extracting {COUNT} frames from {VIDEO} ({duration:.1f}s)...")
for i in range(COUNT):
t = (i + 0.5) * duration / COUNT # midpoint of each segment
out_path = os.path.join(frames_dir, f"f_{i+1:04d}.jpg")
subprocess.run(
[
"ffmpeg", "-y", "-ss", f"{t:.3f}", "-i", VIDEO,
"-vframes", "1",
"-vf", "scale='min(1920,iw)':-2",
"-q:v", "3",
out_path,
],
capture_output=True, check=True,
)
print(f"Wrote {COUNT} frames to {frames_dir}/")
if BUILD_HTML:
# Locate landing-template.html (sibling to this script)
template_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "landing-template.html")
with open(template_path, "r", encoding="utf-8") as f:
html = f.read()
frame_tags = "\n ".join(
f'<img class="frame{" active" if i == 0 else ""}" loading="eager" src="frames/f_{i+1:04d}.jpg" data-i="{i}" alt="" />'
for i in range(COUNT)
)
html = html.replace("__FRAMES__", frame_tags)
html = html.replace("__FRAME_COUNT__", str(COUNT))
out_html = os.path.join(OUT_DIR, "index.html")
with open(out_html, "w", encoding="utf-8") as f:
f.write(html)
print(f"Wrote {out_html}")
print("Customise the placeholders: __HEADLINE__, __TAGLINE__, __CTA_TEXT__, __CTA_HREF__")
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>__HEADLINE__</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Anton&family=Inter:wght@400;600;700&family=Rubik:wght@900&family=Assistant:wght@400;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--pink: #ff3da6;
--yellow: #ffd24a;
--bone: #f5f0d0;
--bg: #0a0a12;
--ink: #14141c;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
background: var(--bg);
color: var(--bone);
font-family: "Inter", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
/* ---- scroll stage ---- */
.scroll-stage {
position: relative;
min-height: 600vh; /* tweak: more vh = slower scrub */
}
.hero {
position: sticky;
top: 0;
width: 100%;
height: 100vh;
overflow: hidden;
background: #000;
}
.frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: none;
}
.frame.active { display: block; }
.hero-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 6vw;
pointer-events: none;
background:
radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 35%, rgba(0, 0, 0, 0.55) 100%),
linear-gradient(180deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0) 70%, rgba(0, 0, 0, 0.45) 100%);
}
.headline {
font-family: "Anton", sans-serif;
font-weight: 400;
font-size: clamp(48px, 8vw, 160px);
line-height: 0.95;
letter-spacing: 0.02em;
color: var(--bone);
text-transform: uppercase;
text-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}
.tagline {
font-family: "Inter", sans-serif;
font-weight: 400;
font-size: clamp(16px, 1.6vw, 26px);
margin-top: 1.2em;
max-width: 64ch;
opacity: 0.9;
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
}
.scroll-cue {
position: absolute;
bottom: 4vh;
left: 50%;
transform: translateX(-50%);
font-family: "Inter", sans-serif;
font-size: 13px;
letter-spacing: 0.3em;
text-transform: uppercase;
opacity: 0.7;
}
.scroll-cue::after {
content: "";
display: block;
width: 1px;
height: 36px;
background: var(--bone);
margin: 12px auto 0;
animation: cue 1.8s ease-in-out infinite;
transform-origin: top;
}
@keyframes cue {
0%, 100% { transform: scaleY(0.2); opacity: 0.3; }
50% { transform: scaleY(1); opacity: 0.9; }
}
/* ---- below-hero sections ---- */
.below {
position: relative;
z-index: 2;
background: var(--bg);
}
.section {
padding: 18vh 8vw;
border-top: 1px solid rgba(245, 240, 208, 0.08);
}
.section h2 {
font-family: "Anton", sans-serif;
font-weight: 400;
font-size: clamp(40px, 5.4vw, 110px);
line-height: 0.95;
letter-spacing: 0.01em;
color: var(--yellow);
text-transform: uppercase;
margin-bottom: 0.4em;
}
.section p {
font-family: "Inter", sans-serif;
font-weight: 400;
font-size: clamp(17px, 1.3vw, 22px);
line-height: 1.6;
max-width: 62ch;
opacity: 0.86;
}
.cta {
display: inline-block;
margin-top: 2.4em;
padding: 1em 2em;
background: var(--pink);
color: var(--ink);
font-family: "Anton", sans-serif;
font-size: clamp(18px, 1.6vw, 28px);
text-transform: uppercase;
letter-spacing: 0.06em;
border: 0;
cursor: pointer;
}
/* ---- responsive niceties ---- */
@media (max-width: 720px) {
.scroll-stage { min-height: 450vh; }
.headline { font-size: 14vw; }
.section { padding: 12vh 6vw; }
}
</style>
</head>
<body>
<div class="scroll-stage">
<div class="hero">
__FRAMES__
<div class="hero-overlay">
<h1 class="headline">__HEADLINE__</h1>
<p class="tagline">__TAGLINE__</p>
</div>
<div class="scroll-cue">scroll</div>
</div>
</div>
<main class="below">
<section class="section">
<h2>About</h2>
<p>Replace this block with the story you want to tell. The hero above scrubs through the video as the visitor scrolls — keep your copy down here brief and punchy.</p>
<a class="cta" href="__CTA_HREF__">__CTA_TEXT__</a>
</section>
</main>
<script>
(function () {
var frames = Array.from(document.querySelectorAll(".frame"));
var N = frames.length;
if (N === 0) return;
var stage = document.querySelector(".scroll-stage");
var currentIdx = 0;
var ticking = false;
function update() {
var rect = stage.getBoundingClientRect();
var scrollable = rect.height - window.innerHeight;
if (scrollable <= 0) {
ticking = false;
return;
}
var progress = Math.max(0, Math.min(1, -rect.top / scrollable));
var idx = Math.min(N - 1, Math.floor(progress * (N - 1) + 0.0001));
if (idx !== currentIdx) {
frames[currentIdx].classList.remove("active");
frames[idx].classList.add("active");
currentIdx = idx;
}
ticking = false;
}
window.addEventListener(
"scroll",
function () {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
},
{ passive: true },
);
window.addEventListener("resize", function () {
window.requestAnimationFrame(update);
});
update();
})();
</script>
</body>
</html>
Setup — Prerequisites for video-to-landing-page
Minimal install. The output is a static HTML page — no build step, no node deps required for the page itself.
Required
| Tool | Version | Purpose |
|---|---|---|
| FFmpeg | any recent | Frame extraction from the source video |
| Python | ≥ 3.8 | Running the extract-frames.py build helper |
Install commands
Windows (PowerShell)
winget install Python.Python.3.12
winget install Gyan.FFmpegmacOS (with Homebrew)
brew install python@3.12 ffmpegLinux
sudo apt update
sudo apt install -y python3 ffmpegVerify
ffmpeg -version
python --versionTo preview the generated page
A browser is enough. For local testing:
cd landing-<videoname>
python -m http.server 8000 # then open http://localhost:8000To deploy
Any static host. Drag-and-drop the output folder onto Vercel / Netlify / Cloudflare Pages.
# Vercel CLI example
npx vercel --prod ./landing-<videoname>Browser support
The scroll-frame mechanic uses:
- CSS
position: sticky requestAnimationFrame<img>swap
All evergreen browsers (Chrome 90+, Safari 14+, Firefox 90+, Edge 90+). Mobile works.