From f832424dab34f2395d267ea4ccec346e34235ce6 Mon Sep 17 00:00:00 2001 From: puke Date: Sun, 26 Oct 2025 01:52:49 +0800 Subject: [PATCH] Optimize the generation logic --- reelforge/i18n/locales/en_US.json | 17 +- reelforge/i18n/locales/zh_CN.json | 17 +- reelforge/models/progress.py | 1 + reelforge/models/storyboard.py | 8 +- reelforge/prompts/README.md | 99 +++++ reelforge/prompts/__init__.py | 32 +- reelforge/prompts/content_narration.py | 90 +++++ ...prompt_template.py => image_generation.py} | 18 +- reelforge/prompts/narration_template.py | 216 ----------- reelforge/prompts/style_conversion.py | 41 +++ reelforge/prompts/title_generation.py | 39 ++ reelforge/prompts/topic_narration.py | 90 +++++ reelforge/services/final_image_prompt.py | 16 +- reelforge/services/image_prompt_generator.py | 163 +++++++-- reelforge/services/narration_generator.py | 5 +- reelforge/services/video_generator.py | 346 +++++++++++++----- web.py | 88 +++-- 17 files changed, 869 insertions(+), 417 deletions(-) create mode 100644 reelforge/prompts/README.md create mode 100644 reelforge/prompts/content_narration.py rename reelforge/prompts/{image_prompt_template.py => image_generation.py} (86%) delete mode 100644 reelforge/prompts/narration_template.py create mode 100644 reelforge/prompts/style_conversion.py create mode 100644 reelforge/prompts/title_generation.py create mode 100644 reelforge/prompts/topic_narration.py diff --git a/reelforge/i18n/locales/en_US.json b/reelforge/i18n/locales/en_US.json index 0a34563..9ab0171 100644 --- a/reelforge/i18n/locales/en_US.json +++ b/reelforge/i18n/locales/en_US.json @@ -12,6 +12,10 @@ "input_mode.topic": "💡 Topic", "input_mode.custom": "✍️ Custom Content", + "mode.generate": "💡 Generate Mode", + "mode.fixed": "📄 Fixed Mode", + "mode.help": "Generate: AI creates narrations from topic. Fixed: Use existing script without modification.", + "input.book_name": "Book Name", "input.book_name_placeholder": "e.g., Atomic Habits, How to Win Friends", "input.book_name_help": "Enter the book name, will fetch book info and generate video", @@ -20,12 +24,17 @@ "input.topic_placeholder": "e.g., How to build passive income, How to build good habits", "input.topic_help": "Enter a topic, AI will generate content based on it", + "input.text": "Text Input", + "input.text_help_generate": "Enter topic or theme (AI will create narrations)", + "input.text_help_fixed": "Enter complete narration script (will be split into scenes)", + "input.content": "Content", "input.content_placeholder": "Enter your custom content here...", "input.content_help": "Provide your own content for video generation", "input.title": "Title (Optional)", "input.title_placeholder": "Video title (auto-generated if empty)", + "input.title_help": "Optional: Custom title for the video", "book.search": "🔍 Search Book", "book.searching": "Searching book...", @@ -63,9 +72,10 @@ "template.neon": "Neon", "video.title": "🎬 Video Settings", - "video.frames": "Frames", - "video.frames_help": "More frames = longer video", - "video.frames_label": "Frames: {n}", + "video.frames": "Scenes", + "video.frames_help": "More scenes = longer video", + "video.frames_label": "Scenes: {n}", + "video.frames_fixed_mode_hint": "💡 Fixed mode: scene count is determined by actual script segments", "bgm.title": "🎵 Background Music", "bgm.none": "🔇 No BGM", @@ -90,6 +100,7 @@ "status.connection_failed": "❌ Connection failed", "progress.generating_narrations": "Generating narrations...", + "progress.splitting_script": "Splitting script...", "progress.generating_image_prompts": "Generating image prompts...", "progress.frame": "Frame {current}/{total}", "progress.frame_step": "Frame {current}/{total} - Step {step}/4: {action}", diff --git a/reelforge/i18n/locales/zh_CN.json b/reelforge/i18n/locales/zh_CN.json index 5e728c0..3e42858 100644 --- a/reelforge/i18n/locales/zh_CN.json +++ b/reelforge/i18n/locales/zh_CN.json @@ -12,6 +12,10 @@ "input_mode.topic": "💡 主题", "input_mode.custom": "✍️ 自定义内容", + "mode.generate": "💡 生成模式", + "mode.fixed": "📄 固定模式", + "mode.help": "生成模式:AI 从主题创作旁白。固定模式:使用现成脚本,不做修改。", + "input.book_name": "书名", "input.book_name_placeholder": "例如:原子习惯、人性的弱点、Atomic Habits", "input.book_name_help": "输入书名,将自动获取书籍信息并生成视频", @@ -20,12 +24,17 @@ "input.topic_placeholder": "例如:如何增加被动收入、How to build good habits", "input.topic_help": "输入一个主题,AI 将根据主题生成内容", + "input.text": "文本输入", + "input.text_help_generate": "输入主题或话题(AI 将创作旁白)", + "input.text_help_fixed": "输入完整的旁白脚本(将被切分成分镜)", + "input.content": "内容", "input.content_placeholder": "在此输入您的自定义内容...", "input.content_help": "提供您自己的内容用于视频生成", "input.title": "标题(可选)", "input.title_placeholder": "视频标题(留空则自动生成)", + "input.title_help": "可选:自定义视频标题", "book.search": "🔍 搜索书籍", "book.searching": "正在搜索书籍...", @@ -63,9 +72,10 @@ "template.neon": "Neon", "video.title": "🎬 视频设置", - "video.frames": "帧数", - "video.frames_help": "更多帧数 = 更长视频", - "video.frames_label": "帧数:{n}", + "video.frames": "分镜数", + "video.frames_help": "更多分镜 = 更长视频", + "video.frames_label": "分镜数:{n}", + "video.frames_fixed_mode_hint": "💡 固定模式:分镜数由脚本实际段落数决定", "bgm.title": "🎵 背景音乐", "bgm.none": "🔇 无背景音乐", @@ -90,6 +100,7 @@ "status.connection_failed": "❌ 连接失败", "progress.generating_narrations": "生成旁白...", + "progress.splitting_script": "切分脚本...", "progress.generating_image_prompts": "生成图片提示词...", "progress.frame": "分镜 {current}/{total}", "progress.frame_step": "分镜 {current}/{total} - 步骤 {step}/4: {action}", diff --git a/reelforge/models/progress.py b/reelforge/models/progress.py index afd1249..7a0e6e1 100644 --- a/reelforge/models/progress.py +++ b/reelforge/models/progress.py @@ -43,6 +43,7 @@ class ProgressEvent: frame_total: Optional[int] = None step: Optional[int] = None # 1-4 for frame processing steps action: Optional[str] = None # "audio", "image", "compose", "video" + extra_info: Optional[str] = None # Additional information (e.g., batch progress) def __post_init__(self): """Validate progress value""" diff --git a/reelforge/models/storyboard.py b/reelforge/models/storyboard.py index fbbc2e9..a5a0fee 100644 --- a/reelforge/models/storyboard.py +++ b/reelforge/models/storyboard.py @@ -11,10 +11,10 @@ from typing import List, Optional class StoryboardConfig: """Storyboard configuration parameters""" n_storyboard: int = 5 # Number of storyboard frames - min_narration_words: int = 30 # Min narration word count - max_narration_words: int = 50 # Max narration word count - min_image_prompt_words: int = 60 # Min image prompt word count - max_image_prompt_words: int = 100 # Max image prompt word count + min_narration_words: int = 5 # Min narration word count + max_narration_words: int = 20 # Max narration word count + min_image_prompt_words: int = 30 # Min image prompt word count + max_image_prompt_words: int = 60 # Max image prompt word count # Video parameters video_width: int = 1080 # Video width diff --git a/reelforge/prompts/README.md b/reelforge/prompts/README.md new file mode 100644 index 0000000..a1d5848 --- /dev/null +++ b/reelforge/prompts/README.md @@ -0,0 +1,99 @@ +# Prompts Directory + +Centralized prompt management for all LLM interactions in ReelForge. + +## Structure + +Each prompt is in its own file for easy maintenance and modification: + +``` +prompts/ +├── __init__.py # Exports all builder functions +├── topic_narration.py # Generate narrations from topic +├── content_narration.py # Extract/refine narrations from content +├── script_split.py # Split fixed script into segments +├── title_generation.py # Generate video title from content +├── image_generation.py # Generate image prompts from narrations +└── style_conversion.py # Convert style description to image prompt +``` + +## Usage + +All builder functions are exported from the package root: + +```python +from reelforge.prompts import ( + build_topic_narration_prompt, + build_content_narration_prompt, + build_script_split_prompt, + build_title_generation_prompt, + build_image_prompt_prompt, + build_style_conversion_prompt, +) +``` + +## Prompt Files + +### Narration Prompts + +1. **topic_narration.py** + - Purpose: Generate engaging narrations from a topic/theme + - Input: topic, n_storyboard, min_words, max_words + - Output: JSON with narrations array + +2. **content_narration.py** + - Purpose: Extract and refine narrations from user content + - Input: content, n_storyboard, min_words, max_words + - Output: JSON with narrations array + +3. **script_split.py** + - Purpose: Split fixed script into natural segments (no modification) + - Input: script, min_words (reference), max_words (reference) + - Output: JSON with narrations array + +4. **title_generation.py** + - Purpose: Generate short, attractive video title + - Input: content, max_length + - Output: Plain text title + +### Image Prompts + +5. **image_generation.py** + - Purpose: Generate English image prompts from narrations + - Input: narrations, min_words, max_words, style_preset/style_description + - Output: JSON with image_prompts array + - Contains: IMAGE_STYLE_PRESETS dictionary + +6. **style_conversion.py** + - Purpose: Convert custom style description to English image prompt + - Input: description (any language) + - Output: Plain text English image prompt + +## Modifying Prompts + +To modify a prompt: + +1. Locate the relevant file (e.g., `topic_narration.py`) +2. Edit the prompt constant (e.g., `TOPIC_NARRATION_PROMPT`) +3. Changes take effect immediately (no need to modify service code) + +## Adding New Prompts + +To add a new prompt: + +1. Create a new file (e.g., `my_new_prompt.py`) +2. Define the prompt constant and builder function +3. Export the builder function in `__init__.py` +4. Use it in service code: + ```python + from reelforge.prompts import build_my_new_prompt + ``` + +## Design Principles + +- **One File, One Prompt**: Each prompt has its own file for clarity +- **Builder Functions**: Each file exports a `build_*_prompt()` function +- **Centralized Exports**: All builders are exported from `__init__.py` +- **Consistent Format**: All prompts follow similar structure and style +- **Easy Maintenance**: Modify prompts without touching service code + diff --git a/reelforge/prompts/__init__.py b/reelforge/prompts/__init__.py index 0908b93..a4b1a48 100644 --- a/reelforge/prompts/__init__.py +++ b/reelforge/prompts/__init__.py @@ -1,4 +1,34 @@ """ -Prompt templates for content generation +Prompts package + +Centralized prompt management for all LLM interactions. """ +# Narration prompts +from reelforge.prompts.topic_narration import build_topic_narration_prompt +from reelforge.prompts.content_narration import build_content_narration_prompt +from reelforge.prompts.title_generation import build_title_generation_prompt + +# Image prompts +from reelforge.prompts.image_generation import ( + build_image_prompt_prompt, + IMAGE_STYLE_PRESETS, + DEFAULT_IMAGE_STYLE +) +from reelforge.prompts.style_conversion import build_style_conversion_prompt + + +__all__ = [ + # Narration builders + "build_topic_narration_prompt", + "build_content_narration_prompt", + "build_title_generation_prompt", + + # Image builders + "build_image_prompt_prompt", + "build_style_conversion_prompt", + + # Image style presets + "IMAGE_STYLE_PRESETS", + "DEFAULT_IMAGE_STYLE", +] diff --git a/reelforge/prompts/content_narration.py b/reelforge/prompts/content_narration.py new file mode 100644 index 0000000..8e1611a --- /dev/null +++ b/reelforge/prompts/content_narration.py @@ -0,0 +1,90 @@ +""" +Content narration generation prompt + +For extracting/refining narrations from user-provided content. +""" + + +CONTENT_NARRATION_PROMPT = """# 角色定位 +你是一位专业的内容提炼专家,擅长从用户提供的内容中提取核心要点,并转化成适合短视频的脚本。 + +# 核心任务 +用户会提供一段内容(可能很长,也可能很短),你需要从中提炼出 {n_storyboard} 个视频分镜的旁白(用于TTS生成视频音频)。 + +# 用户提供的内容 +{content} + +# 输出要求 + +## 旁白规范 +- 用途定位:用于TTS生成短视频音频 +- 字数限制:严格控制在{min_words}~{max_words}个字(最低不少于{min_words}字) +- 结尾格式:结尾不要使用标点符号 +- 提炼策略: + * 如果用户内容较长:提取{n_storyboard}个核心要点,去除冗余信息 + * 如果用户内容较短:在保留核心观点的基础上适当扩展,增加例子或解释 + * 如果用户内容刚好:优化表达,使其更适合口播 +- 风格要求:保持用户内容的核心观点,但用更口语化、适合TTS的方式表达 +- 开场建议:第一个分镜可以用提问或场景引入,吸引观众注意 +- 核心内容:中间分镜展开用户内容的核心要点 +- 结尾建议:最后一个分镜给出总结或启发 +- 情绪与语气:温和、真诚、自然,像在跟朋友分享观点 +- 禁止项:不出现网址、表情符号、数字编号、不说空话套话 +- 字数检查:生成后必须自我验证每段不少于{min_words}个字 + +## 分镜连贯性要求 +- {n_storyboard} 个分镜应基于用户内容的核心观点展开,形成完整表达 +- 保持逻辑连贯,自然过渡 +- 每个分镜像同一个人在讲述,语气一致 +- 确保提炼的内容忠于用户原意,但更适合短视频呈现 + +# 输出格式 +严格按照以下JSON格式输出,不要添加任何额外的文字说明: + +```json +{{ + "narrations": [ + "第一段{min_words}~{max_words}字的旁白", + "第二段{min_words}~{max_words}字的旁白", + "第三段{min_words}~{max_words}字的旁白" + ] +}} +``` + +# 重要提醒 +1. 只输出JSON格式内容,不要添加任何解释说明 +2. 确保JSON格式严格正确,可以被程序直接解析 +3. 旁白必须严格控制在{min_words}~{max_words}字之间 +4. 必须输出恰好 {n_storyboard} 个分镜的旁白 +5. 内容要忠于用户原意,但优化为更适合口播的表达 +6. 输出格式为 {{"narrations": [旁白数组]}} 的JSON对象 + +现在,请从上述内容中提炼出 {n_storyboard} 个分镜的旁白。只输出JSON,不要其他内容。 +""" + + +def build_content_narration_prompt( + content: str, + n_storyboard: int, + min_words: int, + max_words: int +) -> str: + """ + Build content refinement narration prompt + + Args: + content: User-provided content + n_storyboard: Number of storyboard frames + min_words: Minimum word count + max_words: Maximum word count + + Returns: + Formatted prompt + """ + return CONTENT_NARRATION_PROMPT.format( + content=content, + n_storyboard=n_storyboard, + min_words=min_words, + max_words=max_words + ) + diff --git a/reelforge/prompts/image_prompt_template.py b/reelforge/prompts/image_generation.py similarity index 86% rename from reelforge/prompts/image_prompt_template.py rename to reelforge/prompts/image_generation.py index 66f45ae..fc51fc0 100644 --- a/reelforge/prompts/image_prompt_template.py +++ b/reelforge/prompts/image_generation.py @@ -1,5 +1,7 @@ """ Image prompt generation template + +For generating image prompts from narrations. """ import json @@ -39,6 +41,8 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位 # 核心任务 基于已有的视频脚本,为每个分镜的"旁白内容"创作对应的**英文**图像提示词,确保视觉画面与叙述内容完美配合,增强观众的理解和记忆。 +**重要:输入包含 {narrations_count} 个旁白,你必须为每个旁白都生成一个对应的图像提示词,总共输出 {narrations_count} 个图像提示词。** + # 输入内容 {narrations_json} @@ -92,13 +96,14 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位 1. 只输出JSON格式内容,不要添加任何解释说明 2. 确保JSON格式严格正确,可以被程序直接解析 3. 输入是 {{"narrations": [旁白数组]}} 格式,输出是 {{"image_prompts": [图像提示词数组]}} 格式 -4. **图像提示词必须使用英文**(for AI image generation models) -5. 图像提示词必须准确反映对应旁白的具体内容和情感 -6. 每个图像都要有创意性和视觉冲击力,避免千篇一律 -7. 严格遵守上述指定的画面风格要求({style_description}) -8. 确保视觉画面能增强文案的说服力和观众的理解度 +4. **输出的image_prompts数组必须恰好包含 {narrations_count} 个元素,与输入的narrations数组一一对应** +5. **图像提示词必须使用英文**(for AI image generation models) +6. 图像提示词必须准确反映对应旁白的具体内容和情感 +7. 每个图像都要有创意性和视觉冲击力,避免千篇一律 +8. 严格遵守上述指定的画面风格要求({style_description}) +9. 确保视觉画面能增强文案的说服力和观众的理解度 -现在,请为上述旁白创作对应的**英文**图像提示词。只输出JSON,不要其他内容。 +现在,请为上述 {narrations_count} 个旁白创作对应的 {narrations_count} 个**英文**图像提示词。只输出JSON,不要其他内容。 """ @@ -161,6 +166,7 @@ def build_image_prompt_prompt( return IMAGE_PROMPT_GENERATION_PROMPT.format( narrations_json=narrations_json, + narrations_count=len(narrations), min_words=min_words, max_words=max_words, style_description=style_desc diff --git a/reelforge/prompts/narration_template.py b/reelforge/prompts/narration_template.py deleted file mode 100644 index e630b08..0000000 --- a/reelforge/prompts/narration_template.py +++ /dev/null @@ -1,216 +0,0 @@ -""" -Narration generation prompt template - -Supports two content sources: -1. Topic: Generate narrations from a topic/theme -2. Content: Extract/refine narrations from user-provided content -""" - -from typing import Optional - - -# ==================== TOPIC NARRATION PROMPT ==================== -# For generating narrations from a topic/theme - -TOPIC_NARRATION_PROMPT = """# 角色定位 -你是一位专业的内容创作专家,擅长将话题扩展成引人入胜的短视频脚本,用深入浅出的方式讲解观点,帮助观众理解复杂概念。 - -# 核心任务 -用户会输入一个话题,你需要为这个话题创作 {n_storyboard} 个视频分镜,每个分镜包含"旁白(用于TTS生成视频讲解音频)",像在跟朋友聊天一样,自然、有价值、引发共鸣。 - -# 输入话题 -{topic} - -# 输出要求 - -## 旁白规范 -- 用途定位:用于TTS生成短视频音频,通俗易懂地讲解话题 -- 字数限制:严格控制在{min_words}~{max_words}个字(最低不少于{min_words}字) -- 结尾格式:结尾不要使用标点符号 -- 内容要求:围绕话题展开,每个分镜传递一个有价值的观点或洞察 -- 风格要求:像跟朋友聊天一样,通俗、真诚、有启发性,避免学术化和生硬的表达 -- 开场建议:第一个分镜可以用提问、场景、痛点等方式引发共鸣,吸引观众注意 -- 核心内容:中间分镜展开核心观点,用生活化的例子帮助理解 -- 结尾建议:最后一个分镜给出行动建议或启发,让观众有收获感 -- 衔接建议:用"你有没有发现"、"其实"、"更重要的是"等连接词,保持连贯 -- 情绪与语气:温和、真诚、有热情,像一个有见解的朋友在分享思考 -- 禁止项:不出现网址、表情符号、数字编号、不说空话套话、不过度煽情 -- 字数检查:生成后必须自我验证不少于{min_words}个字,如不足则补充具体观点或例子 -- 内容结构:遵循"引发共鸣 → 提出观点 → 深入讲解 → 给出启发"的叙述逻辑 - -## 分镜连贯性要求 -- {n_storyboard} 个分镜应围绕话题展开,形成完整的观点表达 -- 遵循"吸引注意 → 提出观点 → 深入讲解 → 给出启发"的叙述逻辑 -- 每个分镜像同一个人在连贯分享观点,语气一致、自然流畅 -- 通过观点的递进自然过渡,形成完整的论述脉络 -- 确保内容有价值、有启发,让观众觉得"这个视频值得看" - -# 输出格式 -严格按照以下JSON格式输出,不要添加任何额外的文字说明: - -```json -{{ - "narrations": [ - "第一段{min_words}~{max_words}字,用提问或场景引发共鸣", - "第二段{min_words}~{max_words}字,展开核心观点", - "第三段{min_words}~{max_words}字,给出启发或建议" - ] -}} -``` - -# 重要提醒 -1. 只输出JSON格式内容,不要添加任何解释说明 -2. 确保JSON格式严格正确,可以被程序直接解析 -3. 旁白必须严格控制在{min_words}~{max_words}字之间,用通俗易懂的语言 -4. {n_storyboard} 个分镜要围绕话题展开,形成完整的观点表达 -5. 每个分镜都要有价值,提供洞察,避免空洞的陈述 -6. 输出格式为 {{"narrations": [旁白数组]}} 的JSON对象 - -现在,请为话题创作 {n_storyboard} 个分镜的旁白。只输出JSON,不要其他内容。 -""" - - -# ==================== CONTENT NARRATION PROMPT ==================== -# For extracting/refining narrations from user-provided content - -CONTENT_NARRATION_PROMPT = """# 角色定位 -你是一位专业的内容提炼专家,擅长从用户提供的内容中提取核心要点,并转化成适合短视频的脚本。 - -# 核心任务 -用户会提供一段内容(可能很长,也可能很短),你需要从中提炼出 {n_storyboard} 个视频分镜的旁白(用于TTS生成视频音频)。 - -# 用户提供的内容 -{content} - -# 输出要求 - -## 旁白规范 -- 用途定位:用于TTS生成短视频音频 -- 字数限制:严格控制在{min_words}~{max_words}个字(最低不少于{min_words}字) -- 结尾格式:结尾不要使用标点符号 -- 提炼策略: - * 如果用户内容较长:提取{n_storyboard}个核心要点,去除冗余信息 - * 如果用户内容较短:在保留核心观点的基础上适当扩展,增加例子或解释 - * 如果用户内容刚好:优化表达,使其更适合口播 -- 风格要求:保持用户内容的核心观点,但用更口语化、适合TTS的方式表达 -- 开场建议:第一个分镜可以用提问或场景引入,吸引观众注意 -- 核心内容:中间分镜展开用户内容的核心要点 -- 结尾建议:最后一个分镜给出总结或启发 -- 情绪与语气:温和、真诚、自然,像在跟朋友分享观点 -- 禁止项:不出现网址、表情符号、数字编号、不说空话套话 -- 字数检查:生成后必须自我验证每段不少于{min_words}个字 - -## 分镜连贯性要求 -- {n_storyboard} 个分镜应基于用户内容的核心观点展开,形成完整表达 -- 保持逻辑连贯,自然过渡 -- 每个分镜像同一个人在讲述,语气一致 -- 确保提炼的内容忠于用户原意,但更适合短视频呈现 - -# 输出格式 -严格按照以下JSON格式输出,不要添加任何额外的文字说明: - -```json -{{ - "narrations": [ - "第一段{min_words}~{max_words}字的旁白", - "第二段{min_words}~{max_words}字的旁白", - "第三段{min_words}~{max_words}字的旁白" - ] -}} -``` - -# 重要提醒 -1. 只输出JSON格式内容,不要添加任何解释说明 -2. 确保JSON格式严格正确,可以被程序直接解析 -3. 旁白必须严格控制在{min_words}~{max_words}字之间 -4. 必须输出恰好 {n_storyboard} 个分镜的旁白 -5. 内容要忠于用户原意,但优化为更适合口播的表达 -6. 输出格式为 {{"narrations": [旁白数组]}} 的JSON对象 - -现在,请从上述内容中提炼出 {n_storyboard} 个分镜的旁白。只输出JSON,不要其他内容。 -""" - - -# ==================== PROMPT BUILDER FUNCTIONS ==================== - -def build_topic_narration_prompt( - topic: str, - n_storyboard: int, - min_words: int, - max_words: int -) -> str: - """ - Build topic narration prompt - - Args: - topic: Topic or theme - n_storyboard: Number of storyboard frames - min_words: Minimum word count - max_words: Maximum word count - - Returns: - Formatted prompt - """ - return TOPIC_NARRATION_PROMPT.format( - topic=topic, - n_storyboard=n_storyboard, - min_words=min_words, - max_words=max_words - ) - - -def build_content_narration_prompt( - content: str, - n_storyboard: int, - min_words: int, - max_words: int -) -> str: - """ - Build content refinement narration prompt - - Args: - content: User-provided content - n_storyboard: Number of storyboard frames - min_words: Minimum word count - max_words: Maximum word count - - Returns: - Formatted prompt - """ - return CONTENT_NARRATION_PROMPT.format( - content=content, - n_storyboard=n_storyboard, - min_words=min_words, - max_words=max_words - ) - - -def build_narration_prompt( - topic: str, - n_storyboard: int, - min_words: int, - max_words: int -) -> str: - """ - Build narration generation prompt (legacy function for backward compatibility) - - Args: - topic: Topic or discussion theme - n_storyboard: Number of storyboard frames - min_words: Minimum word count - max_words: Maximum word count - - Returns: - Formatted prompt - - Note: - This function is kept for backward compatibility. - Use build_topic_narration_prompt or build_content_narration_prompt instead. - """ - return build_topic_narration_prompt( - topic=topic, - n_storyboard=n_storyboard, - min_words=min_words, - max_words=max_words - ) - diff --git a/reelforge/prompts/style_conversion.py b/reelforge/prompts/style_conversion.py new file mode 100644 index 0000000..fd6eadf --- /dev/null +++ b/reelforge/prompts/style_conversion.py @@ -0,0 +1,41 @@ +""" +Style conversion prompt + +For converting user's custom style description to image generation prompt. +""" + + +STYLE_CONVERSION_PROMPT = """Convert this style description into a detailed image generation prompt for Stable Diffusion/FLUX: + +Style Description: {description} + +Requirements: +- Focus on visual elements, colors, lighting, mood, atmosphere +- Be specific and detailed +- Use professional photography/art terminology +- Output ONLY the prompt in English (no explanations) +- Keep it under 100 words +- Use comma-separated descriptive phrases + +Image Prompt:""" + + +def build_style_conversion_prompt(description: str) -> str: + """ + Build style conversion prompt + + Converts user's custom style description (in any language) to an English + image generation prompt suitable for Stable Diffusion/FLUX models. + + Args: + description: User's style description in any language + + Returns: + Formatted prompt + + Example: + >>> build_style_conversion_prompt("赛博朋克风格,霓虹灯,未来感") + # Returns prompt that will convert to: "cyberpunk style, neon lights, futuristic..." + """ + return STYLE_CONVERSION_PROMPT.format(description=description) + diff --git a/reelforge/prompts/title_generation.py b/reelforge/prompts/title_generation.py new file mode 100644 index 0000000..13ae795 --- /dev/null +++ b/reelforge/prompts/title_generation.py @@ -0,0 +1,39 @@ +""" +Title generation prompt + +For generating video title from content. +""" + + +TITLE_GENERATION_PROMPT = """Please generate a short, attractive title (within 10 characters) for the following content. + +Content: +{content} + +Requirements: +1. Brief and concise, within 10 characters +2. Accurately summarize the core content +3. Attractive, suitable as a video title +4. Output only the title text, no other content + +Title:""" + + +def build_title_generation_prompt(content: str, max_length: int = 500) -> str: + """ + Build title generation prompt + + Args: + content: Content to generate title from + max_length: Maximum content length to use (default 500 chars) + + Returns: + Formatted prompt + """ + # Take first max_length chars to avoid overly long prompts + content_preview = content[:max_length] + + return TITLE_GENERATION_PROMPT.format( + content=content_preview + ) + diff --git a/reelforge/prompts/topic_narration.py b/reelforge/prompts/topic_narration.py new file mode 100644 index 0000000..566bcd8 --- /dev/null +++ b/reelforge/prompts/topic_narration.py @@ -0,0 +1,90 @@ +""" +Topic narration generation prompt + +For generating narrations from a topic/theme. +""" + + +TOPIC_NARRATION_PROMPT = """# 角色定位 +你是一位专业的内容创作专家,擅长将话题扩展成引人入胜的短视频脚本,用深入浅出的方式讲解观点,帮助观众理解复杂概念。 + +# 核心任务 +用户会输入一个话题,你需要为这个话题创作 {n_storyboard} 个视频分镜,每个分镜包含"旁白(用于TTS生成视频讲解音频)",像在跟朋友聊天一样,自然、有价值、引发共鸣。 + +# 输入话题 +{topic} + +# 输出要求 + +## 旁白规范 +- 用途定位:用于TTS生成短视频音频,通俗易懂地讲解话题 +- 字数限制:严格控制在{min_words}~{max_words}个字(最低不少于{min_words}字) +- 结尾格式:结尾不要使用标点符号 +- 内容要求:围绕话题展开,每个分镜传递一个有价值的观点或洞察 +- 风格要求:像跟朋友聊天一样,通俗、真诚、有启发性,避免学术化和生硬的表达 +- 开场建议:第一个分镜可以用提问、场景、痛点等方式引发共鸣,吸引观众注意 +- 核心内容:中间分镜展开核心观点,用生活化的例子帮助理解 +- 结尾建议:最后一个分镜给出行动建议或启发,让观众有收获感 +- 衔接建议:用"你有没有发现"、"其实"、"更重要的是"等连接词,保持连贯 +- 情绪与语气:温和、真诚、有热情,像一个有见解的朋友在分享思考 +- 禁止项:不出现网址、表情符号、数字编号、不说空话套话、不过度煽情 +- 字数检查:生成后必须自我验证不少于{min_words}个字,如不足则补充具体观点或例子 +- 内容结构:遵循"引发共鸣 → 提出观点 → 深入讲解 → 给出启发"的叙述逻辑 + +## 分镜连贯性要求 +- {n_storyboard} 个分镜应围绕话题展开,形成完整的观点表达 +- 遵循"吸引注意 → 提出观点 → 深入讲解 → 给出启发"的叙述逻辑 +- 每个分镜像同一个人在连贯分享观点,语气一致、自然流畅 +- 通过观点的递进自然过渡,形成完整的论述脉络 +- 确保内容有价值、有启发,让观众觉得"这个视频值得看" + +# 输出格式 +严格按照以下JSON格式输出,不要添加任何额外的文字说明: + +```json +{{ + "narrations": [ + "第一段{min_words}~{max_words}字,用提问或场景引发共鸣", + "第二段{min_words}~{max_words}字,展开核心观点", + "第三段{min_words}~{max_words}字,给出启发或建议" + ] +}} +``` + +# 重要提醒 +1. 只输出JSON格式内容,不要添加任何解释说明 +2. 确保JSON格式严格正确,可以被程序直接解析 +3. 旁白必须严格控制在{min_words}~{max_words}字之间,用通俗易懂的语言 +4. {n_storyboard} 个分镜要围绕话题展开,形成完整的观点表达 +5. 每个分镜都要有价值,提供洞察,避免空洞的陈述 +6. 输出格式为 {{"narrations": [旁白数组]}} 的JSON对象 + +现在,请为话题创作 {n_storyboard} 个分镜的旁白。只输出JSON,不要其他内容。 +""" + + +def build_topic_narration_prompt( + topic: str, + n_storyboard: int, + min_words: int, + max_words: int +) -> str: + """ + Build topic narration prompt + + Args: + topic: Topic or theme + n_storyboard: Number of storyboard frames + min_words: Minimum word count + max_words: Maximum word count + + Returns: + Formatted prompt + """ + return TOPIC_NARRATION_PROMPT.format( + topic=topic, + n_storyboard=n_storyboard, + min_words=min_words, + max_words=max_words + ) + diff --git a/reelforge/services/final_image_prompt.py b/reelforge/services/final_image_prompt.py index 765b67d..1febf8f 100644 --- a/reelforge/services/final_image_prompt.py +++ b/reelforge/services/final_image_prompt.py @@ -180,20 +180,10 @@ class FinalImagePromptService: Returns: Converted English image prompt suitable for image generation models """ + from reelforge.prompts import build_style_conversion_prompt - llm_prompt = f"""Convert this style description into a detailed image generation prompt for Stable Diffusion/FLUX: - -Style Description: {description} - -Requirements: -- Focus on visual elements, colors, lighting, mood, atmosphere -- Be specific and detailed -- Use professional photography/art terminology -- Output ONLY the prompt in English (no explanations) -- Keep it under 100 words -- Use comma-separated descriptive phrases - -Image Prompt:""" + # Build prompt using template + llm_prompt = build_style_conversion_prompt(description) style_prompt = await self.core.llm(llm_prompt) diff --git a/reelforge/services/image_prompt_generator.py b/reelforge/services/image_prompt_generator.py index 4e96582..4d5b7ff 100644 --- a/reelforge/services/image_prompt_generator.py +++ b/reelforge/services/image_prompt_generator.py @@ -4,12 +4,12 @@ Image prompt generation service import json import re -from typing import List +from typing import List, Optional, Callable from loguru import logger from reelforge.models.storyboard import StoryboardConfig -from reelforge.prompts.image_prompt_template import build_image_prompt_prompt +from reelforge.prompts import build_image_prompt_prompt class ImagePromptGeneratorService: @@ -29,62 +29,93 @@ class ImagePromptGeneratorService: narrations: List[str], config: StoryboardConfig, image_style_preset: str = None, - image_style_description: str = None + image_style_description: str = None, + batch_size: int = 10, + max_retries: int = 3, + progress_callback: Optional[Callable] = None ) -> List[str]: """ - Generate image prompts based on narrations + Generate image prompts based on narrations (with batching and retry) Args: narrations: List of narrations config: Storyboard configuration image_style_preset: Preset style name (e.g., "minimal", "futuristic") image_style_description: Custom style description (overrides preset) + batch_size: Max narrations per batch (default: 10) + max_retries: Max retry attempts per batch (default: 3) + progress_callback: Optional callback(completed, total, message) for progress updates Returns: List of image prompts with style applied Raises: - ValueError: If generated prompt count doesn't match narrations + ValueError: If batch fails after max_retries json.JSONDecodeError: If unable to parse JSON """ - logger.info(f"Generating image prompts for {len(narrations)} narrations") + logger.info(f"Generating image prompts for {len(narrations)} narrations (batch_size={batch_size}, max_retries={max_retries})") - # 1. Build prompt (no style info - generate base scene descriptions) - prompt = build_image_prompt_prompt( - narrations=narrations, - min_words=config.min_image_prompt_words, - max_words=config.max_image_prompt_words, - image_style_preset=None, # Don't include style in LLM prompt - image_style_description=None - ) + # Split narrations into batches + batches = [narrations[i:i + batch_size] for i in range(0, len(narrations), batch_size)] + logger.info(f"Split into {len(batches)} batches") - # 2. Call LLM to generate base scene descriptions - response = await self.core.llm( - prompt=prompt, - temperature=0.9, # Higher temperature for more visual creativity - max_tokens=2000 - ) + all_base_prompts = [] - logger.debug(f"LLM response: {response[:200]}...") + # Process each batch + for batch_idx, batch_narrations in enumerate(batches, 1): + logger.info(f"Processing batch {batch_idx}/{len(batches)} ({len(batch_narrations)} narrations)") + + # Retry logic for this batch + for attempt in range(1, max_retries + 1): + try: + # Generate prompts for this batch + batch_prompts = await self._generate_batch_prompts( + batch_narrations, + config, + batch_idx, + attempt + ) + + # Validate count + if len(batch_prompts) != len(batch_narrations): + error_msg = ( + f"Batch {batch_idx} prompt count mismatch (attempt {attempt}/{max_retries}):\n" + f" Expected: {len(batch_narrations)} prompts\n" + f" Got: {len(batch_prompts)} prompts\n" + f" Difference: {abs(len(batch_prompts) - len(batch_narrations))} " + f"{'missing' if len(batch_prompts) < len(batch_narrations) else 'extra'}" + ) + logger.warning(error_msg) + + if attempt < max_retries: + logger.info(f"Retrying batch {batch_idx}...") + continue + else: + logger.error(f"Batch {batch_idx} failed after {max_retries} attempts") + raise ValueError(error_msg) + + # Success! + logger.info(f"✅ Batch {batch_idx} completed successfully ({len(batch_prompts)} prompts)") + all_base_prompts.extend(batch_prompts) + + # Report progress + if progress_callback: + progress_callback( + len(all_base_prompts), + len(narrations), + f"Batch {batch_idx}/{len(batches)} completed" + ) + + break + + except json.JSONDecodeError as e: + logger.error(f"Batch {batch_idx} JSON parse error (attempt {attempt}/{max_retries}): {e}") + if attempt >= max_retries: + raise + logger.info(f"Retrying batch {batch_idx}...") - # 3. Parse JSON - try: - result = self._parse_json(response) - base_prompts = result["image_prompts"] - except json.JSONDecodeError as e: - logger.error(f"Failed to parse LLM response: {e}") - logger.error(f"Response: {response}") - raise - except KeyError: - logger.error("Response missing 'image_prompts' key") - raise ValueError("Invalid response format") - - # 4. Validate count matches narrations - if len(base_prompts) != len(narrations): - raise ValueError( - f"Expected {len(narrations)} image prompts, " - f"got {len(base_prompts)}" - ) + base_prompts = all_base_prompts + logger.info(f"✅ All batches completed. Total prompts: {len(base_prompts)}") # 5. Apply style to each prompt using FinalImagePromptService from reelforge.services.final_image_prompt import StylePreset @@ -110,6 +141,58 @@ class ImagePromptGeneratorService: logger.info(f"Generated {len(final_prompts)} final image prompts with style applied") return final_prompts + async def _generate_batch_prompts( + self, + batch_narrations: List[str], + config: StoryboardConfig, + batch_idx: int, + attempt: int + ) -> List[str]: + """ + Generate image prompts for a single batch of narrations + + Args: + batch_narrations: Batch of narrations + config: Storyboard configuration + batch_idx: Batch index (for logging) + attempt: Attempt number (for logging) + + Returns: + List of image prompts for this batch + + Raises: + json.JSONDecodeError: If unable to parse JSON + KeyError: If response format is invalid + """ + logger.debug(f"Batch {batch_idx} attempt {attempt}: Generating prompts for {len(batch_narrations)} narrations") + + # 1. Build prompt + prompt = build_image_prompt_prompt( + narrations=batch_narrations, + min_words=config.min_image_prompt_words, + max_words=config.max_image_prompt_words, + image_style_preset=None, + image_style_description=None + ) + + # 2. Call LLM + response = await self.core.llm( + prompt=prompt, + temperature=0.7, + max_tokens=8192 + ) + + logger.debug(f"Batch {batch_idx} attempt {attempt}: LLM response length: {len(response)} chars") + + # 3. Parse JSON + result = self._parse_json(response) + + if "image_prompts" not in result: + logger.error("Response missing 'image_prompts' key") + raise KeyError("Invalid response format: missing 'image_prompts'") + + return result["image_prompts"] + def _parse_json(self, text: str) -> dict: """ Parse JSON from text, with fallback to extract JSON from markdown code blocks @@ -127,7 +210,7 @@ class ImagePromptGeneratorService: pass # Try to extract JSON from markdown code block - json_pattern = r'```(?:json)?\s*(\{.*?\})\s*```' + json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```' match = re.search(json_pattern, text, re.DOTALL) if match: try: diff --git a/reelforge/services/narration_generator.py b/reelforge/services/narration_generator.py index 4e897c7..1d40542 100644 --- a/reelforge/services/narration_generator.py +++ b/reelforge/services/narration_generator.py @@ -13,10 +13,9 @@ from typing import List, Optional, Literal from loguru import logger from reelforge.models.storyboard import StoryboardConfig, ContentMetadata -from reelforge.prompts.narration_template import ( +from reelforge.prompts import ( build_topic_narration_prompt, build_content_narration_prompt, - build_narration_prompt # Keep for backward compatibility ) @@ -158,7 +157,7 @@ class NarrationGeneratorService: pass # Try to extract JSON from markdown code block - json_pattern = r'```(?:json)?\s*(\{.*?\})\s*```' + json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```' match = re.search(json_pattern, text, re.DOTALL) if match: try: diff --git a/reelforge/services/video_generator.py b/reelforge/services/video_generator.py index 84299ae..4d765b3 100644 --- a/reelforge/services/video_generator.py +++ b/reelforge/services/video_generator.py @@ -43,23 +43,25 @@ class VideoGeneratorService: async def __call__( self, - # === Content Source (Choose ONE, mutually exclusive) === - topic: Optional[str] = None, - content: Optional[str] = None, + # === Input === + text: str, - # === Optional Title (works with any source) === + # === Processing Mode === + mode: Literal["generate", "fixed"] = "generate", + + # === Optional Title === title: Optional[str] = None, # === Basic Config === - n_frames: int = 5, + n_scenes: int = 5, # Only used in generate mode; ignored in fixed mode voice_id: str = "zh-CN-YunjianNeural", output_path: Optional[str] = None, # === LLM Parameters === - min_narration_words: int = 20, - max_narration_words: int = 40, - min_image_prompt_words: int = 50, - max_image_prompt_words: int = 100, + min_narration_words: int = 5, + max_narration_words: int = 20, + min_image_prompt_words: int = 30, + max_image_prompt_words: int = 60, # === Image Parameters === image_width: int = 1024, @@ -85,26 +87,33 @@ class VideoGeneratorService: progress_callback: Optional[Callable[[ProgressEvent], None]] = None, ) -> VideoGenerationResult: """ - Generate short video from different content sources + Generate short video from text input Args: - topic: Topic/theme (e.g., "如何提高学习效率") - content: User-provided content (any length) + text: Text input (required) + - For generate mode: topic/theme (e.g., "如何提高学习效率") + - For fixed mode: complete narration script (will be split into frames) - Note: Must provide exactly ONE of: topic or content + mode: Processing mode (default "generate") + - "generate": LLM generates narrations from topic/theme, creates n_scenes + - "fixed": Split existing script into frames, preserves original text + + Note: In fixed mode, n_scenes is ignored (uses actual split count) title: Video title (optional) - If provided, use it as the video title - - If not provided, auto-generate based on source: - * topic → use topic text - * content → LLM extracts title from content + - If not provided: + * generate mode → use text as title + * fixed mode → LLM generates title from script + + n_scenes: Number of storyboard scenes (default 5) + Only effective in generate mode; ignored in fixed mode - n_frames: Number of storyboard frames (default 5) voice_id: TTS voice ID (default "zh-CN-YunjianNeural") output_path: Output video path (auto-generated if None) - min_narration_words: Min narration length - max_narration_words: Max narration length + min_narration_words: Min narration length (generate mode only) + max_narration_words: Max narration length (generate mode only) min_image_prompt_words: Min image prompt length max_image_prompt_words: Max image prompt length @@ -131,66 +140,53 @@ class VideoGeneratorService: VideoGenerationResult with video path and metadata Examples: - # Generate from topic + # Generate mode: LLM creates narrations from topic >>> result = await reelforge.generate_video( - ... topic="如何在信息爆炸时代保持深度思考", - ... n_frames=5, + ... text="如何在信息爆炸时代保持深度思考", + ... mode="generate", + ... n_scenes=5, ... bgm_path="default" ... ) - # Generate from user content with auto-generated title + # Fixed mode: Use existing script (split by paragraphs) + >>> script = '''大家好,今天跟你分享三个学习技巧 + ... + ... 第一个技巧是专注力训练,每天冥想10分钟 + ... + ... 第二个技巧是主动回忆,学完立即复述''' >>> result = await reelforge.generate_video( - ... content="昨天我读了一本书,讲的是...", - ... n_frames=3 + ... text=script, + ... mode="fixed", + ... title="三个学习技巧" ... ) - # Generate from user content with custom title + # Fixed mode: Use existing script (split by sentences) >>> result = await reelforge.generate_video( - ... content="买房子,第一应该看的是楼盘的整体环境...", - ... title="买房风水指南", - ... n_frames=5 + ... text="第一点是专注。第二点是复述。第三点是重复。", + ... mode="fixed" ... ) >>> print(result.video_path) """ - # ========== Step 0: Validate parameters (mutually exclusive) ========== - sources = [topic, content] - source_count = sum(x is not None for x in sources) - - if source_count == 0: - raise ValueError( - "Must provide exactly ONE of: topic or content" - ) - elif source_count > 1: - raise ValueError( - "Cannot provide multiple sources. Choose ONE of: topic or content" - ) - - # Determine source type - if topic: - source_type = "topic" - else: # content - source_type = "content" + # ========== Step 0: Process text and determine title ========== + logger.info(f"🚀 Starting video generation in '{mode}' mode") + logger.info(f" Text length: {len(text)} chars") # Determine final title (priority: user-specified > auto-generated) if title: # User specified title, use it directly final_title = title - logger.info(f"🚀 Starting video generation from {source_type} with title: '{title}'") + logger.info(f" Title: '{title}' (user-specified)") else: - # Auto-generate title based on source - if source_type == "topic": - final_title = topic - logger.info(f"🚀 Starting video generation from topic: '{final_title}'") - else: # content - # Will generate title from content using LLM - logger.info(f"🚀 Starting video generation from content ({len(content)} chars)") - final_title = None # Will be generated later - - # Generate title from content if needed (before creating output path) - if source_type == "content" and final_title is None: - self._report_progress(progress_callback, "generating_title", 0.01) - final_title = await self._generate_title_from_content(content) - logger.info(f"✅ Generated title: {final_title}") + # Auto-generate title based on mode + if mode == "generate": + # Use text as title (it's a topic/theme) + final_title = text[:20] if len(text) > 20 else text + logger.info(f" Title: '{final_title}' (from text)") + else: # fixed + # Generate title from script using LLM + self._report_progress(progress_callback, "generating_title", 0.01) + final_title = await self._generate_title_from_content(text) + logger.info(f" Title: '{final_title}' (LLM-generated)") # Auto-generate output path if not provided if output_path is None: @@ -204,7 +200,7 @@ class VideoGeneratorService: # Create storyboard config config = StoryboardConfig( - n_storyboard=n_frames, + n_storyboard=n_scenes, min_narration_words=min_narration_words, max_narration_words=max_narration_words, min_image_prompt_words=min_image_prompt_words, @@ -230,24 +226,46 @@ class VideoGeneratorService: self.core._current_storyboard = storyboard try: - # ========== Step 1: Generate narrations ========== - self._report_progress(progress_callback, "generating_narrations", 0.05) - narrations = await self.core.narration_generator.generate_narrations( - config=config, - source_type=source_type, - content_metadata=None, # No metadata needed for topic/content - topic=topic if source_type == "topic" else None, - content=content if source_type == "content" else None - ) - logger.info(f"✅ Generated {len(narrations)} narrations") + # ========== Step 1: Generate/Split narrations ========== + if mode == "generate": + # Generate narrations using LLM + self._report_progress(progress_callback, "generating_narrations", 0.05) + narrations = await self.core.narration_generator.generate_narrations( + config=config, + source_type="topic", + content_metadata=None, + topic=text, + content=None + ) + logger.info(f"✅ Generated {len(narrations)} narrations") + else: # fixed + # Split fixed script using LLM (preserves original text) + self._report_progress(progress_callback, "splitting_script", 0.05) + narrations = await self._split_narration_script(text, config) + logger.info(f"✅ Split script into {len(narrations)} segments") + logger.info(f" Note: n_scenes={n_scenes} is ignored in fixed mode") # Step 2: Generate image prompts self._report_progress(progress_callback, "generating_image_prompts", 0.15) + + # Create progress callback wrapper for image prompt generation (15%-30% range) + def image_prompt_progress(completed: int, total: int, message: str): + # Map batch progress to 15%-30% range + batch_progress = completed / total if total > 0 else 0 + overall_progress = 0.15 + (batch_progress * 0.15) # 15% -> 30% + self._report_progress( + progress_callback, + "generating_image_prompts", + overall_progress, + extra_info=message + ) + image_prompts = await self.core.image_prompt_generator.generate_image_prompts( narrations=narrations, config=config, image_style_preset=image_style_preset, - image_style_description=image_style_description + image_style_description=image_style_description, + progress_callback=image_prompt_progress ) logger.info(f"✅ Generated {len(image_prompts)} image prompts") @@ -370,6 +388,169 @@ class VideoGeneratorService: else: logger.debug(f"Progress: {progress*100:.0f}% - {event_type}") + def _parse_json(self, text: str) -> dict: + """ + Parse JSON from text, with fallback to extract JSON from markdown code blocks + + Args: + text: Text containing JSON + + Returns: + Parsed JSON dict + """ + import json + import re + + # Try direct parsing first + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Try to extract JSON from markdown code block + json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```' + match = re.search(json_pattern, text, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + pass + + # Try to find any JSON object in the text (flexible pattern for narrations) + json_pattern = r'\{[^{}]*"narrations"\s*:\s*\[[^\]]*\][^{}]*\}' + match = re.search(json_pattern, text, re.DOTALL) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + + # If all fails, raise error + raise json.JSONDecodeError("No valid JSON found", text, 0) + + async def _split_narration_script(self, script: str, config: StoryboardConfig) -> list[str]: + """ + Split user-provided narration script into segments (programmatic splitting). + + Priority: + 1. Split by major punctuation (newline, 。!?;) + 2. If segment > max_len, split by comma (,) + 3. If still > max_len, keep original (no force split) + 4. Merge segments < min_len with next segment + + Args: + script: Fixed narration script + config: Storyboard configuration (for length guidelines) + + Returns: + List of narration segments + """ + import re + + min_len = config.min_narration_words + max_len = config.max_narration_words + + logger.info(f"Splitting script (length: {len(script)} chars) with target: {min_len}-{max_len} chars") + + # Step 1: Split by major punctuation (newline, period, exclamation, question mark, semicolon) + major_delimiters = r'[\n。!?;]' + parts = re.split(f'({major_delimiters})', script) + + # Reconstruct sentences (text only, remove trailing punctuation) + sentences = [] + for i in range(0, len(parts)-1, 2): + text = parts[i].strip() + if text: + sentences.append(text) + # Handle last part if no delimiter + if len(parts) % 2 == 1 and parts[-1].strip(): + sentences.append(parts[-1].strip()) + + logger.debug(f"After major split: {len(sentences)} sentences") + + # Step 2: For segments > max_len, try splitting by comma + final_segments = [] + for sentence in sentences: + sent_len = len(sentence) + + # If within range or short, keep as is + if sent_len <= max_len: + final_segments.append(sentence) + continue + + # Too long: try splitting by comma + comma_parts = re.split(r'(,)', sentence) + sub_segments = [] + current = "" + + for part in comma_parts: + if part == ',': + continue + + if not current: + current = part + elif len(current + part) <= max_len: + current += part + else: + # Current segment is ready + if current: + sub_segments.append(current.strip()) + current = part + + # Add last segment + if current: + sub_segments.append(current.strip()) + + # If comma splitting worked (resulted in multiple segments), use it + if sub_segments and len(sub_segments) > 1: + final_segments.extend(sub_segments) + else: + # Keep original sentence even if > max_len + logger.debug(f"Keeping long segment ({sent_len} chars): {sentence[:30]}...") + final_segments.append(sentence) + + # Step 3: Merge segments that are too short + merged_segments = [] + i = 0 + while i < len(final_segments): + segment = final_segments[i] + + # If too short and not the last one, try merging with next + if len(segment) < min_len and i < len(final_segments) - 1: + next_segment = final_segments[i + 1] + merged = segment + "," + next_segment + + # If merged result is within max_len, use it + if len(merged) <= max_len: + merged_segments.append(merged) + i += 2 # Skip next segment + continue + + # Otherwise keep as is + merged_segments.append(segment) + i += 1 + + # Clean up + result = [s.strip() for s in merged_segments if s.strip()] + + # Log statistics + lengths = [len(s) for s in result] + logger.info(f"Script split into {len(result)} segments") + if lengths: + logger.info(f" Min: {min(lengths)} chars, Max: {max(lengths)} chars, Avg: {sum(lengths)//len(lengths)} chars") + + in_range = sum(1 for l in lengths if min_len <= l <= max_len) + too_short = sum(1 for l in lengths if l < min_len) + too_long = sum(1 for l in lengths if l > max_len) + + logger.info(f" In range ({min_len}-{max_len}): {in_range}/{len(result)} ({in_range*100//len(result)}%)") + if too_short: + logger.info(f" Too short (< {min_len}): {too_short}/{len(result)} ({too_short*100//len(result)}%)") + if too_long: + logger.info(f" Too long (> {max_len}): {too_long}/{len(result)} ({too_long*100//len(result)}%)") + + return result + async def _generate_title_from_content(self, content: str) -> str: """ Generate a short, attractive title from user content using LLM @@ -380,21 +561,10 @@ class VideoGeneratorService: Returns: Generated title (10 characters or less) """ - # Take first 500 chars to avoid overly long prompts - content_preview = content[:500] + from reelforge.prompts import build_title_generation_prompt - prompt = f"""请为以下内容生成一个简短、有吸引力的标题(10字以内)。 - -内容: -{content_preview} - -要求: -1. 简短精炼,10字以内 -2. 准确概括核心内容 -3. 有吸引力,适合作为视频标题 -4. 只输出标题文本,不要其他内容 - -标题:""" + # Build prompt using template + prompt = build_title_generation_prompt(content, max_length=500) # Call LLM to generate title response = await self.core.llm( diff --git a/web.py b/web.py index c95eba6..9813e06 100644 --- a/web.py +++ b/web.py @@ -400,37 +400,34 @@ def main(): with st.container(border=True): st.markdown(f"**{tr('section.content_input')}**") - # Input mode selection - input_mode = st.radio( - "Input Mode", - [tr("input_mode.topic"), tr("input_mode.custom")], + # Processing mode selection + mode = st.radio( + "Processing Mode", + ["generate", "fixed"], horizontal=True, - label_visibility="collapsed" + format_func=lambda x: tr(f"mode.{x}"), + label_visibility="collapsed", + help=tr("mode.help") ) - topic = None - content = None - title = None + # Text input (unified for both modes) + text_placeholder = tr("input.topic_placeholder") if mode == "generate" else tr("input.content_placeholder") + text_height = 100 if mode == "generate" else 200 + text_help = tr("input.text_help_generate") if mode == "generate" else tr("input.text_help_fixed") - if input_mode == tr("input_mode.topic"): - topic = st.text_area( - tr("input.topic"), - placeholder=tr("input.topic_placeholder"), - height=100, - help=tr("input.topic_help") - ) + text = st.text_area( + tr("input.text"), + placeholder=text_placeholder, + height=text_height, + help=text_help + ) - else: # Custom Content - content = st.text_area( - tr("input.content"), - placeholder=tr("input.content_placeholder"), - height=200, - help=tr("input.content_help") - ) - title = st.text_input( - tr("input.title"), - placeholder=tr("input.title_placeholder") - ) + # Title input (optional for both modes) + title = st.text_input( + tr("input.title"), + placeholder=tr("input.title_placeholder"), + help=tr("input.title_help") + ) # ==================================================================== # Video Settings (moved from right column) @@ -438,16 +435,23 @@ def main(): with st.container(border=True): st.markdown(f"**{tr('video.title')}**") - # Number of frames - n_frames = st.slider( - tr("video.frames"), - min_value=3, - max_value=30, - value=5, - help=tr("video.frames_help"), - label_visibility="collapsed" - ) - st.caption(tr("video.frames_label", n=n_frames)) + # Number of scenes (only show in generate mode) + if mode == "generate": + n_scenes = st.slider( + tr("video.frames"), + min_value=3, + max_value=30, + value=5, + help=tr("video.frames_help"), + label_visibility="collapsed" + ) + st.caption(tr("video.frames_label", n=n_scenes)) + + st.markdown("---") + else: + # Fixed mode: n_scenes is ignored, set default value + n_scenes = 5 + st.info(tr("video.frames_fixed_mode_hint")) st.markdown("---") @@ -645,7 +649,7 @@ def main(): st.stop() # Validate input - if not topic and not content: + if not text: st.error(tr("error.input_required")) st.stop() @@ -680,6 +684,10 @@ def main(): # Simple events: use i18n key directly message = tr(f"progress.{event.event_type}") + # Append extra_info if available (e.g., batch progress) + if event.extra_info: + message = f"{message} - {event.extra_info}" + status_text.text(message) progress_bar.progress(min(int(event.progress * 100), 99)) # Cap at 99% until complete @@ -695,10 +703,10 @@ def main(): style_preset_param = style_preset result = run_async(reelforge.generate_video( - topic=topic if topic else None, - content=content if content else None, + text=text, + mode=mode, title=title if title else None, - n_frames=n_frames, + n_scenes=n_scenes, voice_id=voice_id, image_style_preset=style_preset_param, image_style_description=style_description_param,