init
This commit is contained in:
166
reelforge/services/frame_composer.py
Normal file
166
reelforge/services/frame_composer.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user