Files
yaoyaoai/scripts/nodejs/cover_generate.js
949036910@qq.com a2c01f306f 11
2026-06-02 21:52:12 +08:00

256 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* 视频封面生成:优先高级 Python → PIL Python → ffmpeg drawtext 回退
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawnSync } = require("child_process");
const pipelineNative = require("./pipeline_native.js");
const coverPython = require("./cover_python_runner.js");
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-cover");
function ensureDir() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
return OUTPUT_DIR;
}
function execFfmpeg(args) {
const ffmpeg = pipelineNative.locateFfmpeg();
if (!ffmpeg) {
throw new Error("未找到 ffmpeg请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
}
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
if (r.status !== 0) {
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
}
}
function escapeDrawtext(text) {
return String(text)
.replace(/\\/g, "\\\\")
.replace(/'/g, "'\\''")
.replace(/:/g, "\\:")
.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";
}
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",
sourcePath,
"-vframes",
"1",
"-pix_fmt",
"yuvj420p",
"-c:v",
"mjpeg",
"-strict:v",
"unofficial",
"-f",
"image2",
"-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 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…");
try {
execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]);
} finally {
try {
fs.unlinkSync(titleFilePath);
} catch {
/* ignore */
}
}
}
function shouldUsePilCover(effectStyle) {
if (coverPython.isAdvancedCoverConfig(effectStyle)) return false;
return (
(effectStyle.titleStrokeWidth && effectStyle.titleStrokeWidth > 0) ||
Boolean(effectStyle.titleFontFamily) ||
Boolean(effectStyle.titleColor) ||
effectStyle.titleShadowBlur > 0
);
}
globalThis.__nodejsMain = async function main(params) {
const p = params || {};
const videoPath = String(p.videoPath || "").trim();
const titleText = String(p.titleText || "").trim();
const effectStyle = p.effectStyle || {};
if (!videoPath || !fs.existsSync(videoPath)) {
return { success: false, error: "视频文件不存在,请先在步骤 02/03/05 生成视频" };
}
if (!titleText) {
return { success: false, error: "标题文本不能为空,请先在步骤 04 生成标题" };
}
ensureDir();
const ts = Date.now();
const tempFramePath = path.join(OUTPUT_DIR, `temp_frame_${ts}.jpg`);
const coverPath = path.join(OUTPUT_DIR, `cover_${ts}.jpg`);
let sourceVideo = videoPath;
const custom = String(effectStyle.customVideoPath || "").trim();
if (custom && fs.existsSync(custom)) {
sourceVideo = custom;
}
const emit = (status) => globalThis.__native?.emitProgress?.(status);
try {
if (coverPython.isAdvancedCoverConfig(effectStyle)) {
const adv = coverPython.runAdvancedCoverGenerator({
videoPath: sourceVideo,
titleText,
outputPath: coverPath,
config: effectStyle,
onProgress: emit,
});
if (adv.success) {
return {
success: true,
coverPath: adv.coverPath,
previewImages: adv.previewImages,
message: adv.message || "高级封面生成完成",
mode: "advanced_python",
};
}
emit(`高级生成失败,尝试回退:${(adv.error || "").slice(0, 80)}`);
} else if (shouldUsePilCover(effectStyle) && coverPython.locatePython()) {
const pil = coverPython.runBasicCoverGenerator({
videoPath: sourceVideo,
titleText,
outputPath: coverPath,
config: effectStyle,
timestamp: 3,
onProgress: emit,
});
if (pil.success) {
return {
success: true,
coverPath: pil.coverPath,
message: pil.message || "封面生成完成",
mode: "pil_python",
};
}
emit(`PIL 生成失败,尝试 ffmpeg 回退…`);
}
runFfmpegCover({
sourceVideo,
titleText,
effectStyle,
coverPath,
tempFramePath,
emit,
});
if (fs.existsSync(tempFramePath)) {
try {
fs.unlinkSync(tempFramePath);
} catch {
/* ignore */
}
}
if (!fs.existsSync(coverPath)) {
return { success: false, error: "封面文件未生成" };
}
return {
success: true,
coverPath,
message: "封面生成完成",
mode: "ffmpeg",
};
} catch (e) {
return {
success: false,
error: e.message || String(e),
};
}
};