Parcourir la source

修改github相关场景内容;webui增加notts选项

lkatzey il y a 1 mois
Parent
commit
c890205b6e

+ 1 - 0
apps/cli/src/commands/render.ts

@@ -148,6 +148,7 @@ export const renderCommand = new Command("render")
         ttsProvider: providerName as any,
         voiceId: pipelineConfig.tts.voiceId,
         outputPath: resolve(opts.output || "./output"),
+        source: opts.source,
       },
       pipelineConfig,
       {

+ 52 - 2
apps/web/src/app/create/page.tsx

@@ -58,6 +58,7 @@ export default function CreatePage() {
   const [platforms, setPlatforms] = useState<string[]>(["bilibili"]);
   const [ttsProvider, setTtsProvider] = useState("openai-tts");
   const [voiceId, setVoiceId] = useState("");
+  const [noTts, setNoTts] = useState(false);
   const [submitting, setSubmitting] = useState(false);
   const [jobId, setJobId] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
@@ -119,6 +120,7 @@ export default function CreatePage() {
     try {
       const payload: any = {
         input: { template, platforms, ttsProvider, voiceId },
+        options: { noTts },
       };
       // Prefer already-collected (and possibly edited) text. Only fall back to
       // server-side collection if the textarea is still empty.
@@ -382,6 +384,46 @@ export default function CreatePage() {
         {step === "voice" && (
           <div>
             <div className="form-group">
+              <label
+                style={{
+                  display: "flex",
+                  alignItems: "center",
+                  gap: 8,
+                  cursor: "pointer",
+                  userSelect: "none",
+                }}
+              >
+                <input
+                  type="checkbox"
+                  checked={noTts}
+                  onChange={(e) => setNoTts(e.target.checked)}
+                  style={{ width: 16, height: 16 }}
+                />
+                Skip TTS (generate silent video)
+              </label>
+              {noTts && (
+                <p
+                  style={{
+                    fontSize: 13,
+                    color: "var(--text-muted)",
+                    marginTop: 8,
+                    marginLeft: 24,
+                  }}
+                >
+                  No narration will be synthesized. Each scene uses its{" "}
+                  <code>duration</code> (default 5s) and subtitles are spread
+                  evenly across the timeline.
+                </p>
+              )}
+            </div>
+
+            <div
+              className="form-group"
+              style={{
+                opacity: noTts ? 0.5 : 1,
+                pointerEvents: noTts ? "none" : "auto",
+              }}
+            >
               <label>TTS Provider</label>
               <select
                 className="form-select"
@@ -394,7 +436,13 @@ export default function CreatePage() {
                 <option value="elevenlabs">ElevenLabs</option>
               </select>
             </div>
-            <div className="form-group">
+            <div
+              className="form-group"
+              style={{
+                opacity: noTts ? 0.5 : 1,
+                pointerEvents: noTts ? "none" : "auto",
+              }}
+            >
               <label>Voice ID (optional)</label>
               <input
                 className="form-input"
@@ -428,7 +476,9 @@ export default function CreatePage() {
               </div>
               <div className="card">
                 <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
-                <div style={{ fontWeight: 600 }}>{ttsProvider}</div>
+                <div style={{ fontWeight: 600 }}>
+                  {noTts ? "Skipped (silent)" : ttsProvider}
+                </div>
               </div>
               <div className="card">
                 <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>

+ 5 - 4
packages/collect/src/collectors/github-daily.ts

@@ -1,6 +1,7 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { extractItems } from "../types.js";
 import { registerCollector } from "../registry.js";
+import { formatCount } from "@pipeline/shared";
 
 class GitHubDailyCollector implements DataSource {
   readonly name = "github-daily";
@@ -66,7 +67,7 @@ function formatTrendingSummary(repos: any[]): string {
   const lines: string[] = ["# GitHub Trending Today\n"];
   for (const repo of repos) {
     const name = repo.fullName || `${repo.author}/${repo.name}`;
-    lines.push(`- **${name}**: ${repo.description ?? ""} (${repo.language ?? ""}, ${repo.stars ?? "?"} stars)`);
+    lines.push(`- **${name}**: ${repo.description ?? ""} (${repo.language ?? ""}, ${formatCount(repo.stars)} stars)`);
   }
   return lines.join("\n");
 }
@@ -78,9 +79,9 @@ function formatRepo(data: any): string {
   if (data.description) lines.push(`\n${data.description}`);
   const meta: string[] = [];
   if (data.language) meta.push(`Language: ${data.language}`);
-  if (data.stars != null) meta.push(`Stars: ${data.stars}`);
-  else if (data.stargazers_count != null) meta.push(`Stars: ${data.stargazers_count}`);
-  if (data.forks != null) meta.push(`Forks: ${data.forks}`);
+  if (data.stars != null) meta.push(`Stars: ${formatCount(data.stars)}`);
+  else if (data.stargazers_count != null) meta.push(`Stars: ${formatCount(data.stargazers_count)}`);
+  if (data.forks != null) meta.push(`Forks: ${formatCount(data.forks)}`);
   if (data.topics?.length) meta.push(`Topics: ${data.topics.join(", ")}`);
   if (meta.length) lines.push(meta.map((m) => `- ${m}`).join("\n"));
 

+ 4 - 3
packages/collect/src/collectors/github-repo.ts

@@ -1,5 +1,6 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { registerCollector } from "../registry.js";
+import { formatCount } from "@pipeline/shared";
 
 class GitHubRepoCollector implements DataSource {
   readonly name = "github-repo";
@@ -36,9 +37,9 @@ class GitHubRepoCollector implements DataSource {
     const lines: string[] = [`# ${name}\n`];
     if (data.description) lines.push(`${data.description}\n`);
     if (data.language) lines.push(`- Language: ${data.language}`);
-    if (data.stars != null) lines.push(`- Stars: ${data.stars}`);
-    if (data.forks != null) lines.push(`- Forks: ${data.forks}`);
-    if (data.openIssues != null) lines.push(`- Open Issues: ${data.openIssues}`);
+    if (data.stars != null) lines.push(`- Stars: ${formatCount(data.stars)}`);
+    if (data.forks != null) lines.push(`- Forks: ${formatCount(data.forks)}`);
+    if (data.openIssues != null) lines.push(`- Open Issues: ${formatCount(data.openIssues)}`);
     if (data.topics?.length) lines.push(`- Topics: ${data.topics.join(", ")}`);
     lines.push("");
     if (data.readme) {

+ 3 - 2
packages/collect/src/collectors/github-trending.ts

@@ -1,6 +1,7 @@
 import type { DataSource, CollectResult, CollectParams } from "../types.js";
 import { extractItems } from "../types.js";
 import { registerCollector } from "../registry.js";
+import { formatCount } from "@pipeline/shared";
 
 class GitHubTrendingCollector implements DataSource {
   readonly name = "github-trending";
@@ -29,8 +30,8 @@ class GitHubTrendingCollector implements DataSource {
       lines.push(`## ${name}`);
       if (repo.description) lines.push(`${repo.description}`);
       if (repo.language) lines.push(`- Language: ${repo.language}`);
-      if (repo.stars != null) lines.push(`- Stars: ${repo.stars}`);
-      if (repo.currentPeriodStars != null) lines.push(`- Today: +${repo.currentPeriodStars}`);
+      if (repo.stars != null) lines.push(`- Stars: ${formatCount(repo.stars)}`);
+      if (repo.currentPeriodStars != null) lines.push(`- Today: +${formatCount(repo.currentPeriodStars)}`);
       if (repo.url) lines.push(`- URL: ${repo.url}`);
       lines.push("");
     }

+ 1 - 0
packages/core/src/pipeline.ts

@@ -79,6 +79,7 @@ export async function runPipeline(
     const parsed = await parseText(input.text, input.template, {
       llm: config.llm,
       skipLlm: config.skipLlm,
+      source: input.source,
     });
     job.parsed = parsed;
     callbacks?.onStageComplete?.("parse");

+ 43 - 3
packages/core/src/stages/parse.ts

@@ -4,6 +4,7 @@ import {
   detectInputFormat,
   LLMClient,
   getParsePrompt,
+  stripUnsupportedGlyphs,
 } from "@pipeline/shared";
 import type { ParsedContent, Scene, VideoInput } from "@pipeline/shared";
 
@@ -14,6 +15,42 @@ export interface ParseStageConfig {
     model: string;
   };
   skipLlm?: boolean;
+  source?: string;
+}
+
+/** Strip emoji/decorative glyphs from every visible-text field of a VideoInput. */
+function sanitizeVideoInput(input: VideoInput): VideoInput {
+  const cleanKeyframes = (kfs: typeof input.scenes[number]["keyframes"]) =>
+    Array.isArray(kfs)
+      ? kfs.map((kf) => ({ ...kf, content: stripUnsupportedGlyphs(kf.content ?? "") }))
+      : kfs;
+
+  return {
+    ...input,
+    title: stripUnsupportedGlyphs(input.title ?? ""),
+    subtitle: input.subtitle ? stripUnsupportedGlyphs(input.subtitle) : input.subtitle,
+    summary: input.summary ? stripUnsupportedGlyphs(input.summary) : input.summary,
+    cover: input.cover
+      ? { ...input.cover, keyframes: cleanKeyframes(input.cover.keyframes) }
+      : input.cover,
+    scenes: input.scenes.map((s) => ({
+      ...s,
+      title: s.title ? stripUnsupportedGlyphs(s.title) : s.title,
+      narration: stripUnsupportedGlyphs(s.narration ?? ""),
+      displayText: s.displayText ? stripUnsupportedGlyphs(s.displayText) : s.displayText,
+      keyframes: cleanKeyframes(s.keyframes),
+    })),
+    outro: input.outro
+      ? {
+          ...input.outro,
+          text: stripUnsupportedGlyphs(input.outro.text ?? ""),
+          narration: input.outro.narration
+            ? stripUnsupportedGlyphs(input.outro.narration)
+            : input.outro.narration,
+          cta: input.outro.cta ? stripUnsupportedGlyphs(input.outro.cta) : input.outro.cta,
+        }
+      : input.outro,
+  };
 }
 
 export async function parseText(
@@ -27,7 +64,7 @@ export async function parseText(
   let videoInput: VideoInput;
 
   if (detected.format === "valid-schema") {
-    videoInput = detected.parsed!;
+    videoInput = sanitizeVideoInput(detected.parsed!);
   } else if (
     config.skipLlm ||
     !(config.llm.apiKey || process.env.OPENAI_API_KEY)
@@ -38,7 +75,10 @@ export async function parseText(
     );
   } else {
     const client = new LLMClient(config.llm);
-    const systemPrompt = getParsePrompt(template as "news" | "knowledge" | "opinion" | "marketing");
+    const systemPrompt = getParsePrompt(
+      template as "news" | "knowledge" | "opinion" | "marketing",
+      config.source
+    );
     const rawResponse = await client.chat(systemPrompt, text);
 
     // Strip markdown code block wrapper if present (```json ... ```)
@@ -62,7 +102,7 @@ export async function parseText(
         `AI output does not match VideoInputSchema: ${validationResult.error.message}`
       );
     }
-    videoInput = validationResult.data;
+    videoInput = sanitizeVideoInput(validationResult.data);
   }
   // --- End input normalization ---
 

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

@@ -14,3 +14,4 @@ export type {
 } from "./constants.js";
 export { LLMClient, getParsePrompt, detectInputFormat } from "./llm/index.js";
 export type { LLMClientConfig, InputFormat, DetectResult } from "./llm/index.js";
+export { stripUnsupportedGlyphs, formatCount } from "./utils/index.js";

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

@@ -1,7 +1,8 @@
 import type { TemplateType } from "../../constants.js";
 
 export function getParsePrompt(
-  template: TemplateType
+  template: TemplateType,
+  source?: string
 ): string {
   const base = `You are a video script structuring assistant. Convert the input text into a JSON object for video generation.
 
@@ -44,7 +45,11 @@ Rules:
 - The first scene's narration may include a brief greeting (e.g. "大家好,"), but set displayText to the content without the greeting — the greeting should only be heard, not shown as main text
 - The last content scene should NOT include a conclusion or closing — outro handles that
 - outro.text should be a short, natural closing (10-30 characters)
-- outro.cta should encourage audience engagement`;
+- outro.cta should encourage audience engagement
+
+CRITICAL — Text content restrictions:
+- All visible-text fields (title, summary, narration, displayText, keyframes[].content, outro.text, outro.cta) must contain ONLY plain text characters: Chinese characters, English letters, Arabic numerals, standard Chinese/English punctuation (。,!?、;:""''()【】《》 .,!?:;"'()-/), and regular spaces/newlines.
+- NEVER use emoji (🚀💻🔥 etc.), dingbats (★✓✗), arrows (→←↑↓), or any decorative Unicode symbols. These render as missing-glyph boxes (tofu) in the video because the font only covers plain CJK + Latin text. If you feel tempted to add a symbol, use plain punctuation instead (e.g. write "->" or "—" instead of "→").`;
 
   const templateSpecific: Record<TemplateType, string> = {
     news: `
@@ -80,5 +85,76 @@ Template: Product Marketing (产品营销)
 - layoutHint preference: "split" for features, "centered" for highlights`,
   };
 
-  return base + "\n\n" + templateSpecific[template];
+  const githubProjectIntroRule = `
+Per-project scene structure (applies to EVERY GitHub repository you introduce):
+
+OVERLAP RULE (most important): the headline (displayText) and the content (keyframe cards + narration) must have MINIMAL semantic overlap. The headline stays short and abstract; the cards and narration carry the actual detail. Do NOT make the headline echo what the 功能简介 card already says.
+
+A. Project name + ultra-short category → displayText (the prominent on-screen headline)
+   Format: "{owner}/{repo}" OR "{owner}/{repo} — {极简类别,2-5 字}".
+   Examples (good): "facebook/react", "vercel/next.js — 应用框架", "openai/whisper — 语音识别", "oven-sh/bun — JS 运行时".
+   The category tag (if present) must be ≤ 5 Chinese characters and stay at the level of "what kind of thing" (UI 库 / 框架 / CLI 工具 / 运行时 / 数据库 / 编辑器 / AI 模型). NEVER expand it into a one-line positioning sentence like "声明式用户界面库" — that level of detail belongs in the 功能简介 card. The headline ALREADY carries the project name; do NOT repeat it inside keyframe cards.
+
+B. Keyframe cards (4 cards ideally, 3-4 acceptable). Pick content that ADDS value beyond the headline.
+   These 3 cards are MANDATORY for every project:
+   - 功能简介 (what it actually does): format "功能:{2-4 句话具体说明这个工具能做什么、解决什么问题、有什么核心能力}". Example: "功能:声明式组件化 UI 构建方式,状态变化自动驱动视图更新,虚拟 DOM 优化渲染性能,适合处理复杂交互的 Web 应用". This must be DENSE and SPECIFIC — describe the actual capability, the problem it solves, and 1-2 distinguishing mechanisms. Do NOT parrot the headline category (e.g. if the headline already says "UI 库", this card must NOT just say "是一个 UI 库" — explain HOW it works and what makes it different).
+   - GitHub 地址: format "github.com/{owner}/{repo}". Display-only — narration should NOT spell out the URL, just refer to the project by name.
+   - 使用场景: format "使用场景:{目标用户 / 典型场景 / 不适用场景可选}". Example: "使用场景:中大型 Web 应用、组件化 UI 开发;不适合简单静态页面".
+
+   Then ADD 1-2 of the following cards to enrich the scene (do not leave the scene with only the 3 mandatory cards unless the project truly has no extra distinctive information):
+   - 核心特性 / 关键卖点: e.g. "特性:虚拟 DOM、单向数据流、Hooks"
+   - 数据信号: e.g. "Stars: 12.3k · 今日 +120" (only stars / growth — NEVER include programming language)
+   - 快速上手: e.g. "安装:npm i react react-dom"
+   - 知名采用: e.g. "采用:Facebook、Netflix、Airbnb"
+   - 生态地位: e.g. "生态:最主流的前端框架之一"
+   - 对比优势: e.g. "对比:相比 Vue,生态更成熟;相比 Angular,学习曲线更平缓"
+
+   FORBIDDEN card content — these add no value for a developer audience, NEVER put them on a card:
+   - Programming language (语言 / Language) as its own information item — e.g. "语言:TypeScript" is forbidden. The language only matters if it IS the value proposition (e.g. "Rust 写的高性能 HTTP 服务器"), in which case it belongs inside 功能简介 or narration, not on its own card.
+   - 技术栈 (tech stack) as a generic label — too vague to be useful.
+   - 协议 / License, repo size, file count, fork count on its own — these are visible on GitHub and waste a card slot.
+   - Any card that just restates the displayText category (e.g. headline "UI 库" + card "类型:UI 库") — that is pure overlap and wastes a card slot.
+
+   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 同类快数倍").
+   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 (口播详细描述).
+
+For multi-project sources, EACH project scene follows this same structure independently — do not mix two projects into one scene.`;
+
+  const sourceSpecific: Record<string, string> = {
+    "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).
+- 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.
+${githubProjectIntroRule}`,
+
+    "github-repo": `
+Data source: Single GitHub Repository (单个仓库详情)
+- The input is one repository's metadata + README excerpt. Go deep on this single project.
+- First scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration) — this is the project's introduction scene.
+- Subsequent scenes (1-2 more) can elaborate: distinctive features pulled from the README, technical highlights, ecosystem signals (contributors, recent activity). They do NOT need to repeat the GitHub 地址 card, the 功能简介 card, or restate the headline.
+- Avoid enumerating every README section. Pick the 2-3 most distinctive aspects for the deeper scenes.
+- Tone: focused technical introduction, like a colleague recommending a tool.
+${githubProjectIntroRule}`,
+
+    "github-daily": `
+Data source: GitHub Daily (trending + 多仓库详情组合)
+- The input combines today's trending list with detailed info on selected repos.
+- Suggested structure: 1-2 opening scenes surveying today's overall trend theme (what topics dominate, what's heating up) — these overview scenes do NOT need the per-project structure. Then 1 scene per notable repo, and EVERY repo scene MUST follow the per-project scene structure below (headline + mandatory 功能简介 / GitHub 地址 / 使用场景 cards + optional value-add card + detailed narration).
+- Total 4-7 scenes is the sweet spot. If trending has many repos, group similar ones or pick only the top 3-4 to dive into.
+${githubProjectIntroRule}`,
+  };
+
+  const parts = [base, templateSpecific[template]];
+  if (source && sourceSpecific[source]) {
+    parts.push(sourceSpecific[source]);
+  }
+  return parts.join("\n\n");
 }

+ 1 - 0
packages/shared/src/types/pipeline.ts

@@ -14,6 +14,7 @@ export const PipelineInputSchema = z.object({
   ttsProvider: z.enum(["fish-audio", "minimax", "elevenlabs", "openai-tts"]),
   voiceId: z.string().optional(),
   outputPath: z.string().optional(),
+  source: z.string().optional(),
 });
 
 export type PipelineInput = z.infer<typeof PipelineInputSchema>;

+ 26 - 0
packages/shared/src/utils/format.ts

@@ -0,0 +1,26 @@
+/**
+ * Format large counts using a lowercase k suffix (never m): 1.2k, 12.3k, 123k, 1235k.
+ *
+ * - < 1000              → plain integer ("843")
+ * - 1,000 – 9,999       → 1 decimal, trailing .0 stripped ("1.2k", "9.9k", "1k")
+ * - 10,000 – 99,999     → 1 decimal ("12.3k")
+ * - >= 100,000          → rounded to integer k ("123k", "1235k")
+ *
+ * Per product decision: we do NOT roll up to "m" — counts stay in k indefinitely,
+ * so very large numbers (1M+) render as e.g. "1235k" rather than "1.2m".
+ */
+export function formatCount(n: number | null | undefined): string {
+  if (n == null || Number.isNaN(n)) return "?";
+  const abs = Math.abs(n);
+
+  if (abs >= 1_000) {
+    const v = n / 1_000;
+    if (v >= 100) return Math.round(v) + "k";
+    return trimDecimal(v) + "k";
+  }
+  return String(n);
+}
+
+function trimDecimal(v: number): string {
+  return v.toFixed(1).replace(/\.0$/, "");
+}

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

@@ -0,0 +1,2 @@
+export { stripUnsupportedGlyphs } from "./sanitize-text.js";
+export { formatCount } from "./format.js";

+ 21 - 0
packages/shared/src/utils/sanitize-text.ts

@@ -0,0 +1,21 @@
+const UNSUPPORTED_GLYPH_PATTERN = new RegExp(
+  [
+    "\\p{Extended_Pictographic}",
+    "\\p{Emoji_Modifier}",
+    "[\\u{FE00}-\\u{FE0F}]",
+    "\\u{200D}",
+    "\\u{20E3}",
+    "[\\u{1F1E6}-\\u{1F1FF}]",
+    "[\\u{E0020}-\\u{E007F}]",
+    "[\\u{2600}-\\u{27BF}]",
+    "[\\u{2B00}-\\u{2BFF}]",
+    "[\\u{2190}-\\u{21FF}]",
+    "[\\u{1F000}-\\u{1FAFF}]",
+  ].join("|"),
+  "gu"
+);
+
+export function stripUnsupportedGlyphs(text: string): string {
+  if (typeof text !== "string" || text.length === 0) return text;
+  return text.replace(UNSUPPORTED_GLYPH_PATTERN, "");
+}