Creating Oneshot Hero Landing Pages
An Antigravity skill to design and build video-driven parallax hero landing pages and looping motion showcases.
1. 4-Step Execution Pipeline
Execute every landing page request in this order:
- Step 1: Reference Images: Generate reference images via
generate_image(the built-in Antigravity tool powered by Nano Banana /gemini-3.1-flash-image) and save them into./assets/(e.g../assets/hero_ref.jpg,./assets/showcase_ref.jpg). - Step 2: Video Generation: Execute Python REST calls against
gemini-omni-flash-preview(Section 3) using the reference images to generate 10s 16:9.mp4video files into./assets/. - Step 3: Frontend Implementation: Implement the Hero Video Scrub Scaffold (Section 4) in
index.htmlwith smooth scroll video scrubbing, progress-locked hero text, and clean navigation. - Step 4: Verification & Local Hosting: Launch a local preview server (
python3 -m http.server 3000 --directory <dir>), verify all video assets return200 OK, and inspect the visual layout.
2. Design Guidelines
- Navigation Guidelines:
- Pick ONE Focused Composition:
[Logo]+[2–4 Editorial Links Max](strictly <= 4 links)[Logo]+[Menu / Index Trigger][Logo]+[Brand Tagline or Location]+[Menu][Logo]+[Single Quiet CTA][Split Nav]with[2 Links]Left,[Logo]Center,[2 Links or 1 CTA]Right
- Keep It Simple: If you have 2–4 inline links, do not stack a heavy CTA button and hamburger menu next to them. If using a drawer, keep the bar quiet.
- No Mechanical Numbering or Fake Tickers: Do not prefix mechanical numbers (
01,02,03) onto nav links. Do not add fake GPS coordinates, sensor readings, or status dots (● LIVE).
- Pick ONE Focused Composition:
- No Decorative SVG Icons on Cards or UI:
- Do not place decorative SVG icons, Lucide icons (
<i data-lucide=...>), or icon badge containers inside feature cards, headings, bullet points, or buttons. - Cards, pillars, and sections should rely on typography, whitespace, and photography/video. (SVGs are fine for a logo mark or modal close button).
- Do not place decorative SVG icons, Lucide icons (
- No Toy Widgets, Interactive Simulators, or Mini-Games:
- Landing pages are editorial showcases.
- Do not generate character stat calculators, RPG inventory boxes, physics toy widgets, interactive sandbox cards, or
canvas-confettiparticle scripts. - For playful brands or games, express personality through color palettes, typography, and video motion rather than playable JavaScript mini-games or calculators.
- No Unrequested Audio Buttons or Sound Synthesizers:
- Do not generate audio toggle buttons (
🔊), background sound generators, or Web Audio API synthesizers (AudioContext, oscillators).
- Do not generate audio toggle buttons (
- Clean Typography Over Badges:
- Default to unbordered typography, subtle font weights, fine line accents (
—), or roman numerals (I,II) instead of encasing words inside rounded pill badges ([ BADGE ]). - When a tag or table cell is necessary:
- 1–2 Words Maximum: Do not put long descriptions or sentences into compact badges (e.g. use
LEO, notLEO Station Rendezvous). - Include
whitespace-nowrap: Keep badge text on a single line so it never breaks into awkward stacks. - Single-Row Filter Bars: Filter buttons should stay on a single unbroken row (
flex items-center gap-2 overflow-x-auto whitespace-nowrap). - Responsive Tables: Data tables should use responsive scroll wrappers (
overflow-x-auto min-w-[650px]).
- 1–2 Words Maximum: Do not put long descriptions or sentences into compact badges (e.g. use
- Default to unbordered typography, subtle font weights, fine line accents (
- Media & Hero Video Rules:
- The hero must use a
<video id="hero-video">element. Do not substitute video with<canvas>image slideshows or static<img>tags. - Supplemental showcases generated by Omni should be looping
<video autoplay muted loop playsinline>elements. - No Visible Scrubber HUDs: Do not render timeline scrub bars, progress percentage meters (
"00%"), or cockpit dials over media.
- The hero must use a
- Progress-Locked Hero Text:
- Calculate hero text opacity and translation directly from scroll progress in JavaScript (see Section 4). Scrolling reveals text, pausing keeps it readable, reversing hides it.
- Do not use time-based CSS transitions (
transition: opacity 0.7s) on hero scroll text that cause text to lag or scroll past unread. (CSS animations are fine in standard body sections).
- Layout & Stacking Rules:
- Root containers (
html,body,#root, wrappers) must haveoverflow: visible(oroverflow-x: clip). Do not useoverflow-hiddenoroverflow-x-hiddenon parent wrappers, which breaks CSSposition: sticky. - Hero videos should stretch edge-to-edge (
w-full h-full object-cover), not styled as small rounded UI cards.
- Root containers (
- Typography & Color:
- Use modern Google Fonts (e.g. Italiana, Cormorant Garamond, Plus Jakarta Sans, Space Grotesk).
- Use curated color palettes (e.g. terracotta, basalt, obsidian, titanium, alabaster) rather than default generic rainbow colors.
3. Omni Video Python Generator
Install dependencies:
pip install google-genai pillow
Save generated .mp4 files directly to ./assets/ using this script:
import base64
import io
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
from PIL import Image
def generate_omni_video(
api_key: str,
img_path: str,
prompt: str,
output_mp4_path: str
) -> bool:
ctx = ssl.create_default_context()
url = "https://generativelanguage.googleapis.com/v1beta/interactions"
try:
img = Image.open(img_path).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="PNG")
b64_img = base64.b64encode(buf.getvalue()).decode("utf-8")
except Exception as e:
print(f"Error opening image '{img_path}': {e}", file=sys.stderr)
return False
payload = {
"model": "models/gemini-omni-flash-preview",
"generation_config": {"thinking_level": "high"},
"response_format": {"type": "video", "aspect_ratio": "16:9", "duration": "10s"},
"input": [
{"type": "text", "text": prompt + " No text, no titles, no subtitles, no overlays."},
{"type": "image", "mime_type": "image/png", "data": b64_img}
]
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={
"Content-Type": "application/json",
"x-goog-api-key": api_key
}
)
try:
with urllib.request.urlopen(req, context=ctx) as response:
res = json.loads(response.read().decode("utf-8"))
for step in res.get("steps", []):
for item in step.get("content", []):
if item.get("type") == "video" or "video" in str(item.get("mime_type")):
os.makedirs(os.path.dirname(output_mp4_path) or ".", exist_ok=True)
with open(output_mp4_path, "wb") as f:
f.write(base64.b64decode(item["data"]))
return True
print("No video data found in response payload.", file=sys.stderr)
return False
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
print(f"API HTTP Error {e.code} ({e.reason}): {error_body}", file=sys.stderr)
return False
except urllib.error.URLError as e:
print(f"Network / URL Error: {e.reason}", file=sys.stderr)
return False
except Exception as e:
print(f"Unexpected error during video generation: {e}", file=sys.stderr)
return False
4. Hero Video Scrub Scaffold (HTML + CSS + JS)
Use this architecture for the sticky 400vh video scrubbing engine:
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brand — Hero Showcase</title>
<script src="https://cdn.tailwindcss.com"></script>
<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=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,400&family=Plus+Jakarta+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
html, body {
margin: 0; padding: 0;
overflow-x: clip; /* Clean clipping without breaking position:sticky */
background: #08090d; color: #f7f5f0;
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
}
.phase-text { will-change: opacity, transform; }
header.nav-scrolled {
background: rgba(8, 9, 13, 0.85);
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 1rem; padding-bottom: 1rem;
}
</style>
</head>
<body class="relative antialiased">
<!-- Navigation goes here -->
<!-- 400vh Sticky Scroll Hero Video Engine -->
<div id="hero-section" class="relative h-[400vh] w-full">
<div class="sticky top-0 w-full h-screen overflow-hidden flex items-center justify-center">
<video id="hero-video" class="absolute inset-0 w-full h-full object-cover pointer-events-none" playsinline muted preload="auto"></video>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/30 pointer-events-none"></div>
<!-- Phase 0 Typography (0% - 25% Scroll) -->
<div id="phase-0" class="phase-text absolute bottom-20 left-8 md:left-20 max-w-xl pointer-events-none">
<h1 class="font-serif text-5xl md:text-7xl font-light text-white leading-tight">First Headline Statement.</h1>
</div>
<!-- Phase 1 Typography (30% - 60% Scroll) -->
<div id="phase-1" class="phase-text absolute top-1/3 right-8 md:right-24 max-w-lg pointer-events-none opacity-0">
<h2 class="font-serif text-4xl md:text-5xl font-light text-white leading-snug">Second Narrative Phase.</h2>
</div>
<!-- Phase 2 Typography (65% - 95% Scroll) -->
<div id="phase-2" class="phase-text absolute bottom-24 left-8 md:left-20 max-w-lg pointer-events-none opacity-0">
<h2 class="font-serif text-4xl md:text-5xl font-light text-white leading-snug">Third Concluding Statement.</h2>
</div>
</div>
</div>
<!-- Supplemental Content & Looping Showcases -->
<section class="py-32 px-8 md:px-20 max-w-7xl mx-auto">
<!-- Body Content Here -->
</section>
<!-- Video Scrubbing Engine & Progress-Locked Text -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const heroVideo = document.getElementById('hero-video');
const heroSection = document.getElementById('hero-section');
const nav = document.querySelector('header');
const phase0 = document.getElementById('phase-0');
const phase1 = document.getElementById('phase-1');
const phase2 = document.getElementById('phase-2');
// 1. In-Memory Blob Preloader with explicit video.load()
fetch('./assets/hero.mp4')
.then(res => {
if (!res.ok) throw new Error('Video network response was not ok');
return res.blob();
})
.then(blob => {
heroVideo.src = URL.createObjectURL(blob);
heroVideo.load();
})
.catch(() => {
heroVideo.src = './assets/hero.mp4';
heroVideo.load();
});
// 2. Normalized Scroll Progress
let targetProgress = 0, currentProgress = 0;
function updateScroll() {
const rect = heroSection.getBoundingClientRect();
const max = rect.height - window.innerHeight;
if (max > 0) targetProgress = Math.max(0, Math.min(1, -rect.top / max));
if (nav) {
if (window.scrollY > 80) nav.classList.add('nav-scrolled');
else nav.classList.remove('nav-scrolled');
}
}
window.addEventListener('scroll', updateScroll, { passive: true });
window.addEventListener('resize', updateScroll);
// 3. Progress-Locked Text Opacity Calculator
function calcOpacity(progress, enterStart, enterEnd, exitStart, exitEnd) {
if (progress < enterStart || progress > exitEnd) return 0;
if (progress < enterEnd) return (progress - enterStart) / (enterEnd - enterStart);
if (progress > exitStart) return Math.max(0, 1 - (progress - exitStart) / (exitEnd - exitStart));
return 1.0;
}
// 4. Animation Loop
function scrubLoop() {
currentProgress += (targetProgress - currentProgress) * 0.15;
// Seek Video
if (heroVideo.duration && !heroVideo.seeking) {
const targetTime = currentProgress * heroVideo.duration;
if (Math.abs(heroVideo.currentTime - targetTime) > 0.015) {
heroVideo.currentTime = targetTime;
}
}
// Lock Typography directly to scroll progress
const op0 = calcOpacity(targetProgress, 0.0, 0.05, 0.20, 0.28);
const op1 = calcOpacity(targetProgress, 0.28, 0.38, 0.55, 0.65);
const op2 = calcOpacity(targetProgress, 0.65, 0.75, 0.90, 0.98);
if (phase0) { phase0.style.opacity = op0.toFixed(3); phase0.style.transform = `translateY(${-targetProgress * 40}px)`; }
if (phase1) { phase1.style.opacity = op1.toFixed(3); phase1.style.transform = `translateY(${(0.45 - targetProgress) * 30}px)`; }
if (phase2) { phase2.style.opacity = op2.toFixed(3); phase2.style.transform = `translateY(${(0.80 - targetProgress) * 30}px)`; }
requestAnimationFrame(scrubLoop);
}
requestAnimationFrame(scrubLoop);
});
</script>
</body>
</html>