| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- 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;
|