diff --git a/scripts/cover/advanced_cover_generator.py b/scripts/cover/advanced_cover_generator.py index 7f0e179..69d65ad 100644 --- a/scripts/cover/advanced_cover_generator.py +++ b/scripts/cover/advanced_cover_generator.py @@ -693,12 +693,13 @@ class AdvancedCoverGenerator: print(f"Converted text position from string to dict: {text_position}", file=sys.stderr) # 加载字体 - font_path = self._get_font_path(font_family) + from modules.chinese_font import load_cjk_font + try: - font = ImageFont.truetype(font_path, font_size) - except: - print(f"Warning: Failed to load font {font_path}, using default", file=sys.stderr) - font = ImageFont.load_default() + font = load_cjk_font(font_family, font_size, font_weight) + except Exception as e: + print(f"Warning: Failed to load title font: {e}", file=sys.stderr) + font = load_cjk_font("SimHei", font_size, font_weight) # ✅ 关键修复:支持多行文字,使用anchor='mm'实现中心对齐 # 计算文字中心点位置(百分比转像素) @@ -949,8 +950,8 @@ class AdvancedCoverGenerator: font_size = font_size_from_config # 重新加载字体 try: - font = ImageFont.truetype(font_path, font_size) - except: + font = load_cjk_font(font_family, font_size, font_weight) + except Exception: pass # 获取排版参数 - 优先使用 titles.main 中的值,否则使用顶级配置,最后使用默认值 @@ -1391,12 +1392,15 @@ class AdvancedCoverGenerator: print(f"Converted subtitle position from string to dict: {subtitle_position}", file=sys.stderr) # 加载副标题字体 - subtitle_font_path = self._get_font_path(subtitle_font_family) + from modules.chinese_font import load_cjk_font + try: - subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size) - except: - print(f"Warning: Failed to load subtitle font {subtitle_font_path}, using default", file=sys.stderr) - subtitle_font = ImageFont.load_default() + subtitle_font = load_cjk_font( + subtitle_font_family, subtitle_font_size, subtitle_font_weight + ) + except Exception as e: + print(f"Warning: Failed to load subtitle font: {e}", file=sys.stderr) + subtitle_font = load_cjk_font("SimHei", subtitle_font_size, subtitle_font_weight) # 计算副标题中心点位置(百分比转像素) # ✅ 修复旋转坐标问题:有旋转时Y轴需要反转,无旋转时不需要 @@ -1442,8 +1446,14 @@ class AdvancedCoverGenerator: subtitle_font_size = subtitle_font_size_from_config # 重新加载字体 try: - subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size) - except: + from modules.chinese_font import load_cjk_font + + subtitle_font = load_cjk_font( + subtitle_font_family, + subtitle_font_size, + subtitle_font_weight, + ) + except Exception: pass # 获取排版参数 - 优先使用 titles.sub 中的值,否则使用顶级配置,最后使用默认值 @@ -1937,13 +1947,13 @@ class AdvancedCoverGenerator: break # 如果还是找不到,使用默认字体 - if not font_path or not os.path.exists(font_path): - print(f"Warning: Font file not found for '{font_family}', using fallback", file=sys.stderr) - if font_path: - print(f" Attempted path: {font_path}", file=sys.stderr) - return 'C:\\Windows\\Fonts\\msyh.ttc' - - return font_path + try: + from modules.chinese_font import resolve_cjk_font_path + + return resolve_cjk_font_path(font_family, 400) + except Exception as e: + print(f"Warning: _get_font_path fallback: {e}", file=sys.stderr) + return str(Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" / "simhei.ttf") # ✅ 标准封面尺寸 3:4 比例(与前端 CoverCustomEditor.vue 一致) COVER_WIDTH = 1080 diff --git a/scripts/cover/cover_generator.py b/scripts/cover/cover_generator.py index 4a06ee1..c47609a 100644 --- a/scripts/cover/cover_generator.py +++ b/scripts/cover/cover_generator.py @@ -115,11 +115,16 @@ def add_text_to_image(img, title, config): style = config.get('style', 'default') # default, outline, blur_bg, gradient, split - # 加载字体 + # 加载字体(禁止 load_default,否则中文乱码) try: - font = ImageFont.truetype(font_path, font_size) - except: - font = ImageFont.load_default() + from modules.chinese_font import load_cjk_font + + font = load_cjk_font(font_family, font_size) + except Exception as e: + print(f"[cover_generator] font load failed: {e}, retry SimHei", file=sys.stderr) + from modules.chinese_font import load_cjk_font + + font = load_cjk_font("SimHei", font_size) # 创建绘图对象 draw = ImageDraw.Draw(pil_img) diff --git a/scripts/cover/fonts/README.md b/scripts/cover/fonts/README.md new file mode 100644 index 0000000..7a4155e --- /dev/null +++ b/scripts/cover/fonts/README.md @@ -0,0 +1,5 @@ +# 封面字体目录(可选) + +将 `simhei.ttf` 等字体放入此目录可在无系统字体环境下生成中文封面。 + +Windows 默认会回退到 `%WINDIR%\Fonts\simhei.ttf` 或 `msyh.ttc`。 diff --git a/scripts/cover/modules/chinese_font.py b/scripts/cover/modules/chinese_font.py new file mode 100644 index 0000000..6ba7b26 --- /dev/null +++ b/scripts/cover/modules/chinese_font.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""封面中文 PIL / ffmpeg 字体解析(避免 load_default 导致乱码)""" + +from __future__ import annotations + +import os +import platform +import sys +from pathlib import Path +from typing import List, Optional + +try: + from PIL import ImageFont +except ImportError: + ImageFont = None # type: ignore + +_FAMILY_ALIASES = { + "microsoft yahei": "Microsoft YaHei", + "微软雅黑": "Microsoft YaHei", + "simhei": "SimHei", + "黑体": "SimHei", + "simsun": "SimSun", + "宋体": "SimSun", + "arial": "Arial", + "times new roman": "Times New Roman", +} + +_WIN_FAMILY_FILES = { + "SimHei": ["simhei.ttf"], + "Microsoft YaHei": ["msyh.ttc", "msyh.ttf", "msyhbd.ttc"], + "SimSun": ["simsun.ttc", "simsunb.ttf"], + "Arial": ["arial.ttf", "arialbd.ttf"], + "Times New Roman": ["times.ttf", "timesbd.ttf"], +} + + +def _scripts_dir() -> Optional[Path]: + env = os.environ.get("AICLIENT_COVER_SCRIPTS_DIR", "").strip() + if env: + p = Path(env) + if p.is_dir(): + return p + # advanced_cover_generator.py 位于 scripts/cover/ + here = Path(__file__).resolve().parent.parent + if (here / "advanced_cover_generator.py").is_file(): + return here + return None + + +def _windows_fonts_dir() -> Path: + windir = os.environ.get("WINDIR", "C:\\Windows") + return Path(windir) / "Fonts" + + +def _normalize_family(font_family: Optional[str]) -> str: + raw = (font_family or "Microsoft YaHei").strip() + key = raw.lower() + return _FAMILY_ALIASES.get(key, raw) + + +def _candidate_filenames(family: str, weight: int = 400) -> List[str]: + names = list(_WIN_FAMILY_FILES.get(family, [])) + if weight >= 700 and family == "Microsoft YaHei": + names = ["msyhbd.ttc", "msyhl.ttc"] + names + # 通用回退:.ttf 优先于 .ttc(PIL 对 ttf 更稳) + fallback = ["simhei.ttf", "msyh.ttc", "simsun.ttc", "msyh.ttf"] + out: List[str] = [] + for n in names + fallback: + if n not in out: + out.append(n) + return out + + +def resolve_cjk_font_path( + font_family: Optional[str] = None, + weight: int = 400, +) -> str: + """ + 解析可用于 PIL / ffmpeg 的中文字体文件路径。 + """ + family = _normalize_family(font_family) + candidates = _candidate_filenames(family, weight) + + scripts = _scripts_dir() + if scripts: + fonts_dir = scripts / "fonts" + if fonts_dir.is_dir(): + for name in candidates: + p = fonts_dir / name + if p.is_file(): + return str(p.resolve()) + + if platform.system() == "Windows": + win_fonts = _windows_fonts_dir() + for name in candidates: + p = win_fonts / name + if p.is_file(): + return str(p.resolve()) + + if platform.system() == "Darwin": + mac_candidates = [ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Light.ttc", + "/Library/Fonts/Arial Unicode.ttf", + ] + for p in mac_candidates: + if os.path.isfile(p): + return p + + # Linux + linux_candidates = [ + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + ] + for p in linux_candidates: + if os.path.isfile(p): + return p + + # 最后尝试:Windows 黑体 + if platform.system() == "Windows": + simhei = _windows_fonts_dir() / "simhei.ttf" + if simhei.is_file(): + return str(simhei.resolve()) + + raise FileNotFoundError( + f"未找到中文字体(family={family}),请在 cover/fonts 放置 simhei.ttf 或安装系统字体" + ) + + +def load_cjk_font( + font_family: Optional[str], + size: int, + weight: int = 400, +) -> "ImageFont.FreeTypeFont": + """加载支持中文的 PIL 字体,禁止回退到 load_default。""" + if ImageFont is None: + raise RuntimeError("PIL ImageFont 不可用") + + path = resolve_cjk_font_path(font_family, weight) + ext = os.path.splitext(path)[1].lower() + + if ext == ".ttc": + last_err: Optional[Exception] = None + for index in range(8): + try: + font = ImageFont.truetype(path, size, index=index) + print( + f"[chinese_font] Loaded TTC index={index}: {path} size={size}", + file=sys.stderr, + ) + return font + except OSError as e: + last_err = e + continue + raise OSError(f"无法加载字体集合 {path}: {last_err}") + + font = ImageFont.truetype(path, size) + print(f"[chinese_font] Loaded: {path} size={size}", file=sys.stderr) + return font diff --git a/scripts/nodejs/cover_generate.js b/scripts/nodejs/cover_generate.js index 2a13cb6..81161d8 100644 --- a/scripts/nodejs/cover_generate.js +++ b/scripts/nodejs/cover_generate.js @@ -37,19 +37,63 @@ function escapeDrawtext(text) { .replace(/%/g, "\\%"); } +/** ffmpeg drawtext 路径转义(Windows 盘符冒号) */ +function escapeFfmpegPath(filePath) { + return String(filePath).replace(/\\/g, "/").replace(/:/g, "\\:"); +} + +/** + * 定位系统中文字体(ffmpeg drawtext 必须指定 fontfile) + */ +function locateCjkFontFile() { + const scriptsDir = process.env.AICLIENT_COVER_SCRIPTS_DIR || ""; + const windir = process.env.WINDIR || "C:\\Windows"; + const candidates = [ + path.join(scriptsDir, "fonts", "simhei.ttf"), + path.join(scriptsDir, "fonts", "msyh.ttc"), + path.join(windir, "Fonts", "simhei.ttf"), + path.join(windir, "Fonts", "msyh.ttc"), + path.join(windir, "Fonts", "simsun.ttc"), + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/msyh.ttc", + ]; + for (const p of candidates) { + if (p && fs.existsSync(p)) return p; + } + return null; +} + function buildTextPosition(titlePosition) { if (titlePosition === "top") return "y=50"; if (titlePosition === "center") return "y=(h-text_h)/2"; return "y=h-text_h-50"; } -function runFfmpegCover({ sourceVideo, titleText, effectStyle, coverPath, tempFramePath, emit }) { +const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".bmp", ".webp"]); + +function isImagePath(filePath) { + return IMAGE_EXT.has(path.extname(String(filePath || "")).toLowerCase()); +} + +function prepareFrameFromSource(sourcePath, tempFramePath, emit) { + if (isImagePath(sourcePath)) { + emit?.("正在加载图片素材…"); + execFfmpeg([ + "-i", + sourcePath, + "-vf", + "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black", + "-y", + tempFramePath, + ]); + return; + } emit?.("正在提取视频帧…"); execFfmpeg([ "-ss", "00:00:03", "-i", - sourceVideo, + sourcePath, "-vframes", "1", "-pix_fmt", @@ -63,16 +107,41 @@ function runFfmpegCover({ sourceVideo, titleText, effectStyle, coverPath, tempFr "-y", tempFramePath, ]); +} + +function runFfmpegCover({ sourceVideo, titleText, effectStyle, coverPath, tempFramePath, emit }) { + prepareFrameFromSource(sourceVideo, tempFramePath, emit); const titleFontSize = effectStyle.titleFontSize ?? 90; const titleFontColor = effectStyle.titleFontColor || "#FFFFFF"; const titlePosition = effectStyle.titlePosition || "bottom"; const textPos = buildTextPosition(titlePosition); - const escaped = escapeDrawtext(titleText); - const vf = `drawtext=text='${escaped}':fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPos}:shadowcolor=black:shadowx=2:shadowy=2`; + + const fontFile = locateCjkFontFile(); + if (!fontFile) { + throw new Error("未找到中文字体文件,无法使用 ffmpeg 绘制标题(请确认 C:\\Windows\\Fonts\\simhei.ttf 存在)"); + } + + const titleFilePath = path.join( + OUTPUT_DIR, + `cover_title_${Date.now()}.txt`, + ); + fs.writeFileSync(titleFilePath, String(titleText), { encoding: "utf8" }); + + const fontOpt = `fontfile='${escapeFfmpegPath(fontFile)}'`; + const textOpt = `textfile='${escapeFfmpegPath(titleFilePath)}'`; + const vf = `drawtext=${fontOpt}:${textOpt}:fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPos}:shadowcolor=black:shadowx=2:shadowy=2`; emit?.("正在叠加标题文字(ffmpeg)…"); - execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]); + try { + execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]); + } finally { + try { + fs.unlinkSync(titleFilePath); + } catch { + /* ignore */ + } + } } function shouldUsePilCover(effectStyle) { diff --git a/scripts/nodejs/cover_python_runner.js b/scripts/nodejs/cover_python_runner.js index 131eae9..b9d774c 100644 --- a/scripts/nodejs/cover_python_runner.js +++ b/scripts/nodejs/cover_python_runner.js @@ -31,7 +31,7 @@ function normalizePathForPython(filePath) { } function buildPythonEnv() { - const env = { ...process.env, PYTHONIOENCODING: "utf-8" }; + const env = { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1" }; const ffmpeg = pipelineNative.locateFfmpeg(); if (ffmpeg && fs.existsSync(ffmpeg)) { const ffmpegDir = path.dirname(ffmpeg); @@ -39,6 +39,16 @@ function buildPythonEnv() { env.PATH = `${ffmpegDir}${sep}${env.PATH || ""}`; env.AICLIENT_FFMPEG_PATH = ffmpeg; } + const scriptsDir = locateCoverScriptsDir(); + console.log("scriptsDir", scriptsDir); + if (scriptsDir) { + env.AICLIENT_COVER_SCRIPTS_DIR = scriptsDir; + env.APP_ROOT = path.dirname(scriptsDir); + const fontsDir = path.join(scriptsDir, "fonts"); + if (fs.existsSync(fontsDir)) { + env.AICLIENT_COVER_FONTS_DIR = fontsDir; + } + } return env; }