diff --git a/.gitignore b/.gitignore index 749dc95..1a74ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,10 @@ repositories/ workflows/* !workflows/*_default.json !workflows/README.md + +# Templates - ignore user customizations but keep presets +templates/* +!templates/default.html +!templates/modern.html +!templates/neon.html +!templates/README.md diff --git a/config.example.yaml b/config.example.yaml index b585b5f..b29f02b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -56,6 +56,16 @@ image: default: default # Default preset name (uses workflows/image_default.json) comfyui_url: http://127.0.0.1:8188 # Local ComfyUI server # runninghub_api_key: "" # Optional: RunningHub cloud API key + + # Prompt prefix - automatically added to all image prompts + # Leave empty ("") if you don't want any prefix + prompt_prefix: "Pure white background, minimalist illustration, matchstick figure style, black and white line drawing, simple clean lines" + + # Common examples: + # prompt_prefix: "" # No prefix + # prompt_prefix: "anime style, vibrant colors, cel shading" # Anime style + # prompt_prefix: "watercolor painting, soft edges, artistic" # Watercolor + # prompt_prefix: "photorealistic, 8k, professional photography" # Realistic # ==================== Notes ==================== # 1. LLM Configuration: diff --git a/reelforge/i18n/locales/en_US.json b/reelforge/i18n/locales/en_US.json index 9ab0171..7bae6bb 100644 --- a/reelforge/i18n/locales/en_US.json +++ b/reelforge/i18n/locales/en_US.json @@ -56,7 +56,12 @@ "voice.previewing": "Generating voice preview...", "voice.preview_failed": "Preview failed: {error}", - "style.title": "🎨 Illustration Style", + "style.title": "🎨 Image Settings", + "style.workflow": "ComfyUI Workflow", + "style.workflow_help": "💡 Custom: Place image_xxx.json in workflows/ folder", + "style.prompt_prefix": "Style Prompt Prefix", + "style.prompt_prefix_placeholder": "Enter style prefix (leave empty for config default)", + "style.prompt_prefix_help": "This text will be automatically added before all image generation prompts. To permanently change, edit config.yaml", "style.custom": "Custom", "style.description": "Style Description", "style.description_placeholder": "Describe the illustration style you want (any language)...", @@ -67,9 +72,10 @@ "style.generated_prompt": "Generated prompt: {prompt}", "template.title": "📐 Storyboard Template", - "template.classic": "Classic", + "template.default": "Default", "template.modern": "Modern", "template.neon": "Neon", + "template.custom_help": "💡 Custom: Place .html files in templates/ folder", "video.title": "🎬 Video Settings", "video.frames": "Scenes", @@ -81,6 +87,7 @@ "bgm.none": "🔇 No BGM", "bgm.preview": "▶ Preview Music", "bgm.preview_failed": "❌ Music file not found: {file}", + "bgm.custom_help": "💡 Custom: Place audio files in bgm/ folder", "btn.generate": "🎬 Generate Video", "btn.save_config": "💾 Save Configuration", diff --git a/reelforge/i18n/locales/zh_CN.json b/reelforge/i18n/locales/zh_CN.json index 3e42858..5ecb863 100644 --- a/reelforge/i18n/locales/zh_CN.json +++ b/reelforge/i18n/locales/zh_CN.json @@ -56,7 +56,12 @@ "voice.previewing": "正在生成语音预览...", "voice.preview_failed": "预览失败:{error}", - "style.title": "🎨 插图风格", + "style.title": "🎨 插图设置", + "style.workflow": "生图工作流", + "style.workflow_help": "💡 自定义:将 image_xxx.json 放入 workflows/ 文件夹", + "style.prompt_prefix": "风格提示词前缀", + "style.prompt_prefix_placeholder": "输入风格前缀(留空则使用配置文件默认值)", + "style.prompt_prefix_help": "此文本将自动添加到所有图像生成提示词之前。要永久修改,请编辑 config.yaml", "style.custom": "自定义", "style.description": "风格描述", "style.description_placeholder": "描述您想要的插图风格(任何语言)...", @@ -67,9 +72,10 @@ "style.generated_prompt": "生成的提示词:{prompt}", "template.title": "📐 分镜模板", - "template.classic": "Classic", - "template.modern": "Modern", - "template.neon": "Neon", + "template.default": "默认", + "template.modern": "现代", + "template.neon": "霓虹", + "template.custom_help": "💡 自定义:将 .html 文件放入 templates/ 文件夹", "video.title": "🎬 视频设置", "video.frames": "分镜数", @@ -81,6 +87,7 @@ "bgm.none": "🔇 无背景音乐", "bgm.preview": "▶ 试听音乐", "bgm.preview_failed": "❌ 音乐文件未找到:{file}", + "bgm.custom_help": "💡 自定义:将音频文件放入 bgm/ 文件夹", "btn.generate": "🎬 生成视频", "btn.save_config": "💾 保存配置", diff --git a/reelforge/models/storyboard.py b/reelforge/models/storyboard.py index a5a0fee..48e0727 100644 --- a/reelforge/models/storyboard.py +++ b/reelforge/models/storyboard.py @@ -27,6 +27,7 @@ class StoryboardConfig: # Image parameters image_width: int = 1024 image_height: int = 1024 + image_preset: Optional[str] = None # Image workflow preset (None = use default) # Frame template frame_template: Optional[str] = None # HTML template name or path (None = use PIL) diff --git a/reelforge/prompts/image_generation.py b/reelforge/prompts/image_generation.py index fc51fc0..8dbdf6b 100644 --- a/reelforge/prompts/image_generation.py +++ b/reelforge/prompts/image_generation.py @@ -50,7 +50,6 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位 ## 图像提示词规范 - 语言:**必须使用英文**(用于 AI 图像生成模型) -- 画面风格:{style_description} - 描述结构:scene + character action + emotion + symbolic elements - 描述长度:确保描述清晰完整且富有创意(建议 50-100 个英文单词) @@ -100,8 +99,7 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位 5. **图像提示词必须使用英文**(for AI image generation models) 6. 图像提示词必须准确反映对应旁白的具体内容和情感 7. 每个图像都要有创意性和视觉冲击力,避免千篇一律 -8. 严格遵守上述指定的画面风格要求({style_description}) -9. 确保视觉画面能增强文案的说服力和观众的理解度 +8. 确保视觉画面能增强文案的说服力和观众的理解度 现在,请为上述 {narrations_count} 个旁白创作对应的 {narrations_count} 个**英文**图像提示词。只输出JSON,不要其他内容。 """ @@ -110,54 +108,24 @@ IMAGE_PROMPT_GENERATION_PROMPT = """# 角色定位 def build_image_prompt_prompt( narrations: List[str], min_words: int, - max_words: int, - image_style_preset: Optional[str] = None, - image_style_description: Optional[str] = None + max_words: int ) -> str: """ Build image prompt generation prompt + Note: Style/prefix will be applied later via prompt_prefix in config. + Args: narrations: List of narrations min_words: Minimum word count max_words: Maximum word count - image_style_preset: Preset style name (e.g., "minimal", "stick_figure", "concept") - Available presets: see IMAGE_STYLE_PRESETS - image_style_description: Custom style description (overrides preset if provided) - Example: "warm scenes, soft lighting, professional photography" Returns: - Formatted prompt + Formatted prompt for LLM - Examples: - # Use preset style - >>> build_image_prompt_prompt(narrations, 50, 100, image_style_preset="minimal") - - # Use custom style - >>> build_image_prompt_prompt( - ... narrations, 50, 100, - ... image_style_description="cyberpunk style, neon colors, futuristic" - ... ) - - # Use default style (stick_figure) + Example: >>> build_image_prompt_prompt(narrations, 50, 100) """ - # Determine style description - if image_style_description: - # Custom description takes priority - style_desc = image_style_description - elif image_style_preset: - # Use preset - if image_style_preset not in IMAGE_STYLE_PRESETS: - raise ValueError( - f"Unknown preset '{image_style_preset}'. " - f"Available presets: {list(IMAGE_STYLE_PRESETS.keys())}" - ) - style_desc = IMAGE_STYLE_PRESETS[image_style_preset]["description"] - else: - # Use default preset - style_desc = IMAGE_STYLE_PRESETS[DEFAULT_IMAGE_STYLE]["description"] - narrations_json = json.dumps( {"narrations": narrations}, ensure_ascii=False, @@ -168,7 +136,6 @@ def build_image_prompt_prompt( narrations_json=narrations_json, narrations_count=len(narrations), min_words=min_words, - max_words=max_words, - style_description=style_desc + max_words=max_words ) diff --git a/reelforge/service.py b/reelforge/service.py index bb2b52d..8f70a57 100644 --- a/reelforge/service.py +++ b/reelforge/service.py @@ -66,7 +66,6 @@ class ReelForgeCore: # Content generation services self.narration_generator = None self.image_prompt_generator = None - self.generate_final_image_prompt = None # Frame processing services self.frame_composer = None @@ -111,11 +110,9 @@ class ReelForgeCore: # 5. Initialize content generation services from reelforge.services.narration_generator import NarrationGeneratorService from reelforge.services.image_prompt_generator import ImagePromptGeneratorService - from reelforge.services.final_image_prompt import FinalImagePromptService self.narration_generator = NarrationGeneratorService(self) self.image_prompt_generator = ImagePromptGeneratorService(self) - self.generate_final_image_prompt = FinalImagePromptService(self) # 6. Initialize frame processing services from reelforge.services.frame_composer import FrameComposerService diff --git a/reelforge/services/__init__.py b/reelforge/services/__init__.py index 62b4813..7268c4b 100644 --- a/reelforge/services/__init__.py +++ b/reelforge/services/__init__.py @@ -14,11 +14,6 @@ from reelforge.services.image_prompt_generator import ImagePromptGeneratorServic from reelforge.services.frame_composer import FrameComposerService from reelforge.services.storyboard_processor import StoryboardProcessorService from reelforge.services.video_generator import VideoGeneratorService -from reelforge.services.final_image_prompt import ( - FinalImagePromptService, - StylePreset, - PresetValue -) __all__ = [ "BaseService", @@ -31,8 +26,5 @@ __all__ = [ "FrameComposerService", "StoryboardProcessorService", "VideoGeneratorService", - "FinalImagePromptService", - "StylePreset", - "PresetValue", ] diff --git a/reelforge/services/final_image_prompt.py b/reelforge/services/final_image_prompt.py deleted file mode 100644 index 1febf8f..0000000 --- a/reelforge/services/final_image_prompt.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -Final Image Prompt Service - -Generates final complete image prompts by converting style descriptions -and combining them with base prompts in consistent order. -""" - -from collections import namedtuple -from enum import Enum -from typing import Optional - -from loguru import logger - - -# Define preset value structure -PresetValue = namedtuple('PresetValue', ['display_name', 'prompt']) - - -class StylePreset(Enum): - """Predefined style presets for image generation""" - - STICK_FIGURE = PresetValue( - display_name="Stick Figure", - prompt=( - "Pure white background, minimalist illustration, matchstick figure style, " - "black and white line drawing, simple clean lines" - ), - ) - - MINIMAL = PresetValue( - display_name="Minimal", - prompt=( - "Simple and clean background, minimal design, soft colors, " - "professional look, modern aesthetic, uncluttered composition" - ), - ) - - FUTURISTIC = PresetValue( - display_name="Futuristic", - prompt=( - "Futuristic sci-fi style, high-tech city background, " - "blue and silver tones, technology sense, soft neon lights, " - "cyberpunk aesthetics, digital art, advanced technology" - ), - ) - - CINEMATIC = PresetValue( - display_name="Cinematic", - prompt=( - "Cinematic lighting, dramatic composition, film grain, " - "professional photography, depth of field, movie still quality" - ), - ) - - -class FinalImagePromptService: - """ - Final Image Prompt Service - - Generates the final complete image prompt by: - 1. Converting style description (preset or custom) - 2. Combining style + base prompt in correct order - - This ensures: - - Consistent style conversion logic across all scenarios - - Consistent prompt concatenation order (style first, then prompt) - - Single source of truth for image prompt generation - - Usage: - # With preset style - final = await reelforge.generate_final_image_prompt( - prompt="A peaceful mountain landscape", - style_preset=StylePreset.FUTURISTIC - ) - - # With custom style (any language) - final = await reelforge.generate_final_image_prompt( - prompt="A coffee cup on table", - custom_style_description="温馨的咖啡馆,暖色调" - ) - - # Only prompt (no style) - final = await reelforge.generate_final_image_prompt( - prompt="A sunset over the ocean" - ) - """ - - def __init__(self, reelforge_core): - """ - Initialize service - - Args: - reelforge_core: ReelForgeCore instance for accessing LLM - """ - self.core = reelforge_core - - async def __call__( - self, - prompt: str = "", - style_preset: Optional[StylePreset] = None, - custom_style_description: str = "" - ) -> str: - """ - Generate final image prompt with style - - Priority: - 1. custom_style_description (if provided) → convert via LLM - 2. style_preset (if provided) → use predefined English prompt - 3. Neither → just return prompt - - Concatenation: - - Style part (if exists) comes first - - Base prompt (if exists) comes second - - Join with comma: "{style_part}, {prompt}" - - Args: - prompt: Base prompt (optional, e.g., "A peaceful landscape") - style_preset: Preset style from StylePreset enum (optional) - custom_style_description: Custom description in any language (optional) - Overrides style_preset if provided - - Returns: - Final complete image prompt in English - - Examples: - # With preset style (IDE autocomplete!) - final = await service( - prompt="A mountain landscape", - style_preset=StylePreset.FUTURISTIC - ) - # Returns: "Futuristic sci-fi style..., A mountain landscape" - - # With custom style (any language) - final = await service( - prompt="A coffee cup", - custom_style_description="温馨的咖啡馆,暖色调" - ) - # Returns: "Cozy coffee shop interior..., A coffee cup" - - # Only prompt - final = await service(prompt="A sunset scene") - # Returns: "A sunset scene" - - # Only style - final = await service(style_preset=StylePreset.MINIMAL) - # Returns: "Simple and clean background..." - """ - - # Step 1: Determine style part - style_part = "" - - if custom_style_description: - # Priority 1: Custom description (convert via LLM) - logger.debug(f"Converting custom style description: {custom_style_description}") - style_part = await self._convert_custom_style(custom_style_description) - - elif style_preset: - # Priority 2: Preset style (use prompt from enum value) - style_part = style_preset.value.prompt - logger.debug(f"Using preset style: {style_preset.name}") - - # Step 2: Combine parts with comma - parts = [p for p in [style_part, prompt] if p] - final_prompt = ", ".join(parts) - - if final_prompt: - logger.debug(f"Final image prompt: {final_prompt}") - else: - logger.warning("Generated empty image prompt") - - return final_prompt - - async def _convert_custom_style(self, description: str) -> str: - """ - Convert custom style description to English image prompt via LLM - - Args: - description: User's style description in any language - - Returns: - Converted English image prompt suitable for image generation models - """ - from reelforge.prompts import build_style_conversion_prompt - - # Build prompt using template - llm_prompt = build_style_conversion_prompt(description) - - style_prompt = await self.core.llm(llm_prompt) - - # Clean up the result (remove extra whitespace, newlines) - style_prompt = " ".join(style_prompt.strip().split()) - - logger.debug(f"Converted custom style to: {style_prompt}") - - return style_prompt - diff --git a/reelforge/services/image_prompt_generator.py b/reelforge/services/image_prompt_generator.py index 4d5b7ff..786d0e3 100644 --- a/reelforge/services/image_prompt_generator.py +++ b/reelforge/services/image_prompt_generator.py @@ -28,8 +28,6 @@ class ImagePromptGeneratorService: self, narrations: List[str], config: StoryboardConfig, - image_style_preset: str = None, - image_style_description: str = None, batch_size: int = 10, max_retries: int = 3, progress_callback: Optional[Callable] = None @@ -40,14 +38,12 @@ class ImagePromptGeneratorService: 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 + List of image prompts with prompt_prefix applied (from config) Raises: ValueError: If batch fails after max_retries @@ -117,28 +113,20 @@ class ImagePromptGeneratorService: 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 + # 5. Apply prompt prefix to each prompt + from reelforge.utils.prompt_helper import build_image_prompt - # Convert style preset name to enum if provided - style_preset_enum = None - if image_style_preset: - try: - style_preset_enum = StylePreset[image_style_preset.upper()] - except KeyError: - logger.warning(f"Unknown style preset: {image_style_preset}") + # Get prompt prefix from config + image_config = self.core.config.get("image", {}) + prompt_prefix = image_config.get("prompt_prefix", "") - # Apply style to each base prompt + # Apply prefix to each base prompt final_prompts = [] for base_prompt in base_prompts: - final_prompt = await self.core.generate_final_image_prompt( - prompt=base_prompt, - style_preset=style_preset_enum, - custom_style_description=image_style_description or "" - ) + final_prompt = build_image_prompt(base_prompt, prompt_prefix) final_prompts.append(final_prompt) - logger.info(f"Generated {len(final_prompts)} final image prompts with style applied") + logger.info(f"Generated {len(final_prompts)} final image prompts with prefix applied") return final_prompts async def _generate_batch_prompts( @@ -170,9 +158,7 @@ class ImagePromptGeneratorService: 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 + max_words=config.max_image_prompt_words ) # 2. Call LLM diff --git a/reelforge/services/storyboard_processor.py b/reelforge/services/storyboard_processor.py index 268095a..445b86d 100644 --- a/reelforge/services/storyboard_processor.py +++ b/reelforge/services/storyboard_processor.py @@ -140,9 +140,10 @@ class StoryboardProcessorService: """Step 2: Generate image using ComfyKit""" logger.debug(f" 2/4: Generating image for frame {frame.index}...") - # Call Image generation (using default preset) + # Call Image generation (with optional preset) image_url = await self.core.image( prompt=frame.image_prompt, + preset=config.image_preset, # Pass preset from config (None = use default) width=config.image_width, height=config.image_height ) @@ -201,19 +202,17 @@ class StoryboardProcessorService: from pathlib import Path # Resolve template path - template_name = config.frame_template - if not template_name.endswith('.html'): - template_name = f"{template_name}.html" + template_filename = config.frame_template # Try templates/ directory first - template_path = Path(f"templates/{template_name}") + template_path = Path(f"templates/{template_filename}") if not template_path.exists(): - # Try as absolute path - template_path = Path(template_name) + # Try as absolute/relative path + template_path = Path(template_filename) if not template_path.exists(): raise FileNotFoundError( - f"Template not found: {template_name}. " - f"Available templates: classic, modern, minimal" + f"Template not found: {template_filename}. " + f"Built-in templates: default.html, modern.html, neon.html" ) # Get storyboard for content metadata diff --git a/reelforge/services/video.py b/reelforge/services/video.py index 08e77a6..34a5a53 100644 --- a/reelforge/services/video.py +++ b/reelforge/services/video.py @@ -101,7 +101,7 @@ class VideoService: - "filter": Slower but handles different formats bgm_path: Background music file path (optional) - None: No BGM - - "default" or "happy": Use built-in BGM from bgm/ folder + - Filename (e.g., "default.mp3", "happy.mp3"): Use built-in BGM from bgm/ folder - Custom path: Use custom BGM file bgm_volume: BGM volume level (0.0-1.0), default 0.2 bgm_mode: BGM playback mode @@ -504,11 +504,11 @@ class VideoService: def _resolve_bgm_path(self, bgm_path: str) -> str: """ - Resolve BGM path (preset name or custom path) + Resolve BGM path (filename or custom path) Args: bgm_path: Can be: - - Preset name (e.g., "default", "happy"): auto-resolved from bgm/ directory + - Filename with extension (e.g., "default.mp3", "happy.mp3"): auto-resolved from bgm/ directory - Custom file path (absolute or relative) Returns: @@ -521,8 +521,8 @@ class VideoService: if os.path.exists(bgm_path): return os.path.abspath(bgm_path) - # Try as preset in bgm/ directory - preset_path = f"bgm/{bgm_path}.mp3" + # Try as filename in bgm/ directory + preset_path = f"bgm/{bgm_path}" if os.path.exists(preset_path): return os.path.abspath(preset_path) @@ -532,9 +532,9 @@ class VideoService: os.path.abspath(preset_path) ] - # List available presets + # List available BGM files available_bgm = self._list_available_bgm() - available_msg = f"\n Available presets: {', '.join(available_bgm)}" if available_bgm else "" + available_msg = f"\n Available BGM files: {', '.join(available_bgm)}" if available_bgm else "" raise FileNotFoundError( f"BGM file not found: '{bgm_path}'\n" @@ -546,10 +546,10 @@ class VideoService: def _list_available_bgm(self) -> list[str]: """ - List available preset BGM files + List available BGM files in bgm/ directory Returns: - List of preset names (without .mp3 extension) + List of filenames (with extensions) """ bgm_dir = "bgm" if not os.path.exists(bgm_dir): @@ -557,7 +557,9 @@ class VideoService: try: files = os.listdir(bgm_dir) - return [f[:-4] for f in files if f.endswith('.mp3')] + # Return all audio files (mp3, wav, ogg, flac, etc.) + audio_extensions = ('.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac') + return [f for f in files if f.lower().endswith(audio_extensions)] except Exception: return [] diff --git a/reelforge/services/video_generator.py b/reelforge/services/video_generator.py index 4d765b3..f8b7876 100644 --- a/reelforge/services/video_generator.py +++ b/reelforge/services/video_generator.py @@ -66,8 +66,7 @@ class VideoGeneratorService: # === Image Parameters === image_width: int = 1024, image_height: int = 1024, - image_style_preset: Optional[str] = None, - image_style_description: Optional[str] = None, + image_preset: Optional[str] = None, # === Video Parameters === video_width: int = 1080, @@ -77,6 +76,9 @@ class VideoGeneratorService: # === Frame Template === frame_template: Optional[str] = None, + # === Image Style === + prompt_prefix: Optional[str] = None, + # === BGM Parameters === bgm_path: Optional[str] = None, bgm_volume: float = 0.2, @@ -119,17 +121,19 @@ class VideoGeneratorService: image_width: Generated image width (default 1024) image_height: Generated image height (default 1024) - image_style_preset: Preset style name (e.g., "minimal", "concept", "cinematic") - image_style_description: Custom style description (overrides preset) + image_preset: Image workflow preset (e.g., "flux", "sdxl", None = use default) video_width: Final video width (default 1080) video_height: Final video height (default 1920) video_fps: Video frame rate (default 30) - frame_template: HTML template name or path (None = use PIL) - e.g., "classic", "modern", "minimal", or custom path + frame_template: HTML template filename or path (None = use PIL) + e.g., "default.html", "modern.html", "neon.html", or custom path - bgm_path: BGM path ("default", "happy", custom path, or None) + prompt_prefix: Image prompt prefix (overrides config.yaml if provided) + e.g., "anime style, vibrant colors" or "" for no prefix + + bgm_path: BGM path (filename like "default.mp3", custom path, or None) bgm_volume: BGM volume 0.0-1.0 (default 0.2) bgm_mode: BGM mode "once" or "loop" (default "loop") @@ -211,6 +215,7 @@ class VideoGeneratorService: voice_id=voice_id, image_width=image_width, image_height=image_height, + image_preset=image_preset, frame_template=frame_template ) @@ -248,25 +253,36 @@ class VideoGeneratorService: # 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 - ) + # Override prompt_prefix if provided (temporarily modify config) + original_prefix = None + if prompt_prefix is not None: + image_config = self.core.config.get("image", {}) + original_prefix = image_config.get("prompt_prefix") + image_config["prompt_prefix"] = prompt_prefix + logger.info(f"Using custom prompt_prefix: '{prompt_prefix}'") - 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, - progress_callback=image_prompt_progress - ) + try: + # 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, + progress_callback=image_prompt_progress + ) + finally: + # Restore original prompt_prefix + if original_prefix is not None: + image_config["prompt_prefix"] = original_prefix logger.info(f"✅ Generated {len(image_prompts)} image prompts") # Step 3: Create frames diff --git a/reelforge/utils/prompt_helper.py b/reelforge/utils/prompt_helper.py new file mode 100644 index 0000000..c88fcb8 --- /dev/null +++ b/reelforge/utils/prompt_helper.py @@ -0,0 +1,38 @@ +""" +Prompt helper utilities + +Simple utilities for building prompts with optional prefixes. +""" + + +def build_image_prompt(prompt: str, prefix: str = "") -> str: + """ + Build final image prompt with optional prefix + + Args: + prompt: User's raw prompt + prefix: Optional prefix to add before the prompt + + Returns: + Final prompt with prefix applied (if provided) + + Examples: + >>> build_image_prompt("a cat", "") + 'a cat' + + >>> build_image_prompt("a cat", "anime style") + 'anime style, a cat' + + >>> build_image_prompt("a cat", " anime style ") + 'anime style, a cat' + """ + prefix = prefix.strip() if prefix else "" + prompt = prompt.strip() if prompt else "" + + if prefix and prompt: + return f"{prefix}, {prompt}" + elif prefix: + return prefix + else: + return prompt + diff --git a/start_web.sh b/start_web.sh index d42f464..f483a5e 100755 --- a/start_web.sh +++ b/start_web.sh @@ -15,5 +15,5 @@ if [ ! -f config.yaml ]; then fi # Start Streamlit -uv run streamlit run web.py --server.port=8501 --server.address=localhost +uv run streamlit run web.py diff --git a/templates/README.md b/templates/README.md deleted file mode 100644 index 894bc99..0000000 --- a/templates/README.md +++ /dev/null @@ -1,274 +0,0 @@ -# HTML Templates Guide - -## 📸 Preset Templates - -ReelForge provides the following preset templates: - -| Template | Style | Preview | -|----------|-------|---------| -| `classic` | Classic black & white, minimalist professional | Clean white background with elegant typography | -| `modern` | Modern gradient with glassmorphism effects | Purple-blue gradient with frosted glass style | -| `minimal` | Minimalist with ample whitespace | Light gray background with refined design | - -### Usage - -```python -from reelforge.service import reelforge - -await reelforge.initialize() - -# Use preset template -result = await reelforge.generate_video( - topic="为什么阅读改变命运", - frame_template="classic" # or "modern", "minimal" -) -``` - ---- - -## 🎨 Creating Custom Templates - -### Method 1: Generate with LLM (Recommended ⭐, Zero Code) - -**For users who want unique styles but don't want to write code** - -#### Step 1: Open Prompt File - -Open [`prompts/generate_html_template.txt`](../prompts/generate_html_template.txt) in the project root directory. - -Or view online: [View Prompt File](https://github.com/xxx/ReelForge/blob/main/prompts/generate_html_template.txt) - -#### Step 2: Copy Prompt, Modify Style Description - -Find this section in the prompt: -``` -## Visual Style -[👉 Replace this section with your desired style] -``` - -Replace with your desired style, for example: -``` -## Visual Style -Cyberpunk style with neon lights, dark background, purple and blue gradients -``` - -#### Step 3: Paste to LLM Platform - -Copy the entire prompt and paste it into any of these platforms: -- 💬 ChatGPT: https://chat.openai.com -- 💬 Claude: https://claude.ai -- 💬 Tongyi Qianwen: https://tongyi.aliyun.com -- 💬 DeepSeek: https://chat.deepseek.com -- 💬 Doubao: https://www.doubao.com -- 💬 Or any LLM you prefer - -#### Step 4: Save HTML - -Copy the HTML code returned by the LLM. - -⚠️ **Note:** If the LLM returns HTML wrapped in \`\`\`html\`\`\`, only copy the HTML inside (excluding \`\`\`html and \`\`\`) - -Save as: -``` -templates/my-cyberpunk.html -``` - -#### Step 5: Use Template - -```python -await reelforge.generate_video( - topic="...", - frame_template="my-cyberpunk" # Use your template -) -``` - -**🎉 Done! Your video now uses your custom style!** - ---- - -### Example Style Descriptions - -Here are some style descriptions you can use or adapt: - -- "Cyberpunk style with neon lights, dark background, purple gradient" -- "Fresh and clean style, light blue background, rounded cards, soft shadows" -- "Professional business style, dark blue with gold accents, serious and dignified" -- "Cute cartoon style, pink theme, rounded fonts, rainbow gradient" -- "Vintage retro style, sepia tones, aged paper texture, rounded borders" -- "Tech minimalist style, pure white background, thin borders, subtle animations" -- "Warm reading style, beige background, book texture, soft lighting" - ---- - -### Method 2: Write HTML Manually (Full Control) - -**For users familiar with HTML/CSS** - -Create an HTML file directly: - -```html - - - - - - - - -
{{topic}}
- -
- Frame Image -
- -
-
{{text}}
-
《{{book_title}}》 - {{book_author}}
-
- - -``` - -**Available Variables:** - -Required (always available): -- `{{topic}}` - Video topic -- `{{text}}` - Current frame narration text -- `{{image}}` - AI-generated image path - -Optional (available via `ext` parameter): -- `{{book_title}}` - Book title -- `{{book_author}}` - Author -- `{{book_cover}}` - Book cover path -- `{{book_rating}}` - Book rating -- And any other custom fields you pass via `ext` - ---- - -## 🤝 Share Your Templates - -Created a beautiful template? Share it with the community! - -1. Fork this repository -2. Add your HTML to `templates/community/` -3. Submit a Pull Request - -Excellent templates will be featured in the official template gallery! - ---- - -## ❓ FAQ - -**Q: What if the HTML generated by LLM has syntax errors?** -A: Regenerate, or emphasize "ensure HTML syntax is correct" in the prompt - -**Q: What if I'm not satisfied with the generated style?** -A: Modify the style description in the prompt, or manually edit the HTML file - -**Q: Can I use external CSS frameworks (like Tailwind)?** -A: Yes, but you need CDN links. We recommend inline CSS for offline compatibility - -**Q: Where should template files be placed?** -A: In the `templates/` directory. The filename is the template name (without .html extension) - -**Q: What's the recommended canvas size?** -A: 1080x1920 (9:16 portrait for TikTok/Douyin) - -**Q: Can I use custom fonts?** -A: Yes, but system fonts are recommended (PingFang SC, Microsoft YaHei, etc.) for compatibility - ---- - -## 📚 Technical Details - -### How HTML Rendering Works - -1. Load HTML template file -2. Replace variables (`{{topic}}`, `{{text}}`, etc.) with actual data -3. Render HTML to image using `html2image` (Chrome headless) -4. Save as frame image - -### Performance - -- First render: ~1-2 seconds (browser initialization) -- Subsequent renders: ~0.5-1 second per frame -- For 5-frame videos: acceptable total time - -### Browser Requirements - -`html2image` uses Chrome/Chromium headless mode. It will automatically find: -- System Chrome -- System Chromium -- Or download a lightweight browser engine - ---- - -## 💡 Tips - -1. **Preview before generating**: Open HTML in browser to preview effect -2. **Maintain aspect ratio**: Keep 1080x1920 for best results -3. **Watch text length**: Ensure long text wraps properly (`word-wrap`, `overflow-wrap`) -4. **Consider contrast**: Ensure text is readable against background -5. **Use relative units**: `%` and `vh/vw` for better responsiveness - ---- - -## 🎨 Inspiration Gallery - -Looking for inspiration? Check out: -- [Official Template Gallery](https://reelforge.ai/templates) (coming soon) -- [Community Templates](./community/) (coming soon) -- Design sites: Dribbble, Behance, Pinterest - ---- - -Need help? Open an [Issue](https://github.com/xxx/ReelForge/issues) or join our [Discord](https://discord.gg/xxx)! - diff --git a/templates/classic.html b/templates/default.html similarity index 100% rename from templates/classic.html rename to templates/default.html diff --git a/web.py b/web.py index 7cdaadf..1c16c84 100644 --- a/web.py +++ b/web.py @@ -77,51 +77,32 @@ def init_i18n(): # Preview Cache Functions # ============================================================================ -def generate_style_preview_cached( - style_preset_name: str, - custom_style_description: str -): +def generate_style_preview_cached(prompt_prefix: str): """ Generate and cache visual style preview Args: - style_preset_name: Preset style name ("minimal", "futuristic", etc.) or empty - custom_style_description: Custom description or empty + prompt_prefix: Prompt prefix to test Returns: - Tuple of (image_path, converted_prompt_if_custom) + Generated image path """ - from reelforge.services.final_image_prompt import StylePreset + from reelforge.utils.prompt_helper import build_image_prompt reelforge = get_reelforge() - # Convert style preset name to enum - style_preset = None - if style_preset_name: - try: - style_preset = StylePreset[style_preset_name.upper()] - except KeyError: - pass - - # Generate final prompt using the new service - final_prompt = run_async(reelforge.generate_final_image_prompt( - prompt="A peaceful mountain landscape", - style_preset=style_preset, - custom_style_description=custom_style_description - )) + # Build final prompt with prefix + test_prompt = "A peaceful mountain landscape" + final_prompt = build_image_prompt(test_prompt, prompt_prefix) # Generate preview image (small size for speed) - # Using default preset (workflows/image_default.json) preview_image_path = run_async(reelforge.image( prompt=final_prompt, width=512, height=512 )) - # Return converted prompt only for custom mode - converted_prompt = final_prompt if custom_style_description else None - - return preview_image_path, converted_prompt + return preview_image_path # ============================================================================ @@ -452,53 +433,7 @@ def main(): st.markdown("---") - # Background music - st.markdown(f"**{tr('bgm.title')}**") - - # Dynamically scan bgm folder for music files (support common audio formats) - bgm_folder = Path("bgm") - bgm_files = [] - if bgm_folder.exists(): - audio_extensions = ["*.mp3", "*.wav", "*.flac", "*.m4a", "*.aac", "*.ogg"] - for ext in audio_extensions: - bgm_files.extend([f.name for f in bgm_folder.glob(ext)]) - bgm_files.sort() - - # Add special "None" option - bgm_options = [tr("bgm.none")] + bgm_files - - # Default to "default.mp3" if exists, otherwise first option - default_index = 0 - if "default.mp3" in bgm_files: - default_index = bgm_options.index("default.mp3") - - bgm_choice = st.selectbox( - "BGM", - bgm_options, - index=default_index, - label_visibility="collapsed" - ) - - # BGM preview button (only if BGM is not "None") - if bgm_choice != tr("bgm.none"): - if st.button(tr("bgm.preview"), key="preview_bgm", use_container_width=True): - bgm_file_path = f"bgm/{bgm_choice}" - if os.path.exists(bgm_file_path): - st.audio(bgm_file_path) - else: - st.error(tr("bgm.preview_failed", file=bgm_choice)) - - # Extract filename without extension for bgm_path (service layer expects stem only) - bgm_path = None if bgm_choice == tr("bgm.none") else Path(bgm_choice).stem - - # ======================================================================== - # Middle Column: Custom Settings (Voice & Visual Style & Template) - # ======================================================================== - with middle_col: - with st.container(border=True): - st.markdown(f"**{tr('section.style_settings')}**") - - # Voice selection + # Voice selection (moved from middle column) st.markdown(f"**{tr('voice.title')}**") voice_id = st.selectbox( "Voice", @@ -538,63 +473,108 @@ def main(): except Exception as e: st.error(tr("voice.preview_failed", error=str(e))) logger.exception(e) + + # ======================================================================== + # Middle Column: Custom Settings (BGM & Visual Style & Template) + # ======================================================================== + with middle_col: + with st.container(border=True): + st.markdown(f"**{tr('section.style_settings')}**") - st.markdown("---") + # Background music (moved from left column) + st.markdown(f"**{tr('bgm.title')}**") + st.caption(tr("bgm.custom_help")) - # Visual style (Illustration Style) - st.markdown(f"**{tr('style.title')}**") + # Dynamically scan bgm folder for music files (support common audio formats) + bgm_folder = Path("bgm") + bgm_files = [] + if bgm_folder.exists(): + audio_extensions = ["*.mp3", "*.wav", "*.flac", "*.m4a", "*.aac", "*.ogg"] + for ext in audio_extensions: + bgm_files.extend([f.name for f in bgm_folder.glob(ext)]) + bgm_files.sort() - # Get available presets dynamically from StylePreset enum - from reelforge.services.final_image_prompt import StylePreset + # Add special "None" option + bgm_options = [tr("bgm.none")] + bgm_files - # Build preset options (enum values + custom) - preset_options = [preset.name.lower() for preset in StylePreset] - preset_options.append("custom") + # Default to "default.mp3" if exists, otherwise first option + default_index = 0 + if "default.mp3" in bgm_files: + default_index = bgm_options.index("default.mp3") - # Dynamic display name function - def get_preset_display(preset_name: str) -> str: - if preset_name == "custom": - return tr("style.custom") - # Find the enum by name and get its display_name - for preset in StylePreset: - if preset.name.lower() == preset_name: - return preset.value.display_name - return preset_name.replace("_", " ").title() - - # Change from radio to selectbox (dropdown) - style_preset = st.selectbox( - "Style Preset", - preset_options, - format_func=get_preset_display, + bgm_choice = st.selectbox( + "BGM", + bgm_options, + index=default_index, label_visibility="collapsed" ) - # Custom style description input (only show for custom mode) - custom_style_description = "" - if style_preset == "custom": - custom_style_description = st.text_area( - tr("style.description"), - placeholder=tr("style.description_placeholder"), - height=80, - label_visibility="collapsed" - ) + # BGM preview button (only if BGM is not "None") + if bgm_choice != tr("bgm.none"): + if st.button(tr("bgm.preview"), key="preview_bgm", use_container_width=True): + bgm_file_path = f"bgm/{bgm_choice}" + if os.path.exists(bgm_file_path): + st.audio(bgm_file_path) + else: + st.error(tr("bgm.preview_failed", file=bgm_choice)) + + # Use full filename for bgm_path (including extension) + bgm_path = None if bgm_choice == tr("bgm.none") else bgm_choice + + + # Visual style (Workflow + Prompt Prefix) + st.markdown(f"**{tr('style.title')}**") + + # 1. ComfyUI Workflow selection + st.caption(tr("style.workflow")) + st.caption(tr("style.workflow_help")) + + # Dynamically scan workflows folder for image_*.json files + workflows_folder = Path("workflows") + workflow_files = [] + if workflows_folder.exists(): + workflow_files = sorted([f.name for f in workflows_folder.glob("image_*.json")]) + + # Default to "image_default.json" if exists, otherwise first option + default_workflow_index = 0 + if "image_default.json" in workflow_files: + default_workflow_index = workflow_files.index("image_default.json") + + workflow_filename = st.selectbox( + "Workflow", + workflow_files if workflow_files else ["image_default.json"], + index=default_workflow_index, + label_visibility="collapsed", + key="image_preset_select" + ) + + # Extract preset name from filename: "image_default.json" -> "default" + image_preset = workflow_filename.replace("image_", "").replace(".json", "") if workflow_filename else None + + + # 2. Prompt prefix input + st.caption(tr("style.prompt_prefix")) + + # Get current prompt_prefix from config + image_config = config_manager.config.get("image", {}) + current_prefix = image_config.get("prompt_prefix", "") + + # Prompt prefix input (temporary, not saved to config) + prompt_prefix = st.text_area( + "Prompt Prefix", + value=current_prefix, + placeholder=tr("style.prompt_prefix_placeholder"), + height=80, + label_visibility="collapsed", + help=tr("style.prompt_prefix_help") + ) # Visual style preview button if st.button(tr("style.preview"), key="preview_style", use_container_width=True): with st.spinner(tr("style.previewing")): try: # Generate preview using cached function - preset_name = style_preset if style_preset != "custom" else "" - custom_desc = custom_style_description if style_preset == "custom" else "" - - preview_image_path, generated_prompt = generate_style_preview_cached( - style_preset_name=preset_name, - custom_style_description=custom_desc - ) - - # Show generated prompt if custom mode - if generated_prompt: - st.info(tr("style.generated_prompt", prompt=generated_prompt)) + preview_image_path = generate_style_preview_cached(prompt_prefix) # Display preview (support both URL and local path) if preview_image_path: @@ -609,21 +589,39 @@ def main(): img_html = f'
Style Preview
' st.markdown(img_html, unsafe_allow_html=True) - st.caption(tr("style.preview_caption")) + st.caption("Preview with test prompt: 'A peaceful mountain landscape'") + + # Show the final prompt used + from reelforge.utils.prompt_helper import build_image_prompt + test_prompt = "A peaceful mountain landscape" + final_prompt = build_image_prompt(test_prompt, prompt_prefix) + st.info(f"Final prompt used: {final_prompt}") else: st.error("Failed to generate preview image") except Exception as e: st.error(tr("style.preview_failed", error=str(e))) logger.exception(e) - st.markdown("---") # Frame template (moved from right column) st.markdown(f"**{tr('template.title')}**") + st.caption(tr("template.custom_help")) + + # Dynamically scan templates folder for HTML files + templates_folder = Path("templates") + template_files = [] + if templates_folder.exists(): + template_files = sorted([f.name for f in templates_folder.glob("*.html")]) + + # Default to default.html if exists, otherwise first option + default_template_index = 0 + if "default.html" in template_files: + default_template_index = template_files.index("default.html") + frame_template = st.selectbox( "Template", - ["classic", "modern", "neon"], - format_func=lambda x: tr(f"template.{x}"), + template_files if template_files else ["default.html"], + index=default_template_index, label_visibility="collapsed" ) @@ -688,26 +686,16 @@ def main(): status_text.text(message) progress_bar.progress(min(int(event.progress * 100), 99)) # Cap at 99% until complete - # Prepare image style parameters - style_preset_param = None - style_description_param = None - - if style_preset == "custom": - # Custom mode: pass user description as-is - style_description_param = custom_style_description - else: - # Preset mode: pass preset name - style_preset_param = style_preset - + # Generate video (directly pass parameters) result = run_async(reelforge.generate_video( text=text, mode=mode, title=title if title else None, n_scenes=n_scenes, voice_id=voice_id, - image_style_preset=style_preset_param, - image_style_description=style_description_param, + image_preset=image_preset, # Pass image_preset frame_template=frame_template, + prompt_prefix=prompt_prefix, # Pass prompt_prefix bgm_path=bgm_path, progress_callback=update_progress, ))