一键导入
video-to-website
Turn a video into a premium scroll-driven animated website with GSAP, canvas frame rendering, and layered animation choreography.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Turn a video into a premium scroll-driven animated website with GSAP, canvas frame rendering, and layered animation choreography.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Use when someone asks to draw a diagram, make an Excalidraw diagram, or build an editable diagram. Default for all diagram requests.
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
A formalized skill for generating hyper-realistic, highly-controlled images using the Nano Banana 2 (Gemini 3.1 Flash) model through parameterized JSON prompting.
Use when creating new skills, optimizing existing skills, or auditing skill quality. Guides skill development following Claude Code official best practices.
基于 SOC 职业分类
| name | video-to-website |
| description | Turn a video into a premium scroll-driven animated website with GSAP, canvas frame rendering, and layered animation choreography. |
Turn a video file into a scroll-driven animated website with animation variety and choreography — multiple animation types working together, not one repeated effect.
The user provides: a video file path (MP4, MOV, etc.) and optionally:
If the user doesn't specify these, ask briefly or use sensible creative defaults.
data-persist="true" keeps final section visible, never disappearsalign-left/align-right), never center. Exception: stats with full dark overlayclip-path: circle() as hero scrolls awayFFmpeg and FFprobe are already installed at C:\Users\nateh\bin\ and on PATH. Do NOT reinstall.
ffprobe -v error -select_streams v:0 -show_entries stream=width,height,duration,r_frame_rate,nb_frames -of csv=p=0 "<VIDEO_PATH>"
Determine resolution, duration, frame rate, total frames. Decide:
mkdir -p frames
ffmpeg -i "<VIDEO_PATH>" -vf "fps=<CALCULATED_FPS>,scale=<WIDTH>:-1" -c:v libwebp -quality 80 "frames/frame_%04d.webp"
After extraction, count frames: ls frames/ | wc -l
project-root/
index.html
css/style.css
js/app.js
frames/frame_0001.webp ...
No bundler. Vanilla HTML/CSS/JS + CDN libraries.
Required structure (in this order):
<!-- 1. Loader: #loader > .loader-brand, #loader-bar, #loader-percent -->
<!-- 2. Fixed header: .site-header > nav with logo + links -->
<!-- 3. Hero: .hero-standalone (100vh, solid bg, word-split heading) -->
<!-- Contains: .section-label, .hero-heading (words in spans), .hero-tagline -->
<!-- Scroll indicator with arrow -->
<!-- 4. Canvas: .canvas-wrap > canvas#canvas (fixed, full viewport) -->
<!-- 5. Dark overlay: #dark-overlay (fixed, full viewport, pointer-events:none) -->
<!-- 6. Marquee(s): .marquee-wrap > .marquee-text (fixed, 12vw font) -->
<!-- 7. Scroll container: #scroll-container (800vh+) -->
<!-- Content sections with data-enter, data-leave, data-animation -->
<!-- Stats section with .stat-number[data-value][data-decimals] -->
<!-- CTA section with data-persist="true" -->
Content section example:
<section class="scroll-section section-content align-left"
data-enter="22" data-leave="38" data-animation="slide-left">
<div class="section-inner">
<span class="section-label">002 / Feature</span>
<h2 class="section-heading">Feature Headline</h2>
<p class="section-body">Description text here.</p>
</div>
</section>
Stats section example:
<section class="scroll-section section-stats"
data-enter="54" data-leave="72" data-animation="stagger-up">
<div class="stats-grid">
<div class="stat">
<span class="stat-number" data-value="24" data-decimals="0">0</span>
<span class="stat-suffix">hrs</span>
<span class="stat-label">Cold retention</span>
</div>
</div>
</section>
CDN scripts (end of body, this order):
<script src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script src="js/app.js"></script>
Use the frontend-design skill for creative, distinctive styling. Key technical patterns:
:root {
--bg-light: #f5f3f0;
--bg-dark: #111111;
--text-on-light: #1a1a1a;
--text-on-dark: #f0ede8;
--font-display: '[DISPLAY FONT]', sans-serif;
--font-body: '[BODY FONT]', sans-serif;
}
/* Side-aligned text zones — product occupies center */
.align-left { padding-left: 5vw; padding-right: 55vw; }
.align-right { padding-left: 55vw; padding-right: 5vw; }
.align-left .section-inner,
.align-right .section-inner { max-width: 40vw; }
position: absolute within scroll container, positioned at midpoint of enter/leave range, transform: translateY(-50%).#999 for important text on light backgrounds. Use #666 minimum for body, var(--text-on-light) for headings.const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true
});
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
Two-phase loading: load first 10 frames immediately (fast first paint), then load remaining frames in background. Show progress bar during load. Hide loader only after all frames are ready.
const IMAGE_SCALE = 0.85; // 0.82-0.90 sweet spot
function drawFrame(index) {
const img = frames[index];
if (!img) return;
const cw = canvas.width, ch = canvas.height;
const iw = img.naturalWidth, ih = img.naturalHeight;
const scale = Math.max(cw / iw, ch / ih) * IMAGE_SCALE;
const dw = iw * scale, dh = ih * scale;
const dx = (cw - dw) / 2, dy = (ch - dh) / 2;
ctx.fillStyle = bgColor; // sampled from frame corners
ctx.fillRect(0, 0, cw, ch);
ctx.drawImage(img, dx, dy, dw, dh);
}
sampleBgColor() every ~20 framesconst FRAME_SPEED = 2.0; // 1.8-2.2, higher = product animation finishes earlier
ScrollTrigger.create({
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const accelerated = Math.min(self.progress * FRAME_SPEED, 1);
const index = Math.min(Math.floor(accelerated * FRAME_COUNT), FRAME_COUNT - 1);
if (index !== currentFrame) {
currentFrame = index;
requestAnimationFrame(() => drawFrame(currentFrame));
}
}
});
Each section reads data-animation and gets a different entrance. Sections with data-persist="true" stay visible once animated in. Position sections absolutely at the midpoint of their enter/leave range with translateY(-50%).
function setupSectionAnimation(section) {
const type = section.dataset.animation;
const persist = section.dataset.persist === "true";
const enter = parseFloat(section.dataset.enter) / 100;
const leave = parseFloat(section.dataset.leave) / 100;
const children = section.querySelectorAll(
".section-label, .section-heading, .section-body, .section-note, .cta-button, .stat"
);
const tl = gsap.timeline({ paused: true });
switch (type) {
case "fade-up":
tl.from(children, { y: 50, opacity: 0, stagger: 0.12, duration: 0.9, ease: "power3.out" });
break;
case "slide-left":
tl.from(children, { x: -80, opacity: 0, stagger: 0.14, duration: 0.9, ease: "power3.out" });
break;
case "slide-right":
tl.from(children, { x: 80, opacity: 0, stagger: 0.14, duration: 0.9, ease: "power3.out" });
break;
case "scale-up":
tl.from(children, { scale: 0.85, opacity: 0, stagger: 0.12, duration: 1.0, ease: "power2.out" });
break;
case "rotate-in":
tl.from(children, { y: 40, rotation: 3, opacity: 0, stagger: 0.1, duration: 0.9, ease: "power3.out" });
break;
case "stagger-up":
tl.from(children, { y: 60, opacity: 0, stagger: 0.15, duration: 0.8, ease: "power3.out" });
break;
case "clip-reveal":
tl.from(children, { clipPath: "inset(100% 0 0 0)", opacity: 0, stagger: 0.15, duration: 1.2, ease: "power4.inOut" });
break;
}
// Play/reverse based on scroll position via ScrollTrigger onUpdate
// If persist is true, never reverse when scrolling past the leave point
}
document.querySelectorAll(".stat-number").forEach(el => {
const target = parseFloat(el.dataset.value);
const decimals = parseInt(el.dataset.decimals || "0");
gsap.from(el, {
textContent: 0,
duration: 2,
ease: "power1.out",
snap: { textContent: decimals === 0 ? 1 : 0.01 },
scrollTrigger: { trigger: el.closest(".scroll-section"), start: "top 70%", toggleActions: "play none none reverse" }
});
});
document.querySelectorAll(".marquee-wrap").forEach(el => {
const speed = parseFloat(el.dataset.scrollSpeed) || -25;
gsap.to(el.querySelector(".marquee-text"), {
xPercent: speed,
ease: "none",
scrollTrigger: { trigger: scrollContainer, start: "top top", end: "bottom bottom", scrub: true }
});
// Fade marquee in/out based on scroll range using opacity transitions
});
function initDarkOverlay(enter, leave) {
const overlay = document.getElementById("dark-overlay");
const fadeRange = 0.04;
ScrollTrigger.create({
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const p = self.progress;
let opacity = 0;
if (p >= enter - fadeRange && p <= enter) opacity = (p - (enter - fadeRange)) / fadeRange;
else if (p > enter && p < leave) opacity = 0.9;
else if (p >= leave && p <= leave + fadeRange) opacity = 0.9 * (1 - (p - leave) / fadeRange);
overlay.style.opacity = opacity;
}
});
}
function initHeroTransition() {
ScrollTrigger.create({
trigger: scrollContainer,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate: (self) => {
const p = self.progress;
// Hero fades out as scroll begins
heroSection.style.opacity = Math.max(0, 1 - p * 15);
// Canvas reveals via expanding circle clip-path
const wipeProgress = Math.min(1, Math.max(0, (p - 0.01) / 0.06));
const radius = wipeProgress * 75; // 0% to 75% of viewport
canvasWrap.style.clipPath = `circle(${radius}% at 50% 50%)`;
}
});
}
npx serve . (or python -m http.server 8000)| Type | Initial State | Animate To | Duration |
|---|---|---|---|
fade-up | y:50, opacity:0 | y:0, opacity:1 | 0.9s |
slide-left | x:-80, opacity:0 | x:0, opacity:1 | 0.9s |
slide-right | x:80, opacity:0 | x:0, opacity:1 | 0.9s |
scale-up | scale:0.85, opacity:0 | scale:1, opacity:1 | 1.0s |
rotate-in | y:40, rotation:3, opacity:0 | y:0, rotation:0, opacity:1 | 0.9s |
stagger-up | y:60, opacity:0 | y:0, opacity:1 | 0.8s |
clip-reveal | clipPath:inset(100% 0 0 0) | clipPath:inset(0% 0 0 0) | 1.2s |
All types use stagger (0.1-0.15s) and ease power3.out (except scale-up: power2.out, clip-reveal: power4.inOut).
Math.max at 1.0) — product clips into header. Use IMAGE_SCALE 0.82-0.90Math.min) — leaves visible border that doesn't match page bgcircle(0% at 50% 50%) → circle(75% at 50% 50%)inset(0 100% 0 0) → inset(0 0% 0 0)inset(100% 0 0 0) → inset(0% 0 0 0)polygon(50% 0%, 50% 0%, 50% 100%, 50% 100%) → polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)file://scrub value, reduce frame countdevicePixelRatio scaling to canvas dimensionslenis.on("scroll", ScrollTrigger.update) is connecteddata-value attribute exists and snap settings match decimal places