jinye_huang f96f0257ab feat(poster_v2): 优化布局渲染效果
- split_vertical: 字号调大 (标题84/副标题34/正文32)
- hero_bottom: 从图片提取主色调,渐变范围350px更柔和
- 提示词优化: 小红书爆款公式 (悬念/数字/对比/情感/身份式)
- card_float: 卡片高度限制500-800px
- overlay_bottom: 亮点标签支持换行,间距优化
2025-12-10 15:33:53 +08:00

202 lines
7.7 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
布局A: 大图在上,文字在下
适用场景: 景点、美食、通用
"""
from typing import Tuple
from PIL import Image, ImageDraw
from .base import BaseLayout
from ..schemas.content import PosterContent
from ..schemas.theme import Theme
from ..renderers.text import TextRenderer
class HeroBottomLayout(BaseLayout):
"""大图底部文字布局"""
# 字号配置
TITLE_SIZE = 96
SUBTITLE_SIZE = 36
PRICE_SIZE = 88
DETAIL_SIZE = 32
TAG_SIZE = 24
def calculate_content_height(self, content: PosterContent, theme: Theme) -> int:
"""计算内容区域高度 (与 design_samples 保持一致)"""
height = self.PADDING
# 标题
title_font = TextRenderer.get_title_font(self.TITLE_SIZE)
_, title_h = TextRenderer.measure_text(content.title, title_font)
height += title_h + 14
# 副标题 (始终计算,即使为空也保留空间)
height += 32 + 44
# 装饰线
height += 28
# 详情
if content.details:
height += len(content.details) * 44 + 20
# 价格
price_font = TextRenderer.get_title_font(self.PRICE_SIZE)
_, price_h = TextRenderer.measure_text("¥999", price_font)
height += price_h + self.PADDING
return height
def generate(self, content: PosterContent, theme: Theme) -> Image.Image:
"""生成海报 (与 design_samples 保持一致)"""
# 计算内容高度
content_height = self.calculate_content_height(content, theme)
content_y = self.height - content_height - 40
# 创建画布 (淡色底)
canvas = self.create_canvas(theme.secondary_rgb)
draw = ImageDraw.Draw(canvas)
# === 上半部分: 渐变或图片 ===
overlay_color = theme.primary_rgb # 默认使用主题色
if content.image:
# 有图片: 图片覆盖整个画布
img = content.image.copy().convert("RGBA")
img = img.resize(self.size, Image.LANCZOS)
canvas.paste(img, (0, 0))
# 从图片底部区域提取主色调
bottom_region = img.crop((0, int(self.height * 0.7), self.width, self.height))
bottom_small = bottom_region.resize((50, 50), Image.LANCZOS)
pixels = list(bottom_small.getdata())
# 计算平均颜色
r = sum(p[0] for p in pixels) // len(pixels)
g = sum(p[1] for p in pixels) // len(pixels)
b = sum(p[2] for p in pixels) // len(pixels)
# 稍微加深以便文字可读
overlay_color = (max(0, r - 30), max(0, g - 30), max(0, b - 30))
else:
# 无图片: 渐变色
gradient = self.effect.create_gradient(
self.size,
theme.gradient_rgb[0],
theme.gradient_rgb[1]
)
canvas.paste(gradient, (0, 0))
# === 底部渐变遮罩 (根据图片颜色自适应) ===
# 清晰范围更大,只遮盖底部文字区域
# 实色底(仅覆盖文字区域)
solid_start = content_y + 50 # 更低的起点,让图片更清晰
solid_bg = Image.new("RGBA", (self.width, self.height - solid_start), (*overlay_color, 200))
canvas.paste(solid_bg, (0, solid_start))
# 渐变过渡(更大范围,更柔和)
fade_height = 350 # 更大的渐变范围
fade = self.effect.create_gradient(
(self.width, fade_height),
(*overlay_color, 0), # 顶部:完全透明
(*overlay_color, 200) # 底部:与实色底衔接
)
canvas.paste(fade, (0, solid_start - fade_height), fade)
# 重新获取 draw (因为 paste 后需要)
draw = ImageDraw.Draw(canvas)
# 字体
title_font = TextRenderer.get_title_font(self.TITLE_SIZE)
subtitle_font = TextRenderer.get_body_font(self.SUBTITLE_SIZE)
price_font = TextRenderer.get_title_font(self.PRICE_SIZE)
detail_font = TextRenderer.get_body_font(self.DETAIL_SIZE)
tag_font = TextRenderer.get_body_font(self.TAG_SIZE)
suffix_font = TextRenderer.get_body_font(28)
text_white = theme.text_rgb
accent = theme.accent_rgb
cur_y = content_y + self.PADDING
content_width = self.width - self.MARGIN * 2
# === 标题 (自适应大小,尽量单行显示) ===
adaptive_title_font = TextRenderer.get_adaptive_title_font(
content.title, content_width, base_size=self.TITLE_SIZE, min_size=56
)
title_w, title_h = TextRenderer.measure_text(content.title, adaptive_title_font)
# 居中显示
title_x = self.MARGIN + (content_width - title_w) // 2
TextRenderer.draw_text_with_shadow(
draw, (title_x, cur_y), content.title, adaptive_title_font,
theme.text, shadow_color=(0, 0, 0, 60), offset=(2, 2)
)
cur_y += title_h + 14
# === 副标题 (支持换行) ===
if content.subtitle:
_, sub_h = TextRenderer.draw_wrapped_text(
draw, (self.MARGIN, cur_y), content.subtitle, subtitle_font,
(*text_white, 200), content_width, line_spacing=4
)
cur_y += max(sub_h, 36) + 8
# === 装饰线 ===
self.shape.draw_decorator_line(draw, (self.MARGIN, cur_y), 60, (*accent, 200))
cur_y += 28
# === 详情 ===
if content.details:
for detail in content.details:
self.shape.draw_ellipse(draw,
(self.MARGIN, cur_y + 12, self.MARGIN + 8, cur_y + 20),
fill=(*accent, 180))
draw.text((self.MARGIN + 18, cur_y), detail,
font=detail_font, fill=(*text_white, 180))
cur_y += 44
cur_y += 20
# === 价格 ===
if content.price:
price_w, price_h = TextRenderer.measure_text(content.price, price_font)
# 计算后缀宽度
suffix = content.price_suffix or ""
suffix_w = 0
if suffix:
suffix_w, _ = TextRenderer.measure_text(suffix, suffix_font)
# 价格背景 (半透明主题色)
bg_width = price_w + suffix_w + 40 if suffix else price_w + 24
self.shape.draw_rounded_rect(draw,
(self.MARGIN - 12, cur_y - 6, self.MARGIN + bg_width, cur_y + price_h + 8),
radius=12, fill=(*accent, 40))
# 价格文字 (白色,与背景形成对比)
draw.text((self.MARGIN, cur_y), content.price, font=price_font, fill=text_white)
# 后缀 (如果有)
if suffix:
draw.text((self.MARGIN + price_w + 8, cur_y + price_h - 28), suffix,
font=suffix_font, fill=(*text_white, 200))
# === 标签 ===
if content.tags:
tag_x = self.width - self.MARGIN
for tag in reversed(content.tags):
tag_text = f"#{tag}"
tag_w, _ = TextRenderer.measure_text(tag_text, tag_font)
tag_x -= tag_w + 22
self.shape.draw_rounded_rect(draw,
(tag_x, cur_y + 20, tag_x + tag_w + 16, cur_y + 54),
radius=17, fill=(*accent, 35))
draw.text((tag_x + 8, cur_y + 24), tag_text,
font=tag_font, fill=text_white)
tag_x -= 8
return canvas