Files
yaoyaoai/scripts/nodejs/cover_generate.js
fengchuanhn@gmail.com 7f57dfa9ed 11
2026-05-21 00:19:11 +08:00

187 lines
5.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, "\\%");
}
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),
};
}
};