|
|
@@ -1,393 +0,0 @@
|
|
|
-import {
|
|
|
- VideoInputSchema,
|
|
|
- ParsedContentSchema,
|
|
|
- detectInputFormat,
|
|
|
- LLMClient,
|
|
|
- getParsePrompt,
|
|
|
- stripUnsupportedGlyphs,
|
|
|
- clipToLength,
|
|
|
- formatChineseDate,
|
|
|
- normalizeCountsForTTS,
|
|
|
-} from "@pipeline/shared";
|
|
|
-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;
|
|
|
- apiKey?: string;
|
|
|
- model: string;
|
|
|
- };
|
|
|
- skipLlm?: boolean;
|
|
|
- source?: string;
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * Clip LLM-generated text fields to their schema-enforced maximums before
|
|
|
- * validation. Clips at a sentence/clause boundary when possible (see
|
|
|
- * clipToLength) so the result still reads naturally. Mutates a copy and
|
|
|
- * 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 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;
|
|
|
- const root = (input as any).scenes && Array.isArray((input as any).scenes)
|
|
|
- ? { ...(input as any) }
|
|
|
- : input;
|
|
|
- if (!Array.isArray((root as any).scenes)) return input;
|
|
|
-
|
|
|
- (root as any).scenes = (root as any).scenes.map((scene: any) => {
|
|
|
- if (!scene || typeof scene !== "object") return scene;
|
|
|
- const next: any = { ...scene };
|
|
|
- const narration = template === "github-trending"
|
|
|
- ? stripLeadingGreeting(scene.narration)
|
|
|
- : scene.narration;
|
|
|
- next.narration = clipToLength(narration, 200);
|
|
|
- if (scene.github && typeof scene.github === "object") {
|
|
|
- next.github = {
|
|
|
- ...scene.github,
|
|
|
- highlights: clipToLength(scene.github.highlights, 30),
|
|
|
- intro: clipToLength(scene.github.intro, 200),
|
|
|
- review: clipToLength(scene.github.review, 30),
|
|
|
- };
|
|
|
- }
|
|
|
- return next;
|
|
|
- });
|
|
|
-
|
|
|
- // github-trending: clip the cover-scene metadata the LLM emits at the top
|
|
|
- // level (coverTags ≤5, each ≤12 chars; trendSummary ≤40). Same drift
|
|
|
- // tolerance as the per-scene clipping above.
|
|
|
- if (template === "github-trending") {
|
|
|
- if (Array.isArray((root as any).coverTags)) {
|
|
|
- (root as any).coverTags = (root as any).coverTags
|
|
|
- .filter((t: any) => typeof t === "string" && t.trim())
|
|
|
- .map((t: any) => t.trim().slice(0, 12))
|
|
|
- .slice(0, 5);
|
|
|
- }
|
|
|
- if (typeof (root as any).trendSummary === "string") {
|
|
|
- (root as any).trendSummary = clipToLength((root as any).trendSummary, 40);
|
|
|
- }
|
|
|
- // Clip the publish sidecar metadata too (description ≤200; ≤8 tags, each
|
|
|
- // ≤20) so an over-budget LLM response still validates instead of failing.
|
|
|
- if ((root as any).publish && typeof (root as any).publish === "object") {
|
|
|
- const pub: any = { ...(root as any).publish };
|
|
|
- if (typeof pub.description === "string") {
|
|
|
- pub.description = clipToLength(pub.description, 200);
|
|
|
- }
|
|
|
- if (Array.isArray(pub.tags)) {
|
|
|
- pub.tags = pub.tags
|
|
|
- .filter((t: any) => typeof t === "string" && t.trim())
|
|
|
- .map((t: any) => clipToLength(t.trim(), 20))
|
|
|
- .slice(0, 8);
|
|
|
- }
|
|
|
- (root as any).publish = pub;
|
|
|
- }
|
|
|
- }
|
|
|
- return root;
|
|
|
-}
|
|
|
-
|
|
|
-/** Common Chinese greeting prefixes that open a narration. Used to dedup
|
|
|
- * greetings when the cover scene already provides one. */
|
|
|
-const GREETING_PATTERNS = [
|
|
|
- /^[大各]位(?:好|大大|朋友们)?[,,!!\s]+/,
|
|
|
- /^大家(?:好|朋友们)?[,,!!\s]+/,
|
|
|
- /^哈喽[,,!!\s]+/,
|
|
|
- /^嗨[,,!!\s]+/,
|
|
|
- /^早(?:上)?好[,,!!\s]+/,
|
|
|
- /^下(?:午)?好[,,!!\s]+/,
|
|
|
- /^晚(?:上)?好[,,!!\s]+/,
|
|
|
-];
|
|
|
-
|
|
|
-/** Show-opener clauses that reference the day / show / format instead of the
|
|
|
- * project. e.g. "今天是GitHub热榜速览,..." or "今天为大家带来..." — these
|
|
|
- * duplicate the cover scene's framing and must be stripped from content
|
|
|
- * narrations. Each pattern removes one comma-delimited leading clause. */
|
|
|
-const META_OPENER_PATTERNS = [
|
|
|
- // 今天 / 本期 / 本周 / 本次 + a show-context noun + clause boundary
|
|
|
- /^(?:今天|本(?:周|期|次))[^,,。!!.]*?(?:热榜|速览|榜单|节目|频道|播报|速递|盘点|精选|专栏|特辑)[^,,。!!.]*?[,,。!!.]\s*/,
|
|
|
- // "今天[为给]大家[带介推带聊]..." — host-style lead-in to a list of items
|
|
|
- /^今天[为给][^,,。!!.]*?[,,。!!.]\s*/,
|
|
|
-];
|
|
|
-
|
|
|
-function stripLeadingGreeting(s: string | undefined): string | undefined {
|
|
|
- if (typeof s !== "string" || s.length === 0) return s;
|
|
|
- let out = s;
|
|
|
- // Strip simple greetings first (may reveal a meta-opener that follows).
|
|
|
- for (const re of GREETING_PATTERNS) {
|
|
|
- out = out.replace(re, "");
|
|
|
- }
|
|
|
- // Then strip show-opener clauses. Run a few passes so back-to-back
|
|
|
- // meta clauses ("今天为大家带来几个项目,首先要聊的是 React") collapse fully.
|
|
|
- for (let i = 0; i < 3; i++) {
|
|
|
- let next = out;
|
|
|
- for (const re of META_OPENER_PATTERNS) {
|
|
|
- next = next.replace(re, "");
|
|
|
- }
|
|
|
- if (next === out) break;
|
|
|
- out = next;
|
|
|
- }
|
|
|
- return out;
|
|
|
-}
|
|
|
-
|
|
|
-/** 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,
|
|
|
- coverTags: Array.isArray(input.coverTags)
|
|
|
- ? input.coverTags.map((t) => stripUnsupportedGlyphs(t ?? ""))
|
|
|
- : input.coverTags,
|
|
|
- trendSummary: input.trendSummary ? stripUnsupportedGlyphs(input.trendSummary) : input.trendSummary,
|
|
|
- publish: input.publish
|
|
|
- ? {
|
|
|
- ...input.publish,
|
|
|
- title: stripUnsupportedGlyphs(input.publish.title ?? ""),
|
|
|
- description: stripUnsupportedGlyphs(input.publish.description ?? ""),
|
|
|
- tags: Array.isArray(input.publish.tags)
|
|
|
- ? input.publish.tags.map((t) => stripUnsupportedGlyphs(t ?? ""))
|
|
|
- : input.publish.tags,
|
|
|
- }
|
|
|
- : input.publish,
|
|
|
- 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(
|
|
|
- text: string,
|
|
|
- template: string,
|
|
|
- config: ParseStageConfig
|
|
|
-): Promise<ParsedContent> {
|
|
|
- // --- Input normalization ---
|
|
|
- const detected = detectInputFormat(text);
|
|
|
-
|
|
|
- let videoInput: VideoInput;
|
|
|
-
|
|
|
- if (detected.format === "valid-schema") {
|
|
|
- videoInput = sanitizeVideoInput(detected.parsed!);
|
|
|
- } else if (
|
|
|
- config.skipLlm ||
|
|
|
- !(config.llm.apiKey || process.env.OPENAI_API_KEY)
|
|
|
- ) {
|
|
|
- throw new Error(
|
|
|
- `Input is not valid VideoInputSchema JSON and AI processing is disabled. ` +
|
|
|
- `Provide structured JSON matching VideoInputSchema or enable LLM processing (set OPENAI_API_KEY or remove --skip-llm).`
|
|
|
- );
|
|
|
- } else {
|
|
|
- const client = new LLMClient(config.llm);
|
|
|
- const systemPrompt = getParsePrompt(
|
|
|
- template as "news" | "knowledge" | "opinion" | "marketing" | "github-trending",
|
|
|
- config.source
|
|
|
- );
|
|
|
-
|
|
|
- // 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;
|
|
|
- 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 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/200/30). Clip in place before
|
|
|
- // schema validation so the pipeline tolerates drift instead of hard-failing.
|
|
|
- aiParsed = applyLengthLimits(aiParsed, template);
|
|
|
-
|
|
|
- const validationResult = VideoInputSchema.safeParse(aiParsed);
|
|
|
- if (!validationResult.success) {
|
|
|
- throw new Error(
|
|
|
- `AI output does not match VideoInputSchema: ${validationResult.error.message}`
|
|
|
- );
|
|
|
- }
|
|
|
- videoInput = sanitizeVideoInput(validationResult.data);
|
|
|
- }
|
|
|
- // --- End input normalization ---
|
|
|
-
|
|
|
- const scenes: Scene[] = [];
|
|
|
- let sceneIndex = 0;
|
|
|
-
|
|
|
- // For github-trending the cover is a fixed masthead — title is the channel
|
|
|
- // name, subtitle is today's date in zh-CN. Override whatever the LLM produced
|
|
|
- // so the cover stays deterministic regardless of input.
|
|
|
- if (template === "github-trending") {
|
|
|
- videoInput = {
|
|
|
- ...videoInput,
|
|
|
- title: "GitHub 每日热榜",
|
|
|
- subtitle: formatChineseDate(new Date(), getTimezone()),
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- // Cover scene — always present. When the user/LLM provided explicit cover
|
|
|
- // content, use it. Otherwise derive the cover from the first content scene
|
|
|
- // (its keyframes as a preview list, its first image as the background) so
|
|
|
- // the cover reflects what the video is actually about. Default 1s so it
|
|
|
- // flashes briefly without pushing back the real content.
|
|
|
- const coverInput = videoInput.cover;
|
|
|
- const firstScene = videoInput.scenes[0];
|
|
|
- // For github-trending the cover is a clean title screen — it should not
|
|
|
- // preview or hint at later repo scenes (no inherited keyframes, no inherited
|
|
|
- // image). Other templates keep the original "inherit from first scene"
|
|
|
- // behaviour so the cover reflects the video's actual topic.
|
|
|
- const inheritFromFirstScene = template !== "github-trending";
|
|
|
- const coverKeyframes = inheritFromFirstScene
|
|
|
- ? (coverInput?.keyframes ?? (firstScene?.keyframes ?? []).slice(0, 3))
|
|
|
- : (coverInput?.keyframes ?? []);
|
|
|
- const firstSceneCoverImage = inheritFromFirstScene
|
|
|
- ? (firstScene?.images ?? []).find((img) => img.path || img.url || img.query)
|
|
|
- : undefined;
|
|
|
- const coverImages = coverInput
|
|
|
- ? [
|
|
|
- ...(coverInput.imagePath ? [{ path: coverInput.imagePath }] : []),
|
|
|
- ...(coverInput.imageUrl ? [{ url: coverInput.imageUrl }] : []),
|
|
|
- ...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
|
|
|
- ]
|
|
|
- : (firstSceneCoverImage ? [firstSceneCoverImage] : []);
|
|
|
- // 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;
|
|
|
- // 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",
|
|
|
- index: sceneIndex++,
|
|
|
- sceneType: "cover",
|
|
|
- narration: coverNarration,
|
|
|
- title: videoInput.title,
|
|
|
- keyframes: coverKeyframes,
|
|
|
- images: coverImages,
|
|
|
- backgroundQuery: coverInput?.imageQuery,
|
|
|
- trendSummary: template === "github-trending" ? videoInput.trendSummary : undefined,
|
|
|
- duration: 1,
|
|
|
- });
|
|
|
-
|
|
|
- // Content scenes
|
|
|
- for (const s of contentInputs) {
|
|
|
- scenes.push({
|
|
|
- id: s.id,
|
|
|
- index: sceneIndex++,
|
|
|
- sceneType: "content",
|
|
|
- narration: s.narration,
|
|
|
- displayText: s.displayText,
|
|
|
- title: s.title,
|
|
|
- keyframes: s.keyframes ?? [],
|
|
|
- images: s.images,
|
|
|
- duration: s.duration,
|
|
|
- speed: s.speed,
|
|
|
- layoutHint: s.layoutHint,
|
|
|
- github: s.github,
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // Outro scene (optional)
|
|
|
- if (videoInput.outro) {
|
|
|
- scenes.push({
|
|
|
- id: "outro",
|
|
|
- index: sceneIndex++,
|
|
|
- sceneType: "outro",
|
|
|
- narration: videoInput.outro.narration || videoInput.outro.text,
|
|
|
- title: videoInput.outro.cta,
|
|
|
- keyframes: [],
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- // 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,
|
|
|
- summary: videoInput.summary || "",
|
|
|
- cover: videoInput.cover,
|
|
|
- scenes,
|
|
|
- outro: videoInput.outro,
|
|
|
- globalStyle: videoInput.globalStyle || { tone: "formal", pace: "normal" },
|
|
|
- publish: videoInput.publish,
|
|
|
- };
|
|
|
-
|
|
|
- return ParsedContentSchema.parse(result);
|
|
|
-}
|