import React, { useEffect, useState } from "react"; import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion"; import type { TemplateType, AspectRatio } from "@pipeline/shared"; import type { WordTimestamp, GithubSceneData } from "@pipeline/shared"; import { formatCount } from "@pipeline/shared"; import NewsScene from "./news/index"; import KnowledgeScene from "./knowledge/index"; import OpinionScene from "./opinion/index"; import MarketingScene from "./marketing/index"; import GithubTrendingScene, { ColorDot } from "./github-trending/index"; import { THEMES } from "./base/theme/colors"; export interface RemotionScene { id: string; sceneType: "cover" | "content" | "summary" | "outro"; startFrame: number; endFrame: number; narration: string; displayText?: string; title?: string; wordTimestamps: WordTimestamp[]; keyframes: Array<{ type: string; content: string; startFrame?: number; endFrame?: number; style?: Record; }>; images?: Array<{ url?: string; query?: string; filename: string }>; audioFilename: string; backgroundAsset?: { id: string; filename: string }; layoutHint: string; github?: GithubSceneData; // github-trending cover scene only: theme tags + trend line. coverTags?: string[]; trendSummary?: string; } export interface RemotionProps { scenes: RemotionScene[]; template: TemplateType; platform: string; title: string; subtitle?: string; outro?: { text: string; narration?: string; cta?: string; }; channelName: string; globalStyle: { tone: string; pace: string; }; } const SCENE_MAP: Record> = { news: NewsScene, knowledge: KnowledgeScene, opinion: OpinionScene, marketing: MarketingScene, "github-trending": GithubTrendingScene, }; const RATIO_ID: Record = { "16:9": "landscape", "9:16": "portrait", }; const ASPECT_RATIOS: Record = { "16:9": { width: 1920, height: 1080 }, "9:16": { width: 1080, height: 1920 }, }; const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing", "github-trending"]; const TemplateComposition: React.FC = (props) => { const SceneComponent = SCENE_MAP[props.template]; const totalFrames = props.scenes.at(-1)?.endFrame ?? 90; // github-trending: the cover lists every repo (name / language / today's // star gain / one-line description). Content scenes each carry scene.github. const coverRepos = props.scenes.filter( (s) => s.sceneType === "content" && s.github ); return ( {props.scenes.map((scene) => { const duration = scene.endFrame - scene.startFrame; const sceneAudio = scene.audioFilename ? ( ); }; // --- Cover Scene --- // Faint, sparse code/data motifs on the github-trending cover background. // Low-opacity and edge-anchored so the cover stays clean — the motifs read // only as subtle texture, never competing with the cards. const CoverDecor: React.FC<{ accent: string }> = ({ accent }) => { const tok = (css: React.CSSProperties): React.CSSProperties => ({ position: "absolute", fontFamily: "monospace", fontWeight: 700, color: "#0f172a", opacity: 0.06, letterSpacing: "0.02em", ...css, }); return (
{""} {"{ }"} {"01 10 01"} {"git push"} {/* Faint data line chart */} {/* Faint data bar cluster */}
); }; const CoverScene: React.FC<{ template: TemplateType; title: string; subtitle?: string; keyframes: Array<{ type: string; content: string }>; backgroundAsset?: { id: string; filename: string }; coverRepos?: RemotionScene[]; trendSummary?: string; totalFrames: number; }> = ({ template, title, subtitle, keyframes, backgroundAsset, coverRepos, trendSummary }) => { const accent = THEMES[template].primaryLight; const isGithubTrending = template === "github-trending"; const { width, height } = useVideoConfig(); const isPortrait = height > width; // github-trending cover: a DARK title card (contrast over a light bg with // faint code/data motifs) centered near the top, then the TOP 3 repos by // today's star gain as large rounded "floating" cards — horizontal row in // landscape, vertical stack in portrait. Doubles as the opening narration. if (isGithubTrending) { const topRepos = [...(coverRepos ?? [])] .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0)) .slice(0, 6); const primary = THEMES[template].primary; return ( {/* Top accent line */}
{/* DARK title card — centered, near the top (contrast over light bg) */}
{title}
{subtitle && ( {subtitle} )} {trendSummary && ( · {trendSummary} )}
{/* TOP 6 repos by today's star gain (3 per row → wraps to a grid) */} {topRepos.length > 0 && (
{topRepos.map((s, i) => { const g = s.github!; const repo = g.repo; const language = repo.language || ""; const languageColor = repo.languageColor || accent; const todayLabel = repo.todayStars != null ? `+${formatCount(repo.todayStars)}` : ""; return (
{/* Rank badge + today's gain (the selection signal) */}
{i + 1}
{todayLabel && ( {todayLabel} )}
{/* Repo full name */}
{repo.fullName}
{/* Language + total stars */}
{language && ( {language} )} {repo.stars != null && ( {formatCount(repo.stars)} stars )}
{/* One-line description (highlights) */}
{g.highlights}
); })}
)}
); } return ( {backgroundAsset && ( )}
{title}
{subtitle && (
{subtitle}
)} {keyframes.length > 0 && (
{keyframes.map((kf, i) => { const isHighlight = kf.type === "highlight"; return (
{kf.content}
); })}
)}
); }; // --- Outro Scene --- const OutroScene: React.FC<{ text: string; cta?: string; totalFrames: number; }> = ({ text, cta }) => { const frame = useCurrentFrame(); const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" }); return (
{text}
{cta && (
{cta}
)}
); }; const GlobalProgressBar: React.FC<{ totalFrames: number; color: string; }> = ({ totalFrames, color }) => { const frame = useCurrentFrame(); const progress = interpolate(frame, [0, totalFrames], [0, 100], { extrapolateRight: "clamp", }); return (
); }; const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => { // github-trending only: a chapter table of contents pinned just above the // global progress bar. Lists every repo's short name; the chapter whose // [startFrame, endFrame) contains the current frame is highlighted. A faint // dark gradient strip behind the row keeps labels legible over light // backgrounds (e.g. the cover) and visually groups the TOC with the bar. const frame = useCurrentFrame(); const { width, height } = useVideoConfig(); const isPortrait = height > width; const accent = THEMES["github-trending"].primary; const chapters = scenes.filter((s) => s.sceneType === "content" && s.github); const active = chapters.findIndex( (c) => frame >= c.startFrame && frame < c.endFrame ); return ( <>
{chapters.map((c, i) => { const isActive = i === active; const repo = c.github!.repo; const label = repo.name || repo.fullName || c.displayText || ""; return ( {i > 0 && ( · )} {label} ); })}
); }; const defaultProps: RemotionProps = { scenes: [ { id: "cover", sceneType: "cover", startFrame: 0, endFrame: 90, narration: "", wordTimestamps: [], audioFilename: "", keyframes: [ { type: "text", content: "文本转视频,一键生成" }, ], layoutHint: "centered", }, { id: "scene-1", sceneType: "content", startFrame: 90, endFrame: 180, narration: "欢迎使用 Pipeline 视频生成工具", wordTimestamps: [], audioFilename: "", keyframes: [ { type: "text", content: "支持资讯、知识、观点、营销四大模板" }, ], layoutHint: "centered", }, { id: "outro", sceneType: "outro", startFrame: 180, endFrame: 240, narration: "感谢观看", wordTimestamps: [], audioFilename: "", keyframes: [], layoutHint: "centered", }, ], template: "news", platform: "bilibili", title: "Pipeline Demo", subtitle: "结构化视频生成", outro: { text: "感谢观看", cta: "点赞 | 关注" }, channelName: "Pipeline", globalStyle: { tone: "formal", pace: "normal" }, }; export const RemotionRoot: React.FC = () => { // Load bundled Noto Sans SC at component mount (staticFile() requires the // bundle context which is only ready after the component tree mounts). const [fontHandle] = useState(() => delayRender("Loading Noto Sans SC")); useEffect(() => { let cancelled = false; try { const style = document.createElement("style"); style.textContent = ` @font-face { font-family: "Noto Sans SC"; src: local("Noto Sans CJK SC"), url(${staticFile("fonts/NotoSansSC-Regular.ttf")}) format("truetype"); font-weight: 400; font-display: block; } @font-face { font-family: "Noto Sans SC"; src: local("Noto Sans CJK SC"), url(${staticFile("fonts/NotoSansSC-Bold.ttf")}) format("truetype"); font-weight: 700; font-display: block; } `; document.head.appendChild(style); const fonts = document.fonts as unknown as { load: (font: string) => Promise; }; Promise.all([ fonts.load('400 16px "Noto Sans SC"'), fonts.load('700 16px "Noto Sans SC"'), ]) .then(() => { if (!cancelled) continueRender(fontHandle); }) .catch((err) => { console.error("Font loading failed:", err); if (!cancelled) continueRender(fontHandle); }); } catch (err) { console.error("Font init failed:", err); continueRender(fontHandle); } return () => { cancelled = true; }; }, [fontHandle]); return ( <> {TEMPLATES.map((template) => ( Object.entries(ASPECT_RATIOS) as [ AspectRatio, { width: number; height: number }, ][] ).map(([ratio, dims]) => ( >} durationInFrames={180} fps={30} width={dims.width} height={dims.height} defaultProps={defaultProps} calculateMetadata={async (params) => { const p = params.props as unknown as RemotionProps; return { durationInFrames: p.scenes.at(-1)?.endFrame ?? 90, props: params.props, }; }} /> )) )} ); };