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
This commit is contained in:
251
modules/text_renderer.py
Normal file
251
modules/text_renderer.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
通用文本渲染引擎
|
||||
支持原子化设计参数,供上游 Design Agent 灵活调用
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Tuple, Union, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageColor
|
||||
|
||||
import config
|
||||
from modules.styles import get_style
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 缓存目录
|
||||
CACHE_DIR = config.TEMP_DIR / "text_renderer_cache"
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
class TextRenderer:
|
||||
"""
|
||||
通用文本渲染器
|
||||
基于原子化参数渲染文本图片 (PNG)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.default_font_path = self._resolve_font_path(None)
|
||||
|
||||
def _resolve_font_path(self, font_family: Optional[str]) -> str:
|
||||
"""解析字体路径,支持多级回退"""
|
||||
candidates = []
|
||||
if font_family:
|
||||
# 1. 尝试作为绝对路径
|
||||
candidates.append(font_family)
|
||||
# 2. 尝试在 assets/fonts 下查找
|
||||
candidates.append(str(config.FONTS_DIR / font_family))
|
||||
if not font_family.endswith(".ttf") and not font_family.endswith(".otf"):
|
||||
candidates.append(str(config.FONTS_DIR / f"{font_family}.ttf"))
|
||||
candidates.append(str(config.FONTS_DIR / f"{font_family}.otf"))
|
||||
|
||||
# 3. 预设项目字体
|
||||
candidates.extend([
|
||||
str(config.FONTS_DIR / "SmileySans-Oblique.ttf"),
|
||||
str(config.FONTS_DIR / "AlibabaPuHuiTi-Bold.ttf"),
|
||||
str(config.FONTS_DIR / "AlibabaPuHuiTi-Regular.ttf"),
|
||||
str(config.FONTS_DIR / "NotoSansSC-Bold.otf"), # 假如有效
|
||||
])
|
||||
|
||||
# 4. 系统字体回退
|
||||
candidates.extend([
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"C:/Windows/Fonts/msyh.ttc",
|
||||
"C:/Windows/Fonts/simhei.ttf",
|
||||
])
|
||||
|
||||
for path in candidates:
|
||||
if path and os.path.exists(path):
|
||||
# 简单验证文件大小
|
||||
try:
|
||||
if os.path.getsize(path) > 10000:
|
||||
return path
|
||||
except:
|
||||
continue
|
||||
|
||||
logger.warning("No valid font found, using default load_default()")
|
||||
return None
|
||||
|
||||
def _get_font(self, font_path: str, size: int) -> ImageFont.FreeTypeFont:
|
||||
try:
|
||||
if font_path:
|
||||
return ImageFont.truetype(font_path, size)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load font {font_path}: {e}")
|
||||
return ImageFont.load_default()
|
||||
|
||||
def _parse_color(self, color: Union[str, Tuple]) -> Tuple[int, int, int, int]:
|
||||
"""解析颜色为 RGBA"""
|
||||
if isinstance(color, str):
|
||||
if color.startswith("#"):
|
||||
rgb = ImageColor.getrgb(color)
|
||||
return rgb + (255,)
|
||||
# TODO: 支持 'rgba(r,g,b,a)' 格式
|
||||
if isinstance(color, tuple):
|
||||
if len(color) == 3:
|
||||
return color + (255,)
|
||||
return color
|
||||
return (0, 0, 0, 255)
|
||||
|
||||
def render(self, text: str, style: Union[Dict[str, Any], str], cache: bool = True) -> str:
|
||||
"""
|
||||
渲染文本并返回图片路径
|
||||
|
||||
style 结构:
|
||||
{
|
||||
"font_family": str,
|
||||
"font_size": int,
|
||||
"font_color": str,
|
||||
"stroke": [{"color": str, "width": int}, ...],
|
||||
"shadow": {"color": str, "blur": int, "offset": [x, y], "opacity": float},
|
||||
"background": {
|
||||
"type": "box", "color": str/list, "corner_radius": int, "padding": [t, r, b, l]
|
||||
}
|
||||
}
|
||||
"""
|
||||
# 0. 解析样式
|
||||
if isinstance(style, str):
|
||||
style = get_style(style)
|
||||
|
||||
# 1. 缓存检查
|
||||
cache_key = hashlib.md5(f"{text}_{str(style)}".encode()).hexdigest()
|
||||
if cache:
|
||||
cache_path = CACHE_DIR / f"{cache_key}.png"
|
||||
if cache_path.exists():
|
||||
return str(cache_path)
|
||||
|
||||
# 2. 解析基本参数
|
||||
font_path = self._resolve_font_path(style.get("font_family"))
|
||||
font_size = style.get("font_size", 60)
|
||||
font = self._get_font(font_path, font_size)
|
||||
font_color = self._parse_color(style.get("font_color", "#FFFFFF"))
|
||||
|
||||
# 3. 测量文本尺寸
|
||||
dummy_draw = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||||
bbox = dummy_draw.textbbox((0, 0), text, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
|
||||
# 4. 计算总尺寸 (包含 padding, stroke, shadow)
|
||||
strokes = style.get("stroke", [])
|
||||
if isinstance(strokes, dict): strokes = [strokes] # 兼容旧格式
|
||||
|
||||
max_stroke = 0
|
||||
for s in strokes:
|
||||
max_stroke = max(max_stroke, s.get("width", 0))
|
||||
|
||||
shadow = style.get("shadow", {})
|
||||
shadow_blur = shadow.get("blur", 0)
|
||||
shadow_offset = shadow.get("offset", [0, 0])
|
||||
|
||||
bg = style.get("background", {})
|
||||
padding = bg.get("padding", [0, 0, 0, 0])
|
||||
if isinstance(padding, int): padding = [padding] * 4
|
||||
if len(padding) == 2: padding = [padding[0], padding[1], padding[0], padding[1]] # v, h -> t, r, b, l
|
||||
|
||||
# 内容区域尺寸 (文本 + padding)
|
||||
content_w = text_w + padding[1] + padding[3]
|
||||
content_h = text_h + padding[0] + padding[2]
|
||||
|
||||
# 扩展区域 (描边 + 阴影)
|
||||
extra_margin = max_stroke + shadow_blur + max(abs(shadow_offset[0]), abs(shadow_offset[1])) + 10
|
||||
|
||||
canvas_w = content_w + extra_margin * 2
|
||||
canvas_h = content_h + extra_margin * 2
|
||||
|
||||
# 5. 创建画布
|
||||
img = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 锚点位置 (文本中心点)
|
||||
center_x = canvas_w // 2
|
||||
center_y = canvas_h // 2
|
||||
|
||||
# 6. 绘制顺序: 阴影 -> 背景 -> 描边 -> 文本
|
||||
|
||||
# --- 绘制阴影 (针对整个块) ---
|
||||
if shadow:
|
||||
shadow_color = self._parse_color(shadow.get("color", "#000000"))
|
||||
opacity = shadow.get("opacity", 0.5)
|
||||
shadow_color = (shadow_color[0], shadow_color[1], shadow_color[2], int(255 * opacity))
|
||||
|
||||
# 临时画布绘制形状用于生成阴影
|
||||
shadow_layer = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
|
||||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||||
|
||||
# 如果有背景,阴影跟随背景形状;否则跟随文字
|
||||
if bg and bg.get("type") != "none":
|
||||
self._draw_background(shadow_draw, bg, center_x, center_y, content_w, content_h, shadow_color)
|
||||
else:
|
||||
# 文字阴影
|
||||
txt_x = center_x - text_w / 2
|
||||
txt_y = center_y - text_h / 2
|
||||
shadow_draw.text((txt_x, txt_y), text, font=font, fill=shadow_color)
|
||||
# 描边阴影
|
||||
for s in strokes:
|
||||
width = s.get("width", 0)
|
||||
# 简单模拟描边阴影:多次绘制
|
||||
# (略: 完整描边阴影开销大,暂只做文字阴影)
|
||||
|
||||
# 应用模糊
|
||||
if shadow_blur > 0:
|
||||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(shadow_blur))
|
||||
|
||||
# 应用偏移
|
||||
final_shadow = Image.new("RGBA", (int(canvas_w), int(canvas_h)), (0, 0, 0, 0))
|
||||
final_shadow.paste(shadow_layer, (int(shadow_offset[0]), int(shadow_offset[1])), mask=shadow_layer)
|
||||
|
||||
img = Image.alpha_composite(final_shadow, img)
|
||||
draw = ImageDraw.Draw(img) # 重置 draw
|
||||
|
||||
# --- 绘制背景 ---
|
||||
if bg and bg.get("type") in ["box", "circle"]:
|
||||
bg_color = self._parse_color(bg.get("color", "#000000"))
|
||||
# TODO: 支持渐变背景
|
||||
self._draw_background(draw, bg, center_x, center_y, content_w, content_h, bg_color)
|
||||
|
||||
# --- 绘制描边 (仅针对文字) ---
|
||||
# 从外向内绘制
|
||||
txt_x = center_x - text_w / 2
|
||||
txt_y = center_y - text_h / 2
|
||||
|
||||
for s in reversed(strokes):
|
||||
color = self._parse_color(s.get("color", "#000000"))
|
||||
width = s.get("width", 0)
|
||||
if width > 0:
|
||||
# 通过偏移模拟描边 (Pillow stroke_width 效果一般,但这里先用原生参数)
|
||||
draw.text((txt_x, txt_y), text, font=font, fill=color, stroke_width=width, stroke_fill=color)
|
||||
|
||||
# --- 绘制文字 ---
|
||||
draw.text((txt_x, txt_y), text, font=font, fill=font_color)
|
||||
|
||||
# 7. 裁剪多余透明区域
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
img = img.crop(bbox)
|
||||
|
||||
# 8. 保存
|
||||
output_path = str(CACHE_DIR / f"{cache_key}.png")
|
||||
img.save(output_path)
|
||||
logger.info(f"Rendered text: {text} -> {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
def _draw_background(self, draw, bg, cx, cy, w, h, color):
|
||||
"""绘制背景形状"""
|
||||
corner_radius = bg.get("corner_radius", 0)
|
||||
x0 = cx - w / 2
|
||||
y0 = cy - h / 2
|
||||
x1 = cx + w / 2
|
||||
y1 = cy + h / 2
|
||||
|
||||
if bg.get("type") == "box":
|
||||
draw.rounded_rectangle([x0, y0, x1, y1], radius=corner_radius, fill=color)
|
||||
elif bg.get("type") == "circle":
|
||||
draw.ellipse([x0, y0, x1, y1], fill=color)
|
||||
|
||||
# 全局单例
|
||||
renderer = TextRenderer()
|
||||
Reference in New Issue
Block a user