index.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  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_DAILY_PICK_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. import { ColorDot } from "../github-trending/index";
  17. interface GithubDailyPickSceneProps {
  18. scene: RemotionScene;
  19. sceneIndex: number;
  20. totalScenes: number;
  21. totalFrames: number;
  22. title: string;
  23. channelName?: string;
  24. }
  25. /** Dark-stage tag chip — the deep-dive board keeps the dark look, so it can't
  26. * reuse github-trending's (now light) Tag. Dark glass chip, light accent. */
  27. const Tag: React.FC<{ accent: string; children: React.ReactNode; size?: "default" | "large" }> = ({ accent, children, size = "default" }) => (
  28. <div style={{
  29. display: "inline-flex",
  30. alignItems: "center",
  31. gap: 10,
  32. padding: "12px 24px",
  33. borderRadius: 10,
  34. background: "rgba(255,255,255,0.08)",
  35. border: `1px solid ${accent}55`,
  36. color: "white",
  37. fontFamily: "Noto Sans SC",
  38. fontSize: size === "large" ? 32 : 28,
  39. fontWeight: 500,
  40. backdropFilter: "blur(4px)",
  41. }}>
  42. {children}
  43. </div>
  44. );
  45. /**
  46. * github-daily-pick content scene — one ACT chapter of the day's six-act
  47. * recommendation (钩子/痛点/亮相/亮点/上手/总结). Unlike the board templates (headline = repo fullName), here the scene
  48. * title is the act ("快速上手"), the repo identity moves to a
  49. * compact sub-line, and the body is the scene's keyframe cards plus the repo's
  50. * social preview. A top-right progress badge (03 / 07) reinforces the
  51. * deep-dive chapter structure.
  52. */
  53. const GithubDailyPickScene: React.FC<GithubDailyPickSceneProps> = ({
  54. scene,
  55. sceneIndex,
  56. totalScenes,
  57. channelName,
  58. }) => {
  59. const frame = useCurrentFrame();
  60. const { width, height, durationInFrames } = useVideoConfig();
  61. const isPortrait = height > width;
  62. const palette = GITHUB_DAILY_PICK_PALETTE[sceneIndex % GITHUB_DAILY_PICK_PALETTE.length];
  63. const github: RenderSegmentExtension | undefined =
  64. scene.extension?.type === "github-daily-pick" ? scene.extension : undefined;
  65. const sceneDur = durationInFrames;
  66. const fadeOpacity = interpolate(
  67. frame,
  68. [0, 8, sceneDur - 8, sceneDur],
  69. [0, 1, 1, 0],
  70. { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  71. );
  72. const socialPreview = github?.images?.socialPreview;
  73. const starHistory = github?.images?.starHistory;
  74. const pad = isPortrait ? 48 : 80;
  75. const footerReserved = isPortrait ? 500 : 140;
  76. const repo = github?.repo;
  77. const language = repo?.language || "";
  78. const languageColor = repo?.languageColor || palette.accent;
  79. const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
  80. const license = repo?.license || "";
  81. const fullName = repo?.fullName || "";
  82. if (!github) {
  83. return (
  84. <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
  85. <div style={{
  86. position: "absolute", inset: 0, display: "flex",
  87. alignItems: "center", justifyContent: "center", padding: pad,
  88. }}>
  89. <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
  90. {scene.title || scene.captionOrigin}
  91. </div>
  92. </div>
  93. {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
  94. <Watermark text={channelName} />
  95. </AbsoluteFill>
  96. );
  97. }
  98. // The scene's cards, ordered as produced. Card kinds are free-form strings
  99. // (功能简介 / 快速上手 / 对比 / 安装 ...); shown as titled sections.
  100. const cards = (scene.cardList ?? []).filter((c) => (c.desc ?? "").trim());
  101. const badge = `${String(sceneIndex + 1).padStart(2, "0")} / ${String(totalScenes).padStart(2, "0")}`;
  102. const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
  103. <div>
  104. {/* Aspect title — the chapter headline (from displayText) */}
  105. <div style={{
  106. fontSize,
  107. fontWeight: 800,
  108. color: "white",
  109. fontFamily: "Noto Sans SC",
  110. lineHeight: 1.15,
  111. letterSpacing: "-0.01em",
  112. }}>
  113. {scene.title || fullName}
  114. </div>
  115. {/* Repo identity — compact sub-line under the act title */}
  116. <div style={{
  117. display: "flex", flexWrap: "wrap", alignItems: "center", gap: 16, marginTop: 16,
  118. }}>
  119. <span style={{
  120. fontSize: tagSize === "large" ? 36 : 30, fontWeight: 600,
  121. color: "rgba(255,255,255,0.72)", fontFamily: "Noto Sans SC",
  122. }}>
  123. {fullName}
  124. </span>
  125. {language && (
  126. <Tag accent={palette.accent} size={tagSize}>
  127. <ColorDot color={languageColor} size={dotSize} />
  128. {language}
  129. </Tag>
  130. )}
  131. {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
  132. {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
  133. </div>
  134. </div>
  135. );
  136. const renderBadge = (scale: number) => (
  137. <div style={{
  138. display: "flex", alignItems: "center", gap: 10,
  139. padding: "12px 26px", borderRadius: 12,
  140. background: "rgba(0,0,0,0.35)",
  141. border: `1px solid ${palette.accent}66`,
  142. fontFamily: "Noto Sans SC",
  143. backdropFilter: "blur(4px)",
  144. }}>
  145. <span style={{ fontSize: 30 * scale, fontWeight: 700, color: palette.accent }}>
  146. {String(sceneIndex + 1).padStart(2, "0")}
  147. </span>
  148. <span style={{ fontSize: 22 * scale, fontWeight: 500, color: "rgba(255,255,255,0.55)" }}>
  149. / {String(totalScenes).padStart(2, "0")}
  150. </span>
  151. </div>
  152. );
  153. const cardsRow = (size: "default" | "large") => (
  154. <div style={{
  155. flex: 1, display: "flex", flexDirection: "column", gap: 20, minHeight: 0,
  156. }}>
  157. {cards.slice(0, size === "large" ? 2 : 3).map((card, i) => (
  158. <AspectSection
  159. key={i}
  160. title={card.kind || "要点"}
  161. accent={palette.accent}
  162. body={card.desc ?? ""}
  163. size={size}
  164. />
  165. ))}
  166. </div>
  167. );
  168. return (
  169. <AbsoluteFill style={{
  170. opacity: fadeOpacity,
  171. background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
  172. }}>
  173. {/* Subtle grid overlay (matches other templates' aesthetic) */}
  174. <div style={{
  175. position: "absolute", inset: 0,
  176. backgroundImage: `
  177. linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
  178. linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
  179. `,
  180. backgroundSize: "80px 80px",
  181. }} />
  182. {/* Top accent line */}
  183. <div style={{
  184. position: "absolute", top: 0, left: 0, width: "100%", height: 4,
  185. background: `linear-gradient(90deg, ${palette.accent}, ${palette.accent}88)`,
  186. }} />
  187. {isPortrait ? (
  188. <div style={{
  189. position: "relative",
  190. display: "flex",
  191. flexDirection: "column",
  192. width: "100%",
  193. height: "100%",
  194. boxSizing: "border-box",
  195. padding: `${pad}px ${pad}px ${footerReserved}px`,
  196. }}>
  197. <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20 }}>
  198. <div style={{ flex: 1, minWidth: 0 }}>{renderHeader(84, "large", 16)}</div>
  199. {renderBadge(0.9)}
  200. </div>
  201. <div style={{ height: 1, background: "rgba(255,255,255,0.18)", marginTop: 22 }} />
  202. <div style={{
  203. flex: 1, display: "flex", flexDirection: "column", gap: 24, paddingTop: 22, minHeight: 0,
  204. }}>
  205. {socialPreview && (
  206. <ImageFrame>
  207. <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  208. </ImageFrame>
  209. )}
  210. {cardsRow("large")}
  211. </div>
  212. </div>
  213. ) : (
  214. <>
  215. <div style={{
  216. position: "absolute",
  217. top: pad,
  218. left: pad,
  219. right: pad,
  220. display: "flex",
  221. alignItems: "flex-start",
  222. justifyContent: "space-between",
  223. gap: 24,
  224. }}>
  225. <div style={{ flex: 1, minWidth: 0 }}>{renderHeader(64, "default", 14)}</div>
  226. {renderBadge(1)}
  227. </div>
  228. <div style={{
  229. position: "absolute",
  230. top: pad + 150,
  231. left: pad,
  232. right: pad,
  233. height: 1,
  234. background: "rgba(255,255,255,0.18)",
  235. }} />
  236. <div style={{
  237. position: "absolute",
  238. top: pad + 174,
  239. left: pad,
  240. right: pad,
  241. bottom: footerReserved,
  242. display: "flex",
  243. gap: 40,
  244. }}>
  245. {socialPreview && (
  246. <div style={{
  247. width: "38%",
  248. display: "flex",
  249. flexDirection: "column",
  250. gap: 20,
  251. overflow: "hidden",
  252. }}>
  253. <ImageFrame>
  254. <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  255. </ImageFrame>
  256. {starHistory && (
  257. <ImageFrame>
  258. <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
  259. </ImageFrame>
  260. )}
  261. </div>
  262. )}
  263. {cardsRow("default")}
  264. </div>
  265. </>
  266. )}
  267. {(scene.caption?.length ?? 0) > 0 && (
  268. <SubtitleBar
  269. caption={scene.caption}
  270. style={isPortrait ? { bottom: 380 } : undefined}
  271. fontSize={isPortrait ? 56 : undefined}
  272. />
  273. )}
  274. <Watermark text={channelName} />
  275. </AbsoluteFill>
  276. );
  277. };
  278. const ImageFrame: React.FC<{ children: React.ReactNode }> = ({ children }) => (
  279. <div style={{
  280. width: "100%",
  281. height: "auto",
  282. background: "#0b1220",
  283. border: "1px solid rgba(255,255,255,0.10)",
  284. borderRadius: 16,
  285. overflow: "hidden",
  286. display: "flex",
  287. alignItems: "center",
  288. justifyContent: "center",
  289. boxShadow: "0 12px 32px rgba(0,0,0,0.45), 0 2px 8px rgba(0,0,0,0.35)",
  290. }}>
  291. {children}
  292. </div>
  293. );
  294. const AspectSection: React.FC<{
  295. title: string;
  296. accent: string;
  297. body: string;
  298. size?: "default" | "large";
  299. }> = ({ title, accent, body, size = "default" }) => {
  300. const isLarge = size === "large";
  301. const cleanBody = (body ?? "").replace(/[。,、,.;;::\s]+$/, "");
  302. return (
  303. <div style={{
  304. flex: 1,
  305. background: "rgba(255,255,255,0.05)",
  306. border: "1px solid rgba(255,255,255,0.10)",
  307. borderRadius: 12,
  308. padding: isLarge ? "24px 32px" : "22px 28px",
  309. display: "flex",
  310. flexDirection: "column",
  311. gap: 12,
  312. minHeight: 0,
  313. }}>
  314. <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
  315. <span style={{
  316. display: "inline-block",
  317. width: isLarge ? 6 : 5,
  318. height: isLarge ? 32 : 26,
  319. background: accent,
  320. borderRadius: 2,
  321. }} />
  322. <span style={{
  323. fontSize: isLarge ? 34 : 28,
  324. fontWeight: 700,
  325. color: accent,
  326. fontFamily: "Noto Sans SC",
  327. letterSpacing: "0.04em",
  328. }}>
  329. {title}
  330. </span>
  331. </div>
  332. <div style={{
  333. flex: "0 0 auto",
  334. fontSize: isLarge ? 52 : 32,
  335. lineHeight: isLarge ? 1.35 : 1.4,
  336. color: "white",
  337. fontFamily: "Noto Sans SC",
  338. fontWeight: 400,
  339. display: "-webkit-box",
  340. WebkitLineClamp: isLarge ? 3 : 4,
  341. WebkitBoxOrient: "vertical",
  342. overflow: "hidden",
  343. overflowWrap: "break-word",
  344. }}>
  345. {cleanBody}
  346. </div>
  347. </div>
  348. );
  349. };
  350. export default GithubDailyPickScene;