From b4f810e12761c42fbaaa7e93f9670912f69b0c9a Mon Sep 17 00:00:00 2001 From: jinye_huang Date: Mon, 4 Aug 2025 13:41:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86UI=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=EF=BC=8C=E6=AD=A3=E7=A1=AE=E6=8E=A5=E5=88=B0java=E7=AB=AF?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models/poster.py | 6 +- api/services/poster.py | 453 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 403 insertions(+), 56 deletions(-) diff --git a/api/models/poster.py b/api/models/poster.py index c955e02..5ceee02 100644 --- a/api/models/poster.py +++ b/api/models/poster.py @@ -14,8 +14,7 @@ from pydantic import BaseModel, Field class PosterGenerateRequest(BaseModel): """海报生成请求模型""" templateId: str = Field("vibrant", description="模板ID") - imagesBase64: Optional[str] = Field(None, description="图像base64编码") - posterContent: Optional[Dict[str, Any]] = Field(None, description="海报内容,如果提供则直接使用此内容") + imagesBase64: Optional[List[str]] = Field(None, description="图像base64编码列表") posterContent: Optional[Dict[str, Any]] = Field(None, description="海报内容,如果提供则直接使用此内容") contentId: Optional[str] = Field(None, description="内容ID,用于AI生成内容") productId: Optional[str] = Field(None, description="产品ID,用于AI生成内容") scenicSpotId: Optional[str] = Field(None, description="景区ID,用于AI生成内容") @@ -28,8 +27,7 @@ class PosterGenerateRequest(BaseModel): json_schema_extra = { "example": { "templateId": "vibrant", - "imagesBase64": "", - "numVariations": 1, + "imagesBase64": ["base64_encoded_image_1", "base64_encoded_image_2"], "numVariations": 1, "forceLlmGeneration":False, "generatePsd": True, "psdOutputPath": "custom_poster.psd", diff --git a/api/services/poster.py b/api/services/poster.py index e77cce2..3d7eeb1 100644 --- a/api/services/poster.py +++ b/api/services/poster.py @@ -15,7 +15,7 @@ import importlib import base64 import binascii from io import BytesIO -from typing import List, Dict, Any, Optional, Type, Union, cast, Tuple +from typing import List, Dict, Any, Optional, Type, Union, cast from datetime import datetime from pathlib import Path from PIL import Image @@ -229,31 +229,42 @@ class PosterService: # raise ValueError("无法获取指定的图片") - # # 3. 图片解码 + # # 3. 图片解码 images = None # 获取模板的默认尺寸,如果获取不到则使用标准尺寸 template_size = getattr(template_handler, 'size', (900, 1200)) - if images_base64 and images_base64.strip(): + if images_base64 and len(images_base64) > 0: try: + logger.info(f"🎯 接收到 {len(images_base64)} 张图片的base64数据") + + # 处理第一张图片(目前模板只支持单张图片) + # 未来可以扩展为处理多张图片 + first_image_base64 = images_base64[0] if len(images_base64) > 0 else "" + + if not first_image_base64 or not first_image_base64.strip(): + raise ValueError("第一张图片的base64数据为空") + + logger.info(f"📊 处理第一张图片,base64长度: {len(first_image_base64)}") + # 移除可能存在的MIME类型前缀 - if images_base64.startswith("data:"): - images_base64 = images_base64.split(",", 1)[1] + if first_image_base64.startswith("data:"): + first_image_base64 = first_image_base64.split(",", 1)[1] # 彻底清理base64字符串 - 移除所有空白字符 - images_base64 = ''.join(images_base64.split()) + first_image_base64 = ''.join(first_image_base64.split()) # 验证base64字符串长度(应该是4的倍数) - if len(images_base64) % 4 != 0: + if len(first_image_base64) % 4 != 0: # 添加必要的填充 - images_base64 += '=' * (4 - len(images_base64) % 4) - logger.info(f"为base64字符串添加了填充,最终长度: {len(images_base64)}") + first_image_base64 += '=' * (4 - len(first_image_base64) % 4) + logger.info(f"为base64字符串添加了填充,最终长度: {len(first_image_base64)}") - logger.info(f"准备解码base64数据,长度: {len(images_base64)}, 前20字符: {images_base64[:20]}...") + logger.info(f"准备解码base64数据,长度: {len(first_image_base64)}, 前20字符: {first_image_base64[:20]}...") # 解码base64 - image_bytes = base64.b64decode(images_base64) - logger.info(f"base64解码成功,图片数据大小: {len(image_bytes)} bytes") + image_bytes = base64.b64decode(first_image_base64) + logger.info(f"✅ base64解码成功,图片数据大小: {len(image_bytes)} bytes") # 验证解码后的数据不为空 if len(image_bytes) == 0: @@ -262,12 +273,11 @@ class PosterService: # 检查文件头判断图片格式 file_header = image_bytes[:10] if file_header.startswith(b'\xff\xd8\xff'): - logger.info("检测到JPEG格式图片") + logger.info("✅ 检测到JPEG格式图片") elif file_header.startswith(b'\x89PNG'): - logger.info("检测到PNG格式图片") + logger.info("✅ 检测到PNG格式图片") else: - logger.warning(f"未识别的图片格式,文件头: {file_header.hex()}") - + logger.warning(f"⚠️ 未识别的图片格式,文件头: {file_header.hex()}") # 创建PIL Image对象 image_io = BytesIO(image_bytes) images = Image.open(image_io) @@ -659,10 +669,10 @@ class PosterService: # 生成JSON的base64编码 json_base64 = None - try: + try: json_string = json.dumps(fabric_json, ensure_ascii=False) json_base64 = base64.b64encode(json_string.encode('utf-8')).decode('utf-8') - except Exception as e: + except Exception as e: logger.warning(f"生成JSON base64编码失败: {e}") logger.info(f"Fabric.js JSON文件生成成功: {json_path} ({file_size/1024:.1f}KB)") @@ -692,7 +702,7 @@ class PosterService: def _generate_fabric_json(self, content: Dict[str, Any], template_id: str, image_size: List[int], images: Image.Image = None) -> Dict[str, Any]: """ - 生成与VibrantTemplate视觉效果一致的Fabric.js JSON格式 + 完全复制VibrantTemplate的渲染逻辑生成Fabric.js JSON Args: content: 海报内容数据 @@ -701,33 +711,42 @@ class PosterService: images: 用户上传的图片 Returns: - Dict: 扁平化的Fabric.js JSON格式数据 + Dict: 完全匹配VibrantTemplate的Fabric.js JSON格式数据 """ 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) + # VibrantTemplate的基础尺寸(900x1200) + base_width, base_height = 900, 1200 + # 最终输出尺寸(1350x1800) + final_width, final_height = image_size[0], image_size[1] # 1. 用户上传的图片(最底层 - Level 0) if images and hasattr(images, 'width'): - image_object = self._create_vibrant_image_object(images, canvas_width, canvas_height) + # 按VibrantTemplate方式缩放到基础尺寸 + image_object = self._create_vibrant_image_object_precise(images, final_width, final_height) fabric_objects.append(image_object) else: - # 占位符 - placeholder_object = self._create_placeholder_object(canvas_width, canvas_height) + placeholder_object = self._create_placeholder_object(final_width, final_height) fabric_objects.append(placeholder_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) + # 2. 估算内容高度(复制VibrantTemplate逻辑) + estimated_height = self._estimate_vibrant_content_height(content) + + # 3. 动态检测渐变起始位置(复制VibrantTemplate逻辑) + gradient_start = self._detect_vibrant_gradient_start_position(images, estimated_height, base_height) + + # 4. 提取毛玻璃颜色(复制VibrantTemplate逻辑) + glass_colors = self._extract_vibrant_glass_colors(images, gradient_start) + + # 5. 创建精确的毛玻璃效果(Level 1) + gradient_object = self._create_vibrant_glass_effect(final_width, final_height, gradient_start, glass_colors) fabric_objects.append(gradient_object) - # 3. VibrantTemplate风格的文字布局(Level 2) - text_objects = self._create_vibrant_text_objects(content, canvas_width, canvas_height, gradient_start, scale_ratio) + # 6. 按VibrantTemplate精确位置渲染文字(Level 2) + # 缩放渐变起始位置到最终尺寸 + scaled_gradient_start = int(gradient_start * final_height / base_height) + text_objects = self._create_vibrant_text_layout_precise(content, final_width, final_height, scaled_gradient_start) fabric_objects.extend(text_objects) # 构建完整的Fabric.js JSON @@ -738,8 +757,8 @@ class PosterService: "backgroundImage": None, "overlayImage": None, "clipPath": None, - "width": canvas_width, - "height": canvas_height, + "width": final_width, + "height": final_height, "viewportTransform": [1, 0, 0, 1, 0, 0], "backgroundVpt": True, "overlayVpt": True, @@ -756,10 +775,19 @@ class PosterService: "perPixelTargetFind": False, "targetFindTolerance": 0, "skipOffscreen": True, - "includeDefaultValues": True + "includeDefaultValues": True, + "metadata": { + "template": "VibrantTemplate", + "base_size": [base_width, base_height], + "final_size": [final_width, final_height], + "gradient_start": gradient_start, + "scaled_gradient_start": scaled_gradient_start, + "estimated_content_height": estimated_height, + "glass_colors": glass_colors + } } - logger.info(f"成功生成扁平化Fabric.js JSON,包含 {len(fabric_objects)} 个独立对象") + logger.info(f"成功生成VibrantTemplate精确Fabric.js JSON,包含 {len(fabric_objects)} 个对象") return fabric_json except Exception as e: @@ -917,11 +945,11 @@ class PosterService: "text": text_content, "textAlign": "center" if key in ['title', 'slogan', 'price'] else "left", "lineHeight": 1.2, - "angle": 0, - "flipX": False, - "flipY": False, - "opacity": 1, - "visible": True, + "angle": 0, + "flipX": False, + "flipY": False, + "opacity": 1, + "visible": True, "name": f"text_{key}", "data": { "type": "text", @@ -1040,7 +1068,7 @@ class PosterService: } def _create_gradient_background_object(self, canvas_width: int, canvas_height: int, gradient_start: int) -> Dict[str, Any]: - """创建模拟毛玻璃效果的渐变背景(简化版本,提高兼容性)""" + """创建VibrantTemplate风格的毛玻璃渐变背景""" return { "type": "rect", "version": "5.3.0", @@ -1050,19 +1078,33 @@ class PosterService: "top": gradient_start, "width": canvas_width, "height": canvas_height - gradient_start, - "fill": "rgba(0, 50, 120, 0.6)", # 简化为单一颜色,提高兼容性 + "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.7)"}, + {"offset": 1, "color": "rgba(0, 30, 80, 0.9)"} + ] + }, "stroke": "", "strokeWidth": 0, "angle": 0, "flipX": False, "flipY": False, - "opacity": 0.8, + "opacity": 0.85, "visible": True, "name": "glass_gradient", "data": { "type": "glass_effect", "layer": "background", - "level": 1 + "level": 1, + "effect": "vibrant_glass" }, "selectable": False, "evented": False @@ -1113,7 +1155,8 @@ class PosterService: "level": 2, "style": "vibrant_title", "target_width": title_target_width, - "actual_width": title_actual_width + "actual_width": title_actual_width, + "font_path": "/assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True @@ -1185,7 +1228,8 @@ class PosterService: "level": 2, "style": "vibrant_subtitle", "target_width": subtitle_target_width, - "actual_width": subtitle_actual_width + "actual_width": subtitle_actual_width, + "font_path": "/assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True @@ -1268,7 +1312,7 @@ class PosterService: "width": button_width - 20, "height": button_height, "fill": "#ffffff", - "fontFamily": "Arial, sans-serif", + "fontFamily": "Arial, sans-serif", "fontWeight": "bold", "fontSize": int(30 * scale_ratio), "text": button_text, @@ -1301,7 +1345,7 @@ class PosterService: "fontWeight": "normal", "fontSize": font_size, "text": f"• {item}", - "textAlign": "left", + "textAlign": "left", "name": f"content_item_{i}", "data": {"type": "content_item", "layer": "content", "level": 2}, "selectable": True, @@ -1587,7 +1631,8 @@ class PosterService: "layer": "content", "level": 2, "target_width": price_target_width, - "actual_width": price_actual_width + "actual_width": price_actual_width, + "font_path": "/assets/font/兰亭粗黑简.TTF" }, "selectable": True, "evented": True @@ -1684,4 +1729,308 @@ class PosterService: } objects.append(ticket_obj) - return objects \ No newline at end of file + return objects + + def _estimate_vibrant_content_height(self, content: Dict[str, Any]) -> int: + """复制VibrantTemplate的内容高度估算逻辑""" + standard_margin = 25 + title_height = 100 + subtitle_height = 80 + button_height = 40 + content_items = content.get("content_items", []) + content_line_height = 32 + content_list_height = len(content_items) * content_line_height + price_height = 90 + ticket_height = 60 + remarks = content.get("remarks", []) + if isinstance(remarks, str): + remarks = [remarks] + remarks_height = len(remarks) * 25 + 10 + footer_height = 40 + total_height = ( + 20 + title_height + standard_margin + subtitle_height + standard_margin + + button_height + 15 + content_list_height + price_height + ticket_height + + remarks_height + footer_height + 30 + ) + logger.info(f"VibrantTemplate估算内容高度: {total_height}") + return total_height + + def _detect_vibrant_gradient_start_position(self, image: Image.Image, estimated_height: int, base_height: int) -> int: + """复制VibrantTemplate的动态渐变起始位置检测""" + if not image or not hasattr(image, 'width'): + # 如果没有图像,使用估算位置 + bottom_margin = 60 + gradient_start = max(base_height - estimated_height - bottom_margin, base_height // 2) + logger.info(f"无图像,使用估算渐变起始位置: {gradient_start}") + return gradient_start + + # 临时缩放图像到基础尺寸进行分析 + temp_image = image.resize((900, 1200), Image.LANCZOS) + if temp_image.mode != 'RGB': + temp_image = temp_image.convert('RGB') + + width, height = temp_image.size + center_x = width // 2 + gradient_start = None + + # 从中央开始扫描,寻找亮度>50的像素 + for y in range(height // 2, height): + try: + pixel = temp_image.getpixel((center_x, y)) + if isinstance(pixel, (tuple, list)) and len(pixel) >= 3: + brightness = sum(pixel[:3]) / 3 + if brightness > 50: + gradient_start = max(y - 20, height // 2) + logger.info(f"检测到亮度>50的像素位置: y={y}, brightness={brightness:.1f}") + break + except: + continue + + # 如果没有找到合适位置,使用估算位置 + if gradient_start is None: + bottom_margin = 60 + gradient_start = max(height - estimated_height - bottom_margin, height // 2) + logger.info(f"未找到合适像素,使用估算渐变起始位置: {gradient_start}") + else: + logger.info(f"动态检测到渐变起始位置: {gradient_start}") + + return gradient_start + + def _extract_vibrant_glass_colors(self, image: Image.Image, gradient_start: int) -> Dict[str, Tuple[int, int, int]]: + """复制VibrantTemplate的毛玻璃颜色提取逻辑""" + if not image or not hasattr(image, 'width'): + # 默认蓝色毛玻璃效果 + default_colors = { + "top_color": (0, 5, 15), + "bottom_color": (0, 25, 50) + } + logger.info(f"无图像,使用默认毛玻璃颜色: {default_colors}") + return default_colors + + # 临时缩放图像到基础尺寸进行颜色提取 + temp_image = image.resize((900, 1200), Image.LANCZOS) + if temp_image.mode != 'RGB': + temp_image = temp_image.convert('RGB') + + width, height = temp_image.size + top_samples, bottom_samples = [], [] + + # 在渐变起始位置+20px采样顶部颜色 + top_y = min(gradient_start + 20, height - 1) + for x in range(0, width, 20): + try: + pixel = temp_image.getpixel((x, top_y)) + if sum(pixel) > 30: # 排除过暗像素 + top_samples.append(pixel) + except: + continue + + # 在底部-50px采样底部颜色 + bottom_y = min(height - 50, height - 1) + for x in range(0, width, 20): + try: + pixel = temp_image.getpixel((x, bottom_y)) + if sum(pixel) > 30: # 排除过暗像素 + bottom_samples.append(pixel) + except: + continue + + # 计算平均颜色并降低亮度(复制VibrantTemplate逻辑) + import numpy as np + if top_samples: + top_avg = np.mean(top_samples, axis=0) + top_color = tuple(max(0, int(c * 0.1)) for c in top_avg) # 10%亮度 + else: + top_color = (0, 5, 15) + + if bottom_samples: + bottom_avg = np.mean(bottom_samples, axis=0) + bottom_color = tuple(max(0, int(c * 0.2)) for c in bottom_avg) # 20%亮度 + else: + bottom_color = (0, 25, 50) + + colors = { + "top_color": top_color, + "bottom_color": bottom_color + } + logger.info(f"提取毛玻璃颜色: 顶部={top_color}({len(top_samples)}样本), 底部={bottom_color}({len(bottom_samples)}样本)") + return colors + + def _create_vibrant_glass_effect(self, canvas_width: int, canvas_height: int, gradient_start: int, glass_colors: Dict) -> Dict[str, Any]: + """创建VibrantTemplate精确的毛玻璃效果""" + # 缩放渐变起始位置到最终尺寸 + scaled_gradient_start = int(gradient_start * canvas_height / 1200) + + top_color = glass_colors["top_color"] + bottom_color = glass_colors["bottom_color"] + + # 创建复杂的渐变效果,模拟VibrantTemplate的数学公式 + gradient_stops = [] + + # 生成多个渐变停止点以模拟复杂的数学渐变 + import math + for i in range(10): + ratio = i / 9 # 0到1 + # 复制VibrantTemplate的smooth_ratio公式 + smooth_ratio = 0.5 - 0.5 * math.cos(ratio * math.pi) + + # 插值颜色 + r = int((1 - smooth_ratio) * top_color[0] + smooth_ratio * bottom_color[0]) + g = int((1 - smooth_ratio) * top_color[1] + smooth_ratio * bottom_color[1]) + b = int((1 - smooth_ratio) * top_color[2] + smooth_ratio * bottom_color[2]) + + # 复制VibrantTemplate的透明度计算 + alpha_smooth = ratio ** (1.1 / 1.5) # intensity=1.5 + alpha = 0.02 + 0.98 * alpha_smooth + + gradient_stops.append({ + "offset": ratio, + "color": f"rgba({r}, {g}, {b}, {alpha:.3f})" + }) + + logger.info(f"创建毛玻璃效果: 起始位置={scaled_gradient_start}, 渐变点={len(gradient_stops)}") + + return { + "type": "rect", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": 0, + "top": scaled_gradient_start, + "width": canvas_width, + "height": canvas_height - scaled_gradient_start, + "fill": { + "type": "linear", + "coords": { + "x1": 0, + "y1": 0, + "x2": 0, + "y2": canvas_height - scaled_gradient_start + }, + "colorStops": gradient_stops + }, + "stroke": "", + "strokeWidth": 0, + "angle": 0, + "flipX": False, + "flipY": False, + "opacity": 0.85, + "visible": True, + "name": "vibrant_glass_effect", + "data": { + "type": "glass_effect", + "layer": "background", + "level": 1, + "effect": "vibrant_template_precise" + }, + "selectable": False, + "evented": False + } + + def _create_vibrant_image_object_precise(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": "vibrant_background_image", + "data": { + "type": "background_image", + "layer": "image", + "level": 0, + "replaceable": True + }, + "selectable": True, + "evented": True + } + + def _create_vibrant_text_layout_precise(self, content: Dict[str, Any], canvas_width: int, canvas_height: int, gradient_start: int) -> List[Dict[str, Any]]: + """复制VibrantTemplate的精确文本布局逻辑""" + text_objects = [] + + # 计算基础参数(缩放到最终尺寸) + center_x = canvas_width // 2 + scale_factor = canvas_height / 1800 # 从1800缩放到最终高度 + + # 简化版本:使用固定边距 + margin_ratio = 0.1 + left_margin = int(canvas_width * margin_ratio) + right_margin = int(canvas_width * (1 - margin_ratio)) + + # 标题(VibrantTemplate: gradient_start + 40) + title_y = gradient_start + int(40 * scale_factor) + if title := content.get("title"): + title_size = int(80 * scale_factor) + title_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "center", + "originY": "top", + "left": center_x, + "top": title_y, + "width": right_margin - left_margin, + "height": title_size + 20, + "fill": "#ffffff", + "stroke": "#001e50", + "strokeWidth": 4, + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial Black, sans-serif", + "fontWeight": "bold", + "fontSize": title_size, + "text": title, + "textAlign": "center", + "lineHeight": 1.1, + "name": "vibrant_title_precise", + "data": {"type": "title", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + text_objects.append(title_obj) + + # 副标题 + subtitle_y = title_y + int(130 * scale_factor) + if slogan := content.get("slogan"): + subtitle_size = int(40 * scale_factor) + subtitle_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "center", + "originY": "top", + "left": center_x, + "top": subtitle_y, + "width": right_margin - left_margin, + "height": subtitle_size + 15, + "fill": "#ffffff", + "shadow": "rgba(0, 0, 0, 0.7) 2px 2px 5px", + "fontFamily": "兰亭粗黑简, 微软雅黑, Microsoft YaHei, PingFang SC, Hiragino Sans GB, Arial, sans-serif", + "fontWeight": "normal", + "fontSize": subtitle_size, + "text": slogan, + "textAlign": "center", + "lineHeight": 1.2, + "name": "vibrant_slogan_precise", + "data": {"type": "slogan", "layer": "content", "level": 2}, + "selectable": True, + "evented": True + } + text_objects.append(subtitle_obj) + + logger.info(f"创建VibrantTemplate精确文本布局: {len(text_objects)}个对象") + return text_objects \ No newline at end of file