| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- 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<string, string>;
- }>;
- 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<TemplateType, React.FC<any>> = {
- news: NewsScene,
- knowledge: KnowledgeScene,
- opinion: OpinionScene,
- marketing: MarketingScene,
- "github-trending": GithubTrendingScene,
- };
- const RATIO_ID: Record<AspectRatio, string> = {
- "16:9": "landscape",
- "9:16": "portrait",
- };
- const ASPECT_RATIOS: Record<AspectRatio, { width: number; height: number }> = {
- "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<RemotionProps> = (props) => {
- const SceneComponent = SCENE_MAP[props.template];
- const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
- return (
- <AbsoluteFill style={{ backgroundColor: "#000" }}>
- {props.scenes.map((scene) => {
- const duration = scene.endFrame - scene.startFrame;
- const sceneAudio = scene.audioFilename ? (
- <Audio src={staticFile(scene.audioFilename)} />
- ) : null;
- if (scene.sceneType === "cover") {
- return (
- <Sequence
- key={scene.id}
- from={scene.startFrame}
- durationInFrames={duration}
- >
- {sceneAudio}
- <CoverScene
- template={props.template}
- title={props.title}
- subtitle={props.subtitle}
- keyframes={scene.keyframes}
- backgroundAsset={scene.backgroundAsset}
- coverTags={scene.coverTags}
- trendSummary={scene.trendSummary}
- totalFrames={totalFrames}
- />
- </Sequence>
- );
- }
- if (scene.sceneType === "outro") {
- return (
- <Sequence
- key={scene.id}
- from={scene.startFrame}
- durationInFrames={duration}
- >
- {sceneAudio}
- <OutroScene
- text={props.outro?.text || scene.narration}
- cta={props.outro?.cta}
- totalFrames={totalFrames}
- />
- </Sequence>
- );
- }
- return (
- <Sequence
- key={scene.id}
- from={scene.startFrame}
- durationInFrames={duration}
- >
- {sceneAudio}
- <SceneComponent
- scene={scene}
- sceneIndex={props.scenes.filter((s) => s.sceneType === "content").indexOf(scene)}
- totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
- totalFrames={totalFrames}
- title={props.title}
- channelName={props.channelName}
- />
- </Sequence>
- );
- })}
- <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
- </AbsoluteFill>
- );
- };
- // --- 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 (
- <AbsoluteFill style={{
- background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 60%, #cbd5e1 100%)",
- }}>
- {/* Top accent bar in template primary */}
- <div style={{
- position: "absolute", top: 0, left: 0, width: "100%", height: 8,
- background: `linear-gradient(90deg, ${THEMES[template].primary}, ${accent})`,
- }} />
- <div style={{
- position: "absolute", inset: 0, display: "flex",
- flexDirection: "column", justifyContent: "center", alignItems: "center",
- padding: 80, textAlign: "center",
- }}>
- <div style={{
- fontSize: 104, fontWeight: 800, color: "#0f172a",
- fontFamily: "Noto Sans SC", lineHeight: 1.15,
- marginBottom: 28, letterSpacing: "-0.02em",
- }}>
- {title}
- </div>
- {subtitle && (
- <div style={{
- fontSize: 50, fontWeight: 600, color: "#475569",
- fontFamily: "Noto Sans SC",
- padding: "12px 36px",
- borderRadius: 16,
- background: "rgba(255,255,255,0.7)",
- border: `2px solid ${accent}`,
- boxShadow: "0 8px 24px rgba(15,23,42,0.08)",
- marginBottom: hasTags || trendSummary ? 44 : 0,
- }}>
- {subtitle}
- </div>
- )}
- {hasTags && (
- <div style={{
- display: "flex", flexWrap: "wrap", justifyContent: "center",
- gap: 18, maxWidth: "82%", marginBottom: trendSummary ? 36 : 0,
- }}>
- {coverTags!.map((tag, i) => (
- <div key={i} style={{
- display: "inline-flex", alignItems: "center", gap: 12,
- padding: "12px 26px", borderRadius: 12,
- background: "rgba(255,255,255,0.78)",
- border: `1.5px solid ${accent}66`,
- color: "#1e293b",
- fontFamily: "Noto Sans SC", fontSize: 34, fontWeight: 600,
- boxShadow: "0 2px 10px rgba(15,23,42,0.06)",
- }}>
- <span style={{
- display: "inline-block", width: 11, height: 11,
- borderRadius: "50%", background: accent, flexShrink: 0,
- }} />
- {tag}
- </div>
- ))}
- </div>
- )}
- {trendSummary && (
- <div style={{
- fontSize: 40, fontWeight: 500, color: "#475569",
- fontFamily: "Noto Sans SC", maxWidth: "70%", lineHeight: 1.5,
- }}>
- {trendSummary}
- </div>
- )}
- </div>
- </AbsoluteFill>
- );
- }
- return (
- <AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
- {backgroundAsset && (
- <Img
- src={staticFile(backgroundAsset.filename)}
- style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover" }}
- />
- )}
- <div
- style={{
- position: "absolute",
- inset: 0,
- background:
- "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.55))",
- }}
- />
- <div style={{
- position: "absolute", inset: 0, display: "flex",
- flexDirection: "column", justifyContent: "center", alignItems: "center",
- padding: 80, textAlign: "center",
- }}>
- <div style={{
- fontSize: 72, fontWeight: 800, color: "white",
- fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 20,
- maxWidth: "80%",
- textShadow: "0 2px 16px rgba(0,0,0,0.85), 0 0 4px rgba(0,0,0,0.85)",
- }}>
- {title}
- </div>
- {subtitle && (
- <div style={{
- fontSize: 28, color: "white",
- fontFamily: "Noto Sans SC", marginBottom: 40,
- textShadow: "0 2px 12px rgba(0,0,0,0.85)",
- }}>
- {subtitle}
- </div>
- )}
- {keyframes.length > 0 && (
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
- {keyframes.map((kf, i) => {
- const isHighlight = kf.type === "highlight";
- return (
- <div key={i} style={{
- fontSize: isHighlight ? 30 : 24,
- fontWeight: isHighlight ? 700 : 500,
- color: isHighlight ? accent : "white",
- fontFamily: "Noto Sans SC",
- textShadow: "0 1px 8px rgba(0,0,0,0.85)",
- }}>
- {kf.content}
- </div>
- );
- })}
- </div>
- )}
- </div>
- </AbsoluteFill>
- );
- };
- // --- 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 (
- <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
- <div style={{
- position: "absolute", inset: 0, display: "flex",
- flexDirection: "column", justifyContent: "center", alignItems: "center",
- padding: 80, textAlign: "center",
- }}>
- <div style={{
- fontSize: 48, fontWeight: 700, color: "white",
- fontFamily: "Noto Sans SC", lineHeight: 1.4, marginBottom: 40,
- maxWidth: "80%",
- }}>
- {text}
- </div>
- {cta && (
- <div style={{
- fontSize: 28, fontWeight: 600, color: "#fbbf24",
- fontFamily: "Noto Sans SC",
- padding: "16px 48px",
- borderRadius: 12,
- border: "2px solid #fbbf24",
- }}>
- {cta}
- </div>
- )}
- </div>
- </AbsoluteFill>
- );
- };
- const GlobalProgressBar: React.FC<{
- totalFrames: number;
- color: string;
- }> = ({ totalFrames, color }) => {
- const frame = useCurrentFrame();
- const progress = interpolate(frame, [0, totalFrames], [0, 100], {
- extrapolateRight: "clamp",
- });
- return (
- <div
- style={{
- position: "absolute",
- bottom: 0,
- left: 0,
- width: "100%",
- height: 4,
- backgroundColor: "rgba(255,255,255,0.1)",
- }}
- >
- <div
- style={{
- width: `${progress}%`,
- height: "100%",
- backgroundColor: color,
- }}
- />
- </div>
- );
- };
- 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<unknown>;
- };
- 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]) => (
- <Composition
- key={`${template}-${RATIO_ID[ratio]}`}
- id={`${template}-${RATIO_ID[ratio]}`}
- component={TemplateComposition as unknown as React.FC<Record<string, unknown>>}
- 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,
- };
- }}
- />
- ))
- )}
- </>
- );
- };
|