Optimize the generation logic
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
99
reelforge/prompts/README.md
Normal file
99
reelforge/prompts/README.md
Normal 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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
90
reelforge/prompts/content_narration.py
Normal file
90
reelforge/prompts/content_narration.py
Normal 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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
41
reelforge/prompts/style_conversion.py
Normal file
41
reelforge/prompts/style_conversion.py
Normal 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)
|
||||
|
||||
39
reelforge/prompts/title_generation.py
Normal file
39
reelforge/prompts/title_generation.py
Normal 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
|
||||
)
|
||||
|
||||
90
reelforge/prompts/topic_narration.py
Normal file
90
reelforge/prompts/topic_narration.py
Normal 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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
88
web.py
88
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,
|
||||
|
||||
Reference in New Issue
Block a user