256 lines
7.1 KiB
JavaScript
256 lines
7.1 KiB
JavaScript
"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),
|
||
};
|
||
}
|
||
};
|