275 lines
8.0 KiB
Python
275 lines
8.0 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
|
|||
|
|
"""
|
|||
|
|
海报渲染器
|
|||
|
|
负责海报图片的渲染和输出
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import base64
|
|||
|
|
from io import BytesIO
|
|||
|
|
from typing import Dict, Any, Optional, List, Tuple
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from PIL import Image
|
|||
|
|
PIL_AVAILABLE = True
|
|||
|
|
except ImportError:
|
|||
|
|
PIL_AVAILABLE = False
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PosterRenderer:
|
|||
|
|
"""
|
|||
|
|
海报渲染器
|
|||
|
|
|
|||
|
|
职责:
|
|||
|
|
- 调用模板渲染海报图片
|
|||
|
|
- 处理图片格式转换
|
|||
|
|
- 管理输出文件
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, output_dir: Optional[str] = None):
|
|||
|
|
"""
|
|||
|
|
初始化渲染器
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
output_dir: 输出目录
|
|||
|
|
"""
|
|||
|
|
self._output_dir = Path(output_dir) if output_dir else None
|
|||
|
|
self.logger = logging.getLogger(f"{__name__}.PosterRenderer")
|
|||
|
|
|
|||
|
|
def render(
|
|||
|
|
self,
|
|||
|
|
template_handler,
|
|||
|
|
images: List['Image.Image'],
|
|||
|
|
content: Dict[str, Any],
|
|||
|
|
output_format: str = 'png'
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""
|
|||
|
|
渲染海报
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
template_handler: 模板处理器实例
|
|||
|
|
images: 输入图片列表
|
|||
|
|
content: 海报内容
|
|||
|
|
output_format: 输出格式 (png, json, both)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
渲染结果字典
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
self.logger.info(f"开始渲染海报,格式: {output_format}")
|
|||
|
|
|
|||
|
|
# 获取第一张图片作为背景
|
|||
|
|
background = images[0] if images else None
|
|||
|
|
|
|||
|
|
# 检查模板是否支持统一渲染
|
|||
|
|
if hasattr(template_handler, '_unified_render'):
|
|||
|
|
return self._render_unified(
|
|||
|
|
template_handler, background, content, output_format
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 使用传统渲染
|
|||
|
|
return self._render_legacy(
|
|||
|
|
template_handler, images, content, output_format
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
self.logger.error(f"渲染失败: {e}")
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def _render_unified(
|
|||
|
|
self,
|
|||
|
|
template_handler,
|
|||
|
|
background: Optional['Image.Image'],
|
|||
|
|
content: Dict[str, Any],
|
|||
|
|
output_format: str
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""使用统一渲染方法"""
|
|||
|
|
try:
|
|||
|
|
result = template_handler._unified_render(
|
|||
|
|
images=background,
|
|||
|
|
content=content,
|
|||
|
|
theme_color=None,
|
|||
|
|
glass_intensity=1.5,
|
|||
|
|
output_format=output_format
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if "error" in result:
|
|||
|
|
return {"success": False, "error": result["error"]}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"image": result.get("image"),
|
|||
|
|
"fabric_json": result.get("fabric_json"),
|
|||
|
|
"layout_params": result.get("layout_params"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
self.logger.error(f"统一渲染失败: {e}")
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def _render_legacy(
|
|||
|
|
self,
|
|||
|
|
template_handler,
|
|||
|
|
images: List['Image.Image'],
|
|||
|
|
content: Dict[str, Any],
|
|||
|
|
output_format: str
|
|||
|
|
) -> Dict[str, Any]:
|
|||
|
|
"""使用传统渲染方法"""
|
|||
|
|
try:
|
|||
|
|
# 调用模板的 generate 方法
|
|||
|
|
if hasattr(template_handler, 'generate'):
|
|||
|
|
result = template_handler.generate(images, content)
|
|||
|
|
|
|||
|
|
if isinstance(result, Image.Image):
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
"image": result,
|
|||
|
|
}
|
|||
|
|
elif isinstance(result, dict):
|
|||
|
|
return {
|
|||
|
|
"success": True,
|
|||
|
|
**result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {"success": False, "error": "模板不支持渲染"}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
self.logger.error(f"传统渲染失败: {e}")
|
|||
|
|
return {"success": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
def image_to_base64(self, image: 'Image.Image', format: str = 'PNG') -> str:
|
|||
|
|
"""
|
|||
|
|
将图片转换为 base64
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
image: PIL Image
|
|||
|
|
format: 图片格式
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
base64 字符串
|
|||
|
|
"""
|
|||
|
|
buffer = BytesIO()
|
|||
|
|
image.save(buffer, format=format)
|
|||
|
|
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|||
|
|
|
|||
|
|
def base64_to_image(self, base64_str: str) -> Optional['Image.Image']:
|
|||
|
|
"""
|
|||
|
|
将 base64 转换为图片
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
base64_str: base64 字符串
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
PIL Image
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# 移除可能的 data URL 前缀
|
|||
|
|
if ',' in base64_str:
|
|||
|
|
base64_str = base64_str.split(',', 1)[1]
|
|||
|
|
|
|||
|
|
image_data = base64.b64decode(base64_str)
|
|||
|
|
image = Image.open(BytesIO(image_data))
|
|||
|
|
|
|||
|
|
if image.mode != 'RGBA':
|
|||
|
|
image = image.convert('RGBA')
|
|||
|
|
|
|||
|
|
return image
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
self.logger.error(f"base64 转图片失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def save_image(
|
|||
|
|
self,
|
|||
|
|
image: 'Image.Image',
|
|||
|
|
filename: str,
|
|||
|
|
format: str = 'PNG',
|
|||
|
|
quality: int = 95
|
|||
|
|
) -> Optional[Path]:
|
|||
|
|
"""
|
|||
|
|
保存图片到文件
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
image: PIL Image
|
|||
|
|
filename: 文件名
|
|||
|
|
format: 图片格式
|
|||
|
|
quality: 质量
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
保存路径
|
|||
|
|
"""
|
|||
|
|
if not self._output_dir:
|
|||
|
|
self.logger.warning("未设置输出目录")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|||
|
|
file_path = self._output_dir / filename
|
|||
|
|
|
|||
|
|
save_kwargs = {'format': format}
|
|||
|
|
if format.upper() in ('JPEG', 'WEBP'):
|
|||
|
|
save_kwargs['quality'] = quality
|
|||
|
|
|
|||
|
|
image.save(file_path, **save_kwargs)
|
|||
|
|
self.logger.info(f"图片已保存: {file_path}")
|
|||
|
|
|
|||
|
|
return file_path
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
self.logger.error(f"保存图片失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def resize_image(
|
|||
|
|
self,
|
|||
|
|
image: 'Image.Image',
|
|||
|
|
target_size: Tuple[int, int],
|
|||
|
|
mode: str = 'cover'
|
|||
|
|
) -> 'Image.Image':
|
|||
|
|
"""
|
|||
|
|
调整图片尺寸
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
image: PIL Image
|
|||
|
|
target_size: 目标尺寸 (width, height)
|
|||
|
|
mode: 调整模式 (cover, contain, stretch)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
调整后的图片
|
|||
|
|
"""
|
|||
|
|
target_width, target_height = target_size
|
|||
|
|
|
|||
|
|
if mode == 'stretch':
|
|||
|
|
return image.resize(target_size, Image.Resampling.LANCZOS)
|
|||
|
|
|
|||
|
|
# 计算缩放比例
|
|||
|
|
scale_x = target_width / image.width
|
|||
|
|
scale_y = target_height / image.height
|
|||
|
|
|
|||
|
|
if mode == 'cover':
|
|||
|
|
scale = max(scale_x, scale_y)
|
|||
|
|
else: # contain
|
|||
|
|
scale = min(scale_x, scale_y)
|
|||
|
|
|
|||
|
|
new_width = int(image.width * scale)
|
|||
|
|
new_height = int(image.height * scale)
|
|||
|
|
|
|||
|
|
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|||
|
|
|
|||
|
|
if mode == 'cover':
|
|||
|
|
# 裁剪到目标尺寸
|
|||
|
|
left = (new_width - target_width) // 2
|
|||
|
|
top = (new_height - target_height) // 2
|
|||
|
|
return resized.crop((left, top, left + target_width, top + target_height))
|
|||
|
|
else:
|
|||
|
|
# 创建目标尺寸画布并居中
|
|||
|
|
result = Image.new('RGBA', target_size, (255, 255, 255, 255))
|
|||
|
|
x = (target_width - new_width) // 2
|
|||
|
|
y = (target_height - new_height) // 2
|
|||
|
|
result.paste(resized, (x, y))
|
|||
|
|
return result
|