import React, { useEffect, useState } from "react"; import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion"; import type { TemplateType, AspectRatio } from "@pipeline/shared"; import type { WordTimestamp, GithubSceneData } 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 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; return ( {props.scenes.map((scene) => { const duration = scene.endFrame - scene.startFrame; const sceneAudio = scene.audioFilename ? ( ); }; // --- Cover Scene --- const CoverScene: React.FC<{ template: TemplateType; title: string; subtitle?: string; keyframes: Array<{ type: string; content: string }>; backgroundAsset?: { id: string; filename: string }; coverTags?: string[]; trendSummary?: string; totalFrames: number; }> = ({ template, title, subtitle, keyframes, backgroundAsset, coverTags, trendSummary }) => { const accent = THEMES[template].primaryLight; const isGithubTrending = template === "github-trending"; // github-trending cover doubles as the opening narration screen — light // background, large masthead typography, no dark image overlay. It also // shows the day's theme tags (coverTags) and a one-line trend summary // (trendSummary), both LLM-produced. if (isGithubTrending) { const hasTags = !!(coverTags && coverTags.length > 0); return ( {/* Top accent bar in template primary */}
{title}
{subtitle && (
{subtitle}
)} {hasTags && (
{coverTags!.map((tag, i) => (
{tag}
))}
)} {trendSummary && (
{trendSummary}
)}
); } 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 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, }; }} /> )) )} ); };