Root.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. import React, { useEffect, useState } from "react";
  2. import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion";
  3. import type { TemplateType, AspectRatio } from "@pipeline/shared";
  4. import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
  5. import NewsScene from "./news/index";
  6. import KnowledgeScene from "./knowledge/index";
  7. import OpinionScene from "./opinion/index";
  8. import MarketingScene from "./marketing/index";
  9. import GithubTrendingScene from "./github-trending/index";
  10. import { THEMES } from "./base/theme/colors";
  11. export interface RemotionScene {
  12. id: string;
  13. sceneType: "cover" | "content" | "summary" | "outro";
  14. startFrame: number;
  15. endFrame: number;
  16. narration: string;
  17. displayText?: string;
  18. title?: string;
  19. wordTimestamps: WordTimestamp[];
  20. keyframes: Array<{
  21. type: string;
  22. content: string;
  23. startFrame?: number;
  24. endFrame?: number;
  25. style?: Record<string, string>;
  26. }>;
  27. images?: Array<{ url?: string; query?: string; filename: string }>;
  28. audioFilename: string;
  29. backgroundAsset?: { id: string; filename: string };
  30. layoutHint: string;
  31. github?: GithubSceneData;
  32. // github-trending cover scene only: theme tags + trend line.
  33. coverTags?: string[];
  34. trendSummary?: string;
  35. }
  36. export interface RemotionProps {
  37. scenes: RemotionScene[];
  38. template: TemplateType;
  39. platform: string;
  40. title: string;
  41. subtitle?: string;
  42. outro?: {
  43. text: string;
  44. narration?: string;
  45. cta?: string;
  46. };
  47. channelName: string;
  48. globalStyle: {
  49. tone: string;
  50. pace: string;
  51. };
  52. }
  53. const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
  54. news: NewsScene,
  55. knowledge: KnowledgeScene,
  56. opinion: OpinionScene,
  57. marketing: MarketingScene,
  58. "github-trending": GithubTrendingScene,
  59. };
  60. const RATIO_ID: Record<AspectRatio, string> = {
  61. "16:9": "landscape",
  62. "9:16": "portrait",
  63. };
  64. const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
  65. "16:9": { width: 1920, height: 1080 },
  66. "9:16": { width: 1080, height: 1920 },
  67. };
  68. const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
  69. const TemplateComposition: React.FC<RemotionProps> = (props) => {
  70. const SceneComponent = SCENE_MAP[props.template];
  71. const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
  72. return (
  73. <AbsoluteFill style={{ backgroundColor: "#000" }}>
  74. {props.scenes.map((scene) => {
  75. const duration = scene.endFrame - scene.startFrame;
  76. const sceneAudio = scene.audioFilename ? (
  77. <Audio src={staticFile(scene.audioFilename)} />
  78. ) : null;
  79. if (scene.sceneType === "cover") {
  80. return (
  81. <Sequence
  82. key={scene.id}
  83. from={scene.startFrame}
  84. durationInFrames={duration}
  85. >
  86. {sceneAudio}
  87. <CoverScene
  88. template={props.template}
  89. title={props.title}
  90. subtitle={props.subtitle}
  91. keyframes={scene.keyframes}
  92. backgroundAsset={scene.backgroundAsset}
  93. coverTags={scene.coverTags}
  94. trendSummary={scene.trendSummary}
  95. totalFrames={totalFrames}
  96. />
  97. </Sequence>
  98. );
  99. }
  100. if (scene.sceneType === "outro") {
  101. return (
  102. <Sequence
  103. key={scene.id}
  104. from={scene.startFrame}
  105. durationInFrames={duration}
  106. >
  107. {sceneAudio}
  108. <OutroScene
  109. text={props.outro?.text || scene.narration}
  110. cta={props.outro?.cta}
  111. totalFrames={totalFrames}
  112. />
  113. </Sequence>
  114. );
  115. }
  116. return (
  117. <Sequence
  118. key={scene.id}
  119. from={scene.startFrame}
  120. durationInFrames={duration}
  121. >
  122. {sceneAudio}
  123. <SceneComponent
  124. scene={scene}
  125. sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
  126. totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
  127. totalFrames={totalFrames}
  128. title={props.title}
  129. channelName={props.channelName}
  130. />
  131. </Sequence>
  132. );
  133. })}
  134. <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
  135. </AbsoluteFill>
  136. );
  137. };
  138. // --- Cover Scene ---
  139. const CoverScene: React.FC<{
  140. template: TemplateType;
  141. title: string;
  142. subtitle?: string;
  143. keyframes: Array<{ type: string; content: string }>;
  144. backgroundAsset?: { id: string; filename: string };
  145. coverTags?: string[];
  146. trendSummary?: string;
  147. totalFrames: number;
  148. }> = ({ template, title, subtitle, keyframes, backgroundAsset, coverTags, trendSummary }) => {
  149. const accent = THEMES[template].primaryLight;
  150. const isGithubTrending = template === "github-trending";
  151. // github-trending cover doubles as the opening narration screen — light
  152. // background, large masthead typography, no dark image overlay. It also
  153. // shows the day's theme tags (coverTags) and a one-line trend summary
  154. // (trendSummary), both LLM-produced.
  155. if (isGithubTrending) {
  156. const hasTags = !!(coverTags && coverTags.length > 0);
  157. return (
  158. <AbsoluteFill style={{
  159. background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 60%, #cbd5e1 100%)",
  160. }}>
  161. {/* Top accent bar in template primary */}
  162. <div style={{
  163. position: "absolute", top: 0, left: 0, width: "100%", height: 8,
  164. background: `linear-gradient(90deg, ${THEMES[template].primary}, ${accent})`,
  165. }} />
  166. <div style={{
  167. position: "absolute", inset: 0, display: "flex",
  168. flexDirection: "column", justifyContent: "center", alignItems: "center",
  169. padding: 80, textAlign: "center",
  170. }}>
  171. <div style={{
  172. fontSize: 104, fontWeight: 800, color: "#0f172a",
  173. fontFamily: "Noto Sans SC", lineHeight: 1.15,
  174. marginBottom: 28, letterSpacing: "-0.02em",
  175. }}>
  176. {title}
  177. </div>
  178. {subtitle && (
  179. <div style={{
  180. fontSize: 50, fontWeight: 600, color: "#475569",
  181. fontFamily: "Noto Sans SC",
  182. padding: "12px 36px",
  183. borderRadius: 16,
  184. background: "rgba(255,255,255,0.7)",
  185. border: `2px solid ${accent}`,
  186. boxShadow: "0 8px 24px rgba(15,23,42,0.08)",
  187. marginBottom: hasTags || trendSummary ? 44 : 0,
  188. }}>
  189. {subtitle}
  190. </div>
  191. )}
  192. {hasTags && (
  193. <div style={{
  194. display: "flex", flexWrap: "wrap", justifyContent: "center",
  195. gap: 18, maxWidth: "82%", marginBottom: trendSummary ? 36 : 0,
  196. }}>
  197. {coverTags!.map((tag, i) => (
  198. <div key={i} style={{
  199. display: "inline-flex", alignItems: "center", gap: 12,
  200. padding: "12px 26px", borderRadius: 12,
  201. background: "rgba(255,255,255,0.78)",
  202. border: `1.5px solid ${accent}66`,
  203. color: "#1e293b",
  204. fontFamily: "Noto Sans SC", fontSize: 34, fontWeight: 600,
  205. boxShadow: "0 2px 10px rgba(15,23,42,0.06)",
  206. }}>
  207. <span style={{
  208. display: "inline-block", width: 11, height: 11,
  209. borderRadius: "50%", background: accent, flexShrink: 0,
  210. }} />
  211. {tag}
  212. </div>
  213. ))}
  214. </div>
  215. )}
  216. {trendSummary && (
  217. <div style={{
  218. fontSize: 40, fontWeight: 500, color: "#475569",
  219. fontFamily: "Noto Sans SC", maxWidth: "70%", lineHeight: 1.5,
  220. }}>
  221. {trendSummary}
  222. </div>
  223. )}
  224. </div>
  225. </AbsoluteFill>
  226. );
  227. }
  228. return (
  229. <AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
  230. {backgroundAsset && (
  231. <Img
  232. src={staticFile(backgroundAsset.filename)}
  233. style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover" }}
  234. />
  235. )}
  236. <div
  237. style={{
  238. position: "absolute",
  239. inset: 0,
  240. background:
  241. "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.55))",
  242. }}
  243. />
  244. <div style={{
  245. position: "absolute", inset: 0, display: "flex",
  246. flexDirection: "column", justifyContent: "center", alignItems: "center",
  247. padding: 80, textAlign: "center",
  248. }}>
  249. <div style={{
  250. fontSize: 72, fontWeight: 800, color: "white",
  251. fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 20,
  252. maxWidth: "80%",
  253. textShadow: "0 2px 16px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.85)",
  254. }}>
  255. {title}
  256. </div>
  257. {subtitle && (
  258. <div style={{
  259. fontSize: 28, color: "white",
  260. fontFamily: "Noto Sans SC", marginBottom: 40,
  261. textShadow: "0 2px 12px rgba(0,0,0,0.85)",
  262. }}>
  263. {subtitle}
  264. </div>
  265. )}
  266. {keyframes.length > 0 && (
  267. <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
  268. {keyframes.map((kf, i) => {
  269. const isHighlight = kf.type === "highlight";
  270. return (
  271. <div key={i} style={{
  272. fontSize: isHighlight ? 30 : 24,
  273. fontWeight: isHighlight ? 700 : 500,
  274. color: isHighlight ? accent : "white",
  275. fontFamily: "Noto Sans SC",
  276. textShadow: "0 1px 8px rgba(0,0,0,0.85)",
  277. }}>
  278. {kf.content}
  279. </div>
  280. );
  281. })}
  282. </div>
  283. )}
  284. </div>
  285. </AbsoluteFill>
  286. );
  287. };
  288. // --- Outro Scene ---
  289. const OutroScene: React.FC<{
  290. text: string;
  291. cta?: string;
  292. totalFrames: number;
  293. }> = ({ text, cta }) => {
  294. const frame = useCurrentFrame();
  295. const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
  296. return (
  297. <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
  298. <div style={{
  299. position: "absolute", inset: 0, display: "flex",
  300. flexDirection: "column", justifyContent: "center", alignItems: "center",
  301. padding: 80, textAlign: "center",
  302. }}>
  303. <div style={{
  304. fontSize: 48, fontWeight: 700, color: "white",
  305. fontFamily: "Noto Sans SC", lineHeight: 1.4, marginBottom: 40,
  306. maxWidth: "80%",
  307. }}>
  308. {text}
  309. </div>
  310. {cta && (
  311. <div style={{
  312. fontSize: 28, fontWeight: 600, color: "#fbbf24",
  313. fontFamily: "Noto Sans SC",
  314. padding: "16px 48px",
  315. borderRadius: 12,
  316. border: "2px solid #fbbf24",
  317. }}>
  318. {cta}
  319. </div>
  320. )}
  321. </div>
  322. </AbsoluteFill>
  323. );
  324. };
  325. const GlobalProgressBar: React.FC<{
  326. totalFrames: number;
  327. color: string;
  328. }> = ({ totalFrames, color }) => {
  329. const frame = useCurrentFrame();
  330. const progress = interpolate(frame, [0, totalFrames], [0, 100], {
  331. extrapolateRight: "clamp",
  332. });
  333. return (
  334. <div
  335. style={{
  336. position: "absolute",
  337. bottom: 0,
  338. left: 0,
  339. width: "100%",
  340. height: 4,
  341. backgroundColor: "rgba(255,255,255,0.1)",
  342. }}
  343. >
  344. <div
  345. style={{
  346. width: `${progress}%`,
  347. height: "100%",
  348. backgroundColor: color,
  349. }}
  350. />
  351. </div>
  352. );
  353. };
  354. const defaultProps: RemotionProps = {
  355. scenes: [
  356. {
  357. id: "cover",
  358. sceneType: "cover",
  359. startFrame: 0,
  360. endFrame: 90,
  361. narration: "",
  362. wordTimestamps: [],
  363. audioFilename: "",
  364. keyframes: [
  365. { type: "text", content: "文本转视频,一键生成" },
  366. ],
  367. layoutHint: "centered",
  368. },
  369. {
  370. id: "scene-1",
  371. sceneType: "content",
  372. startFrame: 90,
  373. endFrame: 180,
  374. narration: "欢迎使用 Pipeline 视频生成工具",
  375. wordTimestamps: [],
  376. audioFilename: "",
  377. keyframes: [
  378. { type: "text", content: "支持资讯、知识、观点、营销四大模板" },
  379. ],
  380. layoutHint: "centered",
  381. },
  382. {
  383. id: "outro",
  384. sceneType: "outro",
  385. startFrame: 180,
  386. endFrame: 240,
  387. narration: "感谢观看",
  388. wordTimestamps: [],
  389. audioFilename: "",
  390. keyframes: [],
  391. layoutHint: "centered",
  392. },
  393. ],
  394. template: "news",
  395. platform: "bilibili",
  396. title: "Pipeline Demo",
  397. subtitle: "结构化视频生成",
  398. outro: { text: "感谢观看", cta: "点赞 | 关注" },
  399. channelName: "Pipeline",
  400. globalStyle: { tone: "formal", pace: "normal" },
  401. };
  402. export const RemotionRoot: React.FC = () => {
  403. // Load bundled Noto Sans SC at component mount (staticFile() requires the
  404. // bundle context which is only ready after the component tree mounts).
  405. const [fontHandle] = useState(() => delayRender("Loading Noto Sans SC"));
  406. useEffect(() => {
  407. let cancelled = false;
  408. try {
  409. const style = document.createElement("style");
  410. style.textContent = `
  411. @font-face {
  412. font-family: "Noto Sans SC";
  413. src: local("Noto Sans CJK SC"),
  414. url(${staticFile("fonts/NotoSansSC-Regular.ttf")}) format("truetype");
  415. font-weight: 400;
  416. font-display: block;
  417. }
  418. @font-face {
  419. font-family: "Noto Sans SC";
  420. src: local("Noto Sans CJK SC"),
  421. url(${staticFile("fonts/NotoSansSC-Bold.ttf")}) format("truetype");
  422. font-weight: 700;
  423. font-display: block;
  424. }
  425. `;
  426. document.head.appendChild(style);
  427. const fonts = document.fonts as unknown as {
  428. load: (font: string) => Promise<unknown>;
  429. };
  430. Promise.all([
  431. fonts.load('400 16px "Noto Sans SC"'),
  432. fonts.load('700 16px "Noto Sans SC"'),
  433. ])
  434. .then(() => {
  435. if (!cancelled) continueRender(fontHandle);
  436. })
  437. .catch((err) => {
  438. console.error("Font loading failed:", err);
  439. if (!cancelled) continueRender(fontHandle);
  440. });
  441. } catch (err) {
  442. console.error("Font init failed:", err);
  443. continueRender(fontHandle);
  444. }
  445. return () => {
  446. cancelled = true;
  447. };
  448. }, [fontHandle]);
  449. return (
  450. <>
  451. {TEMPLATES.map((template) =>
  452. (
  453. Object.entries(ASPECT_RATIOS) as [
  454. AspectRatio,
  455. { width: number; height: number },
  456. ][]
  457. ).map(([ratio, dims]) => (
  458. <Composition
  459. key={`${template}-${RATIO_ID[ratio]}`}
  460. id={`${template}-${RATIO_ID[ratio]}`}
  461. component={TemplateComposition as unknown as React.FC<Record<string, unknown>>}
  462. durationInFrames={180}
  463. fps={30}
  464. width={dims.width}
  465. height={dims.height}
  466. defaultProps={defaultProps}
  467. calculateMetadata={async (params) => {
  468. const p = params.props as unknown as RemotionProps;
  469. return {
  470. durationInFrames: p.scenes.at(-1)?.endFrame ?? 90,
  471. props: params.props,
  472. };
  473. }}
  474. />
  475. ))
  476. )}
  477. </>
  478. );
  479. };