소스 검색

github-trending:首屏后增加一个概括场景,修复文本溢出问题

lkatzey 2 주 전
부모
커밋
dec22ee099

+ 1 - 0
packages/core/src/stages/compose.ts

@@ -67,6 +67,7 @@ export function composeProject(
       backgroundAsset,
       layoutHint: scene.layoutHint ?? "centered",
       github: scene.github,
+      trendingRepos: scene.trendingRepos,
     };
   });
 

+ 30 - 5
packages/core/src/stages/parse.ts

@@ -7,7 +7,7 @@ import {
   stripUnsupportedGlyphs,
   clipToLength,
 } from "@pipeline/shared";
-import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
+import type { ParsedContent, Scene, VideoInput, RepoMeta } from "@pipeline/shared";
 
 export interface ParseStageConfig {
   llm: {
@@ -239,11 +239,17 @@ export async function parseText(
       ]
     : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
   // For github-trending the cover doubles as the opening narration screen —
-  // give it a fixed opening line (with today's date) so the TTS stage produces
-  // audio and the compose stage sets the cover duration to match. Other
-  // templates keep the original behaviour (1s silent cover).
+  // give it a fixed masthead line (greeting + date + show name) so the TTS
+  // stage produces audio and the compose stage sets the cover duration to
+  // match. The longer "today's selection" portion moves to a dedicated
+  // summary scene inserted between cover and the first repo. Other templates
+  // keep the original behaviour (1s silent cover).
+  const gtRepoCount = videoInput.scenes.filter(s => s.github).length;
   const coverNarration = template === "github-trending"
-    ? `大家好,今天是${formatTodayChineseDate()},欢迎收看 GitHub 每日热榜。今天为大家精选了几个值得关注的优质开源项目,让我们一探究竟。`
+    ? `大家好,今天是${formatTodayChineseDate()},欢迎收看 GitHub 每日热榜。`
+    : "";
+  const summaryNarration = template === "github-trending"
+    ? `今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目,让我们一探究竟。`
     : "";
   scenes.push({
     id: "cover",
@@ -257,6 +263,25 @@ export async function parseText(
     duration: 1,
   });
 
+  // For github-trending inject a deterministic summary scene between cover
+  // and the first repo. It carries the "today's selection" narration and the
+  // full repo list for visual rendering. Skipped for other templates.
+  if (template === "github-trending") {
+    const trendingRepos: RepoMeta[] = videoInput.scenes
+      .map(s => s.github?.repo)
+      .filter((r): r is RepoMeta => !!r);
+    scenes.push({
+      id: "summary",
+      index: sceneIndex++,
+      sceneType: "summary",
+      narration: summaryNarration,
+      title: "今日精选",
+      keyframes: [],
+      trendingRepos,
+      duration: 1,
+    });
+  }
+
   // Content scenes
   for (const s of videoInput.scenes) {
     scenes.push({

+ 7 - 0
packages/shared/src/llm/client.ts

@@ -27,6 +27,13 @@ export class LLMClient {
       ],
       temperature: 0.3,
       response_format: { type: "json_object" },
+      // The github-trending template produces ~5 repos × ~600 chars of
+      // structured JSON each (repo metadata + narration + intro + highlights
+      // + review + image refs) — that's ~6000 tokens before counting JSON
+      // overhead. Provider defaults (often 1024–4096) truncate the response
+      // mid-scene and break JSON parsing. 8192 leaves headroom for up to
+      // ~10 repos; raise if needed.
+      max_tokens: 8192,
     });
 
     const content = response.choices[0]?.message?.content;

+ 1 - 1
packages/shared/src/llm/prompts/parse-text.ts

@@ -90,7 +90,7 @@ Template: Product Marketing (产品营销)
 Template: GitHub Trending Showcase (GitHub 每日热榜)
 - Use a pragmatic, factual tone for a developer audience — no hype, no marketing fluff.
 - Each repository produces EXACTLY ONE scene.
-- For multi-repo inputs, optionally open with a 1-sentence trend observation woven into the first repo's narration, but do not create a separate trend-overview scene.
+- Do not create overview, summary, or trend-overview scenes — the pipeline injects a dedicated summary scene separately. Each repo scene MUST focus on its own repo.
 - The output for this template relies on structured metadata embedded in the input. Treat that metadata as authoritative — DO NOT paraphrase or invent values.
 
 PER-REPO SCENE STRUCTURE (mandatory for every repo scene):

+ 3 - 2
packages/shared/src/types/pipeline.ts

@@ -1,5 +1,5 @@
 import { z } from "zod";
-import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema } from "./scene.js";
+import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema, RepoMetaSchema } from "./scene.js";
 
 export const PipelineInputSchema = z.object({
   text: z.string(),
@@ -57,7 +57,7 @@ export type ComposedKeyframe = z.infer<typeof ComposedKeyframeSchema>;
 
 export const ComposedSceneSchema = z.object({
   id: z.string(),
-  sceneType: z.enum(["cover", "content", "outro"]).default("content"),
+  sceneType: z.enum(["cover", "content", "summary", "outro"]).default("content"),
   startFrame: z.number(),
   endFrame: z.number(),
   narration: z.string(),
@@ -75,6 +75,7 @@ export const ComposedSceneSchema = z.object({
     .optional(),
   layoutHint: z.string(),
   github: GithubSceneDataSchema.optional(),
+  trendingRepos: z.array(RepoMetaSchema).optional(),
 });
 
 export type ComposedScene = z.infer<typeof ComposedSceneSchema>;

+ 2 - 1
packages/shared/src/types/scene.ts

@@ -118,7 +118,7 @@ export type VideoInput = z.infer<typeof VideoInputSchema>;
 export const SceneSchema = z.object({
   id: z.string(),
   index: z.number(),
-  sceneType: z.enum(["cover", "content", "outro"]).default("content"),
+  sceneType: z.enum(["cover", "content", "summary", "outro"]).default("content"),
   narration: z.string().max(200),
   displayText: z.string().optional(),
   title: z.string().optional(),
@@ -131,6 +131,7 @@ export const SceneSchema = z.object({
     .enum(["full-text", "split", "centered", "bullet-list"])
     .optional(),
   github: GithubSceneDataSchema.optional(),
+  trendingRepos: z.array(RepoMetaSchema).optional(),
 });
 
 export type Scene = z.infer<typeof SceneSchema>;

+ 20 - 3
packages/templates/src/Root.tsx

@@ -1,17 +1,17 @@
 import React, { useEffect, useState } from "react";
 import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion";
 import type { TemplateType, AspectRatio } from "@pipeline/shared";
-import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
+import type { WordTimestamp, GithubSceneData, RepoMeta } 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, { GithubTrendingSummaryScene } from "./github-trending/index";
 import { THEMES } from "./base/theme/colors";
 
 export interface RemotionScene {
   id: string;
-  sceneType: "cover" | "content" | "outro";
+  sceneType: "cover" | "content" | "summary" | "outro";
   startFrame: number;
   endFrame: number;
   narration: string;
@@ -30,6 +30,7 @@ export interface RemotionScene {
   backgroundAsset?: { id: string; filename: string };
   layoutHint: string;
   github?: GithubSceneData;
+  trendingRepos?: RepoMeta[];
 }
 
 export interface RemotionProps {
@@ -117,6 +118,22 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
             </Sequence>
           );
         }
+        if (scene.sceneType === "summary" && props.template === "github-trending") {
+          return (
+            <Sequence
+              key={scene.id}
+              from={scene.startFrame}
+              durationInFrames={duration}
+            >
+              {sceneAudio}
+              <GithubTrendingSummaryScene
+                scene={scene}
+                totalFrames={totalFrames}
+                channelName={props.channelName}
+              />
+            </Sequence>
+          );
+        }
         return (
           <Sequence
             key={scene.id}

+ 12 - 7
packages/templates/src/github-trending/index.tsx

@@ -202,7 +202,7 @@ const GithubTrendingScene: React.FC<GithubTrendingSceneProps> = ({
 
 // --- Sub-components ---
 
-const Tag: React.FC<{ accent: string; children: React.ReactNode; size?: "default" | "large" }> = ({ accent, children, size = "default" }) => (
+export const Tag: React.FC<{ accent: string; children: React.ReactNode; size?: "default" | "large" }> = ({ accent, children, size = "default" }) => (
   <div style={{
     display: "inline-flex",
     alignItems: "center",
@@ -221,7 +221,7 @@ const Tag: React.FC<{ accent: string; children: React.ReactNode; size?: "default
   </div>
 );
 
-const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, size = 14 }) => (
+export const ColorDot: React.FC<{ color: string; size?: number }> = ({ color, size = 14 }) => (
   <span style={{
     display: "inline-block",
     width: size,
@@ -295,16 +295,20 @@ const Section: React.FC<{
         </span>
       </div>
       <div style={{
-        flex: 1,
-        minHeight: 0,
+        // Size to content (capped at N lines by -webkit-line-clamp), not by
+        // flex distribution. With flex:1 the body's flex-basis was 0% and its
+        // height was purely the parent's allocation — when the section was
+        // short (images consuming vertical space) the body could end up
+        // shorter than a single line of text, and `overflow: hidden` clipped
+        // the bottom of that line. `0 0 auto` makes body exactly content-
+        // sized: never shrinks, never grows. Empty space below short text is
+        // fine (section background shows through).
+        flex: "0 0 auto",
         fontSize: isLarge ? 60 : 32,
         lineHeight: isLarge ? 1.35 : 1.4,
         color: "white",
         fontFamily: "Noto Sans SC",
         fontWeight: 400,
-        // -webkit-line-clamp truncates cleanly at line N with an ellipsis,
-        // instead of clipping mid-character when the section's flex-sized
-        // body area is shorter than the text content.
         display: "-webkit-box",
         WebkitLineClamp: isLarge ? 3 : 5,
         WebkitBoxOrient: "vertical",
@@ -449,3 +453,4 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
 };
 
 export default GithubTrendingScene;
+export { GithubTrendingSummaryScene } from "./summary-scene";

+ 208 - 0
packages/templates/src/github-trending/summary-scene.tsx

@@ -0,0 +1,208 @@
+import React from "react";
+import {
+  useCurrentFrame,
+  useVideoConfig,
+  AbsoluteFill,
+  interpolate,
+} from "remotion";
+import type { RemotionScene } from "../Root";
+import { GITHUB_TRENDING_PALETTE } from "../base/theme/colors";
+import { SubtitleBar } from "../base/components/subtitle-bar";
+import { Watermark } from "../base/components/watermark";
+import { formatCount, type RepoMeta } from "@pipeline/shared";
+import { Tag, ColorDot } from "./index";
+
+interface SummarySceneProps {
+  scene: RemotionScene;
+  totalFrames: number;
+  channelName?: string;
+}
+
+// Circled digits 1-20 for clean rank badges. Fall back to "<n>." past 20.
+const CIRCLED = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩",
+                 "⑪", "⑫", "⑬", "⑭", "⑮", "⑯", "⑰", "⑱", "⑲", "⑳"];
+
+function rankBadge(idx: number): string {
+  return idx < CIRCLED.length ? CIRCLED[idx] : `${idx + 1}.`;
+}
+
+export const GithubTrendingSummaryScene: React.FC<SummarySceneProps> = ({
+  scene,
+  channelName,
+}) => {
+  const frame = useCurrentFrame();
+  const { width, height } = useVideoConfig();
+  const isPortrait = height > width;
+  const palette = GITHUB_TRENDING_PALETTE[0]; // navy — consistent with cover
+
+  const sceneDur = scene.endFrame - scene.startFrame;
+  const fadeOpacity = interpolate(
+    frame,
+    [0, 8, sceneDur - 8, sceneDur],
+    [0, 1, 1, 0],
+    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
+  );
+
+  const repos: RepoMeta[] = scene.trendingRepos ?? [];
+  const repoCount = repos.length;
+  const title = scene.title || "今日精选";
+
+  const pad = isPortrait ? 48 : 80;
+  const footerReserved = isPortrait ? 500 : 140;
+  const titleFontSize = isPortrait ? 92 : 72;
+  const subtitleFontSize = isPortrait ? 36 : 28;
+  const rankFontSize = isPortrait ? 56 : 44;
+  const nameFontSize = isPortrait ? 48 : 36;
+  const rowGap = isPortrait ? 24 : 16;
+
+  return (
+    <AbsoluteFill style={{
+      opacity: fadeOpacity,
+      background: `linear-gradient(135deg, ${palette.from} 0%, ${palette.to} 100%)`,
+    }}>
+      {/* Subtle grid overlay — matches the content scene 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)`,
+      }} />
+
+      <div style={{
+        position: "relative",
+        display: "flex",
+        flexDirection: "column",
+        width: "100%",
+        height: "100%",
+        boxSizing: "border-box",
+        padding: `${pad}px ${pad}px ${footerReserved}px`,
+      }}>
+        {/* Title + subtitle */}
+        <div>
+          <div style={{
+            fontSize: titleFontSize,
+            fontWeight: 800,
+            color: "white",
+            fontFamily: "Noto Sans SC",
+            lineHeight: 1.15,
+            letterSpacing: "-0.01em",
+          }}>
+            {title}
+          </div>
+          <div style={{
+            fontSize: subtitleFontSize,
+            fontWeight: 500,
+            color: "rgba(255,255,255,0.65)",
+            fontFamily: "Noto Sans SC",
+            marginTop: 12,
+          }}>
+            {repoCount} 个优质项目
+          </div>
+        </div>
+
+        {/* Divider */}
+        <div style={{
+          height: 1,
+          background: "rgba(255,255,255,0.18)",
+          marginTop: 24,
+        }} />
+
+        {/* Repo list — flex:1, evenly distributed */}
+        <div style={{
+          flex: 1,
+          display: "flex",
+          flexDirection: "column",
+          justifyContent: "space-evenly",
+          paddingTop: 16,
+          minHeight: 0,
+        }}>
+          {repos.map((repo, idx) => {
+            const language = repo.language || "";
+            const languageColor = repo.languageColor || palette.accent;
+            const starsLabel = repo.stars != null ? `${formatCount(repo.stars)} stars` : "";
+            const license = repo.license || "";
+            return (
+              <div key={repo.fullName} style={{
+                display: "flex",
+                alignItems: "center",
+                gap: isPortrait ? 24 : 18,
+              }}>
+                <span style={{
+                  fontSize: rankFontSize,
+                  fontWeight: 700,
+                  color: palette.accent,
+                  fontFamily: "Noto Sans SC",
+                  lineHeight: 1,
+                  flexShrink: 0,
+                  minWidth: isPortrait ? 56 : 44,
+                  textAlign: "center",
+                }}>
+                  {rankBadge(idx)}
+                </span>
+                <div style={{
+                  display: "flex",
+                  flexDirection: "column",
+                  gap: 8,
+                  minWidth: 0,
+                  flex: 1,
+                }}>
+                  <div style={{
+                    fontSize: nameFontSize,
+                    fontWeight: 700,
+                    color: "white",
+                    fontFamily: "Noto Sans SC",
+                    lineHeight: 1.2,
+                    overflow: "hidden",
+                    textOverflow: "ellipsis",
+                    whiteSpace: "nowrap",
+                  }}>
+                    {repo.fullName}
+                  </div>
+                  {(language || starsLabel || license) && (
+                    <div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
+                      {language && (
+                        <Tag accent={palette.accent} size={isPortrait ? "large" : "default"}>
+                          <ColorDot color={languageColor} size={isPortrait ? 16 : 14} />
+                          {language}
+                        </Tag>
+                      )}
+                      {starsLabel && (
+                        <Tag accent={palette.accent} size={isPortrait ? "large" : "default"}>
+                          {starsLabel}
+                        </Tag>
+                      )}
+                      {license && (
+                        <Tag accent={palette.accent} size={isPortrait ? "large" : "default"}>
+                          {license}
+                        </Tag>
+                      )}
+                    </div>
+                  )}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {scene.wordTimestamps.length > 0 && (
+        <SubtitleBar
+          wordTimestamps={scene.wordTimestamps}
+          style={isPortrait ? { bottom: 380 } : undefined}
+          fontSize={isPortrait ? 56 : undefined}
+        />
+      )}
+      <Watermark text={channelName} />
+    </AbsoluteFill>
+  );
+};
+
+export default GithubTrendingSummaryScene;