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