|
|
@@ -1,12 +1,13 @@
|
|
|
import React, { useEffect, useState } from "react";
|
|
|
-import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion";
|
|
|
+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 from "./github-trending/index";
|
|
|
+import GithubTrendingScene, { ColorDot } from "./github-trending/index";
|
|
|
import { THEMES } from "./base/theme/colors";
|
|
|
|
|
|
export interface RemotionScene {
|
|
|
@@ -76,6 +77,11 @@ 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;
|
|
|
+ // 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 (
|
|
|
<AbsoluteFill style={{ backgroundColor: "#000" }}>
|
|
|
@@ -99,7 +105,7 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
|
|
|
subtitle={props.subtitle}
|
|
|
keyframes={scene.keyframes}
|
|
|
backgroundAsset={scene.backgroundAsset}
|
|
|
- coverTags={scene.coverTags}
|
|
|
+ coverRepos={coverRepos}
|
|
|
trendSummary={scene.trendSummary}
|
|
|
totalFrames={totalFrames}
|
|
|
/>
|
|
|
@@ -141,96 +147,222 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
|
|
|
);
|
|
|
})}
|
|
|
<GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
|
|
|
+ {props.template === "github-trending" && <ChapterToc scenes={props.scenes} />}
|
|
|
</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 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 (
|
|
|
+ <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: 0.08 }}>
|
|
|
+ <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: 0.08 }}>
|
|
|
+ <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;
|
|
|
keyframes: Array<{ type: string; content: string }>;
|
|
|
backgroundAsset?: { id: string; filename: string };
|
|
|
- coverTags?: string[];
|
|
|
+ coverRepos?: RemotionScene[];
|
|
|
trendSummary?: string;
|
|
|
totalFrames: number;
|
|
|
-}> = ({ template, title, subtitle, keyframes, backgroundAsset, coverTags, trendSummary }) => {
|
|
|
+}> = ({ 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 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.
|
|
|
+ // 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 hasTags = !!(coverTags && coverTags.length > 0);
|
|
|
+ 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 (
|
|
|
<AbsoluteFill style={{
|
|
|
- background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 60%, #cbd5e1 100%)",
|
|
|
+ background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 55%, #e2e8f0 100%)",
|
|
|
}}>
|
|
|
- {/* Top accent bar in template primary */}
|
|
|
+ <CoverDecor accent={accent} />
|
|
|
+ {/* Top accent line */}
|
|
|
<div style={{
|
|
|
- position: "absolute", top: 0, left: 0, width: "100%", height: 8,
|
|
|
- background: `linear-gradient(90deg, ${THEMES[template].primary}, ${accent})`,
|
|
|
+ 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", justifyContent: "center", alignItems: "center",
|
|
|
- padding: 80, textAlign: "center",
|
|
|
+ position: "absolute", inset: 0, display: "flex", flexDirection: "column",
|
|
|
+ alignItems: "center",
|
|
|
+ justifyContent: isPortrait ? "center" : "flex-start",
|
|
|
+ padding: isPortrait ? "72px 64px 130px" : "58px 80px 112px",
|
|
|
}}>
|
|
|
+ {/* DARK title card — centered, near the top (contrast over light bg) */}
|
|
|
<div style={{
|
|
|
- fontSize: 104, fontWeight: 800, color: "#0f172a",
|
|
|
- fontFamily: "Noto Sans SC", lineHeight: 1.15,
|
|
|
- marginBottom: 28, letterSpacing: "-0.02em",
|
|
|
+ display: "flex", flexDirection: "column", alignItems: "center", gap: 14,
|
|
|
+ padding: isPortrait ? "38px 76px" : "32px 88px",
|
|
|
+ borderRadius: 26,
|
|
|
+ background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
|
|
|
+ border: `1.5px solid ${primary}55`,
|
|
|
+ boxShadow: "0 18px 44px rgba(15,23,42,0.22)",
|
|
|
+ marginBottom: isPortrait ? 44 : 38,
|
|
|
+ maxWidth: isPortrait ? "84%" : "74%",
|
|
|
+ textAlign: "center",
|
|
|
}}>
|
|
|
- {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,
|
|
|
+ fontSize: isPortrait ? 72 : 66, fontWeight: 800, color: "#ffffff",
|
|
|
+ fontFamily: "Noto Sans SC", lineHeight: 1.15, letterSpacing: "-0.02em",
|
|
|
}}>
|
|
|
- {subtitle}
|
|
|
+ {title}
|
|
|
</div>
|
|
|
- )}
|
|
|
- {hasTags && (
|
|
|
<div style={{
|
|
|
- display: "flex", flexWrap: "wrap", justifyContent: "center",
|
|
|
- gap: 18, maxWidth: "82%", marginBottom: trendSummary ? 36 : 0,
|
|
|
+ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
|
|
|
+ justifyContent: "center",
|
|
|
}}>
|
|
|
- {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)",
|
|
|
+ {subtitle && (
|
|
|
+ <span style={{
|
|
|
+ fontSize: isPortrait ? 30 : 26, fontWeight: 600, color: accent,
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
}}>
|
|
|
- <span style={{
|
|
|
- display: "inline-block", width: 11, height: 11,
|
|
|
- borderRadius: "50%", background: accent, flexShrink: 0,
|
|
|
- }} />
|
|
|
- {tag}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ {subtitle}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {trendSummary && (
|
|
|
+ <span style={{
|
|
|
+ fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#cbd5e1",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ }}>
|
|
|
+ · {trendSummary}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
</div>
|
|
|
- )}
|
|
|
- {trendSummary && (
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* TOP 6 repos by today's star gain (3 per row → wraps to a grid) */}
|
|
|
+ {topRepos.length > 0 && (
|
|
|
<div style={{
|
|
|
- fontSize: 40, fontWeight: 500, color: "#475569",
|
|
|
- fontFamily: "Noto Sans SC", maxWidth: "70%", lineHeight: 1.5,
|
|
|
+ display: "flex", flexWrap: "wrap",
|
|
|
+ gap: 24,
|
|
|
+ width: "100%",
|
|
|
+ maxWidth: isPortrait ? "90%" : "94%",
|
|
|
}}>
|
|
|
- {trendSummary}
|
|
|
+ {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 (
|
|
|
+ <div key={s.id} style={{
|
|
|
+ flex: isPortrait ? "0 0 calc((100% - 24px) / 2)" : "0 0 calc((100% - 48px) / 3)",
|
|
|
+ minWidth: 0,
|
|
|
+ display: "flex", flexDirection: "column",
|
|
|
+ padding: isPortrait ? "28px 30px" : "30px",
|
|
|
+ borderRadius: 22,
|
|
|
+ background: "#ffffff",
|
|
|
+ border: `1px solid ${primary}22`,
|
|
|
+ boxShadow: "0 16px 38px rgba(15,23,42,0.12), 0 2px 8px rgba(15,23,42,0.06)",
|
|
|
+ }}>
|
|
|
+ {/* Rank badge + today's gain (the selection signal) */}
|
|
|
+ <div style={{
|
|
|
+ display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
|
+ marginBottom: 18,
|
|
|
+ }}>
|
|
|
+ <div style={{
|
|
|
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
|
+ width: isPortrait ? 56 : 52, height: isPortrait ? 56 : 52,
|
|
|
+ borderRadius: "50%",
|
|
|
+ background: `linear-gradient(135deg, ${accent}, ${primary})`,
|
|
|
+ color: "#fff", fontWeight: 800, fontFamily: "Noto Sans SC",
|
|
|
+ fontSize: isPortrait ? 30 : 26,
|
|
|
+ boxShadow: "0 4px 12px rgba(34,197,94,0.35)",
|
|
|
+ }}>
|
|
|
+ {i + 1}
|
|
|
+ </div>
|
|
|
+ {todayLabel && (
|
|
|
+ <span style={{
|
|
|
+ fontSize: isPortrait ? 56 : 50, fontWeight: 800, color: primary,
|
|
|
+ fontFamily: "Noto Sans SC", lineHeight: 1,
|
|
|
+ }}>{todayLabel}</span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {/* Repo full name */}
|
|
|
+ <div style={{
|
|
|
+ fontSize: isPortrait ? 40 : 34, fontWeight: 800, color: "#0f172a",
|
|
|
+ fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 12,
|
|
|
+ wordBreak: "break-word",
|
|
|
+ }}>
|
|
|
+ {repo.fullName}
|
|
|
+ </div>
|
|
|
+ {/* Language + total stars */}
|
|
|
+ <div style={{
|
|
|
+ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
|
|
|
+ marginBottom: 14, fontFamily: "Noto Sans SC",
|
|
|
+ }}>
|
|
|
+ {language && (
|
|
|
+ <span style={{
|
|
|
+ display: "inline-flex", alignItems: "center", gap: 8,
|
|
|
+ fontSize: isPortrait ? 24 : 21, fontWeight: 600, color: "#334155",
|
|
|
+ padding: "5px 14px", borderRadius: 999,
|
|
|
+ background: `${languageColor}1a`, border: `1px solid ${languageColor}55`,
|
|
|
+ }}>
|
|
|
+ <ColorDot color={languageColor} size={isPortrait ? 14 : 12} />
|
|
|
+ {language}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ {repo.stars != null && (
|
|
|
+ <span style={{
|
|
|
+ fontSize: isPortrait ? 23 : 20, color: "#64748b", fontWeight: 500,
|
|
|
+ }}>
|
|
|
+ {formatCount(repo.stars)} stars
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {/* One-line description (highlights) */}
|
|
|
+ <div style={{
|
|
|
+ fontSize: isPortrait ? 27 : 23, lineHeight: 1.4, color: "#475569",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
|
|
|
+ overflow: "hidden",
|
|
|
+ }}>
|
|
|
+ {g.highlights}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
@@ -370,6 +502,90 @@ const GlobalProgressBar: React.FC<{
|
|
|
);
|
|
|
};
|
|
|
|
|
|
+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 (
|
|
|
+ <>
|
|
|
+ <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 = c.github!.repo;
|
|
|
+ const label = repo.name || repo.fullName || c.displayText || "";
|
|
|
+ return (
|
|
|
+ <React.Fragment key={c.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: RemotionProps = {
|
|
|
scenes: [
|
|
|
{
|