Root.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. import React, { useEffect, useState } from "react";
  2. import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
  3. import type { TemplateType, AspectRatio } from "@pipeline/shared";
  4. import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
  5. import { formatCount } from "@pipeline/shared";
  6. import NewsScene from "./news/index";
  7. import KnowledgeScene from "./knowledge/index";
  8. import OpinionScene from "./opinion/index";
  9. import MarketingScene from "./marketing/index";
  10. import GithubTrendingScene, { ColorDot } from "./github-trending/index";
  11. import { THEMES } from "./base/theme/colors";
  12. export interface RemotionScene {
  13. id: string;
  14. sceneType: "cover" | "content" | "summary" | "outro";
  15. startFrame: number;
  16. endFrame: number;
  17. narration: string;
  18. displayText?: string;
  19. title?: string;
  20. wordTimestamps: WordTimestamp[];
  21. keyframes: Array<{
  22. type: string;
  23. content: string;
  24. startFrame?: number;
  25. endFrame?: number;
  26. style?: Record<string, string>;
  27. }>;
  28. images?: Array<{ url?: string; query?: string; filename: string }>;
  29. audioFilename: string;
  30. backgroundAsset?: { id: string; filename: string };
  31. layoutHint: string;
  32. github?: GithubSceneData;
  33. // github-trending cover scene only: theme tags + trend line.
  34. coverTags?: string[];
  35. trendSummary?: string;
  36. }
  37. export interface RemotionProps {
  38. scenes: RemotionScene[];
  39. template: TemplateType;
  40. platform: string;
  41. title: string;
  42. subtitle?: string;
  43. outro?: {
  44. text: string;
  45. narration?: string;
  46. cta?: string;
  47. };
  48. channelName: string;
  49. globalStyle: {
  50. tone: string;
  51. pace: string;
  52. };
  53. }
  54. const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
  55. news: NewsScene,
  56. knowledge: KnowledgeScene,
  57. opinion: OpinionScene,
  58. marketing: MarketingScene,
  59. "github-trending": GithubTrendingScene,
  60. };
  61. const RATIO_ID: Record<AspectRatio, string> = {
  62. "16:9": "landscape",
  63. "9:16": "portrait",
  64. };
  65. const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
  66. "16:9": { width: 1920, height: 1080 },
  67. "9:16": { width: 1080, height: 1920 },
  68. };
  69. const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
  70. const TemplateComposition: React.FC<RemotionProps> = (props) => {
  71. const SceneComponent = SCENE_MAP[props.template];
  72. const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
  73. // github-trending: the cover lists every repo (name / language / today's
  74. // star gain / one-line description). Content scenes each carry scene.github.
  75. const coverRepos = props.scenes.filter(
  76. (s) => s.sceneType === "content" && s.github
  77. );
  78. return (
  79. <AbsoluteFill style={{ backgroundColor: "#000" }}>
  80. {props.scenes.map((scene) => {
  81. const duration = scene.endFrame - scene.startFrame;
  82. const sceneAudio = scene.audioFilename ? (
  83. <Audio src={staticFile(scene.audioFilename)} />
  84. ) : null;
  85. if (scene.sceneType === "cover") {
  86. return (
  87. <Sequence
  88. key={scene.id}
  89. from={scene.startFrame}
  90. durationInFrames={duration}
  91. >
  92. {sceneAudio}
  93. <CoverScene
  94. template={props.template}
  95. title={props.title}
  96. subtitle={props.subtitle}
  97. keyframes={scene.keyframes}
  98. backgroundAsset={scene.backgroundAsset}
  99. coverRepos={coverRepos}
  100. trendSummary={scene.trendSummary}
  101. totalFrames={totalFrames}
  102. />
  103. </Sequence>
  104. );
  105. }
  106. if (scene.sceneType === "outro") {
  107. return (
  108. <Sequence
  109. key={scene.id}
  110. from={scene.startFrame}
  111. durationInFrames={duration}
  112. >
  113. {sceneAudio}
  114. <OutroScene
  115. text={props.outro?.text || scene.narration}
  116. cta={props.outro?.cta}
  117. totalFrames={totalFrames}
  118. />
  119. </Sequence>
  120. );
  121. }
  122. return (
  123. <Sequence
  124. key={scene.id}
  125. from={scene.startFrame}
  126. durationInFrames={duration}
  127. >
  128. {sceneAudio}
  129. <SceneComponent
  130. scene={scene}
  131. sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
  132. totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
  133. totalFrames={totalFrames}
  134. title={props.title}
  135. channelName={props.channelName}
  136. />
  137. </Sequence>
  138. );
  139. })}
  140. <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
  141. {props.template === "github-trending" && <ChapterToc scenes={props.scenes} />}
  142. </AbsoluteFill>
  143. );
  144. };
  145. // --- Cover Scene ---
  146. // Faint, sparse code/data motifs on the github-trending cover background.
  147. // Low-opacity and edge-anchored so the cover stays clean — the motifs read
  148. // only as subtle texture, never competing with the cards.
  149. const CoverDecor: React.FC<{ accent: string }> = ({ accent }) => {
  150. const tok = (css: React.CSSProperties): React.CSSProperties => ({
  151. position: "absolute",
  152. fontFamily: "monospace",
  153. fontWeight: 700,
  154. color: "#0f172a",
  155. opacity: 0.06,
  156. letterSpacing: "0.02em",
  157. ...css,
  158. });
  159. return (
  160. <div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
  161. <span style={tok({ top: 44, left: 52, fontSize: 56 })}>{"</>"}</span>
  162. <span style={tok({ top: 58, right: 60, fontSize: 48 })}>{"{ }"}</span>
  163. <span style={tok({ bottom: 168, left: 54, fontSize: 28 })}>{"01 10 01"}</span>
  164. <span style={tok({ bottom: 150, right: 58, fontSize: 26 })}>{"git push"}</span>
  165. {/* Faint data line chart */}
  166. <svg width="180" height="84" viewBox="0 0 180 84" style={{ position: "absolute", top: 64, right: 168, opacity: 0.08 }}>
  167. <polyline points="0,66 34,50 68,56 102,28 136,34 180,8" fill="none" stroke={accent} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
  168. </svg>
  169. {/* Faint data bar cluster */}
  170. <div style={{ position: "absolute", bottom: 206, left: 80, display: "flex", alignItems: "flex-end", gap: 7, opacity: 0.08 }}>
  171. <div style={{ width: 12, height: 32, background: accent, borderRadius: 3 }} />
  172. <div style={{ width: 12, height: 58, background: accent, borderRadius: 3 }} />
  173. <div style={{ width: 12, height: 24, background: accent, borderRadius: 3 }} />
  174. <div style={{ width: 12, height: 76, background: accent, borderRadius: 3 }} />
  175. <div style={{ width: 12, height: 46, background: accent, borderRadius: 3 }} />
  176. </div>
  177. </div>
  178. );
  179. };
  180. const CoverScene: React.FC<{
  181. template: TemplateType;
  182. title: string;
  183. subtitle?: string;
  184. keyframes: Array<{ type: string; content: string }>;
  185. backgroundAsset?: { id: string; filename: string };
  186. coverRepos?: RemotionScene[];
  187. trendSummary?: string;
  188. totalFrames: number;
  189. }> = ({ template, title, subtitle, keyframes, backgroundAsset, coverRepos, trendSummary }) => {
  190. const accent = THEMES[template].primaryLight;
  191. const isGithubTrending = template === "github-trending";
  192. const { width, height } = useVideoConfig();
  193. const isPortrait = height > width;
  194. // github-trending cover: a DARK title card (contrast over a light bg with
  195. // faint code/data motifs) centered near the top, then the TOP 3 repos by
  196. // today's star gain as large rounded "floating" cards — horizontal row in
  197. // landscape, vertical stack in portrait. Doubles as the opening narration.
  198. if (isGithubTrending) {
  199. const topRepos = [...(coverRepos ?? [])]
  200. .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
  201. .slice(0, 6);
  202. const primary = THEMES[template].primary;
  203. return (
  204. <AbsoluteFill style={{
  205. background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 55%, #e2e8f0 100%)",
  206. }}>
  207. <CoverDecor accent={accent} />
  208. {/* Top accent line */}
  209. <div style={{
  210. position: "absolute", top: 0, left: 0, width: "100%", height: 6,
  211. background: `linear-gradient(90deg, ${primary}, ${accent})`,
  212. }} />
  213. <div style={{
  214. position: "absolute", inset: 0, display: "flex", flexDirection: "column",
  215. alignItems: "center",
  216. justifyContent: isPortrait ? "center" : "flex-start",
  217. padding: isPortrait ? "72px 64px 130px" : "58px 80px 112px",
  218. }}>
  219. {/* DARK title card — centered, near the top (contrast over light bg) */}
  220. <div style={{
  221. display: "flex", flexDirection: "column", alignItems: "center", gap: 14,
  222. padding: isPortrait ? "38px 76px" : "32px 88px",
  223. borderRadius: 26,
  224. background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
  225. border: `1.5px solid ${primary}55`,
  226. boxShadow: "0 18px 44px rgba(15,23,42,0.22)",
  227. marginBottom: isPortrait ? 44 : 38,
  228. maxWidth: isPortrait ? "84%" : "74%",
  229. textAlign: "center",
  230. }}>
  231. <div style={{
  232. fontSize: isPortrait ? 72 : 66, fontWeight: 800, color: "#ffffff",
  233. fontFamily: "Noto Sans SC", lineHeight: 1.15, letterSpacing: "-0.02em",
  234. }}>
  235. {title}
  236. </div>
  237. <div style={{
  238. display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
  239. justifyContent: "center",
  240. }}>
  241. {subtitle && (
  242. <span style={{
  243. fontSize: isPortrait ? 30 : 26, fontWeight: 600, color: accent,
  244. fontFamily: "Noto Sans SC",
  245. }}>
  246. {subtitle}
  247. </span>
  248. )}
  249. {trendSummary && (
  250. <span style={{
  251. fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#cbd5e1",
  252. fontFamily: "Noto Sans SC",
  253. }}>
  254. · {trendSummary}
  255. </span>
  256. )}
  257. </div>
  258. </div>
  259. {/* TOP 6 repos by today's star gain (3 per row → wraps to a grid) */}
  260. {topRepos.length > 0 && (
  261. <div style={{
  262. display: "flex", flexWrap: "wrap",
  263. gap: 24,
  264. width: "100%",
  265. maxWidth: isPortrait ? "90%" : "94%",
  266. }}>
  267. {topRepos.map((s, i) => {
  268. const g = s.github!;
  269. const repo = g.repo;
  270. const language = repo.language || "";
  271. const languageColor = repo.languageColor || accent;
  272. const todayLabel = repo.todayStars != null ? `+${formatCount(repo.todayStars)}` : "";
  273. return (
  274. <div key={s.id} style={{
  275. flex: isPortrait ? "0 0 calc((100% - 24px) / 2)" : "0 0 calc((100% - 48px) / 3)",
  276. minWidth: 0,
  277. display: "flex", flexDirection: "column",
  278. padding: isPortrait ? "28px 30px" : "30px",
  279. borderRadius: 22,
  280. background: "#ffffff",
  281. border: `1px solid ${primary}22`,
  282. boxShadow: "0 16px 38px rgba(15,23,42,0.12), 0 2px 8px rgba(15,23,42,0.06)",
  283. }}>
  284. {/* Rank badge + today's gain (the selection signal) */}
  285. <div style={{
  286. display: "flex", alignItems: "center", justifyContent: "space-between",
  287. marginBottom: 18,
  288. }}>
  289. <div style={{
  290. display: "inline-flex", alignItems: "center", justifyContent: "center",
  291. width: isPortrait ? 56 : 52, height: isPortrait ? 56 : 52,
  292. borderRadius: "50%",
  293. background: `linear-gradient(135deg, ${accent}, ${primary})`,
  294. color: "#fff", fontWeight: 800, fontFamily: "Noto Sans SC",
  295. fontSize: isPortrait ? 30 : 26,
  296. boxShadow: "0 4px 12px rgba(34,197,94,0.35)",
  297. }}>
  298. {i + 1}
  299. </div>
  300. {todayLabel && (
  301. <span style={{
  302. fontSize: isPortrait ? 56 : 50, fontWeight: 800, color: primary,
  303. fontFamily: "Noto Sans SC", lineHeight: 1,
  304. }}>{todayLabel}</span>
  305. )}
  306. </div>
  307. {/* Repo full name */}
  308. <div style={{
  309. fontSize: isPortrait ? 40 : 34, fontWeight: 800, color: "#0f172a",
  310. fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 12,
  311. wordBreak: "break-word",
  312. }}>
  313. {repo.fullName}
  314. </div>
  315. {/* Language + total stars */}
  316. <div style={{
  317. display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
  318. marginBottom: 14, fontFamily: "Noto Sans SC",
  319. }}>
  320. {language && (
  321. <span style={{
  322. display: "inline-flex", alignItems: "center", gap: 8,
  323. fontSize: isPortrait ? 24 : 21, fontWeight: 600, color: "#334155",
  324. padding: "5px 14px", borderRadius: 999,
  325. background: `${languageColor}1a`, border: `1px solid ${languageColor}55`,
  326. }}>
  327. <ColorDot color={languageColor} size={isPortrait ? 14 : 12} />
  328. {language}
  329. </span>
  330. )}
  331. {repo.stars != null && (
  332. <span style={{
  333. fontSize: isPortrait ? 23 : 20, color: "#64748b", fontWeight: 500,
  334. }}>
  335. {formatCount(repo.stars)} stars
  336. </span>
  337. )}
  338. </div>
  339. {/* One-line description (highlights) */}
  340. <div style={{
  341. fontSize: isPortrait ? 27 : 23, lineHeight: 1.4, color: "#475569",
  342. fontFamily: "Noto Sans SC",
  343. display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
  344. overflow: "hidden",
  345. }}>
  346. {g.highlights}
  347. </div>
  348. </div>
  349. );
  350. })}
  351. </div>
  352. )}
  353. </div>
  354. </AbsoluteFill>
  355. );
  356. }
  357. return (
  358. <AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
  359. {backgroundAsset && (
  360. <Img
  361. src={staticFile(backgroundAsset.filename)}
  362. style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover" }}
  363. />
  364. )}
  365. <div
  366. style={{
  367. position: "absolute",
  368. inset: 0,
  369. background:
  370. "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.55))",
  371. }}
  372. />
  373. <div style={{
  374. position: "absolute", inset: 0, display: "flex",
  375. flexDirection: "column", justifyContent: "center", alignItems: "center",
  376. padding: 80, textAlign: "center",
  377. }}>
  378. <div style={{
  379. fontSize: 72, fontWeight: 800, color: "white",
  380. fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 20,
  381. maxWidth: "80%",
  382. textShadow: "0 2px 16px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.85)",
  383. }}>
  384. {title}
  385. </div>
  386. {subtitle && (
  387. <div style={{
  388. fontSize: 28, color: "white",
  389. fontFamily: "Noto Sans SC", marginBottom: 40,
  390. textShadow: "0 2px 12px rgba(0,0,0,0.85)",
  391. }}>
  392. {subtitle}
  393. </div>
  394. )}
  395. {keyframes.length > 0 && (
  396. <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
  397. {keyframes.map((kf, i) => {
  398. const isHighlight = kf.type === "highlight";
  399. return (
  400. <div key={i} style={{
  401. fontSize: isHighlight ? 30 : 24,
  402. fontWeight: isHighlight ? 700 : 500,
  403. color: isHighlight ? accent : "white",
  404. fontFamily: "Noto Sans SC",
  405. textShadow: "0 1px 8px rgba(0,0,0,0.85)",
  406. }}>
  407. {kf.content}
  408. </div>
  409. );
  410. })}
  411. </div>
  412. )}
  413. </div>
  414. </AbsoluteFill>
  415. );
  416. };
  417. // --- Outro Scene ---
  418. const OutroScene: React.FC<{
  419. text: string;
  420. cta?: string;
  421. totalFrames: number;
  422. }> = ({ text, cta }) => {
  423. const frame = useCurrentFrame();
  424. const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
  425. return (
  426. <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
  427. <div style={{
  428. position: "absolute", inset: 0, display: "flex",
  429. flexDirection: "column", justifyContent: "center", alignItems: "center",
  430. padding: 80, textAlign: "center",
  431. }}>
  432. <div style={{
  433. fontSize: 48, fontWeight: 700, color: "white",
  434. fontFamily: "Noto Sans SC", lineHeight: 1.4, marginBottom: 40,
  435. maxWidth: "80%",
  436. }}>
  437. {text}
  438. </div>
  439. {cta && (
  440. <div style={{
  441. fontSize: 28, fontWeight: 600, color: "#fbbf24",
  442. fontFamily: "Noto Sans SC",
  443. padding: "16px 48px",
  444. borderRadius: 12,
  445. border: "2px solid #fbbf24",
  446. }}>
  447. {cta}
  448. </div>
  449. )}
  450. </div>
  451. </AbsoluteFill>
  452. );
  453. };
  454. const GlobalProgressBar: React.FC<{
  455. totalFrames: number;
  456. color: string;
  457. }> = ({ totalFrames, color }) => {
  458. const frame = useCurrentFrame();
  459. const progress = interpolate(frame, [0, totalFrames], [0, 100], {
  460. extrapolateRight: "clamp",
  461. });
  462. return (
  463. <div
  464. style={{
  465. position: "absolute",
  466. bottom: 0,
  467. left: 0,
  468. width: "100%",
  469. height: 4,
  470. backgroundColor: "rgba(255,255,255,0.1)",
  471. }}
  472. >
  473. <div
  474. style={{
  475. width: `${progress}%`,
  476. height: "100%",
  477. backgroundColor: color,
  478. }}
  479. />
  480. </div>
  481. );
  482. };
  483. const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
  484. // github-trending only: a chapter table of contents pinned just above the
  485. // global progress bar. Lists every repo's short name; the chapter whose
  486. // [startFrame, endFrame) contains the current frame is highlighted. A faint
  487. // dark gradient strip behind the row keeps labels legible over light
  488. // backgrounds (e.g. the cover) and visually groups the TOC with the bar.
  489. const frame = useCurrentFrame();
  490. const { width, height } = useVideoConfig();
  491. const isPortrait = height > width;
  492. const accent = THEMES["github-trending"].primary;
  493. const chapters = scenes.filter((s) => s.sceneType === "content" && s.github);
  494. const active = chapters.findIndex(
  495. (c) => frame >= c.startFrame && frame < c.endFrame
  496. );
  497. return (
  498. <>
  499. <div
  500. style={{
  501. position: "absolute",
  502. bottom: 0,
  503. left: 0,
  504. width: "100%",
  505. height: 72,
  506. background: "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
  507. pointerEvents: "none",
  508. }}
  509. />
  510. <div
  511. style={{
  512. position: "absolute",
  513. bottom: 12,
  514. left: 0,
  515. width: "100%",
  516. display: "flex",
  517. justifyContent: "center",
  518. alignItems: "center",
  519. gap: isPortrait ? 16 : 12,
  520. padding: "0 32px",
  521. fontFamily: "Noto Sans SC",
  522. pointerEvents: "none",
  523. }}
  524. >
  525. {chapters.map((c, i) => {
  526. const isActive = i === active;
  527. const repo = c.github!.repo;
  528. const label = repo.name || repo.fullName || c.displayText || "";
  529. return (
  530. <React.Fragment key={c.id}>
  531. {i > 0 && (
  532. <span
  533. style={{
  534. color: "rgba(255,255,255,0.3)",
  535. fontSize: isPortrait ? 22 : 18,
  536. flexShrink: 0,
  537. }}
  538. >
  539. ·
  540. </span>
  541. )}
  542. <span
  543. style={{
  544. flex: "1 1 0",
  545. minWidth: 0,
  546. fontSize: isPortrait ? 26 : 22,
  547. fontWeight: isActive ? 700 : 500,
  548. color: isActive ? accent : "rgba(255,255,255,0.6)",
  549. whiteSpace: "nowrap",
  550. overflow: "hidden",
  551. textOverflow: "ellipsis",
  552. textAlign: "center",
  553. }}
  554. >
  555. {label}
  556. </span>
  557. </React.Fragment>
  558. );
  559. })}
  560. </div>
  561. </>
  562. );
  563. };
  564. const defaultProps: RemotionProps = {
  565. scenes: [
  566. {
  567. id: "cover",
  568. sceneType: "cover",
  569. startFrame: 0,
  570. endFrame: 90,
  571. narration: "",
  572. wordTimestamps: [],
  573. audioFilename: "",
  574. keyframes: [
  575. { type: "text", content: "文本转视频,一键生成" },
  576. ],
  577. layoutHint: "centered",
  578. },
  579. {
  580. id: "scene-1",
  581. sceneType: "content",
  582. startFrame: 90,
  583. endFrame: 180,
  584. narration: "欢迎使用 Pipeline 视频生成工具",
  585. wordTimestamps: [],
  586. audioFilename: "",
  587. keyframes: [
  588. { type: "text", content: "支持资讯、知识、观点、营销四大模板" },
  589. ],
  590. layoutHint: "centered",
  591. },
  592. {
  593. id: "outro",
  594. sceneType: "outro",
  595. startFrame: 180,
  596. endFrame: 240,
  597. narration: "感谢观看",
  598. wordTimestamps: [],
  599. audioFilename: "",
  600. keyframes: [],
  601. layoutHint: "centered",
  602. },
  603. ],
  604. template: "news",
  605. platform: "bilibili",
  606. title: "Pipeline Demo",
  607. subtitle: "结构化视频生成",
  608. outro: { text: "感谢观看", cta: "点赞 | 关注" },
  609. channelName: "Pipeline",
  610. globalStyle: { tone: "formal", pace: "normal" },
  611. };
  612. export const RemotionRoot: React.FC = () => {
  613. // Load bundled Noto Sans SC at component mount (staticFile() requires the
  614. // bundle context which is only ready after the component tree mounts).
  615. const [fontHandle] = useState(() => delayRender("Loading Noto Sans SC"));
  616. useEffect(() => {
  617. let cancelled = false;
  618. try {
  619. const style = document.createElement("style");
  620. style.textContent = `
  621. @font-face {
  622. font-family: "Noto Sans SC";
  623. src: local("Noto Sans CJK SC"),
  624. url(${staticFile("fonts/NotoSansSC-Regular.ttf")}) format("truetype");
  625. font-weight: 400;
  626. font-display: block;
  627. }
  628. @font-face {
  629. font-family: "Noto Sans SC";
  630. src: local("Noto Sans CJK SC"),
  631. url(${staticFile("fonts/NotoSansSC-Bold.ttf")}) format("truetype");
  632. font-weight: 700;
  633. font-display: block;
  634. }
  635. `;
  636. document.head.appendChild(style);
  637. const fonts = document.fonts as unknown as {
  638. load: (font: string) => Promise<unknown>;
  639. };
  640. Promise.all([
  641. fonts.load('400 16px "Noto Sans SC"'),
  642. fonts.load('700 16px "Noto Sans SC"'),
  643. ])
  644. .then(() => {
  645. if (!cancelled) continueRender(fontHandle);
  646. })
  647. .catch((err) => {
  648. console.error("Font loading failed:", err);
  649. if (!cancelled) continueRender(fontHandle);
  650. });
  651. } catch (err) {
  652. console.error("Font init failed:", err);
  653. continueRender(fontHandle);
  654. }
  655. return () => {
  656. cancelled = true;
  657. };
  658. }, [fontHandle]);
  659. return (
  660. <>
  661. {TEMPLATES.map((template) =>
  662. (
  663. Object.entries(ASPECT_RATIOS) as [
  664. AspectRatio,
  665. { width: number; height: number },
  666. ][]
  667. ).map(([ratio, dims]) => (
  668. <Composition
  669. key={`${template}-${RATIO_ID[ratio]}`}
  670. id={`${template}-${RATIO_ID[ratio]}`}
  671. component={TemplateComposition as unknown as React.FC<Record<string, unknown>>}
  672. durationInFrames={180}
  673. fps={30}
  674. width={dims.width}
  675. height={dims.height}
  676. defaultProps={defaultProps}
  677. calculateMetadata={async (params) => {
  678. const p = params.props as unknown as RemotionProps;
  679. return {
  680. durationInFrames: p.scenes.at(-1)?.endFrame ?? 90,
  681. props: params.props,
  682. };
  683. }}
  684. />
  685. ))
  686. )}
  687. </>
  688. );
  689. };