|
@@ -0,0 +1,350 @@
|
|
|
|
|
+import React from "react";
|
|
|
|
|
+import {
|
|
|
|
|
+ useCurrentFrame,
|
|
|
|
|
+ useVideoConfig,
|
|
|
|
|
+ AbsoluteFill,
|
|
|
|
|
+ Img,
|
|
|
|
|
+ staticFile,
|
|
|
|
|
+ interpolate,
|
|
|
|
|
+} from "remotion";
|
|
|
|
|
+import type { RemotionScene } from "../Root";
|
|
|
|
|
+import type { RenderSegmentExtension } from "@pipeline/shared";
|
|
|
|
|
+import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
|
|
|
|
|
+import { SubtitleBar } from "../base/components/subtitle-bar";
|
|
|
|
|
+import { Watermark } from "../base/components/watermark";
|
|
|
|
|
+import { formatCount } from "@pipeline/shared";
|
|
|
|
|
+import { Tag, ColorDot } from "../github-trending/index";
|
|
|
|
|
+
|
|
|
|
|
+interface GithubDailyPickSceneProps {
|
|
|
|
|
+ scene: RemotionScene;
|
|
|
|
|
+ sceneIndex: number;
|
|
|
|
|
+ totalScenes: number;
|
|
|
|
|
+ totalFrames: number;
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ channelName?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * github-daily-pick content scene — one ACT chapter of the day's six-act
|
|
|
|
|
+ * recommendation (钩子/痛点/亮相/亮点/上手/总结). Unlike the board templates (headline = repo fullName), here the scene
|
|
|
|
|
+ * title is the act ("快速上手"), the repo identity moves to a
|
|
|
|
|
+ * compact sub-line, and the body is the scene's keyframe cards plus the repo's
|
|
|
|
|
+ * social preview. A top-right progress badge (03 / 07) reinforces the
|
|
|
|
|
+ * deep-dive chapter structure.
|
|
|
|
|
+ */
|
|
|
|
|
+const GithubDailyPickScene: React.FC<GithubDailyPickSceneProps> = ({
|
|
|
|
|
+ scene,
|
|
|
|
|
+ sceneIndex,
|
|
|
|
|
+ totalScenes,
|
|
|
|
|
+ channelName,
|
|
|
|
|
+}) => {
|
|
|
|
|
+ const frame = useCurrentFrame();
|
|
|
|
|
+ const { width, height, durationInFrames } = useVideoConfig();
|
|
|
|
|
+ const isPortrait = height > width;
|
|
|
|
|
+ const palette = GITHUB_TRENDING_PALETTE[sceneIndex % GITHUB_TRENDING_PALETTE.length];
|
|
|
|
|
+ const github: RenderSegmentExtension | undefined =
|
|
|
|
|
+ scene.extension?.type === "github-daily-pick" ? scene.extension : undefined;
|
|
|
|
|
+
|
|
|
|
|
+ const sceneDur = durationInFrames;
|
|
|
|
|
+ const fadeOpacity = interpolate(
|
|
|
|
|
+ frame,
|
|
|
|
|
+ [0, 8, sceneDur - 8, sceneDur],
|
|
|
|
|
+ [0, 1, 1, 0],
|
|
|
|
|
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const socialPreview = github?.images?.socialPreview;
|
|
|
|
|
+ const starHistory = github?.images?.starHistory;
|
|
|
|
|
+
|
|
|
|
|
+ const pad = isPortrait ? 48 : 80;
|
|
|
|
|
+ const footerReserved = isPortrait ? 500 : 140;
|
|
|
|
|
+
|
|
|
|
|
+ const repo = github?.repo;
|
|
|
|
|
+ const language = repo?.language || "";
|
|
|
|
|
+ const languageColor = repo?.languageColor || palette.accent;
|
|
|
|
|
+ const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
|
|
|
|
|
+ const license = repo?.license || "";
|
|
|
|
|
+ const fullName = repo?.fullName || "";
|
|
|
|
|
+
|
|
|
|
|
+ if (!github) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute", inset: 0, display: "flex",
|
|
|
|
|
+ alignItems: "center", justifyContent: "center", padding: pad,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ fontSize: 48, fontWeight: 700, color: "white", fontFamily: "Noto Sans SC", textAlign: "center" }}>
|
|
|
|
|
+ {scene.title || scene.captionOrigin}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {(scene.caption?.length ?? 0) > 0 && <SubtitleBar caption={scene.caption} />}
|
|
|
|
|
+ <Watermark text={channelName} />
|
|
|
|
|
+ </AbsoluteFill>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // The scene's cards, ordered as produced. Card kinds are free-form strings
|
|
|
|
|
+ // (功能简介 / 快速上手 / 对比 / 安装 ...); shown as titled sections.
|
|
|
|
|
+ const cards = (scene.cardList ?? []).filter((c) => (c.desc ?? "").trim());
|
|
|
|
|
+ const badge = `${String(sceneIndex + 1).padStart(2, "0")} / ${String(totalScenes).padStart(2, "0")}`;
|
|
|
|
|
+
|
|
|
|
|
+ const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {/* Aspect title — the chapter headline (from displayText) */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ fontSize,
|
|
|
|
|
+ fontWeight: 800,
|
|
|
|
|
+ color: "white",
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ lineHeight: 1.15,
|
|
|
|
|
+ letterSpacing: "-0.01em",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {scene.title || fullName}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/* Repo identity — compact sub-line under the act title */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 16, marginTop: 16,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ fontSize: tagSize === "large" ? 36 : 30, fontWeight: 600,
|
|
|
|
|
+ color: "rgba(255,255,255,0.72)", fontFamily: "Noto Sans SC",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {fullName}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {language && (
|
|
|
|
|
+ <Tag accent={palette.accent} size={tagSize}>
|
|
|
|
|
+ <ColorDot color={languageColor} size={dotSize} />
|
|
|
|
|
+ {language}
|
|
|
|
|
+ </Tag>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
|
|
|
|
|
+ {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const renderBadge = (scale: number) => (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ display: "flex", alignItems: "center", gap: 10,
|
|
|
|
|
+ padding: "12px 26px", borderRadius: 12,
|
|
|
|
|
+ background: "rgba(0,0,0,0.35)",
|
|
|
|
|
+ border: `1px solid ${palette.accent}66`,
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ backdropFilter: "blur(4px)",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <span style={{ fontSize: 30 * scale, fontWeight: 700, color: palette.accent }}>
|
|
|
|
|
+ {String(sceneIndex + 1).padStart(2, "0")}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span style={{ fontSize: 22 * scale, fontWeight: 500, color: "rgba(255,255,255,0.55)" }}>
|
|
|
|
|
+ / {String(totalScenes).padStart(2, "0")}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const cardsRow = (size: "default" | "large") => (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: 1, display: "flex", flexDirection: "column", gap: 20, minHeight: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {cards.slice(0, size === "large" ? 2 : 3).map((card, i) => (
|
|
|
|
|
+ <AspectSection
|
|
|
|
|
+ key={i}
|
|
|
|
|
+ title={card.kind || "要点"}
|
|
|
|
|
+ accent={palette.accent}
|
|
|
|
|
+ body={card.desc ?? ""}
|
|
|
|
|
+ size={size}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <AbsoluteFill style={{
|
|
|
|
|
+ opacity: fadeOpacity,
|
|
|
|
|
+ background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {/* Subtle grid overlay (matches other templates' aesthetic) */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute", inset: 0,
|
|
|
|
|
+ backgroundImage: `
|
|
|
|
|
+ linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
|
|
|
|
+ linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
|
|
|
|
|
+ `,
|
|
|
|
|
+ backgroundSize: "80px 80px",
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Top accent line */}
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute", top: 0, left: 0, width: "100%", height: 4,
|
|
|
|
|
+ background: `linear-gradient(90deg, ${palette.accent}, ${palette.accent}88)`,
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ {isPortrait ? (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "relative",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ width: "100%",
|
|
|
|
|
+ height: "100%",
|
|
|
|
|
+ boxSizing: "border-box",
|
|
|
|
|
+ padding: `${pad}px ${pad}px ${footerReserved}px`,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 20 }}>
|
|
|
|
|
+ <div style={{ flex: 1, minWidth: 0 }}>{renderHeader(84, "large", 16)}</div>
|
|
|
|
|
+ {renderBadge(0.9)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{ height: 1, background: "rgba(255,255,255,0.18)", marginTop: 22 }} />
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: 1, display: "flex", flexDirection: "column", gap: 24, paddingTop: 22, minHeight: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {socialPreview && (
|
|
|
|
|
+ <ImageFrame>
|
|
|
|
|
+ <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {cardsRow("large")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: pad,
|
|
|
|
|
+ left: pad,
|
|
|
|
|
+ right: pad,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ alignItems: "flex-start",
|
|
|
|
|
+ justifyContent: "space-between",
|
|
|
|
|
+ gap: 24,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ flex: 1, minWidth: 0 }}>{renderHeader(64, "default", 14)}</div>
|
|
|
|
|
+ {renderBadge(1)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: pad + 150,
|
|
|
|
|
+ left: pad,
|
|
|
|
|
+ right: pad,
|
|
|
|
|
+ height: 1,
|
|
|
|
|
+ background: "rgba(255,255,255,0.18)",
|
|
|
|
|
+ }} />
|
|
|
|
|
+
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ position: "absolute",
|
|
|
|
|
+ top: pad + 174,
|
|
|
|
|
+ left: pad,
|
|
|
|
|
+ right: pad,
|
|
|
|
|
+ bottom: footerReserved,
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ gap: 40,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {socialPreview && (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: "38%",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 20,
|
|
|
|
|
+ overflow: "hidden",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <ImageFrame>
|
|
|
|
|
+ <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ {starHistory && (
|
|
|
|
|
+ <ImageFrame>
|
|
|
|
|
+ <Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
|
|
+ </ImageFrame>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {cardsRow("default")}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {(scene.caption?.length ?? 0) > 0 && (
|
|
|
|
|
+ <SubtitleBar
|
|
|
|
|
+ caption={scene.caption}
|
|
|
|
|
+ style={isPortrait ? { bottom: 380 } : undefined}
|
|
|
|
|
+ fontSize={isPortrait ? 56 : undefined}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+ <Watermark text={channelName} />
|
|
|
|
|
+ </AbsoluteFill>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const ImageFrame: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ width: "100%",
|
|
|
|
|
+ height: "auto",
|
|
|
|
|
+ background: "#0b1220",
|
|
|
|
|
+ border: "1px solid rgba(255,255,255,0.10)",
|
|
|
|
|
+ borderRadius: 16,
|
|
|
|
|
+ overflow: "hidden",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ alignItems: "center",
|
|
|
|
|
+ justifyContent: "center",
|
|
|
|
|
+ boxShadow: "0 12px 32px rgba(0,0,0,0.45), 0 2px 8px rgba(0,0,0,0.35)",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </div>
|
|
|
|
|
+);
|
|
|
|
|
+
|
|
|
|
|
+const AspectSection: React.FC<{
|
|
|
|
|
+ title: string;
|
|
|
|
|
+ accent: string;
|
|
|
|
|
+ body: string;
|
|
|
|
|
+ size?: "default" | "large";
|
|
|
|
|
+}> = ({ title, accent, body, size = "default" }) => {
|
|
|
|
|
+ const isLarge = size === "large";
|
|
|
|
|
+ const cleanBody = (body ?? "").replace(/[。,、,.;;::\s]+$/, "");
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: 1,
|
|
|
|
|
+ background: "rgba(255,255,255,0.05)",
|
|
|
|
|
+ border: "1px solid rgba(255,255,255,0.10)",
|
|
|
|
|
+ borderRadius: 12,
|
|
|
|
|
+ padding: isLarge ? "24px 32px" : "22px 28px",
|
|
|
|
|
+ display: "flex",
|
|
|
|
|
+ flexDirection: "column",
|
|
|
|
|
+ gap: 12,
|
|
|
|
|
+ minHeight: 0,
|
|
|
|
|
+ }}>
|
|
|
|
|
+ <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ display: "inline-block",
|
|
|
|
|
+ width: isLarge ? 6 : 5,
|
|
|
|
|
+ height: isLarge ? 32 : 26,
|
|
|
|
|
+ background: accent,
|
|
|
|
|
+ borderRadius: 2,
|
|
|
|
|
+ }} />
|
|
|
|
|
+ <span style={{
|
|
|
|
|
+ fontSize: isLarge ? 34 : 28,
|
|
|
|
|
+ fontWeight: 700,
|
|
|
|
|
+ color: accent,
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ letterSpacing: "0.04em",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {title}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style={{
|
|
|
|
|
+ flex: "0 0 auto",
|
|
|
|
|
+ fontSize: isLarge ? 52 : 32,
|
|
|
|
|
+ lineHeight: isLarge ? 1.35 : 1.4,
|
|
|
|
|
+ color: "white",
|
|
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
|
|
+ fontWeight: 400,
|
|
|
|
|
+ display: "-webkit-box",
|
|
|
|
|
+ WebkitLineClamp: isLarge ? 3 : 4,
|
|
|
|
|
+ WebkitBoxOrient: "vertical",
|
|
|
|
|
+ overflow: "hidden",
|
|
|
|
|
+ overflowWrap: "break-word",
|
|
|
|
|
+ }}>
|
|
|
|
|
+ {cleanBody}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export default GithubDailyPickScene;
|