Переглянути джерело

feat(templates): github 双榜横屏接入动效层

动机: 上一轮仅竖屏改版加了动效, 横屏除场景淡入淡出外
全部静态登场, 两个方向观感割裂; 且淡入淡出在 Sequence
交界处露出黑底 (黑闪), 横屏同样存在。

新行为: 横屏分支复用共享动效原语——标题上浮、标签逐个
弹出、今日/本周+N 与 stars 数字滚动、顶部双线与分隔线
从左画出、社媒预览图揭示+缓慢沉降、右列三卡依次升起;
两个方向均移除场景级淡入淡出, 节奏交给元素入场。

旧行为: 横屏布局静态呈现, 仅场景 8 帧淡入淡出。

布局几何零改动 (绝对定位/列宽/卡片内容不变);
typecheck 17/17 与 templates 构建通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lkatzey 4 тижнів тому
батько
коміт
ae6c12b818

+ 90 - 73
packages/templates/src/github-trending/index.tsx

@@ -1,11 +1,9 @@
 import React from "react";
 import {
-  useCurrentFrame,
   useVideoConfig,
   AbsoluteFill,
   Img,
   staticFile,
-  interpolate,
 } from "remotion";
 import type { RemotionScene } from "../Root";
 import type { RenderSegmentExtension } from "@pipeline/shared";
@@ -13,6 +11,7 @@ import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
 import { SubtitleBar } from "../base/components/subtitle-bar";
 import { Watermark } from "../base/components/watermark";
 import { BoardPortrait, PORTRAIT_FOOTER_RESERVED } from "../base/components/board-portrait";
+import { Rise, Pop, DrawLine, CountUp, RevealImage, useRise } from "../base/components/motion";
 import { formatCount } from "@pipeline/shared";
 
 interface GithubTrendingSceneProps {
@@ -27,29 +26,17 @@ interface GithubTrendingSceneProps {
 const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
   scene,
   sceneIndex,
-  title,
   channelName,
 }) => {
-  const frame = useCurrentFrame();
-  const { width, height, durationInFrames } = useVideoConfig();
+  const { width, height } = useVideoConfig();
   const isPortrait = height > width;
   const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
   const github: RenderSegmentExtension | undefined =
     scene.extension?.type === "github-trending" ? scene.extension : undefined;
 
-  const sceneDur = durationInFrames;
-  // Landscape keeps the legacy soft fade. Portrait cuts hard between scenes:
-  // Sequences don't overlap, so the old fade dipped both scenes through the
-  // composition's black base (a black flash at every boundary) — and the
-  // board's staggered element entrances now carry the motion anyway.
-  const fadeOpacity = isPortrait
-    ? 1
-    : interpolate(
-        frame,
-        [0, 8, sceneDur - 8, sceneDur],
-        [0, 1, 1, 0],
-        { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
-      );
+  // No scene-level fade on either orientation: Sequences don't overlap, so a
+  // fade dips both scenes through the composition's black base (black flash at
+  // every boundary). The staggered element entrances carry the motion instead.
 
   // Named images from the template extension (resolved to filenames by the
   // renderer). Object, not positional array — socialPreview / starHistory.
@@ -70,14 +57,14 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
   const repo = github?.repo;
   const language = repo?.language || "";
   const languageColor = repo?.languageColor || palette.accent;
-  const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
+  const stars = repo?.stars;
   const license = repo?.license || "";
   const fullName = repo?.fullName || scene.title || "";
-  const gainLabel = repo?.todayStars != null ? `今日 +${formatCount(repo.todayStars)}` : "";
+  const gain = repo?.todayStars;
 
   if (!github) {
     return (
-      <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
+      <AbsoluteFill style={{ background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
         <div style={{
           position: "absolute", inset: 0, display: "flex",
           alignItems: "center", justifyContent: "center", padding: pad,
@@ -94,37 +81,53 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
 
   const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
     <div>
-      <div style={{
-        fontSize,
-        fontWeight: 800,
-        color: "#1e293b",
-        fontFamily: "Noto Sans SC",
-        lineHeight: 1.15,
-        letterSpacing: "-0.01em",
-      }}>
-        {fullName}
-      </div>
+      <Rise delay={2}>
+        <div style={{
+          fontSize,
+          fontWeight: 800,
+          color: "#1e293b",
+          fontFamily: "Noto Sans SC",
+          lineHeight: 1.15,
+          letterSpacing: "-0.01em",
+        }}>
+          {fullName}
+        </div>
+      </Rise>
       <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
         {language && (
-          <Tag accent={palette.accent} size={tagSize}>
-            <ColorDot color={languageColor} size={dotSize} />
-            {language}
-          </Tag>
+          <Pop delay={12}>
+            <Tag accent={palette.accent} size={tagSize}>
+              <ColorDot color={languageColor} size={dotSize} />
+              {language}
+            </Tag>
+          </Pop>
+        )}
+        {gain != null && (
+          <Pop delay={17}>
+            <Tag accent={palette.accent} size={tagSize} emphasized>
+              <CountUp value={gain} format={(n) => `今日 +${formatCount(n)}`} delay={18} duration={32} />
+            </Tag>
+          </Pop>
+        )}
+        {stars != null && (
+          <Pop delay={22}>
+            <Tag accent={palette.accent} size={tagSize}>
+              <CountUp value={stars} format={formatCount} delay={23} duration={34} />
+              {" stars"}
+            </Tag>
+          </Pop>
         )}
-        {gainLabel && (
-          <Tag accent={palette.accent} size={tagSize} emphasized>
-            {gainLabel}
-          </Tag>
+        {license && (
+          <Pop delay={27}>
+            <Tag accent={palette.accent} size={tagSize}>{license}</Tag>
+          </Pop>
         )}
-        {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
-        {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
       </div>
     </div>
   );
 
   return (
     <AbsoluteFill style={{
-      opacity: fadeOpacity,
       background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
     }}>
       {/* Subtle paper ruling (the light counterpart of the old dark grid) */}
@@ -137,9 +140,10 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
         backgroundSize: "80px 80px",
       }} />
 
-      {/* Top accent rule — a double editorial rule, magazine style */}
-      <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 6, background: palette.accent }} />
-      <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
+      {/* Top accent rule — a double editorial rule, magazine style; the heavy
+          band draws itself in, the hairline follows */}
+      <DrawLine delay={0} duration={18} color={palette.accent} height={6} style={{ position: "absolute", top: 0, left: 0 }} />
+      <DrawLine delay={8} duration={18} color={`${palette.accent}55`} height={1} style={{ position: "absolute", top: 10, left: 0 }} />
 
       {isPortrait ? (
         // Portrait: the shared animated Hero board (base/components/board-portrait) —
@@ -179,7 +183,9 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
           </div>
         )
       ) : (
-        // Landscape: absolute positioning (single-line 80px header).
+        // Landscape: absolute positioning (single-line 80px header). Elements
+        // enter staggered — title rises, tags pop in one by one, divider and
+        // rules draw, images reveal, sections rise in sequence.
         <>
           <div style={{
             position: "absolute",
@@ -195,9 +201,9 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
             top: landscapeDividerY,
             left: pad,
             right: pad,
-            height: 1,
-            background: "rgba(30,41,59,0.18)",
-          }} />
+          }}>
+            <DrawLine delay={14} duration={22} color="rgba(30,41,59,0.18)" />
+          </div>
 
           <LandscapeContent
             contentTop={landscapeContentTop}
@@ -262,23 +268,29 @@ export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, si
   }} />
 );
 
-const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number }> = ({ children, flex }) => (
-  <div style={{
-    flex,
-    width: flex ? undefined : "100%",
-    height: "auto",
-    background: "#ffffff",
-    border: "1px solid rgba(30,41,59,0.12)",
-    borderRadius: 12,
-    overflow: "hidden",
-    display: "flex",
-    alignItems: "center",
-    justifyContent: "center",
-    boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
-  }}>
-    {children}
-  </div>
-);
+// Frame rises in (delay-driven); images inside reveal via RevealImage.
+const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number; delay?: number }> = ({ children, flex, delay = 0 }) => {
+  const { opacity, y } = useRise(delay, 30);
+  return (
+    <div style={{
+      flex,
+      width: flex ? undefined : "100%",
+      height: "auto",
+      background: "#ffffff",
+      border: "1px solid rgba(30,41,59,0.12)",
+      borderRadius: 12,
+      overflow: "hidden",
+      display: "flex",
+      alignItems: "center",
+      justifyContent: "center",
+      boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
+      opacity,
+      transform: `translateY(${y}px)`,
+    }}>
+      {children}
+    </div>
+  );
+};
 
 const Section: React.FC<{
   title: string;
@@ -286,8 +298,10 @@ const Section: React.FC<{
   body: string;
   flex?: number;
   size?: "default" | "large";
-}> = ({ title, accent, body, flex, size = "default" }) => {
+  delay?: number;
+}> = ({ title, accent, body, flex, size = "default", delay = 0 }) => {
   const isLarge = size === "large";
+  const { opacity, y } = useRise(delay, 40);
   // Strip trailing punctuation that would look awkward if rendered at the
   // end of the clamped text (LLM occasionally leaves a hanging 。,,;).
   const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
@@ -303,6 +317,8 @@ const Section: React.FC<{
       flexDirection: "column",
       gap: 12,
       minHeight: 0,
+      opacity,
+      transform: `translateY(${y}px)`,
     }}>
       <div style={{
         display: "flex",
@@ -388,12 +404,13 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
           overflow: "hidden",
         }}>
           {socialPreview && (
-            <ImageFrame>
-              <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+            <ImageFrame delay={18}>
+              {/* Reveal: clip wipes open while the image settles (Ken Burns-ish) */}
+              <RevealImage filename={socialPreview.filename} delay={26} duration={24} />
             </ImageFrame>
           )}
           {starHistory && (
-            <ImageFrame>
+            <ImageFrame delay={44}>
               <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
             </ImageFrame>
           )}
@@ -407,9 +424,9 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
         flexDirection: "column",
         gap: 20,
       }}>
-        <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
-        <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} />
-        <Section title="建议" accent={palette.accent} body={github.review} />
+        <Section title="项目亮点" accent={palette.accent} body={github.highlights} delay={30} />
+        <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} delay={40} />
+        <Section title="建议" accent={palette.accent} body={github.review} delay={50} />
       </div>
     </div>
   );

+ 100 - 76
packages/templates/src/github-weekly/index.tsx

@@ -1,11 +1,9 @@
 import React from "react";
 import {
-  useCurrentFrame,
   useVideoConfig,
   AbsoluteFill,
   Img,
   staticFile,
-  interpolate,
 } from "remotion";
 import type { RemotionScene } from "../Root";
 import type { RenderSegmentExtension } from "@pipeline/shared";
@@ -13,6 +11,7 @@ import { GITHUB_WEEKLY_PALETTE } from "../base/theme/colors";
 import { SubtitleBar } from "../base/components/subtitle-bar";
 import { Watermark } from "../base/components/watermark";
 import { BoardPortrait, PORTRAIT_FOOTER_RESERVED } from "../base/components/board-portrait";
+import { Rise, Pop, DrawLine, CountUp, RevealImage, useRise } from "../base/components/motion";
 import { formatCount } from "@pipeline/shared";
 
 /**
@@ -41,8 +40,7 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
   sceneIndex,
   channelName,
 }) => {
-  const frame = useCurrentFrame();
-  const { width, height, durationInFrames } = useVideoConfig();
+  const { width, height } = useVideoConfig();
   const isPortrait = height > width;
   const palette = GITHUB_WEEKLY_PALETTE[sceneIndex % GITHUB_WEEKLY_PALETTE.length];
   const github: RenderSegmentExtension | undefined =
@@ -53,19 +51,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
       : undefined;
   const isRecap = scene.extension?.type === "github-weekly-recap";
 
-  const sceneDur = durationInFrames;
-  // Landscape keeps the legacy soft fade. Portrait cuts hard between scenes:
-  // Sequences don't overlap, so the old fade dipped both scenes through the
-  // composition's black base (a black flash at every boundary) — and the
-  // board's staggered element entrances now carry the motion anyway.
-  const fadeOpacity = isPortrait
-    ? 1
-    : interpolate(
-        frame,
-        [0, 8, sceneDur - 8, sceneDur],
-        [0, 1, 1, 0],
-        { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
-      );
+  // No scene-level fade on either orientation: Sequences don't overlap, so a
+  // fade dips both scenes through the composition's black base (black flash at
+  // every boundary). The staggered element entrances carry the motion instead.
 
   // Named images from the shared board extension (resolved to filenames by the
   // renderer). Object, not positional array — socialPreview / starHistory.
@@ -86,24 +74,20 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
   const repo = github?.repo;
   const language = repo?.language || "";
   const languageColor = repo?.languageColor || palette.accent;
-  const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
+  const stars = repo?.stars;
   // Under the weekly board, todayStars carries the WEEKLY gain. The recap
   // instead tags the scene with the repo's featured date ("2026-08-12" →
   // "推荐 · 8月12日"); its snapshot numbers are feature-time, not this week's.
   const featuredLabel = github?.coveredDate
     ? `推荐 · ${github.coveredDate.replace(/^\d{4}-/, "").replace("-", "月").replace(/^0/, "")}日`
     : "";
-  const weekGainLabel = isRecap
-    ? featuredLabel
-    : repo?.todayStars != null
-      ? `本周 +${formatCount(repo.todayStars)}`
-      : "";
+  const gain = isRecap ? undefined : repo?.todayStars;
   const license = repo?.license || "";
   const fullName = repo?.fullName || scene.title || "";
 
   if (!github) {
     return (
-      <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
+      <AbsoluteFill style={{ background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
         <div style={{
           position: "absolute", inset: 0, display: "flex",
           alignItems: "center", justifyContent: "center", padding: pad,
@@ -120,37 +104,63 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
 
   const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
     <div>
-      <div style={{
-        fontSize,
-        fontWeight: 800,
-        color: "#1e293b",
-        fontFamily: "Noto Sans SC",
-        lineHeight: 1.15,
-        letterSpacing: "-0.01em",
-      }}>
-        {fullName}
-      </div>
+      <Rise delay={2}>
+        <div style={{
+          fontSize,
+          fontWeight: 800,
+          color: "#1e293b",
+          fontFamily: "Noto Sans SC",
+          lineHeight: 1.15,
+          letterSpacing: "-0.01em",
+        }}>
+          {fullName}
+        </div>
+      </Rise>
       <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
         {language && (
-          <Tag accent={palette.accent} size={tagSize}>
-            <ColorDot color={languageColor} size={dotSize} />
-            {language}
-          </Tag>
+          <Pop delay={12}>
+            <Tag accent={palette.accent} size={tagSize}>
+              <ColorDot color={languageColor} size={dotSize} />
+              {language}
+            </Tag>
+          </Pop>
+        )}
+        {isRecap ? (
+          featuredLabel && (
+            <Pop delay={17}>
+              <Tag accent={palette.accent} size={tagSize} emphasized>
+                {featuredLabel}
+              </Tag>
+            </Pop>
+          )
+        ) : (
+          gain != null && (
+            <Pop delay={17}>
+              <Tag accent={palette.accent} size={tagSize} emphasized>
+                <CountUp value={gain} format={(n) => `本周 +${formatCount(n)}`} delay={18} duration={32} />
+              </Tag>
+            </Pop>
+          )
         )}
-        {weekGainLabel && (
-          <Tag accent={palette.accent} size={tagSize} emphasized>
-            {weekGainLabel}
-          </Tag>
+        {stars != null && (
+          <Pop delay={22}>
+            <Tag accent={palette.accent} size={tagSize}>
+              <CountUp value={stars} format={formatCount} delay={23} duration={34} />
+              {" stars"}
+            </Tag>
+          </Pop>
+        )}
+        {license && (
+          <Pop delay={27}>
+            <Tag accent={palette.accent} size={tagSize}>{license}</Tag>
+          </Pop>
         )}
-        {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
-        {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
       </div>
     </div>
   );
 
   return (
     <AbsoluteFill style={{
-      opacity: fadeOpacity,
       background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
     }}>
       {/* Subtle paper ruling (the light counterpart of the daily board's grid) */}
@@ -163,9 +173,10 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
         backgroundSize: "80px 80px",
       }} />
 
-      {/* Top accent rule — a double editorial rule, magazine style */}
-      <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 6, background: palette.accent }} />
-      <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
+      {/* Top accent rule — a double editorial rule, magazine style; the heavy
+          band draws itself in, the hairline follows */}
+      <DrawLine delay={0} duration={18} color={palette.accent} height={6} style={{ position: "absolute", top: 0, left: 0 }} />
+      <DrawLine delay={8} duration={18} color={`${palette.accent}55`} height={1} style={{ position: "absolute", top: 10, left: 0 }} />
 
       {isPortrait ? (
         // Portrait: the shared animated Hero board (base/components/board-portrait)
@@ -204,7 +215,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
           </div>
         )
       ) : (
-        // Landscape: absolute positioning (single-line 80px header).
+        // Landscape: absolute positioning (single-line 80px header). Elements
+        // enter staggered — title rises, tags pop in one by one, divider and
+        // rules draw, images reveal, sections rise in sequence.
         <>
           <div style={{
             position: "absolute",
@@ -220,9 +233,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
             top: landscapeDividerY,
             left: pad,
             right: pad,
-            height: 1,
-            background: "rgba(30,41,59,0.18)",
-          }} />
+          }}>
+            <DrawLine delay={14} duration={22} color="rgba(30,41,59,0.18)" />
+          </div>
 
           <LandscapeContent
             contentTop={landscapeContentTop}
@@ -286,23 +299,29 @@ export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, si
   }} />
 );
 
-const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number }> = ({ children, flex }) => (
-  <div style={{
-    flex,
-    width: flex ? undefined : "100%",
-    height: "auto",
-    background: "#ffffff",
-    border: "1px solid rgba(30,41,59,0.12)",
-    borderRadius: 12,
-    overflow: "hidden",
-    display: "flex",
-    alignItems: "center",
-    justifyContent: "center",
-    boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
-  }}>
-    {children}
-  </div>
-);
+// Frame rises in (delay-driven); the social preview inside reveals via RevealImage.
+const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number; delay?: number }> = ({ children, flex, delay = 0 }) => {
+  const { opacity, y } = useRise(delay, 30);
+  return (
+    <div style={{
+      flex,
+      width: flex ? undefined : "100%",
+      height: "auto",
+      background: "#ffffff",
+      border: "1px solid rgba(30,41,59,0.12)",
+      borderRadius: 12,
+      overflow: "hidden",
+      display: "flex",
+      alignItems: "center",
+      justifyContent: "center",
+      boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
+      opacity,
+      transform: `translateY(${y}px)`,
+    }}>
+      {children}
+    </div>
+  );
+};
 
 const Section: React.FC<{
   title: string;
@@ -310,8 +329,10 @@ const Section: React.FC<{
   body: string;
   flex?: number;
   size?: "default" | "large";
-}> = ({ title, accent, body, flex, size = "default" }) => {
+  delay?: number;
+}> = ({ title, accent, body, flex, size = "default", delay = 0 }) => {
   const isLarge = size === "large";
+  const { opacity, y } = useRise(delay, 40);
   // Strip trailing punctuation that would look awkward if rendered at the
   // end of the clamped text (LLM occasionally leaves a hanging 。,,;).
   const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
@@ -327,6 +348,8 @@ const Section: React.FC<{
       flexDirection: "column",
       gap: 12,
       minHeight: 0,
+      opacity,
+      transform: `translateY(${y}px)`,
     }}>
       <div style={{
         display: "flex",
@@ -412,12 +435,13 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
           overflow: "hidden",
         }}>
           {socialPreview && (
-            <ImageFrame>
-              <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
+            <ImageFrame delay={18}>
+              {/* Reveal: clip wipes open while the image settles (Ken Burns-ish) */}
+              <RevealImage filename={socialPreview.filename} delay={26} duration={24} />
             </ImageFrame>
           )}
           {starHistory && (
-            <ImageFrame>
+            <ImageFrame delay={44}>
               <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
             </ImageFrame>
           )}
@@ -431,9 +455,9 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
         flexDirection: "column",
         gap: 20,
       }}>
-        <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
-        <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} />
-        <Section title="建议" accent={palette.accent} body={github.review} />
+        <Section title="项目亮点" accent={palette.accent} body={github.highlights} delay={30} />
+        <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} delay={40} />
+        <Section title="建议" accent={palette.accent} body={github.review} delay={50} />
       </div>
     </div>
   );