|
|
@@ -1,11 +1,9 @@
|
|
|
import React from "react";
|
|
|
import {
|
|
|
- useCurrentFrame,
|
|
|
useVideoConfig,
|
|
|
AbsoluteFill,
|
|
|
Img,
|
|
|
staticFile,
|
|
|
- interpolate,
|
|
|
} from "remotion";
|
|
|
import type { RemotionScene } from "../Root";
|
|
|
import type { RenderSegmentExtension } from "@pipeline/shared";
|
|
|
@@ -13,6 +11,7 @@ import { GITHUB_WEEKLY_PALETTE } from "../base/theme/colors";
|
|
|
import { SubtitleBar } from "../base/components/subtitle-bar";
|
|
|
import { Watermark } from "../base/components/watermark";
|
|
|
import { BoardPortrait, PORTRAIT_FOOTER_RESERVED } from "../base/components/board-portrait";
|
|
|
+import { Rise, Pop, DrawLine, CountUp, RevealImage, useRise } from "../base/components/motion";
|
|
|
import { formatCount } from "@pipeline/shared";
|
|
|
|
|
|
/**
|
|
|
@@ -41,8 +40,7 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
sceneIndex,
|
|
|
channelName,
|
|
|
}) => {
|
|
|
- const frame = useCurrentFrame();
|
|
|
- const { width, height, durationInFrames } = useVideoConfig();
|
|
|
+ const { width, height } = useVideoConfig();
|
|
|
const isPortrait = height > width;
|
|
|
const palette = GITHUB_WEEKLY_PALETTE[sceneIndex % GITHUB_WEEKLY_PALETTE.length];
|
|
|
const github: RenderSegmentExtension | undefined =
|
|
|
@@ -53,19 +51,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
: undefined;
|
|
|
const isRecap = scene.extension?.type === "github-weekly-recap";
|
|
|
|
|
|
- const sceneDur = durationInFrames;
|
|
|
- // Landscape keeps the legacy soft fade. Portrait cuts hard between scenes:
|
|
|
- // Sequences don't overlap, so the old fade dipped both scenes through the
|
|
|
- // composition's black base (a black flash at every boundary) — and the
|
|
|
- // board's staggered element entrances now carry the motion anyway.
|
|
|
- const fadeOpacity = isPortrait
|
|
|
- ? 1
|
|
|
- : interpolate(
|
|
|
- frame,
|
|
|
- [0, 8, sceneDur - 8, sceneDur],
|
|
|
- [0, 1, 1, 0],
|
|
|
- { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
|
|
- );
|
|
|
+ // No scene-level fade on either orientation: Sequences don't overlap, so a
|
|
|
+ // fade dips both scenes through the composition's black base (black flash at
|
|
|
+ // every boundary). The staggered element entrances carry the motion instead.
|
|
|
|
|
|
// Named images from the shared board extension (resolved to filenames by the
|
|
|
// renderer). Object, not positional array — socialPreview / starHistory.
|
|
|
@@ -86,24 +74,20 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
const repo = github?.repo;
|
|
|
const language = repo?.language || "";
|
|
|
const languageColor = repo?.languageColor || palette.accent;
|
|
|
- const starsLabel = repo?.stars != null ? `${formatCount(repo.stars)} stars` : "";
|
|
|
+ const stars = repo?.stars;
|
|
|
// Under the weekly board, todayStars carries the WEEKLY gain. The recap
|
|
|
// instead tags the scene with the repo's featured date ("2026-08-12" →
|
|
|
// "推荐 · 8月12日"); its snapshot numbers are feature-time, not this week's.
|
|
|
const featuredLabel = github?.coveredDate
|
|
|
? `推荐 · ${github.coveredDate.replace(/^\d{4}-/, "").replace("-", "月").replace(/^0/, "")}日`
|
|
|
: "";
|
|
|
- const weekGainLabel = isRecap
|
|
|
- ? featuredLabel
|
|
|
- : repo?.todayStars != null
|
|
|
- ? `本周 +${formatCount(repo.todayStars)}`
|
|
|
- : "";
|
|
|
+ const gain = isRecap ? undefined : repo?.todayStars;
|
|
|
const license = repo?.license || "";
|
|
|
const fullName = repo?.fullName || scene.title || "";
|
|
|
|
|
|
if (!github) {
|
|
|
return (
|
|
|
- <AbsoluteFill style={{ opacity: fadeOpacity, background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
|
|
|
+ <AbsoluteFill style={{ background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)` }}>
|
|
|
<div style={{
|
|
|
position: "absolute", inset: 0, display: "flex",
|
|
|
alignItems: "center", justifyContent: "center", padding: pad,
|
|
|
@@ -120,37 +104,63 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
|
|
|
const renderHeader = (fontSize: number, tagSize: "default" | "large", dotSize: number) => (
|
|
|
<div>
|
|
|
- <div style={{
|
|
|
- fontSize,
|
|
|
- fontWeight: 800,
|
|
|
- color: "#1e293b",
|
|
|
- fontFamily: "Noto Sans SC",
|
|
|
- lineHeight: 1.15,
|
|
|
- letterSpacing: "-0.01em",
|
|
|
- }}>
|
|
|
- {fullName}
|
|
|
- </div>
|
|
|
+ <Rise delay={2}>
|
|
|
+ <div style={{
|
|
|
+ fontSize,
|
|
|
+ fontWeight: 800,
|
|
|
+ color: "#1e293b",
|
|
|
+ fontFamily: "Noto Sans SC",
|
|
|
+ lineHeight: 1.15,
|
|
|
+ letterSpacing: "-0.01em",
|
|
|
+ }}>
|
|
|
+ {fullName}
|
|
|
+ </div>
|
|
|
+ </Rise>
|
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 14, marginTop: 22 }}>
|
|
|
{language && (
|
|
|
- <Tag accent={palette.accent} size={tagSize}>
|
|
|
- <ColorDot color={languageColor} size={dotSize} />
|
|
|
- {language}
|
|
|
- </Tag>
|
|
|
+ <Pop delay={12}>
|
|
|
+ <Tag accent={palette.accent} size={tagSize}>
|
|
|
+ <ColorDot color={languageColor} size={dotSize} />
|
|
|
+ {language}
|
|
|
+ </Tag>
|
|
|
+ </Pop>
|
|
|
+ )}
|
|
|
+ {isRecap ? (
|
|
|
+ featuredLabel && (
|
|
|
+ <Pop delay={17}>
|
|
|
+ <Tag accent={palette.accent} size={tagSize} emphasized>
|
|
|
+ {featuredLabel}
|
|
|
+ </Tag>
|
|
|
+ </Pop>
|
|
|
+ )
|
|
|
+ ) : (
|
|
|
+ gain != null && (
|
|
|
+ <Pop delay={17}>
|
|
|
+ <Tag accent={palette.accent} size={tagSize} emphasized>
|
|
|
+ <CountUp value={gain} format={(n) => `本周 +${formatCount(n)}`} delay={18} duration={32} />
|
|
|
+ </Tag>
|
|
|
+ </Pop>
|
|
|
+ )
|
|
|
)}
|
|
|
- {weekGainLabel && (
|
|
|
- <Tag accent={palette.accent} size={tagSize} emphasized>
|
|
|
- {weekGainLabel}
|
|
|
- </Tag>
|
|
|
+ {stars != null && (
|
|
|
+ <Pop delay={22}>
|
|
|
+ <Tag accent={palette.accent} size={tagSize}>
|
|
|
+ <CountUp value={stars} format={formatCount} delay={23} duration={34} />
|
|
|
+ {" stars"}
|
|
|
+ </Tag>
|
|
|
+ </Pop>
|
|
|
+ )}
|
|
|
+ {license && (
|
|
|
+ <Pop delay={27}>
|
|
|
+ <Tag accent={palette.accent} size={tagSize}>{license}</Tag>
|
|
|
+ </Pop>
|
|
|
)}
|
|
|
- {starsLabel && <Tag accent={palette.accent} size={tagSize}>{starsLabel}</Tag>}
|
|
|
- {license && <Tag accent={palette.accent} size={tagSize}>{license}</Tag>}
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
<AbsoluteFill style={{
|
|
|
- opacity: fadeOpacity,
|
|
|
background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
|
|
|
}}>
|
|
|
{/* Subtle paper ruling (the light counterpart of the daily board's grid) */}
|
|
|
@@ -163,9 +173,10 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
backgroundSize: "80px 80px",
|
|
|
}} />
|
|
|
|
|
|
- {/* Top accent rule — a double editorial rule, magazine style */}
|
|
|
- <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: 6, background: palette.accent }} />
|
|
|
- <div style={{ position: "absolute", top: 10, left: 0, width: "100%", height: 1, background: `${palette.accent}55` }} />
|
|
|
+ {/* Top accent rule — a double editorial rule, magazine style; the heavy
|
|
|
+ band draws itself in, the hairline follows */}
|
|
|
+ <DrawLine delay={0} duration={18} color={palette.accent} height={6} style={{ position: "absolute", top: 0, left: 0 }} />
|
|
|
+ <DrawLine delay={8} duration={18} color={`${palette.accent}55`} height={1} style={{ position: "absolute", top: 10, left: 0 }} />
|
|
|
|
|
|
{isPortrait ? (
|
|
|
// Portrait: the shared animated Hero board (base/components/board-portrait)
|
|
|
@@ -204,7 +215,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
</div>
|
|
|
)
|
|
|
) : (
|
|
|
- // Landscape: absolute positioning (single-line 80px header).
|
|
|
+ // Landscape: absolute positioning (single-line 80px header). Elements
|
|
|
+ // enter staggered — title rises, tags pop in one by one, divider and
|
|
|
+ // rules draw, images reveal, sections rise in sequence.
|
|
|
<>
|
|
|
<div style={{
|
|
|
position: "absolute",
|
|
|
@@ -220,9 +233,9 @@ const GithubWeeklyScene: React.FC<GithubWeeklySceneProps> = ({
|
|
|
top: landscapeDividerY,
|
|
|
left: pad,
|
|
|
right: pad,
|
|
|
- height: 1,
|
|
|
- background: "rgba(30,41,59,0.18)",
|
|
|
- }} />
|
|
|
+ }}>
|
|
|
+ <DrawLine delay={14} duration={22} color="rgba(30,41,59,0.18)" />
|
|
|
+ </div>
|
|
|
|
|
|
<LandscapeContent
|
|
|
contentTop={landscapeContentTop}
|
|
|
@@ -286,23 +299,29 @@ export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, si
|
|
|
}} />
|
|
|
);
|
|
|
|
|
|
-const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number }> = ({ children, flex }) => (
|
|
|
- <div style={{
|
|
|
- flex,
|
|
|
- width: flex ? undefined : "100%",
|
|
|
- height: "auto",
|
|
|
- background: "#ffffff",
|
|
|
- border: "1px solid rgba(30,41,59,0.12)",
|
|
|
- borderRadius: 12,
|
|
|
- overflow: "hidden",
|
|
|
- display: "flex",
|
|
|
- alignItems: "center",
|
|
|
- justifyContent: "center",
|
|
|
- boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
|
|
|
- }}>
|
|
|
- {children}
|
|
|
- </div>
|
|
|
-);
|
|
|
+// Frame rises in (delay-driven); the social preview inside reveals via RevealImage.
|
|
|
+const ImageFrame: React.FC<{ children: React.ReactNode; flex?: string | number; delay?: number }> = ({ children, flex, delay = 0 }) => {
|
|
|
+ const { opacity, y } = useRise(delay, 30);
|
|
|
+ return (
|
|
|
+ <div style={{
|
|
|
+ flex,
|
|
|
+ width: flex ? undefined : "100%",
|
|
|
+ height: "auto",
|
|
|
+ background: "#ffffff",
|
|
|
+ border: "1px solid rgba(30,41,59,0.12)",
|
|
|
+ borderRadius: 12,
|
|
|
+ overflow: "hidden",
|
|
|
+ display: "flex",
|
|
|
+ alignItems: "center",
|
|
|
+ justifyContent: "center",
|
|
|
+ boxShadow: "0 10px 28px rgba(120,100,60,0.14), 0 2px 6px rgba(120,100,60,0.10)",
|
|
|
+ opacity,
|
|
|
+ transform: `translateY(${y}px)`,
|
|
|
+ }}>
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
|
|
|
const Section: React.FC<{
|
|
|
title: string;
|
|
|
@@ -310,8 +329,10 @@ const Section: React.FC<{
|
|
|
body: string;
|
|
|
flex?: number;
|
|
|
size?: "default" | "large";
|
|
|
-}> = ({ title, accent, body, flex, size = "default" }) => {
|
|
|
+ delay?: number;
|
|
|
+}> = ({ title, accent, body, flex, size = "default", delay = 0 }) => {
|
|
|
const isLarge = size === "large";
|
|
|
+ const { opacity, y } = useRise(delay, 40);
|
|
|
// Strip trailing punctuation that would look awkward if rendered at the
|
|
|
// end of the clamped text (LLM occasionally leaves a hanging 。,,;).
|
|
|
const cleanBody = body.replace(/[。,、,.;;::\s]+$/, "");
|
|
|
@@ -327,6 +348,8 @@ const Section: React.FC<{
|
|
|
flexDirection: "column",
|
|
|
gap: 12,
|
|
|
minHeight: 0,
|
|
|
+ opacity,
|
|
|
+ transform: `translateY(${y}px)`,
|
|
|
}}>
|
|
|
<div style={{
|
|
|
display: "flex",
|
|
|
@@ -412,12 +435,13 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
|
|
|
overflow: "hidden",
|
|
|
}}>
|
|
|
{socialPreview && (
|
|
|
- <ImageFrame>
|
|
|
- <Img src={staticFile(socialPreview.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
+ <ImageFrame delay={18}>
|
|
|
+ {/* Reveal: clip wipes open while the image settles (Ken Burns-ish) */}
|
|
|
+ <RevealImage filename={socialPreview.filename} delay={26} duration={24} />
|
|
|
</ImageFrame>
|
|
|
)}
|
|
|
{starHistory && (
|
|
|
- <ImageFrame>
|
|
|
+ <ImageFrame delay={44}>
|
|
|
<Img src={staticFile(starHistory.filename)} style={{ display: "block", width: "100%", height: "auto" }} />
|
|
|
</ImageFrame>
|
|
|
)}
|
|
|
@@ -431,9 +455,9 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
|
|
|
flexDirection: "column",
|
|
|
gap: 20,
|
|
|
}}>
|
|
|
- <Section title="项目亮点" accent={palette.accent} body={github.highlights} />
|
|
|
- <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} />
|
|
|
- <Section title="建议" accent={palette.accent} body={github.review} />
|
|
|
+ <Section title="项目亮点" accent={palette.accent} body={github.highlights} delay={30} />
|
|
|
+ <Section title="项目介绍" accent={palette.accent} body={github.intro} flex={3} delay={40} />
|
|
|
+ <Section title="建议" accent={palette.accent} body={github.review} delay={50} />
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|