TravelContentCreator/domain/poster/poster_service.py

245 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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. 调用模板渲染
# 注意: VibrantTemplate._generate_legacy 有 bug直接把 images 当单图用
# 这里传第一张图片,后续方案 C 会修复模板
main_image = images[0] if images else None
result = template.generate(
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