同步了fabric and vibrant

This commit is contained in:
jinye_huang 2025-08-04 12:41:07 +08:00
parent d15a72e489
commit 610404d60f

View File

@ -213,6 +213,7 @@ class PosterService:
# 2. 准备内容 (LLM或用户提供)
final_content = poster_content
if force_llm_generation or not final_content:
logger.info(f"为模板 {template_id} 按需生成内容...")
final_content = await self._generate_content_with_llm(template_id, content_id, product_id, scenic_spot_id, poster_content)
@ -283,22 +284,22 @@ class PosterService:
except binascii.Error as e:
logger.error(f"Base64解码失败: {e}")
logger.error(f"问题数据长度: {len(images_base64) if 'images_base64' in locals() else 'unknown'}")
# 创建一个与目标大小一致的纯黑底图
images = Image.new('RGB', template_size, color='black')
logger.info(f"创建默认黑色背景图,尺寸: {template_size}")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")
except Exception as e:
logger.error(f"图片处理失败: {e}")
logger.error(f"错误类型: {type(e).__name__}")
if 'image_bytes' in locals():
logger.error(f"图片数据大小: {len(image_bytes)} bytes, 前20字节: {image_bytes[:20].hex()}")
# 创建一个与目标大小一致的纯黑底图
images = Image.new('RGB', template_size, color='black')
logger.info(f"创建默认黑色背景图,尺寸: {template_size}")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")
else:
logger.warning("未提供图片数据,使用默认黑色背景图")
# 创建一个与目标大小一致的纯黑底图
images = Image.new('RGB', template_size, color='black')
logger.info(f"创建默认黑色背景图,尺寸: {template_size}")
logger.warning("未提供图片数据,使用默认透明背景图")
# 创建一个与目标大小一致的透明底图
images = Image.new('RGBA', template_size, color=(0, 0, 0, 0))
logger.info(f"创建默认透明背景图,尺寸: {template_size}")
# 4. 调用模板生成海报
try:
@ -425,36 +426,78 @@ class PosterService:
data = {}
def safe_int_convert(id_str: Optional[str]) -> Optional[int]:
"""安全将字符串ID转换为整数"""
"""安全将字符串ID转换为整数,避免大整数精度丢失"""
if not id_str:
return None
try:
# 去除前后空格
id_str = id_str.strip()
# 如果ID包含非数字字符只提取数字部分或返回None
if id_str.isdigit():
return int(id_str)
# 直接转换纯数字字符串,避免精度丢失
converted_id = int(id_str)
logger.debug(f"成功转换ID: {id_str} -> {converted_id}")
return converted_id
else:
# 对于类似 "generated_note_1753693091224_0" 的ID提取数字部分
import re
numbers = re.findall(r'\d+', id_str)
if numbers:
return int(numbers[0]) # 使用第一个数字序列
# 使用第一个数字序列,但要验证它是有效的大整数
first_number = numbers[0]
converted_id = int(first_number)
logger.debug(f"从复合ID中提取数字: {id_str} -> {converted_id}")
return converted_id
logger.warning(f"无法从ID中提取有效数字: {id_str}")
return None
except (ValueError, TypeError):
logger.warning(f"无法转换ID为整数: {id_str}")
except (ValueError, TypeError, OverflowError) as e:
logger.error(f"无法转换ID为整数: {id_str}, 类型: {type(id_str)}, 错误: {e}")
return None
# 添加详细的数据获取调试信息
logger.info(f"开始获取数据 - content_id: {content_id}, product_id: {product_id}, scenic_spot_id: {scenic_spot_id}")
if content_id:
content_id_int = safe_int_convert(content_id)
if content_id_int:
data['content'] = self.db_service.get_content_by_id(content_id_int)
logger.info(f"处理content_id: {content_id} (类型: {type(content_id)})")
# content_id直接用字符串查询不需要转换
content_data = self.db_service.get_content_by_id(content_id)
logger.info(f"从数据库获取的content数据: {content_data is not None}")
if content_data:
logger.info(f"content数据预览: {list(content_data.keys()) if isinstance(content_data, dict) else type(content_data)}")
data['content'] = content_data
else:
logger.info("未提供content_id")
if product_id:
logger.info(f"处理product_id: {product_id} (类型: {type(product_id)})")
product_id_int = safe_int_convert(product_id)
logger.info(f"转换后的product_id_int: {product_id_int}")
if product_id_int:
data['product'] = self.db_service.get_product_by_id(product_id_int)
product_data = self.db_service.get_product_by_id(product_id_int)
logger.info(f"从数据库获取的product数据: {product_data is not None}")
if product_data:
logger.info(f"product数据预览: {list(product_data.keys()) if isinstance(product_data, dict) else type(product_data)}")
data['product'] = product_data
else:
logger.warning(f"product_id转换失败: {product_id}")
else:
logger.info("未提供product_id")
if scenic_spot_id:
logger.info(f"处理scenic_spot_id: {scenic_spot_id} (类型: {type(scenic_spot_id)})")
scenic_spot_id_int = safe_int_convert(scenic_spot_id)
logger.info(f"转换后的scenic_spot_id_int: {scenic_spot_id_int}")
if scenic_spot_id_int:
data['scenic_spot'] = self.db_service.get_scenic_spot_by_id(scenic_spot_id_int)
scenic_spot_data = self.db_service.get_scenic_spot_by_id(scenic_spot_id_int)
logger.info(f"从数据库获取的scenic_spot数据: {scenic_spot_data is not None}")
if scenic_spot_data:
logger.info(f"scenic_spot数据预览: {list(scenic_spot_data.keys()) if isinstance(scenic_spot_data, dict) else type(scenic_spot_data)}")
data['scenic_spot'] = scenic_spot_data
else:
logger.warning(f"scenic_spot_id转换失败: {scenic_spot_id}")
else:
logger.info("未提供scenic_spot_id")
logger.info(f"获取到的数据: content={data.get('content') is not None}, product={data.get('product') is not None}, scenic_spot={data.get('scenic_spot') is not None}")
@ -489,14 +532,23 @@ class PosterService:
详细描述: {product.get('detailedDescription', '')}"""
logger.info("产品信息格式化完成")
# 内容信息格式化
# 内容信息格式化 - 优先使用poster_content
tweet_info = "无相关内容信息"
if data.get('content'):
logger.info("正在格式化内容信息...")
if poster_content is not None:
logger.info(f"使用poster_content作为文章内容类型: {type(poster_content)}")
if isinstance(poster_content, str):
tweet_info = f"文章内容: {poster_content}"
elif isinstance(poster_content, dict):
tweet_info = f"文章内容: {str(poster_content)}"
else:
tweet_info = f"文章内容: {str(poster_content)}"
logger.info(f"poster_content格式化完成长度: {len(tweet_info)}")
elif data.get('content'):
logger.info("正在格式化数据库内容信息...")
content = data['content']
tweet_info = f"""标题: {content.get('title', '')}
内容: {content.get('content', '')}"""
logger.info("内容信息格式化完成")
logger.info("数据库内容信息格式化完成")
logger.info("开始构建用户提示词...")
# 构建用户提示词
@ -508,6 +560,15 @@ class PosterService:
logger.info(f"用户提示词构建成功,长度: {len(user_prompt)}")
# 输出系统提示词和用户提示词内容以供调试
logger.info("=" * 80)
logger.info("系统提示词内容:")
logger.info(system_prompt)
logger.info("=" * 80)
logger.info("用户提示词内容:")
logger.info(user_prompt)
logger.info("=" * 80)
except Exception as e:
logger.error(f"格式化提示词时发生错误: {e}", exc_info=True)
# 提供兜底方案
@ -627,11 +688,11 @@ class PosterService:
return len(fabric_data.get('objects', []))
except Exception as e:
logger.warning(f"获取JSON对象数量失败: {e}")
return None
return None
def _generate_fabric_json(self, content: Dict[str, Any], template_id: str, image_size: List[int], images: Image.Image = None) -> Dict[str, Any]:
"""
生成扁平化的Fabric.js JSON格式不使用group每个对象独立成层
生成与VibrantTemplate视觉效果一致的Fabric.js JSON格式
Args:
content: 海报内容数据
@ -645,31 +706,30 @@ class PosterService:
try:
fabric_objects = []
# 基础画布尺寸
# 基础画布尺寸VibrantTemplate使用900x1200最终resize到1350x1800
canvas_width, canvas_height = image_size[0], image_size[1]
# 计算缩放比例以匹配VibrantTemplate的最终输出
scale_ratio = min(canvas_width / 900, canvas_height / 1200)
# 1. 用户上传的图片(最底层 - Level 0
if images and hasattr(images, 'width'):
image_object = self._create_image_object(images, canvas_width, canvas_height)
image_object = self._create_vibrant_image_object(images, canvas_width, canvas_height)
fabric_objects.append(image_object)
else:
# 占位符
placeholder_object = self._create_placeholder_object(canvas_width, canvas_height)
fabric_objects.append(placeholder_object)
# 2. 背景Level 1
if template_id:
background_object = self._create_background_object(canvas_width, canvas_height)
fabric_objects.append(background_object)
# 2. 毛玻璃渐变背景Level 1
gradient_start = self._calculate_gradient_start_position(canvas_height)
gradient_object = self._create_gradient_background_object(canvas_width, canvas_height, gradient_start)
fabric_objects.append(gradient_object)
# 3. 内容文字层Level 2
text_objects = self._create_text_objects(content, canvas_width, canvas_height)
# 3. VibrantTemplate风格的文字布局Level 2
text_objects = self._create_vibrant_text_objects(content, canvas_width, canvas_height, gradient_start, scale_ratio)
fabric_objects.extend(text_objects)
# 4. 装饰层Level 3
decoration_objects = self._create_decoration_objects(canvas_width, canvas_height)
fabric_objects.extend(decoration_objects)
# 构建完整的Fabric.js JSON
fabric_json = {
"version": "5.3.0",
@ -763,23 +823,23 @@ class PosterService:
def _create_placeholder_object(self, canvas_width: int, canvas_height: int) -> Dict[str, Any]:
"""创建图片占位符对象"""
return {
"type": "rect",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": 0,
"width": canvas_width,
"height": canvas_height,
"type": "rect",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": 0,
"width": canvas_width,
"height": canvas_height,
"fill": "#f8f9fa",
"stroke": "#dee2e6",
"strokeWidth": 2,
"strokeDashArray": [10, 5],
"angle": 0,
"flipX": False,
"flipY": False,
"opacity": 1,
"visible": True,
"angle": 0,
"flipX": False,
"flipY": False,
"opacity": 1,
"visible": True,
"name": "image_placeholder",
"data": {
"type": "placeholder",
@ -834,21 +894,21 @@ class PosterService:
}
for key, config in text_configs.items():
if key in content and content[key]:
text_content = content[key]
if isinstance(text_content, list):
text_content = '\n'.join(text_content)
elif not isinstance(text_content, str):
text_content = str(text_content)
if key in content and content[key]:
text_content = content[key]
if isinstance(text_content, list):
text_content = '\n'.join(text_content)
elif not isinstance(text_content, str):
text_content = str(text_content)
text_object = {
"type": "textbox",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 50,
"type": "textbox",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 50,
"top": config['top'],
"width": canvas_width - 100,
"width": canvas_width - 100,
"height": 60 if key != 'content' else 120,
"fill": config['fill'],
"fontFamily": "Arial, sans-serif",
@ -934,7 +994,433 @@ class PosterService:
},
"selectable": False,
"evented": False
}
}
decoration_objects.append(corner_object)
return decoration_objects
return decoration_objects
def _calculate_gradient_start_position(self, canvas_height: int) -> int:
"""计算毛玻璃渐变开始位置模拟VibrantTemplate的逻辑"""
# VibrantTemplate中大约从画布的60%位置开始渐变
return int(canvas_height * 0.6)
def _create_vibrant_image_object(self, images: Image.Image, canvas_width: int, canvas_height: int) -> Dict[str, Any]:
"""创建VibrantTemplate风格的图片对象"""
# 将PIL图像转换为base64
image_base64 = self._image_to_base64(images)
# VibrantTemplate将图片resize到画布大小
return {
"type": "image",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": 0,
"width": images.width,
"height": images.height,
"scaleX": canvas_width / images.width,
"scaleY": canvas_height / images.height,
"angle": 0,
"flipX": False,
"flipY": False,
"opacity": 1,
"visible": True,
"src": f"data:image/png;base64,{image_base64}",
"crossOrigin": "anonymous",
"name": "background_image",
"data": {
"type": "background_image",
"layer": "image",
"level": 0,
"replaceable": True
},
"selectable": True,
"evented": True
}
def _create_gradient_background_object(self, canvas_width: int, canvas_height: int, gradient_start: int) -> Dict[str, Any]:
"""创建模拟毛玻璃效果的渐变背景"""
return {
"type": "rect",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": 0,
"top": gradient_start,
"width": canvas_width,
"height": canvas_height - gradient_start,
"fill": {
"type": "linear",
"coords": {
"x1": 0,
"y1": 0,
"x2": 0,
"y2": canvas_height - gradient_start
},
"colorStops": [
{"offset": 0, "color": "rgba(0, 30, 80, 0.3)"},
{"offset": 0.5, "color": "rgba(0, 50, 120, 0.6)"},
{"offset": 1, "color": "rgba(0, 80, 160, 0.8)"}
]
},
"angle": 0,
"flipX": False,
"flipY": False,
"opacity": 1,
"visible": True,
"name": "glass_gradient",
"data": {
"type": "glass_effect",
"layer": "background",
"level": 1
},
"selectable": False,
"evented": False
}
def _create_vibrant_text_objects(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int, scale_ratio: float) -> List[Dict[str, Any]]:
"""创建VibrantTemplate风格的文字对象"""
text_objects = []
# 计算内容区域边距模拟VibrantTemplate的计算
content_margin = max(40, int(canvas_width * 0.1))
content_width = canvas_width - 2 * content_margin
# 标题位置和样式
title_y = gradient_start + int(40 * scale_ratio)
if title := content.get("title"):
title_size = self._calculate_vibrant_font_size(title, content_width * 0.95, 40, 140, scale_ratio)
title_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "center",
"originY": "top",
"left": canvas_width / 2,
"top": title_y,
"width": content_width,
"height": title_size + 20,
"fill": "#ffffff",
"stroke": "rgba(0, 30, 80, 0.8)",
"strokeWidth": 2,
"fontFamily": "Arial Black, sans-serif",
"fontWeight": "bold",
"fontSize": title_size,
"text": title,
"textAlign": "center",
"lineHeight": 1.1,
"name": "vibrant_title",
"data": {
"type": "title",
"layer": "content",
"level": 2,
"style": "vibrant_title"
},
"selectable": True,
"evented": True
}
text_objects.append(title_obj)
# 副标题位置和样式
subtitle_y = title_y + int(100 * scale_ratio)
if slogan := content.get("slogan"):
subtitle_size = self._calculate_vibrant_font_size(slogan, content_width * 0.9, 20, 75, scale_ratio)
subtitle_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "center",
"originY": "top",
"left": canvas_width / 2,
"top": subtitle_y,
"width": content_width,
"height": subtitle_size + 15,
"fill": "#ffffff",
"shadow": {
"color": "rgba(0, 0, 0, 0.7)",
"blur": 5,
"offsetX": 2,
"offsetY": 2
},
"fontFamily": "Arial, sans-serif",
"fontWeight": "normal",
"fontSize": subtitle_size,
"text": slogan,
"textAlign": "center",
"lineHeight": 1.2,
"name": "vibrant_slogan",
"data": {
"type": "slogan",
"layer": "content",
"level": 2,
"style": "vibrant_subtitle"
},
"selectable": True,
"evented": True
}
text_objects.append(subtitle_obj)
# 双栏布局
column_start_y = subtitle_y + int(80 * scale_ratio)
left_column_width = int(content_width * 0.5)
right_column_x = content_margin + left_column_width
# 左栏:内容按钮和项目列表
left_objects = self._create_left_column_objects(content, content_margin, column_start_y, left_column_width, scale_ratio)
text_objects.extend(left_objects)
# 右栏:价格和票种信息
right_objects = self._create_right_column_objects(content, right_column_x, column_start_y, content_margin + content_width, scale_ratio)
text_objects.extend(right_objects)
# 底部标签和分页
footer_objects = self._create_footer_objects(content, content_margin, canvas_height - int(30 * scale_ratio), content_width, scale_ratio)
text_objects.extend(footer_objects)
return text_objects
def _calculate_vibrant_font_size(self, text: str, target_width: int, min_size: int, max_size: int, scale_ratio: float) -> int:
"""计算VibrantTemplate风格的字体大小"""
# 简化的字体大小计算,基于文本长度和目标宽度
char_count = len(text)
if char_count == 0:
return int(min_size * scale_ratio)
# 估算字符宽度中文字符按1.5倍计算)
avg_char_width = target_width / max(1, char_count * 1.2)
estimated_font_size = int(avg_char_width * 0.8)
# 应用缩放比例并限制在范围内
scaled_size = int(estimated_font_size * scale_ratio)
return max(int(min_size * scale_ratio), min(int(max_size * scale_ratio), scaled_size))
def _create_left_column_objects(self, content: Dict[str, Any], x: int, y: int, width: int, scale_ratio: float) -> List[Dict[str, Any]]:
"""创建左栏对象:按钮和项目列表"""
objects = []
# 内容按钮
button_text = content.get("content_button", "套餐内容")
button_width = min(width - 20, int(len(button_text) * 20 * scale_ratio + 40))
button_height = int(50 * scale_ratio)
# 按钮背景
button_bg = {
"type": "rect",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": x,
"top": y,
"width": button_width,
"height": button_height,
"fill": "rgba(0, 140, 210, 0.7)",
"stroke": "#ffffff",
"strokeWidth": 1,
"rx": 20,
"ry": 20,
"name": "content_button_bg",
"data": {"type": "button", "layer": "content", "level": 2},
"selectable": False,
"evented": False
}
objects.append(button_bg)
# 按钮文字
button_text_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "center",
"originY": "center",
"left": x + button_width / 2,
"top": y + button_height / 2,
"width": button_width - 20,
"height": button_height,
"fill": "#ffffff",
"fontFamily": "Arial, sans-serif",
"fontWeight": "bold",
"fontSize": int(30 * scale_ratio),
"text": button_text,
"textAlign": "center",
"name": "content_button_text",
"data": {"type": "button_text", "layer": "content", "level": 2},
"selectable": True,
"evented": True
}
objects.append(button_text_obj)
# 项目列表
items = content.get("content_items", [])
list_y = y + button_height + int(20 * scale_ratio)
font_size = int(28 * scale_ratio)
line_spacing = int(36 * scale_ratio)
for i, item in enumerate(items):
item_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": x,
"top": list_y + i * line_spacing,
"width": width,
"height": font_size + 10,
"fill": "#ffffff",
"fontFamily": "Arial, sans-serif",
"fontWeight": "normal",
"fontSize": font_size,
"text": f"{item}",
"textAlign": "left",
"name": f"content_item_{i}",
"data": {"type": "content_item", "layer": "content", "level": 2},
"selectable": True,
"evented": True
}
objects.append(item_obj)
return objects
def _create_right_column_objects(self, content: Dict[str, Any], x: int, y: int, right_margin: int, scale_ratio: float) -> List[Dict[str, Any]]:
"""创建右栏对象:价格和票种"""
objects = []
column_width = right_margin - x
# 价格
if price := content.get("price"):
price_size = self._calculate_vibrant_font_size(str(price), column_width * 0.7, 40, 120, scale_ratio)
price_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "right",
"originY": "top",
"left": right_margin,
"top": y,
"width": column_width,
"height": price_size + 20,
"fill": "#ffffff",
"shadow": {
"color": "rgba(0, 0, 0, 0.7)",
"blur": 5,
"offsetX": 2,
"offsetY": 2
},
"fontFamily": "Arial Black, sans-serif",
"fontWeight": "bold",
"fontSize": price_size,
"text": f"¥{price}",
"textAlign": "right",
"name": "vibrant_price",
"data": {"type": "price", "layer": "content", "level": 2},
"selectable": True,
"evented": True
}
objects.append(price_obj)
# 价格下划线
underline_y = y + price_size + int(18 * scale_ratio)
underline = {
"type": "line",
"version": "5.3.0",
"originX": "center",
"originY": "center",
"left": right_margin - column_width * 0.5,
"top": underline_y,
"x1": -column_width * 0.4,
"y1": 0,
"x2": column_width * 0.4,
"y2": 0,
"stroke": "rgba(255, 255, 255, 0.3)",
"strokeWidth": 2,
"name": "price_underline",
"data": {"type": "decoration", "layer": "content", "level": 2},
"selectable": False,
"evented": False
}
objects.append(underline)
# 票种
if ticket_type := content.get("ticket_type"):
ticket_y = y + price_size + int(35 * scale_ratio)
ticket_size = self._calculate_vibrant_font_size(ticket_type, column_width * 0.7, 30, 60, scale_ratio)
ticket_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "right",
"originY": "top",
"left": right_margin,
"top": ticket_y,
"width": column_width,
"height": ticket_size + 10,
"fill": "#ffffff",
"shadow": {
"color": "rgba(0, 0, 0, 0.5)",
"blur": 3,
"offsetX": 1,
"offsetY": 1
},
"fontFamily": "Arial, sans-serif",
"fontWeight": "normal",
"fontSize": ticket_size,
"text": ticket_type,
"textAlign": "right",
"name": "ticket_type",
"data": {"type": "ticket_type", "layer": "content", "level": 2},
"selectable": True,
"evented": True
}
objects.append(ticket_obj)
return objects
def _create_footer_objects(self, content: Dict[str, Any], x: int, y: int, width: int, scale_ratio: float) -> List[Dict[str, Any]]:
"""创建底部对象:标签和分页"""
objects = []
font_size = int(18 * scale_ratio)
# 左侧标签
if tag := content.get("tag"):
tag_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "left",
"originY": "top",
"left": x,
"top": y,
"width": width // 2,
"height": font_size + 5,
"fill": "#ffffff",
"fontFamily": "Arial, sans-serif",
"fontWeight": "normal",
"fontSize": font_size,
"text": tag,
"textAlign": "left",
"name": "footer_tag",
"data": {"type": "tag", "layer": "content", "level": 2},
"selectable": True,
"evented": True
}
objects.append(tag_obj)
# 右侧分页
if pagination := content.get("pagination"):
pagination_obj = {
"type": "textbox",
"version": "5.3.0",
"originX": "right",
"originY": "top",
"left": x + width,
"top": y,
"width": width // 2,
"height": font_size + 5,
"fill": "#ffffff",
"fontFamily": "Arial, sans-serif",
"fontWeight": "normal",
"fontSize": font_size,
"text": pagination,
"textAlign": "right",
"name": "footer_pagination",
"data": {"type": "pagination", "layer": "content", "level": 2},
"selectable": True,
"evented": True
}
objects.append(pagination_obj)
return objects