From be5b749913f8eedc787e8c55f90171c69db7387d Mon Sep 17 00:00:00 2001 From: puke Date: Mon, 27 Oct 2025 23:24:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A0=87=E9=A2=98=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reelforge/models/storyboard.py | 4 +- reelforge/service.py | 6 +- reelforge/services/__init__.py | 4 +- reelforge/services/frame_composer.py | 166 --------------------- reelforge/services/frame_html.py | 6 +- reelforge/services/storyboard_processor.py | 28 +--- reelforge/services/title_generator.py | 138 +++++++++++++++++ reelforge/services/video_generator.py | 57 ++----- templates/default.html | 16 +- templates/modern.html | 34 ++--- templates/neon.html | 8 +- 11 files changed, 191 insertions(+), 276 deletions(-) delete mode 100644 reelforge/services/frame_composer.py create mode 100644 reelforge/services/title_generator.py diff --git a/reelforge/models/storyboard.py b/reelforge/models/storyboard.py index 94622a6..9f237ef 100644 --- a/reelforge/models/storyboard.py +++ b/reelforge/models/storyboard.py @@ -30,7 +30,7 @@ class StoryboardConfig: image_workflow: Optional[str] = None # Image workflow filename (None = use default) # Frame template - frame_template: Optional[str] = None # HTML template name or path (None = use PIL) + frame_template: str = "default.html" # HTML template name or path (e.g., "default.html", "modern.html") @dataclass @@ -70,7 +70,7 @@ class ContentMetadata: @dataclass class Storyboard: """Complete storyboard""" - topic: str # Topic or content title + title: str # Video title config: StoryboardConfig # Configuration frames: List[StoryboardFrame] = field(default_factory=list) diff --git a/reelforge/service.py b/reelforge/service.py index 512a475..49473d6 100644 --- a/reelforge/service.py +++ b/reelforge/service.py @@ -14,7 +14,7 @@ from reelforge.services.tts_service import TTSService from reelforge.services.image import ImageService from reelforge.services.narration_generator import NarrationGeneratorService from reelforge.services.image_prompt_generator import ImagePromptGeneratorService -from reelforge.services.frame_composer import FrameComposerService +from reelforge.services.title_generator import TitleGeneratorService from reelforge.services.storyboard_processor import StoryboardProcessorService from reelforge.services.video_generator import VideoGeneratorService @@ -67,9 +67,9 @@ class ReelForgeCore: # Content generation services self.narration_generator: Optional[NarrationGeneratorService] = None self.image_prompt_generator: Optional[ImagePromptGeneratorService] = None + self.title_generator: Optional[TitleGeneratorService] = None # Frame processing services - self.frame_composer: Optional[FrameComposerService] = None self.storyboard_processor: Optional[StoryboardProcessorService] = None # Video generation service (named as verb for direct calling) @@ -98,9 +98,9 @@ class ReelForgeCore: # 2. Initialize content generation services self.narration_generator = NarrationGeneratorService(self) self.image_prompt_generator = ImagePromptGeneratorService(self) + self.title_generator = TitleGeneratorService(self) # 3. Initialize frame processing services - self.frame_composer = FrameComposerService() self.storyboard_processor = StoryboardProcessorService(self) # 4. Initialize video generation service diff --git a/reelforge/services/__init__.py b/reelforge/services/__init__.py index ed02440..b749fdb 100644 --- a/reelforge/services/__init__.py +++ b/reelforge/services/__init__.py @@ -11,7 +11,7 @@ from reelforge.services.image import ImageService from reelforge.services.video import VideoService from reelforge.services.narration_generator import NarrationGeneratorService from reelforge.services.image_prompt_generator import ImagePromptGeneratorService -from reelforge.services.frame_composer import FrameComposerService +from reelforge.services.title_generator import TitleGeneratorService from reelforge.services.storyboard_processor import StoryboardProcessorService from reelforge.services.video_generator import VideoGeneratorService @@ -23,7 +23,7 @@ __all__ = [ "VideoService", "NarrationGeneratorService", "ImagePromptGeneratorService", - "FrameComposerService", + "TitleGeneratorService", "StoryboardProcessorService", "VideoGeneratorService", ] diff --git a/reelforge/services/frame_composer.py b/reelforge/services/frame_composer.py deleted file mode 100644 index 2e5cf4d..0000000 --- a/reelforge/services/frame_composer.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Frame composer service - Compose image with subtitle overlay - -Simple implementation for MVP: adds subtitle text to generated image. -""" - -from pathlib import Path - -from loguru import logger -from PIL import Image, ImageDraw, ImageFont - -from reelforge.models.storyboard import StoryboardConfig - - -class FrameComposerService: - """ - Frame composer service - - Composes final frame image by adding subtitle text to generated image. - For MVP, we keep it simple - just overlay subtitle at bottom. - """ - - def __init__(self): - self.font_path = self._get_font_path() - - def _get_font_path(self) -> str: - """Get Chinese font path""" - # Try multiple possible font paths - possible_fonts = [ - "/System/Library/Fonts/PingFang.ttc", # macOS PingFang - "/System/Library/Fonts/STHeiti Light.ttc", # macOS Heiti - "/System/Library/Fonts/Hiragino Sans GB.ttc", # macOS Hiragino - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", # Linux Noto - ] - - for font in possible_fonts: - if Path(font).exists(): - logger.debug(f"Using font: {font}") - return font - - logger.warning("No Chinese font found, will use default") - return None - - async def compose_frame( - self, - image_path: str, - subtitle: str, - output_path: str, - config: StoryboardConfig - ) -> str: - """ - Compose frame image with subtitle overlay - - Args: - image_path: Generated image path - subtitle: Subtitle text (narration) - output_path: Output path for composed image - config: Storyboard configuration - - Returns: - Path to composed image - """ - logger.debug(f"Composing frame: {output_path}") - - # Load generated image - img = Image.open(image_path) - - # Create canvas with target video size - canvas = Image.new('RGB', (config.video_width, config.video_height), 'white') - - # Calculate scaling to fit image in canvas while preserving aspect ratio - # Leave space at top (100px) and bottom (300px for subtitle) - max_img_width = config.video_width - max_img_height = config.video_height - 400 # Reserve space for subtitle - - # Calculate aspect-fit scaling - img_aspect = img.width / img.height - target_aspect = max_img_width / max_img_height - - if img_aspect > target_aspect: - # Image is wider - fit to width - new_width = max_img_width - new_height = int(max_img_width / img_aspect) - else: - # Image is taller - fit to height - new_height = max_img_height - new_width = int(max_img_height * img_aspect) - - img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # Paste image at center-top - x_offset = (config.video_width - new_width) // 2 - y_offset = 100 # Leave some space at top - canvas.paste(img_resized, (x_offset, y_offset)) - - # Add subtitle at bottom - draw = ImageDraw.Draw(canvas) - self._draw_subtitle(draw, subtitle, config.video_width, config.video_height) - - # Save - canvas.save(output_path, quality=95) - logger.debug(f"Frame composed: {output_path}") - - return output_path - - def _draw_subtitle( - self, - draw: ImageDraw.Draw, - text: str, - width: int, - height: int - ): - """Draw subtitle text at bottom of canvas""" - try: - if self.font_path: - font = ImageFont.truetype(self.font_path, 40) - else: - font = ImageFont.load_default() - except Exception as e: - logger.warning(f"Failed to load font: {e}, using default") - font = ImageFont.load_default() - - # Wrap text if too long - max_chars_per_line = 25 - lines = self._wrap_text(text, max_chars_per_line) - - # Calculate position (bottom area) - line_height = 60 - total_height = len(lines) * line_height - y_start = height - total_height - 150 # 150px from bottom - - # Draw each line centered - for i, line in enumerate(lines): - # Get text bounding box - bbox = draw.textbbox((0, 0), line, font=font) - text_width = bbox[2] - bbox[0] - - # Center horizontally - x = (width - text_width) // 2 - y = y_start + i * line_height - - # Draw shadow for better readability - draw.text((x + 2, y + 2), line, fill='lightgray', font=font) - # Draw main text - draw.text((x, y), line, fill='black', font=font) - - def _wrap_text(self, text: str, max_chars: int) -> list: - """Wrap text into multiple lines""" - if len(text) <= max_chars: - return [text] - - lines = [] - current_line = "" - - for char in text: - if len(current_line) >= max_chars: - lines.append(current_line) - current_line = char - else: - current_line += char - - if current_line: - lines.append(current_line) - - return lines - diff --git a/reelforge/services/frame_html.py b/reelforge/services/frame_html.py index 6a16730..d9183d4 100644 --- a/reelforge/services/frame_html.py +++ b/reelforge/services/frame_html.py @@ -193,7 +193,7 @@ class HTMLFrameGenerator: async def generate_frame( self, - topic: str, + title: str, text: str, image: str, ext: Optional[Dict[str, Any]] = None, @@ -204,7 +204,7 @@ class HTMLFrameGenerator: Generate frame from HTML template Args: - topic: Video topic/theme + title: Video title text: Narration text for this frame image: Path to AI-generated image ext: Additional data (content_title, content_author, etc.) @@ -217,7 +217,7 @@ class HTMLFrameGenerator: # Build variable context context = { # Required variables - "topic": topic, + "title": title, "text": text, "image": image, } diff --git a/reelforge/services/storyboard_processor.py b/reelforge/services/storyboard_processor.py index aa29c14..09ffde2 100644 --- a/reelforge/services/storyboard_processor.py +++ b/reelforge/services/storyboard_processor.py @@ -162,38 +162,18 @@ class StoryboardProcessorService: storyboard: 'Storyboard', config: StoryboardConfig ): - """Step 3: Compose frame with subtitle""" + """Step 3: Compose frame with subtitle using HTML template""" logger.debug(f" 3/4: Composing frame {frame.index}...") output_path = get_temp_path(f"frame_{frame.index}_composed.png") - # Choose frame generator based on template config - if config.frame_template: - # Use HTML template - composed_path = await self._compose_frame_html(frame, storyboard, config, output_path) - else: - # Use PIL (default) - composed_path = await self._compose_frame_pil(frame, config, output_path) + # Use HTML template to compose frame + composed_path = await self._compose_frame_html(frame, storyboard, config, output_path) frame.composed_image_path = composed_path logger.debug(f" ✓ Frame composed: {composed_path}") - async def _compose_frame_pil( - self, - frame: StoryboardFrame, - config: StoryboardConfig, - output_path: str - ) -> str: - """Compose frame using PIL (default)""" - composed_path = await self.core.frame_composer.compose_frame( - image_path=frame.image_path, - subtitle=frame.narration, - output_path=output_path, - config=config - ) - return composed_path - async def _compose_frame_html( self, frame: StoryboardFrame, @@ -233,7 +213,7 @@ class StoryboardProcessorService: # Generate frame using HTML generator = HTMLFrameGenerator(str(template_path)) composed_path = await generator.generate_frame( - topic=storyboard.topic, + title=storyboard.title, text=frame.narration, image=frame.image_path, ext=ext, diff --git a/reelforge/services/title_generator.py b/reelforge/services/title_generator.py new file mode 100644 index 0000000..b86c0d2 --- /dev/null +++ b/reelforge/services/title_generator.py @@ -0,0 +1,138 @@ +""" +Title Generator Service + +Service for generating video titles from content. +""" + +from typing import Literal + +from loguru import logger + + +# Title generation constants +AUTO_LENGTH_THRESHOLD = 15 +MAX_TITLE_LENGTH = 15 + + +class TitleGeneratorService: + """ + Title generation service + + Generates video titles from content using different strategies: + - auto: Automatically decide based on content length + - direct: Use content directly as title + - llm: Always use LLM to generate title + """ + + def __init__(self, reelforge_core): + """ + Initialize title generator service + + Args: + reelforge_core: ReelForgeCore instance + """ + self.core = reelforge_core + + async def __call__( + self, + content: str, + strategy: Literal["auto", "direct", "llm"] = "auto", + max_length: int = MAX_TITLE_LENGTH + ) -> str: + """ + Generate title from content + + Args: + content: Source content (topic or script) + strategy: Generation strategy + - "auto": Auto-decide based on content length (default) + * If content <= AUTO_LENGTH_THRESHOLD chars: use directly + * If content > AUTO_LENGTH_THRESHOLD chars: use LLM + - "direct": Use content directly (truncated to max_length if needed) + - "llm": Always use LLM to generate title + max_length: Maximum title length (default: MAX_TITLE_LENGTH) + + Returns: + Generated title + + Examples: + # Auto strategy (default) + >>> title = await title_generator("AI技术") # Short, use directly + >>> # Returns: "AI技术" + + >>> title = await title_generator("如何在信息爆炸时代保持深度思考") # Long, use LLM + >>> # Returns: "信息时代的深度思考" (LLM generated) + + # Direct strategy + >>> title = await title_generator("Very long content...", strategy="direct") + >>> # Returns: "Very long content..." (truncated to max_length) + + # LLM strategy + >>> title = await title_generator("AI", strategy="llm") # Force LLM even for short content + >>> # Returns: "人工智能技术" (LLM generated) + """ + if strategy == "direct": + return self._use_directly(content, max_length) + elif strategy == "llm": + return await self._generate_by_llm(content, max_length) + else: # auto + if len(content.strip()) <= AUTO_LENGTH_THRESHOLD: + return content.strip() + return await self._generate_by_llm(content, max_length) + + def _use_directly(self, content: str, max_length: int) -> str: + """ + Use content directly as title (with truncation if needed) + + Args: + content: Source content + max_length: Maximum title length + + Returns: + Truncated or original content + """ + content = content.strip() + if len(content) <= max_length: + return content + return content[:max_length] + + async def _generate_by_llm(self, content: str, max_length: int) -> str: + """ + Generate title using LLM + + Args: + content: Source content (topic or script) + max_length: Maximum title length + + Returns: + LLM-generated title + """ + from reelforge.prompts import build_title_generation_prompt + + # Build prompt using template + prompt = build_title_generation_prompt(content, max_length=500) + + # Call LLM to generate title + response = await self.core.llm( + prompt=prompt, + temperature=0.7, + max_tokens=50 + ) + + # Clean up response + title = response.strip() + + # Remove quotes if present + if title.startswith('"') and title.endswith('"'): + title = title[1:-1] + if title.startswith("'") and title.endswith("'"): + title = title[1:-1] + + # Limit to max_length (safety) + if len(title) > max_length: + title = title[:max_length] + + logger.debug(f"Generated title: '{title}' (length: {len(title)})") + + return title + diff --git a/reelforge/services/video_generator.py b/reelforge/services/video_generator.py index bd285d1..c8bb59c 100644 --- a/reelforge/services/video_generator.py +++ b/reelforge/services/video_generator.py @@ -127,7 +127,7 @@ class VideoGeneratorService: video_height: Final video height (default 1920) video_fps: Video frame rate (default 30) - frame_template: HTML template filename or path (None = use PIL) + frame_template: HTML template filename or path (None = use default template) e.g., "default.html", "modern.html", "neon.html", or custom path prompt_prefix: Image prompt prefix (overrides config.yaml if provided) @@ -181,15 +181,15 @@ class VideoGeneratorService: final_title = title logger.info(f" Title: '{title}' (user-specified)") else: - # Auto-generate title based on mode + # Auto-generate title using title_generator service + self._report_progress(progress_callback, "generating_title", 0.01) if mode == "generate": - # Use text as title (it's a topic/theme) - final_title = text[:20] if len(text) > 20 else text - logger.info(f" Title: '{final_title}' (from text)") + # Auto strategy: decide based on content length + final_title = await self.core.title_generator(text, strategy="auto") + logger.info(f" Title: '{final_title}' (auto-generated)") else: # fixed - # Generate title from script using LLM - self._report_progress(progress_callback, "generating_title", 0.01) - final_title = await self._generate_title_from_content(text) + # Force LLM strategy: always use LLM for script + final_title = await self.core.title_generator(text, strategy="llm") logger.info(f" Title: '{final_title}' (LLM-generated)") # Auto-generate output path if not provided @@ -216,12 +216,12 @@ class VideoGeneratorService: image_width=image_width, image_height=image_height, image_workflow=image_workflow, - frame_template=frame_template + frame_template=frame_template or "default.html" ) # Create storyboard storyboard = Storyboard( - topic=final_title, # Use final_title as video title + title=final_title, # Use final_title as video title config=config, content_metadata=content_metadata, created_at=datetime.now() @@ -564,41 +564,4 @@ class VideoGeneratorService: logger.info(f" Too long (> {max_len}): {too_long}/{len(result)} ({too_long*100//len(result)}%)") return result - - async def _generate_title_from_content(self, content: str) -> str: - """ - Generate a short, attractive title from user content using LLM - - Args: - content: User-provided content - - Returns: - Generated title (10 characters or less) - """ - from reelforge.prompts import build_title_generation_prompt - - # Build prompt using template - prompt = build_title_generation_prompt(content, max_length=500) - - # Call LLM to generate title - response = await self.core.llm( - prompt=prompt, - temperature=0.7, - max_tokens=50 - ) - - # Clean up response - title = response.strip() - - # Remove quotes if present - if title.startswith('"') and title.endswith('"'): - title = title[1:-1] - if title.startswith("'") and title.endswith("'"): - title = title[1:-1] - - # Limit to 20 chars max (safety) - if len(title) > 20: - title = title[:20] - - return title diff --git a/templates/default.html b/templates/default.html index bddd277..f0e297b 100644 --- a/templates/default.html +++ b/templates/default.html @@ -115,14 +115,14 @@ .square-2 { top: 45%; right: 80px; } .square-3 { bottom: 20%; left: 100px; transform: rotate(45deg); } - /* Topic section */ - .topic-wrapper { + /* Video title section */ + .video-title-wrapper { position: relative; max-width: 800px; text-align: center; } - .topic-ornament-top { + .video-title-ornament-top { position: absolute; top: -40px; left: 50%; @@ -145,7 +145,7 @@ background: rgba(149, 165, 166, 0.65); } - .topic { + .video-title { font-size: 68px; font-weight: 600; color: #1a252f; @@ -154,7 +154,7 @@ position: relative; } - .topic::after { + .video-title::after { content: ''; position: absolute; bottom: -20px; @@ -460,15 +460,15 @@
-
+
-
+
-
{{topic}}
+
{{title}}
diff --git a/templates/modern.html b/templates/modern.html index b32d0a0..ffd0fa0 100644 --- a/templates/modern.html +++ b/templates/modern.html @@ -149,12 +149,12 @@ } /* Header with icon decoration */ - .topic-wrapper { + .video-title-wrapper { position: relative; text-align: center; } - .topic-decoration { + .video-title-decoration { position: absolute; top: -30px; left: 50%; @@ -163,17 +163,17 @@ gap: 8px; } - .topic-dot { + .video-title-dot { width: 10px; height: 10px; border-radius: 50%; } - .topic-dot:nth-child(1) { background: #FF6B9D; } - .topic-dot:nth-child(2) { background: #4ECDC4; } - .topic-dot:nth-child(3) { background: #FFE66D; } + .video-title-dot:nth-child(1) { background: #FF6B9D; } + .video-title-dot:nth-child(2) { background: #4ECDC4; } + .video-title-dot:nth-child(3) { background: #FFE66D; } - .topic { + .video-title { font-size: 72px; font-weight: bold; color: white; @@ -184,8 +184,8 @@ display: inline-block; } - .topic::before, - .topic::after { + .video-title::before, + .video-title::after { content: ''; position: absolute; width: 70px; @@ -194,11 +194,11 @@ top: 50%; } - .topic::before { + .video-title::before { left: -90px; } - .topic::after { + .video-title::after { right: -90px; } @@ -492,11 +492,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -504,7 +504,7 @@ -
{{topic}}
+
{{title}}
diff --git a/templates/neon.html b/templates/neon.html index 0a84b4b..30f54f6 100644 --- a/templates/neon.html +++ b/templates/neon.html @@ -3,7 +3,7 @@ - {{topic}} + {{title}}