TravelContentCreator/domain/poster/poster_service.py

245 lines
7.8 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
海报服务 V2 - 轻量版
特点:
1. 不依赖数据库
2. 不依赖旧的 core/utils 模块
3. 直接调用模板渲染
4. 支持 URL/路径/Base64 三种图片输入
"""
import logging
import base64
import importlib
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
logger.warning("PIL 未安装,海报功能不可用")
class PosterServiceV2:
"""
轻量海报服务
职责:
- 管理海报模板
- 处理图片输入
- 调用模板渲染
- 输出结果
"""
# 默认模板配置
DEFAULT_TEMPLATES = {
'vibrant': {
'id': 'vibrant',
'name': '活力风格',
'handler_path': 'poster.templates.vibrant_template',
'class_name': 'VibrantTemplate',
'description': '适合景点、活动等充满活力的场景',
},
'business': {
'id': 'business',
'name': '商务风格',
'handler_path': 'poster.templates.business_template',
'class_name': 'BusinessTemplate',
'description': '适合酒店、房地产等商务场景',
},
'collage': {
'id': 'collage',
'name': '拼贴风格',
'handler_path': 'poster.templates.collage_template',
'class_name': 'CollageTemplate',
'description': '多图拼贴风格',
},
}
def __init__(self):
self._templates = self.DEFAULT_TEMPLATES.copy()
self._template_instances = {}
self.logger = logging.getLogger(f"{__name__}.PosterServiceV2")
def get_available_templates(self) -> List[Dict[str, Any]]:
"""获取所有可用模板"""
return [
{
'id': t['id'],
'name': t['name'],
'description': t['description'],
}
for t in self._templates.values()
]
def generate_poster(
self,
template_id: str,
content: Dict[str, Any],
images: List['Image.Image'] = None,
images_base64: List[str] = None,
generate_fabric_json: bool = False,
**kwargs
) -> Dict[str, Any]:
"""
生成海报
Args:
template_id: 模板 ID
content: 海报内容 {title, content, tag, ...}
images: PIL Image 列表
images_base64: Base64 图片列表
generate_fabric_json: 是否生成 Fabric.js JSON
Returns:
{
'success': True/False,
'image': PIL.Image (如果成功),
'image_base64': str (如果成功),
'fabric_json': dict (如果 generate_fabric_json=True),
'error': str (如果失败),
}
"""
try:
self.logger.info(f"开始生成海报,模板: {template_id}")
# 1. 加载模板
template = self._load_template(template_id)
if not template:
return {'success': False, 'error': f'模板 {template_id} 不存在或加载失败'}
# 2. 处理图片输入
if images is None:
images = []
if images_base64:
for b64 in images_base64:
img = self._base64_to_image(b64)
if img:
images.append(img)
if not images:
self.logger.warning("没有输入图片,使用空白背景")
# 创建默认背景
images = [Image.new('RGBA', (1350, 1800), (240, 240, 240, 255))]
# 3. 调用模板渲染
2025-12-10 10:07:40 +08:00
# 注意: VibrantTemplate._generate_legacy 有 bug直接把 images 当单图用
# 这里传第一张图片,后续方案 C 会修复模板
main_image = images[0] if images else None
result = template.generate(
2025-12-10 10:07:40 +08:00
images=main_image, # 传递单张图片 (兼容模板 bug)
content=content,
generate_fabric_json=generate_fabric_json,
**kwargs
)
# 4. 处理结果
if isinstance(result, Image.Image):
# 直接返回图片
return {
'success': True,
'image': result,
'image_base64': self._image_to_base64(result),
}
elif isinstance(result, dict):
# 返回包含多种格式的结果
output = {'success': True}
if 'image' in result:
output['image'] = result['image']
output['image_base64'] = self._image_to_base64(result['image'])
if 'fabric_json' in result:
output['fabric_json'] = result['fabric_json']
if 'error' in result:
output['success'] = False
output['error'] = result['error']
return output
else:
return {'success': False, 'error': '模板返回格式异常'}
except Exception as e:
self.logger.error(f"生成海报失败: {e}", exc_info=True)
return {'success': False, 'error': str(e)}
def _load_template(self, template_id: str):
"""加载模板实例"""
# 检查缓存
if template_id in self._template_instances:
return self._template_instances[template_id]
# 检查模板是否存在
if template_id not in self._templates:
self.logger.error(f"模板不存在: {template_id}")
return None
template_info = self._templates[template_id]
handler_path = template_info.get('handler_path')
class_name = template_info.get('class_name')
try:
# 动态导入
module = importlib.import_module(handler_path)
template_class = getattr(module, class_name)
# 实例化
instance = template_class()
# 缓存
self._template_instances[template_id] = instance
self.logger.info(f"加载模板成功: {template_id}")
return instance
except Exception as e:
self.logger.error(f"加载模板失败: {template_id}, {e}")
return None
def _base64_to_image(self, base64_str: str) -> Optional['Image.Image']:
"""Base64 转 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 _image_to_base64(self, image: 'Image.Image', format: str = 'PNG') -> str:
"""PIL Image 转 Base64"""
buffer = BytesIO()
image.save(buffer, format=format)
return base64.b64encode(buffer.getvalue()).decode('utf-8')
# 全局单例
_service_instance = None
def get_poster_service() -> PosterServiceV2:
"""获取全局 PosterService 实例"""
global _service_instance
if _service_instance is None:
_service_instance = PosterServiceV2()
return _service_instance