|
@@ -30,8 +30,14 @@ function formatTodayChineseDate(): string {
|
|
|
* validation. Clips at a sentence/clause boundary when possible (see
|
|
* validation. Clips at a sentence/clause boundary when possible (see
|
|
|
* clipToLength) so the result still reads naturally. Mutates a copy and
|
|
* clipToLength) so the result still reads naturally. Mutates a copy and
|
|
|
* returns it; the input is left untouched.
|
|
* 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.
|
|
|
*/
|
|
*/
|
|
|
-function applyLengthLimits(input: unknown): unknown { if (!input || typeof input !== "object") return input;
|
|
|
|
|
|
|
+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)
|
|
const root = (input as any).scenes && Array.isArray((input as any).scenes)
|
|
|
? { ...(input as any) }
|
|
? { ...(input as any) }
|
|
|
: input;
|
|
: input;
|
|
@@ -40,12 +46,15 @@ function applyLengthLimits(input: unknown): unknown { if (!input || typeof inpu
|
|
|
(root as any).scenes = (root as any).scenes.map((scene: any) => {
|
|
(root as any).scenes = (root as any).scenes.map((scene: any) => {
|
|
|
if (!scene || typeof scene !== "object") return scene;
|
|
if (!scene || typeof scene !== "object") return scene;
|
|
|
const next: any = { ...scene };
|
|
const next: any = { ...scene };
|
|
|
- next.narration = clipToLength(scene.narration, 200);
|
|
|
|
|
|
|
+ const narration = template === "github-trending"
|
|
|
|
|
+ ? stripLeadingGreeting(scene.narration)
|
|
|
|
|
+ : scene.narration;
|
|
|
|
|
+ next.narration = clipToLength(narration, 200);
|
|
|
if (scene.github && typeof scene.github === "object") {
|
|
if (scene.github && typeof scene.github === "object") {
|
|
|
next.github = {
|
|
next.github = {
|
|
|
...scene.github,
|
|
...scene.github,
|
|
|
highlights: clipToLength(scene.github.highlights, 30),
|
|
highlights: clipToLength(scene.github.highlights, 30),
|
|
|
- intro: clipToLength(scene.github.intro, 300),
|
|
|
|
|
|
|
+ intro: clipToLength(scene.github.intro, 200),
|
|
|
review: clipToLength(scene.github.review, 30),
|
|
review: clipToLength(scene.github.review, 30),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
@@ -54,6 +63,49 @@ function applyLengthLimits(input: unknown): unknown { if (!input || typeof inpu
|
|
|
return root;
|
|
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. */
|
|
/** Strip emoji/decorative glyphs from every visible-text field of a VideoInput. */
|
|
|
function sanitizeVideoInput(input: VideoInput): VideoInput {
|
|
function sanitizeVideoInput(input: VideoInput): VideoInput {
|
|
|
const cleanKeyframes = (kfs: typeof input.scenes[number]["keyframes"]) =>
|
|
const cleanKeyframes = (kfs: typeof input.scenes[number]["keyframes"]) =>
|
|
@@ -135,7 +187,7 @@ export async function parseText(
|
|
|
// LLMs routinely overshoot the documented character budgets (narration
|
|
// 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/60/30). Clip in place before
|
|
|
// schema validation so the pipeline tolerates drift instead of hard-failing.
|
|
// schema validation so the pipeline tolerates drift instead of hard-failing.
|
|
|
- aiParsed = applyLengthLimits(aiParsed);
|
|
|
|
|
|
|
+ aiParsed = applyLengthLimits(aiParsed, template);
|
|
|
|
|
|
|
|
const validationResult = VideoInputSchema.safeParse(aiParsed);
|
|
const validationResult = VideoInputSchema.safeParse(aiParsed);
|
|
|
if (!validationResult.success) {
|
|
if (!validationResult.success) {
|
|
@@ -186,11 +238,18 @@ export async function parseText(
|
|
|
...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
|
|
...(coverInput.imageQuery ? [{ query: coverInput.imageQuery }] : []),
|
|
|
]
|
|
]
|
|
|
: (firstSceneCoverImage ? [firstSceneCoverImage] : []);
|
|
: (firstSceneCoverImage ? [firstSceneCoverImage] : []);
|
|
|
|
|
+ // For github-trending the cover doubles as the opening narration screen —
|
|
|
|
|
+ // give it a fixed opening line (with today's date) so the TTS stage produces
|
|
|
|
|
+ // audio and the compose stage sets the cover duration to match. Other
|
|
|
|
|
+ // templates keep the original behaviour (1s silent cover).
|
|
|
|
|
+ const coverNarration = template === "github-trending"
|
|
|
|
|
+ ? `大家好,今天是${formatTodayChineseDate()},欢迎收看 GitHub 每日热榜。今天为大家精选了几个值得关注的优质开源项目,让我们一探究竟。`
|
|
|
|
|
+ : "";
|
|
|
scenes.push({
|
|
scenes.push({
|
|
|
id: "cover",
|
|
id: "cover",
|
|
|
index: sceneIndex++,
|
|
index: sceneIndex++,
|
|
|
sceneType: "cover",
|
|
sceneType: "cover",
|
|
|
- narration: "",
|
|
|
|
|
|
|
+ narration: coverNarration,
|
|
|
title: videoInput.title,
|
|
title: videoInput.title,
|
|
|
keyframes: coverKeyframes,
|
|
keyframes: coverKeyframes,
|
|
|
images: coverImages,
|
|
images: coverImages,
|