|
|
@@ -0,0 +1,464 @@
|
|
|
+import React from "react";
|
|
|
+import {
|
|
|
+ useCurrentFrame,
|
|
|
+ useVideoConfig,
|
|
|
+ AbsoluteFill,
|
|
|
+ Img,
|
|
|
+ staticFile,
|
|
|
+ interpolate,
|
|
|
+} from "remotion";
|
|
|
+import type { RemotionScene } from "../Root";
|
|
|
+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 { formatCount } from "@pipeline/shared";
|
|
|
+
|
|
|
+/**
|
|
|
+ * github-weekly content scene — the LIGHT magazine-weekly counterpart of the
|
|
|
+ * github-trending scene. Paper background, dark slate text, editorial section
|
|
|
+ * cards (white cards on paper, the inverse of the daily board's dark-on-dark),
|
|
|
+ * and the same named images (social preview + star history) from the shared
|
|
|
+ * github board extension.
|
|
|
+ */
|
|
|
+interface GithubWeeklySceneProps {
|
|
|
+ scene: RemotionScene;
|
|
|
+ sceneIndex: number;
|
|
|
+ totalScenes: number;
|
|
|
+ totalFrames: number;
|
|
|
+ title: string;
|
|
|
+ channelName?: string;
|
|
|
+}
|
|
|
+
|
|
|
+const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
+ scene,
|
|
|
+ sceneIndex,
|
|
|
+ channelName,
|
|
|
+}) => {
|
|
|
+ const frame = useCurrentFrame();
|
|
|
+ const { width, height, durationInFrames } = useVideoConfig();
|
|
|
+ const isPortrait = height > width;
|
|
|
+ const palette = GITHUB_WEEKLY_PALETTE[sceneIndex % GITHUB_WEEKLY_PALETTE.length];
|
|
|
+ const github: RenderSegmentExtension | undefined =
|
|
|
+ scene.extension?.type === "github-trending" || scene.extension?.type === "github-weekly"
|
|
|
+ ? scene.extension
|
|
|
+ : undefined;
|
|
|
+
|
|
|
+ const sceneDur = durationInFrames;
|
|
|
+ const fadeOpacity = 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.
|
|
|
+ const socialPreview = github?.images?.socialPreview;
|
|
|
+ const starHistory = github?.images?.starHistory;
|
|
|
+
|
|
|
+ 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 || "";
|
|
|
+ const languageColor = repo?.languageColor || palette.accent;
|
|
|
+ // Under the weekly board, todayStars carries the WEEKLY gain.
|
|
|
+ const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
|
|
|
+ const weekGainLabel =
|
|
|
+ repo?.todayStars != null ? `本周 +${formatCount(repo.todayStars)}` : "";
|
|
|
+ const license = repo?.license || "";
|
|
|
+ const fullName = repo?.fullName || scene.title || "";
|
|
|
+
|
|
|
+ if (!github) {
|
|
|
+ return (
|
|
|
+ <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
|
|
|
+ <div style={{
|
|
|
+ position: "absolute", inset: 0, display: "flex",
|
|
|
+ alignItems: "center", justifyContent: "center", padding: pad,
|
|
|
+ }}>
|
|
|
+ <div style={{ fontSize: 48, fontWeight: 700, color: "#1e293b", fontFamily: "Noto Sans SC", textAlign: "center" }}>
|
|
|
+ {scene.title || scene.captionOrigin}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
|
|
|
+ <Watermark text={channelName} light />
|
|
|
+ </AbsoluteFill>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
|
|
|
+ <div>
|
|
|
+ <div style={{
|
|
|
+ fontSize,
|
|
|
+ fontWeight: 800,
|
|
|
+ color: "#1e293b",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ lineHeight: 1.15,
|
|
|
+ letterSpacing: "-0.01em",
|
|
|
+ }}>
|
|
|
+ {fullName}
|
|
|
+ </div>
|
|
|
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
|
|
|
+ {language && (
|
|
|
+ <Tag accent={palette.accent} size={tagSize}>
|
|
|
+ <ColorDot color={languageColor} size={dotSize} />
|
|
|
+ {language}
|
|
|
+ </Tag>
|
|
|
+ )}
|
|
|
+ {weekGainLabel && (
|
|
|
+ <Tag accent={palette.accent} size={tagSize} emphasized>
|
|
|
+ {weekGainLabel}
|
|
|
+ </Tag>
|
|
|
+ )}
|
|
|
+ {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
|
|
|
+ {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <AbsoluteFill style={{
|
|
|
+ opacity: fadeOpacity,
|
|
|
+ background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
|
|
+ }}>
|
|
|
+ {/* Subtle paper ruling (the light counterpart of the daily board's grid) */}
|
|
|
+ <div style={{
|
|
|
+ position: "absolute", inset: 0,
|
|
|
+ backgroundImage: `
|
|
|
+ linear-gradient(rgba(30,41,59,0.03) 1px, transparent 1px),
|
|
|
+ linear-gradient(90deg, rgba(30,41,59,0.03) 1px, transparent 1px)
|
|
|
+ `,
|
|
|
+ backgroundSize: "80px 80px",
|
|
|
+ }} />
|
|
|
+
|
|
|
+ {/* Top accent rule — a double editorial rule, magazine style */}
|
|
|
+ <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 6, background: palette.accent }} />
|
|
|
+ <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
|
|
|
+
|
|
|
+ {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
|
|
|
+ palette={palette}
|
|
|
+ socialPreview={socialPreview}
|
|
|
+ starHistory={starHistory}
|
|
|
+ github={github}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ // Landscape: absolute positioning (single-line 80px header).
|
|
|
+ <>
|
|
|
+ <div style={{
|
|
|
+ position: "absolute",
|
|
|
+ top: pad,
|
|
|
+ left: pad,
|
|
|
+ right: pad,
|
|
|
+ }}>
|
|
|
+ {renderHeader(80, "default", 14)}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div style={{
|
|
|
+ position: "absolute",
|
|
|
+ top: landscapeDividerY,
|
|
|
+ left: pad,
|
|
|
+ right: pad,
|
|
|
+ height: 1,
|
|
|
+ background: "rgba(30,41,59,0.18)",
|
|
|
+ }} />
|
|
|
+
|
|
|
+ <LandscapeContent
|
|
|
+ contentTop={landscapeContentTop}
|
|
|
+ contentHeight={landscapeContentHeight}
|
|
|
+ pad={pad}
|
|
|
+ contentWidth={landscapeContentWidth}
|
|
|
+ palette={palette}
|
|
|
+ socialPreview={socialPreview}
|
|
|
+ starHistory={starHistory}
|
|
|
+ github={github}
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {(scene.caption?.length ?? 0) > 0 && (
|
|
|
+ <SubtitleBar
|
|
|
+ caption={scene.caption}
|
|
|
+ style={isPortrait ? { bottom: 380 } : undefined}
|
|
|
+ fontSize={isPortrait ? 56 : undefined}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ <Watermark text={channelName} light />
|
|
|
+ </AbsoluteFill>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+// --- Sub-components (light counterparts of the github-trending ones) ---
|
|
|
+
|
|
|
+export const Tag: React.FC<{
|
|
|
+ accent: string;
|
|
|
+ children: React.ReactNode;
|
|
|
+ size?: "default" | "large";
|
|
|
+ emphasized?: boolean;
|
|
|
+}> = ({ accent, children, size = "default", emphasized }) => (
|
|
|
+ <div style={{
|
|
|
+ display: "inline-flex",
|
|
|
+ alignItems: "center",
|
|
|
+ gap: 10,
|
|
|
+ padding: "12px 24px",
|
|
|
+ borderRadius: 999,
|
|
|
+ background: emphasized ? accent : "rgba(255,255,255,0.75)",
|
|
|
+ border: `1px solid ${emphasized ? accent : `${accent}44`}`,
|
|
|
+ color: emphasized ? "#ffffff" : "#334155",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ fontSize: size === "large" ? 32 : 28,
|
|
|
+ fontWeight: emphasized ? 700 : 500,
|
|
|
+ backdropFilter: "blur(4px)",
|
|
|
+ }}>
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+);
|
|
|
+
|
|
|
+export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, size = 14 }) => (
|
|
|
+ <span style={{
|
|
|
+ display: "inline-block",
|
|
|
+ width: size,
|
|
|
+ height: size,
|
|
|
+ borderRadius: "50%",
|
|
|
+ background: color || "#999",
|
|
|
+ }} />
|
|
|
+);
|
|
|
+
|
|
|
+const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number }> = ({ children, flex }) => (
|
|
|
+ <div style={{
|
|
|
+ flex,
|
|
|
+ width: flex ? undefined : "100%",
|
|
|
+ height: "auto",
|
|
|
+ background: "#ffffff",
|
|
|
+ border: "1px solid rgba(30,41,59,0.12)",
|
|
|
+ borderRadius: 12,
|
|
|
+ overflow: "hidden",
|
|
|
+ display: "flex",
|
|
|
+ alignItems: "center",
|
|
|
+ justifyContent: "center",
|
|
|
+ boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
|
|
|
+ }}>
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+);
|
|
|
+
|
|
|
+const Section: React.FC<{
|
|
|
+ title: string;
|
|
|
+ accent: string;
|
|
|
+ body: string;
|
|
|
+ flex?: number;
|
|
|
+ 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.82)",
|
|
|
+ border: "1px solid rgba(30,41,59,0.10)",
|
|
|
+ borderLeft: `${isLarge ? 6 : 5}px solid ${accent}`,
|
|
|
+ borderRadius: 10,
|
|
|
+ padding: isLarge ? "24px 32px" : "22px 28px",
|
|
|
+ display: "flex",
|
|
|
+ flexDirection: "column",
|
|
|
+ gap: 12,
|
|
|
+ minHeight: 0,
|
|
|
+ }}>
|
|
|
+ <div style={{
|
|
|
+ display: "flex",
|
|
|
+ alignItems: "center",
|
|
|
+ gap: 12,
|
|
|
+ }}>
|
|
|
+ {/* No color bar (it moved to the card's left border) — editorial
|
|
|
+ small-caps style label with wide tracking instead. */}
|
|
|
+ <span style={{
|
|
|
+ fontSize: isLarge ? 34 : 28,
|
|
|
+ fontWeight: 700,
|
|
|
+ color: accent,
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ letterSpacing: "0.14em",
|
|
|
+ }}>
|
|
|
+ {title}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div style={{
|
|
|
+ // Size to content (capped at N lines by -webkit-line-clamp), not by
|
|
|
+ // flex distribution — mirrors the github-trending Section rationale.
|
|
|
+ flex: "0 0 auto",
|
|
|
+ fontSize: isLarge ? 60 : 32,
|
|
|
+ lineHeight: isLarge ? 1.35 : 1.4,
|
|
|
+ color: "#334155",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ fontWeight: 400,
|
|
|
+ display: "-webkit-box",
|
|
|
+ WebkitLineClamp: isLarge ? 3 : 5,
|
|
|
+ WebkitBoxOrient: "vertical",
|
|
|
+ overflow: "hidden",
|
|
|
+ overflowWrap: "break-word",
|
|
|
+ }}>
|
|
|
+ {cleanBody}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+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;
|
|
|
+ pad: number;
|
|
|
+ contentWidth: number;
|
|
|
+ palette: { from: string; to: string; accent: string };
|
|
|
+ socialPreview?: { filename: string };
|
|
|
+ starHistory?: { filename: string };
|
|
|
+ github: RenderSegmentExtension;
|
|
|
+}
|
|
|
+
|
|
|
+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,
|
|
|
+ height: contentHeight,
|
|
|
+ display: "flex",
|
|
|
+ gap: colGap,
|
|
|
+ }}>
|
|
|
+ {hasAnyImage && (
|
|
|
+ <div style={{
|
|
|
+ width: leftColWidth,
|
|
|
+ height: contentHeight,
|
|
|
+ display: "flex",
|
|
|
+ flexDirection: "column",
|
|
|
+ gap: 20,
|
|
|
+ overflow: "hidden",
|
|
|
+ }}>
|
|
|
+ {socialPreview && (
|
|
|
+ <ImageFrame>
|
|
|
+ <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
+ </ImageFrame>
|
|
|
+ )}
|
|
|
+ {starHistory && (
|
|
|
+ <ImageFrame>
|
|
|
+ <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
+ </ImageFrame>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <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={3} />
|
|
|
+ <Section title="建议" accent={palette.accent} body={github.review} />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default GithubWeeklyScene;
|