Pārlūkot izejas kodu

feat(github-trending): 优化口播——封面问候+趋势开场、k 星数转中文口语、不念作者名

- 封面口播改为三段式:问候 → 今日趋势(朗读 trendSummary)→ 进入项目详解,
  去掉冗长欢迎语;trendSummary 缺失时退化为简洁版
- 新增 normalizeCountsForTTS:parse 末尾对所有 narration 把 Nk 星数展开为
  中文口语(11.3k→一万一千三百、2k→两千),遵循 两/二 口语规则
  (千/百/万位读两,十/个位读二);视觉卡片 formatCount 的 k 格式不受影响
- 提示词新增 PROJECT NAME RULE:narration 只念项目短名,不念 owner/作者;
  修正 GREETING RULE 与封面示例的过时描述

Co-Authored-By: Claude <noreply@anthropic.com>
lkatzey 2 dienas atpakaļ
vecāks
revīzija
5945456446

+ 2 - 2
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` 在这里:①内容场景按 `github.repo.todayStars` 降序取 **top 6**(视频只介绍今日涨星最多的 6 个,顺序与首屏一致);②确定性写入 cover 开场口播(一句承接语 `下面进入项目详解。`,**不含日期/数量**——日期与卡片由首屏视觉呈现);③承载 LLM 产出的 `trendSummary`(一句话趋势)透传到首屏渲染。不再注入 summary 场景,也已停用 `coverTags`
+1. **parse** — 通过 `VideoInputSchema` 验证 JSON,转换为内部 `ParsedContent`(cover → scene-0,outro → 最后一个 scene)。`github-trending` 在这里:①内容场景按 `github.repo.todayStars` 降序取 **top 6**(视频只介绍今日涨星最多的 6 个,顺序与首屏一致);②确定性写入 cover 开场口播(结构:问候 → 今日趋势 → 进入项目详解,即 `大家好,今天{trendSummary}。下面进入项目详解。`,**不含日期/数量**——日期与卡片由首屏视觉呈现;`trendSummary` 缺失时退化为 `大家好,下面进入项目详解。`);③承载 LLM 产出的 `trendSummary`(一句话趋势)透传到首屏渲染。不再注入 summary 场景,也已停用 `coverTags`。parse 末尾还会对所有场景 narration 跑 `normalizeCountsForTTS`,把 `Nk` 星数(如 `11.3k`)展开成中文口语(一万一千三百),避免 TTS 把 k 读成字母
 2. **tts** — 按场景调用 TTS provider,生成逐场景音频文件 + word timestamps
 3. **assets** — 解析图片资源(本地 `path` > 远程 `url` > 关键词 `query`),复制背景图/字体
 4. **compose** — 转换为带帧时间轴的 `ComposedProject`,word timestamps 转为场景内相对时间。逐字段拷贝场景数据——新增场景字段时**必须在此阶段显式透传**(`buildInputProps` 用 `...scene` 自动透传,但 compose 是手写字段映射)
@@ -121,5 +121,5 @@ publish 阶段位于 `packages/core/src/publish/`(`oss.ts` 用 `ali-oss` 分
 ## LLM 与 TTS 提示
 
 - `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` 已停用
+- `github-trending` 的 cover narration 是 `parse.ts` 按模板拼装的(`大家好,今天{trendSummary}。下面进入项目详解。`;无 trendSummary 时退化为 `大家好,下面进入项目详解。`),**不交给 LLM**,也不含日期/仓库数量。`trendSummary`(一句话趋势)由 LLM 产出,**既朗读进封面口播、又作为副标题渲染到首屏**。内容场景的 narration 只念**项目短名**(如 `React`),不念 owner/作者(由提示词约束)。LLM 只产出 per-repo `scenes[]`(数据源规则要求按今日涨星降序选 6 个),不生成 overview/summary 场景;parse 端会再次按 todayStars 排序取 top6 作兜底。`coverTags` 已停用。所有 narration 在 parse 末尾经 `normalizeCountsForTTS` 把 `Nk` 星数转中文口语(视觉卡片的 `formatCount` k 格式不受影响)
 - TTS 按场景独立合成,受 provider 配额限制;调试布局可用 `--no-tts` 跳过(生成静音视频)

+ 22 - 4
packages/core/src/stages/parse.ts

@@ -7,6 +7,7 @@ import {
   stripUnsupportedGlyphs,
   clipToLength,
   formatChineseDate,
+  normalizeCountsForTTS,
 } from "@pipeline/shared";
 import { getTimezone } from "@pipeline/shared/node";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
@@ -35,9 +36,9 @@ export interface ParseStageConfig {
  * returns it; the input is left untouched.
  *
  * When `template === "github-trending"`, also strips leading greetings from
- * every scene's narration — the cover scene already opens with "大家好,今天
- * 是..." so any per-scene greeting would duplicate it. LLM adherence to the
- * prompt rule is unreliable, so this is the source of truth.
+ * every scene's narration — the cover scene already opens with a greeting
+ * ("大家好,…") so any per-scene greeting would duplicate it. LLM adherence to
+ * the prompt rule is unreliable, so this is the source of truth.
  */
 function applyLengthLimits(input: unknown, template: string): unknown {
   if (!input || typeof input !== "object") return input;
@@ -289,8 +290,17 @@ export async function parseText(
         .sort((a, b) => (b.github!.repo.todayStars ?? 0) - (a.github!.repo.todayStars ?? 0))
         .slice(0, 6)
     : videoInput.scenes;
+  // Cover opens: greeting → today's trend (spoken from the LLM-produced
+  // trendSummary) → transition into the repo rundown. No date/count (shown
+  // visually) and no verbose welcome. Falls back to a bare greeting+transition
+  // when trendSummary is absent.
+  const trend = (videoInput.trendSummary ?? "")
+    .trim()
+    .replace(/[。!?.!?\s]+$/u, "");
   const coverNarration = template === "github-trending"
-    ? "下面进入项目详解。"
+    ? trend
+      ? `大家好,今天${trend}。下面进入项目详解。`
+      : "大家好,下面进入项目详解。"
     : "";
   scenes.push({
     id: "cover",
@@ -335,6 +345,14 @@ export async function parseText(
     });
   }
 
+  // Expand "Nk" star/fork counts in every narration to spoken Chinese before
+  // TTS reads them aloud (e.g. "11.3k" → 一万一千三百). Done here so the spoken
+  // form feeds both audio synthesis and subtitle word-timestamps consistently.
+  // Visual card counts (formatCount on numeric fields) are unaffected.
+  for (const sc of scenes) {
+    if (sc.narration) sc.narration = normalizeCountsForTTS(sc.narration);
+  }
+
   const result: ParsedContent = {
     title: videoInput.title,
     subtitle: videoInput.subtitle ?? videoInput.scenes[0]?.title,

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

@@ -14,4 +14,4 @@ export type {
 } from "./constants.js";
 export { LLMClient, getParsePrompt, detectInputFormat } 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";
+export { stripUnsupportedGlyphs, clipToLength, formatCount, isoDateString, formatChineseDate, normalizeCountsForTTS, DEFAULT_TIMEZONE } from "./utils/index.js";

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

@@ -116,10 +116,12 @@ 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 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:
+GREETING RULE (overrides the base prompt's first-scene greeting rule): the cover scene for this template already opens with a greeting ("大家好,…"), so 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 库...").
+- Any meta lead-in. The narration must start DIRECTLY with project content (e.g. "首先要聊的是 React...", "接下来这个项目...", "React 是一款声明式 UI 库...").
+
+PROJECT NAME RULE: in narration, refer to each project by its SHORT repo name only (e.g. "React", "next.js", "bun"). NEVER read the owner/author prefix ("Facebook", "openai", "facebook/react") — the owner already appears on the GitHub 地址 card, and reading it aloud in a brief intro adds no information and can cause ambiguity (it is unclear whether you mean the person/org or the project). This applies to narration only; displayText and keyframe cards keep the owner/name form.
 
 CHARACTER LIMITS ARE STRICT — exceeding them breaks the layout. If content does not fit, drop the least important detail rather than compressing into a longer sentence.
 
@@ -161,7 +163,7 @@ B. Keyframe cards (4 cards ideally, 3-4 acceptable). Pick content that ADDS valu
    Number formatting rule (STRICT): whenever a count >= 1000 appears (stars, forks, contributors, today's growth, etc.), display it with a lowercase k suffix, NOT the raw integer. Examples: 1234 → "1.2k", 12345 → "12.3k", 123456 → "123k", 1234567 → "1235k", 999 → "999" (unchanged). NEVER use the m suffix — counts stay in k even past one million. The input text from the collector is already in this format — preserve it; if you ever compute or restate a count yourself, apply the same formatting before writing it into a card.
 
 C. 详细描述 → narration (口播)
-   Must be information-dense within the 200-char limit. Cover: what the project does (deeper than the headline), why it is notable, and 1-2 distinctive characteristics a developer would care about. Pack specifics — numbers, comparisons, concrete use cases — instead of filler phrases. Do NOT include a language bullet; mention the language only in passing if it is core to the value (e.g. "用 Rust 实现,启动速度比 Node 同类快数倍").
+   Must be information-dense within the 200-char limit. Cover: what the project does (deeper than the headline), why it is notable, and 1-2 distinctive characteristics a developer would care about. Pack specifics — numbers, comparisons, concrete use cases — instead of filler phrases. Refer to the project by its SHORT repo name only — do NOT read the owner/author in narration (e.g. say "React", not "Facebook 的 React" or "facebook/react"); the owner is already shown on the GitHub 地址 card, and reading it aloud can cause ambiguity. Do NOT include a language bullet; mention the language only in passing if it is core to the value (e.g. "用 Rust 实现,启动速度比 Node 同类快数倍").
    If the content does not fit in 200 chars, split the project across 2 scenes — first scene uses the structure above with concise narration; second scene expands with deeper detail on features, comparisons, or ecosystem.
 
 A typical single-project scene therefore looks like: short displayText (项目名 [+ 极简类别]) + 3 mandatory keyframe cards (功能简介 / GitHub 地址 / 使用场景) + 1-2 value-add cards + dense narration (口播详细描述).

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

@@ -1,3 +1,4 @@
 export { stripUnsupportedGlyphs, clipToLength } from "./sanitize-text.js";
 export { formatCount } from "./format.js";
 export { isoDateString, formatChineseDate, DEFAULT_TIMEZONE } from "./date.js";
+export { normalizeCountsForTTS } from "./narration.js";

+ 102 - 0
packages/shared/src/utils/narration.ts

@@ -0,0 +1,102 @@
+/**
+ * Normalize narration text so a TTS engine reads counts aloud as natural
+ * spoken Chinese instead of a literal ASCII transcription.
+ *
+ * GitHub star/fork counts are written with a lowercase "k" suffix everywhere
+ * (e.g. "11.3k", "123k") to match the on-screen cards (see formatCount). But a
+ * TTS engine fed "11.3k" reads it literally — "十一点三k" or "十一点三千" — which
+ * sounds wrong. This expands every "Nk" token to its full integer value in
+ * spoken Chinese ("11.3k" → 一万一千三百, "2k" → 两千, "20k" → 两万), leaving
+ * everything else untouched: plain numbers stay (TTS reads "312" as 三百一十二
+ * fine), version strings ("React 18"), repo names, and the visual "k" formatting
+ * on cards are all unaffected. Applied to narration only.
+ *
+ * Follows the colloquial 两/二 rule: 2 reads 两 before 千/百/万/亿 (两千, 两百,
+ * 两万, 两亿) but 二 in the tens/ones place (二十, 十二, 二十二). */
+
+const ZH_DIGITS = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
+
+/** Convert a 1..9999 group to Chinese place-value form, collapsing internal
+ *  zeros to a single 零 (e.g. 1001 → 一千零一, 1300 → 一千三百). "一十" is
+ *  shortened to "十" only when the tens place is the group's leading position. */
+function fourDigitsToZh(n: number): string {
+  const places = ["千", "百", "十", ""];
+  const digits = [
+    Math.floor(n / 1000) % 10,
+    Math.floor(n / 100) % 10,
+    Math.floor(n / 10) % 10,
+    n % 10,
+  ];
+  const parts: string[] = [];
+  let started = false;
+  let pendingZero = false;
+  for (let i = 0; i < 4; i++) {
+    const d = digits[i];
+    if (d === 0) {
+      if (started) pendingZero = true;
+      continue;
+    }
+    if (pendingZero) {
+      parts.push("零");
+      pendingZero = false;
+    }
+    if (i === 2 && d === 1 && !started) {
+      parts.push("十");
+    } else {
+      // Colloquial 两/二 rule: 千位/百位的 2 reads 两 (两千/两百); 十位/个位
+      // read 二 (二十/十二). So 2000→两千, 2200→两千两百, but 20→二十, 22→二十二.
+      const word = d === 2 && (i === 0 || i === 1) ? "两" : ZH_DIGITS[d];
+      parts.push(word + places[i]);
+    }
+    started = true;
+  }
+  return parts.join("");
+}
+
+/** Convert a non-negative integer to spoken Chinese (supports up to 亿 scale,
+ *  which covers any k-suffixed count: even 9999k ≈ 一亿). */
+function integerToZh(n: number): string {
+  n = Math.trunc(n);
+  if (n === 0) return "零";
+  const units = ["", "万", "亿"];
+  const groups: number[] = [];
+  while (n > 0) {
+    groups.push(n % 10000);
+    n = Math.floor(n / 10000);
+  }
+  let result = "";
+  let seenNonZero = false;
+  let zeroGap = false; // a higher group was emitted; a lower group is empty
+  for (let i = groups.length - 1; i >= 0; i--) {
+    const g = groups[i];
+    if (g === 0) {
+      if (seenNonZero) zeroGap = true;
+      continue;
+    }
+    // Insert 零 when a non-zero group doesn't fill its 千 place, or when an
+    // interior all-zero group separates two non-zero groups (e.g. 1亿零3千).
+    if (zeroGap || (seenNonZero && g < 1000)) result += "零";
+    const unit = units[i];
+    // A lone 2 multiplying 万/亿 reads 两 (两万/两亿), not 二万/二亿. Larger group
+    // values are handled by fourDigitsToZh (which already uses 两 in 百/千 place).
+    if (g === 2 && unit) result += "两" + unit;
+    else result += fourDigitsToZh(g) + unit;
+    seenNonZero = true;
+    zeroGap = false;
+  }
+  return result;
+}
+
+// Match "11.3k", "12.3K", "123k", "1 k" — the k must NOT be part of a larger
+// word (reject when followed by a letter/digit, so "k8s" / "key" are untouched).
+const K_COUNT_PATTERN = /(\d+(?:\.\d+)?)\s*[kK](?![a-zA-Z0-9])/g;
+
+/** Expand every "Nk" count token in `text` to spoken Chinese. */
+export function normalizeCountsForTTS(text: string): string {
+  if (typeof text !== "string" || text.length === 0) return text;
+  return text.replace(K_COUNT_PATTERN, (match, num) => {
+    const value = Math.round(Number(num) * 1000);
+    if (!Number.isFinite(value) || value <= 0) return match;
+    return integerToZh(value);
+  });
+}