Video-Gen / modules /titles.py
jacky3102's picture
Update modules/titles.py
0fe8505 verified
Raw
History Blame Contribute Delete
8.53 kB
import cv2
import numpy as np
import os
import urllib.request
from PIL import Image, ImageDraw, ImageFont
from modules.utils import parse_color
# ============================================================
# 样式 & 动画常量
# ============================================================
TITLE_STYLES = [
"brush_muyao", # 沐瑶软笔手写体 — 软笔,最像真实毛笔
"brush_zhiyong", # 智勇手书体 — 硬朗书法,大气有力
"brush_yegengyou", # 叶根友微禅云 — 禅意行书,飘逸灵动
"3d", "gold", "fire", "shadow", "outline", "gradient", "glitch", "comic"
]
TITLE_ANIMATIONS = ["typewriter"]
TITLE_POSITIONS = ["top", "center", "bottom"]
# ============================================================
# 字体下载
# ============================================================
_BASE = "https://raw.githubusercontent.com/dxinef/freefonts/master"
_FONTS = {
"brush_muyao": ("沐瑶软笔手写体.ttf", f"{_BASE}/%E6%B2%90%E7%91%B6%E8%BD%AF%E7%AC%94%E6%89%8B%E5%86%99%E4%BD%93.ttf"),
"brush_zhiyong": ("智勇手书体.ttf", f"{_BASE}/%E6%99%BA%E5%8B%87%E6%89%8B%E4%B9%A6%E4%BD%93.ttf"),
"brush_yegengyou": ("叶根友微禅云.ttf", f"{_BASE}/%E5%8F%B6%E6%A0%B9%E5%8F%8B%E5%BE%AE%E7%A6%85%E4%BA%91.ttf"),
}
_FONT_DIR = "/app/fonts"
_font_cache = {} # style -> ImageFont | None
def _ensure_fonts():
"""应用启动时调用一次,下载所有毛笔字体"""
os.makedirs(_FONT_DIR, exist_ok=True)
for style, (filename, url) in _FONTS.items():
path = os.path.join(_FONT_DIR, filename)
if not os.path.exists(path):
try:
print(f"⬇️ 下载字体: {filename} ...", flush=True)
urllib.request.urlretrieve(url, path)
print(f"✅ {filename} 就绪", flush=True)
except Exception as e:
print(f"⚠️ 字体下载失败 {filename}: {e}", flush=True)
def _get_font(style, font_size, fallback_path=None):
"""返回对应 style 的 ImageFont,优先毛笔字体,其次 fallback,再次系统默认"""
cache_key = (style, font_size)
if cache_key in _font_cache:
return _font_cache[cache_key]
font = None
if style in _FONTS:
filename, _ = _FONTS[style]
path = os.path.join(_FONT_DIR, filename)
if os.path.exists(path):
try:
font = ImageFont.truetype(path, font_size)
except Exception:
font = None
if font is None:
for p in ([fallback_path] if fallback_path else []) + [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
]:
if p and os.path.exists(p):
try:
font = ImageFont.truetype(p, font_size)
break
except Exception:
pass
if font is None:
font = ImageFont.load_default()
_font_cache[cache_key] = font
return font
# ============================================================
# 样式渲染
# ============================================================
def _render_style(draw, text, x, y, font, color_rgb, alpha, style):
r, g, b = color_rgb
if style.startswith("brush_"):
# 毛笔字:墨迹晕染描边 + 主体(真字体自带笔锋,不加假飞白)
ink = (max(0, int(r*0.25)), max(0, int(g*0.25)), max(0, int(b*0.25)))
for ox, oy in [(-4,0),(4,0),(0,-4),(0,4),(-3,-3),(3,-3),(-3,3),(3,3),(-5,0),(5,0),(0,-5),(0,5)]:
draw.text((x+ox, y+oy), text, fill=ink+(min(int(alpha*0.18),255),), font=font)
for ox, oy in [(-2,0),(2,0),(0,-2),(0,2),(-2,-2),(2,-2),(-2,2),(2,2)]:
draw.text((x+ox, y+oy), text, fill=ink+(min(int(alpha*0.45),255),), font=font)
for ox, oy in [(-1,0),(1,0),(0,-1),(0,1)]:
draw.text((x+ox, y+oy), text, fill=ink+(min(int(alpha*0.75),255),), font=font)
draw.text((x, y), text, fill=(r, g, b, alpha), font=font)
elif style == "3d":
for ox, oy, a in [(6,6,80),(4,4,120),(2,2,160)]:
draw.text((x+ox,y+oy), text, fill=(0,0,0,min(a,alpha)), font=font)
draw.text((x,y), text, fill=(r,g,b,alpha), font=font)
draw.text((x-1,y-1), text, fill=(255,255,255,min(80,alpha)), font=font)
elif style == "gold":
for (gr,gg,gb,ga),(ox,oy) in zip(
[(80,40,0,min(100,alpha)),(160,100,0,min(180,alpha)),(220,170,30,alpha),(255,220,80,min(120,alpha))],
[(3,3),(1,1),(0,0),(-1,-1)]
):
draw.text((x+ox,y+oy), text, fill=(gr,gg,gb,ga), font=font)
elif style == "fire":
for ox,oy,a in [(3,3,180),(2,2,200)]:
draw.text((x+ox,y+oy), text, fill=(180,30,0,min(a,alpha)), font=font)
draw.text((x,y), text, fill=(255,120,0,alpha), font=font)
draw.text((x,y-1), text, fill=(255,220,50,min(160,alpha)), font=font)
elif style == "shadow":
draw.text((x+4,y+4), text, fill=(0,0,0,min(160,alpha)), font=font)
draw.text((x,y), text, fill=(r,g,b,alpha), font=font)
elif style == "outline":
for ox,oy in [(-2,0),(2,0),(0,-2),(0,2),(-2,-2),(2,-2),(-2,2),(2,2)]:
draw.text((x+ox,y+oy), text, fill=(0,0,0,alpha), font=font)
draw.text((x,y), text, fill=(r,g,b,alpha), font=font)
elif style == "glitch":
for col,(cx,cy) in zip([(255,0,0),(0,255,0),(0,0,255)],[(-3,2),(0,-2),(3,1)]):
ch = tuple(c if col[i]>0 else 0 for i,c in enumerate(color_rgb))
draw.text((x+cx,y+cy), text, fill=ch+(min(180,alpha),), font=font)
draw.text((x,y), text, fill=(r,g,b,alpha), font=font)
elif style == "comic":
for ox,oy in [(-3,0),(3,0),(0,-3),(0,3),(-3,-3),(3,-3),(-3,3),(3,3),(-2,0),(2,0),(0,-2),(0,2)]:
draw.text((x+ox,y+oy), text, fill=(0,0,0,alpha), font=font)
draw.text((x,y), text, fill=(r,g,b,alpha), font=font)
draw.text((x-2,y-2), text, fill=(255,255,255,min(60,alpha)), font=font)
else:
draw.text((x,y), text, fill=(r,g,b,alpha), font=font)
# ============================================================
# 主入口
# ============================================================
def draw_title(frame, title_text, title_style, title_color_str, title_animation,
title_start_sec, title_duration, title_position,
current_time, font_path, video_w, video_h, title_font_size=None):
if not title_text:
return frame
if current_time < title_start_sec:
return frame
elapsed = current_time - title_start_sec
if elapsed > title_duration:
return frame
color_rgb = parse_color(title_color_str)
font_size = title_font_size if (title_font_size and title_font_size > 0) else max(36, video_w // 8)
font = _get_font(title_style, font_size, font_path)
# 打字机:按时间比例逐字
progress = elapsed / max(title_duration, 0.001)
visible_chars = max(1, int(len(title_text) * min(progress * 1.1, 1.0)))
display_text = title_text[:visible_chars]
img_rgba = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).convert("RGBA")
draw = ImageDraw.Draw(img_rgba)
# 用完整文字算位置,避免打字时跳动
bbox_full = draw.textbbox((0, 0), title_text, font=font)
text_w = bbox_full[2] - bbox_full[0]
text_h = bbox_full[3] - bbox_full[1]
if title_position == "top":
base_y = video_h // 6
elif title_position == "bottom":
base_y = video_h * 5 // 6 - text_h
else:
base_y = (video_h - text_h) // 2
base_x = (video_w - text_w) // 2
if title_style == "gradient":
bbox_cur = draw.textbbox((0, 0), display_text, font=font)
tw = bbox_cur[2] - bbox_cur[0]
th = bbox_cur[3] - bbox_cur[1]
temp = Image.new("RGBA", (tw+20, th+20), (0,0,0,0))
td = ImageDraw.Draw(temp)
td.text((10,10), display_text, fill=color_rgb+(255,), font=font)
arr = np.array(temp).astype(np.float32)
for row in range(arr.shape[0]):
factor = 0.5 + 0.5 * (row / max(arr.shape[0]-1, 1))
arr[row,:,:3] = np.clip(arr[row,:,:3]*factor, 0, 255)
patch = Image.fromarray(arr.astype(np.uint8))
img_rgba.paste(patch, (base_x-10, base_y-10), patch)
else:
_render_style(draw, display_text, base_x, base_y, font, color_rgb, 255, title_style)
return cv2.cvtColor(np.array(img_rgba.convert("RGB")), cv2.COLOR_RGB2BGR)