This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:11 +08:00
parent 1325ff92e5
commit 7f57dfa9ed
31 changed files with 10437 additions and 12 deletions

View File

@@ -0,0 +1,186 @@
"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, "\\%");
}
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 }) {
emit?.("正在提取视频帧…");
execFfmpeg([
"-ss",
"00:00:03",
"-i",
sourceVideo,
"-vframes",
"1",
"-pix_fmt",
"yuvj420p",
"-c:v",
"mjpeg",
"-strict:v",
"unofficial",
"-f",
"image2",
"-y",
tempFramePath,
]);
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`;
emit?.("正在叠加标题文字ffmpeg…");
execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]);
}
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),
};
}
};