index.tsx 15 KB

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