6 Revize 0a07ac6498 ... b7e85254a6

Autor SHA1 Zpráva Datum
  lkatzey b7e85254a6 chore: 忽略 k8s secret 清单文件 před 5 dny
  lkatzey fb0be6add8 docs: 同步 CLAUDE.md(github-trending 与定时生成) před 5 dny
  lkatzey 6d06346038 web: 新增配置驱动的进程内定时生成(node-cron + instrumentation) před 5 dny
  lkatzey c38cf617ba github-trending: 重设计封面与进度条章节目录 před 5 dny
  lkatzey fd24f38169 github-trending: top6 选取、精简封面口播、移除 coverTags、LLM 截断容错 před 5 dny
  lkatzey 54fa83f064 github-trending: 仓库元数据增加今日涨星 todayStars před 5 dny

+ 6 - 0
.env.example

@@ -66,6 +66,12 @@ FEISHU_WEBHOOK_URL=
 # the per-job "日志" panel. Default info.
 # LOG_LEVEL=info
 
+# --- Scheduled renders (in-process scheduler) ---
+# Override config/default.yaml `schedules` at deploy time without rebuilding.
+# JSON array, same shape as the YAML section, e.g.:
+# SCHEDULES=[{"cron":"50 7 * * *","template":"github-trending","platform":"bilibili","source":"github-trending","publish":true}]
+# SCHEDULER_ENABLED=false   # kill switch for the whole scheduler (default: on)
+
 # --- Container DNS ---
 # Public resolvers used by the container (the host's resolver can fail to
 # resolve some public domains, e.g. open.feishu.cn behind a VPN). Defaults to

+ 3 - 0
.gitignore

@@ -8,6 +8,9 @@ dist/
 .env.local
 .env.*.local
 
+# Secrets (k8s) — never commit a filled-in secret manifest
+deploy/k8s/06-secret.yaml
+
 # Next.js
 .next/
 next-env.d.ts

+ 12 - 6
CLAUDE.md

@@ -39,7 +39,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` 模板的 cover 场景在这里确定性写入开场口播(masthead + "今日精选 N 个项目"),并承载 LLM 产出的 `coverTags`(≤5 个主题标签)与 `trendSummary`(一句话趋势总结),由模板在首屏渲染;不再注入单独的 summary 场景
+1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)。`github-trending` 在这里:①内容场景按 `github.repo.todayStars` 降序取 **top 6**(视频只介绍今日涨星最多的 6 个,顺序与首屏一致);②确定性写入 cover 开场口播(一句承接语 `下面进入项目详解。`,**不含日期/数量**——日期与卡片由首屏视觉呈现);③承载 LLM 产出的 `trendSummary`(一句话趋势)透传到首屏渲染。不再注入 summary 场景,也已停用 `coverTags`
 2. **tts** — 按场景调用 TTS provider,生成逐场景音频文件 + word timestamps
 3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
 4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间。逐字段拷贝场景数据——新增场景字段时**必须在此阶段显式透传**(`buildInputProps` 用 `...scene` 自动透传,但 compose 是手写字段映射)
@@ -62,7 +62,7 @@ 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`(`github-trending` 的 cover 接收 `coverTags`/`trendSummary`,在首屏渲染主题标签行 + 一句话趋势句
+- `sceneType === "cover"` → 内置 `CoverScene`(`github-trending` cover 渲染:深色标题卡居中靠上 + top6 仓库卡片网格[横屏 3×2 / 竖屏 2×3,每卡:名称/语言/今日 +N 涨星/highlights 一句话] + trendSummary 副标题;另在进度条上方渲染 `ChapterToc` 章节目录,当前章节高亮
 - `sceneType === "outro"` → 内置 `OutroScene`
 - 其他 content 类型 → `SCENE_MAP[template]` 对应模板组件
 
@@ -100,13 +100,19 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 - `TIMEZONE` — github-trending 首屏日期与输出日期子目录所用时区,默认 `Asia/Shanghai`(Docker 同时据此设 `TZ`)
 - `OSS_REGION` `OSS_BUCKET` `OSS_ACCESS_KEY_ID` `OSS_ACCESS_KEY_SECRET` `OSS_ENDPOINT` `OSS_PATH` `OSS_PUBLIC_DOMAIN` `OSS_SECURE` — 阿里云 OSS(密钥走 .env)
 - `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` — 飞书自定义机器人 webhook
+- `SCHEDULES` — JSON 数组,整体覆盖 `config/default.yaml` 的 `schedules`(部署期改定时任务无需重建镜像)
+- `SCHEDULER_ENABLED` — `false` 关闭容器内进程内调度器(默认开启)
+
+## 定时生成(容器内调度)
+
+容器内置**进程内调度器**:Next.js 服务启动时经 `apps/web/src/instrumentation.ts`(`register()` 钩子)启动 `apps/web/src/lib/scheduler.ts`,用 `node-cron` 按 `config/default.yaml` 的 `schedules` 段(cron + TZ,默认 Asia/Shanghai)到点 spawn CLI 渲染——复用 `/api/render` 同一链路(`apps/web/src/lib/run-render.ts` 的 `startRenderJob`),任务进 jobs 列表、走 OSS/飞书发布。单副本下进程内调度不重复执行;内置单飞(上一次未结束则跳过)。改配置后重启容器生效;`SCHEDULES` 环境变量可整体覆盖,`SCHEDULER_ENABLED=false` 关闭。详见 `docs/DEPLOYMENT.md` §4.3。
 
 ## 核心 Schema
 
 全部在 `packages/shared/src/types/` 中用 Zod 定义:
 
-- `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 场景使用)
+- `VideoInputSchema` — 用户输入格式(title、subtitle、cover、scenes[]、outro、globalStyle;github-trending 另有可选 `trendSummary`,由 LLM 产出。`coverTags` 字段保留但已停用
+- `SceneSchema` — 内部场景,含 `sceneType: "cover" | "content" | "summary" | "outro"`、可选 `github`(GithubSceneData,`repo` 含 `todayStars` 今日涨星)、可选 `trendSummary`(string,仅 github-trending 的 cover 场景用。`coverTags` 保留但停用)
 - `ComposedSceneSchema` — compose 后的场景,含帧时间轴、`audioPath`、已解析的图片。字段与 `SceneSchema` 一一对应
 - `PipelineInputSchema` — 顶层流水线输入(text、template、platform、ttsProvider)
 
@@ -114,6 +120,6 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 
 ## LLM 与 TTS 提示
 
-- `LLMClient`(`packages/shared/src/llm/client.ts`)显式设置 `max_tokens: 8192`——`github-trending` 多仓库结构化 JSON 输出约 6000 tokens,provider 默认值(1024-4096)会截断响应导致 JSON 解析失败
-- `github-trending` 的 cover narration 是 `parse.ts` 硬编码模板字符串(含日期和仓库计数),**不交给 LLM**;但首屏的 `coverTags`(≤5 个主题标签)与 `trendSummary`(一句话趋势)由 LLM 产出并透传到 cover 场景渲染。LLM 仍只产出 per-repo `scenes[]`,不生成 overview/summary 场景
+- `LLMClient`(`packages/shared/src/llm/client.ts`)设置 `max_tokens: 16384`,`chat()` 返回 `{ content, finishReason }`。github-trending 大 trending 列表可能撑爆 token 上限被截断 → JSON 不完整;parse 阶段对解析失败会**重试一次**(追加精简指令让响应在限额内闭合),错误信息带 `finish_reason` 便于诊断
+- `github-trending` 的 cover narration 是 `parse.ts` 硬编码的一句承接语(`下面进入项目详解。`),**不交给 LLM**,也不含日期/仓库数量。`trendSummary`(一句话趋势)由 LLM 产出并透传到首屏渲染。LLM 只产出 per-repo `scenes[]`(数据源规则要求按今日涨星降序选 6 个),不生成 overview/summary 场景;parse 端会再次按 todayStars 排序取 top6 作兜底。`coverTags` 已停用
 - TTS 按场景独立合成,受 provider 配额限制;调试布局可用 `--no-tts` 跳过(生成静音视频)

+ 2 - 0
apps/web/package.json

@@ -11,6 +11,7 @@
     "@pipeline/shared": "workspace:*",
     "@pipeline/collect": "workspace:*",
     "next": "^15.3.0",
+    "node-cron": "^3.0.3",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "yaml": "^2.7.0",
@@ -18,6 +19,7 @@
   },
   "devDependencies": {
     "@types/node": "^22.0.0",
+    "@types/node-cron": "^3.0.11",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",
     "typescript": "^5.8.0"

+ 11 - 154
apps/web/src/app/api/render/route.ts

@@ -1,11 +1,5 @@
 import { NextResponse } from "next/server";
-import { createJob, updateJob } from "@/lib/job-store";
-import { loadConfig } from "@/lib/config";
-import { resolveOutputDir } from "@pipeline/shared/node";
-import { spawn } from "node:child_process";
-import { writeFileSync, mkdirSync, statSync } from "node:fs";
-import { join, resolve } from "node:path";
-import { tmpdir } from "node:os";
+import { startRenderJob } from "@/lib/run-render";
 
 export async function POST(request: Request) {
   const body = await request.json();
@@ -32,153 +26,16 @@ export async function POST(request: Request) {
     return NextResponse.json({ error: "text or source is required" }, { status: 400 });
   }
 
-  const job = createJob({ ...input, text: input.text ?? "", platforms });
-
-  const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
-  mkdirSync(jobDir, { recursive: true });
-
-  // Same unified output directory the CLI resolves (OUTPUT_DIR > config > ./output).
-  const config = loadConfig();
-  const outputDir = resolveOutputDir(config?.output?.dir);
-
-  const cliArgs = [
-    "render",
-    "-t", input.template,
-    "-p", platforms.join(","),
-    "--tts-provider", input.ttsProvider,
-    "--output", outputDir,
-  ];
-
-  if (input.source) {
-    cliArgs.push("--source", input.source);
-    if (input.sourceArgs?.owner) cliArgs.push("--source-owner", input.sourceArgs.owner);
-    if (input.sourceArgs?.repo) cliArgs.push("--source-repo", input.sourceArgs.repo);
-  } else {
-    const inputFile = join(jobDir, "input.json");
-    writeFileSync(inputFile, input.text!, "utf-8");
-    cliArgs.splice(1, 0, inputFile);
-  }
-  if (input.voiceId) {
-    cliArgs.push("--voice", input.voiceId);
-  }
-  if (options?.noTts) {
-    cliArgs.push("--no-tts");
-  }
-
-  const cliPath = resolve(process.cwd(), "../cli/dist/index.js");
-  const projectRoot = resolve(process.cwd(), "..");
-
-  updateJob(job.id, { status: "running" });
-
-  const child = spawn("node", [cliPath, ...cliArgs], {
-    cwd: projectRoot,
-    stdio: ["ignore", "pipe", "pipe"],
-    env: { ...process.env },
-  });
-
-  const jobIdShort = job.id.slice(0, 8);
-  const linePrefix = `[job ${jobIdShort}] `;
-  const fwdOut = makePrefixer(linePrefix, (s) => process.stdout.write(s));
-  const fwdErr = makePrefixer(linePrefix, (s) => process.stderr.write(s));
-
-  let stdout = "";
-  let stderr = "";
-  let logBuf = "";
-
-  // Capture for parsing/UI AND mirror to the container's stdout/stderr so
-  // `docker compose logs` shows every stage / TTS retry / publish event live.
-  child.stdout.on("data", (data: Buffer) => {
-    const s = data.toString();
-    stdout += s;
-    logBuf += s;
-    fwdOut.push(s);
+  const { jobId } = startRenderJob({
+    template: input.template,
+    platforms,
+    ttsProvider: input.ttsProvider,
+    source: input.source,
+    sourceArgs: input.sourceArgs,
+    text: input.text,
+    voiceId: input.voiceId,
+    noTts: options?.noTts,
   });
 
-  child.stderr.on("data", (data: Buffer) => {
-    const s = data.toString();
-    stderr += s;
-    logBuf += s;
-    fwdErr.push(s);
-  });
-
-  child.on("close", (code: number) => {
-    fwdOut.flush();
-    fwdErr.flush();
-    const log = capLog(logBuf);
-    if (code === 0) {
-      const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
-      const filePaths = mp4Matches.map(m => m[1].trim());
-
-      const outputFiles = filePaths.map(filePath => {
-        let fileSizeBytes = 0;
-        try { fileSizeBytes = statSync(filePath).size; } catch {}
-        const isPortrait = filePath.includes("douyin") || filePath.includes("9x16");
-        return {
-          filePath,
-          fileSizeBytes,
-          width: isPortrait ? 1080 : 1920,
-          height: isPortrait ? 1920 : 1080,
-          durationSeconds: 0,
-        };
-      });
-
-      const ossUrls = [...stdout.matchAll(/^[ \t]*oss: (\S+)/gm)].map(m => m[1].trim());
-      const publishWarn = stdout.match(/^Publish warning: (.+)$/m);
-      const publishError = publishWarn ? publishWarn[1].trim() : undefined;
-
-      updateJob(job.id, {
-        status: "completed",
-        outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
-        ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
-        publishError,
-        log,
-      });
-    } else {
-      updateJob(job.id, {
-        status: "failed",
-        error: stderr.trim() || `Process exited with code ${code}`,
-        log,
-      });
-    }
-  });
-
-  child.on("error", (err: Error) => {
-    fwdOut.flush();
-    fwdErr.flush();
-    updateJob(job.id, {
-      status: "failed",
-      error: err.message,
-      log: capLog(logBuf),
-    });
-  });
-
-  return NextResponse.json({ jobId: job.id, status: "started" });
-}
-
-// Keep the per-job log bounded so jobs.json doesn't grow without limit.
-const LOG_CAP = 200_000;
-function capLog(buf: string): string {
-  if (buf.length <= LOG_CAP) return buf;
-  return `...(truncated ${buf.length - LOG_CAP} bytes from the top)\n` + buf.slice(-LOG_CAP);
-}
-
-// Line-buffered forwarder: writes each complete line (prefixed with the job id)
-// to the container's stdout/stderr, holding back a trailing partial line until
-// the next chunk or flush. Lets `docker compose logs` attribute lines to jobs.
-function makePrefixer(prefix: string, write: (s: string) => void) {
-  let pending = "";
-  return {
-    push(chunk: string) {
-      pending += chunk;
-      const lines = pending.split("\n");
-      pending = lines.pop() ?? "";
-      for (const line of lines) write(prefix + line + "\n");
-    },
-    flush() {
-      if (pending.length > 0) {
-        write(prefix + pending + "\n");
-        pending = "";
-      }
-    },
-  };
+  return NextResponse.json({ jobId, status: "started" });
 }

+ 14 - 0
apps/web/src/instrumentation.ts

@@ -0,0 +1,14 @@
+// Next.js instrumentation hook — `register()` runs once when the Next.js server
+// starts (Node.js runtime). Used to boot the in-process render scheduler so the
+// container can generate videos on a cron without any external scheduler.
+// Stable in Next.js 15 (no next.config flag needed); not invoked during build.
+export async function register(): Promise<void> {
+  if (process.env.NEXT_RUNTIME !== "nodejs") return; // skip edge runtime
+  if (process.env.SCHEDULER_ENABLED === "false") return; // global kill switch
+  const { startScheduler } = await import("./lib/scheduler");
+  try {
+    startScheduler();
+  } catch (e) {
+    console.error("[scheduler] failed to start:", e);
+  }
+}

+ 190 - 0
apps/web/src/lib/run-render.ts

@@ -0,0 +1,190 @@
+import { createJob, updateJob } from "@/lib/job-store";
+import { loadConfig } from "@/lib/config";
+import { resolveOutputDir } from "@pipeline/shared/node";
+import { spawn } from "node:child_process";
+import { writeFileSync, mkdirSync, statSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { tmpdir } from "node:os";
+
+export interface RenderJobParams {
+  template: string;
+  platforms: string[];
+  ttsProvider: string;
+  source?: string;
+  sourceArgs?: Record<string, string>;
+  /** Raw input text (JSON / markdown / plain). Required when `source` is absent. */
+  text?: string;
+  voiceId?: string;
+  noTts?: boolean;
+  /** Skip OSS upload + Feishu notification. Default: publish when configured. */
+  noPublish?: boolean;
+}
+
+/**
+ * Spawn the compiled CLI to render a video, tracking it in the job store so it
+ * shows up in the WebUI / jobs list exactly like a WebUI/HTTP-triggered render.
+ * Shared by `POST /api/render` and the in-process scheduler.
+ *
+ * Returns immediately with the `jobId`; `done` resolves when the child process
+ * exits (success or failure) so callers that need ordering (e.g. the scheduler's
+ * single-flight guard) can await it.
+ */
+export function startRenderJob(params: RenderJobParams): {
+  jobId: string;
+  done: Promise<void>;
+} {
+  const {
+    template, platforms, ttsProvider, source, sourceArgs,
+    text, voiceId, noTts, noPublish,
+  } = params;
+
+  const job = createJob({ text: text ?? "", template, platforms, ttsProvider, voiceId });
+
+  const jobDir = join(tmpdir(), "pipeline-jobs", job.id);
+  mkdirSync(jobDir, { recursive: true });
+
+  // Same unified output directory the CLI resolves (OUTPUT_DIR > config > ./output).
+  const config = loadConfig();
+  const outputDir = resolveOutputDir(config?.output?.dir);
+
+  const cliArgs = [
+    "render",
+    "-t", template,
+    "-p", platforms.join(","),
+    "--tts-provider", ttsProvider,
+    "--output", outputDir,
+  ];
+
+  if (source) {
+    cliArgs.push("--source", source);
+    if (sourceArgs?.owner) cliArgs.push("--source-owner", sourceArgs.owner);
+    if (sourceArgs?.repo) cliArgs.push("--source-repo", sourceArgs.repo);
+  } else {
+    const inputFile = join(jobDir, "input.json");
+    writeFileSync(inputFile, text!, "utf-8");
+    cliArgs.splice(1, 0, inputFile);
+  }
+  if (voiceId) cliArgs.push("--voice", voiceId);
+  if (noTts) cliArgs.push("--no-tts");
+  if (noPublish) cliArgs.push("--no-publish");
+
+  const cliPath = resolve(process.cwd(), "../cli/dist/index.js");
+  const projectRoot = resolve(process.cwd(), "..");
+
+  updateJob(job.id, { status: "running" });
+
+  const child = spawn("node", [cliPath, ...cliArgs], {
+    cwd: projectRoot,
+    stdio: ["ignore", "pipe", "pipe"],
+    env: { ...process.env },
+  });
+
+  const jobIdShort = job.id.slice(0, 8);
+  const linePrefix = `[job ${jobIdShort}] `;
+  const fwdOut = makePrefixer(linePrefix, (s) => process.stdout.write(s));
+  const fwdErr = makePrefixer(linePrefix, (s) => process.stderr.write(s));
+
+  let stdout = "";
+  let stderr = "";
+  let logBuf = "";
+
+  const done = new Promise<void>((resolveDone) => {
+    // Capture for parsing/UI AND mirror to the container's stdout/stderr so
+    // `docker compose logs` shows every stage / TTS retry / publish event live.
+    child.stdout.on("data", (data: Buffer) => {
+      const s = data.toString();
+      stdout += s;
+      logBuf += s;
+      fwdOut.push(s);
+    });
+    child.stderr.on("data", (data: Buffer) => {
+      const s = data.toString();
+      stderr += s;
+      logBuf += s;
+      fwdErr.push(s);
+    });
+
+    child.on("close", (code: number) => {
+      fwdOut.flush();
+      fwdErr.flush();
+      const log = capLog(logBuf);
+      if (code === 0) {
+        const mp4Matches = [...stdout.matchAll(/-> (.+\.mp4)/g)];
+        const filePaths = mp4Matches.map((m) => m[1].trim());
+
+        const outputFiles = filePaths.map((filePath) => {
+          let fileSizeBytes = 0;
+          try { fileSizeBytes = statSync(filePath).size; } catch {}
+          const isPortrait = filePath.includes("douyin") || filePath.includes("9x16");
+          return {
+            filePath,
+            fileSizeBytes,
+            width: isPortrait ? 1080 : 1920,
+            height: isPortrait ? 1920 : 1080,
+            durationSeconds: 0,
+          };
+        });
+
+        const ossUrls = [...stdout.matchAll(/^[ \t]*oss: (\S+)/gm)].map((m) => m[1].trim());
+        const publishWarn = stdout.match(/^Publish warning: (.+)$/m);
+        const publishError = publishWarn ? publishWarn[1].trim() : undefined;
+
+        updateJob(job.id, {
+          status: "completed",
+          outputFiles: outputFiles.length > 0 ? outputFiles : undefined,
+          ossUrls: ossUrls.length > 0 ? ossUrls : undefined,
+          publishError,
+          log,
+        });
+      } else {
+        updateJob(job.id, {
+          status: "failed",
+          error: stderr.trim() || `Process exited with code ${code}`,
+          log,
+        });
+      }
+      resolveDone();
+    });
+
+    child.on("error", (err: Error) => {
+      fwdOut.flush();
+      fwdErr.flush();
+      updateJob(job.id, {
+        status: "failed",
+        error: err.message,
+        log: capLog(logBuf),
+      });
+      resolveDone();
+    });
+  });
+
+  return { jobId: job.id, done };
+}
+
+// Keep the per-job log bounded so jobs.json doesn't grow without limit.
+const LOG_CAP = 200_000;
+function capLog(buf: string): string {
+  if (buf.length <= LOG_CAP) return buf;
+  return `...(truncated ${buf.length - LOG_CAP} bytes from the top)\n` + buf.slice(-LOG_CAP);
+}
+
+// Line-buffered forwarder: writes each complete line (prefixed with the job id)
+// to the container's stdout/stderr, holding back a trailing partial line until
+// the next chunk or flush. Lets `docker compose logs` attribute lines to jobs.
+function makePrefixer(prefix: string, write: (s: string) => void) {
+  let pending = "";
+  return {
+    push(chunk: string) {
+      pending += chunk;
+      const lines = pending.split("\n");
+      pending = lines.pop() ?? "";
+      for (const line of lines) write(prefix + line + "\n");
+    },
+    flush() {
+      if (pending.length > 0) {
+        write(prefix + pending + "\n");
+        pending = "";
+      }
+    },
+  };
+}

+ 136 - 0
apps/web/src/lib/scheduler.ts

@@ -0,0 +1,136 @@
+import { schedule, validate } from "node-cron";
+import { loadConfig } from "@/lib/config";
+import { startRenderJob } from "@/lib/run-render";
+
+export interface ScheduleJob {
+  /** 5-field cron (min hour dom month dow). Interpreted in the container TZ. */
+  cron: string;
+  template: string;
+  platform?: string;
+  /** Data source to collect before rendering (e.g. "github-trending"). */
+  source?: string;
+  /** Upload OSS + push Feishu after render. Default true. */
+  publish?: boolean;
+  enabled?: boolean;
+}
+
+// Single-flight: renders are heavy (Remotion + Chrome + ffmpeg). Only one
+// scheduled render runs at a time; overlapping fires are skipped.
+let busy = false;
+let started = false;
+
+const timezone = (): string => process.env.TZ || "Asia/Shanghai";
+
+/**
+ * Load scheduled-render jobs. Reads `config.schedules` and lets the `SCHEDULES`
+ * env var (a JSON array of the same shape) override it entirely, so a deployment
+ * can change schedules without rebuilding the image. Invalid/disabled entries
+ * are dropped with a warning.
+ */
+export function loadSchedules(): ScheduleJob[] {
+  let raw: unknown;
+  const envSchedules = process.env.SCHEDULES?.trim();
+  if (envSchedules) {
+    try {
+      raw = JSON.parse(envSchedules);
+    } catch (e) {
+      console.error("[scheduler] SCHEDULES env is invalid JSON, falling back to config:", e);
+      raw = loadConfig()?.schedules;
+    }
+  } else {
+    raw = loadConfig()?.schedules;
+  }
+  if (!Array.isArray(raw)) return [];
+
+  const jobs: ScheduleJob[] = [];
+  for (const item of raw) {
+    const j = item as Partial<ScheduleJob>;
+    if (!j || typeof j !== "object") continue;
+    if (!j.cron || typeof j.cron !== "string") {
+      console.warn("[scheduler] skipping schedule missing 'cron':", j);
+      continue;
+    }
+    if (!j.template || typeof j.template !== "string") {
+      console.warn(`[scheduler] skipping schedule missing 'template': ${j.cron}`);
+      continue;
+    }
+    if (!validate(j.cron)) {
+      console.warn(`[scheduler] skipping schedule with invalid cron expression: ${j.cron}`);
+      continue;
+    }
+    if (j.enabled === false) continue;
+    jobs.push({
+      cron: j.cron,
+      template: j.template,
+      platform: j.platform || "bilibili",
+      source: j.source,
+      publish: j.publish !== false, // default true
+      enabled: true,
+    });
+  }
+  return jobs;
+}
+
+/** Register every schedule with node-cron. Idempotent (safe if called twice). */
+export function startScheduler(): void {
+  if (started) return;
+  started = true;
+
+  const jobs = loadSchedules();
+  if (jobs.length === 0) {
+    console.log("[scheduler] no enabled schedules — scheduler idle");
+    return;
+  }
+
+  let registered = 0;
+  for (const job of jobs) {
+    try {
+      schedule(
+        job.cron,
+        () => {
+          fire(job).catch((e) => console.error("[scheduler] fire error:", e));
+        },
+        { timezone: timezone() },
+      );
+      registered += 1;
+      console.log(
+        `[scheduler] registered: "${job.cron}" (${timezone()}) → ${job.template}/${job.platform}` +
+          `${job.source ? ` source=${job.source}` : ""}${job.publish ? "" : " (no-publish)"}`,
+      );
+    } catch (e) {
+      console.error(`[scheduler] failed to register "${job.cron}" (${job.template}):`, e);
+    }
+  }
+  console.log(`[scheduler] started: ${registered}/${jobs.length} schedule(s) active, tz=${timezone()}`);
+}
+
+/** Fire one scheduled render. Reuses the same CLI spawn + job tracking as the
+ *  WebUI/HTTP path, so the result lands in the jobs list and goes through OSS /
+ *  Feishu publishing like any other render. */
+async function fire(job: ScheduleJob): Promise<void> {
+  if (busy) {
+    console.log(
+      `[scheduler] skip "${job.cron}" ${job.template}/${job.platform}: previous run still active`,
+    );
+    return;
+  }
+  busy = true;
+  const ttsProvider = loadConfig()?.tts?.provider ?? "openai-tts";
+  console.log(`[scheduler] firing ${job.template}/${job.platform} (cron "${job.cron}")`);
+  try {
+    const { jobId, done } = startRenderJob({
+      template: job.template,
+      platforms: [job.platform ?? "bilibili"],
+      ttsProvider,
+      source: job.source,
+      noPublish: !job.publish,
+    });
+    console.log(`[scheduler] job ${jobId.slice(0, 8)} started for ${job.template}/${job.platform}`);
+    await done;
+    console.log(`[scheduler] job ${jobId.slice(0, 8)} finished for ${job.template}/${job.platform}`);
+  } catch (e) {
+    console.error(`[scheduler] render failed for ${job.template}/${job.platform}:`, e);
+  } finally {
+    busy = false;
+  }
+}

+ 19 - 0
config/default.yaml

@@ -96,3 +96,22 @@ collect:
     trendingUrl: "https://github.crawler.corp.shuidi.tech/api/trending"
     repoUrl: "https://github.crawler.corp.shuidi.tech/api/repos/:owner/:repo"
     maxRepos: 5
+
+# 定时生成(容器内进程内调度)。Next.js 服务启动时(instrumentation 钩子)注册
+# 一个进程内调度器,到点 spawn CLI 渲染——复用与 WebUI/HTTP 完全相同的链路,
+# 任务同样进 jobs 列表、走 OSS/飞书发布。改配置后需重启容器生效。
+#   cron     — 5 字段 cron(分 时 日 月 周),按 TZ(默认 Asia/Shanghai=北京时间)
+#   template — 渲染模板(news|knowledge|opinion|marketing|github-trending)
+#   platform — 平台规格(bilibili|douyin-long|douyin-short|...),默认 bilibili
+#   source   — 采集源(如 github-trending);定时场景一般用 source 自动取数
+#   publish  — 是否上传 OSS + 推送飞书,默认 true
+#   enabled  — 是否启用该条,默认 true
+# 环境变量 SCHEDULES(JSON 数组,同结构)可整体覆盖(部署期改无需重建镜像);
+# SCHEDULER_ENABLED=false 关闭整个调度器。
+schedules:
+  - cron: "50 7 * * *"          # 北京时间每天 07:50
+    template: "github-trending"
+    platform: "bilibili"
+    source: "github-trending"    # 先采集当日 trending,再解析→渲染→发布
+    publish: true
+    enabled: true

+ 21 - 1
docs/DEPLOYMENT.md

@@ -99,6 +99,8 @@ docker compose up --build -d        # 改完代码后重新构建
 | `FEISHU_WEBHOOK_URL` `FEISHU_WEBHOOK_SECRET` | 否 | 飞书自定义机器人 webhook 及签名密钥 |
 | `LOG_LEVEL` | 否 | 日志级别 `debug\|info\|warn\|error`,默认 `info` |
 | `PORT` | 否 | 宿主机映射端口,默认 13000 |
+| `SCHEDULES` | 否 | 定时任务(JSON 数组),整体覆盖 `config/default.yaml` 的 `schedules`,便于部署期改而无需重建镜像 |
+| `SCHEDULER_ENABLED` | 否 | 调度器总开关,`false` 关闭(默认开启) |
 
 > 也可在 WebUI **Settings** 页面填写并保存到 `.env`(敏感值自动脱敏显示)。
 
@@ -106,6 +108,24 @@ docker compose up --build -d        # 改完代码后重新构建
 
 TTS provider 选择、模型、对齐(whisper)、模板配色、采集源(`collect`)、OSS/飞书的**非敏感**默认值(region/bucket/path 等)在此配置。详见文件内注释。
 
+### 4.3 定时生成(容器内调度)
+
+`schedules` 段定义定时任务,Next.js 服务启动时由**进程内调度器**(`instrumentation` 钩子 + `node-cron`)注册,到点 spawn CLI 渲染——复用 WebUI/HTTP 同一链路,任务进 jobs 列表、走 OSS/飞书发布。cron 按容器时区(`TZ`,默认 `Asia/Shanghai`=北京时间)解释。
+
+```yaml
+schedules:
+  - cron: "50 7 * * *"          # 北京时间每天 07:50
+    template: "github-trending"
+    platform: "bilibili"
+    source: "github-trending"    # 先采集当日 trending,再解析→渲染→发布
+    publish: true
+    enabled: true
+```
+
+- 改配置后需重启容器(`docker compose up -d`)生效;或用 `SCHEDULES` 环境变量(JSON 数组,同结构)整体覆盖,部署期改无需重建镜像。
+- `SCHEDULER_ENABLED=false` 关闭整个调度器。
+- 单副本下进程内调度天然不会重复执行;渲染较重,已内置单飞(上一次未结束则跳过本次)。
+
 ---
 
 ## 5. 输出目录与缓存清理
@@ -266,7 +286,7 @@ curl http://localhost:13000/api/jobs/<jobId>
 
 ```
 apps/cli/            CLI 入口(Commander)
-apps/web/            Next.js WebUI + API 路由
+apps/web/            Next.js WebUI + API 路由 + 进程内定时调度器(instrumentation/node-cron)
 packages/shared/     Zod schema、类型、常量、LLM 客户端、路径/发布配置解析
 packages/core/       流水线编排、7 阶段、cleanup、publish(OSS+飞书)
 packages/tts/        TTS provider 注册表

+ 4 - 0
packages/collect/src/collectors/github-trending.ts

@@ -46,6 +46,10 @@ class GitHubTrendingCollector implements DataSource {
         stars: repo.stars,
         forks: repo.forks,
         license: "",
+        // Today's star gain, carried as structured data so the cover scene can
+        // render "+N" per repo. (Also emitted as a `Today: +N` text line below
+        // as a human-readable fallback the LLM can read if this is absent.)
+        todayStars: repo.currentPeriodStars ?? undefined,
       };
       lines.push(`<!-- repo-meta: ${JSON.stringify(meta)} -->`);
 

+ 49 - 21
packages/core/src/stages/parse.ts

@@ -11,6 +11,13 @@ import {
 import { getTimezone } from "@pipeline/shared/node";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
 
+/** Appended on the retry attempt when the first LLM JSON failed to parse.
+ *  With response_format=json_object a parse failure almost always means the
+ *  response was truncated by the output length limit (finish_reason="length").
+ *  This nudges the model to produce a shorter, fully-closed JSON that fits. */
+const JSON_TRUNCATION_NUDGE =
+  "\n\n[重要] 你上一次的 JSON 输出因超出输出长度上限被截断,导致解析失败。请重新输出一份更精简但结构完整的 JSON:适当减少 scenes 数量、缩短每个场景的 narration 与详细描述字段,务必确保整个 JSON(含所有闭合括号)在输出上限内完整结束。";
+
 export interface ParseStageConfig {
   llm: {
     baseURL?: string;
@@ -182,25 +189,39 @@ export async function parseText(
       template as "news" | "knowledge" | "opinion" | "marketing" | "github-trending",
       config.source
     );
-    const rawResponse = await client.chat(systemPrompt, text);
 
-    // Strip markdown code block wrapper if present (```json ... ```)
-    const cleaned = rawResponse
-      .replace(/^```(?:json)?\s*\n?/i, "")
-      .replace(/\n?```\s*$/i, "")
-      .trim();
+    // Strip markdown code block wrapper if present (```json ... ```).
+    const stripFences = (s: string) =>
+      s.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
 
+    // With response_format=json_object the model emits valid JSON syntax, so a
+    // parse failure almost always means the response was truncated by the
+    // output length limit (finish_reason="length") — common for github-trending
+    // when the trending list is large. Retry once with a conciseness nudge so
+    // the (shorter) JSON completes within the budget.
     let aiParsed: unknown;
-    try {
-      aiParsed = JSON.parse(cleaned);
-    } catch {
+    let lastFinish: string | null = null;
+    let lastRaw = "";
+    for (let attempt = 0; attempt < 2 && aiParsed === undefined; attempt++) {
+      const userMessage = attempt === 0 ? text : `${text}${JSON_TRUNCATION_NUDGE}`;
+      const { content, finishReason } = await client.chat(systemPrompt, userMessage);
+      lastFinish = finishReason;
+      lastRaw = stripFences(content);
+      try {
+        aiParsed = JSON.parse(lastRaw);
+      } catch {
+        // not valid JSON yet — fall through to retry, or to the final error below
+      }
+    }
+    if (aiParsed === undefined) {
       throw new Error(
-        `AI returned invalid JSON: ${cleaned.slice(0, 200)}`
+        `AI returned invalid JSON after retry (finish_reason=${lastFinish ?? "unknown"}; ` +
+          `likely truncated by the output length limit — raise max_tokens in LLMClient):\n${lastRaw.slice(0, 300)}`
       );
     }
 
     // LLMs routinely overshoot the documented character budgets (narration
-    // ≤200, github highlights/intro/review ≤30/60/30). Clip in place before
+    // ≤200; github highlights/intro/review ≤30/200/30). Clip in place before
     // schema validation so the pipeline tolerates drift instead of hard-failing.
     aiParsed = applyLengthLimits(aiParsed, template);
 
@@ -253,15 +274,23 @@ export async function parseText(
         ...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
       ]
     : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
-  // For github-trending the cover doubles as the opening narration screen —
-  // 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;
+  // For github-trending the cover doubles as the opening narration screen. The
+  // narration is a short bridge into the repo details — the date and repo count
+  // are shown on screen (subtitle pill + the cards), NOT read aloud. It also
+  // carries the LLM-produced trendSummary (rendered as a subtitle line by the
+  // template); the repo list itself is rendered from the content scenes. Other
+  // templates keep the original behaviour (1s silent cover).
+  // github-trending covers only the TOP 6 repos by today's star gain (matching
+  // the cover preview), played in descending-gain order so playback follows the
+  // cover ranking. Other templates keep all input scenes.
+  const contentInputs = template === "github-trending"
+    ? videoInput.scenes
+        .filter(s => s.github)
+        .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
+        .slice(0, 6)
+    : videoInput.scenes;
   const coverNarration = template === "github-trending"
-    ? `大家好,今天是${formatChineseDate(new Date(), getTimezone())},欢迎收看 GitHub 每日热榜。今天为大家精选了 ${gtRepoCount} 个值得关注的优质开源项目。`
+    ? "下面进入项目详解。"
     : "";
   scenes.push({
     id: "cover",
@@ -272,13 +301,12 @@ 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,
   });
 
   // Content scenes
-  for (const s of videoInput.scenes) {
+  for (const s of contentInputs) {
     scenes.push({
       id: s.id,
       index: sceneIndex++,

+ 1 - 1
packages/shared/src/index.ts

@@ -13,5 +13,5 @@ export type {
   JobStatus,
 } from "./constants.js";
 export { LLMClient, getParsePrompt, detectInputFormat } from "./llm/index.js";
-export type { LLMClientConfig, InputFormat, DetectResult } from "./llm/index.js";
+export type { LLMClientConfig, LLMResult, InputFormat, DetectResult } from "./llm/index.js";
 export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, DEFAULT_TIMEZONE } from "./utils/index.js";

+ 18 - 10
packages/shared/src/llm/client.ts

@@ -6,6 +6,14 @@ export interface LLMClientConfig {
   model: string;
 }
 
+export interface LLMResult {
+  content: string;
+  // Provider finish reason: "stop" (complete), "length" (truncated by
+  // max_tokens — yields incomplete JSON), "content_filter", etc. The parse
+  // stage uses this to retry truncated github-trending output.
+  finishReason: string | null;
+}
+
 export class LLMClient {
   private client: OpenAI;
   private model: string;
@@ -18,7 +26,7 @@ export class LLMClient {
     this.model = config.model;
   }
 
-  async chat(systemPrompt: string, userMessage: string): Promise<string> {
+  async chat(systemPrompt: string, userMessage: string): Promise<LLMResult> {
     const response = await this.client.chat.completions.create({
       model: this.model,
       messages: [
@@ -27,17 +35,17 @@ 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,
+      // github-trending with a large trending list (many repos × verbose
+      // intro/narration) can exceed 8k output tokens and get truncated mid-JSON,
+      // breaking parsing. 16384 gives headroom. (The gateway clamps rather than
+      // errors on an oversized value, and the parse stage additionally retries
+      // once with a conciseness nudge if finish_reason="length".)
+      max_tokens: 16384,
     });
 
-    const content = response.choices[0]?.message?.content;
+    const choice = response.choices[0];
+    const content = choice?.message?.content;
     if (!content) throw new Error("LLM returned empty response");
-    return content;
+    return { content, finishReason: choice?.finish_reason ?? null };
   }
 }

+ 1 - 1
packages/shared/src/llm/index.ts

@@ -1,5 +1,5 @@
 export { LLMClient } from "./client.js";
-export type { LLMClientConfig } from "./client.js";
+export type { LLMClientConfig, LLMResult } from "./client.js";
 export { getParsePrompt } from "./prompts/parse-text.js";
 export { detectInputFormat } from "./detect-input.js";
 export type { InputFormat, DetectResult } from "./detect-input.js";

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

@@ -10,7 +10,6 @@ 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": [
     {
@@ -22,7 +21,7 @@ The output JSON must follow this exact schema:
         { "type": "text|highlight|image|icon|transition", "content": "string" }
       ],
       "images": "array — optional, image references. Entries may use { path } for local files, { url } for remote URLs, or { repoSocialPreview: \"owner/name\" } for GitHub repo social preview cards (only used by github-trending template).",
-      "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license }, highlights, intro, review }. See template-specific rules.",
+      "github": "object — optional, only for github-trending template. Shape: { repo: { owner, name, fullName, language, languageColor, stars, forks, license, todayStars }, highlights, intro, review }. See template-specific rules.",
       "durationHint": 10,
       "backgroundQuery": "string — describe the ideal background image",
       "layoutHint": "full-text|split|centered|bullet-list"
@@ -95,15 +94,14 @@ Template: GitHub Trending Showcase (GitHub 每日热榜)
 - 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.
+COVER METADATA (github-trending only — emit this TOP-LEVEL field alongside title/summary; omit it for every other template):
+- 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 repo list 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.
 
-github.repo: COPY the contents of the corresponding "<!-- repo-meta: ... -->" block from the input VERBATIM into scene.github.repo. Do not drop fields, do not rename keys, do not reformat numbers (keep stars as an integer). If license is empty string, keep it as empty string — the template will skip the license tag.
+github.repo: COPY the contents of the corresponding "<!-- repo-meta: ... -->" block from the input VERBATIM into scene.github.repo. Do not drop fields, do not rename keys, do not reformat numbers (keep stars as an integer). If license is empty string, keep it as empty string — the template will skip the license tag. If the repo-meta block omits todayStars but the repo's text contains a "Today: +N" line, extract the integer N and set github.repo.todayStars to it.
 
 images: array of EXACTLY TWO entries, copied from the corresponding "<!-- repo-images: ... -->" block:
   1. { "repoSocialPreview": "owner/name" }  — DO NOT expand this into a URL. The assets stage resolves it.
@@ -118,7 +116,7 @@ github.review (点评推荐): one short recommendation, ≤ 30 Chinese character
 
 narration (口播): ≤ 200 characters. Cover what the project does (deeper than intro), why it is notable today (timing / trend / adoption), and one distinctive characteristic a developer cares about. Pack specifics — numbers, comparisons, concrete use cases. Do NOT include a language bullet unless language is core to the value; do NOT read out the GitHub URL.
 
-GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with "大家好,今天是...". Therefore EVERY content scene's narration — including the first repo scene — MUST NOT begin with any of the following:
+GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with a short factual masthead line ("…,今日精选 N 个热榜项目。") that frames the day. Therefore EVERY content scene's narration — including the first repo scene — MUST NOT begin with any of the following:
 - A greeting: "大家好", "各位", "哈喽", "嗨", time-of-day greetings.
 - A show-opener that references the day, show, or format: "今天是GitHub热榜速览", "今天为大家带来...", "今天给大家介绍...", "本期节目...", "本周热榜...", "欢迎收看...".
 - Any meta lead-in. The narration must start DIRECTLY with project content (e.g. "首先要聊的是 React...", "接下来这个项目...", "React 是 Facebook 出品的声明式 UI 库...").
@@ -174,7 +172,7 @@ For multi-project sources, EACH project scene follows this same structure indepe
     "github-trending": `
 Data source: GitHub Trending (热门仓库列表)
 - The input is a list of multiple trending repositories. Treat it as a roundup.
-- Aim for 3-6 repos; if the input has more, pick the most notable ones (highest stars, biggest daily growth, or most novel).
+- Select EXACTLY the 6 repos with the highest today's star gain (github.repo.todayStars, mirrored by the 'Today: +N' line), in descending order. Produce one scene per repo in that order so the video follows the same ranking shown on the cover. If the input has fewer than 6 repos, use all of them.
 - One scene per repo. EVERY repo scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration).
 - Connect repos with a short narrative thread between scenes if there's a shared theme (e.g. "今天 AI Agent 持续火热"), but each project scene stands on its own.
 - Tone: pragmatic, factual, no hype. The audience is developers.

+ 5 - 0
packages/shared/src/types/scene.ts

@@ -42,6 +42,11 @@ export const RepoMetaSchema = z.object({
   stars: z.number().optional(),
   forks: z.number().optional(),
   license: z.string().optional(),
+  // Today's star gain (currentPeriodStars from the trending API). Emitted into
+  // the structured repo-meta block by the collector so the cover scene can show
+  // "+N" per repo. Nested inside github.repo, so it flows through compose/render
+  // automatically (github is passed whole-object) — no extra plumbing needed.
+  todayStars: z.number().optional(),
 });
 
 export type RepoMeta = z.infer<typeof RepoMetaSchema>;

+ 273 - 57
packages/templates/src/Root.tsx

@@ -1,12 +1,13 @@
 import React, { useEffect, useState } from "react";
-import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, interpolate, continueRender, delayRender } from "remotion";
+import { Composition, Sequence, AbsoluteFill, Audio, staticFile, Img, useCurrentFrame, useVideoConfig, interpolate, continueRender, delayRender } from "remotion";
 import type { TemplateType, AspectRatio } from "@pipeline/shared";
 import type { WordTimestamp, GithubSceneData } from "@pipeline/shared";
+import { formatCount } 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, { ColorDot } from "./github-trending/index";
 import { THEMES } from "./base/theme/colors";
 
 export interface RemotionScene {
@@ -76,6 +77,11 @@ const TEMPLATES: TemplateType[] = ["news", "knowledge", "opinion", "marketing",
 const TemplateComposition: React.FC<RemotionProps> = (props) => {
   const SceneComponent = SCENE_MAP[props.template];
   const totalFrames = props.scenes.at(-1)?.endFrame ?? 90;
+  // github-trending: the cover lists every repo (name / language / today's
+  // star gain / one-line description). Content scenes each carry scene.github.
+  const coverRepos = props.scenes.filter(
+    (s) => s.sceneType === "content" && s.github
+  );
 
   return (
     <AbsoluteFill style={{ backgroundColor: "#000" }}>
@@ -99,7 +105,7 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
                 subtitle={props.subtitle}
                 keyframes={scene.keyframes}
                 backgroundAsset={scene.backgroundAsset}
-                coverTags={scene.coverTags}
+                coverRepos={coverRepos}
                 trendSummary={scene.trendSummary}
                 totalFrames={totalFrames}
               />
@@ -141,96 +147,222 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
         );
       })}
       <GlobalProgressBar totalFrames={totalFrames} color={THEMES[props.template].primary} />
+      {props.template === "github-trending" && <ChapterToc scenes={props.scenes} />}
     </AbsoluteFill>
   );
 };
 
 // --- Cover Scene ---
 
+// Faint, sparse code/data motifs on the github-trending cover background.
+// Low-opacity and edge-anchored so the cover stays clean — the motifs read
+// only as subtle texture, never competing with the cards.
+const CoverDecor: React.FC<{ accent: string }> = ({ accent }) => {
+  const tok = (css: React.CSSProperties): React.CSSProperties => ({
+    position: "absolute",
+    fontFamily: "monospace",
+    fontWeight: 700,
+    color: "#0f172a",
+    opacity: 0.06,
+    letterSpacing: "0.02em",
+    ...css,
+  });
+  return (
+    <div style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }}>
+      <span style={tok({ top: 44, left: 52, fontSize: 56 })}>{"</>"}</span>
+      <span style={tok({ top: 58, right: 60, fontSize: 48 })}>{"{ }"}</span>
+      <span style={tok({ bottom: 168, left: 54, fontSize: 28 })}>{"01 10 01"}</span>
+      <span style={tok({ bottom: 150, right: 58, fontSize: 26 })}>{"git push"}</span>
+      {/* Faint data line chart */}
+      <svg width="180" height="84" viewBox="0 0 180 84" style={{ position: "absolute", top: 64, right: 168, opacity: 0.08 }}>
+        <polyline points="0,66 34,50 68,56 102,28 136,34 180,8" fill="none" stroke={accent} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
+      </svg>
+      {/* Faint data bar cluster */}
+      <div style={{ position: "absolute", bottom: 206, left: 80, display: "flex", alignItems: "flex-end", gap: 7, opacity: 0.08 }}>
+        <div style={{ width: 12, height: 32, background: accent, borderRadius: 3 }} />
+        <div style={{ width: 12, height: 58, background: accent, borderRadius: 3 }} />
+        <div style={{ width: 12, height: 24, background: accent, borderRadius: 3 }} />
+        <div style={{ width: 12, height: 76, background: accent, borderRadius: 3 }} />
+        <div style={{ width: 12, height: 46, background: accent, borderRadius: 3 }} />
+      </div>
+    </div>
+  );
+};
+
 const CoverScene: React.FC<{
   template: TemplateType;
   title: string;
   subtitle?: string;
   keyframes: Array<{ type: string; content: string }>;
   backgroundAsset?: { id: string; filename: string };
-  coverTags?: string[];
+  coverRepos?: RemotionScene[];
   trendSummary?: string;
   totalFrames: number;
-}> = ({ template, title, subtitle, keyframes, backgroundAsset, coverTags, trendSummary }) => {
+}> = ({ template, title, subtitle, keyframes, backgroundAsset, coverRepos, trendSummary }) => {
   const accent = THEMES[template].primaryLight;
   const isGithubTrending = template === "github-trending";
+  const { width, height } = useVideoConfig();
+  const isPortrait = height > width;
 
-  // github-trending cover doubles as the opening narration screen — light
-  // 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.
+  // github-trending cover: a DARK title card (contrast over a light bg with
+  // faint code/data motifs) centered near the top, then the TOP 3 repos by
+  // today's star gain as large rounded "floating" cards — horizontal row in
+  // landscape, vertical stack in portrait. Doubles as the opening narration.
   if (isGithubTrending) {
-    const hasTags = !!(coverTags && coverTags.length > 0);
+    const topRepos = [...(coverRepos ?? [])]
+      .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
+      .slice(0, 6);
+    const primary = THEMES[template].primary;
+
     return (
       <AbsoluteFill style={{
-        background: "linear-gradient(135deg, #f8fafc 0%, #e2e8f0 60%, #cbd5e1 100%)",
+        background: "linear-gradient(135deg, #f8fafc 0%, #eef2f7 55%, #e2e8f0 100%)",
       }}>
-        {/* Top accent bar in template primary */}
+        <CoverDecor accent={accent} />
+        {/* Top accent line */}
         <div style={{
-          position: "absolute", top: 0, left: 0, width: "100%", height: 8,
-          background: `linear-gradient(90deg, ${THEMES[template].primary}, ${accent})`,
+          position: "absolute", top: 0, left: 0, width: "100%", height: 6,
+          background: `linear-gradient(90deg, ${primary}, ${accent})`,
         }} />
         <div style={{
-          position: "absolute", inset: 0, display: "flex",
-          flexDirection: "column", justifyContent: "center", alignItems: "center",
-          padding: 80, textAlign: "center",
+          position: "absolute", inset: 0, display: "flex", flexDirection: "column",
+          alignItems: "center",
+          justifyContent: isPortrait ? "center" : "flex-start",
+          padding: isPortrait ? "72px 64px 130px" : "58px 80px 112px",
         }}>
+          {/* DARK title card — centered, near the top (contrast over light bg) */}
           <div style={{
-            fontSize: 104, fontWeight: 800, color: "#0f172a",
-            fontFamily: "Noto Sans SC", lineHeight: 1.15,
-            marginBottom: 28, letterSpacing: "-0.02em",
+            display: "flex", flexDirection: "column", alignItems: "center", gap: 14,
+            padding: isPortrait ? "38px 76px" : "32px 88px",
+            borderRadius: 26,
+            background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
+            border: `1.5px solid ${primary}55`,
+            boxShadow: "0 18px 44px rgba(15,23,42,0.22)",
+            marginBottom: isPortrait ? 44 : 38,
+            maxWidth: isPortrait ? "84%" : "74%",
+            textAlign: "center",
           }}>
-            {title}
-          </div>
-          {subtitle && (
             <div style={{
-              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,
+              fontSize: isPortrait ? 72 : 66, fontWeight: 800, color: "#ffffff",
+              fontFamily: "Noto Sans SC", lineHeight: 1.15, letterSpacing: "-0.02em",
             }}>
-              {subtitle}
+              {title}
             </div>
-          )}
-          {hasTags && (
             <div style={{
-              display: "flex", flexWrap: "wrap", justifyContent: "center",
-              gap: 18, maxWidth: "82%", marginBottom: trendSummary ? 36 : 0,
+              display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
+              justifyContent: "center",
             }}>
-              {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)",
+              {subtitle && (
+                <span style={{
+                  fontSize: isPortrait ? 30 : 26, fontWeight: 600, color: accent,
+                  fontFamily: "Noto Sans SC",
                 }}>
-                  <span style={{
-                    display: "inline-block", width: 11, height: 11,
-                    borderRadius: "50%", background: accent, flexShrink: 0,
-                  }} />
-                  {tag}
-                </div>
-              ))}
+                  {subtitle}
+                </span>
+              )}
+              {trendSummary && (
+                <span style={{
+                  fontSize: isPortrait ? 28 : 24, fontWeight: 500, color: "#cbd5e1",
+                  fontFamily: "Noto Sans SC",
+                }}>
+                  · {trendSummary}
+                </span>
+              )}
             </div>
-          )}
-          {trendSummary && (
+          </div>
+
+          {/* TOP 6 repos by today's star gain (3 per row → wraps to a grid) */}
+          {topRepos.length > 0 && (
             <div style={{
-              fontSize: 40, fontWeight: 500, color: "#475569",
-              fontFamily: "Noto Sans SC", maxWidth: "70%", lineHeight: 1.5,
+              display: "flex", flexWrap: "wrap",
+              gap: 24,
+              width: "100%",
+              maxWidth: isPortrait ? "90%" : "94%",
             }}>
-              {trendSummary}
+              {topRepos.map((s, i) => {
+                const g = s.github!;
+                const repo = g.repo;
+                const language = repo.language || "";
+                const languageColor = repo.languageColor || accent;
+                const todayLabel = repo.todayStars != null ? `+${formatCount(repo.todayStars)}` : "";
+                return (
+                  <div key={s.id} style={{
+                    flex: isPortrait ? "0 0 calc((100% - 24px) / 2)" : "0 0 calc((100% - 48px) / 3)",
+                    minWidth: 0,
+                    display: "flex", flexDirection: "column",
+                    padding: isPortrait ? "28px 30px" : "30px",
+                    borderRadius: 22,
+                    background: "#ffffff",
+                    border: `1px solid ${primary}22`,
+                    boxShadow: "0 16px 38px rgba(15,23,42,0.12), 0 2px 8px rgba(15,23,42,0.06)",
+                  }}>
+                    {/* Rank badge + today's gain (the selection signal) */}
+                    <div style={{
+                      display: "flex", alignItems: "center", justifyContent: "space-between",
+                      marginBottom: 18,
+                    }}>
+                      <div style={{
+                        display: "inline-flex", alignItems: "center", justifyContent: "center",
+                        width: isPortrait ? 56 : 52, height: isPortrait ? 56 : 52,
+                        borderRadius: "50%",
+                        background: `linear-gradient(135deg, ${accent}, ${primary})`,
+                        color: "#fff", fontWeight: 800, fontFamily: "Noto Sans SC",
+                        fontSize: isPortrait ? 30 : 26,
+                        boxShadow: "0 4px 12px rgba(34,197,94,0.35)",
+                      }}>
+                        {i + 1}
+                      </div>
+                      {todayLabel && (
+                        <span style={{
+                          fontSize: isPortrait ? 56 : 50, fontWeight: 800, color: primary,
+                          fontFamily: "Noto Sans SC", lineHeight: 1,
+                        }}>{todayLabel}</span>
+                      )}
+                    </div>
+                    {/* Repo full name */}
+                    <div style={{
+                      fontSize: isPortrait ? 40 : 34, fontWeight: 800, color: "#0f172a",
+                      fontFamily: "Noto Sans SC", lineHeight: 1.2, marginBottom: 12,
+                      wordBreak: "break-word",
+                    }}>
+                      {repo.fullName}
+                    </div>
+                    {/* Language + total stars */}
+                    <div style={{
+                      display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap",
+                      marginBottom: 14, fontFamily: "Noto Sans SC",
+                    }}>
+                      {language && (
+                        <span style={{
+                          display: "inline-flex", alignItems: "center", gap: 8,
+                          fontSize: isPortrait ? 24 : 21, fontWeight: 600, color: "#334155",
+                          padding: "5px 14px", borderRadius: 999,
+                          background: `${languageColor}1a`, border: `1px solid ${languageColor}55`,
+                        }}>
+                          <ColorDot color={languageColor} size={isPortrait ? 14 : 12} />
+                          {language}
+                        </span>
+                      )}
+                      {repo.stars != null && (
+                        <span style={{
+                          fontSize: isPortrait ? 23 : 20, color: "#64748b", fontWeight: 500,
+                        }}>
+                          {formatCount(repo.stars)} stars
+                        </span>
+                      )}
+                    </div>
+                    {/* One-line description (highlights) */}
+                    <div style={{
+                      fontSize: isPortrait ? 27 : 23, lineHeight: 1.4, color: "#475569",
+                      fontFamily: "Noto Sans SC",
+                      display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical",
+                      overflow: "hidden",
+                    }}>
+                      {g.highlights}
+                    </div>
+                  </div>
+                );
+              })}
             </div>
           )}
         </div>
@@ -370,6 +502,90 @@ const GlobalProgressBar: React.FC<{
   );
 };
 
+const ChapterToc: React.FC<{ scenes: RemotionScene[] }> = ({ scenes }) => {
+  // github-trending only: a chapter table of contents pinned just above the
+  // global progress bar. Lists every repo's short name; the chapter whose
+  // [startFrame, endFrame) contains the current frame is highlighted. A faint
+  // dark gradient strip behind the row keeps labels legible over light
+  // backgrounds (e.g. the cover) and visually groups the TOC with the bar.
+  const frame = useCurrentFrame();
+  const { width, height } = useVideoConfig();
+  const isPortrait = height > width;
+  const accent = THEMES["github-trending"].primary;
+
+  const chapters = scenes.filter((s) => s.sceneType === "content" && s.github);
+  const active = chapters.findIndex(
+    (c) => frame >= c.startFrame && frame < c.endFrame
+  );
+
+  return (
+    <>
+      <div
+        style={{
+          position: "absolute",
+          bottom: 0,
+          left: 0,
+          width: "100%",
+          height: 72,
+          background: "linear-gradient(to top, rgba(0,0,0,0.42), transparent)",
+          pointerEvents: "none",
+        }}
+      />
+      <div
+        style={{
+          position: "absolute",
+          bottom: 12,
+          left: 0,
+          width: "100%",
+          display: "flex",
+          justifyContent: "center",
+          alignItems: "center",
+          gap: isPortrait ? 16 : 12,
+          padding: "0 32px",
+          fontFamily: "Noto Sans SC",
+          pointerEvents: "none",
+        }}
+      >
+        {chapters.map((c, i) => {
+          const isActive = i === active;
+          const repo = c.github!.repo;
+          const label = repo.name || repo.fullName || c.displayText || "";
+          return (
+            <React.Fragment key={c.id}>
+              {i > 0 && (
+                <span
+                  style={{
+                    color: "rgba(255,255,255,0.3)",
+                    fontSize: isPortrait ? 22 : 18,
+                    flexShrink: 0,
+                  }}
+                >
+                  ·
+                </span>
+              )}
+              <span
+                style={{
+                  flex: "1 1 0",
+                  minWidth: 0,
+                  fontSize: isPortrait ? 26 : 22,
+                  fontWeight: isActive ? 700 : 500,
+                  color: isActive ? accent : "rgba(255,255,255,0.6)",
+                  whiteSpace: "nowrap",
+                  overflow: "hidden",
+                  textOverflow: "ellipsis",
+                  textAlign: "center",
+                }}
+              >
+                {label}
+              </span>
+            </React.Fragment>
+          );
+        })}
+      </div>
+    </>
+  );
+};
+
 const defaultProps: RemotionProps = {
   scenes: [
     {

+ 25 - 0
pnpm-lock.yaml

@@ -63,6 +63,9 @@ importers:
       next:
         specifier: ^15.3.0
         version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+      node-cron:
+        specifier: ^3.0.3
+        version: 3.0.3
       react:
         specifier: ^19.0.0
         version: 19.2.6
@@ -79,6 +82,9 @@ importers:
       '@types/node':
         specifier: ^22.0.0
         version: 22.19.19
+      '@types/node-cron':
+        specifier: ^3.0.11
+        version: 3.0.11
       '@types/react':
         specifier: ^19.0.0
         version: 19.2.15
@@ -869,6 +875,9 @@ packages:
   '@types/json-schema@7.0.15':
     resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
 
+  '@types/node-cron@3.0.11':
+    resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
+
   '@types/node-fetch@2.6.13':
     resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
 
@@ -1521,6 +1530,10 @@ packages:
       sass:
         optional: true
 
+  node-cron@3.0.3:
+    resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==}
+    engines: {node: '>=6.0.0'}
+
   node-domexception@1.0.0:
     resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
     engines: {node: '>=10.5.0'}
@@ -1989,6 +2002,10 @@ packages:
     resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==}
     engines: {node: '>= 0.12.0'}
 
+  uuid@8.3.2:
+    resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+    hasBin: true
+
   watchpack@2.5.1:
     resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
     engines: {node: '>=10.13.0'}
@@ -2737,6 +2754,8 @@ snapshots:
 
   '@types/json-schema@7.0.15': {}
 
+  '@types/node-cron@3.0.11': {}
+
   '@types/node-fetch@2.6.13':
     dependencies:
       '@types/node': 22.19.19
@@ -3401,6 +3420,10 @@ snapshots:
       - '@babel/core'
       - babel-plugin-macros
 
+  node-cron@3.0.3:
+    dependencies:
+      uuid: 8.3.2
+
   node-domexception@1.0.0: {}
 
   node-fetch@2.7.0:
@@ -3866,6 +3889,8 @@ snapshots:
       mz: 2.7.0
       unescape: 1.0.1
 
+  uuid@8.3.2: {}
+
   watchpack@2.5.1:
     dependencies:
       glob-to-regexp: 0.4.1

+ 145 - 0
test/fixtures/github-trending-cover.json

@@ -0,0 +1,145 @@
+{
+  "title": "GitHub 每日热榜",
+  "summary": "今日精选四个值得关注的开源项目",
+  "trendSummary": "AI Agent 与代码智能工具持续升温",
+  "scenes": [
+    {
+      "id": "scene-1",
+      "narration": "codebase-memory-mcp 是一个高性能代码智能服务,把整个代码库索引成知识图谱。",
+      "displayText": "DeusData/codebase-memory-mcp",
+      "duration": 5,
+      "github": {
+        "repo": {
+          "owner": "DeusData",
+          "name": "codebase-memory-mcp",
+          "fullName": "DeusData/codebase-memory-mcp",
+          "language": "C",
+          "languageColor": "#555555",
+          "stars": 8590,
+          "forks": 653,
+          "license": "MIT",
+          "todayStars": 142
+        },
+        "highlights": "代码知识图谱 + MCP 协议",
+        "intro": "把代码库索引成持久化知识图谱的高性能 MCP 服务,支持跨文件语义检索与引用追踪,适合大型工程的代码智能场景。",
+        "review": "适合构建代码助手与 IDE 智能后端"
+      }
+    },
+    {
+      "id": "scene-2",
+      "narration": "React 是 Facebook 出品的声明式 UI 库,生态最大。",
+      "displayText": "facebook/react",
+      "duration": 5,
+      "github": {
+        "repo": {
+          "owner": "facebook",
+          "name": "react",
+          "fullName": "facebook/react",
+          "language": "JavaScript",
+          "languageColor": "#f1e05a",
+          "stars": 232000,
+          "forks": 47600,
+          "license": "MIT",
+          "todayStars": 95
+        },
+        "highlights": "声明式 UI + 虚拟 DOM,生态最大",
+        "intro": "Facebook 出品的声明式组件化 UI 库,状态驱动视图更新,虚拟 DOM 优化渲染,适合复杂交互的 Web 应用。",
+        "review": "中大型 Web 应用的默认选择"
+      }
+    },
+    {
+      "id": "scene-3",
+      "narration": "Ollama 让本地一键运行大模型变得简单。",
+      "displayText": "ollama/ollama",
+      "duration": 5,
+      "github": {
+        "repo": {
+          "owner": "ollama",
+          "name": "ollama",
+          "fullName": "ollama/ollama",
+          "language": "Go",
+          "languageColor": "#00ADD8",
+          "stars": 142000,
+          "forks": 11300,
+          "license": "MIT",
+          "todayStars": 310
+        },
+        "highlights": "本地一键运行开源大模型",
+        "intro": "用 Go 实现的本地大模型运行时,一条命令拉取并运行 Llama、Qwen 等开源模型,提供 REST API,便于集成到本地工具链。",
+        "review": "想在本地跑大模型的开发者首选"
+      }
+    },
+    {
+      "id": "scene-4",
+      "narration": "Next.js 是 Vercel 出品的 React 全栈应用框架。",
+      "displayText": "vercel/next.js",
+      "duration": 5,
+      "github": {
+        "repo": {
+          "owner": "vercel",
+          "name": "next.js",
+          "fullName": "vercel/next.js",
+          "language": "TypeScript",
+          "languageColor": "#3178c6",
+          "stars": 128000,
+          "forks": 27200,
+          "license": "MIT",
+          "todayStars": 76
+        },
+        "highlights": "React 全栈框架,SSR/SSG/RSC",
+        "intro": "Vercel 出品的 React 全栈应用框架,内置服务端渲染、静态生成与 React Server Components,配套文件路由与边缘部署。",
+        "review": "追求生产效率的全栈团队"
+      }
+    },
+    {
+      "id": "scene-5",
+      "narration": "Rust 是系统级编程语言,靠所有权机制在编译期保证内存安全。",
+      "displayText": "rust-lang/rust",
+      "duration": 5,
+      "github": {
+        "repo": {
+          "owner": "rust-lang",
+          "name": "rust",
+          "fullName": "rust-lang/rust",
+          "language": "Rust",
+          "languageColor": "#dea584",
+          "stars": 102000,
+          "forks": 13300,
+          "license": "Apache-2.0",
+          "todayStars": 120
+        },
+        "highlights": "内存安全 + 零成本抽象的系统语言",
+        "intro": "Rust 是一门系统级编程语言,凭借所有权机制在编译期保证内存与线程安全,无需垃圾回收,适合操作系统、WebAssembly 与高性能服务端。",
+        "review": "追求性能与安全的底层系统开发"
+      }
+    },
+    {
+      "id": "scene-6",
+      "narration": "Tailwind CSS 是原子化实用类 CSS 框架。",
+      "displayText": "tailwindlabs/tailwindcss",
+      "duration": 5,
+      "github": {
+        "repo": {
+          "owner": "tailwindlabs",
+          "name": "tailwindcss",
+          "fullName": "tailwindlabs/tailwindcss",
+          "language": "TypeScript",
+          "languageColor": "#3178c6",
+          "stars": 84000,
+          "forks": 4100,
+          "license": "MIT",
+          "todayStars": 58
+        },
+        "highlights": "原子化实用类 CSS 框架",
+        "intro": "Tailwind CSS 是原子化 CSS 框架,通过组合实用类直接在标记中构建设计系统,产出体积小且一致的 UI。",
+        "review": "偏好实用优先工作流的前端团队"
+      }
+    }
+  ],
+  "outro": {
+    "text": "今天的 GitHub 热榜就到这里",
+    "narration": "感谢观看,我们明天见。",
+    "cta": "点赞 | 关注"
+  },
+  "globalStyle": { "tone": "calm", "pace": "normal" }
+}