11
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user