Root.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  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, RenderScene, RenderProps } from "@pipeline/shared";
  4. import NewsScene from "./news/index";
  5. import KnowledgeScene from "./knowledge/index";
  6. import OpinionScene from "./opinion/index";
  7. import MarketingScene from "./marketing/index";
  8. import GithubTrendingScene, { ColorDot } from "./github-trending/index";
  9. import { THEMES } from "./base/theme/colors";
  10. // Re-export the render-time contract so legacy imports (`import { RemotionScene
  11. // } from "../Root"`) keep resolving during the migration. New code should import
  12. // RenderScene / RenderProps directly from @pipeline/shared.
  13. export type { RenderScene as RemotionScene, RenderProps as RemotionProps } from "@pipeline/shared";
  14. import type { RenderSegmentExtension } from "@pipeline/shared";
  15. /** Narrow a scene's per-template extension to the github-trending variant, or
  16. * undefined. Template-specific data lives in `extension`, not on the scene root. */
  17. const ghExt = (s: { extension?: RenderScene["extension"] }): RenderSegmentExtension | undefined =>
  18. s.extension?.type === "github-trending" ? s.extension : undefined;
  19. /** Default visual length (seconds) for a scene with no audio (e.g. a silent
  20. * cover). Visual duration is the TEMPLATE's call — it may exceed the audio to
  21. * add silent padding, holds, transitions, etc. */
  22. const SILENT_SCENE_SECONDS = 1;
  23. /**
  24. * Visual duration in FRAMES for a scene — computed by the TEMPLATE, not the
  25. * renderer. `scene.duration` is the AUDIO length only; this is where a template
  26. * decides the on-screen length (default = audio, silent scenes get a small
  27. * floor). Override/extend per template to add silent padding or special timing.
  28. */
  29. function sceneDurationFrames(scene: RenderScene, fps: number): number {
  30. const audio = scene.duration ?? 0;
  31. const seconds = audio > 0 ? audio : SILENT_SCENE_SECONDS;
  32. return Math.max(1, Math.round(seconds * fps));
  33. }
  34. const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
  35. news: NewsScene,
  36. knowledge: KnowledgeScene,
  37. opinion: OpinionScene,
  38. marketing: MarketingScene,
  39. "github-trending": GithubTrendingScene,
  40. };
  41. const RATIO_ID: Record<AspectRatio, string> = {
  42. "16:9": "landscape",
  43. "9:16": "portrait",
  44. };
  45. const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
  46. "16:9": { width: 1920, height: 1080 },
  47. "9:16": { width: 1080, height: 1920 },
  48. };
  49. const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"];
  50. const TemplateComposition: React.FC<RenderProps> = (props) => {
  51. const { fps } = useVideoConfig();
  52. const SceneComponent = SCENE_MAP[props.template];
  53. // github-trending: the cover aggregates the repos' languages into tag
  54. // chips. Content scenes carry repo data in extension.
  55. const coverRepos = props.scenes.filter(
  56. (s) => s.kind === "content" && ghExt(s)
  57. );
  58. // Compute the visual timeline (TEMPLATE-controlled). Each scene's visual
  59. // frames come from sceneDurationFrames (default = audio duration); the
  60. // cumulative cursor places Sequences. Visuals may exceed audio (silent pads).
  61. let cursor = 0;
  62. const layout = props.scenes.map((scene) => {
  63. const durationFrames = sceneDurationFrames(scene, fps);
  64. const startFrame = cursor;
  65. cursor += durationFrames;
  66. return { scene, startFrame, endFrame: startFrame + durationFrames, durationFrames };
  67. });
  68. const totalFrames = cursor || 90;
  69. return (
  70. <AbsoluteFill style={{ backgroundColor: "#000" }}>
  71. {layout.map(({ scene, startFrame, durationFrames }) => {
  72. const sceneAudio = scene.audioFilename ? (
  73. <Audio src={staticFile(scene.audioFilename)} />
  74. ) : null;
  75. if (scene.kind === "cover") {
  76. return (
  77. <Sequence
  78. key={scene.id}
  79. from={startFrame}
  80. durationInFrames={durationFrames}
  81. >
  82. {sceneAudio}
  83. <CoverScene
  84. template={props.template}
  85. title={props.title}
  86. subtitle={props.subtitle}
  87. cardList={scene.cardList}
  88. backgroundAsset={scene.backgroundAsset}
  89. coverRepos={coverRepos}
  90. trendSummary={props.trendSummary}
  91. totalFrames={totalFrames}
  92. />
  93. </Sequence>
  94. );
  95. }
  96. if (scene.kind === "outro") {
  97. return (
  98. <Sequence
  99. key={scene.id}
  100. from={startFrame}
  101. durationInFrames={durationFrames}
  102. >
  103. {sceneAudio}
  104. <OutroScene
  105. text={scene.captionOrigin ?? ""}
  106. cta={scene.title}
  107. totalFrames={totalFrames}
  108. />
  109. </Sequence>
  110. );
  111. }
  112. return (
  113. <Sequence
  114. key={scene.id}
  115. from={startFrame}
  116. durationInFrames={durationFrames}
  117. >
  118. {sceneAudio}
  119. <SceneComponent
  120. scene={scene}
  121. sceneIndex={props.scenes.filter((s) => s.kind === "content").indexOf(scene)}
  122. totalScenes={props.scenes.filter((s) => s.kind === "content").length}
  123. totalFrames={totalFrames}
  124. title={props.title}
  125. channelName={props.channelName}
  126. />
  127. </Sequence>
  128. );
  129. })}
  130. <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
  131. {props.template === "github-trending" && <ChapterToc layout={layout} />}
  132. </AbsoluteFill>
  133. );
  134. };
  135. // --- Cover Scene ---
  136. // Faint, sparse code/data motifs on the github-trending cover background.
  137. // Low-opacity and edge-anchored so the cover stays clean — the motifs read
  138. // only as subtle texture, never competing with the title card. Colors adapt
  139. // to the backdrop: near-black on the light gradient, white on the dark
  140. // scrim over the background image.
  141. const CoverDecor: React.FC<{ accent: string; onDark?: boolean }> = ({ accent, onDark }) => {
  142. const tok = (css: React.CSSProperties): React.CSSProperties => ({
  143. position: "absolute",
  144. fontFamily: "monospace",
  145. fontWeight: 700,
  146. color: onDark ? "#ffffff" : "#0f172a",
  147. opacity: onDark ? 0.08 : 0.06,
  148. letterSpacing: "0.02em",
  149. ...css,
  150. });
  151. const chartOpacity = onDark ? 0.16 : 0.08;
  152. return (
  153. <div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
  154. <span style={tok({ top: 44, left: 52, fontSize: 56 })}>{"</>"}</span>
  155. <span style={tok({ top: 58, right: 60, fontSize: 48 })}>{"{ }"}</span>
  156. <span style={tok({ bottom: 168, left: 54, fontSize: 28 })}>{"01 10 01"}</span>
  157. <span style={tok({ bottom: 150, right: 58, fontSize: 26 })}>{"git push"}</span>
  158. {/* Faint data line chart */}
  159. <svg width="180" height="84" viewBox="0 0 180 84" style={{ position: "absolute", top: 64, right: 168, opacity: chartOpacity }}>
  160. <polyline points="0,66 34,50 68,56 102,28 136,34 180,8" fill="none" stroke={accent} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
  161. </svg>
  162. {/* Faint data bar cluster */}
  163. <div style={{ position: "absolute", bottom: 206, left: 80, display: "flex", alignItems: "flex-end", gap: 7, opacity: chartOpacity }}>
  164. <div style={{ width: 12, height: 32, background: accent, borderRadius: 3 }} />
  165. <div style={{ width: 12, height: 58, background: accent, borderRadius: 3 }} />
  166. <div style={{ width: 12, height: 24, background: accent, borderRadius: 3 }} />
  167. <div style={{ width: 12, height: 76, background: accent, borderRadius: 3 }} />
  168. <div style={{ width: 12, height: 46, background: accent, borderRadius: 3 }} />
  169. </div>
  170. </div>
  171. );
  172. };
  173. const CoverScene: React.FC<{
  174. template: TemplateType;
  175. title: string;
  176. subtitle?: string;
  177. cardList?: Array<{ kind?: string; desc?: string }>;
  178. backgroundAsset?: { id: string; filename: string };
  179. coverRepos?: RenderScene[];
  180. trendSummary?: string;
  181. totalFrames: number;
  182. }> = ({ template, title, subtitle, cardList, backgroundAsset, coverRepos, trendSummary }) => {
  183. const accent = THEMES[template].primaryLight;
  184. const isGithubTrending = template === "github-trending";
  185. const { width, height } = useVideoConfig();
  186. const isPortrait = height > width;
  187. // github-trending cover: the renderer-provided background image (default.png
  188. // fallback — no template-specific bg exists yet) under a dark scrim, with a
  189. // DARK title card fully centered on top — no repo list. Shows only the
  190. // masthead title, the date, the day's main language tags (aggregated across
  191. // today's repos), and the trend summary line. Light gradient + CoverDecor is
  192. // the no-image fallback (e.g. studio demo props).
  193. if (isGithubTrending) {
  194. const primary = THEMES[template].primary;
  195. // Aggregate languages across today's repos → the cover's "main tags".
  196. // Count-weighted, top repo's languageColor wins per language.
  197. const langStats = new Map<string, { count: number; color?: string }>();
  198. for (const s of coverRepos ?? []) {
  199. const repo = ghExt(s)!.repo;
  200. if (!repo.language) continue;
  201. const stats = langStats.get(repo.language) ?? { count: 0, color: repo.languageColor };
  202. stats.count += 1;
  203. langStats.set(repo.language, stats);
  204. }
  205. const topLangs = [...langStats.entries()]
  206. .sort((a, b) => b[1].count - a[1].count)
  207. .slice(0, 6);
  208. return (
  209. <AbsoluteFill style={{
  210. background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 55%, #e2e8f0 100%)",
  211. }}>
  212. {backgroundAsset ? (
  213. <>
  214. <Img
  215. src={staticFile(backgroundAsset.filename)}
  216. style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover" }}
  217. />
  218. <div style={{
  219. position: "absolute", inset: 0,
  220. background: "linear-gradient(to bottom, rgba(2,6,23,0.40), rgba(2,6,23,0.70))",
  221. }} />
  222. </>
  223. ) : undefined}
  224. {/* Code/data motifs over both backdrops (light gradient or scrimmed image) */}
  225. <CoverDecor accent={accent} onDark={!!backgroundAsset} />
  226. {/* Top accent line */}
  227. <div style={{
  228. position: "absolute", top: 0, left: 0, width: "100%", height: 6,
  229. background: `linear-gradient(90deg, ${primary}, ${accent})`,
  230. }} />
  231. <div style={{
  232. position: "absolute", inset: 0, display: "flex", flexDirection: "column",
  233. alignItems: "center",
  234. justifyContent: "center",
  235. padding: isPortrait ? "72px 64px 150px" : "58px 80px 130px",
  236. }}>
  237. {/* DARK title card — centered, title + date + tags + summary */}
  238. <div style={{
  239. display: "flex", flexDirection: "column", alignItems: "center", gap: 20,
  240. padding: isPortrait ? "64px 96px" : "56px 110px",
  241. borderRadius: 26,
  242. background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
  243. border: `1.5px solid ${primary}55`,
  244. boxShadow: "0 24px 64px rgba(0,0,0,0.55), 0 4px 16px rgba(0,0,0,0.4)",
  245. maxWidth: isPortrait ? "90%" : "80%",
  246. textAlign: "center",
  247. }}>
  248. {/* Masthead title */}
  249. <div style={{
  250. fontSize: isPortrait ? 108 : 96, fontWeight: 800, color: "#ffffff",
  251. fontFamily: "Noto Sans SC", lineHeight: 1.12, letterSpacing: "-0.02em",
  252. }}>
  253. {title}
  254. </div>
  255. {/* Date */}
  256. {subtitle && (
  257. <div style={{
  258. fontSize: isPortrait ? 36 : 32, fontWeight: 600, color: accent,
  259. fontFamily: "Noto Sans SC",
  260. }}>
  261. {subtitle}
  262. </div>
  263. )}
  264. {/* Main tags — languages aggregated across today's repos */}
  265. {topLangs.length > 0 && (
  266. <div style={{
  267. display: "flex", flexWrap: "wrap",
  268. alignItems: "center", justifyContent: "center",
  269. gap: 14, marginTop: 6,
  270. }}>
  271. {topLangs.map(([lang, stats]) => (
  272. <span key={lang} style={{
  273. display: "inline-flex", alignItems: "center", gap: 9,
  274. fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#ffffff",
  275. fontFamily: "Noto Sans SC",
  276. padding: "10px 24px", borderRadius: 999,
  277. background: "rgba(255,255,255,0.08)",
  278. border: `1px solid ${(stats.color || accent)}66`,
  279. }}>
  280. <ColorDot color={stats.color || accent} size={isPortrait ? 14 : 12} />
  281. {lang}
  282. </span>
  283. ))}
  284. </div>
  285. )}
  286. {/* Trend summary */}
  287. {trendSummary && (
  288. <>
  289. <div style={{
  290. width: "56%", height: 1, marginTop: 10,
  291. background: "rgba(255,255,255,0.16)",
  292. }} />
  293. <div style={{
  294. fontSize: isPortrait ? 34 : 29, fontWeight: 500, color: "#cbd5e1",
  295. fontFamily: "Noto Sans SC", lineHeight: 1.5,
  296. maxWidth: "92%",
  297. }}>
  298. {trendSummary}
  299. </div>
  300. </>
  301. )}
  302. </div>
  303. </div>
  304. </AbsoluteFill>
  305. );
  306. }
  307. return (
  308. <AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
  309. {backgroundAsset && (
  310. <Img
  311. src={staticFile(backgroundAsset.filename)}
  312. style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover" }}
  313. />
  314. )}
  315. <div
  316. style={{
  317. position: "absolute",
  318. inset: 0,
  319. background:
  320. "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.55))",
  321. }}
  322. />
  323. <div style={{
  324. position: "absolute", inset: 0, display: "flex",
  325. flexDirection: "column", justifyContent: "center", alignItems: "center",
  326. padding: 80, textAlign: "center",
  327. }}>
  328. <div style={{
  329. fontSize: 72, fontWeight: 800, color: "white",
  330. fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 20,
  331. maxWidth: "80%",
  332. textShadow: "0 2px 16px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.85)",
  333. }}>
  334. {title}
  335. </div>
  336. {subtitle && (
  337. <div style={{
  338. fontSize: 28, color: "white",
  339. fontFamily: "Noto Sans SC", marginBottom: 40,
  340. textShadow: "0 2px 12px rgba(0,0,0,0.85)",
  341. }}>
  342. {subtitle}
  343. </div>
  344. )}
  345. {(cardList?.length ?? 0) > 0 && (
  346. <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
  347. {cardList!.map((card, i) => {
  348. const isHighlight = card.kind === "highlight";
  349. return (
  350. <div key={i} style={{
  351. fontSize: isHighlight ? 30 : 24,
  352. fontWeight: isHighlight ? 700 : 500,
  353. color: isHighlight ? accent : "white",
  354. fontFamily: "Noto Sans SC",
  355. textShadow: "0 1px 8px rgba(0,0,0,0.85)",
  356. }}>
  357. {card.desc}
  358. </div>
  359. );
  360. })}
  361. </div>
  362. )}
  363. </div>
  364. </AbsoluteFill>
  365. );
  366. };
  367. // --- Outro Scene ---
  368. const OutroScene: React.FC<{
  369. text: string;
  370. cta?: string;
  371. totalFrames: number;
  372. }> = ({ text, cta }) => {
  373. const frame = useCurrentFrame();
  374. const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
  375. return (
  376. <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
  377. <div style={{
  378. position: "absolute", inset: 0, display: "flex",
  379. flexDirection: "column", justifyContent: "center", alignItems: "center",
  380. padding: 80, textAlign: "center",
  381. }}>
  382. <div style={{
  383. fontSize: 48, fontWeight: 700, color: "white",
  384. fontFamily: "Noto Sans SC", lineHeight: 1.4, marginBottom: 40,
  385. maxWidth: "80%",
  386. }}>
  387. {text}
  388. </div>
  389. {cta && (
  390. <div style={{
  391. fontSize: 28, fontWeight: 600, color: "#fbbf24",
  392. fontFamily: "Noto Sans SC",
  393. padding: "16px 48px",
  394. borderRadius: 12,
  395. border: "2px solid #fbbf24",
  396. }}>
  397. {cta}
  398. </div>
  399. )}
  400. </div>
  401. </AbsoluteFill>
  402. );
  403. };
  404. const GlobalProgressBar: React.FC<{
  405. totalFrames: number;
  406. color: string;
  407. }> = ({ totalFrames, color }) => {
  408. const frame = useCurrentFrame();
  409. const progress = interpolate(frame, [0, totalFrames], [0, 100], {
  410. extrapolateRight: "clamp",
  411. });
  412. return (
  413. <div
  414. style={{
  415. position: "absolute",
  416. bottom: 0,
  417. left: 0,
  418. width: "100%",
  419. height: 4,
  420. backgroundColor: "rgba(255,255,255,0.1)",
  421. }}
  422. >
  423. <div
  424. style={{
  425. width: `${progress}%`,
  426. height: "100%",
  427. backgroundColor: color,
  428. }}
  429. />
  430. </div>
  431. );
  432. };
  433. const ChapterToc: React.FC<{
  434. layout: { scene: RenderScene; startFrame: number; endFrame: number; durationFrames: number }[];
  435. }> = ({ layout }) => {
  436. // github-trending only: a chapter table of contents pinned just above the
  437. // global progress bar. Lists every repo's short name; the chapter whose
  438. // [startFrame, endFrame) contains the current frame is highlighted. A faint
  439. // dark gradient strip behind the row keeps labels legible over light
  440. // backgrounds (e.g. the cover) and visually groups the TOC with the bar.
  441. const frame = useCurrentFrame();
  442. const { width, height } = useVideoConfig();
  443. const isPortrait = height > width;
  444. const accent = THEMES["github-trending"].primary;
  445. const chapters = layout.filter((l) => l.scene.kind === "content" && ghExt(l.scene));
  446. const active = chapters.findIndex((c) => frame >= c.startFrame && frame < c.endFrame);
  447. return (
  448. <>
  449. <div
  450. style={{
  451. position: "absolute",
  452. bottom: 0,
  453. left: 0,
  454. width: "100%",
  455. height: 72,
  456. background: "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
  457. pointerEvents: "none",
  458. }}
  459. />
  460. <div
  461. style={{
  462. position: "absolute",
  463. bottom: 12,
  464. left: 0,
  465. width: "100%",
  466. display: "flex",
  467. justifyContent: "center",
  468. alignItems: "center",
  469. gap: isPortrait ? 16 : 12,
  470. padding: "0 32px",
  471. fontFamily: "Noto Sans SC",
  472. pointerEvents: "none",
  473. }}
  474. >
  475. {chapters.map((c, i) => {
  476. const isActive = i === active;
  477. const repo = ghExt(c.scene)!.repo;
  478. const label = repo.name || repo.fullName || c.scene.title || "";
  479. return (
  480. <React.Fragment key={c.scene.id}>
  481. {i > 0 && (
  482. <span
  483. style={{
  484. color: "rgba(255,255,255,0.3)",
  485. fontSize: isPortrait ? 22 : 18,
  486. flexShrink: 0,
  487. }}
  488. >
  489. ·
  490. </span>
  491. )}
  492. <span
  493. style={{
  494. flex: "1 1 0",
  495. minWidth: 0,
  496. fontSize: isPortrait ? 26 : 22,
  497. fontWeight: isActive ? 700 : 500,
  498. color: isActive ? accent : "rgba(255,255,255,0.6)",
  499. whiteSpace: "nowrap",
  500. overflow: "hidden",
  501. textOverflow: "ellipsis",
  502. textAlign: "center",
  503. }}
  504. >
  505. {label}
  506. </span>
  507. </React.Fragment>
  508. );
  509. })}
  510. </div>
  511. </>
  512. );
  513. };
  514. const defaultProps: RenderProps = {
  515. scenes: [
  516. {
  517. id: "cover",
  518. kind: "cover",
  519. duration: 3,
  520. audioFilename: "",
  521. captionOrigin: "",
  522. cardList: [{ kind: "text", desc: "文本转视频,一键生成" }],
  523. },
  524. {
  525. id: "scene-1",
  526. kind: "content",
  527. duration: 3,
  528. audioFilename: "",
  529. title: "欢迎使用 Pipeline",
  530. captionOrigin: "欢迎使用 Pipeline 视频生成工具",
  531. caption: [{ text: "欢迎使用 Pipeline 视频生成工具", duration: 3 }],
  532. cardList: [{ kind: "text", desc: "支持资讯、知识、观点、营销、GitHub热榜模板" }],
  533. },
  534. {
  535. id: "outro",
  536. kind: "outro",
  537. duration: 2,
  538. audioFilename: "",
  539. captionOrigin: "感谢观看",
  540. title: "点赞 | 关注",
  541. },
  542. ],
  543. template: "news",
  544. platform: "bilibili",
  545. title: "Pipeline Demo",
  546. subtitle: "结构化视频生成",
  547. channelName: "Pipeline",
  548. globalStyle: { tone: "formal", pace: "normal" },
  549. };
  550. export const RemotionRoot: React.FC = () => {
  551. // Load bundled Noto Sans SC at component mount (staticFile() requires the
  552. // bundle context which is only ready after the component tree mounts).
  553. const [fontHandle] = useState(() => delayRender("Loading Noto Sans SC"));
  554. useEffect(() => {
  555. let cancelled = false;
  556. try {
  557. const style = document.createElement("style");
  558. style.textContent = `
  559. @font-face {
  560. font-family: "Noto Sans SC";
  561. src: local("Noto Sans CJK SC"),
  562. url(${staticFile("fonts/NotoSansSC-Regular.ttf")}) format("truetype");
  563. font-weight: 400;
  564. font-display: block;
  565. }
  566. @font-face {
  567. font-family: "Noto Sans SC";
  568. src: local("Noto Sans CJK SC"),
  569. url(${staticFile("fonts/NotoSansSC-Bold.ttf")}) format("truetype");
  570. font-weight: 700;
  571. font-display: block;
  572. }
  573. `;
  574. document.head.appendChild(style);
  575. const fonts = document.fonts as unknown as {
  576. load: (font: string) => Promise<unknown>;
  577. };
  578. Promise.all([
  579. fonts.load('400 16px "Noto Sans SC"'),
  580. fonts.load('700 16px "Noto Sans SC"'),
  581. ])
  582. .then(() => {
  583. if (!cancelled) continueRender(fontHandle);
  584. })
  585. .catch((err) => {
  586. console.error("Font loading failed:", err);
  587. if (!cancelled) continueRender(fontHandle);
  588. });
  589. } catch (err) {
  590. console.error("Font init failed:", err);
  591. continueRender(fontHandle);
  592. }
  593. return () => {
  594. cancelled = true;
  595. };
  596. }, [fontHandle]);
  597. return (
  598. <>
  599. {TEMPLATES.map((template) =>
  600. (
  601. Object.entries(ASPECT_RATIOS) as [
  602. AspectRatio,
  603. { width: number; height: number },
  604. ][]
  605. ).map(([ratio, dims]) => (
  606. <Composition
  607. key={`${template}-${RATIO_ID[ratio]}`}
  608. id={`${template}-${RATIO_ID[ratio]}`}
  609. component={TemplateComposition as unknown as React.FC<Record<string, unknown>>}
  610. durationInFrames={180}
  611. fps={30}
  612. width={dims.width}
  613. height={dims.height}
  614. defaultProps={defaultProps}
  615. calculateMetadata={async (params) => {
  616. const p = params.props as unknown as RenderProps;
  617. // Visual timeline is template-computed: sum each scene's visual
  618. // frames (default = audio duration; silent scenes get a floor).
  619. const total = p.scenes.reduce(
  620. (sum, s) => sum + sceneDurationFrames(s, 30),
  621. 0
  622. );
  623. return {
  624. durationInFrames: total || 90,
  625. props: params.props,
  626. };
  627. }}
  628. />
  629. ))
  630. )}
  631. </>
  632. );
  633. };