Optimize the generation logic

This commit is contained in:
puke
2025-10-26 01:52:49 +08:00
committed by puke
parent 198094fe5f
commit f832424dab
17 changed files with 869 additions and 417 deletions

View File

@@ -12,6 +12,10 @@
"input_mode.topic": "💡 Topic", "input_mode.topic": "💡 Topic",
"input_mode.custom": "✍️ Custom Content", "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": "Book Name",
"input.book_name_placeholder": "e.g., Atomic Habits, How to Win Friends", "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", "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_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.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": "Content",
"input.content_placeholder": "Enter your custom content here...", "input.content_placeholder": "Enter your custom content here...",
"input.content_help": "Provide your own content for video generation", "input.content_help": "Provide your own content for video generation",
"input.title": "Title (Optional)", "input.title": "Title (Optional)",
"input.title_placeholder": "Video title (auto-generated if empty)", "input.title_placeholder": "Video title (auto-generated if empty)",
"input.title_help": "Optional: Custom title for the video",
"book.search": "🔍 Search Book", "book.search": "🔍 Search Book",
"book.searching": "Searching book...", "book.searching": "Searching book...",
@@ -63,9 +72,10 @@
"template.neon": "Neon", "template.neon": "Neon",
"video.title": "🎬 Video Settings", "video.title": "🎬 Video Settings",
"video.frames": "Frames", "video.frames": "Scenes",
"video.frames_help": "More frames = longer video", "video.frames_help": "More scenes = longer video",
"video.frames_label": "Frames: {n}", "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.title": "🎵 Background Music",
"bgm.none": "🔇 No BGM", "bgm.none": "🔇 No BGM",
@@ -90,6 +100,7 @@
"status.connection_failed": "❌ Connection failed", "status.connection_failed": "❌ Connection failed",
"progress.generating_narrations": "Generating narrations...", "progress.generating_narrations": "Generating narrations...",
"progress.splitting_script": "Splitting script...",
"progress.generating_image_prompts": "Generating image prompts...", "progress.generating_image_prompts": "Generating image prompts...",
"progress.frame": "Frame {current}/{total}", "progress.frame": "Frame {current}/{total}",
"progress.frame_step": "Frame {current}/{total} - Step {step}/4: {action}", "progress.frame_step": "Frame {current}/{total} - Step {step}/4: {action}",

View File

@@ -12,6 +12,10 @@
"input_mode.topic": "💡 主题", "input_mode.topic": "💡 主题",
"input_mode.custom": "✍️ 自定义内容", "input_mode.custom": "✍️ 自定义内容",
"mode.generate": "💡 生成模式",
"mode.fixed": "📄 固定模式",
"mode.help": "生成模式AI 从主题创作旁白。固定模式:使用现成脚本,不做修改。",
"input.book_name": "书名", "input.book_name": "书名",
"input.book_name_placeholder": "例如原子习惯、人性的弱点、Atomic Habits", "input.book_name_placeholder": "例如原子习惯、人性的弱点、Atomic Habits",
"input.book_name_help": "输入书名,将自动获取书籍信息并生成视频", "input.book_name_help": "输入书名,将自动获取书籍信息并生成视频",
@@ -20,12 +24,17 @@
"input.topic_placeholder": "例如如何增加被动收入、How to build good habits", "input.topic_placeholder": "例如如何增加被动收入、How to build good habits",
"input.topic_help": "输入一个主题AI 将根据主题生成内容", "input.topic_help": "输入一个主题AI 将根据主题生成内容",
"input.text": "文本输入",
"input.text_help_generate": "输入主题或话题AI 将创作旁白)",
"input.text_help_fixed": "输入完整的旁白脚本(将被切分成分镜)",
"input.content": "内容", "input.content": "内容",
"input.content_placeholder": "在此输入您的自定义内容...", "input.content_placeholder": "在此输入您的自定义内容...",
"input.content_help": "提供您自己的内容用于视频生成", "input.content_help": "提供您自己的内容用于视频生成",
"input.title": "标题(可选)", "input.title": "标题(可选)",
"input.title_placeholder": "视频标题(留空则自动生成)", "input.title_placeholder": "视频标题(留空则自动生成)",
"input.title_help": "可选:自定义视频标题",
"book.search": "🔍 搜索书籍", "book.search": "🔍 搜索书籍",
"book.searching": "正在搜索书籍...", "book.searching": "正在搜索书籍...",
@@ -63,9 +72,10 @@
"template.neon": "Neon", "template.neon": "Neon",
"video.title": "🎬 视频设置", "video.title": "🎬 视频设置",
"video.frames": "数", "video.frames": "分镜数",
"video.frames_help": "更多帧数 = 更长视频", "video.frames_help": "更多分镜 = 更长视频",
"video.frames_label": "数:{n}", "video.frames_label": "分镜数:{n}",
"video.frames_fixed_mode_hint": "💡 固定模式:分镜数由脚本实际段落数决定",
"bgm.title": "🎵 背景音乐", "bgm.title": "🎵 背景音乐",
"bgm.none": "🔇 无背景音乐", "bgm.none": "🔇 无背景音乐",
@@ -90,6 +100,7 @@
"status.connection_failed": "❌ 连接失败", "status.connection_failed": "❌ 连接失败",
"progress.generating_narrations": "生成旁白...", "progress.generating_narrations": "生成旁白...",
"progress.splitting_script": "切分脚本...",
"progress.generating_image_prompts": "生成图片提示词...", "progress.generating_image_prompts": "生成图片提示词...",
"progress.frame": "分镜 {current}/{total}", "progress.frame": "分镜 {current}/{total}",
"progress.frame_step": "分镜 {current}/{total} - 步骤 {step}/4: {action}", "progress.frame_step": "分镜 {current}/{total} - 步骤 {step}/4: {action}",

View File

@@ -43,6 +43,7 @@ class ProgressEvent:
frame_total: Optional[int] = None frame_total: Optional[int] = None
step: Optional[int] = None # 1-4 for frame processing steps step: Optional[int] = None # 1-4 for frame processing steps
action: Optional[str] = None # "audio", "image", "compose", "video" action: Optional[str] = None # "audio", "image", "compose", "video"
extra_info: Optional[str] = None # Additional information (e.g., batch progress)
def __post_init__(self): def __post_init__(self):
"""Validate progress value""" """Validate progress value"""

View File

@@ -11,10 +11,10 @@ from typing import List, Optional
class StoryboardConfig: class StoryboardConfig:
"""Storyboard configuration parameters""" """Storyboard configuration parameters"""
n_storyboard: int = 5 # Number of storyboard frames n_storyboard: int = 5 # Number of storyboard frames
min_narration_words: int = 30 # Min narration word count min_narration_words: int = 5 # Min narration word count
max_narration_words: int = 50 # Max narration word count max_narration_words: int = 20 # Max narration word count
min_image_prompt_words: int = 60 # Min image prompt word count min_image_prompt_words: int = 30 # Min image prompt word count
max_image_prompt_words: int = 100 # Max image prompt word count max_image_prompt_words: int = 60 # Max image prompt word count
# Video parameters # Video parameters
video_width: int = 1080 # Video width video_width: int = 1080 # Video width

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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
)

View File

@@ -1,5 +1,7 @@
""" """
Image prompt generation template Image prompt generation template
For generating image prompts from narrations.
""" """
import json import json
@@ -39,6 +41,8 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位
# 核心任务 # 核心任务
基于已有的视频脚本为每个分镜的"旁白内容"创作对应的**英文**图像提示词确保视觉画面与叙述内容完美配合增强观众的理解和记忆 基于已有的视频脚本为每个分镜的"旁白内容"创作对应的**英文**图像提示词确保视觉画面与叙述内容完美配合增强观众的理解和记忆
**重要输入包含 {narrations_count} 个旁白你必须为每个旁白都生成一个对应的图像提示词总共输出 {narrations_count} 个图像提示词**
# 输入内容 # 输入内容
{narrations_json} {narrations_json}
@@ -92,13 +96,14 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位
1. 只输出JSON格式内容不要添加任何解释说明 1. 只输出JSON格式内容不要添加任何解释说明
2. 确保JSON格式严格正确可以被程序直接解析 2. 确保JSON格式严格正确可以被程序直接解析
3. 输入是 {{"narrations": [旁白数组]}} 格式输出是 {{"image_prompts": [图像提示词数组]}} 格式 3. 输入是 {{"narrations": [旁白数组]}} 格式输出是 {{"image_prompts": [图像提示词数组]}} 格式
4. **图像提示词必须使用英文**for AI image generation models 4. **输出的image_prompts数组必须恰好包含 {narrations_count} 个元素与输入的narrations数组一一对应**
5. 图像提示词必须准确反映对应旁白的具体内容和情感 5. **图像提示词必须使用英文**for AI image generation models
6. 每个图像都要有创意性和视觉冲击力避免千篇一律 6. 图像提示词必须准确反映对应旁白的具体内容和情感
7. 严格遵守上述指定的画面风格要求{style_description} 7. 每个图像都要有创意性和视觉冲击力避免千篇一律
8. 确保视觉画面能增强文案的说服力和观众的理解度 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( return IMAGE_PROMPT_GENERATION_PROMPT.format(
narrations_json=narrations_json, narrations_json=narrations_json,
narrations_count=len(narrations),
min_words=min_words, min_words=min_words,
max_words=max_words, max_words=max_words,
style_description=style_desc style_description=style_desc

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -180,20 +180,10 @@ class FinalImagePromptService:
Returns: Returns:
Converted English image prompt suitable for image generation models 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: # Build prompt using template
llm_prompt = build_style_conversion_prompt(description)
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:"""
style_prompt = await self.core.llm(llm_prompt) style_prompt = await self.core.llm(llm_prompt)

View File

@@ -4,12 +4,12 @@ Image prompt generation service
import json import json
import re import re
from typing import List from typing import List, Optional, Callable
from loguru import logger from loguru import logger
from reelforge.models.storyboard import StoryboardConfig 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: class ImagePromptGeneratorService:
@@ -29,62 +29,93 @@ class ImagePromptGeneratorService:
narrations: List[str], narrations: List[str],
config: StoryboardConfig, config: StoryboardConfig,
image_style_preset: str = None, 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]: ) -> List[str]:
""" """
Generate image prompts based on narrations Generate image prompts based on narrations (with batching and retry)
Args: Args:
narrations: List of narrations narrations: List of narrations
config: Storyboard configuration config: Storyboard configuration
image_style_preset: Preset style name (e.g., "minimal", "futuristic") image_style_preset: Preset style name (e.g., "minimal", "futuristic")
image_style_description: Custom style description (overrides preset) 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: Returns:
List of image prompts with style applied List of image prompts with style applied
Raises: Raises:
ValueError: If generated prompt count doesn't match narrations ValueError: If batch fails after max_retries
json.JSONDecodeError: If unable to parse JSON 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) # Split narrations into batches
prompt = build_image_prompt_prompt( batches = [narrations[i:i + batch_size] for i in range(0, len(narrations), batch_size)]
narrations=narrations, logger.info(f"Split into {len(batches)} batches")
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
)
# 2. Call LLM to generate base scene descriptions all_base_prompts = []
response = await self.core.llm(
prompt=prompt,
temperature=0.9, # Higher temperature for more visual creativity
max_tokens=2000
)
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 base_prompts = all_base_prompts
try: logger.info(f"✅ All batches completed. Total prompts: {len(base_prompts)}")
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)}"
)
# 5. Apply style to each prompt using FinalImagePromptService # 5. Apply style to each prompt using FinalImagePromptService
from reelforge.services.final_image_prompt import StylePreset 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") logger.info(f"Generated {len(final_prompts)} final image prompts with style applied")
return final_prompts 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: def _parse_json(self, text: str) -> dict:
""" """
Parse JSON from text, with fallback to extract JSON from markdown code blocks Parse JSON from text, with fallback to extract JSON from markdown code blocks
@@ -127,7 +210,7 @@ class ImagePromptGeneratorService:
pass pass
# Try to extract JSON from markdown code block # 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) match = re.search(json_pattern, text, re.DOTALL)
if match: if match:
try: try:

View File

@@ -13,10 +13,9 @@ from typing import List, Optional, Literal
from loguru import logger from loguru import logger
from reelforge.models.storyboard import StoryboardConfig, ContentMetadata from reelforge.models.storyboard import StoryboardConfig, ContentMetadata
from reelforge.prompts.narration_template import ( from reelforge.prompts import (
build_topic_narration_prompt, build_topic_narration_prompt,
build_content_narration_prompt, build_content_narration_prompt,
build_narration_prompt # Keep for backward compatibility
) )
@@ -158,7 +157,7 @@ class NarrationGeneratorService:
pass pass
# Try to extract JSON from markdown code block # 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) match = re.search(json_pattern, text, re.DOTALL)
if match: if match:
try: try:

View File

@@ -43,23 +43,25 @@ class VideoGeneratorService:
async def __call__( async def __call__(
self, self,
# === Content Source (Choose ONE, mutually exclusive) === # === Input ===
topic: Optional[str] = None, text: str,
content: Optional[str] = None,
# === Optional Title (works with any source) === # === Processing Mode ===
mode: Literal["generate", "fixed"] = "generate",
# === Optional Title ===
title: Optional[str] = None, title: Optional[str] = None,
# === Basic Config === # === 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", voice_id: str = "zh-CN-YunjianNeural",
output_path: Optional[str] = None, output_path: Optional[str] = None,
# === LLM Parameters === # === LLM Parameters ===
min_narration_words: int = 20, min_narration_words: int = 5,
max_narration_words: int = 40, max_narration_words: int = 20,
min_image_prompt_words: int = 50, min_image_prompt_words: int = 30,
max_image_prompt_words: int = 100, max_image_prompt_words: int = 60,
# === Image Parameters === # === Image Parameters ===
image_width: int = 1024, image_width: int = 1024,
@@ -85,26 +87,33 @@ class VideoGeneratorService:
progress_callback: Optional[Callable[[ProgressEvent], None]] = None, progress_callback: Optional[Callable[[ProgressEvent], None]] = None,
) -> VideoGenerationResult: ) -> VideoGenerationResult:
""" """
Generate short video from different content sources Generate short video from text input
Args: Args:
topic: Topic/theme (e.g., "如何提高学习效率") text: Text input (required)
content: User-provided content (any length) - 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) title: Video title (optional)
- If provided, use it as the video title - If provided, use it as the video title
- If not provided, auto-generate based on source: - If not provided:
* topic → use topic text * generate mode → use text as title
* content → LLM extracts title from content * 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") voice_id: TTS voice ID (default "zh-CN-YunjianNeural")
output_path: Output video path (auto-generated if None) output_path: Output video path (auto-generated if None)
min_narration_words: Min narration length min_narration_words: Min narration length (generate mode only)
max_narration_words: Max narration length max_narration_words: Max narration length (generate mode only)
min_image_prompt_words: Min image prompt length min_image_prompt_words: Min image prompt length
max_image_prompt_words: Max image prompt length max_image_prompt_words: Max image prompt length
@@ -131,66 +140,53 @@ class VideoGeneratorService:
VideoGenerationResult with video path and metadata VideoGenerationResult with video path and metadata
Examples: Examples:
# Generate from topic # Generate mode: LLM creates narrations from topic
>>> result = await reelforge.generate_video( >>> result = await reelforge.generate_video(
... topic="如何在信息爆炸时代保持深度思考", ... text="如何在信息爆炸时代保持深度思考",
... n_frames=5, ... mode="generate",
... n_scenes=5,
... bgm_path="default" ... 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( >>> result = await reelforge.generate_video(
... content="昨天我读了一本书,讲的是...", ... text=script,
... n_frames=3 ... mode="fixed",
... title="三个学习技巧"
... ) ... )
# Generate from user content with custom title # Fixed mode: Use existing script (split by sentences)
>>> result = await reelforge.generate_video( >>> result = await reelforge.generate_video(
... content="买房子,第一应该看的是楼盘的整体环境...", ... text="第一点是专注。第二点是复述。第三点是重复。",
... title="买房风水指南", ... mode="fixed"
... n_frames=5
... ) ... )
>>> print(result.video_path) >>> print(result.video_path)
""" """
# ========== Step 0: Validate parameters (mutually exclusive) ========== # ========== Step 0: Process text and determine title ==========
sources = [topic, content] logger.info(f"🚀 Starting video generation in '{mode}' mode")
source_count = sum(x is not None for x in sources) logger.info(f" Text length: {len(text)} chars")
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"
# Determine final title (priority: user-specified > auto-generated) # Determine final title (priority: user-specified > auto-generated)
if title: if title:
# User specified title, use it directly # User specified title, use it directly
final_title = title final_title = title
logger.info(f"🚀 Starting video generation from {source_type} with title: '{title}'") logger.info(f" Title: '{title}' (user-specified)")
else: else:
# Auto-generate title based on source # Auto-generate title based on mode
if source_type == "topic": if mode == "generate":
final_title = topic # Use text as title (it's a topic/theme)
logger.info(f"🚀 Starting video generation from topic: '{final_title}'") final_title = text[:20] if len(text) > 20 else text
else: # content logger.info(f" Title: '{final_title}' (from text)")
# Will generate title from content using LLM else: # fixed
logger.info(f"🚀 Starting video generation from content ({len(content)} chars)") # Generate title from script using LLM
final_title = None # Will be generated later self._report_progress(progress_callback, "generating_title", 0.01)
final_title = await self._generate_title_from_content(text)
# Generate title from content if needed (before creating output path) logger.info(f" Title: '{final_title}' (LLM-generated)")
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 output path if not provided # Auto-generate output path if not provided
if output_path is None: if output_path is None:
@@ -204,7 +200,7 @@ class VideoGeneratorService:
# Create storyboard config # Create storyboard config
config = StoryboardConfig( config = StoryboardConfig(
n_storyboard=n_frames, n_storyboard=n_scenes,
min_narration_words=min_narration_words, min_narration_words=min_narration_words,
max_narration_words=max_narration_words, max_narration_words=max_narration_words,
min_image_prompt_words=min_image_prompt_words, min_image_prompt_words=min_image_prompt_words,
@@ -230,24 +226,46 @@ class VideoGeneratorService:
self.core._current_storyboard = storyboard self.core._current_storyboard = storyboard
try: try:
# ========== Step 1: Generate narrations ========== # ========== Step 1: Generate/Split narrations ==========
self._report_progress(progress_callback, "generating_narrations", 0.05) if mode == "generate":
narrations = await self.core.narration_generator.generate_narrations( # Generate narrations using LLM
config=config, self._report_progress(progress_callback, "generating_narrations", 0.05)
source_type=source_type, narrations = await self.core.narration_generator.generate_narrations(
content_metadata=None, # No metadata needed for topic/content config=config,
topic=topic if source_type == "topic" else None, source_type="topic",
content=content if source_type == "content" else None content_metadata=None,
) topic=text,
logger.info(f"✅ Generated {len(narrations)} narrations") 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 # Step 2: Generate image prompts
self._report_progress(progress_callback, "generating_image_prompts", 0.15) 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( image_prompts = await self.core.image_prompt_generator.generate_image_prompts(
narrations=narrations, narrations=narrations,
config=config, config=config,
image_style_preset=image_style_preset, 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") logger.info(f"✅ Generated {len(image_prompts)} image prompts")
@@ -370,6 +388,169 @@ class VideoGeneratorService:
else: else:
logger.debug(f"Progress: {progress*100:.0f}% - {event_type}") 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: async def _generate_title_from_content(self, content: str) -> str:
""" """
Generate a short, attractive title from user content using LLM Generate a short, attractive title from user content using LLM
@@ -380,21 +561,10 @@ class VideoGeneratorService:
Returns: Returns:
Generated title (10 characters or less) Generated title (10 characters or less)
""" """
# Take first 500 chars to avoid overly long prompts from reelforge.prompts import build_title_generation_prompt
content_preview = content[:500]
prompt = f"""请为以下内容生成一个简短、有吸引力的标题10字以内 # Build prompt using template
prompt = build_title_generation_prompt(content, max_length=500)
内容:
{content_preview}
要求:
1. 简短精炼10字以内
2. 准确概括核心内容
3. 有吸引力,适合作为视频标题
4. 只输出标题文本,不要其他内容
标题:"""
# Call LLM to generate title # Call LLM to generate title
response = await self.core.llm( response = await self.core.llm(

88
web.py
View File

@@ -400,37 +400,34 @@ def main():
with st.container(border=True): with st.container(border=True):
st.markdown(f"**{tr('section.content_input')}**") st.markdown(f"**{tr('section.content_input')}**")
# Input mode selection # Processing mode selection
input_mode = st.radio( mode = st.radio(
"Input Mode", "Processing Mode",
[tr("input_mode.topic"), tr("input_mode.custom")], ["generate", "fixed"],
horizontal=True, horizontal=True,
label_visibility="collapsed" format_func=lambda x: tr(f"mode.{x}"),
label_visibility="collapsed",
help=tr("mode.help")
) )
topic = None # Text input (unified for both modes)
content = None text_placeholder = tr("input.topic_placeholder") if mode == "generate" else tr("input.content_placeholder")
title = None 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"): text = st.text_area(
topic = st.text_area( tr("input.text"),
tr("input.topic"), placeholder=text_placeholder,
placeholder=tr("input.topic_placeholder"), height=text_height,
height=100, help=text_help
help=tr("input.topic_help") )
)
else: # Custom Content # Title input (optional for both modes)
content = st.text_area( title = st.text_input(
tr("input.content"), tr("input.title"),
placeholder=tr("input.content_placeholder"), placeholder=tr("input.title_placeholder"),
height=200, help=tr("input.title_help")
help=tr("input.content_help") )
)
title = st.text_input(
tr("input.title"),
placeholder=tr("input.title_placeholder")
)
# ==================================================================== # ====================================================================
# Video Settings (moved from right column) # Video Settings (moved from right column)
@@ -438,16 +435,23 @@ def main():
with st.container(border=True): with st.container(border=True):
st.markdown(f"**{tr('video.title')}**") st.markdown(f"**{tr('video.title')}**")
# Number of frames # Number of scenes (only show in generate mode)
n_frames = st.slider( if mode == "generate":
tr("video.frames"), n_scenes = st.slider(
min_value=3, tr("video.frames"),
max_value=30, min_value=3,
value=5, max_value=30,
help=tr("video.frames_help"), value=5,
label_visibility="collapsed" help=tr("video.frames_help"),
) label_visibility="collapsed"
st.caption(tr("video.frames_label", n=n_frames)) )
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("---") st.markdown("---")
@@ -645,7 +649,7 @@ def main():
st.stop() st.stop()
# Validate input # Validate input
if not topic and not content: if not text:
st.error(tr("error.input_required")) st.error(tr("error.input_required"))
st.stop() st.stop()
@@ -680,6 +684,10 @@ def main():
# Simple events: use i18n key directly # Simple events: use i18n key directly
message = tr(f"progress.{event.event_type}") 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) status_text.text(message)
progress_bar.progress(min(int(event.progress * 100), 99)) # Cap at 99% until complete 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 style_preset_param = style_preset
result = run_async(reelforge.generate_video( result = run_async(reelforge.generate_video(
topic=topic if topic else None, text=text,
content=content if content else None, mode=mode,
title=title if title else None, title=title if title else None,
n_frames=n_frames, n_scenes=n_scenes,
voice_id=voice_id, voice_id=voice_id,
image_style_preset=style_preset_param, image_style_preset=style_preset_param,
image_style_description=style_description_param, image_style_description=style_description_param,