Bläddra i källkod

增加频道名称定制;增加片尾;问候语文字不展示

lkatzey 1 månad sedan
förälder
incheckning
cd70287e07

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

@@ -37,6 +37,7 @@ export const renderCommand = new Command("render")
   .option("--tts-speed <speed>", "TTS speech speed (0.5-2.0)", parseFloat)
   .option("--tts-format <format>", "TTS audio format (mp3|wav|pcm)")
   .option("--no-tts", "Skip TTS (generate silent video)")
+  .option("--channel-name <name>", "Channel name displayed in video watermark")
 
   // Alignment
   .option("--alignment <mode>", "Timestamp alignment: whisper|native")
@@ -109,6 +110,9 @@ export const renderCommand = new Command("render")
     if (noTts) flags.push("no-tts");
 
     const pipelineConfig: PipelineConfig = {
+      branding: {
+        channelName: opts.channelName || config?.branding?.channelName || "Pipeline",
+      },
       llm: {
         baseURL: opts.llmBaseUrl || config?.llm?.baseURL,
         apiKey: opts.llmApiKey || config?.llm?.apiKey,

+ 3 - 0
config/default.yaml

@@ -2,6 +2,9 @@
 #
 # Priority: CLI flags > this file > .env (for secrets/endpoints)
 
+branding:
+  channelName: "知势·科技洞察"
+
 llm:
   # baseURL: set via OPENAI_BASE_URL in .env
   # apiKey: set via OPENAI_API_KEY in .env

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

@@ -15,6 +15,9 @@ import { renderVideo } from "./stages/render.js";
 import { exportVideo } from "./stages/export.js";
 
 export interface PipelineConfig {
+  branding: {
+    channelName: string;
+  };
   llm: {
     baseURL?: string;
     apiKey?: string;
@@ -109,7 +112,7 @@ export async function runPipeline(
 
       // Stage 4: Compose
       callbacks?.onStageStart?.(`compose:${platform}`);
-      const composed = composeProject(parsed, tts, assets, platform, input.template);
+      const composed = composeProject(parsed, tts, assets, platform, input.template, config.branding.channelName);
       job.composed[platform] = composed;
       callbacks?.onStageComplete?.(`compose:${platform}`);
 

+ 4 - 1
packages/core/src/stages/compose.ts

@@ -14,7 +14,8 @@ export function composeProject(
   tts: TTSStageResult,
   assets: AssetManifest,
   platform: PlatformPreset,
-  template: TemplateType
+  template: TemplateType,
+  channelName: string = "Pipeline"
 ): ComposedProject {
   const preset = PLATFORM_PRESETS[platform];
   const fps = preset.fps;
@@ -38,6 +39,7 @@ export function composeProject(
       startFrame,
       endFrame,
       narration: scene.narration,
+      displayText: scene.displayText,
       title: scene.title,
       wordTimestamps,
       audioPath: ttsScene?.audioFilePath || undefined,
@@ -81,5 +83,6 @@ export function composeProject(
     title: parsed.title,
     subtitle: parsed.subtitle,
     outro: parsed.outro,
+    channelName,
   };
 }

+ 1 - 0
packages/core/src/stages/parse.ts

@@ -100,6 +100,7 @@ export async function parseText(
       index: sceneIndex++,
       sceneType: "content",
       narration: s.narration,
+      displayText: s.displayText,
       title: s.title,
       keyframes: s.keyframes ?? [],
       images: s.images,

+ 1 - 0
packages/core/src/stages/render.ts

@@ -56,6 +56,7 @@ function buildInputProps(project: ComposedProject) {
     title: project.title || "",
     subtitle: project.subtitle || "",
     outro: project.outro,
+    channelName: project.channelName,
     globalStyle: { tone: "formal", pace: "normal" },
   };
 }

+ 16 - 7
packages/shared/src/llm/prompts/parse-text.ts

@@ -14,6 +14,7 @@ The output JSON must follow this exact schema:
       "id": "scene-N",
       "index": 0,
       "narration": "string — the voiceover text for this scene (Chinese, natural and conversational)",
+      "displayText": "string — optional, the text to display on screen (omit if same as narration; use when narration includes a greeting that should not appear visually)",
       "keyframes": [
         { "type": "text|highlight|image|icon|transition", "content": "string" }
       ],
@@ -22,6 +23,11 @@ The output JSON must follow this exact schema:
       "layoutHint": "full-text|split|centered|bullet-list"
     }
   ],
+  "outro": {
+    "text": "string — closing message (e.g. 感谢观看, 今天的内容就到这里)",
+    "narration": "string — voiceover for outro (optional, defaults to text)",
+    "cta": "string — call-to-action (e.g. 点赞关注, 订阅获取更多)"
+  },
   "globalStyle": {
     "tone": "formal|casual|energetic|calm",
     "pace": "slow|normal|fast",
@@ -35,15 +41,18 @@ Rules:
 - narration should be natural spoken Chinese, suitable for TTS
 - Split the content into 3-8 scenes depending on length
 - keyframes should highlight the main visual points
-- The first scene should be an introduction, the last should be a conclusion/call-to-action`;
+- 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`;
 
   const templateSpecific: Record<TemplateType, string> = {
     news: `
 Template: News Report (资讯报道)
 - Use a formal, authoritative tone
-- First scene: headline + breaking news intro
+- First scene: headline + key fact intro
 - Middle scenes: key facts, data points, quotes from sources
-- Last scene: summary + implications
+- Last scene: implications or future outlook (do NOT summarize or conclude — outro handles closing)
 - layoutHint preference: "split" for data, "full-text" for quotes`,
 
     knowledge: `
@@ -51,7 +60,7 @@ Template: Knowledge Explanation (知识讲解)
 - Use a calm, educational tone
 - First scene: topic introduction + why it matters
 - Middle scenes: step-by-step explanation with clear logic
-- Last scene: key takeaways + further reading suggestion
+- Last scene: final insight or extension (do NOT summarize — outro handles closing)
 - layoutHint preference: "centered" for key concepts, "bullet-list" for steps`,
 
     opinion: `
@@ -59,7 +68,7 @@ Template: Opinion Sharing (观点分享)
 - Use a casual, conversational tone
 - First scene: hook + personal stance
 - Middle scenes: arguments, examples, counterpoints
-- Last scene: conclusion + call for discussion
+- Last scene: final thought or reflection (do NOT conclude — outro handles closing)
 - layoutHint preference: "centered" for quotes, "full-text" for arguments`,
 
     marketing: `
@@ -67,8 +76,8 @@ Template: Product Marketing (产品营销)
 - Use an energetic, persuasive tone
 - First scene: pain point / hook
 - Middle scenes: product features, benefits, social proof
-- Last scene: CTA (call-to-action) + pricing/offer
-- layoutHint preference: "split" for features, "centered" for CTA`,
+- Last scene: strongest benefit or proof (do NOT add CTA — outro handles CTA)
+- layoutHint preference: "split" for features, "centered" for highlights`,
   };
 
   return base + "\n\n" + templateSpecific[template];

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

@@ -60,6 +60,7 @@ export const ComposedSceneSchema = z.object({
   startFrame: z.number(),
   endFrame: z.number(),
   narration: z.string(),
+  displayText: z.string().optional(),
   title: z.string().optional(),
   wordTimestamps: z.array(WordTimestampSchema),
   audioPath: z.string().optional(),
@@ -95,6 +96,7 @@ export const ComposedProjectSchema = z.object({
       cta: z.string().optional(),
     })
     .optional(),
+  channelName: z.string().default("Pipeline"),
 });
 
 export type ComposedProject = z.infer<typeof ComposedProjectSchema>;

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

@@ -61,6 +61,7 @@ export const InputSceneSchema = z.object({
   id: z.string(),
   title: z.string().optional(),
   narration: z.string().max(200, "Narration too long — split into more scenes"),
+  displayText: z.string().optional(),
   keyframes: z.array(KeyframeSchema).optional(),
   images: z.array(SceneImageSchema).optional(),
   duration: z.number().optional(),
@@ -93,6 +94,7 @@ export const SceneSchema = z.object({
   index: z.number(),
   sceneType: z.enum(["cover", "content", "outro"]).default("content"),
   narration: z.string().max(200),
+  displayText: z.string().optional(),
   title: z.string().optional(),
   keyframes: z.array(KeyframeSchema),
   images: z.array(SceneImageSchema).optional(),

+ 4 - 0
packages/templates/src/Root.tsx

@@ -14,6 +14,7 @@ export interface RemotionScene {
   startFrame: number;
   endFrame: number;
   narration: string;
+  displayText?: string;
   title?: string;
   wordTimestamps: WordTimestamp[];
   keyframes: Array<{
@@ -40,6 +41,7 @@ export interface RemotionProps {
     narration?: string;
     cta?: string;
   };
+  channelName: string;
   globalStyle: {
     tone: string;
     pace: string;
@@ -124,6 +126,7 @@ const TemplateComposition: React.FC<RemotionProps> = (props) => {
               totalScenes={props.scenes.filter((s) => s.sceneType === "content").length}
               totalFrames={totalFrames}
               title={props.title}
+              channelName={props.channelName}
             />
           </Sequence>
         );
@@ -306,6 +309,7 @@ const defaultProps: RemotionProps = {
   title: "Pipeline Demo",
   subtitle: "结构化视频生成",
   outro: { text: "感谢观看", cta: "点赞 | 关注" },
+  channelName: "Pipeline",
   globalStyle: { tone: "formal", pace: "normal" },
 };
 

+ 5 - 2
packages/templates/src/knowledge/index.tsx

@@ -21,6 +21,7 @@ interface KnowledgeSceneProps {
   totalScenes: number;
   totalFrames: number;
   title: string;
+  channelName?: string;
 }
 
 const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
@@ -29,9 +30,11 @@ const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
   totalScenes,
   totalFrames,
   title,
+  channelName,
 }) => {
   const frame = useCurrentFrame();
   const colors = THEMES.knowledge;
+  const visualText = scene.displayText ?? scene.narration;
 
   const fadeOpacity = interpolate(
     frame,
@@ -93,7 +96,7 @@ const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
         }}
       >
         <AnimatedText
-          text={scene.narration}
+          text={visualText}
           fontSize={44}
           color="white"
           fontWeight={600}
@@ -114,7 +117,7 @@ const KnowledgeScene: React.FC<KnowledgeSceneProps> = ({
         <SubtitleBar wordTimestamps={scene.wordTimestamps} />
       )}
 
-      <Watermark />
+      <Watermark text={channelName} />
     </AbsoluteFill>
   );
 };

+ 6 - 3
packages/templates/src/marketing/index.tsx

@@ -21,6 +21,7 @@ interface MarketingSceneProps {
   totalScenes: number;
   totalFrames: number;
   title: string;
+  channelName?: string;
 }
 
 const MarketingScene: React.FC<MarketingSceneProps> = ({
@@ -29,9 +30,11 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
   totalScenes,
   totalFrames,
   title,
+  channelName,
 }) => {
   const frame = useCurrentFrame();
   const colors = THEMES.marketing;
+  const visualText = scene.displayText ?? scene.narration;
 
   const fadeOpacity = interpolate(
     frame,
@@ -103,14 +106,14 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
         {isLastScene ? (
           // CTA scene
           <CTAOverlay
-            text={scene.narration}
+            text={visualText}
             subtitle={featureKeyframes[0]?.content}
             color={colors.primaryLight}
           />
         ) : (
           <>
             <AnimatedText
-              text={scene.narration}
+              text={visualText}
               fontSize={44}
               color="white"
               fontWeight={700}
@@ -130,7 +133,7 @@ const MarketingScene: React.FC<MarketingSceneProps> = ({
         <SubtitleBar wordTimestamps={scene.wordTimestamps} />
       )}
 
-      <Watermark />
+      <Watermark text={channelName} />
     </AbsoluteFill>
   );
 };

+ 9 - 5
packages/templates/src/news/index.tsx

@@ -22,6 +22,7 @@ interface NewsSceneProps {
   totalScenes: number;
   totalFrames: number;
   title: string;
+  channelName?: string;
 }
 
 const NewsScene: React.FC<NewsSceneProps> = ({
@@ -30,6 +31,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
   totalScenes,
   totalFrames,
   title,
+  channelName,
 }) => {
   const frame = useCurrentFrame();
   const { fps } = useVideoConfig();
@@ -43,6 +45,8 @@ const NewsScene: React.FC<NewsSceneProps> = ({
     { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
   );
 
+  const visualText = scene.displayText ?? scene.narration;
+
   return (
     <AbsoluteFill style={{ opacity: fadeOpacity }}>
       <Background
@@ -108,7 +112,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
           <div style={{ overflow: "hidden" }}>
             <div
               style={{
-                fontSize: scene.narration.length > 60 ? 40 : 64,
+                fontSize: visualText.length > 60 ? 40 : 64,
                 fontWeight: 800,
                 color: "white",
                 fontFamily: "sans-serif",
@@ -121,7 +125,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
                 WebkitBoxOrient: "vertical",
               }}
             >
-              {scene.narration}
+              {visualText}
             </div>
             {scene.keyframes.map((kf, i) => (
               <HeadlineCard
@@ -136,7 +140,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
           <div style={{ overflow: "hidden" }}>
             <div
               style={{
-                fontSize: scene.narration.length > 60 ? 30 : 42,
+                fontSize: visualText.length > 60 ? 30 : 42,
                 fontWeight: 700,
                 color: "white",
                 fontFamily: "sans-serif",
@@ -149,7 +153,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
                 WebkitBoxOrient: "vertical",
               }}
             >
-              {scene.narration}
+              {visualText}
             </div>
             <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
               {scene.keyframes
@@ -176,7 +180,7 @@ const NewsScene: React.FC<NewsSceneProps> = ({
         headlines={scene.keyframes.map((kf) => kf.content)}
       />
 
-      <Watermark />
+      <Watermark text={channelName} />
     </AbsoluteFill>
   );
 };

+ 6 - 3
packages/templates/src/opinion/index.tsx

@@ -20,6 +20,7 @@ interface OpinionSceneProps {
   totalScenes: number;
   totalFrames: number;
   title: string;
+  channelName?: string;
 }
 
 const OpinionScene: React.FC<OpinionSceneProps> = ({
@@ -28,9 +29,11 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
   totalScenes,
   totalFrames,
   title,
+  channelName,
 }) => {
   const frame = useCurrentFrame();
   const colors = THEMES.opinion;
+  const visualText = scene.displayText ?? scene.narration;
 
   const fadeOpacity = interpolate(
     frame,
@@ -96,13 +99,13 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
         {sceneIndex === 0 ? (
           // Opening: quote style
           <QuoteBlock
-            quote={scene.narration}
+            quote={visualText}
             color={colors.primaryLight}
           />
         ) : (
           <>
             <AnimatedText
-              text={scene.narration}
+              text={visualText}
               fontSize={40}
               color="white"
               fontWeight={500}
@@ -125,7 +128,7 @@ const OpinionScene: React.FC<OpinionSceneProps> = ({
         <SubtitleBar wordTimestamps={scene.wordTimestamps} />
       )}
 
-      <Watermark />
+      <Watermark text={channelName} />
     </AbsoluteFill>
   );
 };