lkatzey 3 недель назад
Родитель
Сommit
8e90c5db8b

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

@@ -5,6 +5,7 @@ import type { WordTimestamp } from "@pipeline/shared";
 interface SubtitleBarProps {
   wordTimestamps: WordTimestamp[];
   style?: React.CSSProperties;
+  fontSize?: number;
 }
 
 interface Segment {
@@ -44,6 +45,7 @@ function groupIntoSegments(wordTimestamps: WordTimestamp[]): Segment[] {
 export const SubtitleBar: React.FC<SubtitleBarProps> = ({
   wordTimestamps,
   style,
+  fontSize,
 }) => {
   const frame = useCurrentFrame();
   const { fps } = useVideoConfig();
@@ -84,7 +86,7 @@ export const SubtitleBar: React.FC<SubtitleBarProps> = ({
         <span
           style={{
             color: "#ffffff",
-            fontSize: 40,
+            fontSize: fontSize ?? 40,
             lineHeight: 1.6,
             fontFamily: "Noto Sans SC",
             display: "-webkit-box",

+ 205 - 164
packages/templates/src/github-trending/index.tsx

@@ -47,15 +47,15 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
   const socialPreview = scene.images?.[0];
   const starHistory = scene.images?.[1];
 
-  // -- Layout geometry (px) --
-  const pad = isPortrait ? 60 : 80;
-  const headerHeight = isPortrait ? 260 : 220;
-  const dividerY = pad + headerHeight;
-  const footerReserved = isPortrait ? 220 : 140;
-  const contentTop = dividerY + 24;
-  const contentBottom = height - footerReserved;
-  const contentHeight = contentBottom - contentTop;
-  const contentWidth = width - pad * 2;
+  const pad = isPortrait ? 48 : 80;
+  const footerReserved = isPortrait ? 500 : 140;
+
+  // Landscape geometry (portrait uses flex layout below — no headerHeight).
+  const landscapeHeaderHeight = 220;
+  const landscapeDividerY = pad + landscapeHeaderHeight;
+  const landscapeContentTop = landscapeDividerY + 24;
+  const landscapeContentHeight = height - footerReserved - landscapeContentTop;
+  const landscapeContentWidth = width - pad * 2;
 
   const repo = github?.repo;
   const language = repo?.language || "";
@@ -81,18 +81,30 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
     );
   }
 
-  const colGap = isPortrait ? 0 : 40;
-  // Landscape left column: cap width so both images stacked (each at its
-  // natural aspect ratio) fit within contentHeight. Assumes typical aspects
-  // (social preview 2:1, star history ~1.5:1 → combined height factor 7/6).
-  // Falls back to 40% of contentWidth when that is smaller.
-  const leftColWidth = isPortrait
-    ? 0
-    : Math.min(
-        Math.round(contentWidth * 0.4),
-        Math.round((contentHeight - 20) / (7 / 6)),
-      );
-  const rightColWidth = isPortrait ? contentWidth : contentWidth - leftColWidth - colGap;
+  const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
+    <div>
+      <div style={{
+        fontSize,
+        fontWeight: 800,
+        color: "white",
+        fontFamily: "Noto Sans SC",
+        lineHeight: 1.15,
+        letterSpacing: "-0.01em",
+      }}>
+        {fullName}
+      </div>
+      <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
+        {language && (
+          <Tag accent={palette.accent} size={tagSize}>
+            <ColorDot color={languageColor} size={dotSize} />
+            {language}
+          </Tag>
+        )}
+        {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
+        {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
+      </div>
+    </div>
+  );
 
   return (
     <AbsoluteFill style={{
@@ -115,70 +127,73 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
         background: `linear-gradient(90deg, ${palette.accent}, ${palette.accent}88)`,
       }} />
 
-      {/* Header: fullName + tags */}
-      <div style={{
-        position: "absolute",
-        top: pad,
-        left: pad,
-        right: pad,
-      }}>
+      {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={{
-          fontSize: isPortrait ? 72 : 80,
-          fontWeight: 800,
-          color: "white",
-          fontFamily: "Noto Sans SC",
-          lineHeight: 1.15,
-          letterSpacing: "-0.01em",
+          position: "relative",
+          display: "flex",
+          flexDirection: "column",
+          width: "100%",
+          height: "100%",
+          boxSizing: "border-box",
+          padding: `${pad}px ${pad}px ${footerReserved}px`,
         }}>
-          {fullName}
+          {renderHeader(92, "large", 16)}
+          <div style={{
+            height: 1,
+            background: "rgba(255,255,255,0.18)",
+            marginTop: 24,
+          }} />
+          <PortraitContent
+            palette={palette}
+            socialPreview={socialPreview}
+            starHistory={starHistory}
+            github={github}
+          />
         </div>
-        <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
-          {language && <Tag accent={palette.accent}><ColorDot color={languageColor} />{language}</Tag>}
-          {starsLabel && <Tag accent={palette.accent}>{starsLabel}</Tag>}
-          {license && <Tag accent={palette.accent}>{license}</Tag>}
-        </div>
-      </div>
+      ) : (
+        // Landscape: absolute positioning (unchanged — single-line 80px header).
+        <>
+          <div style={{
+            position: "absolute",
+            top: pad,
+            left: pad,
+            right: pad,
+          }}>
+            {renderHeader(80, "default", 14)}
+          </div>
 
-      {/* Divider */}
-      <div style={{
-        position: "absolute",
-        top: dividerY,
-        left: pad,
-        right: pad,
-        height: 1,
-        background: "rgba(255,255,255,0.18)",
-      }} />
+          <div style={{
+            position: "absolute",
+            top: landscapeDividerY,
+            left: pad,
+            right: pad,
+            height: 1,
+            background: "rgba(255,255,255,0.18)",
+          }} />
 
-      {/* Content: two columns (landscape) or stacked (portrait) */}
-      {isPortrait ? (
-        <PortraitContent
-          contentTop={contentTop}
-          contentHeight={contentHeight}
-          pad={pad}
-          contentWidth={contentWidth}
-          palette={palette}
-          socialPreview={socialPreview}
-          starHistory={starHistory}
-          github={github}
-        />
-      ) : (
-        <LandscapeContent
-          contentTop={contentTop}
-          contentHeight={contentHeight}
-          pad={pad}
-          contentWidth={contentWidth}
-          leftColWidth={leftColWidth}
-          colGap={colGap}
-          rightColWidth={rightColWidth}
-          palette={palette}
-          socialPreview={socialPreview}
-          starHistory={starHistory}
-          github={github}
-        />
+          <LandscapeContent
+            contentTop={landscapeContentTop}
+            contentHeight={landscapeContentHeight}
+            pad={pad}
+            contentWidth={landscapeContentWidth}
+            palette={palette}
+            socialPreview={socialPreview}
+            starHistory={starHistory}
+            github={github}
+          />
+        </>
       )}
 
       {scene.wordTimestamps.length > 0 && (
-        <SubtitleBar wordTimestamps={scene.wordTimestamps} />
+        <SubtitleBar
+          wordTimestamps={scene.wordTimestamps}
+          style={isPortrait ? { bottom: 380 } : undefined}
+          fontSize={isPortrait ? 56 : undefined}
+        />
       )}
       <Watermark text={channelName} />
     </AbsoluteFill>
@@ -187,7 +202,7 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
 
 // --- Sub-components ---
 
-const Tag: React.FC<{ accent: string; children: React.ReactNode }> = ({ accent, children }) => (
+const Tag: React.FC<{ accent: string; children: React.ReactNode; size?: "default" | "large" }> = ({ accent, children, size = "default" }) => (
   <div style={{
     display: "inline-flex",
     alignItems: "center",
@@ -198,7 +213,7 @@ const Tag: React.FC<{ accent: string; children: React.ReactNode }> = ({ accent,
     border: `1px solid ${accent}55`,
     color: "white",
     fontFamily: "Noto Sans SC",
-    fontSize: 28,
+    fontSize: size === "large" ? 32 : 28,
     fontWeight: 500,
     backdropFilter: "blur(4px)",
   }}>
@@ -206,11 +221,11 @@ const Tag: React.FC<{ accent: string; children: React.ReactNode }> = ({ accent,
   </div>
 );
 
-const ColorDot: React.FC<{ color: string }> = ({ color }) => (
+const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, size = 14 }) => (
   <span style={{
     display: "inline-block",
-    width: 14,
-    height: 14,
+    width: size,
+    height: size,
     borderRadius: "50%",
     background: color || "#ccc",
   }} />
@@ -239,165 +254,191 @@ const Section: React.FC<{
   accent: string;
   body: string;
   flex?: number;
-}> = ({ title, accent, body, flex }) => (
-  <div style={{
-    flex: flex ?? 1,
-    background: "rgba(255,255,255,0.05)",
-    border: "1px solid rgba(255,255,255,0.10)",
-    borderRadius: 12,
-    padding: "22px 28px",
-    display: "flex",
-    flexDirection: "column",
-    gap: 12,
-  }}>
+  size?: "default" | "large";
+}> = ({ title, accent, body, flex, size = "default" }) => {
+  const isLarge = size === "large";
+  // 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]+$/, "");
+  return (
     <div style={{
+      flex: flex ?? 1,
+      background: "rgba(255,255,255,0.05)",
+      border: "1px solid rgba(255,255,255,0.10)",
+      borderRadius: 12,
+      padding: isLarge ? "24px 32px" : "22px 28px",
       display: "flex",
-      alignItems: "center",
+      flexDirection: "column",
       gap: 12,
+      minHeight: 0,
     }}>
-      <span style={{
-        display: "inline-block",
-        width: 5,
-        height: 26,
-        background: accent,
-        borderRadius: 2,
-      }} />
-      <span style={{
-        fontSize: 28,
-        fontWeight: 700,
-        color: accent,
+      <div style={{
+        display: "flex",
+        alignItems: "center",
+        gap: 12,
+      }}>
+        <span style={{
+          display: "inline-block",
+          width: isLarge ? 6 : 5,
+          height: isLarge ? 32 : 26,
+          background: accent,
+          borderRadius: 2,
+        }} />
+        <span style={{
+          fontSize: isLarge ? 36 : 28,
+          fontWeight: 700,
+          color: accent,
+          fontFamily: "Noto Sans SC",
+          letterSpacing: "0.04em",
+        }}>
+          {title}
+        </span>
+      </div>
+      <div style={{
+        flex: 1,
+        minHeight: 0,
+        fontSize: isLarge ? 60 : 32,
+        lineHeight: isLarge ? 1.35 : 1.4,
+        color: "white",
         fontFamily: "Noto Sans SC",
-        letterSpacing: "0.04em",
+        fontWeight: 400,
+        // -webkit-line-clamp truncates cleanly at line N with an ellipsis,
+        // instead of clipping mid-character when the section's flex-sized
+        // body area is shorter than the text content.
+        display: "-webkit-box",
+        WebkitLineClamp: isLarge ? 3 : 5,
+        WebkitBoxOrient: "vertical",
+        overflow: "hidden",
+        overflowWrap: "break-word",
       }}>
-        {title}
-      </span>
-    </div>
-    <div style={{
-      fontSize: 32,
-      lineHeight: 1.4,
-      color: "white",
-      fontFamily: "Noto Sans SC",
-      fontWeight: 400,
-      overflow: "hidden",
-    }}>
-      {body}
+        {cleanBody}
+      </div>
     </div>
-  </div>
-);
+  );
+};
 
-interface ContentProps {
-  contentTop: number;
-  contentHeight: number;
-  pad: number;
-  contentWidth?: number;
-  leftColWidth?: number;
-  colGap?: number;
-  rightColWidth?: number;
+interface PortraitContentProps {
   palette: { from: string; to: string; accent: string };
   socialPreview?: { filename: string };
   starHistory?: { filename: string };
   github: NonNullable<RemotionScene["github"]>;
 }
 
-const LandscapeContent: React.FC<ContentProps> = ({
-  contentTop, contentHeight, pad, contentWidth, leftColWidth, colGap, rightColWidth,
+// 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={{
-      position: "absolute",
-      top: contentTop,
-      left: pad,
-      width: contentWidth,
-      height: contentHeight,
+      flex: 1,
       display: "flex",
-      gap: colGap,
+      flexDirection: "column",
+      gap: 28,
+      paddingTop: 24,
+      minHeight: 0,
     }}>
-      {/* Left column: images stacked with natural heights (same width,
-          heights determined by each image's aspect ratio). Hidden entirely
-          when neither is present. */}
+      {/* 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={{
-          width: leftColWidth,
-          height: contentHeight,
           display: "flex",
-          flexDirection: "column",
           gap: 20,
-          overflow: "hidden",
+          alignItems: "flex-start",
         }}>
           {socialPreview && (
-            <ImageFrame>
+            <ImageFrame flex={1}>
               <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
             </ImageFrame>
           )}
           {starHistory && (
-            <ImageFrame>
+            <ImageFrame flex={1}>
               <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
             </ImageFrame>
           )}
         </div>
       )}
 
-      {/* Right column: 3 sections (flex:1 fills full width when no images) */}
+      {/* Bottom: 2 sections stacked (full height when no images) */}
       <div style={{
         flex: 1,
-        height: contentHeight,
         display: "flex",
         flexDirection: "column",
-        gap: 20,
+        gap: 28,
       }}>
-        <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} size="large" />
+        <Section title="建议" accent={palette.accent} body={github.review} size="large" />
       </div>
     </div>
   );
 };
 
-const PortraitContent: React.FC<ContentProps> = ({
-  contentTop, contentHeight, pad, contentWidth, palette,
-  socialPreview, starHistory, github,
+interface LandscapeContentProps {
+  contentTop: number;
+  contentHeight: number;
+  pad: number;
+  contentWidth: number;
+  palette: { from: string; to: string; accent: string };
+  socialPreview?: { filename: string };
+  starHistory?: { filename: string };
+  github: NonNullable<RemotionScene["github"]>;
+}
+
+const LandscapeContent: React.FC<LandscapeContentProps> = ({
+  contentTop, contentHeight, pad, contentWidth,
+  palette, socialPreview, starHistory, github,
 }) => {
   const hasAnyImage = !!(socialPreview || starHistory);
+  // Left column: cap width so both images stacked fit within contentHeight.
+  // Assumes social preview ~2:1 and star history ~1.5:1 → combined height 7/6.
+  const leftColWidth = hasAnyImage
+    ? Math.min(
+        Math.round(contentWidth * 0.4),
+        Math.round((contentHeight - 20) / (7 / 6)),
+      )
+    : 0;
+  const colGap = 40;
+
   return (
     <div style={{
       position: "absolute",
       top: contentTop,
       left: pad,
-      width: contentWidth!,
+      width: contentWidth,
       height: contentHeight,
       display: "flex",
-      flexDirection: "column",
-      gap: 20,
+      gap: colGap,
     }}>
-      {/* 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={{
+          width: leftColWidth,
+          height: contentHeight,
           display: "flex",
+          flexDirection: "column",
           gap: 20,
-          alignItems: "flex-start",
+          overflow: "hidden",
         }}>
           {socialPreview && (
-            <ImageFrame flex={1}>
+            <ImageFrame>
               <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
             </ImageFrame>
           )}
           {starHistory && (
-            <ImageFrame flex={1}>
+            <ImageFrame>
               <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
             </ImageFrame>
           )}
         </div>
       )}
 
-      {/* Bottom: 3 sections stacked (full height when no images) */}
       <div style={{
         flex: 1,
+        height: contentHeight,
         display: "flex",
         flexDirection: "column",
-        gap: 16,
+        gap: 20,
       }}>
         <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
         <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} />