|
@@ -0,0 +1,396 @@
|
|
|
|
|
+import React from "react";
|
|
|
|
|
+import {
|
|
|
|
|
+ useCurrentFrame,
|
|
|
|
|
+ useVideoConfig,
|
|
|
|
|
+ AbsoluteFill,
|
|
|
|
|
+ Img,
|
|
|
|
|
+ staticFile,
|
|
|
|
|
+ interpolate,
|
|
|
|
|
+} from "remotion";
|
|
|
|
|
+import type { RemotionScene } from "../Root";
|
|
|
|
|
+import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
|
|
|
|
|
+import { SubtitleBar } from "../base/components/subtitle-bar";
|
|
|
|
|
+import { Watermark } from "../base/components/watermark";
|
|
|
|
|
+import { formatCount } from "@pipeline/shared";
|
|
|
|
|
+
|
|
|
|
|
+interface GithubTrendingSceneProps {
|
|
|
|
|
+ scene: RemotionScene;
|
|
|
|
|
+ sceneIndex: number;
|
|
|
|
|
+ totalScenes: number;
|
|
|
|
|
+ totalFrames: number;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ channelName?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
|
|
|
|
|
+ scene,
|
|
|
|
|
+ sceneIndex,
|
|
|
|
|
+ title,
|
|
|
|
|
+ channelName,
|
|
|
|
|
+}) => {
|
|
|
|
|
+ const frame = useCurrentFrame();
|
|
|
|
|
+ const { width, height } = useVideoConfig();
|
|
|
|
|
+ const isPortrait = height > width;
|
|
|
|
|
+ const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
|
|
|
|
|
+ const github = scene.github;
|
|
|
|
|
+
|
|
|
|
|
+ const sceneDur = scene.endFrame - scene.startFrame;
|
|
|
|
|
+ const fadeOpacity = interpolate(
|
|
|
|
|
+ frame,
|
|
|
|
|
+ [0, 8, sceneDur - 8, sceneDur],
|
|
|
|
|
+ [0, 1, 1, 0],
|
|
|
|
|
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // Social preview image is images[0], star history is images[1] (per LLM
|
|
|
|
|
+ // prompt ordering — see packages/shared/src/llm/prompts/parse-text.ts).
|
|
|
|
|
+ const socialPreview = scene.images?.[0];
|
|
|
|
|
+ const starHistory = scene.images?.[1];
|
|
|
|
|
+
|
|
|
|
|
+ // -- Layout geometry (px) --
|
|
|
|
|
+ const pad = isPortrait ? 60 : 80;
|
|
|
|
|
+ const headerHeight = isPortrait ? 220 : 180;
|
|
|
|
|
+ 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 repo = github?.repo;
|
|
|
|
|
+ const language = repo?.language || "";
|
|
|
|
|
+ const languageColor = repo?.languageColor || palette.accent;
|
|
|
|
|
+ const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
|
|
|
|
|
+ const license = repo?.license || "";
|
|
|
|
|
+ const fullName = repo?.fullName || scene.displayText || "";
|
|
|
|
|
+
|
|
|
|
|
+ if (!github) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute", inset: 0, display: "flex",
|
|
|
|
|
+ alignItems: "center", justifyContent: "center", padding: pad,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
|
|
|
|
|
+ {scene.displayText || scene.narration}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {scene.wordTimestamps.length > 0 && <SubtitleBar wordTimestamps={scene.wordTimestamps} />}
|
|
|
|
|
+ <Watermark text={channelName} />
|
|
|
|
|
+ </AbsoluteFill>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const colGap = isPortrait ? 0 : 40;
|
|
|
|
|
+ const leftColWidth = isPortrait ? 0 : Math.round(contentWidth * 0.4);
|
|
|
|
|
+ const rightColWidth = isPortrait ? contentWidth : contentWidth - leftColWidth - colGap;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <AbsoluteFill style={{
|
|
|
|
|
+ opacity: fadeOpacity,
|
|
|
|
|
+ background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {/* Subtle grid overlay (matches other templates' aesthetic) */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute", inset: 0,
|
|
|
|
|
+ backgroundImage: `
|
|
|
|
|
+ linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
|
|
|
|
+ linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
|
|
|
|
|
+ `,
|
|
|
|
|
+ backgroundSize: "80px 80px",
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Top accent line */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute", top: 0, left: 0, width: "100%", height: 4,
|
|
|
|
|
+ background: `linear-gradient(90deg, ${palette.accent}, ${palette.accent}88)`,
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Header: fullName + tags */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: pad,
|
|
|
|
|
+ left: pad,
|
|
|
|
|
+ right: pad,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: isPortrait ? 56 : 60,
|
|
|
|
|
+ fontWeight: 800,
|
|
|
|
|
+ color: "white",
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ lineHeight: 1.15,
|
|
|
|
|
+ letterSpacing: "-0.01em",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {fullName}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 12, marginTop: 18 }}>
|
|
|
|
|
+ {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>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Divider */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: dividerY,
|
|
|
|
|
+ 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}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {scene.wordTimestamps.length > 0 && (
|
|
|
|
|
+ <SubtitleBar wordTimestamps={scene.wordTimestamps} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Watermark text={channelName} />
|
|
|
|
|
+ </AbsoluteFill>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// --- Sub-components ---
|
|
|
|
|
+
|
|
|
|
|
+const Tag: React.FC<{ accent: string; children: React.ReactNode }> = ({ accent, children }) => (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ display: "inline-flex",
|
|
|
|
|
+ alignItems: "center",
|
|
|
|
|
+ gap: 8,
|
|
|
|
|
+ padding: "8px 18px",
|
|
|
|
|
+ borderRadius: 8,
|
|
|
|
|
+ background: "rgba(255,255,255,0.08)",
|
|
|
|
|
+ border: `1px solid ${accent}55`,
|
|
|
|
|
+ color: "white",
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ fontSize: 22,
|
|
|
|
|
+ fontWeight: 500,
|
|
|
|
|
+ backdropFilter: "blur(4px)",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </div>
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+const ColorDot: React.FC<{ color: string }> = ({ color }) => (
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: "inline-block",
|
|
|
|
|
+ width: 10,
|
|
|
|
|
+ height: 10,
|
|
|
|
|
+ borderRadius: "50%",
|
|
|
|
|
+ background: color || "#ccc",
|
|
|
|
|
+ }} />
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+const ImageFrame: React.FC<{ children: React.ReactNode; minHeight?: number; flex?: number }> = ({ children, minHeight = 0, flex }) => (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: flex ?? 1,
|
|
|
|
|
+ minHeight,
|
|
|
|
|
+ background: "rgba(255,255,255,0.04)",
|
|
|
|
|
+ border: "1px solid rgba(255,255,255,0.10)",
|
|
|
|
|
+ borderRadius: 12,
|
|
|
|
|
+ overflow: "hidden",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ alignItems: "center",
|
|
|
|
|
+ justifyContent: "center",
|
|
|
|
|
+ padding: 12,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </div>
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+const Section: React.FC<{
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ 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: "20px 24px",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 10,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ alignItems: "center",
|
|
|
|
|
+ gap: 10,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: "inline-block",
|
|
|
|
|
+ width: 4,
|
|
|
|
|
+ height: 20,
|
|
|
|
|
+ background: accent,
|
|
|
|
|
+ borderRadius: 2,
|
|
|
|
|
+ }} />
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ fontSize: 22,
|
|
|
|
|
+ fontWeight: 700,
|
|
|
|
|
+ color: accent,
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ letterSpacing: "0.04em",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {title}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize: 26,
|
|
|
|
|
+ lineHeight: 1.5,
|
|
|
|
|
+ color: "white",
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ fontWeight: 400,
|
|
|
|
|
+ overflow: "hidden",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {body}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+interface ContentProps {
|
|
|
|
|
+ contentTop: number;
|
|
|
|
|
+ contentHeight: number;
|
|
|
|
|
+ pad: number;
|
|
|
|
|
+ contentWidth?: number;
|
|
|
|
|
+ leftColWidth?: number;
|
|
|
|
|
+ colGap?: number;
|
|
|
|
|
+ rightColWidth?: number;
|
|
|
|
|
+ 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,
|
|
|
|
|
+ palette, socialPreview, starHistory, github,
|
|
|
|
|
+}) => {
|
|
|
|
|
+ const hasAnyImage = !!(socialPreview || starHistory);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: contentTop,
|
|
|
|
|
+ left: pad,
|
|
|
|
|
+ width: contentWidth,
|
|
|
|
|
+ height: contentHeight,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ gap: colGap,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {/* Left column: images stacked (hidden entirely when neither is present) */}
|
|
|
|
|
+ {hasAnyImage && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: leftColWidth,
|
|
|
|
|
+ height: contentHeight,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 20,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {socialPreview && (
|
|
|
|
|
+ <ImageFrame flex={1}>
|
|
|
|
|
+ <Img src={staticFile(socialPreview.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {starHistory && (
|
|
|
|
|
+ <ImageFrame flex={1}>
|
|
|
|
|
+ <Img src={staticFile(starHistory.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Right column: 3 sections (flex:1 fills full width when no images) */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ height: contentHeight,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 20,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
|
|
|
|
|
+ <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={1.4} />
|
|
|
|
|
+ <Section title="点评推荐" accent={palette.accent} body={github.review} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const PortraitContent: React.FC<ContentProps> = ({
|
|
|
|
|
+ contentTop, contentHeight, pad, contentWidth, palette,
|
|
|
|
|
+ socialPreview, starHistory, github,
|
|
|
|
|
+}) => {
|
|
|
|
|
+ const hasAnyImage = !!(socialPreview || starHistory);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: contentTop,
|
|
|
|
|
+ left: pad,
|
|
|
|
|
+ width: contentWidth!,
|
|
|
|
|
+ height: contentHeight,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 20,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {/* Top row: images side-by-side (or single full-width; hidden when neither) */}
|
|
|
|
|
+ {hasAnyImage && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ height: Math.round(contentHeight * 0.42),
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ gap: 20,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {socialPreview && (
|
|
|
|
|
+ <ImageFrame flex={1}>
|
|
|
|
|
+ <Img src={staticFile(socialPreview.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {starHistory && (
|
|
|
|
|
+ <ImageFrame flex={1}>
|
|
|
|
|
+ <Img src={staticFile(starHistory.filename)} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Bottom: 3 sections stacked (full height when no images) */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 16,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
|
|
|
|
|
+ <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={1.4} />
|
|
|
|
|
+ <Section title="点评推荐" accent={palette.accent} body={github.review} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export default GithubTrendingScene;
|