Optimize the generation logic
This commit is contained in:
@@ -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}",
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
@@ -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:
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
88
web.py
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user