| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663 |
- import React, { useEffect, useState } from "react";
- import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
- import type { TemplateType, AspectRatio, RenderScene, RenderProps } 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";
- // Re-export the render-time contract so legacy imports (`import { RemotionScene
- // } from "../Root"`) keep resolving during the migration. New code should import
- // RenderScene / RenderProps directly from @pipeline/shared.
- export type { RenderScene as RemotionScene, RenderProps as RemotionProps } from "@pipeline/shared";
- import type { RenderSegmentExtension } from "@pipeline/shared";
- /** Narrow a scene's per-template extension to the github-trending variant, or
- * undefined. Template-specific data lives in `extension`, not on the scene root. */
- const ghExt = (s: { extension?: RenderScene["extension"] }): RenderSegmentExtension | undefined =>
- s.extension?.type === "github-trending" ? s.extension : undefined;
- /** Default visual length (seconds) for a scene with no audio (e.g. a silent
- * cover). Visual duration is the TEMPLATE's call — it may exceed the audio to
- * add silent padding, holds, transitions, etc. */
- const SILENT_SCENE_SECONDS = 1;
- /**
- * Visual duration in FRAMES for a scene — computed by the TEMPLATE, not the
- * renderer. `scene.duration` is the AUDIO length only; this is where a template
- * decides the on-screen length (default = audio, silent scenes get a small
- * floor). Override/extend per template to add silent padding or special timing.
- */
- function sceneDurationFrames(scene: RenderScene, fps: number): number {
- const audio = scene.duration ?? 0;
- const seconds = audio > 0 ? audio : SILENT_SCENE_SECONDS;
- return Math.max(1, Math.round(seconds * fps));
- }
- 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<RenderProps> = (props) => {
- const { fps } = useVideoConfig();
- const SceneComponent = SCENE_MAP[props.template];
- // github-trending: the cover aggregates the repos' languages into tag
- // chips. Content scenes carry repo data in extension.
- const coverRepos = props.scenes.filter(
- (s) => s.kind === "content" && ghExt(s)
- );
- // Compute the visual timeline (TEMPLATE-controlled). Each scene's visual
- // frames come from sceneDurationFrames (default = audio duration); the
- // cumulative cursor places Sequences. Visuals may exceed audio (silent pads).
- let cursor = 0;
- const layout = props.scenes.map((scene) => {
- const durationFrames = sceneDurationFrames(scene, fps);
- const startFrame = cursor;
- cursor += durationFrames;
- return { scene, startFrame, endFrame: startFrame + durationFrames, durationFrames };
- });
- const totalFrames = cursor || 90;
- return (
- <AbsoluteFill style={{ backgroundColor: "#000" }}>
- {layout.map(({ scene, startFrame, durationFrames }) => {
- const sceneAudio = scene.audioFilename ? (
- <Audio src={staticFile(scene.audioFilename)} />
- ) : null;
- if (scene.kind === "cover") {
- return (
- <Sequence
- key={scene.id}
- from={startFrame}
- durationInFrames={durationFrames}
- >
- {sceneAudio}
- <CoverScene
- template={props.template}
- title={props.title}
- subtitle={props.subtitle}
- cardList={scene.cardList}
- backgroundAsset={scene.backgroundAsset}
- coverRepos={coverRepos}
- trendSummary={props.trendSummary}
- totalFrames={totalFrames}
- />
- </Sequence>
- );
- }
- if (scene.kind === "outro") {
- return (
- <Sequence
- key={scene.id}
- from={startFrame}
- durationInFrames={durationFrames}
- >
- {sceneAudio}
- <OutroScene
- text={scene.captionOrigin ?? ""}
- cta={scene.title}
- totalFrames={totalFrames}
- />
- </Sequence>
- );
- }
- return (
- <Sequence
- key={scene.id}
- from={startFrame}
- durationInFrames={durationFrames}
- >
- {sceneAudio}
- <SceneComponent
- scene={scene}
- sceneIndex={props.scenes.filter((s) => s.kind === "content").indexOf(scene)}
- totalScenes={props.scenes.filter((s) => s.kind === "content").length}
- totalFrames={totalFrames}
- title={props.title}
- channelName={props.channelName}
- />
- </Sequence>
- );
- })}
- <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
- {props.template === "github-trending" && <ChapterToc layout={layout} />}
- </AbsoluteFill>
- );
- };
- // --- 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 title card. Colors adapt
- // to the backdrop: near-black on the light gradient, white on the dark
- // scrim over the background image.
- const CoverDecor: React.FC<{ accent: string; onDark?: boolean }> = ({ accent, onDark }) => {
- const tok = (css: React.CSSProperties): React.CSSProperties => ({
- position: "absolute",
- fontFamily: "monospace",
- fontWeight: 700,
- color: onDark ? "#ffffff" : "#0f172a",
- opacity: onDark ? 0.08 : 0.06,
- letterSpacing: "0.02em",
- ...css,
- });
- const chartOpacity = onDark ? 0.16 : 0.08;
- return (
- <div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
- <span style={tok({ top: 44, left: 52, fontSize: 56 })}>{"</>"}</span>
- <span style={tok({ top: 58, right: 60, fontSize: 48 })}>{"{ }"}</span>
- <span style={tok({ bottom: 168, left: 54, fontSize: 28 })}>{"01 10 01"}</span>
- <span style={tok({ bottom: 150, right: 58, fontSize: 26 })}>{"git push"}</span>
- {/* Faint data line chart */}
- <svg width="180" height="84" viewBox="0 0 180 84" style={{ position: "absolute", top: 64, right: 168, opacity: chartOpacity }}>
- <polyline points="0,66 34,50 68,56 102,28 136,34 180,8" fill="none" stroke={accent} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
- </svg>
- {/* Faint data bar cluster */}
- <div style={{ position: "absolute", bottom: 206, left: 80, display: "flex", alignItems: "flex-end", gap: 7, opacity: chartOpacity }}>
- <div style={{ width: 12, height: 32, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 58, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 24, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 76, background: accent, borderRadius: 3 }} />
- <div style={{ width: 12, height: 46, background: accent, borderRadius: 3 }} />
- </div>
- </div>
- );
- };
- const CoverScene: React.FC<{
- template: TemplateType;
- title: string;
- subtitle?: string;
- cardList?: Array<{ kind?: string; desc?: string }>;
- backgroundAsset?: { id: string; filename: string };
- coverRepos?: RenderScene[];
- trendSummary?: string;
- totalFrames: number;
- }> = ({ template, title, subtitle, cardList, backgroundAsset, coverRepos, trendSummary }) => {
- const accent = THEMES[template].primaryLight;
- const isGithubTrending = template === "github-trending";
- const { width, height } = useVideoConfig();
- const isPortrait = height > width;
- // github-trending cover: the renderer-provided background image (default.png
- // fallback — no template-specific bg exists yet) under a dark scrim, with a
- // DARK title card fully centered on top — no repo list. Shows only the
- // masthead title, the date, the day's main language tags (aggregated across
- // today's repos), and the trend summary line. Light gradient + CoverDecor is
- // the no-image fallback (e.g. studio demo props).
- if (isGithubTrending) {
- const primary = THEMES[template].primary;
- // Aggregate languages across today's repos → the cover's "main tags".
- // Count-weighted, top repo's languageColor wins per language.
- const langStats = new Map<string, { count: number; color?: string }>();
- for (const s of coverRepos ?? []) {
- const repo = ghExt(s)!.repo;
- if (!repo.language) continue;
- const stats = langStats.get(repo.language) ?? { count: 0, color: repo.languageColor };
- stats.count += 1;
- langStats.set(repo.language, stats);
- }
- const topLangs = [...langStats.entries()]
- .sort((a, b) => b[1].count - a[1].count)
- .slice(0, 6);
- return (
- <AbsoluteFill style={{
- background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 55%, #e2e8f0 100%)",
- }}>
- {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(2,6,23,0.40), rgba(2,6,23,0.70))",
- }} />
- </>
- ) : undefined}
- {/* Code/data motifs over both backdrops (light gradient or scrimmed image) */}
- <CoverDecor accent={accent} onDark={!!backgroundAsset} />
- {/* Top accent line */}
- <div style={{
- position: "absolute", top: 0, left: 0, width: "100%", height: 6,
- background: `linear-gradient(90deg, ${primary}, ${accent})`,
- }} />
- <div style={{
- position: "absolute", inset: 0, display: "flex", flexDirection: "column",
- alignItems: "center",
- justifyContent: "center",
- padding: isPortrait ? "72px 64px 150px" : "58px 80px 130px",
- }}>
- {/* DARK title card — centered, title + date + tags + summary */}
- <div style={{
- display: "flex", flexDirection: "column", alignItems: "center", gap: 20,
- padding: isPortrait ? "64px 96px" : "56px 110px",
- borderRadius: 26,
- background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
- border: `1.5px solid ${primary}55`,
- boxShadow: "0 24px 64px rgba(0,0,0,0.55), 0 4px 16px rgba(0,0,0,0.4)",
- maxWidth: isPortrait ? "90%" : "80%",
- textAlign: "center",
- }}>
- {/* Masthead title */}
- <div style={{
- fontSize: isPortrait ? 108 : 96, fontWeight: 800, color: "#ffffff",
- fontFamily: "Noto Sans SC", lineHeight: 1.12, letterSpacing: "-0.02em",
- }}>
- {title}
- </div>
- {/* Date */}
- {subtitle && (
- <div style={{
- fontSize: isPortrait ? 36 : 32, fontWeight: 600, color: accent,
- fontFamily: "Noto Sans SC",
- }}>
- {subtitle}
- </div>
- )}
- {/* Main tags — languages aggregated across today's repos */}
- {topLangs.length > 0 && (
- <div style={{
- display: "flex", flexWrap: "wrap",
- alignItems: "center", justifyContent: "center",
- gap: 14, marginTop: 6,
- }}>
- {topLangs.map(([lang, stats]) => (
- <span key={lang} style={{
- display: "inline-flex", alignItems: "center", gap: 9,
- fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#ffffff",
- fontFamily: "Noto Sans SC",
- padding: "10px 24px", borderRadius: 999,
- background: "rgba(255,255,255,0.08)",
- border: `1px solid ${(stats.color || accent)}66`,
- }}>
- <ColorDot color={stats.color || accent} size={isPortrait ? 14 : 12} />
- {lang}
- </span>
- ))}
- </div>
- )}
- {/* Trend summary */}
- {trendSummary && (
- <>
- <div style={{
- width: "56%", height: 1, marginTop: 10,
- background: "rgba(255,255,255,0.16)",
- }} />
- <div style={{
- fontSize: isPortrait ? 34 : 29, fontWeight: 500, color: "#cbd5e1",
- fontFamily: "Noto Sans SC", lineHeight: 1.5,
- maxWidth: "92%",
- }}>
- {trendSummary}
- </div>
- </>
- )}
- </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>
- )}
- {(cardList?.length ?? 0) > 0 && (
- <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
- {cardList!.map((card, i) => {
- const isHighlight = card.kind === "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)",
- }}>
- {card.desc}
- </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 ChapterToc: React.FC<{
- layout: { scene: RenderScene; startFrame: number; endFrame: number; durationFrames: number }[];
- }> = ({ layout }) => {
- // 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 = layout.filter((l) => l.scene.kind === "content" && ghExt(l.scene));
- const active = chapters.findIndex((c) => frame >= c.startFrame && frame < c.endFrame);
- return (
- <>
- <div
- style={{
- position: "absolute",
- bottom: 0,
- left: 0,
- width: "100%",
- height: 72,
- background: "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
- pointerEvents: "none",
- }}
- />
- <div
- style={{
- position: "absolute",
- bottom: 12,
- left: 0,
- width: "100%",
- display: "flex",
- justifyContent: "center",
- alignItems: "center",
- gap: isPortrait ? 16 : 12,
- padding: "0 32px",
- fontFamily: "Noto Sans SC",
- pointerEvents: "none",
- }}
- >
- {chapters.map((c, i) => {
- const isActive = i === active;
- const repo = ghExt(c.scene)!.repo;
- const label = repo.name || repo.fullName || c.scene.title || "";
- return (
- <React.Fragment key={c.scene.id}>
- {i > 0 && (
- <span
- style={{
- color: "rgba(255,255,255,0.3)",
- fontSize: isPortrait ? 22 : 18,
- flexShrink: 0,
- }}
- >
- ·
- </span>
- )}
- <span
- style={{
- flex: "1 1 0",
- minWidth: 0,
- fontSize: isPortrait ? 26 : 22,
- fontWeight: isActive ? 700 : 500,
- color: isActive ? accent : "rgba(255,255,255,0.6)",
- whiteSpace: "nowrap",
- overflow: "hidden",
- textOverflow: "ellipsis",
- textAlign: "center",
- }}
- >
- {label}
- </span>
- </React.Fragment>
- );
- })}
- </div>
- </>
- );
- };
- const defaultProps: RenderProps = {
- scenes: [
- {
- id: "cover",
- kind: "cover",
- duration: 3,
- audioFilename: "",
- captionOrigin: "",
- cardList: [{ kind: "text", desc: "文本转视频,一键生成" }],
- },
- {
- id: "scene-1",
- kind: "content",
- duration: 3,
- audioFilename: "",
- title: "欢迎使用 Pipeline",
- captionOrigin: "欢迎使用 Pipeline 视频生成工具",
- caption: [{ text: "欢迎使用 Pipeline 视频生成工具", duration: 3 }],
- cardList: [{ kind: "text", desc: "支持资讯、知识、观点、营销、GitHub热榜模板" }],
- },
- {
- id: "outro",
- kind: "outro",
- duration: 2,
- audioFilename: "",
- captionOrigin: "感谢观看",
- title: "点赞 | 关注",
- },
- ],
- template: "news",
- platform: "bilibili",
- title: "Pipeline Demo",
- subtitle: "结构化视频生成",
- 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 RenderProps;
- // Visual timeline is template-computed: sum each scene's visual
- // frames (default = audio duration; silent scenes get a floor).
- const total = p.scenes.reduce(
- (sum, s) => sum + sceneDurationFrames(s, 30),
- 0
- );
- return {
- durationInFrames: total || 90,
- props: params.props,
- };
- }}
- />
- ))
- )}
- </>
- );
- };
|