index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import React from "react";
  2. import {
  3. useCurrentFrame,
  4. useVideoConfig,
  5. AbsoluteFill,
  6. Img,
  7. staticFile,
  8. interpolate,
  9. } from "remotion";
  10. import type { RemotionScene } from "../Root";
  11. import type { RenderSegmentExtension } from "@pipeline/shared";
  12. import { GITHUB_WEEKLY_PALETTE } from "../base/theme/colors";
  13. import { SubtitleBar } from "../base/components/subtitle-bar";
  14. import { Watermark } from "../base/components/watermark";
  15. import { formatCount } from "@pipeline/shared";
  16. /**
  17. * github-weekly content scene — the LIGHT magazine-weekly counterpart of the
  18. * github-trending scene. Paper background, dark slate text, editorial section
  19. * cards (white cards on paper, the inverse of the daily board's dark-on-dark),
  20. * and the same named images (social preview + star history) from the shared
  21. * github board extension.
  22. */
  23. interface GithubWeeklySceneProps {
  24. scene: RemotionScene;
  25. sceneIndex: number;
  26. totalScenes: number;
  27. totalFrames: number;
  28. title: string;
  29. channelName?: string;
  30. }
  31. const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
  32. scene,
  33. sceneIndex,
  34. channelName,
  35. }) => {
  36. const frame = useCurrentFrame();
  37. const { width, height, durationInFrames } = useVideoConfig();
  38. const isPortrait = height > width;
  39. const palette = GITHUB_WEEKLY_PALETTE[sceneIndex % GITHUB_WEEKLY_PALETTE.length];
  40. const github: RenderSegmentExtension | undefined =
  41. scene.extension?.type === "github-trending" || scene.extension?.type === "github-weekly"
  42. ? scene.extension
  43. : undefined;
  44. const sceneDur = durationInFrames;
  45. const fadeOpacity = interpolate(
  46. frame,
  47. [0, 8, sceneDur - 8, sceneDur],
  48. [0, 1, 1, 0],
  49. { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  50. );
  51. // Named images from the shared board extension (resolved to filenames by the
  52. // renderer). Object, not positional array — socialPreview / starHistory.
  53. const socialPreview = github?.images?.socialPreview;
  54. const starHistory = github?.images?.starHistory;
  55. const pad = isPortrait ? 48 : 80;
  56. const footerReserved = isPortrait ? 500 : 140;
  57. // Landscape geometry (portrait uses flex layout below — no headerHeight).
  58. const landscapeHeaderHeight = 220;
  59. const landscapeDividerY = pad + landscapeHeaderHeight;
  60. const landscapeContentTop = landscapeDividerY + 24;
  61. const landscapeContentHeight = height - footerReserved - landscapeContentTop;
  62. const landscapeContentWidth = width - pad * 2;
  63. const repo = github?.repo;
  64. const language = repo?.language || "";
  65. const languageColor = repo?.languageColor || palette.accent;
  66. // Under the weekly board, todayStars carries the WEEKLY gain.
  67. const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
  68. const weekGainLabel =
  69. repo?.todayStars != null ? `本周 +${formatCount(repo.todayStars)}` : "";
  70. const license = repo?.license || "";
  71. const fullName = repo?.fullName || scene.title || "";
  72. if (!github) {
  73. return (
  74. <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
  75. <div style={{
  76. position: "absolute", inset: 0, display: "flex",
  77. alignItems: "center", justifyContent: "center", padding: pad,
  78. }}>
  79. <div style={{ fontSize: 48, fontWeight: 700, color: "#1e293b", fontFamily: "Noto Sans SC", textAlign: "center" }}>
  80. {scene.title || scene.captionOrigin}
  81. </div>
  82. </div>
  83. {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
  84. <Watermark text={channelName} light />
  85. </AbsoluteFill>
  86. );
  87. }
  88. const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
  89. <div>
  90. <div style={{
  91. fontSize,
  92. fontWeight: 800,
  93. color: "#1e293b",
  94. fontFamily: "Noto Sans SC",
  95. lineHeight: 1.15,
  96. letterSpacing: "-0.01em",
  97. }}>
  98. {fullName}
  99. </div>
  100. <div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
  101. {language && (
  102. <Tag accent={palette.accent} size={tagSize}>
  103. <ColorDot color={languageColor} size={dotSize} />
  104. {language}
  105. </Tag>
  106. )}
  107. {weekGainLabel && (
  108. <Tag accent={palette.accent} size={tagSize} emphasized>
  109. {weekGainLabel}
  110. </Tag>
  111. )}
  112. {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
  113. {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
  114. </div>
  115. </div>
  116. );
  117. return (
  118. <AbsoluteFill style={{
  119. opacity: fadeOpacity,
  120. background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
  121. }}>
  122. {/* Subtle paper ruling (the light counterpart of the daily board's grid) */}
  123. <div style={{
  124. position: "absolute", inset: 0,
  125. backgroundImage: `
  126. linear-gradient(rgba(30,41,59,0.03) 1px, transparent 1px),
  127. linear-gradient(90deg, rgba(30,41,59,0.03) 1px, transparent 1px)
  128. `,
  129. backgroundSize: "80px 80px",
  130. }} />
  131. {/* Top accent rule — a double editorial rule, magazine style */}
  132. <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 6, background: palette.accent }} />
  133. <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
  134. {isPortrait ? (
  135. // Portrait: flex column. Header takes natural height, divider sits
  136. // right below it, content fills the remainder. paddingBottom reserves
  137. // space for the elevated subtitle (bottom: 380).
  138. <div style={{
  139. position: "relative",
  140. display: "flex",
  141. flexDirection: "column",
  142. width: "100%",
  143. height: "100%",
  144. boxSizing: "border-box",
  145. padding: `${pad}px ${pad}px ${footerReserved}px`,
  146. }}>
  147. {renderHeader(92, "large", 16)}
  148. <div style={{
  149. height: 1,
  150. background: "rgba(30,41,59,0.18)",
  151. marginTop: 24,
  152. }} />
  153. <PortraitContent
  154. palette={palette}
  155. socialPreview={socialPreview}
  156. starHistory={starHistory}
  157. github={github}
  158. />
  159. </div>
  160. ) : (
  161. // Landscape: absolute positioning (single-line 80px header).
  162. <>
  163. <div style={{
  164. position: "absolute",
  165. top: pad,
  166. left: pad,
  167. right: pad,
  168. }}>
  169. {renderHeader(80, "default", 14)}
  170. </div>
  171. <div style={{
  172. position: "absolute",
  173. top: landscapeDividerY,
  174. left: pad,
  175. right: pad,
  176. height: 1,
  177. background: "rgba(30,41,59,0.18)",
  178. }} />
  179. <LandscapeContent
  180. contentTop={landscapeContentTop}
  181. contentHeight={landscapeContentHeight}
  182. pad={pad}
  183. contentWidth={landscapeContentWidth}
  184. palette={palette}
  185. socialPreview={socialPreview}
  186. starHistory={starHistory}
  187. github={github}
  188. />
  189. </>
  190. )}
  191. {(scene.caption?.length ?? 0) > 0 && (
  192. <SubtitleBar
  193. caption={scene.caption}
  194. style={isPortrait ? { bottom: 380 } : undefined}
  195. fontSize={isPortrait ? 56 : undefined}
  196. />
  197. )}
  198. <Watermark text={channelName} light />
  199. </AbsoluteFill>
  200. );
  201. };
  202. // --- Sub-components (light counterparts of the github-trending ones) ---
  203. export const Tag: React.FC<{
  204. accent: string;
  205. children: React.ReactNode;
  206. size?: "default" | "large";
  207. emphasized?: boolean;
  208. }> = ({ accent, children, size = "default", emphasized }) => (
  209. <div style={{
  210. display: "inline-flex",
  211. alignItems: "center",
  212. gap: 10,
  213. padding: "12px 24px",
  214. borderRadius: 999,
  215. background: emphasized ? accent : "rgba(255,255,255,0.75)",
  216. border: `1px solid ${emphasized ? accent : `${accent}44`}`,
  217. color: emphasized ? "#ffffff" : "#334155",
  218. fontFamily: "Noto Sans SC",
  219. fontSize: size === "large" ? 32 : 28,
  220. fontWeight: emphasized ? 700 : 500,
  221. backdropFilter: "blur(4px)",
  222. }}>
  223. {children}
  224. </div>
  225. );
  226. export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, size = 14 }) => (
  227. <span style={{
  228. display: "inline-block",
  229. width: size,
  230. height: size,
  231. borderRadius: "50%",
  232. background: color || "#999",
  233. }} />
  234. );
  235. const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number }> = ({ children, flex }) => (
  236. <div style={{
  237. flex,
  238. width: flex ? undefined : "100%",
  239. height: "auto",
  240. background: "#ffffff",
  241. border: "1px solid rgba(30,41,59,0.12)",
  242. borderRadius: 12,
  243. overflow: "hidden",
  244. display: "flex",
  245. alignItems: "center",
  246. justifyContent: "center",
  247. boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
  248. }}>
  249. {children}
  250. </div>
  251. );
  252. const Section: React.FC<{
  253. title: string;
  254. accent: string;
  255. body: string;
  256. flex?: number;
  257. size?: "default" | "large";
  258. }> = ({ title, accent, body, flex, size = "default" }) => {
  259. const isLarge = size === "large";
  260. // Strip trailing punctuation that would look awkward if rendered at the
  261. // end of the clamped text (LLM occasionally leaves a hanging 。,,;).
  262. const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
  263. return (
  264. <div style={{
  265. flex: flex ?? 1,
  266. background: "rgba(255,255,255,0.82)",
  267. border: "1px solid rgba(30,41,59,0.10)",
  268. borderLeft: `${isLarge ? 6 : 5}px solid ${accent}`,
  269. borderRadius: 10,
  270. padding: isLarge ? "24px 32px" : "22px 28px",
  271. display: "flex",
  272. flexDirection: "column",
  273. gap: 12,
  274. minHeight: 0,
  275. }}>
  276. <div style={{
  277. display: "flex",
  278. alignItems: "center",
  279. gap: 12,
  280. }}>
  281. {/* No color bar (it moved to the card's left border) — editorial
  282. small-caps style label with wide tracking instead. */}
  283. <span style={{
  284. fontSize: isLarge ? 34 : 28,
  285. fontWeight: 700,
  286. color: accent,
  287. fontFamily: "Noto Sans SC",
  288. letterSpacing: "0.14em",
  289. }}>
  290. {title}
  291. </span>
  292. </div>
  293. <div style={{
  294. // Size to content (capped at N lines by -webkit-line-clamp), not by
  295. // flex distribution — mirrors the github-trending Section rationale.
  296. flex: "0 0 auto",
  297. fontSize: isLarge ? 60 : 32,
  298. lineHeight: isLarge ? 1.35 : 1.4,
  299. color: "#334155",
  300. fontFamily: "Noto Sans SC",
  301. fontWeight: 400,
  302. display: "-webkit-box",
  303. WebkitLineClamp: isLarge ? 3 : 5,
  304. WebkitBoxOrient: "vertical",
  305. overflow: "hidden",
  306. overflowWrap: "break-word",
  307. }}>
  308. {cleanBody}
  309. </div>
  310. </div>
  311. );
  312. };
  313. interface PortraitContentProps {
  314. palette: { from: string; to: string; accent: string };
  315. socialPreview?: { filename: string };
  316. starHistory?: { filename: string };
  317. github: RenderSegmentExtension;
  318. }
  319. // PortraitContent uses flex:1 to fill remaining height after the header
  320. // (which takes its natural height in the parent flex column).
  321. const PortraitContent: React.FC<PortraitContentProps> = ({
  322. palette, socialPreview, starHistory, github,
  323. }) => {
  324. const hasAnyImage = !!(socialPreview || starHistory);
  325. return (
  326. <div style={{
  327. flex: 1,
  328. display: "flex",
  329. flexDirection: "column",
  330. gap: 28,
  331. paddingTop: 24,
  332. minHeight: 0,
  333. }}>
  334. {/* Top row: images side-by-side, each flex:1 (same width) with natural
  335. height. Row sizes to tallest child; shorter child top-aligned. */}
  336. {hasAnyImage && (
  337. <div style={{
  338. display: "flex",
  339. gap: 20,
  340. alignItems: "flex-start",
  341. }}>
  342. {socialPreview && (
  343. <ImageFrame flex={1}>
  344. <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  345. </ImageFrame>
  346. )}
  347. {starHistory && (
  348. <ImageFrame flex={1}>
  349. <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  350. </ImageFrame>
  351. )}
  352. </div>
  353. )}
  354. {/* Bottom: 2 sections stacked (full height when no images) */}
  355. <div style={{
  356. flex: 1,
  357. display: "flex",
  358. flexDirection: "column",
  359. gap: 28,
  360. }}>
  361. <Section title="项目亮点" accent={palette.accent} body={github.highlights} size="large" />
  362. <Section title="建议" accent={palette.accent} body={github.review} size="large" />
  363. </div>
  364. </div>
  365. );
  366. };
  367. interface LandscapeContentProps {
  368. contentTop: number;
  369. contentHeight: number;
  370. pad: number;
  371. contentWidth: number;
  372. palette: { from: string; to: string; accent: string };
  373. socialPreview?: { filename: string };
  374. starHistory?: { filename: string };
  375. github: RenderSegmentExtension;
  376. }
  377. const LandscapeContent: React.FC<LandscapeContentProps> = ({
  378. contentTop, contentHeight, pad, contentWidth,
  379. palette, socialPreview, starHistory, github,
  380. }) => {
  381. const hasAnyImage = !!(socialPreview || starHistory);
  382. // Left column: cap width so both images stacked fit within contentHeight.
  383. // Assumes social preview ~2:1 and star history ~1.5:1 → combined height 7/6.
  384. const leftColWidth = hasAnyImage
  385. ? Math.min(
  386. Math.round(contentWidth * 0.4),
  387. Math.round((contentHeight - 20) / (7 / 6)),
  388. )
  389. : 0;
  390. const colGap = 40;
  391. return (
  392. <div style={{
  393. position: "absolute",
  394. top: contentTop,
  395. left: pad,
  396. width: contentWidth,
  397. height: contentHeight,
  398. display: "flex",
  399. gap: colGap,
  400. }}>
  401. {hasAnyImage && (
  402. <div style={{
  403. width: leftColWidth,
  404. height: contentHeight,
  405. display: "flex",
  406. flexDirection: "column",
  407. gap: 20,
  408. overflow: "hidden",
  409. }}>
  410. {socialPreview && (
  411. <ImageFrame>
  412. <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  413. </ImageFrame>
  414. )}
  415. {starHistory && (
  416. <ImageFrame>
  417. <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  418. </ImageFrame>
  419. )}
  420. </div>
  421. )}
  422. <div style={{
  423. flex: 1,
  424. height: contentHeight,
  425. display: "flex",
  426. flexDirection: "column",
  427. gap: 20,
  428. }}>
  429. <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
  430. <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} />
  431. <Section title="建议" accent={palette.accent} body={github.review} />
  432. </div>
  433. </div>
  434. );
  435. };
  436. export default GithubWeeklyScene;