Spaces:
Running
Running
| 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) | |