Files
video-flow/modules/fancy_text.py
Tony Zhang 33a165a615 feat: video-flow initial commit
- app.py: Streamlit UI for video generation workflow
- main_flow.py: CLI tool with argparse support
- modules/: Business logic modules (script_gen, image_gen, video_gen, composer, etc.)
- config.py: Configuration with API keys and paths
- requirements.txt: Python dependencies
- docs/: System prompt documentation
2025-12-12 19:18:27 +08:00

709 lines
22 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.

"""
抖音风格花字生成模块
使用 Pillow 生成透明 PNG 图片,支持描边、渐变、气泡框等效果
"""
import os
import hashlib
import logging
from pathlib import Path
from typing import Dict, Any, Tuple, List, Optional
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import config
logger = logging.getLogger(__name__)
# 花字缓存目录
FANCY_TEXT_CACHE_DIR = config.TEMP_DIR / "fancy_text_cache"
FANCY_TEXT_CACHE_DIR.mkdir(exist_ok=True)
def _get_font(font_name: str = None, size: int = 48) -> ImageFont.FreeTypeFont:
"""获取字体对象,遇到无效字体会继续尝试下一候选,最后才降级为默认字体"""
candidates = []
if font_name and os.path.exists(font_name):
candidates.append(font_name)
else:
candidates.extend([
config.FONTS_DIR / "AlibabaPuHuiTi-Bold.ttf",
config.FONTS_DIR / "AlibabaPuHuiTi-Regular.ttf",
config.FONTS_DIR / "NotoSansSC-Bold.otf",
config.FONTS_DIR / "NotoSansSC-Regular.otf",
])
candidates.extend([
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
])
for path in candidates:
if not path:
continue
p = str(path)
if not os.path.exists(p):
continue
if isinstance(path, Path) and path.stat().st_size < 10000:
continue
try:
return ImageFont.truetype(p, size)
except Exception as e:
logger.warning(f"Failed to load font {p}: {e}")
continue
logger.warning("No suitable font found, using default")
return ImageFont.load_default()
def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
"""十六进制颜色转 RGB"""
hex_color = hex_color.lstrip("#")
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def _get_text_size(text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
"""获取文字尺寸"""
# 创建临时图像来测量文字
dummy_img = Image.new("RGBA", (1, 1))
draw = ImageDraw.Draw(dummy_img)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def _cache_key(text: str, style: Dict) -> str:
"""生成缓存键"""
content = f"{text}_{str(sorted(style.items()))}"
return hashlib.md5(content.encode()).hexdigest()
def create_text_with_stroke(
text: str,
font_size: int = 60,
font_color: str = "#FFFFFF",
stroke_color: str = "#000000",
stroke_width: int = 4,
font_name: str = None,
padding: int = 20
) -> Image.Image:
"""
创建带描边的文字图片
Args:
text: 文字内容
font_size: 字体大小
font_color: 字体颜色(十六进制)
stroke_color: 描边颜色
stroke_width: 描边宽度
font_name: 字体路径
padding: 内边距
Returns:
透明 PNG 图片
"""
font = _get_font(font_name, font_size)
text_w, text_h = _get_text_size(text, font)
# 图片尺寸(加上描边和内边距)
img_w = text_w + stroke_width * 2 + padding * 2
img_h = text_h + stroke_width * 2 + padding * 2
# 创建透明图片
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 文字位置
x = padding + stroke_width
y = padding + stroke_width
# 绘制描边(通过偏移绘制多次)
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx * dx + dy * dy <= stroke_width * stroke_width:
draw.text((x + dx, y + dy), text, font=font, fill=stroke_rgb)
# 绘制主文字
font_rgb = _hex_to_rgb(font_color) + (255,)
draw.text((x, y), text, font=font, fill=font_rgb)
return img
def create_text_with_shadow(
text: str,
font_size: int = 60,
font_color: str = "#FFFFFF",
shadow_color: str = "#000000",
shadow_offset: Tuple[int, int] = (4, 4),
shadow_blur: int = 5,
font_name: str = None,
padding: int = 30,
stroke_color: str = None,
stroke_width: int = 0
) -> Image.Image:
"""
创建带阴影的文字图片,可选描边(用于双层安全描边)
"""
font = _get_font(font_name, font_size)
text_w, text_h = _get_text_size(text, font)
# 图片尺寸
extra = max(shadow_blur, stroke_width * 2)
img_w = text_w + abs(shadow_offset[0]) + extra * 2 + padding * 2
img_h = text_h + abs(shadow_offset[1]) + extra * 2 + padding * 2
shadow_img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
shadow_draw = ImageDraw.Draw(shadow_img)
x = padding + extra
y = padding + extra
# 阴影
shadow_rgb = _hex_to_rgb(shadow_color) + (180,)
shadow_draw.text((x + shadow_offset[0], y + shadow_offset[1]), text, font=font, fill=shadow_rgb)
shadow_img = shadow_img.filter(ImageFilter.GaussianBlur(shadow_blur))
draw = ImageDraw.Draw(shadow_img)
# 可选描边(外层深色或浅色)
if stroke_color and stroke_width > 0:
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx * dx + dy * dy <= stroke_width * stroke_width:
draw.text((x + dx, y + dy), text, font=font, fill=stroke_rgb)
# 主文字
font_rgb = _hex_to_rgb(font_color) + (255,)
draw.text((x, y), text, font=font, fill=font_rgb)
return shadow_img
def create_text_with_gradient(
text: str,
font_size: int = 60,
gradient_colors: List[str] = None,
gradient_direction: str = "vertical", # vertical, horizontal
stroke_color: str = "#000000",
stroke_width: int = 3,
font_name: str = None,
padding: int = 20
) -> Image.Image:
"""
创建渐变色文字图片
Args:
gradient_colors: 渐变颜色列表,如 ["#FF6B6B", "#FFE66D"]
gradient_direction: 渐变方向
"""
if not gradient_colors:
gradient_colors = ["#FF6B6B", "#FFE66D"] # 默认红黄渐变
font = _get_font(font_name, font_size)
text_w, text_h = _get_text_size(text, font)
img_w = text_w + stroke_width * 2 + padding * 2
img_h = text_h + stroke_width * 2 + padding * 2
# 创建渐变图层
gradient = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
gradient_draw = ImageDraw.Draw(gradient)
# 生成渐变
colors = [_hex_to_rgb(c) for c in gradient_colors]
for i in range(img_h if gradient_direction == "vertical" else img_w):
ratio = i / (img_h if gradient_direction == "vertical" else img_w)
# 线性插值颜色
if ratio < 0.5:
r = ratio * 2
c1, c2 = colors[0], colors[min(1, len(colors) - 1)]
else:
r = (ratio - 0.5) * 2
c1 = colors[min(1, len(colors) - 1)]
c2 = colors[min(2, len(colors) - 1)] if len(colors) > 2 else c1
color = tuple(int(c1[j] + (c2[j] - c1[j]) * r) for j in range(3)) + (255,)
if gradient_direction == "vertical":
gradient_draw.line([(0, i), (img_w, i)], fill=color)
else:
gradient_draw.line([(i, 0), (i, img_h)], fill=color)
# 创建文字蒙版
mask = Image.new("L", (img_w, img_h), 0)
mask_draw = ImageDraw.Draw(mask)
x = padding + stroke_width
y = padding + stroke_width
# 先绘制描边蒙版
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx * dx + dy * dy <= stroke_width * stroke_width:
mask_draw.text((x + dx, y + dy), text, font=font, fill=128)
# 主文字蒙版
mask_draw.text((x, y), text, font=font, fill=255)
# 创建结果图片
result = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
# 绘制描边
stroke_img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
stroke_draw = ImageDraw.Draw(stroke_img)
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx * dx + dy * dy <= stroke_width * stroke_width:
stroke_draw.text((x + dx, y + dy), text, font=font, fill=stroke_rgb)
result = Image.alpha_composite(result, stroke_img)
# 应用渐变到文字
text_mask = Image.new("L", (img_w, img_h), 0)
ImageDraw.Draw(text_mask).text((x, y), text, font=font, fill=255)
gradient_text = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
gradient_text.paste(gradient, mask=text_mask)
result = Image.alpha_composite(result, gradient_text)
return result
def create_bubble_text(
text: str,
font_size: int = 48,
font_color: str = "#333333",
bg_color: str = "#FFFFFF",
border_color: str = "#CCCCCC",
border_width: int = 2,
corner_radius: int = 20,
padding: Tuple[int, int] = (30, 15),
font_name: str = None,
tail_direction: str = None # "left", "right", "bottom", None
) -> Image.Image:
"""
创建气泡框文字(对话框效果)
Args:
tail_direction: 气泡尾巴方向
"""
font = _get_font(font_name, font_size)
text_w, text_h = _get_text_size(text, font)
# 气泡尺寸
bubble_w = text_w + padding[0] * 2
bubble_h = text_h + padding[1] * 2
# 增加尾巴空间
tail_size = 20 if tail_direction else 0
if tail_direction in ["left", "right"]:
img_w = bubble_w + tail_size
img_h = bubble_h
elif tail_direction == "bottom":
img_w = bubble_w
img_h = bubble_h + tail_size
else:
img_w = bubble_w
img_h = bubble_h
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 气泡位置
if tail_direction == "left":
bx = tail_size
else:
bx = 0
by = 0
# 绘制圆角矩形
bg_rgb = _hex_to_rgb(bg_color) + (255,)
border_rgb = _hex_to_rgb(border_color) + (255,)
# 使用圆角矩形
draw.rounded_rectangle(
[bx, by, bx + bubble_w, by + bubble_h],
radius=corner_radius,
fill=bg_rgb,
outline=border_rgb,
width=border_width
)
# 绘制尾巴
if tail_direction == "left":
points = [
(bx, bubble_h // 2 - 10),
(0, bubble_h // 2),
(bx, bubble_h // 2 + 10)
]
draw.polygon(points, fill=bg_rgb, outline=border_rgb)
# 覆盖边框内部分
draw.polygon(points, fill=bg_rgb)
elif tail_direction == "right":
points = [
(bx + bubble_w, bubble_h // 2 - 10),
(img_w, bubble_h // 2),
(bx + bubble_w, bubble_h // 2 + 10)
]
draw.polygon(points, fill=bg_rgb, outline=border_rgb)
draw.polygon(points, fill=bg_rgb)
elif tail_direction == "bottom":
points = [
(bubble_w // 2 - 10, bubble_h),
(bubble_w // 2, img_h),
(bubble_w // 2 + 10, bubble_h)
]
draw.polygon(points, fill=bg_rgb, outline=border_rgb)
draw.polygon(points, fill=bg_rgb)
# 绘制文字
font_rgb = _hex_to_rgb(font_color) + (255,)
text_x = bx + padding[0]
text_y = by + padding[1]
draw.text((text_x, text_y), text, font=font, fill=font_rgb)
return img
def create_price_tag(
price: str,
currency: str = "¥",
font_size: int = 72,
price_color: str = "#FF4444",
currency_color: str = "#FF4444",
stroke_color: str = "#FFFFFF",
stroke_width: int = 4,
font_name: str = None
) -> Image.Image:
"""
创建价格标签(电商风格)
"""
font_large = _get_font(font_name, font_size)
font_small = _get_font(font_name, int(font_size * 0.5))
# 测量尺寸
currency_w, currency_h = _get_text_size(currency, font_small)
price_w, price_h = _get_text_size(price, font_large)
total_w = currency_w + price_w + 5
total_h = max(currency_h, price_h)
padding = stroke_width + 10
img_w = total_w + padding * 2
img_h = total_h + padding * 2
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 绘制描边
stroke_rgb = _hex_to_rgb(stroke_color) + (255,)
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx * dx + dy * dy <= stroke_width * stroke_width:
# 货币符号
draw.text(
(padding + dx, padding + (total_h - currency_h) // 2 + dy),
currency, font=font_small, fill=stroke_rgb
)
# 价格
draw.text(
(padding + currency_w + 5 + dx, padding + (total_h - price_h) // 2 + dy),
price, font=font_large, fill=stroke_rgb
)
# 绘制文字
currency_rgb = _hex_to_rgb(currency_color) + (255,)
price_rgb = _hex_to_rgb(price_color) + (255,)
draw.text(
(padding, padding + (total_h - currency_h) // 2),
currency, font=font_small, fill=currency_rgb
)
draw.text(
(padding + currency_w + 5, padding + (total_h - price_h) // 2),
price, font=font_large, fill=price_rgb
)
return img
def create_button(
text: str,
font_size: int = 36,
font_color: str = "#FFFFFF",
bg_color: str = "#FF6B35",
corner_radius: int = 25,
padding: Tuple[int, int] = (40, 15),
font_name: str = None,
shadow: bool = True
) -> Image.Image:
"""
创建按钮样式文字(如"立即抢购"
"""
font = _get_font(font_name, font_size)
text_w, text_h = _get_text_size(text, font)
btn_w = text_w + padding[0] * 2
btn_h = text_h + padding[1] * 2
shadow_offset = 4 if shadow else 0
img_w = btn_w + shadow_offset
img_h = btn_h + shadow_offset
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 绘制阴影
if shadow:
shadow_color = (0, 0, 0, 80)
draw.rounded_rectangle(
[shadow_offset, shadow_offset, btn_w + shadow_offset, btn_h + shadow_offset],
radius=corner_radius,
fill=shadow_color
)
# 绘制按钮背景
bg_rgb = _hex_to_rgb(bg_color) + (255,)
draw.rounded_rectangle(
[0, 0, btn_w, btn_h],
radius=corner_radius,
fill=bg_rgb
)
# 绘制文字
font_rgb = _hex_to_rgb(font_color) + (255,)
text_x = padding[0]
text_y = padding[1]
draw.text((text_x, text_y), text, font=font, fill=font_rgb)
return img
def create_comparison_text(
left_text: str,
right_text: str,
vs_text: str = "vs",
font_size: int = 48,
left_color: str = "#666666",
right_color: str = "#FF6B35",
vs_color: str = "#FF0000",
font_name: str = None
) -> Image.Image:
"""
创建对比文字(如"塌马尾 vs 高颅顶"
"""
font = _get_font(font_name, font_size)
font_vs = _get_font(font_name, int(font_size * 0.8))
left_w, left_h = _get_text_size(left_text, font)
vs_w, vs_h = _get_text_size(vs_text, font_vs)
right_w, right_h = _get_text_size(right_text, font)
spacing = 15
total_w = left_w + vs_w + right_w + spacing * 2
total_h = max(left_h, vs_h, right_h)
padding = 20
stroke_width = 3
img_w = total_w + padding * 2 + stroke_width * 2
img_h = total_h + padding * 2 + stroke_width * 2
img = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
x = padding + stroke_width
y = padding + stroke_width
# 描边
stroke_color = (0, 0, 0, 255)
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx * dx + dy * dy <= stroke_width * stroke_width:
draw.text((x + dx, y + (total_h - left_h) // 2 + dy), left_text, font=font, fill=stroke_color)
draw.text((x + left_w + spacing + dx, y + (total_h - vs_h) // 2 + dy), vs_text, font=font_vs, fill=stroke_color)
draw.text((x + left_w + spacing + vs_w + spacing + dx, y + (total_h - right_h) // 2 + dy), right_text, font=font, fill=stroke_color)
# 绘制文字
left_rgb = _hex_to_rgb(left_color) + (255,)
vs_rgb = _hex_to_rgb(vs_color) + (255,)
right_rgb = _hex_to_rgb(right_color) + (255,)
draw.text((x, y + (total_h - left_h) // 2), left_text, font=font, fill=left_rgb)
draw.text((x + left_w + spacing, y + (total_h - vs_h) // 2), vs_text, font=font_vs, fill=vs_rgb)
draw.text((x + left_w + spacing + vs_w + spacing, y + (total_h - right_h) // 2), right_text, font=font, fill=right_rgb)
return img
# ============================================================
# 预设样式
# ============================================================
PRESET_STYLES = {
"subtitle": {
"font_size": 48,
"font_color": "#FFFFFF",
"stroke_color": "#000000",
"stroke_width": 3,
"version": "v2"
},
"highlight": {
# 暖米白主色 + 浅描边 + 暗色阴影,匹配浅棕背景
"font_size": 90,
"font_color": "#F7E7D3",
"stroke_color": "#C9B59A", # 浅描边
"stroke_width": 4,
"type": "shadow",
"shadow_color": "#3A2C1F", # 暗棕阴影
"shadow_offset": (3, 3),
"shadow_blur": 10,
"padding": 32,
"version": "gloda"
},
"warning": {
# 低饱和陶土红 + 米色描边 + 暗棕阴影
"font_size": 80,
"font_color": "#D96B4F",
"stroke_color": "#F6E5D6",
"stroke_width": 4,
"type": "shadow",
"shadow_color": "#3A2C1F",
"shadow_offset": (3, 3),
"shadow_blur": 10,
"padding": 30,
"version": "gloda"
},
"success": {
"font_size": 52,
"font_color": "#4CAF50",
"stroke_color": "#FFFFFF",
"stroke_width": 4,
"version": "v2"
},
"price": {
# 价格标签:温暖红 + 米白货币符号 + 暗描边
"font_size": 110,
"price_color": "#E25B4F",
"currency_color": "#F6E5D6",
"stroke_color": "#3A2C1F",
"stroke_width": 8,
"type": "price",
"version": "gloda"
},
"cta_button": {
# 暖橙按钮,轻阴影
"font_size": 46,
"font_color": "#FFFFFF",
"bg_color": "#E6763A",
"corner_radius": 32,
"type": "button",
"shadow": True,
"version": "gloda"
}
}
def create_fancy_text(
text: str,
style: str = "subtitle",
custom_style: Dict[str, Any] = None,
cache: bool = True
) -> str:
"""
创建花字图片的统一入口
Args:
text: 文字内容
style: 预设样式名称
custom_style: 自定义样式(覆盖预设)
cache: 是否缓存
Returns:
PNG 图片路径
"""
# 合并样式
base_style = PRESET_STYLES.get(style, PRESET_STYLES["subtitle"]).copy()
if custom_style:
base_style.update(custom_style)
# 检查缓存
if cache:
cache_name = _cache_key(text, base_style)
cache_path = FANCY_TEXT_CACHE_DIR / f"{cache_name}.png"
if cache_path.exists():
return str(cache_path)
# 根据样式类型创建图片
style_type = base_style.pop("type", None)
if style == "price" or style_type == "price":
img = create_price_tag(text, **{k: v for k, v in base_style.items() if k in [
"currency", "font_size", "price_color", "currency_color", "stroke_color", "stroke_width", "font_name"
]})
elif style == "cta_button" or style_type == "button":
img = create_button(text, **{k: v for k, v in base_style.items() if k in [
"font_size", "font_color", "bg_color", "corner_radius", "padding", "font_name", "shadow"
]})
elif style_type == "bubble":
img = create_bubble_text(text, **{k: v for k, v in base_style.items() if k in [
"font_size", "font_color", "bg_color", "border_color", "border_width",
"corner_radius", "padding", "font_name", "tail_direction"
]})
elif style_type == "gradient":
img = create_text_with_gradient(text, **{k: v for k, v in base_style.items() if k in [
"font_size", "gradient_colors", "gradient_direction", "stroke_color", "stroke_width", "font_name", "padding"
]})
elif style_type == "shadow":
img = create_text_with_shadow(text, **{k: v for k, v in base_style.items() if k in [
"font_size", "font_color", "shadow_color", "shadow_offset", "shadow_blur", "font_name", "padding"
]})
else:
# 默认带描边文字
img = create_text_with_stroke(text, **{k: v for k, v in base_style.items() if k in [
"font_size", "font_color", "stroke_color", "stroke_width", "font_name", "padding"
]})
# 保存
if cache:
output_path = str(cache_path)
else:
output_path = str(config.TEMP_DIR / f"fancy_{hash(text)}_{os.getpid()}.png")
img.save(output_path, "PNG")
logger.info(f"Created fancy text: '{text[:20]}...' -> {output_path}")
return output_path
def batch_create_fancy_texts(
configs: List[Dict[str, Any]]
) -> List[str]:
"""
批量创建花字图片
Args:
configs: 配置列表 [{text, style, custom_style}]
Returns:
PNG 图片路径列表
"""
paths = []
for cfg in configs:
path = create_fancy_text(
text=cfg.get("text", ""),
style=cfg.get("style", "subtitle"),
custom_style=cfg.get("custom_style")
)
paths.append(path)
return paths