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)