|
|
@@ -0,0 +1,312 @@
|
|
|
+import React from "react";
|
|
|
+import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img } from "remotion";
|
|
|
+import type { TemplateType, AspectRatio } from "@pipeline/shared";
|
|
|
+import type { WordTimestamp } from "@pipeline/shared";
|
|
|
+import NewsScene from "./news/index";
|
|
|
+import KnowledgeScene from "./knowledge/index";
|
|
|
+import OpinionScene from "./opinion/index";
|
|
|
+import MarketingScene from "./marketing/index";
|
|
|
+
|
|
|
+export interface RemotionScene {
|
|
|
+ id: string;
|
|
|
+ sceneType: "cover" | "content" | "outro";
|
|
|
+ startFrame: number;
|
|
|
+ endFrame: number;
|
|
|
+ narration: 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;
|
|
|
+}
|
|
|
+
|
|
|
+export interface RemotionProps {
|
|
|
+ scenes: RemotionScene[];
|
|
|
+ template: TemplateType;
|
|
|
+ platform: string;
|
|
|
+ title: string;
|
|
|
+ subtitle?: string;
|
|
|
+ outro?: {
|
|
|
+ text: string;
|
|
|
+ narration?: string;
|
|
|
+ cta?: string;
|
|
|
+ };
|
|
|
+ globalStyle: {
|
|
|
+ tone: string;
|
|
|
+ pace: string;
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const SCENE_MAP: Record<TemplateType, React.FC<any>> = {
|
|
|
+ news: NewsScene,
|
|
|
+ knowledge: KnowledgeScene,
|
|
|
+ opinion: OpinionScene,
|
|
|
+ marketing: MarketingScene,
|
|
|
+};
|
|
|
+
|
|
|
+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"];
|
|
|
+
|
|
|
+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
|
|
|
+ title={props.title}
|
|
|
+ subtitle={props.subtitle}
|
|
|
+ keyframes={scene.keyframes}
|
|
|
+ backgroundAsset={scene.backgroundAsset}
|
|
|
+ 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}
|
|
|
+ />
|
|
|
+ </Sequence>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </AbsoluteFill>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+// --- Cover Scene ---
|
|
|
+
|
|
|
+const CoverScene: React.FC<{
|
|
|
+ title: string;
|
|
|
+ subtitle?: string;
|
|
|
+ keyframes: Array<{ type: string; content: string }>;
|
|
|
+ backgroundAsset?: { id: string; filename: string };
|
|
|
+ totalFrames: number;
|
|
|
+}> = ({ title, subtitle, keyframes, backgroundAsset }) => {
|
|
|
+ const frame = useCurrentFrame();
|
|
|
+ const opacity = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
|
|
|
+
|
|
|
+ return (
|
|
|
+ <AbsoluteFill style={{ opacity, backgroundColor: "#0f172a" }}>
|
|
|
+ {backgroundAsset && (
|
|
|
+ <Img
|
|
|
+ src={staticFile(backgroundAsset.filename)}
|
|
|
+ style={{ position: "absolute", width: "100%", height: "100%", objectFit: "cover", opacity: 0.3 }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ <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: "sans-serif", lineHeight: 1.2, marginBottom: 20,
|
|
|
+ maxWidth: "80%",
|
|
|
+ }}>
|
|
|
+ {title}
|
|
|
+ </div>
|
|
|
+ {subtitle && (
|
|
|
+ <div style={{
|
|
|
+ fontSize: 28, color: "rgba(255,255,255,0.7)",
|
|
|
+ fontFamily: "sans-serif", marginBottom: 40,
|
|
|
+ }}>
|
|
|
+ {subtitle}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {keyframes.length > 0 && (
|
|
|
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
|
|
+ {keyframes.map((kf, i) => (
|
|
|
+ <div key={i} style={{
|
|
|
+ fontSize: 22, color: "rgba(255,255,255,0.5)",
|
|
|
+ fontFamily: "sans-serif",
|
|
|
+ }}>
|
|
|
+ {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: "sans-serif", lineHeight: 1.4, marginBottom: 40,
|
|
|
+ maxWidth: "80%",
|
|
|
+ }}>
|
|
|
+ {text}
|
|
|
+ </div>
|
|
|
+ {cta && (
|
|
|
+ <div style={{
|
|
|
+ fontSize: 28, fontWeight: 600, color: "#fbbf24",
|
|
|
+ fontFamily: "sans-serif",
|
|
|
+ padding: "16px 48px",
|
|
|
+ borderRadius: 12,
|
|
|
+ border: "2px solid #fbbf24",
|
|
|
+ }}>
|
|
|
+ {cta}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </AbsoluteFill>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+import { useCurrentFrame, interpolate } from "remotion";
|
|
|
+
|
|
|
+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: "点赞 | 关注" },
|
|
|
+ globalStyle: { tone: "formal", pace: "normal" },
|
|
|
+};
|
|
|
+
|
|
|
+export const RemotionRoot: React.FC = () => {
|
|
|
+ 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,
|
|
|
+ };
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ );
|
|
|
+};
|