| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371 |
- 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_DAILY_PICK_PALETTE } from "../base/theme/colors";
- import { SubtitleBar } from "../base/components/subtitle-bar";
- import { Watermark } from "../base/components/watermark";
- import { formatCount } from "@pipeline/shared";
- import { ColorDot } from "../github-trending/index";
- interface GithubDailyPickSceneProps {
- scene: RemotionScene;
- sceneIndex: number;
- totalScenes: number;
- totalFrames: number;
- title: string;
- channelName?: string;
- }
- /** Dark-stage tag chip — the deep-dive board keeps the dark look, so it can't
- * reuse github-trending's (now light) Tag. Dark glass chip, light accent. */
- const Tag: React.FC<{ accent: string; children: React.ReactNode; size?: "default" | "large" }> = ({ accent, children, size = "default" }) => (
- <div style={{
- display: "inline-flex",
- alignItems: "center",
- gap: 10,
- padding: "12px 24px",
- borderRadius: 10,
- background: "rgba(255,255,255,0.08)",
- border: `1px solid ${accent}55`,
- color: "white",
- fontFamily: "Noto Sans SC",
- fontSize: size === "large" ? 32 : 28,
- fontWeight: 500,
- backdropFilter: "blur(4px)",
- }}>
- {children}
- </div>
- );
- /**
- * 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_DAILY_PICK_PALETTE[sceneIndex % GITHUB_DAILY_PICK_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;
|