"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), }; } };