diff --git a/api/services/poster.py b/api/services/poster.py index 0893484..eef02a7 100644 --- a/api/services/poster.py +++ b/api/services/poster.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file