ソースを参照

github-trending:将概览合并到首屏,在首屏增加一句话趋势总结

lkatzey 1 週間 前
コミット
4e524f5729

+ 5 - 6
CLAUDE.md

@@ -37,7 +37,7 @@ packages/templates/ 5 个模板的 Remotion React 组件
 
 输入始终是**结构化 JSON** (`VideoInputSchema`)。流水线在 `packages/core/src/stages/` 中依次执行 6 个阶段:
 
-1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)。`github-trending` 模板在这里**确定性注入**额外的 summary 场景(不交给 LLM):拆分 cover 开场白为 masthead + "今日精选 N 个项目",并将 `videoInput.scenes[].github.repo` 收集为 summary 场景的 `trendingRepos` 字段
+1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)。`github-trending` 模板的 cover 场景在这里确定性写入开场口播(masthead + "今日精选 N 个项目"),并承载 LLM 产出的 `coverTags`(≤5 个主题标签)与 `trendSummary`(一句话趋势总结),由模板在首屏渲染;不再注入单独的 summary 场景
 2. **tts** — 按场景调用 TTS provider,生成逐场景音频文件 + word timestamps
 3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
 4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间。逐字段拷贝场景数据——新增场景字段时**必须在此阶段显式透传**(`buildInputProps` 用 `...scene` 自动透传,但 compose 是手写字段映射)
@@ -59,9 +59,8 @@ Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 中注册
 ### 模板系统
 
 5 个模板(news、knowledge、opinion、marketing、github-trending),每个在 `packages/templates/src/<name>/index.tsx` 中有对应的场景组件。共享基础组件位于 `packages/templates/src/base/`。`Root.tsx` 按以下规则路由:
-- `sceneType === "cover"` → 内置 `CoverScene`
+- `sceneType === "cover"` → 内置 `CoverScene`(`github-trending` 的 cover 接收 `coverTags`/`trendSummary`,在首屏渲染主题标签行 + 一句话趋势句)
 - `sceneType === "outro"` → 内置 `OutroScene`
-- `sceneType === "summary"` 且 `template === "github-trending"` → `GithubTrendingSummaryScene`(仅此模板有 summary 场景)
 - 其他 content 类型 → `SCENE_MAP[template]` 对应模板组件
 
 `github-trending` 是当前唯一带 `isPortrait` 分支的模板,竖屏布局采用 flex column(头部取自然高度,内容 flex 填充),横屏保持绝对定位。`Section` 子组件通过 `size: "default" | "large"` 区分横竖屏字号档位,并用 `-webkit-line-clamp` 做安全截断(body 用 `flex: "0 0 auto"` 防止 shrink 导致的单行底部裁剪)。
@@ -78,8 +77,8 @@ Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 中注册
 
 全部在 `packages/shared/src/types/` 中用 Zod 定义:
 
-- `VideoInputSchema` — 用户输入格式(title、subtitle、cover、scenes[]、outro、globalStyle)
-- `SceneSchema` — 内部场景,含 `sceneType: "cover" | "content" | "summary" | "outro"`、可选 `github`(GithubSceneData)、可选 `trendingRepos`(RepoMeta[],仅 summary 场景使用)
+- `VideoInputSchema` — 用户输入格式(title、subtitle、cover、scenes[]、outro、globalStyle;github-trending 另有可选 `coverTags`/`trendSummary`,由 LLM 产出
+- `SceneSchema` — 内部场景,含 `sceneType: "cover" | "content" | "summary" | "outro"`、可选 `github`(GithubSceneData)、可选 `coverTags`(string[])/`trendSummary`(string,仅 github-trending 的 cover 场景使用)
 - `ComposedSceneSchema` — compose 后的场景,含帧时间轴、`audioPath`、已解析的图片。字段与 `SceneSchema` 一一对应
 - `PipelineInputSchema` — 顶层流水线输入(text、template、platform、ttsProvider)
 
@@ -88,5 +87,5 @@ Provider 通过 `registerProvider()` 在 `packages/tts/src/providers/` 中注册
 ## LLM 与 TTS 提示
 
 - `LLMClient`(`packages/shared/src/llm/client.ts`)显式设置 `max_tokens: 8192`——`github-trending` 多仓库结构化 JSON 输出约 6000 tokens,provider 默认值(1024-4096)会截断响应导致 JSON 解析失败
-- `github-trending` 的 cover/summary narration 是 `parse.ts` 硬编码模板字符串(含日期和仓库计数),**不交给 LLM**;LLM 只产出 per-repo `scenes[]`
+- `github-trending` 的 cover narration 是 `parse.ts` 硬编码模板字符串(含日期和仓库计数),**不交给 LLM**;但首屏的 `coverTags`(≤5 个主题标签)与 `trendSummary`(一句话趋势)由 LLM 产出并透传到 cover 场景渲染。LLM 仍只产出 per-repo `scenes[]`,不生成 overview/summary 场景
 - TTS 按场景独立合成,受 provider 配额限制;调试布局可用 `--no-tts` 跳过(生成静音视频)

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

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

+ 28 - 29
packages/core/src/stages/parse.ts

@@ -7,7 +7,7 @@ import {
   stripUnsupportedGlyphs,
   clipToLength,
 } from "@pipeline/shared";
-import type { ParsedContent, Scene, VideoInput, RepoMeta } from "@pipeline/shared";
+import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
 
 export interface ParseStageConfig {
   llm: {
@@ -60,6 +60,21 @@ function applyLengthLimits(input: unknown, template: string): unknown {
     }
     return next;
   });
+
+  // github-trending: clip the cover-scene metadata the LLM emits at the top
+  // level (coverTags ≤5, each ≤12 chars; trendSummary ≤40). Same drift
+  // tolerance as the per-scene clipping above.
+  if (template === "github-trending") {
+    if (Array.isArray((root as any).coverTags)) {
+      (root as any).coverTags = (root as any).coverTags
+        .filter((t: any) => typeof t === "string" && t.trim())
+        .map((t: any) => t.trim().slice(0, 12))
+        .slice(0, 5);
+    }
+    if (typeof (root as any).trendSummary === "string") {
+      (root as any).trendSummary = clipToLength((root as any).trendSummary, 40);
+    }
+  }
   return root;
 }
 
@@ -118,6 +133,10 @@ function sanitizeVideoInput(input: VideoInput): VideoInput {
     title: stripUnsupportedGlyphs(input.title ?? ""),
     subtitle: input.subtitle ? stripUnsupportedGlyphs(input.subtitle) : input.subtitle,
     summary: input.summary ? stripUnsupportedGlyphs(input.summary) : input.summary,
+    coverTags: Array.isArray(input.coverTags)
+      ? input.coverTags.map((t) => stripUnsupportedGlyphs(t ?? ""))
+      : input.coverTags,
+    trendSummary: input.trendSummary ? stripUnsupportedGlyphs(input.trendSummary) : input.trendSummary,
     cover: input.cover
       ? { ...input.cover, keyframes: cleanKeyframes(input.cover.keyframes) }
       : input.cover,
@@ -239,17 +258,14 @@ export async function parseText(
       ]
     : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
   // For github-trending the cover doubles as the opening narration screen —
-  // 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).
+  // give it a fixed masthead line (greeting + date + show name + today's
+  // selection count) so the TTS stage produces audio and the compose stage
+  // sets the cover duration to match. It also carries the LLM-produced
+  // coverTags + trendSummary, rendered on the title screen by the template.
+  // 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 每日热榜。`
-    : "";
-  const summaryNarration = template === "github-trending"
-    ? `今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目,让我们一探究竟。`
+    ? `大家好,今天是${formatTodayChineseDate()},欢迎收看 GitHub 每日热榜。今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目。`
     : "";
   scenes.push({
     id: "cover",
@@ -260,28 +276,11 @@ export async function parseText(
     keyframes: coverKeyframes,
     images: coverImages,
     backgroundQuery: coverInput?.imageQuery,
+    coverTags: template === "github-trending" ? videoInput.coverTags : undefined,
+    trendSummary: template === "github-trending" ? videoInput.trendSummary : undefined,
     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 - 1
packages/shared/src/llm/prompts/parse-text.ts

@@ -10,6 +10,8 @@ The output JSON must follow this exact schema:
 {
   "title": "string — video title, concise and catchy",
   "summary": "string — one-sentence summary",
+  "coverTags": "array of strings — OPTIONAL, github-trending only. ≤5 short theme tags (2-6 chars each) naming the main tech areas/themes across the selected repos. Omit for other templates.",
+  "trendSummary": "string — OPTIONAL, github-trending only. One short sentence (≤30 chars) synthesizing the day's overall trend. Omit for other templates.",
   "scenes": [
     {
       "id": "scene-N",
@@ -90,9 +92,13 @@ 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.
-- 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.
+- Do not create overview, summary, or trend-overview SCENES. The opening title screen (cover) is built by the pipeline; it does not need a scene. Instead, convey the day's overall theme via the two top-level cover metadata fields below. 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.
 
+COVER METADATA (github-trending only — emit these two TOP-LEVEL fields alongside title/summary; omit them for every other template):
+- coverTags: ≤5 short theme tags (2-6 Chinese chars, or a short English term). Each tag names the main technical AREA or THEME a selected repo belongs to — e.g. "代码智能", "时序模型", "视频编辑", "情报看板", "生成式AI", "AI Agent", "Rust 运行时". NOT programming languages and NOT repo names. Prefer one tag per selected repo; if two repos share a theme, merge them and pick a distinct area so the set still spans up to 5 areas. Plain CJK/ASCII only — no emoji, no decorative symbols.
+- trendSummary: one short sentence (≤30 chars) synthesizing the overall trend across today's repos — what theme is hot or emerging. Must read as an editorial insight, NOT a restatement of the tags or a generic filler. Example: "AI Agent 与代码智能工具持续升温". Plain CJK/ASCII only.
+
 PER-REPO SCENE STRUCTURE (mandatory for every repo scene):
 
 displayText: the repo's fullName (e.g. "facebook/react"). Nothing else.

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

@@ -1,5 +1,5 @@
 import { z } from "zod";
-import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema, RepoMetaSchema } from "./scene.js";
+import { WordTimestampSchema, SceneImageSchema, KeyframeSchema, ParsedContentSchema, GithubSceneDataSchema } from "./scene.js";
 
 export const PipelineInputSchema = z.object({
   text: z.string(),
@@ -75,7 +75,9 @@ export const ComposedSceneSchema = z.object({
     .optional(),
   layoutHint: z.string(),
   github: GithubSceneDataSchema.optional(),
-  trendingRepos: z.array(RepoMetaSchema).optional(),
+  // github-trending cover scene only.
+  coverTags: z.array(z.string()).optional(),
+  trendSummary: z.string().optional(),
 });
 
 export type ComposedScene = z.infer<typeof ComposedSceneSchema>;

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

@@ -105,6 +105,10 @@ export const VideoInputSchema = z.object({
   title: z.string(),
   subtitle: z.string().optional(),
   summary: z.string().optional(),
+  // github-trending only: ≤5 short theme tags + a one-sentence trend line for
+  // the cover scene. LLM-produced, surfaced on the opening title screen.
+  coverTags: z.array(z.string()).max(5).optional(),
+  trendSummary: z.string().max(40).optional(),
   cover: VideoCoverSchema.optional(),
   scenes: z.array(InputSceneSchema).min(1),
   outro: VideoOutroSchema.optional(),
@@ -131,7 +135,10 @@ export const SceneSchema = z.object({
     .enum(["full-text", "split", "centered", "bullet-list"])
     .optional(),
   github: GithubSceneDataSchema.optional(),
-  trendingRepos: z.array(RepoMetaSchema).optional(),
+  // github-trending cover scene only: theme tags + trend line for the opening
+  // title screen. Attached to the cover scene by the parse stage.
+  coverTags: z.array(z.string()).optional(),
+  trendSummary: z.string().optional(),
 });
 
 export type Scene = z.infer<typeof SceneSchema>;

+ 50 - 24
packages/templates/src/Root.tsx

@@ -1,12 +1,12 @@
 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, RepoMeta } from "@pipeline/shared";
+import type { WordTimestamp, GithubSceneData } 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, { GithubTrendingSummaryScene } from "./github-trending/index";
+import GithubTrendingScene from "./github-trending/index";
 import { THEMES } from "./base/theme/colors";
 
 export interface RemotionScene {
@@ -30,7 +30,9 @@ export interface RemotionScene {
   backgroundAsset?: { id: string; filename: string };
   layoutHint: string;
   github?: GithubSceneData;
-  trendingRepos?: RepoMeta[];
+  // github-trending cover scene only: theme tags + trend line.
+  coverTags?: string[];
+  trendSummary?: string;
 }
 
 export interface RemotionProps {
@@ -97,6 +99,8 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
                 subtitle={props.subtitle}
                 keyframes={scene.keyframes}
                 backgroundAsset={scene.backgroundAsset}
+                coverTags={scene.coverTags}
+                trendSummary={scene.trendSummary}
                 totalFrames={totalFrames}
               />
             </Sequence>
@@ -118,22 +122,6 @@ 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}
@@ -165,14 +153,19 @@ const CoverScene: React.FC<{
   subtitle?: string;
   keyframes: Array<{ type: string; content: string }>;
   backgroundAsset?: { id: string; filename: string };
+  coverTags?: string[];
+  trendSummary?: string;
   totalFrames: number;
-}> = ({ template, title, subtitle, keyframes, backgroundAsset }) => {
+}> = ({ template, title, subtitle, keyframes, backgroundAsset, coverTags, trendSummary }) => {
   const accent = THEMES[template].primaryLight;
   const isGithubTrending = template === "github-trending";
 
   // github-trending cover doubles as the opening narration screen — light
-  // background, much larger masthead typography, no dark image overlay.
+  // 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.
   if (isGithubTrending) {
+    const hasTags = !!(coverTags && coverTags.length > 0);
     return (
       <AbsoluteFill style={{
         background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 60%, #cbd5e1 100%)",
@@ -188,25 +181,58 @@ const CoverScene: React.FC<{
           padding: 80, textAlign: "center",
         }}>
           <div style={{
-            fontSize: 132, fontWeight: 800, color: "#0f172a",
+            fontSize: 104, fontWeight: 800, color: "#0f172a",
             fontFamily: "Noto Sans SC", lineHeight: 1.15,
-            marginBottom: 32, letterSpacing: "-0.02em",
+            marginBottom: 28, letterSpacing: "-0.02em",
           }}>
             {title}
           </div>
           {subtitle && (
             <div style={{
-              fontSize: 56, fontWeight: 600, color: "#475569",
+              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,
             }}>
               {subtitle}
             </div>
           )}
+          {hasTags && (
+            <div style={{
+              display: "flex", flexWrap: "wrap", justifyContent: "center",
+              gap: 18, maxWidth: "82%", marginBottom: trendSummary ? 36 : 0,
+            }}>
+              {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)",
+                }}>
+                  <span style={{
+                    display: "inline-block", width: 11, height: 11,
+                    borderRadius: "50%", background: accent, flexShrink: 0,
+                  }} />
+                  {tag}
+                </div>
+              ))}
+            </div>
+          )}
+          {trendSummary && (
+            <div style={{
+              fontSize: 40, fontWeight: 500, color: "#475569",
+              fontFamily: "Noto Sans SC", maxWidth: "70%", lineHeight: 1.5,
+            }}>
+              {trendSummary}
+            </div>
+          )}
         </div>
       </AbsoluteFill>
     );

+ 0 - 1
packages/templates/src/github-trending/index.tsx

@@ -453,4 +453,3 @@ const LandscapeContent: React.FC<LandscapeContentProps> = ({
 };
 
 export default GithubTrendingScene;
-export { GithubTrendingSummaryScene } from "./summary-scene";

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

@@ -1,208 +0,0 @@
-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;