优化标题逻辑

This commit is contained in:
puke
2025-10-27 23:24:36 +08:00
committed by puke
parent f2551b5f9c
commit be5b749913
11 changed files with 191 additions and 276 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 @@
</div>
<div class="page-container">
<div class="topic-wrapper">
<div class="video-title-wrapper">
<!-- Top ornament -->
<div class="topic-ornament-top">
<div class="video-title-ornament-top">
<div class="ornament-line"></div>
<div class="ornament-dot"></div>
<div class="ornament-line"></div>
</div>
<div class="topic">{{topic}}</div>
<div class="video-title">{{title}}</div>
</div>
<div class="image-wrapper">

View File

@@ -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 @@
</div>
<div class="page-container">
<div class="topic-wrapper">
<div class="topic-decoration">
<div class="topic-dot"></div>
<div class="topic-dot"></div>
<div class="topic-dot"></div>
<div class="video-title-wrapper">
<div class="video-title-decoration">
<div class="video-title-dot"></div>
<div class="video-title-dot"></div>
<div class="video-title-dot"></div>
</div>
<!-- Bookmark decoration -->
@@ -504,7 +504,7 @@
<path d="M2 0h20v32l-10-6-10 6V0z"/>
</svg>
<div class="topic">{{topic}}</div>
<div class="video-title">{{title}}</div>
</div>
<div class="image-wrapper">

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{topic}}</title>
<title>{{title}}</title>
<style>
:root {
--bg: #0b0f1a;
@@ -865,7 +865,7 @@
<!-- Header -->
<header class="header" role="banner">
<h1 class="title">{{topic}}</h1>
<h1 class="title">{{title}}</h1>
<div class="title-meta" role="list">
<div class="chip" role="listitem"><span class="dot"></span>读书短视频</div>
<div class="chip" role="listitem"><span class="dot pink"></span>思维提升</div>
@@ -874,7 +874,7 @@
</header>
<!-- Media -->
<section class="media" role="img" aria-label="Illustration for the topic">
<section class="media" role="img" aria-label="Illustration for the video">
<!-- Corner indicators -->
<div class="corner-indicator tl"></div>
<div class="corner-indicator tr"></div>
@@ -893,7 +893,7 @@
<div class="badge-dot pink"></div>
</div>
<img src="{{image}}" alt="图像:{{topic}}">
<img src="{{image}}" alt="图像:{{title}}">
</section>
<!-- Caption -->