Sfoglia il codice sorgente

feat(templates): github 双榜竖屏 Hero 板式与动效改版

动机: 竖屏模板只有场景级淡入淡出、双图并排小图在手机上
不可读, 字幕黑块在浅色纸面上过重。

新行为:
- 新增 motion.tsx 动效原语 (Rise/Pop/DrawLine/CountUp/
  RevealImage) 与 board-portrait.tsx 竖屏 Hero 板式:
  全宽社媒预览大图+星史小卡叠角, 标签逐个弹出、数字滚动、
  分隔线画出; daily-report (排名数字/数据条 chips) 与
  weekly-magazine (胶囊 chips/左编辑条) 双皮肤
- SubtitleBar 新增 glass-light 白玻璃变体, 竖屏启用;
  字幕保留带 500px 缩至 340px
- 两封面竖屏加交错入场, 日报封面数据条数字滚动
- 修复场景交界黑闪: 淡入淡出作用于不重叠 Sequence 露出
  黑底, 竖屏改硬切 (横屏不变)

旧行为: 竖屏为双图并排+两卡静态排版, 仅场景淡入淡出,
与横屏共用绝对定位布局。

横屏分支零改动; 已验证 typecheck/build 全绿并实渲染
1080x1920 样片抽帧检查。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lkatzey 4 settimane fa
parent
commit
2c32d35683

+ 47 - 6
packages/templates/src/Root.tsx

@@ -11,6 +11,7 @@ import GithubWeeklyScene from "./github-weekly/index";
 import GithubDailyPickScene from "./github-daily-pick/index";
 import { THEMES } from "./base/theme/colors";
 import { Watermark } from "./base/components/watermark";
+import { Rise, Pop, CountUp } from "./base/components/motion";
 
 // Re-export the render-time contract so legacy imports (`import { RemotionScene
 // } from "../Root"`) keep resolving during the migration. New code should import
@@ -251,6 +252,11 @@ const CoverScene: React.FC<{
     const topLangs = [...langStats.entries()]
       .sort((a, b) => b[1].count - a[1].count)
       .slice(0, 6);
+    // Portrait-only entrance reinforcement (same rule as the trending cover):
+    // stagger kicker → masthead → week range → language pills → dek in.
+    // Layout is unchanged; landscape passes through untouched.
+    const ent = (delay: number) => (isPortrait ? { delay } : {});
+    const pop = (delay: number) => (isPortrait ? { delay } : { delay: -9999 });
 
     return (
       <AbsoluteFill style={{
@@ -280,6 +286,7 @@ const CoverScene: React.FC<{
           padding: isPortrait ? "72px 64px 150px" : "58px 80px 130px",
         }}>
           {/* Issue kicker above the masthead — journal convention */}
+          <Rise {...ent(0)}>
           <div style={{
             fontSize: isPortrait ? 30 : 26, fontWeight: 600, color: primary,
             fontFamily: "Noto Sans SC", letterSpacing: "0.42em",
@@ -287,7 +294,9 @@ const CoverScene: React.FC<{
           }}>
             Weekly Issue
           </div>
+          </Rise>
           {/* Masthead title */}
+          <Rise {...ent(5)}>
           <div style={{
             fontSize: isPortrait ? 112 : 104, fontWeight: 800, color: "#1e293b",
             fontFamily: "Noto Sans SC", lineHeight: 1.1, letterSpacing: "0.02em",
@@ -296,9 +305,11 @@ const CoverScene: React.FC<{
           }}>
             {title}
           </div>
+          </Rise>
           {/* Week range — the issue date, set large so the week reads at a
               glance (journal issue-line convention). */}
           {subtitle && (
+            <Rise {...ent(11)}>
             <div style={{
               fontSize: isPortrait ? 52 : 46, fontWeight: 700, color: "#1e293b",
               fontFamily: "Noto Sans SC", letterSpacing: "0.06em",
@@ -306,6 +317,7 @@ const CoverScene: React.FC<{
             }}>
               {subtitle}
             </div>
+            </Rise>
           )}
           {/* Main tags — languages aggregated across the week's repos */}
           {topLangs.length > 0 && (
@@ -314,8 +326,9 @@ const CoverScene: React.FC<{
               alignItems: "center", justifyContent: "center",
               gap: 14, marginTop: 30,
             }}>
-              {topLangs.map(([lang, stats]) => (
-                <span key={lang} style={{
+              {topLangs.map(([lang, stats], i) => (
+                <Pop key={lang} {...pop(17 + i * 4)} origin="center">
+                <span style={{
                   display: "inline-flex", alignItems: "center", gap: 9,
                   fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#334155",
                   fontFamily: "Noto Sans SC",
@@ -326,16 +339,20 @@ const CoverScene: React.FC<{
                   <ColorDot color={stats.color || primary} size={isPortrait ? 14 : 12} />
                   {lang}
                 </span>
+                </Pop>
               ))}
             </div>
           )}
           {/* Trend summary — set like a journal dek */}
           {trendSummary && (
             <>
+              <Rise {...ent(24)}>
               <div style={{
                 width: "52%", height: 1, marginTop: 34,
                 background: "rgba(30,41,59,0.18)",
               }} />
+              </Rise>
+              <Rise {...ent(27)}>
               <div style={{
                 fontSize: isPortrait ? 34 : 29, fontWeight: 500, color: "#44403c",
                 fontFamily: "Noto Sans SC", lineHeight: 1.5,
@@ -343,6 +360,7 @@ const CoverScene: React.FC<{
               }}>
                 {trendSummary}
               </div>
+              </Rise>
             </>
           )}
         </div>
@@ -496,6 +514,13 @@ const CoverScene: React.FC<{
     const repos = (coverRepos ?? []).map((s) => ghExt(s)!.repo);
     const totalStars = repos.reduce((sum, r) => sum + (r.stars ?? 0), 0);
 
+    // Portrait-only entrance reinforcement: stagger the existing elements in
+    // (kicker → masthead → summary → data strip → language chips). The layout
+    // itself is unchanged; on landscape every wrapper passes through.
+    const ent = (delay: number) => (isPortrait ? { delay } : {});
+    const pop = (delay: number) =>
+      isPortrait ? { delay } : { delay: -9999 }; // landscape: spring settles instantly at frame 0
+
     return (
       <AbsoluteFill style={{
         background: `linear-gradient(150deg, ${theme.bg} 0%, ${theme.bgLight} 60%, #dbe4ee 100%)`,
@@ -526,6 +551,7 @@ const CoverScene: React.FC<{
           maxWidth: "100%",
         }}>
           {/* Doc-type kicker — "DAILY BRIEFING", not the weekly's "WEEKLY ISSUE" */}
+          <Rise {...ent(0)}>
           <div style={{
             display: "inline-flex", alignItems: "baseline", gap: 18,
             marginBottom: 24,
@@ -548,8 +574,10 @@ const CoverScene: React.FC<{
               </span>
             )}
           </div>
+          </Rise>
           {/* Masthead title — flush left with a heavy primary sidebar rule,
               no underline (the weekly underlines its centered masthead) */}
+          <Rise {...ent(5)}>
           <div style={{
             fontSize: isPortrait ? 108 : 98, fontWeight: 800, color: "#1e293b",
             fontFamily: "Noto Sans SC", lineHeight: 1.12,
@@ -559,8 +587,10 @@ const CoverScene: React.FC<{
           }}>
             {title}
           </div>
+          </Rise>
           {/* Trend summary — set as the report's abstract line */}
           {trendSummary && (
+            <Rise {...ent(12)}>
             <div style={{
               fontSize: isPortrait ? 34 : 28, fontWeight: 500, color: "#475569",
               fontFamily: "Noto Sans SC", lineHeight: 1.5,
@@ -571,6 +601,7 @@ const CoverScene: React.FC<{
             }}>
               {trendSummary}
             </div>
+            </Rise>
           )}
           {/* Data strip — repo count + aggregate stars, then language tags.
               Numbers-first row a report leads with; chips are SQUARE-cornered
@@ -581,6 +612,7 @@ const CoverScene: React.FC<{
             alignItems: "stretch",
             gap: 12, marginTop: 44,
           }}>
+            <Pop {...pop(18)} origin="left center">
             <span style={{
               display: "inline-flex", alignItems: "baseline", gap: 8,
               padding: "12px 22px",
@@ -591,12 +623,16 @@ const CoverScene: React.FC<{
             }}>
               <span style={{
                 fontSize: isPortrait ? 38 : 32, fontWeight: 800, color: primary,
-              }}>{repos.length}</span>
+              }}>
+                {isPortrait ? <CountUp value={repos.length} format={(n) => String(n)} delay={20} duration={26} /> : repos.length}
+              </span>
               <span style={{
                 fontSize: isPortrait ? 26 : 22, fontWeight: 500, color: "#64748b",
               }}>个项目</span>
             </span>
+            </Pop>
             {totalStars > 0 && (
+              <Pop {...pop(23)} origin="left center">
               <span style={{
                 display: "inline-flex", alignItems: "baseline", gap: 8,
                 padding: "12px 22px",
@@ -607,14 +643,18 @@ const CoverScene: React.FC<{
               }}>
                 <span style={{
                   fontSize: isPortrait ? 38 : 32, fontWeight: 800, color: primary,
-                }}>{formatCount(totalStars)}</span>
+                }}>
+                  {isPortrait ? <CountUp value={totalStars} format={formatCount} delay={25} duration={32} /> : formatCount(totalStars)}
+                </span>
                 <span style={{
                   fontSize: isPortrait ? 26 : 22, fontWeight: 500, color: "#64748b",
                 }}>总 stars</span>
               </span>
+              </Pop>
             )}
-            {topLangs.map(([lang, stats]) => (
-              <span key={lang} style={{
+            {topLangs.map(([lang, stats], i) => (
+              <Pop key={lang} {...pop(28 + i * 4)} origin="left center">
+              <span style={{
                 display: "inline-flex", alignItems: "center", gap: 8,
                 fontSize: isPortrait ? 26 : 22, fontWeight: 500, color: "#334155",
                 fontFamily: "Noto Sans SC",
@@ -626,6 +666,7 @@ const CoverScene: React.FC<{
                 <ColorDot color={stats.color || primary} size={isPortrait ? 13 : 11} />
                 {lang}
               </span>
+              </Pop>
             ))}
           </div>
         </div>

+ 364 - 0
packages/templates/src/base/components/board-portrait.tsx

@@ -0,0 +1,364 @@
+import React from "react";
+import { Img, staticFile } from "remotion";
+import type { RenderSegmentExtension } from "@pipeline/shared";
+import { formatCount } from "@pipeline/shared";
+import { Rise, Pop, DrawLine, CountUp, RevealImage } from "./motion";
+import { ColorDot } from "../../github-trending/index";
+
+/**
+ * BoardPortrait — the shared portrait Hero board for the github boards:
+ * github-trending (skin "daily-report") and github-weekly / github-weekly-recap
+ * (skin "weekly-magazine"). One skeleton, two skins:
+ *
+ *   daily-report    square-cornered data report: ghosted rank numeral, top
+ *                   data-bar chips, wide-tracked report labels.
+ *   weekly-magazine rounded editorial magazine: pill chips, left editorial
+ *                   bars, softer radii, no rank numeral.
+ *
+ * Layout (top→bottom inside the scene's flex column; the caller renders the
+ * subtitle band and chapter toc below the reserved footer):
+ *   repo fullName header → tag row pops in one by one → divider draws itself →
+ *   full-width social-preview HERO (clip reveal + slow settle) with the
+ *   star-history mini-card tucked bottom-right → highlights card → review card.
+ *
+ * All entrances are staggered frame-driven motion from ./motion.
+ */
+
+/** Subtitle band + chapter toc strip reserved below the content column. */
+export const PORTRAIT_FOOTER_RESERVED = 340;
+
+export type BoardSkin = "daily-report" | "weekly-magazine";
+
+export interface BoardPortraitProps {
+  palette: { from: string; to: string; accent: string };
+  github: RenderSegmentExtension;
+  skin: BoardSkin;
+  /** 1-based position among content scenes — drives the ghosted rank numeral
+   *  (daily-report only). Undefined = no numeral. */
+  rank?: number;
+  /** Star-gain chip prefix: "今日" (daily) / "本周" (weekly). */
+  gainPrefix?: string;
+  /** Recap mode: renders the featured-date chip ("推荐 · 8月12日") instead of
+   *  a star gain. ISO date, e.g. "2026-08-12". */
+  coveredDate?: string;
+}
+
+interface SkinTable {
+  showRank: boolean;
+  chipRadius: number;
+  cardRadius: number;
+  /** Card accent position: top data bar (report) vs left editorial bar (magazine). */
+  cardBar: "top" | "left";
+  cardBg: string;
+  cardBorder: string;
+  heroRadius: number;
+  heroShadow: string;
+}
+
+const SKINS: Record<BoardSkin, SkinTable> = {
+  "daily-report": {
+    showRank: true,
+    chipRadius: 0,
+    cardRadius: 4,
+    cardBar: "top",
+    cardBg: "rgba(255,255,255,0.88)",
+    cardBorder: "rgba(30,41,59,0.12)",
+    heroRadius: 12,
+    heroShadow: "0 18px 44px rgba(30,41,59,0.18), 0 4px 12px rgba(30,41,59,0.10)",
+  },
+  "weekly-magazine": {
+    showRank: false,
+    chipRadius: 999,
+    cardRadius: 10,
+    cardBar: "left",
+    cardBg: "rgba(255,255,255,0.82)",
+    cardBorder: "rgba(30,41,59,0.10)",
+    heroRadius: 16,
+    heroShadow: "0 18px 44px rgba(120,100,60,0.16), 0 4px 12px rgba(120,100,60,0.10)",
+  },
+};
+
+export const BoardPortrait: React.FC<BoardPortraitProps> = ({
+  palette,
+  github,
+  skin,
+  rank,
+  gainPrefix = "今日",
+  coveredDate,
+}) => {
+  const s = SKINS[skin];
+  const repo = github.repo;
+  const language = repo?.language || "";
+  const languageColor = repo?.languageColor || palette.accent;
+  const stars = repo?.stars;
+  const license = repo?.license || "";
+  const fullName = repo?.fullName || "";
+  // Recap scenes tag with the featured date (snapshot numbers are feature-time,
+  // so a gain label would be wrong there) — same rule as the landscape header.
+  const gainLabel = coveredDate
+    ? `推荐 · ${coveredDate.replace(/^\d{4}-/, "").replace("-", "月").replace(/^0/, "")}日`
+    : repo?.todayStars != null
+      ? `${gainPrefix} +${formatCount(repo.todayStars)}`
+      : "";
+
+  const socialPreview = github.images?.socialPreview;
+  const starHistory = github.images?.starHistory;
+
+  return (
+    <div
+      style={{
+        position: "relative",
+        display: "flex",
+        flexDirection: "column",
+        width: "100%",
+        height: "100%",
+        boxSizing: "border-box",
+        padding: `48px 48px ${PORTRAIT_FOOTER_RESERVED}px`,
+      }}
+    >
+      {/* Ghosted rank numeral behind the header — the daily report's chapter
+          number, huge and faint. The magazine skin omits it. */}
+      {s.showRank && rank != null && (
+        <Rise
+          delay={0}
+          distance={24}
+          style={{
+            position: "absolute",
+            top: 8,
+            right: 40,
+            fontFamily: "Noto Sans SC",
+            fontWeight: 800,
+            fontSize: 220,
+            lineHeight: 1,
+            color: palette.accent,
+            opacity: 0.1,
+            pointerEvents: "none",
+          }}
+        >
+          {String(rank).padStart(2, "0")}
+        </Rise>
+      )}
+
+      {/* --- Header --- */}
+      <Rise delay={2} style={{ position: "relative", zIndex: 1 }}>
+        <div
+          style={{
+            fontSize: 84,
+            fontWeight: 800,
+            color: "#1e293b",
+            fontFamily: "Noto Sans SC",
+            lineHeight: 1.15,
+            letterSpacing: "-0.01em",
+            overflowWrap: "break-word",
+          }}
+        >
+          {fullName}
+        </div>
+      </Rise>
+      <div
+        style={{
+          display: "flex",
+          flexWrap: "wrap",
+          gap: 14,
+          marginTop: 22,
+          position: "relative",
+          zIndex: 1,
+        }}
+      >
+        {language && (
+          <Pop delay={12}>
+            <Chip radius={s.chipRadius} border={`${palette.accent}44`} topBar={s.cardBar === "top" ? palette.accent : undefined}>
+              <ColorDot color={languageColor} size={16} />
+              {language}
+            </Chip>
+          </Pop>
+        )}
+        {gainLabel && (
+          <Pop delay={17}>
+            <Chip
+              radius={s.chipRadius}
+              bg={palette.accent}
+              border={palette.accent}
+              topBar={s.cardBar === "top" ? palette.accent : undefined}
+              emphasis
+            >
+              {gainLabel}
+            </Chip>
+          </Pop>
+        )}
+        {stars != null && (
+          <Pop delay={22}>
+            <Chip radius={s.chipRadius} border={`${palette.accent}44`} topBar={s.cardBar === "top" ? palette.accent : undefined}>
+              <CountUp value={stars} format={formatCount} delay={24} duration={34} />
+              {" stars"}
+            </Chip>
+          </Pop>
+        )}
+        {license && (
+          <Pop delay={27}>
+            <Chip radius={s.chipRadius} border={`${palette.accent}44`} topBar={s.cardBar === "top" ? palette.accent : undefined}>
+              {license}
+            </Chip>
+          </Pop>
+        )}
+      </div>
+
+      {/* --- Divider draws itself --- */}
+      <DrawLine delay={16} duration={22} color="rgba(30,41,59,0.18)" style={{ marginTop: 24 }} />
+
+      {/* --- Content column --- */}
+      <div
+        style={{
+          flex: 1,
+          display: "flex",
+          flexDirection: "column",
+          gap: 24,
+          paddingTop: 24,
+          minHeight: 0,
+        }}
+      >
+        {/* Hero: full-width social preview with the star-history mini-card
+            tucked into its bottom-right corner */}
+        {socialPreview && (
+          <Rise delay={18} distance={44}>
+            <div
+              style={{
+                position: "relative",
+                background: "#ffffff",
+                border: "1px solid rgba(30,41,59,0.12)",
+                borderRadius: s.heroRadius,
+                boxShadow: s.heroShadow,
+                overflow: "hidden",
+              }}
+            >
+              <RevealImage filename={socialPreview.filename} delay={26} duration={26} />
+              {starHistory && (
+                <div
+                  style={{
+                    position: "absolute",
+                    right: 18,
+                    bottom: 18,
+                    width: "38%",
+                    background: "#ffffff",
+                    border: "1px solid rgba(30,41,59,0.14)",
+                    borderRadius: Math.max(8, s.heroRadius - 4),
+                    boxShadow: "0 10px 28px rgba(30,41,59,0.22)",
+                    overflow: "hidden",
+                  }}
+                >
+                  <Rise delay={44} distance={26}>
+                    <Img
+                      src={staticFile(starHistory.filename)}
+                      style={{ display: "block", width: "100%", height: "auto" }}
+                    />
+                  </Rise>
+                </div>
+              )}
+            </div>
+          </Rise>
+        )}
+
+        {/* Highlights / review cards rise in sequence */}
+        <div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 24, minHeight: 0 }}>
+          <Rise delay={34} distance={40} style={{ flex: 1, minHeight: 0 }}>
+            <SectionCard title="项目亮点" accent={palette.accent} body={github.highlights} s={s} />
+          </Rise>
+          <Rise delay={44} distance={40} style={{ flex: 1, minHeight: 0 }}>
+            <SectionCard title="建议" accent={palette.accent} body={github.review} s={s} />
+          </Rise>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+// --- Pieces ----------------------------------------------------------------
+
+const Chip: React.FC<{
+  children: React.ReactNode;
+  radius: number;
+  bg?: string;
+  border: string;
+  topBar?: string;
+  emphasis?: boolean;
+}> = ({ children, radius, bg, border, topBar, emphasis }) => (
+  <div
+    style={{
+      display: "inline-flex",
+      alignItems: "center",
+      gap: 10,
+      padding: "12px 24px",
+      background: bg ?? "rgba(255,255,255,0.8)",
+      border: `1px solid ${border}`,
+      borderTop: topBar ? `3px solid ${topBar}` : `1px solid ${border}`,
+      borderRadius: radius,
+      color: emphasis ? "#ffffff" : "#334155",
+      fontFamily: "Noto Sans SC",
+      fontSize: 32,
+      fontWeight: emphasis ? 700 : 500,
+    }}
+  >
+    {children}
+  </div>
+);
+
+const SectionCard: React.FC<{
+  title: string;
+  accent: string;
+  body: string;
+  s: SkinTable;
+}> = ({ title, accent, body, s }) => {
+  // Strip trailing punctuation that would look awkward at the end of the
+  // clamped text (LLM occasionally leaves a hanging 。,,;).
+  const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
+  return (
+    <div
+      style={{
+        height: "100%",
+        background: s.cardBg,
+        border: `1px solid ${s.cardBorder}`,
+        borderTop: s.cardBar === "top" ? `3px solid ${accent}` : undefined,
+        borderLeft: s.cardBar === "left" ? `6px solid ${accent}` : undefined,
+        borderRadius: s.cardRadius,
+        padding: "24px 32px",
+        display: "flex",
+        flexDirection: "column",
+        gap: 12,
+        minHeight: 0,
+        boxSizing: "border-box",
+        overflow: "hidden",
+      }}
+    >
+      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
+        <span
+          style={{
+            fontSize: 34,
+            fontWeight: 700,
+            color: accent,
+            fontFamily: "Noto Sans SC",
+            letterSpacing: s.cardBar === "top" ? "0.14em" : "0.04em",
+          }}
+        >
+          {title}
+        </span>
+      </div>
+      <div
+        style={{
+          flex: "0 0 auto",
+          fontSize: 60,
+          lineHeight: 1.35,
+          color: "#334155",
+          fontFamily: "Noto Sans SC",
+          fontWeight: 400,
+          display: "-webkit-box",
+          WebkitLineClamp: 3,
+          WebkitBoxOrient: "vertical",
+          overflow: "hidden",
+          overflowWrap: "break-word",
+        }}
+      >
+        {cleanBody}
+      </div>
+    </div>
+  );
+};

+ 189 - 0
packages/templates/src/base/components/motion.tsx

@@ -0,0 +1,189 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  interpolate,
+  Easing,
+  spring,
+  Img,
+  staticFile,
+} from "remotion";
+
+/**
+ * Shared motion primitives for the github boards' portrait layouts (and small
+ * cover reinforcement). All are deterministic (frame-driven, no randomness) so
+ * Remotion renders them identically on every frame. Sizes are tuned for the
+ * portrait 1080x1920 canvas; landscape layouts don't use them.
+ */
+
+/** Standard entrance duration (frames) for fades/rises. */
+const ENTR = 14;
+
+/** Eased rise: translateY `distance`→0 with a soft cubic-out, paired with fade. */
+export const useRise = (delay = 0, distance = 36) => {
+  const frame = useCurrentFrame();
+  const opacity = interpolate(frame, [delay, delay + ENTR], [0, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+  });
+  const y = interpolate(frame, [delay, delay + ENTR], [distance, 0], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+  return { opacity, y };
+};
+
+/** A div that rises+fades in. The workhorse entrance for cards/headers. */
+export const Rise: React.FC<{
+  delay?: number;
+  distance?: number;
+  children: React.ReactNode;
+  style?: React.CSSProperties;
+}> = ({ delay = 0, distance = 36, children, style }) => {
+  const { opacity, y } = useRise(delay, distance);
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `translateY(${y}px)`,
+        ...style,
+      }}
+    >
+      {children}
+    </div>
+  );
+};
+
+/** Spring pop: scale overshoots slightly then settles, with fade. Good for chips/tags. */
+export const Pop: React.FC<{
+  delay?: number;
+  /** transform-origin — "left center" deals tags out left→right; "center" for
+   *  centered rows (cover tag chips). */
+  origin?: string;
+  children: React.ReactNode;
+  style?: React.CSSProperties;
+}> = ({ delay = 0, origin = "left center", children, style }) => {
+  const frame = useCurrentFrame();
+  const { fps } = useVideoConfig();
+  const scale = spring({
+    frame: frame - delay,
+    fps,
+    config: { damping: 12, stiffness: 160, mass: 0.7 },
+    durationInFrames: 22,
+  });
+  const opacity = interpolate(frame, [delay, delay + 6], [0, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+  });
+  return (
+    <div
+      style={{
+        opacity,
+        transform: `scale(${0.6 + 0.4 * scale})`,
+        transformOrigin: origin,
+        ...style,
+      }}
+    >
+      {children}
+    </div>
+  );
+};
+
+/** Horizontal line that draws itself left→right. */
+export const DrawLine: React.FC<{
+  delay?: number;
+  color: string;
+  height?: number;
+  duration?: number;
+  style?: React.CSSProperties;
+}> = ({ delay = 0, color, height = 1, duration = 20, style }) => {
+  const frame = useCurrentFrame();
+  const w = interpolate(frame, [delay, delay + duration], [0, 100], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+  return (
+    <div
+      style={{
+        height,
+        background: color,
+        width: `${w}%`,
+        transformOrigin: "left center",
+        ...style,
+      }}
+    />
+  );
+};
+
+/**
+ * Animated count: interpolates the raw number and re-formats each frame via the
+ * caller's `format` (usually formatCount), so "1.2k" counts up through the real
+ * decimal transitions.
+ */
+export const CountUp: React.FC<{
+  value: number;
+  format: (n: number) => string;
+  delay?: number;
+  duration?: number;
+  style?: React.CSSProperties;
+}> = ({ value, format, delay = 0, duration = 30, style }) => {
+  const frame = useCurrentFrame();
+  const eased = interpolate(frame, [delay, delay + duration], [0, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+  return <span style={style}>{format(Math.round(value * eased))}</span>;
+};
+
+/**
+ * Hero reveal: the frame wipes open (clip-path inset from bottom) while the
+ * image inside settles — a slow scale 1.12→1 + translateY drift (Ken Burns-ish
+ * landing). Used for the portrait's full-width social preview.
+ */
+export const RevealImage: React.FC<{
+  filename: string;
+  delay?: number;
+  duration?: number;
+  alt?: string;
+  style?: React.CSSProperties;
+}> = ({ filename, delay = 0, duration = 24, alt, style }) => {
+  const frame = useCurrentFrame();
+  const clip = interpolate(frame, [delay, delay + duration], [100, 0], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+  const scale = interpolate(frame, [delay, delay + duration + 40], [1.12, 1], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+  const drift = interpolate(frame, [delay, delay + duration + 40], [26, 0], {
+    extrapolateLeft: "clamp",
+    extrapolateRight: "clamp",
+    easing: Easing.out(Easing.cubic),
+  });
+  return (
+    <div
+      style={{
+        clipPath: `inset(0 0 ${clip}% 0)`,
+        ...style,
+      }}
+    >
+      <Img
+        src={staticFile(filename)}
+        alt={alt}
+        style={{
+          display: "block",
+          width: "100%",
+          height: "auto",
+          transform: `scale(${scale}) translateY(${drift}px)`,
+          transformOrigin: "center bottom",
+        }}
+      />
+    </div>
+  );
+};

+ 13 - 3
packages/templates/src/base/components/subtitle-bar.tsx

@@ -8,6 +8,10 @@ interface SubtitleBarProps {
   caption?: Caption[];
   style?: React.CSSProperties;
   fontSize?: number;
+  /** "glass-light" renders a frosted white pill with dark text — for the light
+   *  paper boards, where the default black block reads heavy. Default (dark
+   *  block) is unchanged for all other templates. */
+  variant?: "default" | "glass-light";
 }
 
 /**
@@ -17,7 +21,7 @@ interface SubtitleBarProps {
  * pre-splits captions (porting the old runtime groupIntoSegments), so this just
  * picks the active one — identical on-screen timing to before.
  */
-export const SubtitleBar: React.FC<SubtitleBarProps> = ({ caption, style, fontSize }) => {
+export const SubtitleBar: React.FC<SubtitleBarProps> = ({ caption, style, fontSize, variant = "default" }) => {
   const frame = useCurrentFrame();
   const { fps } = useVideoConfig();
   const currentTime = frame / fps;
@@ -38,6 +42,8 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({ caption, style, fontSi
   }
   if (!active) active = captions[captions.length - 1];
 
+  const glass = variant === "glass-light";
+
   return (
     <div
       style={{
@@ -53,21 +59,25 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({ caption, style, fontSi
     >
       <div
         style={{
-          backgroundColor: "rgba(0,0,0,0.8)",
+          backgroundColor: glass ? "rgba(255,255,255,0.78)" : "rgba(0,0,0,0.8)",
+          border: glass ? "1px solid rgba(30,41,59,0.14)" : undefined,
           borderRadius: 12,
           padding: "14px 28px",
           maxWidth: "90%",
           display: "flex",
           alignItems: "center",
           justifyContent: "center",
+          backdropFilter: glass ? "blur(10px)" : undefined,
+          boxShadow: glass ? "0 4px 20px rgba(30,41,59,0.10)" : undefined,
         }}
       >
         <span
           style={{
-            color: "#ffffff",
+            color: glass ? "#1e293b" : "#ffffff",
             fontSize: fontSize ?? 40,
             lineHeight: 1.6,
             fontFamily: "Noto Sans SC",
+            fontWeight: glass ? 600 : 400,
             display: "-webkit-box",
             WebkitLineClamp: 2,
             WebkitBoxOrient: "vertical",

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

@@ -12,6 +12,7 @@ import type { RenderSegmentExtension } from "@pipeline/shared";
 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 { formatCount } from "@pipeline/shared";
 
 interface GithubTrendingSceneProps {
@@ -37,12 +38,18 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
     scene.extension?.type === "github-trending" ? scene.extension : undefined;
 
   const sceneDur = durationInFrames;
-  const fadeOpacity = interpolate(
-    frame,
-    [0, 8, sceneDur - 8, sceneDur],
-    [0, 1, 1, 0],
-    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
-  );
+  // 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" }
+      );
 
   // Named images from the template extension (resolved to filenames by the
   // renderer). Object, not positional array — socialPreview / starHistory.
@@ -50,7 +57,8 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
   const starHistory = github?.images?.starHistory;
 
   const pad = isPortrait ? 48 : 80;
-  const footerReserved = isPortrait ? 500 : 140;
+  // Portrait reserves the new (smaller) subtitle band; landscape keeps 140.
+  const footerReserved = isPortrait ? PORTRAIT_FOOTER_RESERVED : 140;
 
   // Landscape geometry (portrait uses flex layout below — no headerHeight).
   const landscapeHeaderHeight = 220;
@@ -134,32 +142,42 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
       <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
 
       {isPortrait ? (
-        // Portrait: flex column. Header takes natural height regardless of
-        // fullName length (1, 2, or 3 lines all flow correctly), divider sits
-        // right below it, content fills the remainder. paddingBottom reserves
-        // space for the elevated subtitle (bottom: 380).
-        <div style={{
-          position: "relative",
-          display: "flex",
-          flexDirection: "column",
-          width: "100%",
-          height: "100%",
-          boxSizing: "border-box",
-          padding: `${pad}px ${pad}px ${footerReserved}px`,
-        }}>
-          {renderHeader(92, "large", 16)}
-          <div style={{
-            height: 1,
-            background: "rgba(30,41,59,0.18)",
-            marginTop: 24,
-          }} />
-          <PortraitContent
+        // Portrait: the shared animated Hero board (base/components/board-portrait) —
+        // staggered entrances, full-width social preview with star-history
+        // mini-card, ghosted rank numeral. Header height flows naturally in the
+        // board's own flex column; this branch only wraps it.
+        github ? (
+          <BoardPortrait
             palette={palette}
-            socialPreview={socialPreview}
-            starHistory={starHistory}
             github={github}
+            skin="daily-report"
+            rank={sceneIndex + 1}
+            gainPrefix="今日"
           />
-        </div>
+        ) : (
+          // No-extension fallback keeps the simple flex column.
+          <div style={{
+            position: "relative",
+            display: "flex",
+            flexDirection: "column",
+            width: "100%",
+            height: "100%",
+            boxSizing: "border-box",
+            padding: `${pad}px ${pad}px ${footerReserved}px`,
+            alignItems: "center",
+            justifyContent: "center",
+          }}>
+            <div style={{
+              fontSize: 60,
+              fontWeight: 700,
+              color: "#1e293b",
+              fontFamily: "Noto Sans SC",
+              textAlign: "center",
+            }}>
+              {scene.title || scene.captionOrigin}
+            </div>
+          </div>
+        )
       ) : (
         // Landscape: absolute positioning (single-line 80px header).
         <>
@@ -197,8 +215,9 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
       {(scene.caption?.length ?? 0) > 0 && (
         <SubtitleBar
           caption={scene.caption}
-          style={isPortrait ? { bottom: 380 } : undefined}
-          fontSize={isPortrait ? 56 : undefined}
+          style={isPortrait ? { bottom: 116 } : undefined}
+          fontSize={isPortrait ? 50 : undefined}
+          variant={isPortrait ? "glass-light" : "default"}
         />
       )}
       <Watermark text={channelName} light />
@@ -323,64 +342,6 @@ const Section: React.FC<{
   );
 };
 
-interface PortraitContentProps {
-  palette: { from: string; to: string; accent: string };
-  socialPreview?: { filename: string };
-  starHistory?: { filename: string };
-  github: RenderSegmentExtension;
-}
-
-// PortraitContent uses flex:1 to fill remaining height after the header
-// (which takes its natural height in the parent flex column). No absolute
-// positioning, no contentTop/contentHeight needed.
-const PortraitContent: React.FC<PortraitContentProps> = ({
-  palette, socialPreview, starHistory, github,
-}) => {
-  const hasAnyImage = !!(socialPreview || starHistory);
-  return (
-    <div style={{
-      flex: 1,
-      display: "flex",
-      flexDirection: "column",
-      gap: 28,
-      paddingTop: 24,
-      minHeight: 0,
-    }}>
-      {/* Top row: images side-by-side, each flex:1 (same width) with natural
-          height. Row sizes to tallest child; shorter child top-aligned. */}
-      {hasAnyImage && (
-        <div style={{
-          display: "flex",
-          gap: 20,
-          alignItems: "flex-start",
-        }}>
-          {socialPreview && (
-            <ImageFrame flex={1}>
-              <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
-            </ImageFrame>
-          )}
-          {starHistory && (
-            <ImageFrame flex={1}>
-              <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
-            </ImageFrame>
-          )}
-        </div>
-      )}
-
-      {/* Bottom: 2 sections stacked (full height when no images) */}
-      <div style={{
-        flex: 1,
-        display: "flex",
-        flexDirection: "column",
-        gap: 28,
-      }}>
-        <Section title="项目亮点" accent={palette.accent} body={github.highlights} size="large" />
-        <Section title="建议" accent={palette.accent} body={github.review} size="large" />
-      </div>
-    </div>
-  );
-};
-
 interface LandscapeContentProps {
   contentTop: number;
   contentHeight: number;

+ 50 - 88
packages/templates/src/github-weekly/index.tsx

@@ -12,6 +12,7 @@ import type { RenderSegmentExtension } from "@pipeline/shared";
 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 { formatCount } from "@pipeline/shared";
 
 /**
@@ -53,12 +54,18 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
   const isRecap = scene.extension?.type === "github-weekly-recap";
 
   const sceneDur = durationInFrames;
-  const fadeOpacity = interpolate(
-    frame,
-    [0, 8, sceneDur - 8, sceneDur],
-    [0, 1, 1, 0],
-    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
-  );
+  // 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" }
+      );
 
   // Named images from the shared board extension (resolved to filenames by the
   // renderer). Object, not positional array — socialPreview / starHistory.
@@ -66,7 +73,8 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
   const starHistory = github?.images?.starHistory;
 
   const pad = isPortrait ? 48 : 80;
-  const footerReserved = isPortrait ? 500 : 140;
+  // Portrait reserves the new (smaller) subtitle band; landscape keeps 140.
+  const footerReserved = isPortrait ? PORTRAIT_FOOTER_RESERVED : 140;
 
   // Landscape geometry (portrait uses flex layout below — no headerHeight).
   const landscapeHeaderHeight = 220;
@@ -160,31 +168,41 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
       <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
 
       {isPortrait ? (
-        // Portrait: flex column. Header takes natural height, divider sits
-        // right below it, content fills the remainder. paddingBottom reserves
-        // space for the elevated subtitle (bottom: 380).
-        <div style={{
-          position: "relative",
-          display: "flex",
-          flexDirection: "column",
-          width: "100%",
-          height: "100%",
-          boxSizing: "border-box",
-          padding: `${pad}px ${pad}px ${footerReserved}px`,
-        }}>
-          {renderHeader(92, "large", 16)}
-          <div style={{
-            height: 1,
-            background: "rgba(30,41,59,0.18)",
-            marginTop: 24,
-          }} />
-          <PortraitContent
+        // Portrait: the shared animated Hero board (base/components/board-portrait)
+        // in the magazine skin — pill chips, left editorial bars, no rank
+        // numeral; recap passes coveredDate so the tag reads "推荐 · 8月12日".
+        github ? (
+          <BoardPortrait
             palette={palette}
-            socialPreview={socialPreview}
-            starHistory={starHistory}
             github={github}
+            skin="weekly-magazine"
+            gainPrefix="本周"
+            coveredDate={github.coveredDate}
           />
-        </div>
+        ) : (
+          // No-extension fallback keeps a simple centered column.
+          <div style={{
+            position: "relative",
+            display: "flex",
+            flexDirection: "column",
+            width: "100%",
+            height: "100%",
+            boxSizing: "border-box",
+            padding: `${pad}px ${pad}px ${footerReserved}px`,
+            alignItems: "center",
+            justifyContent: "center",
+          }}>
+            <div style={{
+              fontSize: 60,
+              fontWeight: 700,
+              color: "#1e293b",
+              fontFamily: "Noto Sans SC",
+              textAlign: "center",
+            }}>
+              {scene.title || scene.captionOrigin}
+            </div>
+          </div>
+        )
       ) : (
         // Landscape: absolute positioning (single-line 80px header).
         <>
@@ -222,8 +240,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
       {(scene.caption?.length ?? 0) > 0 && (
         <SubtitleBar
           caption={scene.caption}
-          style={isPortrait ? { bottom: 380 } : undefined}
-          fontSize={isPortrait ? 56 : undefined}
+          style={isPortrait ? { bottom: 116 } : undefined}
+          fontSize={isPortrait ? 50 : undefined}
+          variant={isPortrait ? "glass-light" : "default"}
         />
       )}
       <Watermark text={channelName} light />
@@ -347,63 +366,6 @@ const Section: React.FC<{
   );
 };
 
-interface PortraitContentProps {
-  palette: { from: string; to: string; accent: string };
-  socialPreview?: { filename: string };
-  starHistory?: { filename: string };
-  github: RenderSegmentExtension;
-}
-
-// PortraitContent uses flex:1 to fill remaining height after the header
-// (which takes its natural height in the parent flex column).
-const PortraitContent: React.FC<PortraitContentProps> = ({
-  palette, socialPreview, starHistory, github,
-}) => {
-  const hasAnyImage = !!(socialPreview || starHistory);
-  return (
-    <div style={{
-      flex: 1,
-      display: "flex",
-      flexDirection: "column",
-      gap: 28,
-      paddingTop: 24,
-      minHeight: 0,
-    }}>
-      {/* Top row: images side-by-side, each flex:1 (same width) with natural
-          height. Row sizes to tallest child; shorter child top-aligned. */}
-      {hasAnyImage && (
-        <div style={{
-          display: "flex",
-          gap: 20,
-          alignItems: "flex-start",
-        }}>
-          {socialPreview && (
-            <ImageFrame flex={1}>
-              <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
-            </ImageFrame>
-          )}
-          {starHistory && (
-            <ImageFrame flex={1}>
-              <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
-            </ImageFrame>
-          )}
-        </div>
-      )}
-
-      {/* Bottom: 2 sections stacked (full height when no images) */}
-      <div style={{
-        flex: 1,
-        display: "flex",
-        flexDirection: "column",
-        gap: 28,
-      }}>
-        <Section title="项目亮点" accent={palette.accent} body={github.highlights} size="large" />
-        <Section title="建议" accent={palette.accent} body={github.review} size="large" />
-      </div>
-    </div>
-  );
-};
-
 interface LandscapeContentProps {
   contentTop: number;
   contentHeight: number;