优化标题逻辑
This commit is contained in:
@@ -30,7 +30,7 @@ class StoryboardConfig:
|
|||||||
image_workflow: Optional[str] = None # Image workflow filename (None = use default)
|
image_workflow: Optional[str] = None # Image workflow filename (None = use default)
|
||||||
|
|
||||||
# Frame template
|
# 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
|
@dataclass
|
||||||
@@ -70,7 +70,7 @@ class ContentMetadata:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class Storyboard:
|
class Storyboard:
|
||||||
"""Complete storyboard"""
|
"""Complete storyboard"""
|
||||||
topic: str # Topic or content title
|
title: str # Video title
|
||||||
config: StoryboardConfig # Configuration
|
config: StoryboardConfig # Configuration
|
||||||
frames: List[StoryboardFrame] = field(default_factory=list)
|
frames: List[StoryboardFrame] = field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from reelforge.services.tts_service import TTSService
|
|||||||
from reelforge.services.image import ImageService
|
from reelforge.services.image import ImageService
|
||||||
from reelforge.services.narration_generator import NarrationGeneratorService
|
from reelforge.services.narration_generator import NarrationGeneratorService
|
||||||
from reelforge.services.image_prompt_generator import ImagePromptGeneratorService
|
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.storyboard_processor import StoryboardProcessorService
|
||||||
from reelforge.services.video_generator import VideoGeneratorService
|
from reelforge.services.video_generator import VideoGeneratorService
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ class ReelForgeCore:
|
|||||||
# Content generation services
|
# Content generation services
|
||||||
self.narration_generator: Optional[NarrationGeneratorService] = None
|
self.narration_generator: Optional[NarrationGeneratorService] = None
|
||||||
self.image_prompt_generator: Optional[ImagePromptGeneratorService] = None
|
self.image_prompt_generator: Optional[ImagePromptGeneratorService] = None
|
||||||
|
self.title_generator: Optional[TitleGeneratorService] = None
|
||||||
|
|
||||||
# Frame processing services
|
# Frame processing services
|
||||||
self.frame_composer: Optional[FrameComposerService] = None
|
|
||||||
self.storyboard_processor: Optional[StoryboardProcessorService] = None
|
self.storyboard_processor: Optional[StoryboardProcessorService] = None
|
||||||
|
|
||||||
# Video generation service (named as verb for direct calling)
|
# Video generation service (named as verb for direct calling)
|
||||||
@@ -98,9 +98,9 @@ class ReelForgeCore:
|
|||||||
# 2. Initialize content generation services
|
# 2. Initialize content generation services
|
||||||
self.narration_generator = NarrationGeneratorService(self)
|
self.narration_generator = NarrationGeneratorService(self)
|
||||||
self.image_prompt_generator = ImagePromptGeneratorService(self)
|
self.image_prompt_generator = ImagePromptGeneratorService(self)
|
||||||
|
self.title_generator = TitleGeneratorService(self)
|
||||||
|
|
||||||
# 3. Initialize frame processing services
|
# 3. Initialize frame processing services
|
||||||
self.frame_composer = FrameComposerService()
|
|
||||||
self.storyboard_processor = StoryboardProcessorService(self)
|
self.storyboard_processor = StoryboardProcessorService(self)
|
||||||
|
|
||||||
# 4. Initialize video generation service
|
# 4. Initialize video generation service
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from reelforge.services.image import ImageService
|
|||||||
from reelforge.services.video import VideoService
|
from reelforge.services.video import VideoService
|
||||||
from reelforge.services.narration_generator import NarrationGeneratorService
|
from reelforge.services.narration_generator import NarrationGeneratorService
|
||||||
from reelforge.services.image_prompt_generator import ImagePromptGeneratorService
|
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.storyboard_processor import StoryboardProcessorService
|
||||||
from reelforge.services.video_generator import VideoGeneratorService
|
from reelforge.services.video_generator import VideoGeneratorService
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ __all__ = [
|
|||||||
"VideoService",
|
"VideoService",
|
||||||
"NarrationGeneratorService",
|
"NarrationGeneratorService",
|
||||||
"ImagePromptGeneratorService",
|
"ImagePromptGeneratorService",
|
||||||
"FrameComposerService",
|
"TitleGeneratorService",
|
||||||
"StoryboardProcessorService",
|
"StoryboardProcessorService",
|
||||||
"VideoGeneratorService",
|
"VideoGeneratorService",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ class HTMLFrameGenerator:
|
|||||||
|
|
||||||
async def generate_frame(
|
async def generate_frame(
|
||||||
self,
|
self,
|
||||||
topic: str,
|
title: str,
|
||||||
text: str,
|
text: str,
|
||||||
image: str,
|
image: str,
|
||||||
ext: Optional[Dict[str, Any]] = None,
|
ext: Optional[Dict[str, Any]] = None,
|
||||||
@@ -204,7 +204,7 @@ class HTMLFrameGenerator:
|
|||||||
Generate frame from HTML template
|
Generate frame from HTML template
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topic: Video topic/theme
|
title: Video title
|
||||||
text: Narration text for this frame
|
text: Narration text for this frame
|
||||||
image: Path to AI-generated image
|
image: Path to AI-generated image
|
||||||
ext: Additional data (content_title, content_author, etc.)
|
ext: Additional data (content_title, content_author, etc.)
|
||||||
@@ -217,7 +217,7 @@ class HTMLFrameGenerator:
|
|||||||
# Build variable context
|
# Build variable context
|
||||||
context = {
|
context = {
|
||||||
# Required variables
|
# Required variables
|
||||||
"topic": topic,
|
"title": title,
|
||||||
"text": text,
|
"text": text,
|
||||||
"image": image,
|
"image": image,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,38 +162,18 @@ class StoryboardProcessorService:
|
|||||||
storyboard: 'Storyboard',
|
storyboard: 'Storyboard',
|
||||||
config: StoryboardConfig
|
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}...")
|
logger.debug(f" 3/4: Composing frame {frame.index}...")
|
||||||
|
|
||||||
output_path = get_temp_path(f"frame_{frame.index}_composed.png")
|
output_path = get_temp_path(f"frame_{frame.index}_composed.png")
|
||||||
|
|
||||||
# Choose frame generator based on template config
|
# Use HTML template to compose frame
|
||||||
if config.frame_template:
|
|
||||||
# Use HTML template
|
|
||||||
composed_path = await self._compose_frame_html(frame, storyboard, config, output_path)
|
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)
|
|
||||||
|
|
||||||
frame.composed_image_path = composed_path
|
frame.composed_image_path = composed_path
|
||||||
|
|
||||||
logger.debug(f" ✓ Frame composed: {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(
|
async def _compose_frame_html(
|
||||||
self,
|
self,
|
||||||
frame: StoryboardFrame,
|
frame: StoryboardFrame,
|
||||||
@@ -233,7 +213,7 @@ class StoryboardProcessorService:
|
|||||||
# Generate frame using HTML
|
# Generate frame using HTML
|
||||||
generator = HTMLFrameGenerator(str(template_path))
|
generator = HTMLFrameGenerator(str(template_path))
|
||||||
composed_path = await generator.generate_frame(
|
composed_path = await generator.generate_frame(
|
||||||
topic=storyboard.topic,
|
title=storyboard.title,
|
||||||
text=frame.narration,
|
text=frame.narration,
|
||||||
image=frame.image_path,
|
image=frame.image_path,
|
||||||
ext=ext,
|
ext=ext,
|
||||||
|
|||||||
138
reelforge/services/title_generator.py
Normal file
138
reelforge/services/title_generator.py
Normal 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
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class VideoGeneratorService:
|
|||||||
video_height: Final video height (default 1920)
|
video_height: Final video height (default 1920)
|
||||||
video_fps: Video frame rate (default 30)
|
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
|
e.g., "default.html", "modern.html", "neon.html", or custom path
|
||||||
|
|
||||||
prompt_prefix: Image prompt prefix (overrides config.yaml if provided)
|
prompt_prefix: Image prompt prefix (overrides config.yaml if provided)
|
||||||
@@ -181,15 +181,15 @@ class VideoGeneratorService:
|
|||||||
final_title = title
|
final_title = title
|
||||||
logger.info(f" Title: '{title}' (user-specified)")
|
logger.info(f" Title: '{title}' (user-specified)")
|
||||||
else:
|
else:
|
||||||
# Auto-generate title based on mode
|
# Auto-generate title using title_generator service
|
||||||
if mode == "generate":
|
|
||||||
# Use text as title (it's a topic/theme)
|
|
||||||
final_title = text[:20] if len(text) > 20 else text
|
|
||||||
logger.info(f" Title: '{final_title}' (from text)")
|
|
||||||
else: # fixed
|
|
||||||
# Generate title from script using LLM
|
|
||||||
self._report_progress(progress_callback, "generating_title", 0.01)
|
self._report_progress(progress_callback, "generating_title", 0.01)
|
||||||
final_title = await self._generate_title_from_content(text)
|
if mode == "generate":
|
||||||
|
# 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
|
||||||
|
# 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)")
|
logger.info(f" Title: '{final_title}' (LLM-generated)")
|
||||||
|
|
||||||
# Auto-generate output path if not provided
|
# Auto-generate output path if not provided
|
||||||
@@ -216,12 +216,12 @@ class VideoGeneratorService:
|
|||||||
image_width=image_width,
|
image_width=image_width,
|
||||||
image_height=image_height,
|
image_height=image_height,
|
||||||
image_workflow=image_workflow,
|
image_workflow=image_workflow,
|
||||||
frame_template=frame_template
|
frame_template=frame_template or "default.html"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create storyboard
|
# Create storyboard
|
||||||
storyboard = Storyboard(
|
storyboard = Storyboard(
|
||||||
topic=final_title, # Use final_title as video title
|
title=final_title, # Use final_title as video title
|
||||||
config=config,
|
config=config,
|
||||||
content_metadata=content_metadata,
|
content_metadata=content_metadata,
|
||||||
created_at=datetime.now()
|
created_at=datetime.now()
|
||||||
@@ -565,40 +565,3 @@ class VideoGeneratorService:
|
|||||||
|
|
||||||
return 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
|
|
||||||
|
|
||||||
|
|||||||
@@ -115,14 +115,14 @@
|
|||||||
.square-2 { top: 45%; right: 80px; }
|
.square-2 { top: 45%; right: 80px; }
|
||||||
.square-3 { bottom: 20%; left: 100px; transform: rotate(45deg); }
|
.square-3 { bottom: 20%; left: 100px; transform: rotate(45deg); }
|
||||||
|
|
||||||
/* Topic section */
|
/* Video title section */
|
||||||
.topic-wrapper {
|
.video-title-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-ornament-top {
|
.video-title-ornament-top {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -40px;
|
top: -40px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
background: rgba(149, 165, 166, 0.65);
|
background: rgba(149, 165, 166, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic {
|
.video-title {
|
||||||
font-size: 68px;
|
font-size: 68px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a252f;
|
color: #1a252f;
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic::after {
|
.video-title::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -20px;
|
bottom: -20px;
|
||||||
@@ -460,15 +460,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="topic-wrapper">
|
<div class="video-title-wrapper">
|
||||||
<!-- Top ornament -->
|
<!-- Top ornament -->
|
||||||
<div class="topic-ornament-top">
|
<div class="video-title-ornament-top">
|
||||||
<div class="ornament-line"></div>
|
<div class="ornament-line"></div>
|
||||||
<div class="ornament-dot"></div>
|
<div class="ornament-dot"></div>
|
||||||
<div class="ornament-line"></div>
|
<div class="ornament-line"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topic">{{topic}}</div>
|
<div class="video-title">{{title}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="image-wrapper">
|
<div class="image-wrapper">
|
||||||
|
|||||||
@@ -149,12 +149,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Header with icon decoration */
|
/* Header with icon decoration */
|
||||||
.topic-wrapper {
|
.video-title-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-decoration {
|
.video-title-decoration {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -30px;
|
top: -30px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -163,17 +163,17 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-dot {
|
.video-title-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-dot:nth-child(1) { background: #FF6B9D; }
|
.video-title-dot:nth-child(1) { background: #FF6B9D; }
|
||||||
.topic-dot:nth-child(2) { background: #4ECDC4; }
|
.video-title-dot:nth-child(2) { background: #4ECDC4; }
|
||||||
.topic-dot:nth-child(3) { background: #FFE66D; }
|
.video-title-dot:nth-child(3) { background: #FFE66D; }
|
||||||
|
|
||||||
.topic {
|
.video-title {
|
||||||
font-size: 72px;
|
font-size: 72px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -184,8 +184,8 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic::before,
|
.video-title::before,
|
||||||
.topic::after {
|
.video-title::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 70px;
|
width: 70px;
|
||||||
@@ -194,11 +194,11 @@
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic::before {
|
.video-title::before {
|
||||||
left: -90px;
|
left: -90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic::after {
|
.video-title::after {
|
||||||
right: -90px;
|
right: -90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,11 +492,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<div class="topic-wrapper">
|
<div class="video-title-wrapper">
|
||||||
<div class="topic-decoration">
|
<div class="video-title-decoration">
|
||||||
<div class="topic-dot"></div>
|
<div class="video-title-dot"></div>
|
||||||
<div class="topic-dot"></div>
|
<div class="video-title-dot"></div>
|
||||||
<div class="topic-dot"></div>
|
<div class="video-title-dot"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bookmark decoration -->
|
<!-- Bookmark decoration -->
|
||||||
@@ -504,7 +504,7 @@
|
|||||||
<path d="M2 0h20v32l-10-6-10 6V0z"/>
|
<path d="M2 0h20v32l-10-6-10 6V0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div class="topic">{{topic}}</div>
|
<div class="video-title">{{title}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="image-wrapper">
|
<div class="image-wrapper">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{{topic}}</title>
|
<title>{{title}}</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0f1a;
|
--bg: #0b0f1a;
|
||||||
@@ -865,7 +865,7 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header" role="banner">
|
<header class="header" role="banner">
|
||||||
<h1 class="title">{{topic}}</h1>
|
<h1 class="title">{{title}}</h1>
|
||||||
<div class="title-meta" role="list">
|
<div class="title-meta" role="list">
|
||||||
<div class="chip" role="listitem"><span class="dot"></span>读书短视频</div>
|
<div class="chip" role="listitem"><span class="dot"></span>读书短视频</div>
|
||||||
<div class="chip" role="listitem"><span class="dot pink"></span>思维提升</div>
|
<div class="chip" role="listitem"><span class="dot pink"></span>思维提升</div>
|
||||||
@@ -874,7 +874,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Media -->
|
<!-- 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 -->
|
<!-- Corner indicators -->
|
||||||
<div class="corner-indicator tl"></div>
|
<div class="corner-indicator tl"></div>
|
||||||
<div class="corner-indicator tr"></div>
|
<div class="corner-indicator tr"></div>
|
||||||
@@ -893,7 +893,7 @@
|
|||||||
<div class="badge-dot pink"></div>
|
<div class="badge-dot pink"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img src="{{image}}" alt="图像:{{topic}}">
|
<img src="{{image}}" alt="图像:{{title}}">
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Caption -->
|
<!-- Caption -->
|
||||||
|
|||||||
Reference in New Issue
Block a user