|
|
@@ -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} />
|