import { ipcMain, BrowserWindow, dialog, app } from "electron"; import { Log } from "../log/main"; import { ConfigMain } from "../config/main"; import ServerApi from "../server/api"; import { spawn, exec, execSync } from "child_process"; import * as path from "path"; import * as fs from "fs"; import * as os from "os"; import { recognizeAudioWithFunasr, extractAudioFromVideo, funasrResultToSrt, FunasrResult } from "./funasr"; import { AppEnv } from "../env"; import AuthMain from "../auth/main"; import { buildRuntimeProcessEnv, execFFmpegCommand } from "../shell"; import { registerCoverTemplateHandlers } from "./coverTemplate"; import { registerSubtitleTemplateHandlers } from "./subtitleTemplate"; import { isDev } from "../../lib/env"; import { getPythonPath } from "../../lib/python-util"; import { getFFmpegExecutablePath, resourceExists } from "../../lib/resource-path"; import { getPythonScriptPath } from "../../lib/resource-path"; import { getElectronSystemConfigSync } from "../systemConfig"; let browserWindow: BrowserWindow | null = null; /** * 将 Windows 长路径转换为短路径(8.3 格式) * 用于处理包含中文字符或空格的路径 * @param longPath 长路径 * @returns 短路径,如果转换失败则返回原路径 */ function getShortPath(longPath: string): string { return longPath; } // 获取设置文件路径 const getSettingsFilePath = () => { return path.join(AppEnv.dataRoot, 'ipAgent', 'settings.json'); }; // 获取统一的输出目录(所有生成的文件都保存在这里) const getOutputDir = (subDir?: string) => { const hubRoot = ConfigMain.getSync('hubRoot') || ''; const baseDir = hubRoot || path.join(AppEnv.dataRoot, 'hub'); const outputDir = subDir ? path.join(baseDir, subDir) : path.join(baseDir, 'file'); console.log(`[getOutputDir] hubRoot=${hubRoot}, baseDir=${baseDir}, outputDir=${outputDir}`); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } return outputDir; }; const escapeDrawtextText = (text: string): string => { return String(text || '') .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/:/g, '\\:') .replace(/\r?\n/g, ' '); }; // 确保设置目录存在 const ensureSettingsDir = () => { const settingsDir = path.join(AppEnv.dataRoot, 'ipAgent'); if (!fs.existsSync(settingsDir)) { fs.mkdirSync(settingsDir, { recursive: true }); } }; const DEFAULT_DOUBAO_TEXT_MODEL = 'doubao-seed-2-0-mini-260215'; function getBundledZitiDirs(): string[] { const dirs: string[] = []; const pushIfExists = (dir: string) => { if (dir && fs.existsSync(dir) && !dirs.includes(dir)) { dirs.push(dir); } }; if (isDev) { pushIfExists(path.join(process.env.APP_ROOT || process.cwd(), 'packaging', 'vendor', 'ziti')); pushIfExists(path.join(process.env.APP_ROOT || process.cwd(), 'ziti')); return dirs; } if (AppEnv.resourceBundleRoot) { pushIfExists(path.join(AppEnv.resourceBundleRoot, 'ziti')); } if (process.resourcesPath) { pushIfExists(path.join(process.resourcesPath, 'resources-bundles', 'ziti')); pushIfExists(path.join(path.dirname(process.resourcesPath), 'resources-bundles', 'ziti')); } const installRoot = AppEnv.installRoot || AppEnv.appRoot || process.cwd(); pushIfExists(path.join(installRoot, 'resources-bundles', 'ziti')); pushIfExists(path.join(process.cwd(), 'ziti')); return dirs; } function getConfiguredDoubaoTextModelKey(): string { const configuredModelId = (getElectronSystemConfigSync().volcengine_model || '').trim() || DEFAULT_DOUBAO_TEXT_MODEL; return `builtInDoubao|${configuredModelId}`; } function normalizeIpAgentSettings(settings: any): { settings: any; changed: boolean } { if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { return { settings, changed: false }; } const normalizedSettings = { ...settings }; let changed = false; const configuredDoubaoTextModelKey = getConfiguredDoubaoTextModelKey(); const currentTextModelKey = typeof normalizedSettings.textModelKey === 'string' ? normalizedSettings.textModelKey.trim() : ''; if (normalizedSettings.enableGpuCleanup) { normalizedSettings.enableGpuCleanup = false; changed = true; } if (!currentTextModelKey) { normalizedSettings.textModelKey = configuredDoubaoTextModelKey; return { settings: normalizedSettings, changed: true }; } const [providerId, modelId] = currentTextModelKey.split('|'); const [, configuredModelId] = configuredDoubaoTextModelKey.split('|'); // 内置豆包当前只支持后台配置的单一文本模型,老安装包遗留的模型Key需要自动纠正。 if (providerId === 'builtInDoubao' && modelId !== configuredModelId) { normalizedSettings.textModelKey = configuredDoubaoTextModelKey; return { settings: normalizedSettings, changed: true }; } return { settings: normalizedSettings, changed }; } // RGB颜色转ASS ABGR格式(不带透明度) const hexToABGR = (hex: string): string => { if (!hex || typeof hex !== 'string') return '&H00FFFFFF'; const rgb = hex.replace('#', '').toUpperCase(); if (rgb.length === 6) { const r = rgb.substring(0, 2); const g = rgb.substring(2, 4); const b = rgb.substring(4, 6); return `&H00${b}${g}${r}`; } return '&H00FFFFFF'; }; // RGB颜色转ASS ABGR格式(带透明度) // 修复:ASS格式中,00=不透明,FF=完全透明 // 新增:混合颜色以模拟透明度(ASS BackColour不支持Alpha) // 原理:混合色 = 前景色 * 透明度 + 白色 * (1 - 透明度) // 这样能产生"变淡"效果而不是"变暗",符合正常的透明度视觉效果 const blendColorForOpacity = (foregroundHex: string, opacity: number): string => { // 完全透明时返回白色(完全看不到背景) if (!foregroundHex || foregroundHex === 'transparent' || opacity === 0) { return '#FFFFFF'; } // 完全不透明时返回原始颜色 if (opacity === 1) { return foregroundHex; } // 解析前景色(用户选择的背景颜色) const fgHex = foregroundHex.replace('#', '').toUpperCase(); if (fgHex.length !== 6) { return foregroundHex; // 格式错误,返回原始颜色 } const fgR = parseInt(fgHex.substring(0, 2), 16); const fgG = parseInt(fgHex.substring(2, 4), 16); const fgB = parseInt(fgHex.substring(4, 6), 16); // 背景色为白色(用于产生淡化效果) const bgR = 255; const bgG = 255; const bgB = 255; // 计算混合颜色 // 公式:混合色 = 前景色 * opacity + 白色 * (1 - opacity) // 效果:opacity高 → 接近前景色,opacity低 → 接近白色(变淡) const blendR = Math.round(fgR * opacity + bgR * (1 - opacity)); const blendG = Math.round(fgG * opacity + bgG * (1 - opacity)); const blendB = Math.round(fgB * opacity + bgB * (1 - opacity)); // 转换回16进制 const blendHex = '#' + blendR.toString(16).padStart(2, '0').toUpperCase() + blendG.toString(16).padStart(2, '0').toUpperCase() + blendB.toString(16).padStart(2, '0').toUpperCase(); Log.info("blendColorForOpacity.calculation", { inputColor: foregroundHex, inputOpacity: opacity, foregroundRGB: { r: fgR, g: fgG, b: fgB }, 白色背景RGB: { r: bgR, g: bgG, b: bgB }, blendedRGB: { r: blendR, g: blendG, b: blendB }, blendedColor: blendHex, visualEffect: '透明度越低 → 颜色越淡(趋向白色)', note: '混合颜色用于模拟ASS中的背景透明度效果(ASS不支持Alpha)' }); return blendHex; }; const hexToABGRWithAlpha = (hex: string, opacity: number): string => { // 如果颜色是透明或透明度为0,返回完全透明 if (!hex || typeof hex !== 'string' || hex === 'transparent' || opacity === 0) { return '&HFF000000'; // 完全透明(Alpha=FF表示完全透明) } const rgb = hex.replace('#', '').toUpperCase(); if (rgb.length === 6) { const r = rgb.substring(0, 2); const g = rgb.substring(2, 4); const b = rgb.substring(4, 6); // 修复:ASS格式透明度计算 // opacity: 0-1 (0=完全透明, 1=完全不透明) // ASS Alpha: 00-FF (00=不透明, FF=完全透明) // 公式:alpha = (1 - opacity) * 255 const alpha = Math.round((1 - opacity) * 255); const alphaHex = alpha.toString(16).padStart(2, '0').toUpperCase(); Log.info("hexToABGRWithAlpha.calculation", { inputOpacity: opacity, calculatedAlpha: alpha, alphaHex, finalColor: `&H${alphaHex}${b}${g}${r}` }); return `&H${alphaHex}${b}${g}${r}`; } return '&HFF000000'; // 默认完全透明 }; const resolveDrawtextBoxStyle = (backgroundColor?: string, backgroundOpacity?: number) => { const normalizedOpacity = typeof backgroundOpacity === 'number' && Number.isFinite(backgroundOpacity) ? Math.max(0, Math.min(1, backgroundOpacity)) : 0; const normalizedColor = typeof backgroundColor === 'string' ? backgroundColor.trim() : ''; if (!normalizedColor || normalizedColor.toLowerCase() === 'transparent' || normalizedOpacity <= 0) { return { colorHex: '000000', opacity: 0, hasBackground: false }; } const colorHex = normalizedColor.replace('#', '').toUpperCase(); if (!/^[0-9A-F]{6}$/.test(colorHex)) { return { colorHex: '000000', opacity: 0, hasBackground: false }; } return { colorHex, opacity: normalizedOpacity, hasBackground: true }; }; // 生成 drawtext 过滤器链(支持真正的背景透明度和关键词特效) // 这是实现透明背景的唯一可靠方法(FFmpeg libass 限制) // 定义返回类型 interface DrawtextFilterResult { filters: string; occupiedRanges: Array<{ start: number; // 开始时间(秒) end: number; // 结束时间(秒) keyword: string; // 关键词 lineIndex: number; // 对话行索引 wordIndex: number; // 字索引 }>; } // 字幕布局信息 interface SubtitleLayoutInfo { line: 1 | 2; // 第1行或第2行 y: number; // y坐标(像素) isKeywordEffect: boolean; // 是否是关键词特效 } // 遮挡检测结果 interface OcclusionResult { isSafe: boolean; // 是否安全(不会遮挡) affectedSubtitles: string[]; // 会遮挡的字幕列表 suggestedActions: string[]; // 建议的调整措施 adjustmentParams?: { // 建议的调整参数 scaleReduction?: number; // 缩放倍数降低(0-1) durationReduction?: number; // 时长缩短(0-1) positionShift?: number; // 位置偏移(像素) }; } // 动画遮挡检测器 class AnimationOcclusionDetector { private videoWidth: number = 1280; private videoHeight: number = 720; constructor(videoWidth: number = 1280, videoHeight: number = 720) { this.videoWidth = videoWidth; this.videoHeight = videoHeight; } /** * 检测特效字幕动画是否会遮挡后续字幕 * @param effectDuration 特效时长(秒) * @param scaleAmplitude 缩放幅度(相对于原始字体大小) * @param followingSubtitles 后续字幕列表 * @returns 遮挡检测结果 */ detectOcclusion( effectDuration: number, scaleAmplitude: number, followingSubtitles: string[] ): OcclusionResult { const maxScaleFactor = 1 + scaleAmplitude; const maxExpandedHeight = 60 * maxScaleFactor; // 假设字体大小为60 const isSafe = maxExpandedHeight < this.videoHeight * 0.6; // 不超过视频高度的60% return { isSafe: isSafe, affectedSubtitles: isSafe ? [] : followingSubtitles.slice(0, 2), suggestedActions: isSafe ? ["动画安全,不会遮挡后续字幕"] : [ "缩放幅度过大,建议降低", "缩短动画时长", "改变字幕显示位置" ], adjustmentParams: isSafe ? undefined : { scaleReduction: 0.3, // 建议降低30%的缩放幅度 durationReduction: 0.2, // 建议缩短20%的时长 positionShift: -20 // 建议向上偏移20像素 } }; } } // 遮挡避免器 class OcclusionAvoider { private detector: AnimationOcclusionDetector; constructor(videoWidth: number = 1280, videoHeight: number = 720) { this.detector = new AnimationOcclusionDetector(videoWidth, videoHeight); } /** * 自动调整字幕参数以避免遮挡 */ applyAutoAdjustment( originalScaleAmplitude: number, originalDuration: number, detectionResult: OcclusionResult ): { adjustedScaleAmplitude: number; adjustedDuration: number; strategy: string; } { if (detectionResult.isSafe) { return { adjustedScaleAmplitude: originalScaleAmplitude, adjustedDuration: originalDuration, strategy: "no_adjustment" }; } const adjustment = detectionResult.adjustmentParams || {}; const scaleReduction = adjustment.scaleReduction || 0.2; const durationReduction = adjustment.durationReduction || 0.1; return { adjustedScaleAmplitude: originalScaleAmplitude * (1 - scaleReduction), adjustedDuration: originalDuration * (1 - durationReduction), strategy: "reduce_scale_and_duration" }; } } // 2行字幕布局管理器 class SubtitleLayoutManager { private maxLines: number = 2; private lineHeight: number = 60; // 每行的高度(像素) private videoHeight: number = 720; // 视频高度(默认值,可配置) private bottomMargin: number = 20; // 距离底部的边距 constructor(videoHeight: number = 720) { this.videoHeight = videoHeight; } /** * 计算字幕应该显示的行号和y坐标 * @param isKeywordEffect 是否是关键词特效(优先显示在上方) * @param currentLineCount 当前已分配的行数 * @returns 布局信息 */ calculateLayout(isKeywordEffect: boolean, currentLineCount: number): SubtitleLayoutInfo { // 关键词特效优先显示在上方(第1行) // 普通字幕显示在下方(第2行) // 如果超过2行,则向上调整位置 if (isKeywordEffect) { // 关键词特效:优先使用第1行 return { line: 1, y: this.videoHeight - this.lineHeight - this.bottomMargin, isKeywordEffect: true }; } else { // 普通字幕:使用第2行 // 如果已有关键词特效,则向下放置 return { line: 2, y: this.videoHeight - 2 * this.lineHeight - this.bottomMargin, isKeywordEffect: false }; } } /** * 当超过2行时,自动调整所有字幕的y坐标 */ adjustForMultipleLines(subtitleCount: number): number { // 如果字幕数超过2行,需要整体向上移动 // 向上移动的距离 = (总行数 - 2) * lineHeight const extraLines = Math.max(0, Math.ceil(subtitleCount / 2) - 1); return extraLines * this.lineHeight; } } // 生成普通字幕的enable条件,排除被关键词占用的时间段 const generateEnableCondition = ( baseStart: number, baseEnd: number, occupiedRanges: Array<{ start: number, end: number }> ): string => { // 基础条件:在对话行的时间段内 let condition = `between(t\\,${baseStart}\\,${baseEnd})`; // 如果没有冲突,直接返回 if (!occupiedRanges || occupiedRanges.length === 0) { return condition; } // 添加排除条件:排除所有占用的时间段 // 生成表达式:between(t,start,end) AND !between(t,occ1_start,occ1_end) AND ... for (const occupied of occupiedRanges) { condition += ` AND !between(t\\,${occupied.start}\\,${occupied.end})`; } return condition; }; const generateDrawtextFilters = ( assPath: string, subtitleStyle: any, keywords?: string[], // 关键词列表 keywordStyle?: any, // 关键词样式 keywordGroups?: any[] // 关键词组(包含每个组的样式和关键词) ): DrawtextFilterResult => { try { const assContent = fs.readFileSync(assPath, 'utf-8'); const lines = assContent.split('\n'); // 从 subtitleStyle 提取样式参数 let fontSize = 48; let fontColor = 'FFFFFF'; let backgroundColor = '000000'; let bgOpacity = 0; let hasBackgroundBox = false; let fontName = 'Arial'; if (subtitleStyle) { fontSize = subtitleStyle.fontSize || 48; fontColor = (subtitleStyle.fontColor || '#FFFFFF').replace('#', '').toUpperCase(); fontName = subtitleStyle.fontName || 'Arial'; const subtitleBackgroundStyle = resolveDrawtextBoxStyle( subtitleStyle.backgroundColor, subtitleStyle.backgroundOpacity ); backgroundColor = subtitleBackgroundStyle.colorHex; bgOpacity = subtitleBackgroundStyle.opacity; hasBackgroundBox = subtitleBackgroundStyle.hasBackground; } // 确保颜色格式为 6 位十六进制 if (fontColor.length !== 6) fontColor = 'FFFFFF'; // 字体描边参数 let outlineWidth = subtitleStyle?.outlineWidth ?? 0; let outlineColor = (subtitleStyle?.outlineColor || '#000000').replace('#', '').toUpperCase(); if (outlineColor.length !== 6) outlineColor = '000000'; // 从 ASS 文件中提取实际的字体名称(可能已被更新为内部 Family 名称) let assExtractedFontName = 'Arial'; try { const styleLineMatch = assContent.match(/Style:\s*Default,([^,]+)/); if (styleLineMatch) { assExtractedFontName = styleLineMatch[1].trim().replace(/^["']|["']$/g, ''); Log.info("generateDrawtextFilters.fontNameFromASS", { extractedFontName: assExtractedFontName, originalFontName: fontName }); fontName = assExtractedFontName; } } catch (e) { Log.info("generateDrawtextFilters.failedToExtractFontFromASS", { error: String(e) }); } // 查找实际的字体文件路径(优先查找 ASS 目录中的复制字体文件) const assDir = path.dirname(assPath); let fontFilePath = ''; let fontFileForFilter = ''; try { const fontExtensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2']; const fontNameLower = fontName.toLowerCase().replace(/\s+/g, ''); // 在 ASS 文件所在目录查找字体文件 if (fs.existsSync(assDir)) { const files = fs.readdirSync(assDir); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (!fontExtensions.includes(ext)) continue; const fileName = path.basename(file, ext); const fileNameLower = fileName.toLowerCase().replace(/\s+/g, ''); // 精确匹配或模糊匹配 if (fileNameLower === fontNameLower || fileNameLower.includes(fontNameLower) || fontNameLower.includes(fileNameLower)) { const foundPath = path.join(assDir, file); // 如果文件名包含非 ASCII 字符,复制并重命名为 ASCII 名称 // 这是为了兼容 FFmpeg drawtext filter 对中文字体名称的处理问题 if (/[^\x00-\x7F]/.test(file)) { const ext = path.extname(file); const safeFileName = `font_${Date.now()}${ext}`; const safePath = path.join(assDir, safeFileName); try { if (!fs.existsSync(safePath)) { fs.copyFileSync(foundPath, safePath); } fontFilePath = safePath; fontFileForFilter = path.basename(safePath); Log.info("generateDrawtextFilters.fontFileSafeRenamed", { originalFile: file, originalPath: foundPath, safePath: safePath, reason: "包含非 ASCII 字符,已复制并重命名以兼容 FFmpeg" }); } catch (copyErr) { // 如果复制失败,使用原始路径 fontFilePath = foundPath; fontFileForFilter = foundPath; Log.info("generateDrawtextFilters.fontFileSafeRenameFailed", { originalPath: foundPath, error: String(copyErr), fallback: "使用原始文件路径" }); } } else { // 文件名已是 ASCII,直接使用 fontFilePath = foundPath; fontFileForFilter = path.basename(foundPath); } Log.info("generateDrawtextFilters.fontFileFound", { fontName: fontName, filePath: fontFilePath, location: "assDir", hasNonAsciiChars: /[^\x00-\x7F]/.test(file) }); break; } } } // 如果在 ASS 目录没找到,查找 ziti 目录(自定义字体目录) if (!fontFilePath) { for (const zitiDir of getBundledZitiDirs()) { const files = fs.readdirSync(zitiDir); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (!fontExtensions.includes(ext)) continue; const fileName = path.basename(file, ext); const fileNameLower = fileName.toLowerCase().replace(/\s+/g, ''); // 精确匹配或模糊匹配(忽略大小写和空格) if (fileNameLower === fontNameLower || fileNameLower.includes(fontNameLower) || fontNameLower.includes(fileNameLower)) { const foundPath = path.join(zitiDir, file); // 如果文件名包含非 ASCII 字符,复制到 ASS 目录并重命名 if (/[^\x00-\x7F]/.test(file)) { const ext = path.extname(file); const safeFileName = `font_${Date.now()}${ext}`; const safePath = path.join(assDir, safeFileName); try { if (!fs.existsSync(safePath)) { fs.copyFileSync(foundPath, safePath); } fontFilePath = safePath; fontFileForFilter = path.basename(safePath); Log.info("generateDrawtextFilters.fontFileSafeRenamed", { originalFile: file, originalPath: foundPath, safePath: safePath, reason: "包含非 ASCII 字符,从 ziti 目录复制并重命名" }); } catch (copyErr) { fontFilePath = foundPath; fontFileForFilter = foundPath; Log.info("generateDrawtextFilters.fontFileSafeRenameFailed", { originalPath: foundPath, error: String(copyErr), fallback: "使用原始文件路径" }); } } else { // 文件名已是 ASCII,直接复制到 ASS 目录以便 FFmpeg 使用 const targetPath = path.join(assDir, file); try { if (!fs.existsSync(targetPath)) { fs.copyFileSync(foundPath, targetPath); } fontFilePath = targetPath; fontFileForFilter = path.basename(targetPath); } catch (copyErr) { // 如果复制失败,使用原始路径 fontFilePath = foundPath; fontFileForFilter = foundPath; Log.info("generateDrawtextFilters.fontFileCopyFailed", { originalPath: foundPath, targetPath: targetPath, error: String(copyErr), fallback: "使用原始文件路径" }); } } Log.info("generateDrawtextFilters.fontFileFound", { fontName: fontName, filePath: fontFilePath, location: "zitiDir", zitiDir: zitiDir, hasNonAsciiChars: /[^\x00-\x7F]/.test(file) }); break; } } if (fontFilePath) { break; } } } } catch (e) { Log.info("generateDrawtextFilters.fontSearchError", { error: String(e) }); } if (!fontFilePath) { Log.error("generateDrawtextFilters.requiredBundledFontMissing", { fontName, assDir, installRoot: AppEnv.installRoot, resourceBundleRoot: AppEnv.resourceBundleRoot, resourcesPath: process.resourcesPath }); throw new Error(`字幕字体包缺失或未找到字体 "${fontName}",请确认安装目录包含 resources-bundles/ziti 后重新生成`); } // 提取所有字幕对话行 const dialogues: Array<{ text: string, start: string, end: string }> = []; for (const line of lines) { if (line.startsWith('Dialogue:')) { const parts = line.substring('Dialogue:'.length).split(','); if (parts.length >= 10) { const start = parts[1]?.trim() || '0:00:00.00'; const end = parts[2]?.trim() || '0:00:10.00'; const text = parts.slice(9).join(',').trim(); if (text && text !== '') { dialogues.push({ text, start, end }); } } } } if (dialogues.length === 0) { Log.info("generateDrawtextFilters.noDialogues", { assPath }); return { filters: '', occupiedRanges: [] }; } // 时间转换:ASS 格式 HH:MM:SS.MS -> 秒数 const timeToSeconds = (timeStr: string): number => { const match = timeStr.match(/(\d+):(\d+):([\d.]+)/); if (match) { const hours = parseInt(match[1], 10) || 0; const minutes = parseInt(match[2], 10) || 0; const seconds = parseFloat(match[3]) || 0; return hours * 3600 + minutes * 60 + seconds; } return 0; }; // 生成 drawtext 过滤器 const filters: string[] = []; const tempTextFiles: string[] = []; // 用于清理临时文件 const occupiedRanges: DrawtextFilterResult['occupiedRanges'] = []; // 追踪被关键词占用的时间段 const uniqueId = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // 唯一标识符,避免文件名重复 // ========== 预处理:提前计算会被关键词分组处理的关键词 ========== // 这样在计算foundKeywords时可以排除这些词,避免重复渲染 const groupProcessedKeywords = new Set(); if (keywordGroups && keywordGroups.length > 0) { for (const group of keywordGroups) { if (!group.keywords || !group.keywords.length) { continue; } const groupKeywords = Array.isArray(group.keywords) ? group.keywords : []; for (const keywordItem of groupKeywords) { let keyword: string; if (typeof keywordItem === 'string') { keyword = keywordItem; } else if (keywordItem && typeof keywordItem === 'object' && keywordItem.keyword) { keyword = keywordItem.keyword; } else { continue; } if (keyword) { groupProcessedKeywords.add(keyword); } } } if (groupProcessedKeywords.size > 0) { Log.info('generateDrawtextFilters.precomputedGroupKeywords', { count: groupProcessedKeywords.size, keywords: Array.from(groupProcessedKeywords) }); } } // ========== 检测ASS文件中的内联样式关键词 ========== // 这用于避免重复渲染:如果关键词已经在ASS中有内联样式,就不在drawtext中重复处理 const assInlineStyledKeywords = new Map(); // 精确匹配{\fn字体\c颜色}关键词{\r}格式 const inlineStyleRegex = /\{\\fn([^\\}]*)\\c(&H[0-9A-F]{8})\}([^{}\\r]+)\{\\r\}/gi; // 也匹配只有颜色的格式 {\c颜色}关键词{\c} const colorOnlyRegex = /\{\\c(&H[0-9A-F]{8})\}([^{}]+)\{\\c\}/gi; for (const dialogue of dialogues) { let text = dialogue.text; // 匹配带字体和颜色的格式 inlineStyleRegex.lastIndex = 0; let match; while ((match = inlineStyleRegex.exec(text)) !== null) { const fontName = match[1]; const color = match[2]; const keyword = match[3].trim(); if (keyword && !assInlineStyledKeywords.has(keyword)) { assInlineStyledKeywords.set(keyword, { color: color, fontName: fontName || undefined }); } } // 匹配只有颜色的格式 colorOnlyRegex.lastIndex = 0; while ((match = colorOnlyRegex.exec(text)) !== null) { const color = match[1]; const keyword = match[2].trim(); if (keyword && !assInlineStyledKeywords.has(keyword)) { assInlineStyledKeywords.set(keyword, { color: color, fontName: undefined }); } } } if (assInlineStyledKeywords.size > 0) { Log.info('generateDrawtextFilters.assInlineStyledKeywords', { count: assInlineStyledKeywords.size, keywords: Array.from(assInlineStyledKeywords.entries()).map(([k, v]) => ({ keyword: k, color: v.color, fontName: v.fontName })) }); } for (let i = 0; i < dialogues.length; i++) { const dialogue = dialogues[i]; const startTime = timeToSeconds(dialogue.start); const endTime = timeToSeconds(dialogue.end); // 清理文本:移除 ASS 标签(drawtext 不理解它们) // 但首先提取关键词和颜色信息用于后续处理 let rawText = dialogue.text; rawText = rawText.replace(/\\N/g, '\n').replace(/\\n/g, '\n'); // 转换换行符 // 从ASS内联标签中提取关键词和颜色 // 格式: {\c&H颜色}文本{\c} // 【关键修复】如果有关键词分组,跳过ASS内联样式提取,避免重复生成 // 因为关键词已经从ASS文本中移除,且keywordGroups会单独处理关键词特效 const keywordColorMap: Map = new Map(); // 只有当没有keywordGroups时,才提取ASS内联样式关键词 if (!keywordGroups || keywordGroups.length === 0) { const colorTagRegex = /\{\\c(&H[0-9A-F]{8})\}([^{]+)\{\\c\}/gi; let colorMatch; while ((colorMatch = colorTagRegex.exec(rawText)) !== null) { const color = colorMatch[1]; const keyword = colorMatch[2].trim(); if (keyword) { keywordColorMap.set(keyword, color); } } } else { Log.info(`generateDrawtextFilters.skipAssInlineStyleExtraction`, { lineIndex: i, reason: "有关键词分组,跳过ASS内联样式提取,避免重复生成特效", keywordGroupsCount: keywordGroups.length }); } // 清理文本:移除所有 ASS 标签 let displayText = rawText; displayText = displayText.replace(/\{[^}]*\}/g, ''); displayText = displayText.trim(); if (!displayText) continue; const plainText = displayText; // ========== 先扫描关键词,以便生成正确的enable条件 ========== // 关键修复:排除已被关键词分组处理的词,避免重复渲染 const foundKeywords: Array<{ keyword: string, index: number }> = []; if (keywords && keywords.length > 0) { const keywordList = keywords.map(k => typeof k === 'string' ? k : k.word || k.keyword || String(k)); for (const keyword of keywordList) { if (!keyword || !plainText.includes(keyword)) { continue; } // 关键修复:如果该关键词已被关键词分组处理,跳过它 if (groupProcessedKeywords.has(keyword)) { Log.info(`generateDrawtextFilters.skipFoundKeyword`, { keyword: keyword, reason: "该关键词已在关键词分组中处理,避免重复渲染" }); continue; } // 修复:跳过已有ASS内联样式的关键词 // 这样它们不会影响foundKeywords的计算 if (assInlineStyledKeywords.has(keyword)) { Log.info(`generateDrawtextFilters.skipInlineStyledKeyword`, { keyword: keyword, reason: "该关键词已在ASS文件中使用内联样式,避免重复渲染" }); continue; } foundKeywords.push({ keyword: keyword, index: plainText.indexOf(keyword) }); } } // 使用 textfile 参数处理中文,避免编码问题 // 创建临时文本文件(使用唯一标识符避免文件名重复) const tempDir = os.tmpdir(); const tempTextPath = path.join(tempDir, `drawtext_${uniqueId}_${i}.txt`); fs.writeFileSync(tempTextPath, displayText, 'utf-8'); tempTextFiles.push(tempTextPath); // 字体路径处理 - 需要转义冒号以支持 Windows 路径 let fontPath = ''; if (fontFilePath) { let processedPath = fontFileForFilter || fontFilePath; if (path.isAbsolute(processedPath) && (/[\u4e00-\u9fa5]/.test(processedPath) || processedPath.includes(' '))) { processedPath = getShortPath(processedPath); Log.info('generateDrawtextFilters.convertToShortPath', { originalPath: fontFilePath, shortPath: processedPath, reason: /[\u4e00-\u9fa5]/.test(fontFilePath) ? '路径包含中文字符' : '路径包含空格' }); } // 使用找到的实际字体文件路径 // 转换反斜杠为正斜杠,转义冒号 fontPath = processedPath.replace(/\\/g, '/').replace(/:/g, '\\:'); Log.info(`generateDrawtextFilters.filter${i + 1}.usingFoundFontFile`, { fontName: fontName, originalPath: fontFilePath, processedPath: processedPath, escapedPath: fontPath, hasChineseChars: /[\u4e00-\u9fa5]/.test(fontFilePath) }); } else { Log.info(`generateDrawtextFilters.fontFileNotFound`, { fontName: fontName, searchedInDir: assDir }); throw new Error(`Bundled font file not found for drawtext: ${fontName}`); } let processedTempPath = tempTextPath; if (/[\u4e00-\u9fa5]/.test(tempTextPath) || tempTextPath.includes(' ')) { processedTempPath = getShortPath(tempTextPath); } // 转义临时文件路径中的冒号 const escapedTempPath = processedTempPath.replace(/\\/g, '/').replace(/:/g, '\\:'); // 在 shell: false 模式下,不需要手动添加引号 // Node.js 的 spawn() 会自动处理参数中的特殊字符 const quotedFontPath = fontPath; const quotedTempPath = escapedTempPath; // 生成单个 drawtext 过滤器 - 使用 textfile 而不是 text 来支持中文 let filter = `drawtext=` + `fontfile=${quotedFontPath}:` + `text='${escapeDrawtextText(displayText)}':` + `fontsize=${fontSize}:` + `fontcolor=0x${fontColor}:`; // 添加字体描边参数(如果设置了描边宽度) if (outlineWidth > 0) { filter += `borderw=${outlineWidth}:bordercolor=0x${outlineColor}:`; } // 检查当前行是否包含需要处理的分组关键词 // 【关键修复】检查keywordGroups中是否有任何关键词 // 即使关键词已经从ASS文本中移除了,只要keywordGroups存在,就应该显示关键词特效 const hasKeywordGroupInThisLine = keywordGroups && keywordGroups.length > 0 && keywordGroups.some(g => { const keywords = g.keywords || []; return keywords.length > 0; }); // 【关键修复】基础字幕应该显示非关键词部分(例如:"123") // 关键词由drawtext特效层单独显示(例如:"456"带特效) // 这样普通字幕和特效字幕都能显示,且关键词只显示一次 let enableCondition = `gte(t\\,${startTime})*lte(t\\,${endTime})`; // 【重要】不要隐藏基础字幕,让基础字幕显示非关键词部分 // 关键词已经从ASS文本中移除了,所以基础字幕只会显示非关键词部分 // 检查当前行是否包含其他样式的关键词(非分组的) if (foundKeywords.length > 0 && !hasKeywordGroupInThisLine) { // 当有普通样式的特效字幕出现时,隐藏普通字幕以避免重复显示 enableCondition = `0`; // 禁用普通字幕,因为特效会替代它 Log.info(`generateDrawtextFilters.normalSubtitleWithKeyword`, { lineIndex: i, foundKeywords: foundKeywords.map(k => k.keyword), normalSubtitleDisplay: "hidden when keyword effects are shown", startTime: startTime, endTime: endTime }); } // 生成基础字幕(当没有关键词特效时显示) if (hasBackgroundBox) { filter += `box=1:` + `boxcolor=0x${backgroundColor}@${bgOpacity}:` + `boxborderw=1:`; } filter += `x=(w-text_w)/2:` + `y=(h-text_h-20):` + `enable='${enableCondition}'`; filters.push(filter); // ========== 关键词特效处理 ========== // 优先级:keywordGroups > keywordColorMap > keywordStyle // 计算是否处理了关键词分组 let keywordGroupsProcessed = false; const processedGroupKeywords = new Set(); // 追踪已被关键词分组处理的关键词 const processedKeywordsInThisLine = new Set(); // 【关键修复】追踪当前dialogue行已处理的关键词,避免重复生成 // 优先处理关键词分组(用户设置的各组样式) if (keywordGroups && keywordGroups.length > 0) { Log.info(`generateDrawtextFilters.keywordGroupsProcessing`, { groupCount: keywordGroups.length, displayText: displayText.substring(0, 100), note: "处理用户配置的关键词分组" }); // 设置标志表示有keywordGroups需要处理 // 即使所有关键词都被跳过(因为有ASS内联样式),也应该设置这个标志 keywordGroupsProcessed = true; for (const group of keywordGroups) { if (!group.keywords || !group.keywords.length || !group.styleOverride) { continue; } const style = group.styleOverride; const groupKeywords = Array.isArray(group.keywords) ? group.keywords : []; // 调试日志:查看关键词分组中的关键词格式 Log.info(`generateDrawtextFilters.debugGroupKeywords`, { groupId: group.groupId || group.id, groupName: group.groupName || group.name, keywordsRaw: group.keywords, keywordsArray: groupKeywords, firstKeywordType: groupKeywords.length > 0 ? typeof groupKeywords[0] : 'N/A', firstKeywordValue: groupKeywords.length > 0 ? groupKeywords[0] : 'N/A' }); // 提取分组的样式信息 const groupFontSize = style.fontSize || fontSize; const groupOutlineWidth = style.outlineWidth || outlineWidth; // 处理颜色转换:#RRGGBB -> drawtext格式 0xRRGGBB let groupFontColor = style.fontColor || '#FFFFFF'; if (groupFontColor.startsWith('#')) { groupFontColor = groupFontColor.substring(1); } groupFontColor = groupFontColor.toUpperCase(); let groupOutlineColor = style.outlineColor || '#000000'; if (groupOutlineColor.startsWith('#')) { groupOutlineColor = groupOutlineColor.substring(1); } groupOutlineColor = groupOutlineColor.toUpperCase(); // 确保颜色格式正确 if (groupFontColor.length !== 6) groupFontColor = 'FFFFFF'; if (groupOutlineColor.length !== 6) groupOutlineColor = '000000'; Log.info(`generateDrawtextFilters.keywordGroup`, { keywordCount: groupKeywords.length, fontSize: groupFontSize, fontColor: groupFontColor, outlineWidth: groupOutlineWidth, outlineColor: groupOutlineColor }); // 为该分组的每个关键词创建drawtext过滤器 for (const keywordItem of groupKeywords) { // 关键词可能是字符串或对象 {keyword: "...", enabled: true} let keyword: string; if (typeof keywordItem === 'string') { keyword = keywordItem; } else if (keywordItem && typeof keywordItem === 'object' && keywordItem.keyword) { keyword = keywordItem.keyword; } else { continue; } // 【关键修复】不再依赖ASS文本中是否包含关键词 // drawtext特效层直接从keywordGroups参数获取关键词,不依赖ASS文本查找 // 这样即使关键词从ASS文本中移除了,特效层仍然可以显示 if (!keyword) { continue; } // 【关键修复】检查关键词是否已经在当前dialogue行处理过,避免重复生成 // 同一个关键词可能在多个分组中出现,或在同一分组中出现多次 // 但每个时间段内,每个关键词只应生成一次特效 if (processedKeywordsInThisLine.has(keyword)) { Log.info(`generateDrawtextFilters.skipDuplicateKeyword`, { keyword: keyword, groupName: group.groupName || group.name, reason: "该关键词已在当前时间段处理过,跳过重复生成", lineIndex: i, startTime: startTime, endTime: endTime }); continue; // 跳过已处理的关键词 } // 检查关键词是否应该在该时间段显示(使用字幕时间段,不依赖文本匹配) // 这样可以确保关键词特效在字幕时间段内显示,即使ASS文本中已经移除了关键词 const keywordShouldDisplayInThisTimeRange = true; // 总是显示,因为关键词分组已经指定了时间段 // 不跳过有ASS样式的关键词! // 虽然ASS提供了颜色,但drawtext特效会在上面一层渲染动画 // drawtext的样式会覆盖ASS样式,显示用户配置的颜色和动画效果 if (assInlineStyledKeywords.has(keyword)) { Log.info(`generateDrawtextFilters.groupKeywordWithAssStyle`, { keyword: keyword, groupName: group.groupName || group.name, assColor: assInlineStyledKeywords.get(keyword)?.color, reason: "虽有ASS样式,但仍生成drawtext特效以显示动画和正确的颜色" }); // 不要continue - 继续处理以生成drawtext特效 } keywordGroupsProcessed = true; processedGroupKeywords.add(keyword); // 记录已处理的关键词,避免重复处理 processedKeywordsInThisLine.add(keyword); // 【关键修复】记录当前行已处理的关键词 Log.info(`generateDrawtextFilters.processingGroupKeyword`, { keyword: keyword, groupName: group.groupName || group.name, foundInDisplayText: displayText.includes(keyword) }); // 创建只包含关键词的临时文件 const tempDir = os.tmpdir(); const keywordTempPath = path.join(tempDir, `drawtext_group_keyword_${uniqueId}_${i}_${keyword.replace(/[^a-zA-Z0-9]/g, '')}.txt`); fs.writeFileSync(keywordTempPath, keyword, 'utf-8'); tempTextFiles.push(keywordTempPath); const escapedKeywordTempPath = keywordTempPath.replace(/\\/g, '/').replace(/:/g, '\\:'); const quotedKeywordTempPath = `'${escapedKeywordTempPath}'`; // 从分组获取特效ID,如果没有则默认无特效 // 【关键修复】effectId可能存储在group.effectId或styleOverride.effectId中 const groupEffectId = group.effectId || group.effect || style?.effectId || group.effectConfig?.effectId || 'none'; // 添加详细日志,追踪effectId的获取 Log.info(`generateDrawtextFilters.effectIdExtraction`, { keyword: keyword, groupName: group.groupName || group.name, groupEffectId: groupEffectId, fromGroupEffectId: group.effectId, fromGroupEffect: group.effect, fromStyleEffectId: style?.effectId, fromEffectConfig: group.effectConfig?.effectId, finalEffectId: groupEffectId }); // 为关键词分组生成特效表达式 // 注意:FFmpeg drawtext filter 参数中不能有空格 let fontSizeExpr = String(groupFontSize); let alphaExpr = '1'; let xExpr = '(w-text_w)/2'; let yExpr = '(h-text_h-20)'; // 根据特效类型生成动画表达式 // 注意:只使用 FFmpeg 支持的参数(fontsize, x, y),不使用 alpha switch (groupEffectId) { case 'zoom-in': fontSizeExpr = `${groupFontSize}+sin(t*15)*3`; break; case 'pulse': // 改为使用字体大小脉冲,而非透明度 fontSizeExpr = `${groupFontSize}*(0.95+sin(t*20)*0.05)`; break; case 'bounce': yExpr = `(h-text_h-20)+sin(t*8)*30`; break; case 'glow': // 改为使用字体大小和位置发光效果,而非透明度 fontSizeExpr = `${groupFontSize}*(0.98+sin(t*12)*0.02)`; break; case 'shake': xExpr = `(w-text_w)/2+sin(t*25)*8`; break; case 'flip': // 改为使用字体大小闪烁,而非透明度闪烁 // 使用绝对值和正弦波模拟闪烁 fontSizeExpr = `${groupFontSize}*(0.85+0.15*abs(sin(t*6)))`; break; case 'slide-up': yExpr = `(h-text_h-20)+(h-text_h)*(1-min(1,(t-${startTime})/0.5))`; break; case 'fade': // 改为使用字体大小渐变,而非透明度渐变 fontSizeExpr = `${groupFontSize}*(0.7+0.3*min(1,(t-${startTime})/0.3))`; break; case 'none': default: // 无特效,使用默认值 break; } // 生成关键词分组的drawtext过滤器 // 注意:只使用 FFmpeg 支持的参数(fontsize, x, y),不使用 alpha // alpha 参数中的某些表达式(如 if())无法被 FFmpeg 正确解析 // 【修复】改进fontsize表达式的处理方式 // 检查fontSizeExpr是否为动画表达式(包含sin, cos等函数) const isFontSizeAnimated = fontSizeExpr.includes('sin') || fontSizeExpr.includes('cos') || fontSizeExpr.includes('tan') || fontSizeExpr.includes('+') || fontSizeExpr.includes('-') || fontSizeExpr.includes('*'); // 对于动画表达式,使用正确的引号处理(移除单引号直接传入表达式) // 或根据FFmpeg版本使用不同的处理方式 const fontSizeParam = isFontSizeAnimated ? `fontsize=${fontSizeExpr}` // 动画表达式:直接传入,不用引号 : `fontsize='${fontSizeExpr}'`; // 静态大小:用引号包裹 let groupFilter = `drawtext=` + `fontfile=${quotedFontPath}:` + `text='${escapeDrawtextText(keyword)}':` + `${fontSizeParam}:` + `fontcolor=0x${groupFontColor}:`; // 添加描边参数 if (groupOutlineWidth > 0) { groupFilter += `borderw=${groupOutlineWidth}:bordercolor=0x${groupOutlineColor}:`; } // 【修复】改进x和y表达式的处理方式 // 检查是否为动画表达式 const isXAnimated = xExpr.includes('sin') || xExpr.includes('cos') || xExpr.includes('t') || xExpr.includes('+') || xExpr.includes('-'); const isYAnimated = yExpr.includes('sin') || yExpr.includes('cos') || yExpr.includes('t') || yExpr.includes('+') || yExpr.includes('-'); const xParam = isXAnimated ? `x=${xExpr}` : `x='${xExpr}'`; const yParam = isYAnimated ? `y=${yExpr}` : `y='${yExpr}'`; // 【关键修复】drawtext特效层使用字幕的时间段来显示关键词 // 这样即使关键词从基础字幕中移除了,特效层仍然知道在哪个时间段显示 groupFilter += `box=1:` + `boxcolor=0x000000@0:` + // 透明背景 `${xParam}:` + `${yParam}:` + `enable='gte(t\\,${startTime})*lte(t\\,${endTime})'`; // 使用字幕时间段显示关键词特效 filters.push(groupFilter); Log.info(`generateDrawtextFilters.keywordGroup_${i}_${keyword}`, { keyword: keyword, groupName: group.groupName || group.name, effectId: groupEffectId, fontSize: groupFontSize, fontColor: groupFontColor, outlineWidth: groupOutlineWidth, fontSizeExpr: fontSizeExpr, xExpr: xExpr, yExpr: yExpr, isAnimated: isFontSizeAnimated || isXAnimated || isYAnimated, start: startTime, end: endTime }); } } } // 【关键修复】当有关键词分组时,完全禁用ASS内联样式路径,避免重复生成 // 只有当完全没有keywordGroups(null/undefined/空数组)时,才使用ASS内联样式 // 这是导致重复显示的根本原因:即使有keywordGroups,ASS内联样式路径仍可能执行 if ((!keywordGroups || keywordGroups.length === 0) && !keywordGroupsProcessed && keywordColorMap.size > 0) { // 只有在完全没有keywordGroups时,才使用从ASS文件提取的关键词颜色信息 Log.info(`generateDrawtextFilters.assKeywordProcessing`, { foundCount: keywordColorMap.size, keywords: Array.from(keywordColorMap.keys()), displayText: displayText.substring(0, 100), hasKeywordGroups: !!(keywordGroups && keywordGroups.length > 0), keywordGroupsProcessed: keywordGroupsProcessed, note: "从ASS内联标签中提取的关键词颜色(仅在无keywordGroups时使用)" }); for (const [keyword, color] of keywordColorMap.entries()) { if (!keyword || !displayText.includes(keyword)) { continue; } // 【关键修复】如果关键词已经被keywordGroups处理过,跳过它,避免重复生成 if (processedGroupKeywords.has(keyword)) { Log.info(`generateDrawtextFilters.skipAssKeywordAlreadyProcessed`, { keyword: keyword, reason: "该关键词已被keywordGroups处理,避免重复生成特效" }); continue; } // 从ASS颜色格式(&H00BBGGRR)转换为RGB颜色 // ASS格式中的颜色是BGR顺序,我们需要转换为RGB const assColor = color.substring(4); // 取&H后面的6个字符(BBGGRR) const blue = assColor.substring(0, 2); const green = assColor.substring(2, 4); const red = assColor.substring(4, 6); const rgbColor = `${red}${green}${blue}`; // 转换为RGB // 创建只包含关键词的临时文件 // 使用运行时临时目录,打包后由主进程指向安装目录 data\temp const tempDir = os.tmpdir(); const keywordTempPath = path.join(tempDir, `drawtext_ass_keyword_${uniqueId}_${i}_${keyword.replace(/[^a-zA-Z0-9]/g, '')}.txt`); fs.writeFileSync(keywordTempPath, keyword, 'utf-8'); tempTextFiles.push(keywordTempPath); const escapedKeywordTempPath = keywordTempPath.replace(/\\/g, '/').replace(/:/g, '\\:'); const quotedKeywordTempPath = `'${escapedKeywordTempPath}'`; // 生成关键词drawtext过滤器,使用提取的颜色 let keywordFilter = `drawtext=` + `fontfile=${quotedFontPath}:` + `text='${escapeDrawtextText(keyword)}':` + `fontsize=${fontSize * 1.2}:` + // 关键词稍大一些 `fontcolor=0x${rgbColor}:` + // 使用提取的颜色 `box=1:` + `boxcolor=0x000000@0:` + // 透明背景 `x=(w-text_w)/2:` + `y=(h-text_h-20):` + `enable='gte(t\\,${startTime})*lte(t\\,${endTime})'`; filters.push(keywordFilter); Log.info(`generateDrawtextFilters.assKeyword_${i}_${keyword}`, { keyword: keyword, color: color, rgbColor: rgbColor, start: startTime, end: endTime }); } } else if (keywords && keywords.length > 0 && keywordStyle && !keywordGroupsProcessed && (!keywordGroups || keywordGroups.length === 0)) { // 【关键修复】仅在没有配置keywordGroups时,才使用参数传入的keywords和keywordStyle // 当有关键词分组时,完全禁用keywordStyle路径,避免重复生成 Log.info(`generateDrawtextFilters.keywordStyleWarning`, { lineIndex: i, message: "使用keywordStyle路径(默认黄色),可能覆盖keywordGroups的样式", hasKeywordGroups: !!(keywordGroups && keywordGroups.length > 0), keywordGroupsProcessed: keywordGroupsProcessed, displayText: displayText.substring(0, 100) }); // 关键词可能是字符串数组或对象数组,需要处理两种情况 const keywordList = keywords.map(k => typeof k === 'string' ? k : k.word || k.keyword || String(k)); Log.info(`generateDrawtextFilters.paramKeywordProcessing`, { hasKeywords: true, keywordCount: keywordList.length, keywords: keywordList.slice(0, 5), displayText: displayText.substring(0, 100), hasKeywordStyle: !!keywordStyle, processedGroupKeywords: Array.from(processedGroupKeywords), processedGroupKeywordsSize: processedGroupKeywords.size }); for (const keyword of keywordList) { // 跳过已经被关键词分组处理过的关键词,避免重复处理 if (processedGroupKeywords.has(keyword)) { Log.info(`generateDrawtextFilters.skipKeywordProcessed`, { keyword: keyword, reason: "已被关键词分组处理过" }); continue; } if (!keyword || !displayText.includes(keyword)) { continue; } // 关键词找到,创建特效过滤器 const keywordFontSize = keywordStyle.fontSize || fontSize; let keywordFontColor = (keywordStyle.fontColor || '#FFD700').replace('#', '').toUpperCase(); const keywordBackgroundStyle = resolveDrawtextBoxStyle( keywordStyle.backgroundColor ?? subtitleStyle?.backgroundColor, keywordStyle.backgroundOpacity ?? subtitleStyle?.backgroundOpacity ); const keywordBgColor = keywordBackgroundStyle.colorHex; const keywordBgOpacity = keywordBackgroundStyle.opacity; const hasKeywordBackgroundBox = keywordBackgroundStyle.hasBackground; // 确保颜色格式正确 if (keywordFontColor.length !== 6) keywordFontColor = 'FFD700'; // 默认黄色 // 创建只包含关键词的临时文件(使用唯一标识符避免文件名重复) // 使用运行时临时目录,打包后由主进程指向安装目录 data\temp const tempDir = os.tmpdir(); const keywordTempPath = path.join(tempDir, `drawtext_keyword_${uniqueId}_${i}_${keyword.replace(/[^a-zA-Z0-9]/g, '')}.txt`); fs.writeFileSync(keywordTempPath, keyword, 'utf-8'); tempTextFiles.push(keywordTempPath); const escapedKeywordTempPath = keywordTempPath.replace(/\\/g, '/').replace(/:/g, '\\:'); const quotedKeywordTempPath = `'${escapedKeywordTempPath}'`; // 关键词特效:支持多种动画效果 // 支持从 keywordStyle 中动态读取特效参数和特效类型 const enableDynamicEffect = keywordStyle.enableDynamicEffect ?? true; const effectType = keywordStyle.effectId || keywordStyle.effect || 'zoom-in'; // 特效类型 let keywordFontSizeExpr = String(keywordFontSize); let keywordAlphaExpr = '1'; // 默认完全不透明 let xExpr = '(w-text_w)/2'; // X坐标 let yExpr = '(h-text_h-20)'; // Y坐标 if (enableDynamicEffect) { // 根据不同的特效类型生成不同的动画表达式 switch (effectType) { case 'zoom-in': // 缩放弹出:字体大小动态变化 const scaleFreq = keywordStyle.scaleFrequency || 15; const scaleAmp = keywordStyle.scaleAmplitude || 3; keywordFontSizeExpr = `${keywordFontSize} + sin(t*${scaleFreq})*${scaleAmp}`; break; case 'pulse': // 脉冲跳动:透明度闪烁 const flashFreq = keywordStyle.flashFrequency || 20; keywordAlphaExpr = `0.9 + sin(t*${flashFreq})*0.1`; break; case 'bounce': // 弹跳反弹:Y坐标上下移动 const bounceFreq = keywordStyle.bounceFrequency || 8; const bounceAmp = keywordStyle.bounceAmplitude || 30; yExpr = `(h-text_h-20) + sin(t*${bounceFreq})*${bounceAmp}`; break; case 'glow': // 发光闪烁:字体大小和透明度同时变化 const glowFreq = keywordStyle.glowFrequency || 12; keywordFontSizeExpr = `${keywordFontSize} + sin(t*${glowFreq})*2`; keywordAlphaExpr = `0.85 + sin(t*${glowFreq})*0.15`; break; case 'shake': // 抖动震颤:X坐标左右摇晃 const shakeFreq = keywordStyle.shakeFrequency || 25; const shakeAmp = keywordStyle.shakeAmplitude || 8; xExpr = `(w-text_w)/2 + sin(t*${shakeFreq})*${shakeAmp}`; break; case 'slide-up': // 底部滑入:Y坐标从下往上变化(使用cos实现一次性效果) const slideStart = startTime; const slideDuration = keywordStyle.slideDuration || 0.5; const slideProgress = `min(1, (t-${slideStart})/${slideDuration})`; yExpr = `(h-text_h-20) + (h-text_h) * (1 - ${slideProgress})`; break; case 'fade': // 渐变高亮:透明度从低到高 const fadeStart = startTime; const fadeDuration = keywordStyle.fadeDuration || 0.3; const fadeProgress = `min(1, (t-${fadeStart})/${fadeDuration})`; keywordAlphaExpr = `0.3 + ${fadeProgress} * 0.7`; break; case 'spin': // 旋转转动:通过缩放比例的不同变化模拟旋转 const spinFreq = keywordStyle.spinFrequency || 8; keywordFontSizeExpr = `${keywordFontSize} * (0.9 + 0.1 * sin(t*${spinFreq}))`; break; case 'wave': // 彩虹波浪:使用组合效果 const waveFreq = keywordStyle.waveFrequency || 10; const waveAmp = keywordStyle.waveAmplitude || 4; keywordFontSizeExpr = `${keywordFontSize} + sin(t*${waveFreq})*${waveAmp}`; yExpr = `(h-text_h-20) + sin(t*${waveFreq})*${waveAmp}`; break; case 'flip': // 翻转效果:通过缩放X实现翻转 const flipFreq = keywordStyle.flipFrequency || 6; keywordFontSizeExpr = `${keywordFontSize} * abs(sin(t*${flipFreq}*PI))`; break; default: // 默认缩放特效 keywordFontSizeExpr = `${keywordFontSize} + sin(t*15)*3`; break; } } // 生成关键词 drawtext 过滤器 // 修复:FFmpeg不支持between()函数,使用gte()*lte()替代 let keywordFilter = `drawtext=` + `fontfile=${quotedFontPath}:` + `text='${escapeDrawtextText(keyword)}':` + `fontsize='${keywordFontSizeExpr}':` + `fontcolor=0x${keywordFontColor}:`; if (hasKeywordBackgroundBox) { keywordFilter += `box=1:` + `boxcolor=0x${keywordBgColor}@${keywordBgOpacity}:` + `boxborderw=${keywordStyle.borderWidth || 2}:`; } keywordFilter += `bordercolor=0x${(keywordStyle.borderColor || '#FF0000').replace('#', '').toUpperCase() || 'FF0000'}:` + `alpha='${keywordAlphaExpr}':` + `x='${xExpr}':` + `y='${yExpr}':` + `enable='gte(t\\,${startTime})*lte(t\\,${endTime})'`; filters.push(keywordFilter); // 记录被关键词占用的时间段和文字位置 const wordIndex = displayText.indexOf(keyword); if (wordIndex >= 0) { occupiedRanges.push({ start: startTime, end: endTime, keyword: keyword, lineIndex: i, wordIndex: wordIndex }); } Log.info(`generateDrawtextFilters.keywordEffect.${i}_${keyword}`, { keyword: keyword, start: startTime, end: endTime, fontColor: keywordFontColor, backgroundColor: keywordBgColor, bgOpacity: keywordBgOpacity, enableDynamicEffect: enableDynamicEffect, scaleFrequency: keywordStyle.scaleFrequency || 15, scaleAmplitude: keywordStyle.scaleAmplitude || 3, flashFrequency: keywordStyle.flashFrequency || 20, occupiedWordIndex: wordIndex }); } } Log.info(`generateDrawtextFilters.filter${i + 1}`, { text: displayText.substring(0, 50), start: startTime, end: endTime, opacity: bgOpacity, hasKeyword: keywords && keywords.length > 0 && keywords.some(k => displayText.includes(k)), textFile: tempTextPath, fontPath: fontPath, fontName: fontName, fontFilePathFound: !!fontFilePath }); } // 清理临时文本文件(在 FFmpeg 处理完成后) // 注意:这里我们保存文件路径列表,在后续的调用中清理 if (tempTextFiles.length > 0) { // 延迟删除,等 FFmpeg 处理完毕 setImmediate(() => { setTimeout(() => { for (const file of tempTextFiles) { try { if (fs.existsSync(file)) { fs.unlinkSync(file); } } catch (err) { Log.error('Failed to clean temp text file:', { file, error: String(err) }); } } }, 5000); // 等待 5 秒后清理 }); } const result = filters.join(','); Log.info("generateDrawtextFilters.complete", { filterCount: filters.length, totalLength: result.length, backgroundColor, bgOpacity, occupiedRangesCount: occupiedRanges.length }); return { filters: result, occupiedRanges: occupiedRanges }; } catch (error) { Log.error("generateDrawtextFilters.error", { error: String(error), assPath }); return { filters: '', occupiedRanges: [] }; } }; // 智能换行:优先在标点符号处换行 const smartLineBreak = (text: string, maxCharsPerLine: number): string[] => { const lines: string[] = []; const punctuations = [',', '。', '!', '?', ';', ':', ',', '.', '!', '?', ';', ':']; const MAX_LINES = 2; // ✅ 添加最多2行限制 let currentLine = ''; let pureTextLength = 0; // 纯文本长度(不包含ASS标签) let i = 0; while (i < text.length) { // ✅ 如果已经有2行,直接将剩余文本附加到最后一行,不再换行 if (lines.length >= MAX_LINES) { currentLine += text[i]; i++; continue; } if (text[i] === '{') { // 找到ASS标签的结尾 const endBrace = text.indexOf('}', i); if (endBrace !== -1) { // 添加整个标签但不计算长度 currentLine += text.substring(i, endBrace + 1); i = endBrace + 1; continue; } } // 普通字符 currentLine += text[i]; pureTextLength++; // 检查是否达到最大长度或遇到标点符号 const isPunctuation = punctuations.includes(text[i]); const isNearMaxLength = pureTextLength >= maxCharsPerLine * 0.8; // 80%阈值 if (isPunctuation && isNearMaxLength && lines.length < MAX_LINES - 1) { // ✅ 在标点符号处换行,但仅当还有空间时 lines.push(currentLine.trim()); currentLine = ''; pureTextLength = 0; } else if (pureTextLength >= maxCharsPerLine && lines.length < MAX_LINES - 1) { // ✅ 强制换行,但仅当还有空间时 lines.push(currentLine.trim()); currentLine = ''; pureTextLength = 0; } i++; } if (currentLine.trim()) { lines.push(currentLine.trim()); } // ✅ 确保最多2行 return lines.slice(0, MAX_LINES); }; // 应用关键词特效 /** * 应用关键词特效 - 使用分段替换方法 * 直接替换关键词并保留普通文本 */ const applyKeywordEffects = (text: string, keywords: string[], keywordStyle: any): string => { if (!keywords || keywords.length === 0) { Log.info("applyKeywordEffects.skipped", { reason: "no keywords" }); return text; } if (!text || text.trim() === '') { Log.info("applyKeywordEffects.skipped", { reason: "empty text" }); return text; } const keywordFontSize = keywordStyle?.fontSize || 72; const keywordColor = hexToABGR(keywordStyle?.fontColor || '#FFD700'); const keywordOutlineColor = keywordStyle?.outlineColor ? hexToABGR(keywordStyle.outlineColor) : hexToABGR('#000000'); const keywordOutlineWidth = keywordStyle?.outlineWidth ?? 3; const keywordFontName = keywordStyle?.fontName || 'NotoSerifCJK-VF'; let result = text; const processedKeywords: string[] = []; // 按长度从长到短排序关键词,避免短关键词被长关键词包含 const sortedKeywords = [...keywords].sort((a, b) => b.length - a.length); for (const keyword of sortedKeywords) { if (!keyword || keyword.trim() === '') continue; // 创建正则表达式来匹配关键词(case-insensitive,但保留原始大小写) // 使用 \b 字边界确保不会匹配单词的一部分 const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`\\b${escapedKeyword}\\b`, 'gi'); // 构建特效标签 const startTag = `{\\fn${keywordFontName}\\fs${keywordFontSize}\\c${keywordColor}\\3c${keywordOutlineColor}\\4c${keywordOutlineColor}\\xbord${keywordOutlineWidth}\\ybord${keywordOutlineWidth}\\b1}`; const endTag = '{\\r}'; // 替换关键词,保留非关键词文本 const matchedCount = (result.match(regex) || []).length; if (matchedCount > 0) { result = result.replace(regex, `${startTag}$&${endTag}`); processedKeywords.push(`${keyword}(${matchedCount})`); } } Log.info("applyKeywordEffects.result", { inputText: text, outputText: result, totalKeywords: keywords.length, processedKeywords: processedKeywords, hasEffects: result !== text && result.includes('{\\\\') }); return result; }; /** * 应用多个关键词分组的特效 * 支持同一文本中的多个分组,每个分组有独立的样式 */ const applyKeywordGroupsEffects = (text: string, keywordGroups: any[]): string => { if (!keywordGroups || keywordGroups.length === 0) { return text; } // 提取纯文本(用于查找关键词位置) const pureText = text.replace(/\{[^}]*\}/g, ''); if (!pureText || pureText.trim() === '') { return text; } // 用于追踪原始文本中的位置(跳过ASS标签) let resultParts: Array<{ type: 'text' | 'tag', content: string }> = []; let i = 0; // 分解原始文本,保留标签和纯文本 while (i < text.length) { if (text[i] === '{') { // 查找ASS标签的结尾 const endBrace = text.indexOf('}', i); if (endBrace !== -1) { resultParts.push({ type: 'tag', content: text.substring(i, endBrace + 1) }); i = endBrace + 1; } else { resultParts.push({ type: 'text', content: text[i] }); i++; } } else { resultParts.push({ type: 'text', content: text[i] }); i++; } } // 创建标记数组用于标记每个字符属于哪个分组 const charGroupMap = new Map(); for (let groupIndex = 0; groupIndex < keywordGroups.length; groupIndex++) { const group = keywordGroups[groupIndex]; const keywords = group.keywords || []; for (const keywordItem of keywords) { const keyword = typeof keywordItem === 'string' ? keywordItem : keywordItem.keyword; if (!keyword || keyword.trim() === '') continue; // 在纯文本中查找关键词(case-insensitive) let searchIndex = 0; while (true) { const pos = pureText.toLowerCase().indexOf(keyword.toLowerCase(), searchIndex); if (pos === -1) break; // 标记这个位置的所有字符属于该分组 for (let j = 0; j < keyword.length; j++) { charGroupMap.set(pos + j, { groupId: group.id, groupIndex: groupIndex }); } searchIndex = pos + 1; } } } // 重新组装文本:按分组应用不同的样式 let finalResult = ''; let purePos = 0; let currentGroupIndex = -1; for (const part of resultParts) { if (part.type === 'tag') { finalResult += part.content; } else { const char = part.content; const charGroupInfo = charGroupMap.get(purePos); if (charGroupInfo) { const group = keywordGroups[charGroupInfo.groupIndex]; const style = group.styleOverride || {}; // 需要应用样式 if (charGroupInfo.groupIndex !== currentGroupIndex) { // 从非关键词或不同分组切换到这个分组 if (currentGroupIndex !== -1) { // 先重置之前的样式 finalResult += '{\\r}'; } const fontSize = style.fontSize || 72; const fontColor = hexToABGR(style.fontColor || '#FFD700'); const outlineColor = style.outlineColor ? hexToABGR(style.outlineColor) : hexToABGR('#000000'); const outlineWidth = style.outlineWidth ?? 3; const fontName = style.fontName || 'NotoSerifCJK-VF'; finalResult += `{\\fn${fontName}\\fs${fontSize}\\c${fontColor}\\3c${outlineColor}\\4c${outlineColor}\\xbord${outlineWidth}\\ybord${outlineWidth}\\b1}`; currentGroupIndex = charGroupInfo.groupIndex; } } else if (currentGroupIndex !== -1) { // 从关键词退出到普通文本 finalResult += '{\\r}'; currentGroupIndex = -1; } finalResult += char; purePos++; } } // 如果以关键词结尾,添加重置标签 if (currentGroupIndex !== -1) { finalResult += '{\\r}'; } return finalResult; }; // 生成ASS格式字幕(支持关键词分组和多样式) const generateAssSubtitleWithKeywordGroups = ( asrRecords: any[], subtitleStyle: any, maxCharsPerLine: number = 18, keywordGroups?: any[] ): string => { // 首先使用基础样式生成字幕,然后应用关键词分组样式 // 关键词分组中的样式会覆盖基础样式 Log.info("generateAssSubtitleWithKeywordGroups", { groupCount: keywordGroups?.length || 0, groups: keywordGroups?.map((g: any) => ({ id: g.id, name: g.name, keywordCount: g.keywords?.length || 0, effectId: g.effectId })) }); // 使用现有的generateAssSubtitle函数生成基础字幕 let assContent = generateAssSubtitle(asrRecords, subtitleStyle, maxCharsPerLine); if (!keywordGroups || keywordGroups.length === 0) { return assContent; } // 为每个关键词分组创建独立的样式行 let styleSection = ''; let dialogLines: string[] = []; // 提取现有的[V4+ Styles]部分 const styleMatch = assContent.match(/\[V4\+ Styles\]([\s\S]*?)(?=\[Events\]|\Z)/); const eventsMatch = assContent.match(/\[Events\]([\s\S]*)/); if (styleMatch && eventsMatch) { styleSection = styleMatch[1]; const eventsSection = eventsMatch[1]; // 【关键修复】不在此函数中创建关键词分组的Style // 原因:这些Style永远不会被应用到Dialogue行(因为关键词被删除了) // 所有关键词特效由drawtext层单独处理 // 详细日志记录关键词分组信息用于调试 Log.info('generateAssSubtitleWithKeywordGroups.skipGroupStyleCreation', { groupCount: keywordGroups.length, reason: '关键词将由drawtext特效层处理,不在ASS中创建关键词分组Style', note: 'Style创建但不使用会造成混乱,已移除', groups: keywordGroups.map((g: any, i: number) => ({ index: i, groupId: g.id, groupName: g.name, groupEffectId: g.effectId, hasStyleOverride: !!g.styleOverride })) }); // 处理对话行,为关键词应用对应的样式(修复:直接赋值给dialogLines,不重新声明) const eventsLines = eventsSection.split('\n'); // 保留Format行 const formatLines = eventsLines.filter((line: string) => line.trim().startsWith('Format:')); if (formatLines.length > 0) { dialogLines.push(...formatLines); } // 关键修复:详细日志记录关键词处理 Log.info('generateAssSubtitleWithKeywordGroups.processingDialogueLines', { totalEventLines: eventsLines.length, dialogueLineCount: eventsLines.filter((line: string) => line.trim().startsWith('Dialogue:')).length, keywordGroupsToProcess: keywordGroups.map((g: any) => ({ groupId: g.id, groupName: g.name, keywordsInGroup: g.keywords?.map((kw: any) => typeof kw === 'string' ? kw : kw.keyword) || [] })) }); // 收集所有会被drawtext特效层处理的关键词 const allEffectKeywords = new Set(); for (const group of keywordGroups) { if (!group.keywords) continue; for (const kw of group.keywords) { const keyword = typeof kw === 'string' ? kw : kw.keyword; if (keyword) { allEffectKeywords.add(keyword); } } } // 处理所有Dialogue行 // 【关键修复】从ASS字幕文本中移除关键词,避免与drawtext特效层重复显示 // drawtext特效层已经从keywordGroups参数获取关键词,不再依赖ASS文本查找 // 这样基础字幕只显示非关键词部分,关键词由特效层单独显示 const dialogueLines = eventsLines .filter((line: string) => line.trim().startsWith('Dialogue:')) .map((line: string, lineIndex: number) => { // 解析Dialogue行,提取文本部分 const dialogueParts = line.split(','); if (dialogueParts.length < 10) { return line; // 格式错误,返回原行 } const dialoguePrefix = dialogueParts.slice(0, 9).join(',') + ','; let dialogueText = dialogueParts.slice(9).join(','); // 文本部分可能包含逗号 // 移除所有ASS标签,获取纯文本 const textWithoutTags = dialogueText.replace(/\{[^}]*\}/g, ''); // 检查是否包含会被特效层处理的关键词 const keywordsInText: string[] = []; for (const keyword of allEffectKeywords) { if (textWithoutTags.includes(keyword)) { keywordsInText.push(keyword); } } // 如果有关键词,从文本中移除它们(包括带内联样式的),避免重复显示 if (keywordsInText.length > 0) { let modifiedText = dialogueText; // 按长度从长到短排序关键词,避免短关键词被长关键词包含 const sortedKeywords = [...keywordsInText].sort((a, b) => b.length - a.length); for (const keyword of sortedKeywords) { // 【关键修复】移除带内联样式的关键词 // 匹配格式:{\fn字体\c颜色}关键词{\r} 或 {\c颜色}关键词{\c} 等 // 1. 移除带完整内联样式的关键词 const inlineStyleWithKeywordRegex = new RegExp( `\\{[^}]*\\}${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\{[^}]*\\}`, 'gi' ); modifiedText = modifiedText.replace(inlineStyleWithKeywordRegex, ''); // 2. 移除带开始标签的关键词:{\c颜色}关键词 const startTagRegex = new RegExp( `\\{[^}]*\\}${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'gi' ); modifiedText = modifiedText.replace(startTagRegex, ''); // 3. 移除带结束标签的关键词:关键词{\c} const endTagRegex = new RegExp( `${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\{[^}]*\\}`, 'gi' ); modifiedText = modifiedText.replace(endTagRegex, ''); // 4. 最后移除纯关键词(没有标签的) const textWithoutTags2 = modifiedText.replace(/\{[^}]*\}/g, ''); if (textWithoutTags2.includes(keyword)) { const keywordRegex = new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); modifiedText = modifiedText.replace(keywordRegex, ''); } } // 清理孤立的ASS标签(标签内没有文本) // 移除连续的标签,如 {\c} {\r} 等 modifiedText = modifiedText.replace(/\{\\[^}]*\}\s*\{\\[^}]*\}/g, ''); modifiedText = modifiedText.replace(/\{\\[^}]*\}\s+/g, ''); modifiedText = modifiedText.replace(/\s+\{\\[^}]*\}/g, ''); // 清理多余的空格和标点 modifiedText = modifiedText.replace(/\s+/g, ' ').trim(); // 如果移除关键词后文本为空,完全隐藏这一行字幕 const finalTextWithoutTags = modifiedText.replace(/\{[^}]*\}/g, '').trim(); if (!finalTextWithoutTags || finalTextWithoutTags.length === 0) { Log.info('generateAssSubtitleWithKeywordGroups.hidingSubtitle', { lineIndex, keywords: keywordsInText, reason: "移除关键词后文本为空,完全隐藏字幕行,关键词由特效层单独显示" }); return ''; // 返回空行,后续会被过滤掉 } dialogueText = modifiedText; Log.info('generateAssSubtitleWithKeywordGroups.removedKeywords', { lineIndex, keywords: keywordsInText, originalText: textWithoutTags.substring(0, 50), newText: finalTextWithoutTags.substring(0, 50), reason: "从ASS字幕中移除关键词(包括带内联样式的),由drawtext特效层单独显示" }); } return dialoguePrefix + dialogueText; }) .filter((line: string) => line.trim().length > 0); // 过滤掉空行 // 合并所有对话行 dialogLines.push(...dialogueLines); // 最后日志:显示是否有任何关键词被应用 Log.info('generateAssSubtitleWithKeywordGroups.summaryAfterProcessing', { totalDialogueLines: dialogueLines.length - formatLines.length, hasAnyKeywordApplication: dialogueLines.some(line => line.includes('{\\c')) }); // 重新构建ASS内容 const header = assContent.substring(0, assContent.indexOf('[V4+ Styles]')); const newContent = header + '[V4+ Styles]\n' + styleSection + '\n[Events]\n' + dialogLines.join('\n'); return newContent; } return assContent; }; // 生成ASS格式字幕的辅助函数(增强版:支持背景颜色、关键词特效、智能换行) const generateAssSubtitle = ( asrRecords: any[], subtitleStyle: any, maxCharsPerLine: number = 18, keywords?: string[], keywordStyle?: any ): string => { // 详细日志:记录接收到的样式参数 Log.info("generateAssSubtitle.receivedStyle", { fullStyle: subtitleStyle, fontName: subtitleStyle?.fontName, backgroundColor: subtitleStyle?.backgroundColor, backgroundOpacity: subtitleStyle?.backgroundOpacity }); // ===== 关键诊断:原始颜色值 ===== Log.info("generateAssSubtitle.colorDiagnostics.原始值", { fontColor_原始: subtitleStyle?.fontColor, outlineColor_原始: subtitleStyle?.outlineColor, backgroundColor_原始: subtitleStyle?.backgroundColor, backgroundOpacity_原始: subtitleStyle?.backgroundOpacity, position_原始: subtitleStyle?.position }); // 解析样式参数 const fontSize = subtitleStyle?.fontSize || 48; const fontColor = subtitleStyle?.fontColor || '#FFFFFF'; const outlineColor = subtitleStyle?.outlineColor || '#000000'; const position = subtitleStyle?.position || 'bottom'; // 修复:确保字体名称正确传递 // 如果fontName是对象(如{value: \"xxx\", label: \"xxx\"}),提取value let fontName = subtitleStyle?.fontName || 'NotoSerifCJK-VF'; if (typeof fontName === 'object' && fontName !== null) { // 处理对象格式:{value: \"...\", label: \"...\"} if (fontName.value) { fontName = fontName.value; } else if (fontName.label) { fontName = fontName.label; } else { // 如果对象没有value或label,尝试序列化或使用默认值 fontName = 'NotoSerifCJK-VF'; Log.error("generateAssSubtitle.invalidFontName", { received: subtitleStyle?.fontName, note: "字体名称是无效的对象格式,使用默认字体" }); } } // 确保最终是字符串类型 if (typeof fontName !== 'string') { fontName = String(fontName || 'NotoSerifCJK-VF'); } fontName = fontName.trim() || 'NotoSerifCJK-VF'; // ASS格式要求:如果字体名称包含逗号,需要用引号包裹 // 但通常字体名称不会有逗号,所以这里先检查 const fontNameForASS = fontName.includes(',') ? `"${fontName}"` : fontName; const backgroundColor = subtitleStyle?.backgroundColor || 'transparent'; // 修复:确保背景透明度是0-1之间的数字 let backgroundOpacity = subtitleStyle?.backgroundOpacity ?? 0; if (typeof backgroundOpacity === 'string') { // 如果是从本地存储读取的百分比格式,转换为0-1范围 if (backgroundOpacity.endsWith('%')) { backgroundOpacity = parseFloat(backgroundOpacity) / 100; } else { backgroundOpacity = parseFloat(backgroundOpacity); } } // 确保是有效的数字且在0-1范围内 if (isNaN(backgroundOpacity) || backgroundOpacity < 0) { backgroundOpacity = 0; } else if (backgroundOpacity > 1) { // 如果是百分比格式(0-100),转换为0-1 if (backgroundOpacity <= 100) { backgroundOpacity = backgroundOpacity / 100; } else { backgroundOpacity = 1; } } // 详细日志:记录解析后的参数 Log.info("generateAssSubtitle.parsedParams", { fontName, fontNameType: typeof fontName, originalFontName: subtitleStyle?.fontName, originalFontNameType: typeof subtitleStyle?.fontName, backgroundColor, backgroundOpacity, backgroundOpacityType: typeof backgroundOpacity, originalBackgroundOpacity: subtitleStyle?.backgroundOpacity, originalBackgroundOpacityType: typeof subtitleStyle?.backgroundOpacity, willUseBorderStyle3: (backgroundColor !== 'transparent' && backgroundOpacity > 0 && backgroundOpacity <= 1), backgroundValid: (backgroundColor !== 'transparent' && backgroundColor !== '' && backgroundOpacity > 0 && backgroundOpacity <= 1), hasValidBackground: (backgroundColor !== 'transparent' && backgroundColor !== '' && backgroundOpacity > 0 && backgroundOpacity <= 1) }); // 检查是否有有效背景 const hasValidBackground = (backgroundColor !== 'transparent' && backgroundColor !== '' && backgroundOpacity > 0 && backgroundOpacity <= 1); // 注意:ASS的BackColour参数不支持Alpha通道 // 解决方案:使用颜色混合来模拟透明度效果 // 混合颜色 = 用户选择的颜色 * 透明度 + 白色背景 * (1 - 透明度) // 这样能产生"变淡"效果而不是"变暗",符合正常的透明度视觉效果 let blendedBackgroundColor = backgroundColor; if (hasValidBackground && backgroundColor !== 'transparent' && backgroundOpacity < 1) { // 计算混合颜色,模拟透明度 blendedBackgroundColor = blendColorForOpacity(backgroundColor, backgroundOpacity); Log.info("generateAssSubtitle.opacityBlending", { originalColor: backgroundColor, opacity: backgroundOpacity, blendedColor: blendedBackgroundColor, method: '颜色混合(ASS BackColour不支持Alpha)' }); } // 转换颜色格式 const primaryColor = hexToABGR(fontColor); const outlineColorASS = hexToABGR(outlineColor); // 修复:ASS BackColour只支持RGB格式(&HBBGGRR),不支持Alpha // 使用混合后的背景颜色(已考虑透明度) const backColorASS = hexToABGR(blendedBackgroundColor); // 使用混合后的颜色,已模拟透明度 // ===== 关键诊断:颜色转换后的值 ===== Log.info("generateAssSubtitle.colorDiagnostics.转换后", { fontColor_输入: fontColor, fontColor_输出: primaryColor, outlineColor_输入: outlineColor, outlineColor_输出: outlineColorASS, backgroundColor_输入: backgroundColor, backgroundColor_混合后: blendedBackgroundColor, backgroundColor_输出: backColorASS, backgroundColor_16进制: blendedBackgroundColor ? blendedBackgroundColor.replace('#', '') : 'N/A', 透明度模拟方式: '颜色混合而非Alpha通道' }); // ===== 颜色值交换检测 ===== // 如果发现颜色可能被交换了,在日志中标记 const outlineHex = outlineColor.replace('#', '').toLowerCase(); const backgroundHex = backgroundColor !== 'transparent' ? backgroundColor.replace('#', '').toLowerCase() : 'transparent'; if (outlineHex && backgroundHex && outlineHex !== backgroundHex && backgroundHex !== 'transparent') { // 两个颜色都被设置且不相同,输出详细的颜色对比日志用于检查 Log.info("generateAssSubtitle.colorDiagnostics.颜色对比", { outlineColor_hex: outlineHex, outlineColor_ABGR: outlineColorASS, backgroundColor_hex: backgroundHex, backgroundColor_ABGR: backColorASS, note: "如果这里显示的颜色对比与UI中选择的颜色不同,说明可能发生了颜色交换" }); } // 确定对齐方式:2=底部,5=居中,8=顶部 const alignment = position === 'top' ? 8 : position === 'center' ? 5 : 2; // 增加安全边距:左右120px,底部80px,防止字幕超出屏幕 const marginL = 120; const marginR = 120; const marginV = 80; // 确定BorderStyle:如果有背景色且透明度大于0,使用3(不透明盒子),否则使用1(描边) // 注意:即使有背景色,如果透明度为0,也应该使用描边模式 // hasValidBackground 已在上面声明,无需重复声明 // 修复:正确处理outlineWidth和shadowOffset let outlineWidth = subtitleStyle?.outlineWidth ?? 3; let shadowOffset = subtitleStyle?.shadowOffset ?? 2; // 修复:根据是否有背景来选择 BorderStyle(优先显示背景) // BorderStyle=1: 描边模式 - 显示OutlineColour(文字描边),忽略BackColour // BorderStyle=3: 背景模式 - 显示BackColour(背景),忽略OutlineColour // ASS格式限制:不能同时显示描边和背景,必须择一 // 优先级:背景 > 描边(如果两者都设置,显示背景) let finalBorderStyle = 1; let finalOutlineWidth = outlineWidth || 3; let finalShadowWidth = shadowOffset || 2; // 关键修复:当使用背景模式时,必须禁用描边以防止覆盖背景颜色 let finalOutlineColorASS = outlineColorASS; if (hasValidBackground) { // 用户设置了背景,使用BorderStyle=3(背景模式),优先显示背景 finalBorderStyle = 3; finalOutlineWidth = 1; // BorderStyle=3 模式下,Outline=1 用于背景边框宽度 finalShadowWidth = 0; // BorderStyle=3 模式下禁用阴影 // 关键修复:将 OutlineColour 设为与背景颜色相同,使边框也是相同颜色 finalOutlineColorASS = backColorASS; // 使用背景颜色作为轮廓色,确保边框和背景颜色一致 Log.info("generateAssSubtitle.borderStyleAdjusted", { reason: "用户设置了背景,优先使用BackColour显示背景(禁用Outline防止覆盖)", finalBorderStyle: finalBorderStyle, finalOutlineWidth: finalOutlineWidth, finalShadowWidth: finalShadowWidth, backgroundColor_原始: backgroundColor, backgroundColor_混合后: blendedBackgroundColor, backgroundOpacity: backgroundOpacity, backColorASS: backColorASS, finalOutlineColorASS: finalOutlineColorASS, note: "BorderStyle=3(背景模式) - 显示BackColour作为背景,禁用Outline(=0)防止覆盖,背景色已通过混合处理透明度" }); } else { // 没有背景,使用BorderStyle=1(描边模式),显示描边 finalBorderStyle = 1; finalOutlineWidth = outlineWidth || 3; // 描边宽度 finalShadowWidth = 0; finalOutlineColorASS = outlineColorASS; // 使用原始描边颜色 Log.info("generateAssSubtitle.borderStyleAdjusted", { reason: "用户没有设置背景,使用BorderStyle=1显示描边", finalBorderStyle: finalBorderStyle, finalOutlineWidth: finalOutlineWidth, finalShadowWidth: finalShadowWidth, outlineColor: outlineColor, finalOutlineColorASS: finalOutlineColorASS, note: "BorderStyle=1(描边模式) - 显示OutlineColour作为描边,BackColour会被忽略" }); } // 详细日志:记录最终的ASS样式参数 Log.info("generateAssSubtitle.finalAssStyle", { fontName, fontNameForASS, fontSize, primaryColor, outlineColor: outlineColor, outlineColorASS, backgroundColor_原始: backgroundColor, backgroundColor_混合后: blendedBackgroundColor, backgroundOpacity: backgroundOpacity, backColorASS, // 混合后的颜色转换为ASS格式 finalBorderStyle, finalOutlineWidth, finalShadowWidth, alignment, 透明度处理方式: `颜色混合(${backgroundColor} * ${backgroundOpacity} → ${blendedBackgroundColor})`, renderMode: { borderStyle: finalBorderStyle, mode: finalBorderStyle === 3 ? 'BackgroundMode(BorderStyle=3)' : 'StrokeMode(BorderStyle=1)', displayContent: finalBorderStyle === 3 ? `背景(BackColour=${backColorASS}, 已混合透明度)` : `描边(OutlineColour=${outlineColorASS})`, note: `优先级:有背景→BorderStyle=3(显示背景,忽略描边) | 无背景→BorderStyle=1(显示描边)` } }); // ASS样式参数说明 // PrimaryColour = 文字颜色 // OutlineColour = 文字描边颜色(仅在BorderStyle=1时显示) // BackColour = 背景颜色(仅在BorderStyle=3时显示) // BorderStyle = 1(描边模式) 或 3(背景模式) // 优先级:有背景→BorderStyle=3 | 无背景→BorderStyle=1 // 生成样式行(使用 finalOutlineColorASS 防止描边覆盖背景) const styleLine = `Style: Default,${fontNameForASS},${fontSize},${primaryColor},&H000000FF,${finalOutlineColorASS},${backColorASS},1,0,0,0,100,100,0,0,${finalBorderStyle},${finalOutlineWidth},${finalShadowWidth},${alignment},${marginL},${marginR},${marginV},1`; Log.info("generateAssSubtitle.styleLine", { styleLine: styleLine, fontNameInStyle: fontNameForASS, primaryColorInStyle: primaryColor, // 文字颜色 outlineColorInStyle: finalOutlineColorASS, // 实际使用的描边颜色(背景模式时为透明) originalOutlineColor: outlineColorASS, // 原始描边颜色 backColorInStyle: backColorASS, // 背景颜色 borderStyleInStyle: finalBorderStyle, // 1=描边模式, 3=背景模式 outlineWidthInStyle: finalOutlineWidth, // 描边/背景边框宽度 shadowWidthInStyle: finalShadowWidth, // 阴影宽度 mode: finalBorderStyle === 3 ? '背景模式(显示BackColour,OutlineColour已清除)' : '描边模式(显示OutlineColour)', note: `ASS格式限制:BorderStyle=${finalBorderStyle}(${finalBorderStyle === 3 ? '背景' : '描边'}模式) - 修复:背景模式时OutlineColour清除防止覆盖` }); // ASS文件头部(使用 finalOutlineColorASS) let assContent = `[Script Info]\nTitle: Generated Subtitle\nScriptType: v4.00+\nWrapStyle: 0\nPlayResX: 1920\nPlayResY: 1080\nScaledBorderAndShadow: yes\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,${fontNameForASS},${fontSize},${primaryColor},&H000000FF,${finalOutlineColorASS},${backColorASS},1,0,0,0,100,100,0,0,${finalBorderStyle},${finalOutlineWidth},${finalShadowWidth},${alignment},${marginL},${marginR},${marginV},1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n`; // 时间格式化函数:秒 -> H:MM:SS.CC const formatTime = (seconds: number): string => { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const cs = Math.floor((seconds % 1) * 100); return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`; }; // 生成字幕事件(每个ASR记录独立处理,确保根据音频时间及时换行) for (const record of asrRecords) { let startTime = record.start || record.begin || 0; let endTime = record.end || startTime + 1; // 时间单位转换(毫秒->秒) if (startTime > 100) startTime = startTime / 1000; if (endTime > 100) endTime = endTime / 1000; let text = record.text || record.sentence || ''; // **修复**:不在ASS字幕层应用关键词特效,避免与drawtext特效层重复显示 // 关键词特效统一由drawtext特效层处理,这样可以: // 1. 支持用户设置的动画效果(ASS内联样式不支持动画) // 2. 避免关键词显示两次(ASS层一次 + drawtext层一次) // 3. 确保颜色和动画效果与用户设置一致 // 备份原始文本(用于对比调试) const originalTextBeforeKeywordEffects = text; // 不再在ASS层应用关键词特效,由drawtext特效层统一处理 // 这样每个关键词只会显示一次,且使用用户设置的动画效果 if (keywords && keywords.length > 0) { Log.info("字幕处理.跳过ASS层关键词特效", { 原因: "关键词特效由drawtext特效层统一处理,避免重复显示并支持动画效果", 关键词数量: keywords.length, 原始文本: originalTextBeforeKeywordEffects.substring(0, 50) }); } // 去除标点符号(在应用关键词特效之后,这样特效位置对应原始文本) text = text.replace(/[,。!?;:、\"\"''()《》【】…—·,.!?;:'\"()\\[\\]{}<>]/g, ''); text = text.trim(); if (!text) continue; // 智能换行:优先在标点符号处换行 const lines = smartLineBreak(text, maxCharsPerLine); const displayText = lines.join('\\\\N'); // ASS使用\\N换行 // 注意:不在这里加\\alpha标签,因为那会影响文字本身的透明度 // 背景透明度在 BorderStyle=3 模式下无法直接实现 // (因为ASS的BackColour参数不支持Alpha通道) // 如果需要背景透明效果,应该在前端选色时就处理(选择更淡的背景颜色) let finalDisplayText = displayText; assContent += `Dialogue: 0,${formatTime(startTime)},${formatTime(endTime)},Default,,0,0,0,,${finalDisplayText}\n`; } return assContent; }; // 注册所有IPC handlers const registerHandlers = () => { // 打开浏览器窗口并自动抓取数据 ipcMain.handle("ipAgent:openBrowserWindow", async (event, params: { url: string }) => { try { const { url } = params; Log.info("ipAgent.openBrowserWindow", { url }); // 关闭之前的窗口(如果存在) if (browserWindow && !browserWindow.isDestroyed()) { // 🔧 修复:使用 destroy() 强制销毁窗口,而不是 close()(close 会跳转到 about:blank) browserWindow.destroy(); browserWindow = null; } // Create new browser window - 确保窗口可见并正确加载URL browserWindow = new BrowserWindow({ width: 1200, height: 800, show: true, // 🔧 修复:确保窗口显示 webPreferences: { nodeIntegration: false, contextIsolation: true, webSecurity: false, sandbox: true, // 禁用preload,避免dispatch_message错误 preload: undefined } }); // 🔧 修复:添加页面加载错误监听 browserWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { Log.error("ipAgent.analyzeDouyinAccount.loadError", { errorCode, errorDescription, validatedURL, originalUrl: url }); console.error(`页面加载失败: ${errorCode} - ${errorDescription} (URL: ${validatedURL})`); }); // 🔧 修复:显示窗口后再加载URL,确保用户能看到加载过程 browserWindow.show(); // 验证URL格式 let finalUrl = url.trim(); if (!finalUrl.startsWith('http://') && !finalUrl.startsWith('https://')) { finalUrl = 'https://' + finalUrl; } Log.info("ipAgent.analyzeDouyinAccount.loadingURL", { originalUrl: url, finalUrl }); browserWindow.loadURL(finalUrl).catch((error) => { Log.error("ipAgent.analyzeDouyinAccount.loadURLError", { error, url: finalUrl }); console.error(`加载URL失败: ${error.message}`); }); // 监听页面加载完成事件 return new Promise((resolve) => { if (!browserWindow) { resolve({ success: false, message: "Browser window creation failed" }); return; } // 设置超时时间(30秒) const timeout = setTimeout(() => { Log.info("ipAgent.openBrowserWindow.timeout", { url }); resolve({ success: false, message: "页面加载超时,请检查网络连接或稍后重试" }); }, 30000); // 监听页面加载完成 browserWindow.webContents.on("did-finish-load", async () => { clearTimeout(timeout); Log.info("ipAgent.openBrowserWindow.pageLoaded", { url }); // 等待5秒让页面完全渲染(抖音是动态加载的,需要更多时间) await new Promise(r => setTimeout(r, 5000)); try { // 自动执行抓取脚本 const script = ` (function() { // 获取账号名称 - 多种尝试 let accountName = ''; // 尝试1: 查找包含nickname的元素 const nicknameEl = document.querySelector('[class*="nickname"], [class*="account"], h1'); if (nicknameEl) accountName = nicknameEl.textContent?.trim(); // 尝试2: 查找meta标签 if (!accountName) { const metaEl = document.querySelector('meta[property="og:title"]'); if (metaEl) accountName = metaEl.getAttribute('content'); } // 尝试3: 查找页面中较大的文本元素 if (!accountName) { const allHeaders = document.querySelectorAll('h1, h2, .dy-name, [data-testid*="name"], [class*="userName"]'); for (let el of allHeaders) { const text = el.textContent?.trim(); if (text && text.length > 2 && text.length < 30) { accountName = text; break; } } } // 尝试4: 从页面title获取(去除杂染信息) if (!accountName) { const titleText = document.title; const match = titleText.match(/(.+?)(?:的抖音|抖音号|-)/); accountName = match ? match[1].trim() : titleText.split('-')[0]?.trim() || ''; } // 获取视频列表(最新的6个) const videoTitles = []; const excludeWords = ['抖音号', 'IP属地', '获赞', '粉丝', '关注', '作品', '点赞', '评论', '分享', '收藏', '私信', '粉丝团', '加关注', '粉丝团主页', '最新作品', '合作推荐', '喜欢作者']; const shouldExclude = (text) => { if (!text) return true; const hasExcludeWord = excludeWords.some(word => text.includes(word)); if (hasExcludeWord) return true; if (text.length < 5 || text.length > 300) return true; if (!/[\u4e00-\u9fa5a-zA-Z0-9]/g.test(text)) return true; return false; }; const allPTags = document.querySelectorAll('p, span, div'); allPTags.forEach((el) => { const text = el.textContent?.trim(); if (text && text.includes('#') && !shouldExclude(text) && text.length > 10) { if (videoTitles.length < 6 && !videoTitles.includes(text)) { videoTitles.push(text); } } }); if (videoTitles.length < 3) { const titleSelectors = ['[class*="title"]', '[class*="desc"]', '[class*="caption"]', '[data-testid*="title"]', '[class*="feed-item"]', '[class*="video-item"]', '.dy-video-title', '.aweme-detail', '[class*="aweme"]']; titleSelectors.forEach(selector => { if (videoTitles.length >= 6) return; document.querySelectorAll(selector).forEach((el) => { if (videoTitles.length < 6) { const text = el.textContent?.trim(); if (text && !shouldExclude(text) && text.length > 5) { if (!videoTitles.includes(text)) { videoTitles.push(text); } } } }); }); } return { accountName, videoTitles: videoTitles.slice(0, 6), pageTitle: document.title, pageUrl: window.location.href }; })(); `; const data = await browserWindow!.webContents.executeJavaScript(script); Log.info("ipAgent.openBrowserWindow.scraped", { accountName: data.accountName, videoCount: data.videoTitles?.length || 0 }); // 🔧 修复:使用 destroy() 强制销毁窗口,而不是 close()(close 会跳转到 about:blank) try { if (browserWindow && !browserWindow.isDestroyed()) { Log.info("ipAgent.analyzeDouyinAccount.destroyingBrowser", { url }); // 直接使用 destroy() 强制销毁窗口,避免跳转到 about:blank browserWindow.destroy(); browserWindow = null; Log.info("ipAgent.analyzeDouyinAccount.browserDestroyed", { message: "浏览器窗口已销毁" }); } } catch (destroyError) { Log.error("ipAgent.analyzeDouyinAccount.destroyBrowserError", destroyError); browserWindow = null; } resolve({ success: true, data }); } catch (scrapeError) { Log.error("ipAgent.openBrowserWindow.scrapeError", scrapeError); resolve({ success: false, message: "数据采集失败: " + (scrapeError?.message || scrapeError) }); } }); browserWindow.on("closed", () => { clearTimeout(timeout); browserWindow = null; }); }); } catch (error) { Log.error("ipAgent.openBrowserWindow.error", error); return { success: false, message: "打开浏览器失败: " + (error?.message || error) }; } }); // 分析抖音账号 - 使用与视频仿写相同的浏览器实例 ipcMain.handle("ipAgent:analyzeDouyinAccount", async (event, params: { url: string }) => { try { const { url } = params; Log.info("ipAgent.analyzeDouyinAccount", { url }); // 🔧 修复:尝试使用 publish 模块的浏览器上下文(与视频仿写共享) let externalBrowserContext: any = null; try { const { getBrowserContext } = await import('../publish/main'); externalBrowserContext = await getBrowserContext('douyin'); Log.info("ipAgent.analyzeDouyinAccount.browserContext", { message: "成功获取共享浏览器上下文" }); } catch (error) { Log.info("ipAgent.analyzeDouyinAccount.browserContext", { message: "未获取到共享浏览器,将使用独立窗口", error: error instanceof Error ? error.message : String(error) }); } // 如果获取到共享浏览器上下文,使用 Puppeteer/Playwright 方式 if (externalBrowserContext) { try { // 使用共享浏览器打开新页面 const page = await externalBrowserContext.newPage(); Log.info("ipAgent.analyzeDouyinAccount.pageCreated", { url }); // 🔧 修复:Playwright 不支持 networkidle2,使用 domcontentloaded 或 networkidle try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); } catch (navigationError) { Log.error("ipAgent.analyzeDouyinAccount.navigationError", { url, error: navigationError?.message }); throw navigationError; } Log.info("ipAgent.analyzeDouyinAccount.pageLoaded", { url }); // 等待页面完全加载 await new Promise(r => setTimeout(r, 5000)); // 执行抓取脚本 - 简化版本,避免正则表达式解析错误 const script = ` (function() { try { // 获取账号名称 - 多种尝试 let accountName = ''; // 尝试1: 查找包含nickname的元素 const nicknameEl = document.querySelector('[class*="nickname"], [class*="account"], h1'); if (nicknameEl) accountName = nicknameEl.textContent?.trim(); // 尝试2: 查找meta标签 if (!accountName) { const metaEl = document.querySelector('meta[property="og:title"]'); if (metaEl) accountName = metaEl.getAttribute('content'); } // 尝试3: 查找页面中较大的文本元素 if (!accountName) { const allHeaders = document.querySelectorAll('h1, h2, .dy-name, [data-testid*="name"], [class*="userName"]'); for (let el of allHeaders) { const text = el.textContent?.trim(); if (text && text.length > 2 && text.length < 30) { accountName = text; break; } } } // 尝试4: 从页面title获取 if (!accountName) { const titleText = document.title; // 简化版本:按'-'分割 const parts = titleText.split('-'); accountName = parts[0]?.trim() || titleText; } // 获取视频列表 const videoTitles = []; const seenTexts = new Set(); const excludeWords = ['抖音号', 'IP属地', '获赞', '粉丝', '关注', '作品', '点赞', '评论', '分享', '收藏', '私信']; const shouldExclude = (text) => { if (!text || text.length < 5 || text.length > 300) return true; return excludeWords.some(word => text.includes(word)); }; // 获取所有 p 标签中的文本 const allElements = document.querySelectorAll('p, span[class*="desc"], div[class*="title"]'); allElements.forEach((el) => { if (videoTitles.length >= 6) return; const text = el.textContent?.trim(); if (text && text.length > 10 && text.length < 500 && !shouldExclude(text) && !seenTexts.has(text)) { videoTitles.push(text); seenTexts.add(text); } }); // 清理标题:移除前导数字 const cleanTitles = videoTitles.map(title => { return title.replace(/^\\d+\\s+/, '').trim(); }).filter(t => t && t.length > 5); return { accountName: accountName || '未知账号', videoTitles: Array.from(new Set(cleanTitles)).slice(0, 6), pageTitle: document.title, pageUrl: window.location.href }; } catch (err) { return { accountName: document.title?.split('-')[0] || '错误', videoTitles: [], pageTitle: document.title, pageUrl: window.location.href, error: err.message }; } })(); `; const data = await page.evaluate(script); await page.close(); // 清理及关闭页面 try { await page.close(); } catch (e) { // 忽略关闭错误 } Log.info("ipAgent.analyzeDouyinAccount.scraped", { accountName: data.accountName, videoCount: data.videoTitles?.length || 0 }); return { success: true, data }; } catch (scrapeError) { Log.error("ipAgent.analyzeDouyinAccount.scrapeError", scrapeError); return { success: false, message: "数据采集失败: " + (scrapeError?.message || scrapeError) }; } } // 关闭之前的窗口(如果存在) if (browserWindow && !browserWindow.isDestroyed()) { browserWindow.destroy(); browserWindow = null; } // Create new browser window browserWindow = new BrowserWindow({ width: 1200, height: 800, webPreferences: { nodeIntegration: false, contextIsolation: true, webSecurity: false, sandbox: true, // 禁用preload,避免dispatch_message错误 preload: undefined } }); // 🔧 修复:阻止窗口关闭时跳转到 about:blank browserWindow.webContents.on('will-navigate', (event, navigationUrl) => { // 如果导航到 about:blank,阻止导航并直接销毁窗口 if (navigationUrl === 'about:blank') { event.preventDefault(); Log.info("ipAgent.analyzeDouyinAccount.preventAboutBlank", { url }); if (browserWindow && !browserWindow.isDestroyed()) { browserWindow.destroy(); browserWindow = null; } } }); browserWindow.loadURL(url); // 监听页面加载完成事件 return new Promise((resolve) => { if (!browserWindow) { resolve({ success: false, message: "Browser window creation failed" }); return; } // 设置超时时间(30秒) const timeout = setTimeout(() => { Log.info("ipAgent.analyzeDouyinAccount.timeout", { url }); if (browserWindow && !browserWindow.isDestroyed()) { // 直接销毁窗口,不使用 close() browserWindow.destroy(); browserWindow = null; } resolve({ success: false, message: "页面加载超时,请检查网络连接或稍后重试" }); }, 30000); // 🔧 修复:添加一个标志,防止多次执行 let isProcessing = false; // 监听页面加载完成 browserWindow.webContents.on("did-finish-load", async () => { // 防止重复执行 if (isProcessing) { Log.warn("ipAgent.analyzeDouyinAccount.alreadyProcessing", { url }); return; } isProcessing = true; clearTimeout(timeout); Log.info("ipAgent.analyzeDouyinAccount.pageLoaded", { url }); // 等待5秒让页面完全渲染(抖音是动态加载的,需要更多时间) await new Promise(r => setTimeout(r, 5000)); try { // 自动执行抓取脚本 - 简化版本,避免正则表达式解析错误 const script = ` (function() { try { // 获取账号名称 - 多种尝试 let accountName = ''; // 尝试1: 查找包含nickname的元素 const nicknameEl = document.querySelector('[class*="nickname"], [class*="account"], h1'); if (nicknameEl) accountName = nicknameEl.textContent?.trim(); // 尝试2: 查找meta标签 if (!accountName) { const metaEl = document.querySelector('meta[property="og:title"]'); if (metaEl) accountName = metaEl.getAttribute('content'); } // 尝试3: 查找页面中较大的文本元素 if (!accountName) { const allHeaders = document.querySelectorAll('h1, h2, .dy-name, [data-testid*="name"], [class*="userName"]'); for (let el of allHeaders) { const text = el.textContent?.trim(); if (text && text.length > 2 && text.length < 30) { accountName = text; break; } } } // 尝试4: 从页面title获取 if (!accountName) { const titleText = document.title; // 简化版本:按'-'分割 const parts = titleText.split('-'); accountName = parts[0]?.trim() || titleText; } // 获取视频列表 const videoTitles = []; const seenTexts = new Set(); const excludeWords = ['抖音号', 'IP属地', '获赞', '粉丝', '关注', '作品', '点赞', '评论', '分享', '收藏', '私信']; const shouldExclude = (text) => { if (!text || text.length < 5 || text.length > 300) return true; return excludeWords.some(word => text.includes(word)); }; // 获取所有 p 和 span 标签中的文本 const allElements = document.querySelectorAll('p, span[class*="desc"], div[class*="title"]'); allElements.forEach((el) => { if (videoTitles.length >= 6) return; const text = el.textContent?.trim(); if (text && text.length > 10 && text.length < 500 && !shouldExclude(text) && !seenTexts.has(text)) { videoTitles.push(text); seenTexts.add(text); } }); // 清理标题:移除前导数字 const cleanTitles = videoTitles.map(title => { return title.replace(/^\\d+\\s+/, '').trim(); }).filter(t => t && t.length > 5); return { accountName: accountName || '未知账号', videoTitles: Array.from(new Set(cleanTitles)).slice(0, 6), pageTitle: document.title, pageUrl: window.location.href }; } catch (err) { return { accountName: document.title?.split('-')[0] || '错误', videoTitles: [], pageTitle: document.title, pageUrl: window.location.href, error: err.message }; } })(); `; const data = await browserWindow!.webContents.executeJavaScript(script); Log.info("ipAgent.analyzeDouyinAccount.scraped", { accountName: data.accountName, videoCount: data.videoTitles?.length || 0 }); // 🔧 修复:强制关闭浏览器窗口(使用 destroy 直接销毁) try { if (browserWindow && !browserWindow.isDestroyed()) { Log.info("ipAgent.analyzeDouyinAccount.destroyingBrowser", { url }); // 直接使用 destroy() 强制销毁窗口,避免跳转到 about:blank browserWindow.destroy(); browserWindow = null; Log.info("ipAgent.analyzeDouyinAccount.browserDestroyed", { message: "浏览器窗口已销毁" }); } } catch (destroyError) { Log.error("ipAgent.analyzeDouyinAccount.destroyBrowserError", destroyError); browserWindow = null; } resolve({ success: true, data }); } catch (scrapeError) { Log.error("ipAgent.analyzeDouyinAccount.scrapeError", scrapeError); // 🔧 修复:确保错误时也强制销毁浏览器窗口 try { if (browserWindow && !browserWindow.isDestroyed()) { Log.info("ipAgent.analyzeDouyinAccount.destroyingBrowserOnError", { url }); browserWindow.destroy(); browserWindow = null; Log.info("ipAgent.analyzeDouyinAccount.browserDestroyedOnError", { message: "错误时浏览器窗口已销毁" }); } } catch (destroyError) { Log.error("ipAgent.analyzeDouyinAccount.destroyBrowserOnErrorError", destroyError); browserWindow = null; } resolve({ success: false, message: "数据采集失败: " + (scrapeError?.message || scrapeError) }); } }); browserWindow.on("closed", () => { clearTimeout(timeout); browserWindow = null; }); }); } catch (error) { Log.error("ipAgent.analyzeDouyinAccount.error", error); // 🔧 修复:捕获异常时也要强制销毁浏览器窗口 try { if (browserWindow && !browserWindow.isDestroyed()) { Log.info("ipAgent.analyzeDouyinAccount.destroyingBrowserOnException", { url }); browserWindow.destroy(); browserWindow = null; Log.info("ipAgent.analyzeDouyinAccount.browserDestroyedOnException", { message: "异常时浏览器窗口已销毁" }); } } catch (destroyError) { Log.error("ipAgent.analyzeDouyinAccount.destroyBrowserOnExceptionError", destroyError); browserWindow = null; } return { success: false, message: "分析失败: " + (error?.message || error) }; } }); // 仿写标题 - 使用配置的文本模型 ipcMain.handle("ipAgent:rewriteTitles", async (event, params: { titles: string[], accountName: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, rewritePrompt: string }) => { try { const { titles, accountName, modelConfig, rewritePrompt } = params; Log.info("ipAgent.rewriteTitles", { accountName, titleCount: titles.length, modelConfig }); if (!modelConfig || !modelConfig.providerId || !modelConfig.modelId) { return { success: false, message: "未配置文本模型,请先在设置中选择模型" }; } const rewrittenTitles: string[] = []; // 构建提示词 - 一次性仿写所有标题 const titlesText = titles.map((t, i) => `${i + 1}. ${t}`).join('\n'); // 使用用户自定义提示词,如果没有则使用默认提示词 let prompt = rewritePrompt; if (!prompt || prompt.trim() === '') { prompt = `参考以下5个标题,帮我生成5个新的标题。要求: 1. 保持相似的主题和风格 2. 标题简洁有力 3. 不要添加表情符号和标签 4. 直接输出5个标题,每行一个,不要编号 参考标题: ${titlesText} 请生成5个新标题:`; } else { // 替换提示词中的变量 prompt = prompt.replace(/\{\{content\}\}/g, titlesText); prompt = prompt.replace(/\{content\}/g, titlesText); } try { // 记录完整的API请求信息 const requestPayload = { model: modelConfig.modelId, messages: [{ role: 'user', content: prompt }] }; Log.info("ipAgent.rewriteTitles.REQUEST", { apiUrl: modelConfig.apiUrl, model: modelConfig.modelId, promptLength: prompt.length, titleCount: titles.length, hasApiKey: !!modelConfig.apiKey }); Log.info("ipAgent.rewriteTitles.REQUEST_FULL_PROMPT", prompt); Log.info("ipAgent.rewriteTitles.REQUEST_FULL_PAYLOAD", JSON.stringify(requestPayload, null, 2)); // 调用文本模型API const response = await fetch(modelConfig.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestPayload) }); if (!response.ok) { const error = await response.text(); Log.error("ipAgent.rewriteTitles.API_ERROR", { status: response.status, errorResponse: error }); throw new Error(`API请求失败: ${response.status}\n${error}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content; // 记录完整的API响应 Log.info("ipAgent.rewriteTitles.RESPONSE_FULL", { rawResponse: JSON.stringify(data, null, 2) }); Log.info("ipAgent.rewriteTitles.RESPONSE_CONTENT", content); if (!content) { Log.error("ipAgent.rewriteTitles.EMPTY_CONTENT", { response: JSON.stringify(data, null, 2) }); throw new Error('模型返回内容为空'); } // 解析返回的标题(每行一个) const lines = content.trim().split('\n').filter(line => line.trim()); // 清理可能的编号前缀(如"1. ", "1、", "- "等) const cleanedTitles = lines.map(line => { return line.replace(/^[\d\s\.\-、]+/, '').trim(); }); // 确保返回的标题数量与输入一致 if (cleanedTitles.length >= titles.length) { rewrittenTitles.push(...cleanedTitles.slice(0, titles.length)); } else { // 如果返回的标题不够,用原标题填充 rewrittenTitles.push(...cleanedTitles); for (let i = cleanedTitles.length; i < titles.length; i++) { rewrittenTitles.push(titles[i]); } } Log.info("ipAgent.rewriteTitles.success", { originalCount: titles.length, rewrittenCount: rewrittenTitles.length }); AuthMain.reportGeneration('script', `仿写标题:${titles.length}→${rewrittenTitles.length}个`); } catch (e) { Log.error("ipAgent.rewriteTitles.api.error", e); // API调用失败时返回原标题 return { success: false, message: "AI模型调用失败: " + (e?.message || e), rewrittenTitles: titles }; } return { success: true, rewrittenTitles }; } catch (error) { Log.error("ipAgent.rewriteTitles.error", error); return { success: false, message: "仿写失败: " + (error?.message || error), rewrittenTitles: params.titles // 失败时返回原标题 }; } }); // 保存设置 ipcMain.handle("ipAgent:saveSettings", async (event, params: { settings: any }) => { try { const { settings } = params; Log.info("ipAgent.saveSettings", { settings }); ensureSettingsDir(); const settingsFilePath = getSettingsFilePath(); fs.writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2), 'utf-8'); await ConfigMain.set('ipAgentSettings', JSON.stringify(settings)); Log.info("ipAgent.saveSettings.success", { path: settingsFilePath }); return { success: true }; } catch (error) { Log.error("ipAgent.saveSettings.error", error); return { success: false, message: "保存设置失败: " + (error?.message || error) }; } }); // 获取设置 ipcMain.handle("ipAgent:getSettings", async (event) => { try { Log.info("ipAgent.getSettings"); const settingsFilePath = getSettingsFilePath(); // 如果文件不存在,尝试加载并保存默认配置 if (!fs.existsSync(settingsFilePath)) { Log.info("ipAgent.getSettings.notFound", { path: settingsFilePath }); // 🆕 首次运行:尝试加载默认配置 try { // 获取默认配置文件路径(从打包资源中) const defaultConfigPath = isDev ? path.join(app.getAppPath(), 'electron', 'config', 'default-ipagent-config.json') : path.join(process.resourcesPath, 'extra', 'common', 'config', 'default-ipagent-config.json'); if (fs.existsSync(defaultConfigPath)) { Log.info("ipAgent.getSettings.loadDefaultConfig", { path: defaultConfigPath }); // 读取默认配置 const defaultContent = fs.readFileSync(defaultConfigPath, 'utf-8'); const defaultSettings = JSON.parse(defaultContent); // 确保目录存在并保存到用户设置文件 ensureSettingsDir(); fs.writeFileSync(settingsFilePath, defaultContent, 'utf-8'); Log.info("ipAgent.getSettings.defaultConfigLoaded", { savedTo: settingsFilePath, hasSubtitleTemplates: !!defaultSettings.subtitleTemplates, hasFrontendState: !!defaultSettings.frontendState }); return { success: true, settings: defaultSettings, isFirstRun: true }; } else { Log.warn("ipAgent.getSettings.defaultConfigNotFound", { path: defaultConfigPath }); } } catch (defaultError) { Log.warn("ipAgent.getSettings.loadDefaultConfigError", defaultError); } // 如果没有默认配置或加载失败,返回null return { success: true, settings: null }; } // 读取并解析JSON文件 const content = fs.readFileSync(settingsFilePath, 'utf-8'); const settings = JSON.parse(content); const normalized = normalizeIpAgentSettings(settings); if (normalized.changed) { ensureSettingsDir(); fs.writeFileSync(settingsFilePath, JSON.stringify(normalized.settings, null, 2), 'utf-8'); Log.info("ipAgent.getSettings.migratedTextModelKey", { path: settingsFilePath, textModelKey: normalized.settings.textModelKey }); } Log.info("ipAgent.getSettings.success", { path: settingsFilePath }); return { success: true, settings: normalized.settings }; } catch (error) { Log.error("ipAgent.getSettings.error", error); return { success: false, message: "获取设置失败: " + (error?.message || error) }; } }); // 获取最近生成的结果文件 ipcMain.handle("ipAgent:getResultFilesRecently", async (event, params: { type: 'audio' | 'video', limit?: number }) => { try { const { type, limit = 5 } = params; const outputDir = getOutputDir(type === 'audio' ? 'audio' : 'video'); Log.info("ipAgent.getResultFilesRecently", { type, limit, outputDir }); if (!fs.existsSync(outputDir)) { return []; } // 定义文件扩展名 const extensions = type === 'audio' ? ['.mp3', '.wav', '.m4a', '.aac', '.ogg'] : ['.mp4', '.mkv', '.avi', '.mov', '.webm']; // 获取目录下所有匹配的文件 const allFiles: { path: string; mtime: number }[] = []; const scanDir = (dir: string) => { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { scanDir(fullPath); } else if (extensions.some(ext => entry.name.toLowerCase().endsWith(ext))) { const stats = fs.statSync(fullPath); allFiles.push({ path: fullPath, mtime: stats.mtime.getTime() }); } } }; scanDir(outputDir); const files = allFiles .sort((a, b) => b.mtime - a.mtime) .slice(0, limit) .map(f => f.path); Log.info("ipAgent.getResultFilesRecently.result", { count: files.length, files }); return files; } catch (error) { Log.error("ipAgent.getResultFilesRecently.error", error); return []; } }); // 生成文案 - 根据标题使用AI模型生成文案 ipcMain.handle("ipAgent:generateScript", async (event, params: { title: string, count: number, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, scriptPrompt: string }) => { try { const { title, count, modelConfig, scriptPrompt } = params; Log.info("ipAgent.generateScript", { title, count, modelConfig }); if (!modelConfig || !modelConfig.providerId || !modelConfig.modelId) { return { success: false, message: "未配置文本模型,请先在设置中选择模型" }; } // 使用用户自定义提示词,如果没有则使用默认提示词 let prompt = scriptPrompt; if (!prompt || prompt.trim() === '') { prompt = `请根据以下标题,生成一篇约{count}字的短视频文案。要求: 1. 内容有趣、生动、吸引人 2. 适合短视频口播 3. 语言简洁易懂 4. 直接输出文案内容,不要其他说明 标题:{title} 请生成文案:`; } // 替换提示词中的变量 prompt = prompt.replace(/\{title\}/g, title); prompt = prompt.replace(/\{\{title\}\}/g, title); prompt = prompt.replace(/\{count\}/g, count.toString()); prompt = prompt.replace(/\{\{count\}\}/g, count.toString()); try { // 记录完整的API请求信息 const requestPayload = { model: modelConfig.modelId, messages: [{ role: 'user', content: prompt }] }; Log.info("ipAgent.generateScript.REQUEST", { apiUrl: modelConfig.apiUrl, model: modelConfig.modelId, promptLength: prompt.length, hasApiKey: !!modelConfig.apiKey }); Log.info("ipAgent.generateScript.REQUEST_FULL_PROMPT", prompt); Log.info("ipAgent.generateScript.REQUEST_FULL_PAYLOAD", JSON.stringify(requestPayload, null, 2)); // 调用文本模型API const response = await fetch(modelConfig.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestPayload) }); if (!response.ok) { const error = await response.text(); Log.error("ipAgent.generateScript.API_ERROR", { status: response.status, errorResponse: error }); throw new Error(`API请求失败: ${response.status}\n${error}`); } const data = await response.json(); const script = data.choices?.[0]?.message?.content; // 记录完整的API响应 Log.info("ipAgent.generateScript.RESPONSE_FULL", { rawResponse: JSON.stringify(data, null, 2) }); Log.info("ipAgent.generateScript.RESPONSE_CONTENT", script); if (!script) { Log.error("ipAgent.generateScript.EMPTY_CONTENT", { response: JSON.stringify(data, null, 2) }); throw new Error('模型返回内容为空'); } Log.info("ipAgent.generateScript.success", { titleLength: title.length, scriptLength: script.length }); AuthMain.reportGeneration('script', `标题:${title} 字数:${script.length}`); return { success: true, script: script.trim() }; } catch (e) { Log.error("ipAgent.generateScript.api.error", e); return { success: false, message: "AI模型调用失败: " + (e?.message || e) }; } } catch (error) { Log.error("ipAgent.generateScript.error", error); return { success: false, message: "生成文案失败: " + (error?.message || error) }; } }); // 生成标题标签关键词 - 根据文案生成 ipcMain.handle("ipAgent:generateTitleTags", async (event, params: { content?: string, script?: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, titleTagPrompt?: string, titleTagsPrompt?: string }) => { try { // 兼容前端传递的参数名称(content或script) const script = params.content || params.script; const titleTagsPrompt = params.titleTagPrompt || params.titleTagsPrompt; const { modelConfig } = params; Log.info("ipAgent.generateTitleTags", { scriptLength: script.length, modelConfig }); if (!modelConfig || !modelConfig.providerId || !modelConfig.modelId) { return { success: false, message: "未配置文本模型,请先在设置中选择模型" }; } // 使用用户自定义提示词,如果没有则使用默认提示词 let prompt = titleTagsPrompt; if (!prompt || prompt.trim() === '') { prompt = `请根据以下文案内容,生成: 1. 一个吸引人的标题(不超过30字) 2. 3-5个相关话题标签(格式:#话题) 3. 5-8个关键词 文案内容: {content} 请按以下格式输出: 标题:xxx 标签:#xxx #xxx #xxx 关键词:xxx, xxx, xxx`; } // 替换提示词中的变量 prompt = prompt.replace(/\{content\}/g, script); prompt = prompt.replace(/\{\{content\}\}/g, script); try { // 记录完整的API请求信息 const requestPayload = { model: modelConfig.modelId, messages: [{ role: 'user', content: prompt }] }; Log.info("ipAgent.generateTitleTags.REQUEST", { apiUrl: modelConfig.apiUrl, model: modelConfig.modelId, promptLength: prompt.length, hasApiKey: !!modelConfig.apiKey }); Log.info("ipAgent.generateTitleTags.REQUEST_FULL_PROMPT", prompt); Log.info("ipAgent.generateTitleTags.REQUEST_FULL_PAYLOAD", JSON.stringify(requestPayload, null, 2)); // 调用文本模型API const response = await fetch(modelConfig.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestPayload) }); if (!response.ok) { const error = await response.text(); Log.error("ipAgent.generateTitleTags.API_ERROR", { status: response.status, errorResponse: error }); throw new Error(`API请求失败: ${response.status}\n${error}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content; // 记录完整的API响应 Log.info("ipAgent.generateTitleTags.RESPONSE_FULL", { rawResponse: JSON.stringify(data, null, 2) }); Log.info("ipAgent.generateTitleTags.RESPONSE_CONTENT", content); if (!content) { Log.error("ipAgent.generateTitleTags.EMPTY_CONTENT", { response: JSON.stringify(data, null, 2) }); throw new Error('模型返回内容为空'); } Log.info("ipAgent.generateTitleTags.rawContent", { content }); // 尝试解析JSON格式的返回内容 let title = ''; let tags = ''; let keywords = ''; try { // 清理可能的markdown代码块标记 let cleanedContent = content.trim(); cleanedContent = cleanedContent.replace(/```json\s*/gi, ''); cleanedContent = cleanedContent.replace(/```\s*/g, ''); cleanedContent = cleanedContent.trim(); // 替换中文引号为英文引号 cleanedContent = cleanedContent.replace(/"/g, '"'); cleanedContent = cleanedContent.replace(/"/g, '"'); cleanedContent = cleanedContent.replace(/'/g, "'"); cleanedContent = cleanedContent.replace(/'/g, "'"); // 替换中文逗号为英文逗号 cleanedContent = cleanedContent.replace(/,/g, ','); // 尝试提取JSON对象(如果有多余的文本) const jsonMatch = cleanedContent.match(/\{[\s\S]*\}/); if (jsonMatch) { cleanedContent = jsonMatch[0]; } Log.info("ipAgent.generateTitleTags.cleanedContent", { cleanedContent }); // 解析JSON const parsed = JSON.parse(cleanedContent); title = parsed.title || ''; // 保持数组格式,让前端自己处理 tags = parsed.tags || []; keywords = parsed.keywords || []; Log.info("ipAgent.generateTitleTags.parsed", { title, tags, keywords }); } catch (parseError) { // 如果JSON解析失败,尝试传统的行分割方式 Log.info("ipAgent.generateTitleTags.jsonParseFailed", { error: parseError.message, content }); const lines = content.split('\n'); for (const line of lines) { if (line.includes('标题') || line.includes('Title')) { title = line.split(/[::]/)[1]?.trim() || ''; } else if (line.includes('标签') || line.includes('Tags') || line.includes('#')) { tags = line.split(/[::]/)[1]?.trim() || line.trim(); } else if (line.includes('关键词') || line.includes('Keywords')) { keywords = line.split(/[::]/)[1]?.trim() || ''; } } } // 如果解析后仍然为空,返回原始内容以供调试 if (!title && !tags && !keywords) { Log.error("ipAgent.generateTitleTags.emptyResult", { content }); return { success: false, message: '解析结果为空,AI返回内容: ' + content }; } Log.info("ipAgent.generateTitleTags.success", { title, tags, keywords }); AuthMain.reportGeneration('script', `标题标签关键词:${title}`); return { success: true, titleTags: JSON.stringify({ title, tags, keywords }), // 同时提供直接字段方便访问 title, tags: Array.isArray(tags) ? tags.join(', ') : tags, keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords }; } catch (e) { Log.error("ipAgent.generateTitleTags.api.error", e); return { success: false, message: "AI模型调用失败: " + (e?.message || e) }; } } catch (error) { Log.error("ipAgent.generateTitleTags.error", error); return { success: false, message: "生成标题标签失败: " + (error?.message || error) }; } }); // 03视频编辑 - 自动处理(静音剪辑) ipcMain.handle("ipAgent:processSilenceDetection", async (event, params: { inputVideo: string, silenceThreshold?: number, silenceDuration?: number, minPauseDuration?: number }) => { try { // 🔧 改进:使用新的默认参数(用户已反馈-40dB, 1秒是最佳值) let { inputVideo, silenceThreshold = -40, silenceDuration = 1, minPauseDuration = 0.15 } = params; // 验证输入参数 if (!inputVideo || typeof inputVideo !== 'string') { Log.error("ipAgent.processSilenceDetection.invalidInput", { inputVideo }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 inputVideo = inputVideo.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(inputVideo)) { Log.error("ipAgent.processSilenceDetection.fileNotFound", { inputVideo }); return { success: false, message: "视频文件不存在: " + inputVideo }; } if (/[\u4e00-\u9fa5]/.test(inputVideo) || inputVideo.includes(' ')) { inputVideo = getShortPath(inputVideo); } Log.info("ipAgent.processSilenceDetection", { inputVideo, silenceThreshold, silenceDuration, minPauseDuration, message: "开始处理视频..." }); const outputDir = getOutputDir('video'); const timestamp = Date.now(); const outputVideo = path.join(outputDir, `video_processed_${timestamp}.mp4`); // 🔧 改进的FFmpeg命令:silenceremove + adelay组合 // 参数说明: // - stop_periods=-1: 删除所有停顿 // - stop_duration=${silenceDuration}: 只删除超过此时长的沉默 ✓ // - stop_threshold=${silenceThreshold}dB: 沉默阈值 ✓ // - adelay=${minPauseDuration*1000}ms: 保留自然停顿 ✓ const minPauseDurationMs = Math.round(minPauseDuration * 1000); const audioFilter = `silenceremove=stop_periods=-1:stop_duration=${silenceDuration}:stop_threshold=${silenceThreshold}dB,adelay=${minPauseDurationMs}ms|${minPauseDurationMs}ms`; const ffmpegArgs = [ '-i', String(inputVideo), '-af', audioFilter, '-c:v', 'copy', '-y', // 覆盖已存在的文件 String(outputVideo) ]; // 打印详细日志以便调试 Log.info("ipAgent.processSilenceDetection.command", { audioFilter, args: ffmpegArgs, description: `删除超过${silenceDuration}秒且音量低于${silenceThreshold}dB的停顿,保留${minPauseDurationMs}ms的自然停顿` }); await execFFmpegCommand(ffmpegArgs); Log.info("ipAgent.processSilenceDetection.success", { outputVideo, message: "视频处理完成!已删除不必要的停顿。" }); return { success: true, outputVideo, message: "视频处理完成!" }; } catch (error) { Log.error("ipAgent.processSilenceDetection.error", error); return { success: false, message: "视频处理失败: " + (error?.message || error) }; } }); // 03视频编辑 - 绿幕替换(chromakey) ipcMain.handle("ipAgent:processGreenScreen", async (event, params: { inputVideo: string, backgroundImage: string, similarity?: number, blend?: number }) => { try { let { inputVideo, backgroundImage, similarity = 0.28, blend = 0.05 } = params; // 验证输入参数 if (!inputVideo || typeof inputVideo !== 'string') { Log.error("ipAgent.processGreenScreen.invalidInput", { inputVideo }); return { success: false, message: "无效的视频路径" }; } if (!backgroundImage || typeof backgroundImage !== 'string') { Log.error("ipAgent.processGreenScreen.invalidBackground", { backgroundImage }); return { success: false, message: "无效的背景图片路径" }; } // 移除file://前缀 inputVideo = inputVideo.replace(/^file:\/\//, ''); backgroundImage = backgroundImage.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(inputVideo)) { Log.error("ipAgent.processGreenScreen.videoNotFound", { inputVideo }); return { success: false, message: "视频文件不存在: " + inputVideo }; } if (!fs.existsSync(backgroundImage)) { Log.error("ipAgent.processGreenScreen.imageNotFound", { backgroundImage }); return { success: false, message: "背景图片不存在: " + backgroundImage }; } if (/[\u4e00-\u9fa5]/.test(inputVideo) || inputVideo.includes(' ')) { inputVideo = getShortPath(inputVideo); } if (/[\u4e00-\u9fa5]/.test(backgroundImage) || backgroundImage.includes(' ')) { backgroundImage = getShortPath(backgroundImage); } Log.info("ipAgent.processGreenScreen", { inputVideo, backgroundImage, similarity, blend, message: "开始绿幕替换处理..." }); const outputDir = getOutputDir('video'); const timestamp = Date.now(); const outputVideo = path.join(outputDir, `video_greenscreen_${timestamp}.mp4`); // FFmpeg chromakey 绿幕替换: // 输入0: 绿幕视频, 输入1: 背景图片(-loop 1) // scale2ref: 把背景图[1:v]缩放到视频[0:v]的尺寸 // chromakey: 去除视频中的绿色 // overlay: 把去绿后的视频叠加到缩放后的背景上 const filterComplex = [ `[1:v][0:v]scale2ref=flags=lanczos[bg][vid]`, `[vid]format=yuva444p,chromakey=0x00FF00:0.28:0.05,chromakey=0x40FF40:0.12:0.15[fg]`, `[bg][fg]overlay=0:0:shortest=1,unsharp=3:3:0.5:3:3:0.5,format=yuv420p[out]` ].join(';'); const ffmpegArgs = [ '-i', String(inputVideo), // 输入0:绿幕视频 '-loop', '1', // 循环图片 '-i', String(backgroundImage), // 输入1:背景图片 '-filter_complex', filterComplex, '-map', '[out]', '-map', '0:a?', // 保留原视频音频 '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-c:a', 'aac', '-b:a', '128k', '-shortest', '-y', String(outputVideo) ]; Log.info("ipAgent.processGreenScreen.command", { filterComplex, args: ffmpegArgs }); await execFFmpegCommand(ffmpegArgs); Log.info("ipAgent.processGreenScreen.success", { outputVideo, message: "绿幕替换完成!" }); return { success: true, outputVideo, message: "绿幕替换完成!" }; } catch (error) { Log.error("ipAgent.processGreenScreen.error", error); return { success: false, message: "绿幕替换失败: " + (error?.message || error) }; } }); // 从视频提取音频(不使用缓存,每次都重新提取) ipcMain.handle("ipAgent:extractAudioFromVideo", async (event, params: { videoPath: string }) => { try { let { videoPath } = params; // 验证输入参数 if (!videoPath || typeof videoPath !== 'string') { Log.error("ipAgent.extractAudioFromVideo.invalidInput", { videoPath }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 videoPath = videoPath.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(videoPath)) { Log.error("ipAgent.extractAudioFromVideo.fileNotFound", { videoPath }); return { success: false, message: "视频文件不存在: " + videoPath }; } if (/[\u4e00-\u9fa5]/.test(videoPath) || videoPath.includes(' ')) { videoPath = getShortPath(videoPath); } Log.info("ipAgent.extractAudioFromVideo", { videoPath, note: "不使用缓存,每次都重新提取" }); const outputDir = getOutputDir('audio'); // 使用时间戳作为音频文件名,确保每次都生成新文件 const timestamp = Date.now(); const audioPath = path.join(outputDir, `audio_${timestamp}.wav`); Log.info("ipAgent.extractAudioFromVideo.extracting", { audioPath }); // 提取音频为WAV格式 const ffmpegArgs = [ '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1', audioPath ]; await execFFmpegCommand(ffmpegArgs); Log.info("ipAgent.extractAudioFromVideo.success", { audioPath, timestamp, message: "音频提取完成(不使用缓存)" }); return { success: true, audioPath, timestamp, cached: false, message: "音频提取完成" }; } catch (error) { Log.error("ipAgent.extractAudioFromVideo.error", error); return { success: false, message: "提取音频失败: " + (error?.message || error) }; } }); // 增强版音频提取(不使用缓存,每次都重新提取) ipcMain.handle("ipAgent:extractAudioFromVideoEnhanced", async (event, params: { videoPath: string, format?: string, quality?: string, sampleRate?: string, outputPath?: string }) => { try { let { videoPath, format = 'wav', quality = '320', sampleRate = '44100', outputPath } = params; // 验证输入参数 if (!videoPath || typeof videoPath !== 'string') { Log.error("ipAgent.extractAudioFromVideoEnhanced.invalidInput", { videoPath }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 videoPath = videoPath.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(videoPath)) { Log.error("ipAgent.extractAudioFromVideoEnhanced.fileNotFound", { videoPath }); return { success: false, message: "视频文件不存在: " + videoPath }; } if (/[\u4e00-\u9fa5]/.test(videoPath) || videoPath.includes(' ')) { videoPath = getShortPath(videoPath); } Log.info("ipAgent.extractAudioFromVideoEnhanced", { videoPath, format, quality, sampleRate, note: "不使用缓存,每次都重新提取" }); // 使用时间戳作为音频文件名,确保每次都生成新文件 const timestamp = Date.now(); let finalOutputPath: string; if (outputPath) { // 如果指定了输出路径,直接使用(不再添加额外时间戳) finalOutputPath = outputPath; } else { finalOutputPath = path.join(getOutputDir('audio'), `audio_enhanced_${timestamp}.${format}`); } Log.info("ipAgent.extractAudioFromVideoEnhanced.extracting", { audioPath: finalOutputPath }); // 根据格式选择编码器和参数 const ffmpegArgs = ['-i', videoPath, '-vn']; if (format === 'wav') { ffmpegArgs.push('-acodec', 'pcm_s16le'); } else if (format === 'mp3') { ffmpegArgs.push('-acodec', 'libmp3lame', '-b:a', `${quality}k`); } ffmpegArgs.push('-ar', sampleRate, '-ac', '2', finalOutputPath); await execFFmpegCommand(ffmpegArgs); Log.info("ipAgent.extractAudioFromVideoEnhanced.success", { outputPath: finalOutputPath, timestamp, message: "音频提取完成(不使用缓存)" }); return { success: true, audioPath: finalOutputPath, timestamp, cached: false, message: "音频提取完成" }; } catch (error) { Log.error("ipAgent.extractAudioFromVideoEnhanced.error", error); return { success: false, message: "提取音频失败: " + (error?.message || error) }; } }); // 从ASR结果生成SRT文件(不使用缓存,每次都重新生成) // 注意:如果需要自定义样式,建议使用ASS格式 ipcMain.handle("ipAgent:generateSrtFromAsrResult", async (event, params: { videoPath: string, asrRecords: any[], maxCharsPerLine?: number, audioPath?: string, timestamp?: number, originalScript?: string, subtitleStyle?: any, // 添加样式参数 keywords?: string[], // 添加关键词参数 keywordStyle?: any, // 添加关键词样式参数 keywordGroups?: any[], // 添加关键词分组参数 enableKeywordEffects?: boolean // 是否启用关键词特效 }) => { try { const { videoPath, asrRecords, maxCharsPerLine = 18, audioPath, timestamp, originalScript, subtitleStyle, keywords, keywordStyle, keywordGroups, enableKeywordEffects } = params; Log.info("ipAgent.generateSrtFromAsrResult", { videoPath, recordCount: asrRecords.length, maxCharsPerLine, hasOriginalScript: !!originalScript, hasSubtitleStyle: !!subtitleStyle, subtitleStyleDetails: subtitleStyle ? { fontName: subtitleStyle.fontName, fontNameType: typeof subtitleStyle.fontName, backgroundColor: subtitleStyle.backgroundColor, backgroundOpacity: subtitleStyle.backgroundOpacity, backgroundOpacityType: typeof subtitleStyle.backgroundOpacity, fontSize: subtitleStyle.fontSize, fontColor: subtitleStyle.fontColor } : null, hasKeywords: !!(keywords && keywords.length > 0), keywordCount: keywords?.length || 0, hasKeywordGroups: !!(keywordGroups && keywordGroups.length > 0), keywordGroupCount: keywordGroups?.length || 0, enableKeywordEffects: enableKeywordEffects || false, note: "SRT字幕不使用缓存,每次都重新生成" }); // 关键修复:详细记录接收到的keywordGroups if (keywordGroups && keywordGroups.length > 0) { Log.info("ipAgent.generateSrtFromAsrResult.keywordGroupsReceived", { groupCount: keywordGroups.length, groups: keywordGroups.map((g: any) => ({ id: g.id, name: g.name, keywordCount: g.keywords?.length || 0, effectId: g.effectId, hasStyleOverride: !!g.styleOverride, styleOverride: g.styleOverride ? { fontColor: g.styleOverride.fontColor, outlineColor: g.styleOverride.outlineColor, fontSize: g.styleOverride.fontSize, fontName: g.styleOverride.fontName, outlineWidth: g.styleOverride.outlineWidth } : null })) }); } const outputDir = getOutputDir('subtitle'); // 这样可以确保基础字幕样式修改后重新生成时能够生效 const finalTimestamp = Date.now(); // 如果提供了样式参数,生成ASS格式(样式更可靠) // 否则生成SRT格式 const useAssFormat = !!subtitleStyle; const subtitlePath = path.join(outputDir, useAssFormat ? `subtitle_${finalTimestamp}.ass` : `subtitle_${finalTimestamp}.srt`); Log.info("ipAgent.generateSrtFromAsrResult.generating", { subtitlePath, format: useAssFormat ? 'ASS' : 'SRT', willApplyKeywordEffects: !!(keywords && keywords.length > 0 && keywordStyle) }); if (useAssFormat) { // 生成ASS格式字幕(支持完整样式和关键词特效) // 如果启用了关键词特效,使用关键词分组信息;否则使用单一关键词样式 const assContent = enableKeywordEffects && keywordGroups && keywordGroups.length > 0 ? generateAssSubtitleWithKeywordGroups(asrRecords, subtitleStyle, maxCharsPerLine, keywordGroups) : generateAssSubtitle(asrRecords, subtitleStyle, maxCharsPerLine, keywords, keywordStyle); // 提取样式行用于详细检查 const styleLineMatch = assContent.match(/Style:\s*Default,([^\n]+)/); const styleLine = styleLineMatch ? styleLineMatch[1] : 'NOT_FOUND'; // 记录生成的ASS文件内容(前1000字符,用于调试) const previewContent = assContent.substring(0, 1000); // 解析样式行中的关键字段 // 注意:styleLine 是去掉了 "Style: Default," 前缀的部分,所以: // 索引0=Fontname, 索引1=Fontsize, 索引6=BackColour, 索引14=BorderStyle const styleParts = styleLine.split(','); const extractedFontName = styleParts[0] || 'NOT_FOUND'; // ASS格式字段顺序(去掉"Style: Default,"后): // 0=Fontname, 1=Fontsize, 2=PrimaryColour, 3=SecondaryColour, 4=OutlineColour, 5=BackColour, ... const extractedBackColor = styleParts[5] || 'NOT_FOUND'; // BackColour是第6个字段(索引5) const extractedBorderStyle = styleParts[14] || 'NOT_FOUND'; // BorderStyle是第15个字段(索引14) // 验证解析结果 let verifiedBackColor = extractedBackColor; if (extractedBackColor === 'NOT_FOUND' || extractedBackColor.length < 3 || !extractedBackColor.startsWith('&H')) { // 如果解析失败,尝试直接从ASS内容中提取BackColour const backColorMatch = assContent.match(/Style:\s*Default,[^,]+,[^,]+,[^,]+,[^,]+,[^,]+,\s*([^,]+)/); if (backColorMatch) { verifiedBackColor = backColorMatch[1].trim(); } Log.info("ipAgent.generateSrtFromAsrResult.styleParsingError", { styleLine: styleLine, stylePartsCount: styleParts.length, extractedBackColor: extractedBackColor, verifiedBackColor: verifiedBackColor, allParts: styleParts.slice(0, 10).map((p, i) => `${i}:${p}`).join(', ') }); } Log.info("ipAgent.generateSrtFromAsrResult.assContent", { preview: previewContent, fullLength: assContent.length, styleLine: styleLine, extractedFontName: extractedFontName, extractedBackColor: verifiedBackColor, extractedBorderStyle: extractedBorderStyle, expectedFontName: subtitleStyle?.fontName, expectedBackgroundColor: subtitleStyle?.backgroundColor, expectedBackgroundOpacity: subtitleStyle?.backgroundOpacity, containsFontName: assContent.includes(String(subtitleStyle?.fontName || '')), containsBackColor: assContent.includes('BackColour'), containsBorderStyle: assContent.includes('BorderStyle') }); fs.writeFileSync(subtitlePath, assContent, 'utf-8'); // 额外保存一个调试版本的ASS文件(包含更多信息) if (isDev) { const debugAssPath = subtitlePath.replace('.ass', '_debug.ass'); const debugContent = `[Debug Info] Generated at: ${new Date().toISOString()} Expected FontName: ${subtitleStyle?.fontName} Expected BackgroundColor: ${subtitleStyle?.backgroundColor} Expected BackgroundOpacity: ${subtitleStyle?.backgroundOpacity} Actual FontName in Style: ${extractedFontName} Actual BackColor in Style: ${verifiedBackColor} Actual BorderStyle in Style: ${extractedBorderStyle} ${assContent}`; try { fs.writeFileSync(debugAssPath, debugContent, 'utf-8'); Log.info("ipAgent.generateSrtFromAsrResult.debugFile", { path: debugAssPath }); } catch (e) { Log.info("ipAgent.generateSrtFromAsrResult.debugFileError", { error: e }); } } Log.info("ipAgent.generateSrtFromAsrResult.success", { subtitlePath, format: 'ASS', timestamp: finalTimestamp, keywordsApplied: !!(keywords && keywords.length > 0), fontName: subtitleStyle?.fontName, backgroundColor: subtitleStyle?.backgroundColor, backgroundOpacity: subtitleStyle?.backgroundOpacity, message: "ASS字幕文件生成完成(支持完整样式和关键词特效)" }); return { success: true, srtPath: subtitlePath, // 保持字段名兼容性 timestamp: finalTimestamp, cached: false, format: 'ASS', message: "ASS字幕文件生成完成" }; } // 生成SRT内容 let srtContent = ''; let index = 1; for (const record of asrRecords) { // ASR返回的时间可能是毫秒,需要转换为秒 let startTime = record.start || record.begin || 0; let endTime = record.end || startTime + 1; // 检测时间单位:如果大于100,认为是毫秒(因为视频一般不会超过100秒才开始说话) // 更保守的阈值,避免误判 if (startTime > 100) { startTime = startTime / 1000; } if (endTime > 100) { endTime = endTime / 1000; } // 记录日志以便调试 if (index === 1) { Log.info("ipAgent.generateSrtFromAsrResult.firstRecord", { originalStart: record.start || record.begin, originalEnd: record.end, convertedStart: startTime, convertedEnd: endTime }); } let text = record.text || record.sentence || ''; // 如果提供了原始文案,尝试校正ASR识别结果 if (originalScript && text) { // 简单的文本匹配和替换逻辑 text = text.trim(); } // 去除所有标点符号(中英文标点) text = text.replace(/[,。!?;:、""''()《》【】…—·,.!?;:'"()\[\]{}<>]/g, ''); text = text.trim(); // 如果去除标点后文本为空,跳过这条记录 if (!text) { continue; } // 按maxCharsPerLine分割文本 const lines = []; while (text.length > maxCharsPerLine) { lines.push(text.substring(0, maxCharsPerLine)); text = text.substring(maxCharsPerLine); } if (text.length > 0) { lines.push(text); } // 格式化时间(输入为秒) // SRT格式:HH:MM:SS,mmm(注意:毫秒用逗号分隔,不是冒号!) const formatTime = (seconds: number) => { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 1000); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`; }; srtContent += `${index}\n`; srtContent += `${formatTime(startTime)} --> ${formatTime(endTime)}\n`; srtContent += lines.join('\n') + '\n\n'; index++; } // 写入SRT文件 fs.writeFileSync(subtitlePath, srtContent, 'utf-8'); Log.info("ipAgent.generateSrtFromAsrResult.success", { subtitlePath, format: 'SRT', timestamp: finalTimestamp, message: "SRT字幕文件生成完成(不使用缓存)" }); return { success: true, srtPath: subtitlePath, // 保持字段名兼容性 timestamp: finalTimestamp, cached: false, format: 'SRT', message: "SRT字幕文件生成完成" }; } catch (error) { Log.error("ipAgent.generateSrtFromAsrResult.error", { error: error, errorMessage: error?.message, errorStack: error?.stack, errorString: String(error) }); return { success: false, message: "生成字幕文件失败: " + (error?.message || String(error) || "未知错误") }; } }); // 添加字幕到视频 ipcMain.handle("ipAgent:addSubtitleToVideo", async (event, params: { inputVideo: string, srtPath: string, subtitleStyle: any, highlightWords?: string[], keywordStyle?: any, keywords?: any[], keywordMarkers?: any[], keywordGroups?: any[], enableTopTitle?: boolean, topTitleStyle?: any, topTitleText?: string }) => { try { let { inputVideo, srtPath, subtitleStyle, highlightWords = [], keywordStyle, keywords = [], keywordMarkers = [], keywordGroups = [], enableTopTitle = false, topTitleStyle, topTitleText } = params; // 验证输入参数 if (!inputVideo || typeof inputVideo !== 'string') { Log.error("ipAgent.addSubtitleToVideo.invalidInput", { inputVideo }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 inputVideo = inputVideo.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(inputVideo)) { Log.error("ipAgent.addSubtitleToVideo.fileNotFound", { inputVideo }); return { success: false, message: "视频文件不存在: " + inputVideo }; } if (!srtPath || !fs.existsSync(srtPath)) { Log.error("ipAgent.addSubtitleToVideo.srtNotFound", { srtPath }); return { success: false, message: "字幕文件不存在" }; } if (/[\u4e00-\u9fa5]/.test(inputVideo) || inputVideo.includes(' ')) { inputVideo = getShortPath(inputVideo); } if (/[\u4e00-\u9fa5]/.test(srtPath) || srtPath.includes(' ')) { srtPath = getShortPath(srtPath); } Log.info("ipAgent.addSubtitleToVideo", { inputVideo, srtPath, subtitleStyle, highlightWords, keywordStyle, keywords: keywords.length, enableTopTitle, isAssFile: srtPath.endsWith('.ass') }); const outputDir = getOutputDir('video'); const timestamp = Date.now(); const outputVideo = path.join(outputDir, `video_with_subtitle_${timestamp}.mp4`); // 检查是否是ASS文件(情感分析字幕) const isAssFile = srtPath.endsWith('.ass'); let ffmpegArgs: string[]; if (isAssFile) { // ASS文件:直接使用,不覆盖样式(保留情感分析特效) // 重要:必须重新编码视频,否则字幕不会显示 Log.info("ipAgent.addSubtitleToVideo.usingAssFile", { srtPath }); // 修复:从ASS文件中提取所有字体名称(不仅是Default样式),并确保字体文件可被FFmpeg访问 let fontNames: string[] = []; try { const assContent = fs.readFileSync(srtPath, 'utf-8'); // 提取所有Style定义中的字体名称 const styleRegex = /Style:\s*[^,]+,([^,]+)/g; let match; while ((match = styleRegex.exec(assContent)) !== null) { let fontName = match[1].trim(); // 如果字体名称被引号包裹,去掉引号 fontName = fontName.replace(/^["']|["']$/g, ''); if (fontName && !fontNames.includes(fontName)) { fontNames.push(fontName); } } Log.info("ipAgent.addSubtitleToVideo.extractedAllFontNames", { fontNames, count: fontNames.length, note: "从ASS文件中提取了所有Style定义中的字体名称" }); } catch (e) { Log.info("ipAgent.addSubtitleToVideo.failedToReadAss", { error: String(e) }); } // 如果找到了字体名称,尝试将字体文件复制到ASS文件所在目录 // 这样FFmpeg可以找到字体文件(特别是ziti目录中的自定义字体) if (fontNames.length > 0) { try { const assDir = path.dirname(srtPath); const zitiDirs = getBundledZitiDirs(); const fontExtensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2']; // 为每个字体处理复制逻辑 for (const fontName of fontNames) { let fontFound = false; // 1. 优先查找ziti目录(支持精确匹配和模糊匹配) for (const zitiDir of zitiDirs) { const files = fs.readdirSync(zitiDir); const fontNameLower = fontName.toLowerCase().replace(/\s+/g, ''); for (const file of files) { const ext = path.extname(file).toLowerCase(); if (!fontExtensions.includes(ext)) continue; const fileName = path.basename(file, ext); const fileNameLower = fileName.toLowerCase().replace(/\s+/g, ''); // 精确匹配或模糊匹配(忽略大小写和空格) if (fileNameLower === fontNameLower || fileNameLower.includes(fontNameLower) || fontNameLower.includes(fileNameLower)) { const fontFilePath = path.join(zitiDir, file); const targetFontPath = path.join(assDir, file); // 如果字体文件不在ASS目录中,复制它 if (!fs.existsSync(targetFontPath)) { fs.copyFileSync(fontFilePath, targetFontPath); Log.info("ipAgent.addSubtitleToVideo.fontCopiedFromZiti", { fontName, fileName, from: fontFilePath, to: targetFontPath }); } fontFound = true; break; } } if (fontFound) { break; } } // 2. ziti 字体包是字幕渲染的唯一字体来源,不能回退到系统字体。 if (!fontFound) { Log.error("ipAgent.addSubtitleToVideo.requiredBundledFontMissing", { fontName, zitiDirs, assDir, installRoot: AppEnv.installRoot, resourceBundleRoot: AppEnv.resourceBundleRoot }); throw new Error(`字幕字体包缺失或未找到字体 "${fontName}",请确认安装目录包含 resources-bundles/ziti 后重新生成`); } } } catch (e) { Log.error("ipAgent.addSubtitleToVideo.fontCopyError", { error: String(e), note: "字体复制过程中发生错误" }); } } // 使用 drawtext 过滤器处理 ASS 文件(支持字体和特效) Log.info("ipAgent.addSubtitleToVideo.usingDrawtextFilter", { reason: "使用 drawtext filter 处理 ASS 文件,支持字体描边和背景透明度" }); const assDir = path.dirname(srtPath); const inputVideoAbsolute = path.isAbsolute(inputVideo) ? inputVideo : path.join(assDir, inputVideo); const outputVideoAbsolute = path.isAbsolute(outputVideo) ? outputVideo : path.join(assDir, outputVideo); // 生成 drawtext 过滤器 const drawtextResult = generateDrawtextFilters(srtPath, subtitleStyle, keywords, keywordStyle, keywordGroups); const drawtextFilter = drawtextResult.filters; if (!drawtextFilter || drawtextFilter.length === 0) { Log.error("ipAgent.addSubtitleToVideo.emptyDrawtextFilter", { reason: "Failed to generate drawtext filters from ASS file" }); return { success: false, message: "无法从 ASS 文件生成字幕过滤器" }; } ffmpegArgs = [ '-i', inputVideoAbsolute, '-vf', drawtextFilter, '-c:v', 'libx264', '-preset', 'medium', '-crf', '23', '-c:a', 'copy', '-y', outputVideoAbsolute ]; Log.info("ipAgent.addSubtitleToVideo.drawtextCommand", { assPath: srtPath, drawtextFilter: drawtextFilter.substring(0, 100) + (drawtextFilter.length > 100 ? '...' : ''), reason: "使用 drawtext filter 生成过滤器链(支持背景透明度)" }); try { await execFFmpegCommand(ffmpegArgs, { cwd: assDir }); } catch (ffmpegError: any) { Log.error("ipAgent.addSubtitleToVideo.ffmpegFailed", { error: ffmpegError?.message || String(ffmpegError), ffmpegArgs: ffmpegArgs, cwd: assDir, note: "FFmpeg 执行失败,可能是字体文件问题、视频格式问题或其他编码错误" }); return { success: false, message: "视频处理失败:" + (ffmpegError?.message || "FFmpeg 执行失败") }; } // 检查输出文件是否存在 if (!fs.existsSync(outputVideoAbsolute)) { Log.error("ipAgent.addSubtitleToVideo.outputNotFound", { outputVideo: outputVideoAbsolute }); return { success: false, message: "视频文件生成失败" }; } Log.info("ipAgent.addSubtitleToVideo.success", { outputVideo: outputVideoAbsolute, inputVideo: inputVideoAbsolute, srtPath }); return { success: true, outputVideo: outputVideoAbsolute }; } else { // SRT文件:应用自定义样式 Log.info("ipAgent.addSubtitleToVideo.usingSrtFile", { srtPath, subtitleStyle }); // 🔧 修复中文路径和空格:Windows路径处理 // FFmpeg的subtitles滤镜需要特殊处理: // 1. 将反斜杠转为正斜杠(FFmpeg 可以正确处理) // 2. 转义冒号(C: -> C\\:) // 3. 转义单引号(如果路径中包含单引号) // 4. 空格不需要转义(因为整个路径会用单引号包裹) let srtPathForFilter = srtPath; if (process.platform === 'win32') { // Windows 上:转换为正斜杠 srtPathForFilter = srtPathForFilter.replace(/\\/g, '/'); // 转义冒号 srtPathForFilter = srtPathForFilter.replace(/:/g, '\\:'); // 转义单引号(如果路径中包含单引号,需要转义为 '\'') srtPathForFilter = srtPathForFilter.replace(/'/g, "'\\''"); } else { // Unix 系统:转义特殊字符 srtPathForFilter = srtPathForFilter.replace(/:/g, '\\:'); srtPathForFilter = srtPathForFilter.replace(/'/g, "'\\''"); } Log.info("ipAgent.addSubtitleToVideo.pathConversion", { originalPath: srtPath, convertedPath: srtPathForFilter, platform: process.platform, hasSpace: srtPath.includes(' '), hasChinese: /[\u4e00-\u9fa5]/.test(srtPath) }); // 确定字幕对齐方式 const alignment = subtitleStyle.position === 'top' ? 8 : subtitleStyle.position === 'center' ? 5 : 2; // 将RGB颜色转换为ASS格式的ABGR(FFmpeg的force_style要求) // FFmpeg force_style格式:&HAABBGGRR,其中AA是透明度(00=不透明,FF=透明) const hexToABGR = (hex: string) => { if (!hex || typeof hex !== 'string') { Log.info("ipAgent.addSubtitleToVideo.invalidColor", { hex, message: "使用默认白色" }); return '&H00FFFFFF'; // 默认白色 } const rgb = hex.replace('#', '').toUpperCase(); if (rgb.length === 6) { const r = rgb.substring(0, 2); const g = rgb.substring(2, 4); const b = rgb.substring(4, 6); // 返回完整的ASS颜色格式:&H + AA(透明度) + BB + GG + RR return `&H00${b}${g}${r}`; // 00=不透明 + BGR } Log.info("ipAgent.addSubtitleToVideo.invalidColorLength", { hex, rgb, message: "使用默认白色" }); return '&H00FFFFFF'; // 默认白色 }; // 转换颜色 const fontColor = hexToABGR(subtitleStyle.fontColor || '#FFFFFF'); const outlineColor = hexToABGR(subtitleStyle.outlineColor || '#000000'); // 构建样式字符串 - 注意:不要在样式字符串中使用单引号,会与外层冲突 // 使用逗号分隔,不使用空格 const fontSize = subtitleStyle.fontSize || 48; const outlineWidth = subtitleStyle.outlineWidth || 3; // 确保字体名称是字符串 let fontName = subtitleStyle.fontName || 'NotoSerifCJK-VF'; if (typeof fontName === 'object' && fontName !== null) { fontName = fontName.value || fontName.label || 'NotoSerifCJK-VF'; } else if (typeof fontName !== 'string') { fontName = String(fontName || 'NotoSerifCJK-VF'); } fontName = fontName.trim() || 'NotoSerifCJK-VF'; // 确保背景透明度是0-1范围的数字 let backgroundOpacity = subtitleStyle.backgroundOpacity ?? 0; if (typeof backgroundOpacity === 'string') { if (backgroundOpacity.endsWith('%')) { backgroundOpacity = parseFloat(backgroundOpacity) / 100; } else { backgroundOpacity = parseFloat(backgroundOpacity); } } if (isNaN(backgroundOpacity) || backgroundOpacity < 0) { backgroundOpacity = 0; } else if (backgroundOpacity > 1) { if (backgroundOpacity <= 100) { backgroundOpacity = backgroundOpacity / 100; } else { backgroundOpacity = 1; } } // 构建样式字符串 - 使用单引号包裹整个force_style值 let styleStr = `FontName=${fontName},FontSize=${fontSize},PrimaryColour=${fontColor},OutlineColour=${outlineColor},Outline=${outlineWidth},Alignment=${alignment}`; // 如果有背景色且透明度大于0,添加背景色和BorderStyle const backgroundColor = subtitleStyle.backgroundColor || 'transparent'; if (backgroundColor !== 'transparent' && backgroundColor !== '' && backgroundOpacity > 0 && backgroundOpacity <= 1) { // 计算背景颜色的ASS格式 const bgHex = backgroundColor.replace('#', '').toUpperCase(); if (bgHex.length === 6) { const r = bgHex.substring(0, 2); const g = bgHex.substring(2, 4); const b = bgHex.substring(4, 6); // ASS格式:Alpha值(00=不透明,FF=完全透明) const alpha = Math.round((1 - backgroundOpacity) * 255); const alphaHex = alpha.toString(16).padStart(2, '0').toUpperCase(); // ASS格式:&HAABBGGRR const backColor = `&H${alphaHex}${b}${g}${r}`; styleStr += `,BackColour=${backColor},BorderStyle=3`; } } Log.info("ipAgent.addSubtitleToVideo.styleConfig", { originalPath: srtPath, convertedPath: srtPathForFilter, fontName: fontName, originalFontColor: subtitleStyle.fontColor, convertedFontColor: fontColor, originalOutlineColor: subtitleStyle.outlineColor, convertedOutlineColor: outlineColor, fontSize, outlineWidth, alignment, position: subtitleStyle.position, backgroundColor: subtitleStyle.backgroundColor, backgroundOpacity: backgroundOpacity, styleStr }); // 重要:SRT字幕需要重新编码视频才能应用自定义样式 // 使用subtitles滤镜 + 重新编码 // 关键修复: // 1. 路径用单引号包裹(避免空格和特殊字符问题) // 2. 冒号必须转义(C: -> C\\:) // 3. force_style也用单引号包裹(避免&符号被shell解释) const vfFilter = `subtitles='${srtPathForFilter}':force_style='${styleStr}'`; Log.info("ipAgent.addSubtitleToVideo.ffmpegFilter", { vfFilter }); ffmpegArgs = [ '-i', inputVideo, '-vf', vfFilter, '-c:v', 'libx264', // 重新编码视频 '-preset', 'medium', // 编码速度预设 '-crf', '23', // 质量控制 '-c:a', 'copy', // 音频直接复制 '-y', // 覆盖已存在的文件 outputVideo ]; Log.info("ipAgent.addSubtitleToVideo.ffmpegCommand", { args: ffmpegArgs.join(' ') }); } try { await execFFmpegCommand(ffmpegArgs); } catch (ffmpegError) { Log.error("ipAgent.addSubtitleToVideo.ffmpegError", { message: ffmpegError?.message || String(ffmpegError), stack: ffmpegError?.stack, args: ffmpegArgs.join(' '), inputVideo, srtPath, outputVideo }); throw ffmpegError; } Log.info("ipAgent.addSubtitleToVideo.success", { outputVideo }); return { success: true, outputVideo }; } catch (error) { Log.error("ipAgent.addSubtitleToVideo.error", { message: error?.message || String(error), stack: error?.stack }); return { success: false, message: "添加字幕失败: " + (error?.message || String(error) || "未知错误") }; } }); // 添加BGM到视频 ipcMain.handle("ipAgent:addBGMToVideo", async (event, params: { inputVideo: string, bgmPath: string, bgmVolume?: number }) => { try { let { inputVideo, bgmPath, bgmVolume = 30 } = params; // 验证输入参数 if (!inputVideo || typeof inputVideo !== 'string') { Log.error("ipAgent.addBGMToVideo.invalidInput", { inputVideo }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 inputVideo = inputVideo.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(inputVideo)) { Log.error("ipAgent.addBGMToVideo.fileNotFound", { inputVideo }); return { success: false, message: "视频文件不存在: " + inputVideo }; } if (!bgmPath || !fs.existsSync(bgmPath)) { Log.error("ipAgent.addBGMToVideo.bgmNotFound", { bgmPath }); return { success: false, message: "BGM文件不存在" }; } Log.info("ipAgent.addBGMToVideo", { inputVideo, bgmPath, bgmVolume }); const outputDir = getOutputDir('video'); const timestamp = Date.now(); const outputVideo = path.join(outputDir, `video_with_bgm_${timestamp}.mp4`); // 计算音量比例(0-100转换为0-1) const volumeRatio = bgmVolume / 100; // FFmpeg命令:混合视频原声和BGM const ffmpegArgs = [ '-i', inputVideo, '-stream_loop', '-1', '-i', bgmPath, '-filter_complex', `[0:a]volume=1.0[a0];[1:a]volume=${volumeRatio}[a1];[a0][a1]amix=inputs=2:duration=first`, '-c:v', 'copy', '-shortest', outputVideo ]; await execFFmpegCommand(ffmpegArgs); Log.info("ipAgent.addBGMToVideo.success", { outputVideo, outputDir }); return { success: true, outputVideo, outputDir }; } catch (error) { Log.error("ipAgent.addBGMToVideo.error", error); return { success: false, message: "添加BGM失败: " + (error?.message || error) }; } }); // 提取关键词 ipcMain.handle("ipAgent:extractKeywords", async (event, params: { subtitleText: string, modelConfig: any }) => { try { const { subtitleText, modelConfig } = params; Log.info("ipAgent.extractKeywords", { textLength: subtitleText.length }); const prompt = `请从以下文本中提取5-8个关键词,这些关键词应该是文本中最重要、最有代表性的词汇。每个关键词2-4个字。 文本内容: ${subtitleText} 请直接输出关键词列表,每行一个,不要编号,不要额外说明。`; // 调用AI模型 const response = await fetch(modelConfig.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: modelConfig.modelId, messages: [{ role: 'user', content: prompt }] }) }); if (!response.ok) { throw new Error(`API请求失败: ${response.status}`); } const data = await response.json(); const content = data.choices?.[0]?.message?.content || ''; // 解析关键词 const keywords = content .split('\n') .map((line: string) => line.trim()) .filter((line: string) => line && !line.startsWith('#')) .map((keyword: string) => ({ keyword, enabled: true })); Log.info("ipAgent.extractKeywords.success", { keywordCount: keywords.length }); AuthMain.reportGeneration('script', `关键词提取:${keywords.length}个`); return { success: true, keywords }; } catch (error) { const errorMessage = error?.message || String(error || '????'); const errorName = error?.cause?.name || error?.name || ''; const errorCode = error?.cause?.code || error?.code || ''; const isTimeoutError = /timeout/i.test(errorMessage) || /timeout/i.test(errorName) || /timeout/i.test(errorCode); Log.error("ipAgent.extractKeywords.error", error); if (isTimeoutError) { Log.warn("ipAgent.extractKeywords.timeoutFallback", { errorMessage, errorName, errorCode }); return { success: true, degraded: true, keywords: [], message: "????????????????" }; } return { success: false, message: "???????: " + errorMessage }; } }); // 选择音乐文件 ipcMain.handle("ipAgent:selectMusicFile", async (event) => { try { let defaultPath = ""; // 尝试查找bgm目录 // 开发环境: packaging/vendor/bgm // 生产环境: resources-bundles/bgm const possiblePaths = [ path.join(process.cwd(), 'packaging', 'vendor', 'bgm'), AppEnv.resourceBundleRoot ? path.join(AppEnv.resourceBundleRoot, 'bgm') : '', path.join(process.cwd(), 'resources-bundles', 'bgm'), path.join(process.cwd(), 'bgm'), path.join(process.resourcesPath, 'app.asar.unpacked', 'bgm'), path.join(process.resourcesPath, 'bgm'), path.join(app.getAppPath(), 'bgm') ].filter(Boolean); for (const p of possiblePaths) { try { if (fs.existsSync(p)) { defaultPath = p; Log.info("ipAgent.selectMusicFile.defaultPathFound", { path: p }); break; } } catch (e) { // ignore } } const result = await dialog.showOpenDialog({ title: '选择背景音乐', defaultPath: defaultPath, filters: [ { name: '音频文件', extensions: ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg'] } ], properties: ['openFile'] }); if (result.canceled || !result.filePaths || result.filePaths.length === 0) { return { success: false, message: '用户取消选择' }; } const filePath = result.filePaths[0]; Log.info("ipAgent.selectMusicFile.success", { filePath }); return { success: true, filePath }; } catch (error) { Log.error("ipAgent.selectMusicFile.error", error); return { success: false, message: "选择文件失败: " + (error?.message || error) }; } }); // 选择视频文件(用于上传自己的视频生成字幕) ipcMain.handle("ipAgent:selectVideoFile", async (event) => { try { const result = await dialog.showOpenDialog({ title: '选择视频文件', filters: [ { name: '视频文件', extensions: ['mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', 'webm'] } ], properties: ['openFile'] }); if (result.canceled || !result.filePaths || result.filePaths.length === 0) { return { success: false, message: '用户取消选择' }; } const filePath = result.filePaths[0]; Log.info("ipAgent.selectVideoFile.success", { filePath }); return { success: true, filePath }; } catch (error) { Log.error("ipAgent.selectVideoFile.error", error); return { success: false, message: "选择文件失败: " + (error?.message || error) }; } }); // 生成视频封面 ipcMain.handle("ipAgent:generateVideoCover", async (event, params: { videoPath: string, titleText: string, effectStyle?: any, template?: any // ✅ 添加template参数 }) => { try { let { videoPath, titleText, effectStyle, template } = params; // ✅ 关键修复:优先使用template参数,如果没有则使用effectStyle const templateConfig = template || effectStyle; // 验证输入参数 if (!videoPath || typeof videoPath !== 'string') { Log.error("ipAgent.generateVideoCover.invalidInput", { videoPath }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 videoPath = videoPath.replace(/^file:\/\//, ''); // 验证文件是否存在 if (!fs.existsSync(videoPath)) { Log.error("ipAgent.generateVideoCover.fileNotFound", { videoPath }); return { success: false, message: "视频文件不存在: " + videoPath }; } if (!titleText || typeof titleText !== 'string' || titleText.trim() === '') { Log.error("ipAgent.generateVideoCover.invalidTitle", { titleText }); return { success: false, message: "标题文本不能为空" }; } Log.info("ipAgent.generateVideoCover", { videoPath, titleText, effectStyle, template, usingTemplate: !!template }); const outputDir = getOutputDir('cover'); const timestamp = Date.now(); const coverPath = path.join(outputDir, `cover_${timestamp}.jpg`); // 1. 从视频中提取一个出彩帧(取视频3秒位置的帧) const tempFramePath = path.join(outputDir, `temp_frame_${timestamp}.jpg`); // 修复: // 1. -ss 参数必须放在 -i 之前(快速跳转模式) // 2. 添加 -pix_fmt yuvj420p 将像素格式从 yuv420p 转换为 yuvj420p(full-range) // 3. 添加 -c:v mjpeg 明确指定 MJPEG 编码器 // 4. 添加 -strict:v unofficial 允许非标准参数 // 这样可以解决 "Non full-range YUV" 错误 const extractFrameArgs = [ '-ss', '00:00:03', // 快速跳转到 3 秒位置 '-i', videoPath, // 读取输入视频 '-vframes', '1', // 只提取一帧 '-pix_fmt', 'yuvj420p', // 转换像素格式为 full-range YUV '-c:v', 'mjpeg', // 使用 MJPEG 编码器 '-strict:v', 'unofficial', // 允许非标准 YUV 范围 '-f', 'image2', // 输出格式为图像 '-y', // 覆盖已存在的输出文件 tempFramePath // 输出文件路径 ]; await execFFmpegCommand(extractFrameArgs); // 2. 检查是否使用新的模板系统 // 判断条件:如果 templateConfig 包含以下任一属性,则认为是新模板 // - blurBackground: 背景模糊 // - extractPerson: 人物抠图 // - personOutlineColor/personOutlineWidth: 人物描边 // - maskImagePath: 蒙版图片 // - titleFontFamily: 自定义字体 // - titleBackgroundEnabled: 标题背景 // - personSize: 人物大小 // - backgroundBlurEnabled: 背景模糊开关 const isNewTemplate = templateConfig && ( templateConfig.blurBackground !== undefined || templateConfig.extractPerson !== undefined || templateConfig.personOutlineColor !== undefined || templateConfig.personOutlineWidth !== undefined || templateConfig.maskImagePath !== undefined || templateConfig.titleFontFamily || templateConfig.titleBackgroundEnabled || templateConfig.personSize !== undefined || templateConfig.backgroundBlurEnabled !== undefined ); // 详细日志:记录判断过程 Log.info("ipAgent.generateVideoCover.templateCheck", { hasEffectStyle: !!templateConfig, effectStyleKeys: templateConfig ? Object.keys(templateConfig) : [], blurBackground: templateConfig?.blurBackground, extractPerson: templateConfig?.extractPerson, personOutlineColor: templateConfig?.personOutlineColor, personOutlineWidth: templateConfig?.personOutlineWidth, maskImagePath: templateConfig?.maskImagePath, titleFontFamily: templateConfig?.titleFontFamily, titleBackgroundEnabled: templateConfig?.titleBackgroundEnabled, personSize: templateConfig?.personSize, backgroundBlurEnabled: templateConfig?.backgroundBlurEnabled, isNewTemplate }); if (isNewTemplate) { // 使用高级Python封面生成器(支持完整的模板配置和步骤化生成) Log.info("ipAgent.generateVideoCover.usingAdvancedPythonGenerator", { templateConfig: templateConfig }); try { // 调用高级Python生成器 const pythonScript = getPythonScriptPath('advanced_cover_generator.py'); const pythonExe = getPythonPath(); // ✅ 关键修复:使用templateConfig而不是effectStyle // 准备配置JSON字符串(包含所有模板配置) const configJson = JSON.stringify(templateConfig); // 检查是否有自定义视频路径 let finalVideoPath = videoPath; if (templateConfig.customVideoPath && fs.existsSync(templateConfig.customVideoPath)) { finalVideoPath = templateConfig.customVideoPath; Log.info("ipAgent.generateVideoCover.usingCustomVideo", { originalVideo: videoPath, customVideo: finalVideoPath }); } else if (templateConfig.customVideoPath) { Log.info("ipAgent.generateVideoCover.customVideoNotFound", { customVideoPath: templateConfig.customVideoPath, fallbackToOriginal: videoPath }); } // 🔧 修复中文路径问题:在 Windows 上,将路径转换为短路径格式(8.3格式) // 这样可以避免中文和特殊字符导致的编码问题 const normalizePathForPython = (filePath: string): string => { if (process.platform === 'win32') { // Windows 上使用正斜杠,Python 可以正确处理 return filePath.replace(/\\/g, '/'); } return filePath; }; // 构建命令行参数 const pythonArgs = [ pythonScript, '--video', normalizePathForPython(finalVideoPath), // 使用自定义视频路径或原始视频路径 '--title', titleText, '--output', normalizePathForPython(coverPath), '--config', configJson ]; Log.info("ipAgent.generateVideoCover.advancedPythonArgs", { pythonExe, videoPath: videoPath, normalizedVideoPath: normalizePathForPython(finalVideoPath), outputPath: coverPath, normalizedOutputPath: normalizePathForPython(coverPath), titleText: titleText, configKeys: Object.keys(templateConfig).length, // ✅ 新增:验证polygon背景点数据是否被传递 titleBackgroundPointsCount: templateConfig.titleBackgroundPoints?.length || 0, titleBackgroundShape: templateConfig.titleBackgroundShape, subtitleBackgroundPointsCount: templateConfig.subtitleBackgroundPoints?.length || 0, subtitleBackgroundShape: templateConfig.subtitleBackgroundShape }); const result = await new Promise<{ success: boolean, message?: string, previewImages?: any[] }>((resolve) => { // 设置工作目录为 python 脚本所在目录,确保能找到 modules 目录 const pythonScriptDir = path.dirname(pythonScript); // 🔧 修复:传递APP_ROOT环境变量给Python进程,使其能正确识别打包环境 // ✅ 关键修复:使用AppEnv.appRoot(已在主进程初始化时正确设置)代替process.env.APP_ROOT // 避免降级到process.cwd()导致找不到模型文件 const env = buildRuntimeProcessEnv({ APP_ROOT: AppEnv.appRoot || process.resourcesPath || process.cwd(), // 🔧 新增:设置 Python 使用 UTF-8 编码 PYTHONIOENCODING: 'utf-8' }); // 🔧 修复:添加 FFmpeg 目录到 PATH const ffmpegPath = getFFmpegExecutablePath(); if (resourceExists(ffmpegPath)) { const ffmpegDir = path.dirname(ffmpegPath); const pathSep = process.platform === 'win32' ? ';' : ':'; env['PATH'] = ffmpegDir + pathSep + (env['PATH'] || process.env.PATH || ''); } // spawn 选项:使用 shell: false 以正确传递参数 const spawnOptions: any = { cwd: AppEnv.appRoot || process.resourcesPath || process.cwd(), env: env, shell: false }; const pythonProcess = spawn(pythonExe, pythonArgs, spawnOptions); let stdout = ''; let stderr = ''; pythonProcess.stdout.on('data', (data) => { stdout += data.toString(); }); pythonProcess.stderr.on('data', (data) => { stderr += data.toString(); }); pythonProcess.on('close', (code) => { Log.info("ipAgent.generateVideoCover.advancedPythonCompleted", { exitCode: code, coverExists: fs.existsSync(coverPath), stdoutLength: stdout.length, stderrLength: stderr.length }); // 输出Python的stderr以便调试 if (stderr && stderr.length > 0) { console.log("=== Python stderr (调试信息) ==="); console.log(stderr); console.log("=== stderr 结束 ==="); } if (code === 0 && fs.existsSync(coverPath)) { // 解析Python输出以获取预览图信息 let previewImages = []; try { // Python脚本现在只输出一行JSON const pythonResult = JSON.parse(stdout.trim()); previewImages = pythonResult.previewImages || []; } catch (e) { Log.error("ipAgent.generateVideoCover.parsePreviewFailed", { error: e, stdout: stdout }); } Log.info("ipAgent.generateVideoCover.advancedPythonSuccess", { coverPath, previewCount: previewImages.length, previews: previewImages.slice(0, 3) // 只记录前3个,避免日志过长 }); resolve({ success: true, previewImages }); } else { Log.error("ipAgent.generateVideoCover.advancedPythonFailed", { code, stdout, stderr, coverExists: fs.existsSync(coverPath) }); resolve({ success: false, message: `高级Python生成器失败 (code: ${code}): ${stderr || stdout}` }); } }); pythonProcess.on('error', (err) => { Log.error("ipAgent.generateVideoCover.pythonError", { error: err }); resolve({ success: false, message: `Python进程错误: ${err.message}` }); }); }); if (!result.success) { throw new Error(result.message || 'Python生成器失败'); } } catch (pythonError: any) { Log.info("ipAgent.generateVideoCover.pythonFallback", { error: pythonError.message }); // Python生成失败,回退到FFmpeg简单模式 const { titleFontSize = 90, titleFontColor = '#FFFFFF', titlePosition = 'bottom' } = effectStyle || {}; const textPosition = titlePosition === 'top' ? 'y=50' : titlePosition === 'center' ? 'y=(h-text_h)/2' : 'y=h-text_h-50'; const addTextArgs = [ '-i', tempFramePath, '-vf', `drawtext=text='${titleText}':fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPosition}:shadowcolor=black:shadowx=2:shadowy=2`, coverPath ]; await execFFmpegCommand(addTextArgs); } } else { // 使用FFmpeg简单模式(旧的效果样式) const { titleFontSize = 90, titleFontColor = '#FFFFFF', titlePosition = 'bottom', backgroundColor = '#000000', backgroundOpacity = 0.8 } = effectStyle || {}; const textPosition = titlePosition === 'top' ? 'y=50' : titlePosition === 'center' ? 'y=(h-text_h)/2' : 'y=h-text_h-50'; const addTextArgs = [ '-i', tempFramePath, '-vf', `drawtext=text='${titleText}':fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPosition}:shadowcolor=black:shadowx=2:shadowy=2`, coverPath ]; await execFFmpegCommand(addTextArgs); } // 删除临时文件 if (fs.existsSync(tempFramePath)) { fs.unlinkSync(tempFramePath); } Log.info("ipAgent.generateVideoCover.success", { coverPath }); return { success: true, coverPath, finalCoverPath: coverPath }; } catch (error) { Log.error("ipAgent.generateVideoCover.error", error); return { success: false, message: "生成封面失败: " + (error?.message || error), error: error?.message || String(error) }; } }); // 读取SRT文件并解析 ipcMain.handle("ipAgent:readSrtFile", async (event, params: { srtPath: string }) => { try { const { srtPath } = params; Log.info("ipAgent.readSrtFile", { srtPath }); // 验证文件是否存在 if (!fs.existsSync(srtPath)) { Log.error("ipAgent.readSrtFile.fileNotFound", { srtPath }); return { success: false, message: "SRT文件不存在: " + srtPath }; } // 读取SRT文件内容 const content = fs.readFileSync(srtPath, 'utf-8'); // 解析SRT格式 const subtitles: Array<{ index: number; startTime: string; endTime: string; text: string; }> = []; const blocks = content.trim().split('\n\n'); for (const block of blocks) { const lines = block.split('\n'); if (lines.length >= 3) { const index = parseInt(lines[0]); const timeMatch = lines[1].match(/(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})/); if (timeMatch) { const startTime = timeMatch[1]; const endTime = timeMatch[2]; const text = lines.slice(2).join('\n'); subtitles.push({ index, startTime, endTime, text }); } } } Log.info("ipAgent.readSrtFile.success", { srtPath, subtitleCount: subtitles.length }); return { success: true, subtitles, content }; } catch (error) { Log.error("ipAgent.readSrtFile.error", error); return { success: false, message: "读取SRT文件失败: " + (error?.message || error) }; } }); // 保存ASS字幕文件(不使用缓存,每次都生成新文件) ipcMain.handle("ipAgent:saveAssFile", async (event, params: { assContent: string, videoPath?: string, timestamp?: number }) => { try { const { assContent, videoPath, timestamp } = params; Log.info("ipAgent.saveAssFile", { contentLength: assContent.length, hasVideoPath: !!videoPath, note: "ASS字幕不使用缓存,每次都重新生成" }); // ASS字幕始终使用当前时间戳,不使用缓存 // 这样可以确保情感分析结果每次都是最新的 const finalTimestamp = Date.now(); const outputDir = getOutputDir('subtitle'); const assPath = path.join(outputDir, `subtitle_emotion_${finalTimestamp}.ass`); // 写入ASS文件 fs.writeFileSync(assPath, assContent, 'utf-8'); Log.info("ipAgent.saveAssFile.success", { assPath, message: "ASS字幕文件已生成(不使用缓存)" }); return { success: true, assPath, cached: false, // 明确标记不使用缓存 message: "ASS字幕文件已生成" }; } catch (error) { Log.error("ipAgent.saveAssFile.error", error); return { success: false, message: "保存ASS文件失败: " + (error?.message || error) }; } }); // 从URL下载视频文件 ipcMain.handle("ipAgent:downloadVideoFromUrl", async (event, params: { videoUrl: string, fileName?: string }) => { try { const { videoUrl, fileName } = params; Log.info("ipAgent.downloadVideoFromUrl", { videoUrl }); if (!videoUrl || typeof videoUrl !== 'string') { return { success: false, message: "无效的视频URL" }; } // 确定输出目录和文件名 const outputDir = getOutputDir('video'); const timestamp = Date.now(); const fileExt = path.extname(new URL(videoUrl).pathname) || '.mp4'; const finalFileName = fileName || `video_${timestamp}${fileExt}`; const videoPath = path.join(outputDir, finalFileName); Log.info("ipAgent.downloadVideoFromUrl.downloading", { videoPath }); // 使用fetch下载视频 const response = await fetch(videoUrl, { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } }); if (!response.ok) { throw new Error(`下载失败: ${response.status} ${response.statusText}`); } // 将响应流写入文件 const fileStream = fs.createWriteStream(videoPath); const reader = response.body?.getReader(); if (!reader) { throw new Error("无法读取响应流"); } const pump = async () => { while (true) { const { done, value } = await reader.read(); if (done) break; fileStream.write(Buffer.from(value)); } fileStream.end(); }; await pump(); // 等待文件写入完成 await new Promise((resolve, reject) => { fileStream.on('finish', () => resolve()); fileStream.on('error', (err) => reject(err)); }); Log.info("ipAgent.downloadVideoFromUrl.success", { videoPath }); return { success: true, videoPath, message: "视频下载成功" }; } catch (error) { Log.error("ipAgent.downloadVideoFromUrl.error", error); return { success: false, message: "视频下载失败: " + (error?.message || error) }; } }); // 视频文案分析:使用浏览器自动化+API拦截+语音识别 ipcMain.handle("ipAgent:rewriteVideoContent", async (event, params: { videoUrl: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, rewritePrompt: string, asrModelKey?: string, scriptWordCount?: number }) => { // 提升参数到外层作用域,确保catch块中可访问 const videoUrl = params.videoUrl; const modelConfig = params.modelConfig; const rewritePrompt = params.rewritePrompt; const asrModelKey = params.asrModelKey; const scriptWordCount = params.scriptWordCount || 300; // 默认 300 字 // 提升浏览器上下文变量到外层作用域,确保catch块中可访问 let externalBrowserContext: any = null; try { Log.info("ipAgent.rewriteVideoContent.start", { videoUrl, hasModelConfig: !!modelConfig, hasRewritePrompt: !!rewritePrompt, mode: "浏览器自动化+语音识别模式" }); // 导入视频下载器 const { DouyinVideoDownloader } = await import('./videoDownloader'); // 尝试从publish模块获取现有的浏览器上下文 try { const { getBrowserContext } = await import('../publish/main'); console.log('尝试从publish模块获取浏览器...'); externalBrowserContext = await getBrowserContext('douyin'); console.log('成功获取publish模块的浏览器上下文,将复用该浏览器'); } catch (error) { console.log('未能从publish模块获取浏览器,将使用独立的浏览器管理器:', error instanceof Error ? error.message : error); } // 创建下载器实例(传入外部浏览器上下文,如果有的话) const downloader = new DouyinVideoDownloader(externalBrowserContext); let videoPath: string | null = null; let audioPath: string | null = null; let textPath: string | null = null; try { // 第一步:下载视频文件 Log.info("ipAgent.rewriteVideoContent.step1", { step: "使用浏览器自动化下载视频" }); const downloadResult = await downloader.downloadDouyinVideo(videoUrl); Log.info("ipAgent.rewriteVideoContent.step1.result", { success: downloadResult.success, error: downloadResult.error, videoPath: downloadResult.videoPath }); if (!downloadResult.success) { throw new Error(downloadResult.error || "视频下载失败"); } videoPath = downloadResult.videoPath; audioPath = downloadResult.audioPath; Log.info("ipAgent.rewriteVideoContent.step1.success", { videoPath: downloadResult.videoPath, videoTitle: downloadResult.videoInfo?.desc, author: downloadResult.videoInfo?.author?.nickname }); // 【立即关闭浏览器】- 视频下载完成,不再需要浏览器 Log.info("ipAgent.rewriteVideoContent.closeBrowser", { reason: "视频下载完成,关闭浏览器" }); try { await downloader.close(); // 如果使用了外部浏览器上下文,需要显式关闭它 if (externalBrowserContext) { const { closeBrowserContext } = await import('../publish/main'); await closeBrowserContext('douyin'); Log.info("ipAgent.rewriteVideoContent.closeBrowser.success", { message: "已关闭抖音浏览器上下文" }); } } catch (closeError) { Log.error("ipAgent.rewriteVideoContent.close.error", closeError); } // 第二步:提取音频并进行语音识别(本地处理) Log.info("ipAgent.rewriteVideoContent.step2", { step: "提取音频并进行语音识别" }); // 第二步:提取音频并进行语音识别 Log.info("ipAgent.rewriteVideoContent.step2.usingASR", { asrModelKey }); Log.info("ipAgent.rewriteVideoContent.step2.calling", { message: "即将调用extractAudioAndRecognize" }); const recognizeResult = await extractAudioAndRecognize( downloadResult.videoPath!, downloadResult.audioPath!, asrModelKey ); Log.info("ipAgent.rewriteVideoContent.step2.receivedResult", { message: "已收到extractAudioAndRecognize的返回值", hasText: !!recognizeResult?.text, textLength: recognizeResult?.text?.length || 0 }); const extractedText = recognizeResult.text; textPath = recognizeResult.textPath; Log.info("ipAgent.rewriteVideoContent.step2.success", { extractedTextLength: extractedText.length, extractedTextPreview: extractedText.substring(0, 100) + "...", textPath: textPath }); // 第三步:先用AI优化识别文本(添加标点符号),再进行仿写 Log.info("ipAgent.rewriteVideoContent.step3", { step: "优化识别文本并进行仿写" }); // 【新增】先调用AI优化识别文本,添加标点符号 Log.info("ipAgent.rewriteVideoContent.step3.optimizingText", { sourceLength: extractedText.length, preview: extractedText.substring(0, 100) }); const optimizationPrompt = `这是我使用ASR技术对音频内容进行转录的结果。请直接输出优化后的文本,添加适当的标点符号,无需任何额外说明文字。 转录结果:${extractedText}`; let optimizedText = extractedText; try { optimizedText = await callTextModelAPI(optimizationPrompt, modelConfig); Log.info("ipAgent.rewriteVideoContent.step3.optimizationSuccess", { optimizedLength: optimizedText?.length || 0, preview: optimizedText?.substring(0, 100) || '' }); } catch (optimizationError) { Log.info("ipAgent.rewriteVideoContent.step3.optimizationFailed", { error: optimizationError instanceof Error ? optimizationError.message : optimizationError }); // 如果优化失败,继续使用原始文本 } // 使用优化后的文本作为仿写的源内容 Log.info("ipAgent.rewriteVideoContent.step3.rewriteSourceText", { sourceLength: optimizedText.length, preview: optimizedText.substring(0, 100) }); // 使用用户配置的提示词(rewritePrompt来自设置) // 替换提示词中的占位符:{content} 和 {{count}} let finalRewritePrompt = rewritePrompt && rewritePrompt.trim().length > 0 ? rewritePrompt : `请基于以下视频原文案,创作一个新的仿写文案:\n\n原文案:${optimizedText}\n\n要求:\n1. 保持原文案的核心内容和风格\n2. 适当改变表达方式和用词\n3. 确保新文案通顺自然\n4. 长度与原文案相当\n\n请直接输出仿写后的文案:`; // 替换 {content} 和 {{content}} 为优化后的文本 finalRewritePrompt = finalRewritePrompt .replace(/{content}/g, optimizedText) .replace(/{{content}}/g, optimizedText); // 替换 {{count}} 为实际字数(支持多种格式:{{count}}、{count} 等) finalRewritePrompt = finalRewritePrompt .replace(/{{count}}/g, String(scriptWordCount)) .replace(/{count}/g, String(scriptWordCount)); Log.info("ipAgent.rewriteVideoContent.step3.usingPrompt", { isCustomPrompt: !!(rewritePrompt && rewritePrompt.trim().length > 0), promptLength: finalRewritePrompt.length, promptPreview: finalRewritePrompt.substring(0, 80) }); // 调用设置中的在线文案模型进行仿写 Log.info("ipAgent.rewriteVideoContent.step3.callingTextModel", { modelId: modelConfig.modelId, providerId: modelConfig.providerId }); const rewrittenContent = await callTextModelAPI(finalRewritePrompt, modelConfig); Log.info("ipAgent.rewriteVideoContent.step3.rewriteResult", { resultLength: rewrittenContent?.length || 0, resultPreview: rewrittenContent?.substring(0, 100) || '' }); Log.info("ipAgent.rewriteVideoContent.success", { originalTextLength: extractedText.length, rewrittenTextLength: rewrittenContent.length }); // 【新增】清理临时文件:转文字成功后删除下载的视频和提取的音频 Log.info("ipAgent.rewriteVideoContent.cleanupTempFiles", { videoPath: videoPath, audioPath: audioPath, reason: "转文字成功,删除临时文件以节省空间" }); try { // 删除下载的视频文件 if (videoPath && fs.existsSync(videoPath)) { fs.unlinkSync(videoPath); Log.info("ipAgent.rewriteVideoContent.cleanupTempFiles.deletedVideo", { videoPath }); } // 删除提取的音频文件 if (audioPath && fs.existsSync(audioPath)) { fs.unlinkSync(audioPath); Log.info("ipAgent.rewriteVideoContent.cleanupTempFiles.deletedAudio", { audioPath }); } Log.info("ipAgent.rewriteVideoContent.cleanupTempFiles.success", { message: "临时文件清理完成" }); } catch (cleanupError) { // 清理失败不影响主流程,只记录日志 Log.error("ipAgent.rewriteVideoContent.cleanupTempFiles.error", { error: cleanupError instanceof Error ? cleanupError.message : cleanupError, message: "临时文件清理失败,不影响主流程" }); } return { success: true, recognizedText: extractedText, // 返回识别文本(txt文件的内容:去掉空格,保留标点) rewrittenText: rewrittenContent, // 返回AI生成的仿写内容(原样返回,不处理) videoInfo: { title: downloadResult.videoInfo?.desc, author: downloadResult.videoInfo?.author?.nickname, duration: downloadResult.videoInfo?.video?.duration }, message: "视频文案分析完成", debug: { videoUrl: videoUrl, recognizedTextLength: extractedText.length, rewrittenTextLength: rewrittenContent.length, videoPath: videoPath, audioPath: audioPath, textPath: textPath } }; } catch (processingError) { throw processingError; } } catch (error: any) { // 记录详细的错误信息,便于诊断 console.error("【浏览器自动化失败】详细错误信息:"); console.error("错误类型:", error?.constructor?.name); console.error("错误消息:", error?.message); console.error("错误堆栈:", error?.stack); console.error("原始错误对象:", error); Log.error("ipAgent.rewriteVideoContent.error", { errorType: error?.constructor?.name, errorMessage: error?.message, errorStack: error?.stack, fullError: String(error) }); // 发生错误时也要关闭浏览器 if (externalBrowserContext) { try { const { closeBrowserContext } = await import('../publish/main'); await closeBrowserContext('douyin'); Log.info("ipAgent.rewriteVideoContent.closeBrowser.onError", { message: "错误发生后已关闭抖音浏览器上下文" }); } catch (closeError) { Log.error("ipAgent.rewriteVideoContent.closeBrowser.onError.failed", closeError); } } // 直接抛出错误,返回失败状态 return { success: false, error: error?.message || "视频处理失败", message: "视频仿写失败:" + (error?.message || "未知错误"), debug: { videoUrl: videoUrl, errorType: error?.constructor?.name, errorMessage: error?.message, errorStack: error?.stack } }; } }); // 下载视频 (专门用于视频仿写,返回详细信息) ipcMain.handle("ipAgent:downloadVideo", async (event, params: { url: string }) => { try { const { url } = params; Log.info("ipAgent.downloadVideo", { url }); // 导入视频下载器 const { DouyinVideoDownloader } = await import('./videoDownloader'); // 创建下载器实例 const downloader = new DouyinVideoDownloader(); // 下载视频 const result = await downloader.downloadDouyinVideo(url); if (result.success) { Log.info("ipAgent.downloadVideo.success", { videoPath: result.videoPath, audioPath: result.audioPath }); return { success: true, videoPath: result.videoPath, audioPath: result.audioPath, videoInfo: result.videoInfo }; } else { Log.error("ipAgent.downloadVideo.fail", result.error); return { success: false, message: result.error || "下载失败" }; } } catch (error: any) { Log.error("ipAgent.downloadVideo.error", error); return { success: false, message: "下载视频出错: " + (error?.message || error) }; } }); /** * 提取音频并进行语音识别 */ async function extractAudioAndRecognize(videoPath: string, audioPath: string, asrModelKey?: string): Promise<{ text: string, textPath: string }> { try { const { exec } = await import('child_process'); const { dirname, join, extname } = await import('path'); const { existsSync, mkdirSync, writeFileSync, statSync } = await import('fs'); Log.info("extractAudioAndRecognize.start", { videoPath, audioPath }); // 确保音频目录存在 const audioDir = dirname(audioPath); if (!existsSync(audioDir)) { mkdirSync(audioDir, { recursive: true }); } // 【修复】检查音频文件是否已存在且有效 if (existsSync(audioPath)) { try { const stats = statSync(audioPath); if (stats.size > 0) { Log.info("extractAudioAndRecognize.audioExists", { audioPath, fileSize: stats.size }); // 音频文件已存在且大小有效,直接使用 } else { Log.info("extractAudioAndRecognize.audioFileEmpty", { audioPath }); // 音频文件为空,删除并重新提取 require('fs').unlinkSync(audioPath); } } catch (statError) { Log.info("extractAudioAndRecognize.statError", statError); } } // 如果音频文件不存在,使用FFmpeg提取 if (!existsSync(audioPath)) { Log.info("extractAudioAndRecognize.extractingAudio", { videoPath, audioPath }); // 使用FFmpeg提取音频,⚠️ 使用打包的 FFmpeg,不依赖系统环境 const ffmpegParams = [ '-i', videoPath, '-vn', '-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1', '-y', audioPath ]; Log.info("extractAudioAndRecognize.ffmpeg", { params: ffmpegParams }); try { await execFFmpegCommand(ffmpegParams); Log.info("extractAudioAndRecognize.ffmpeg.success"); } catch (error: any) { Log.error("extractAudioAndRecognize.ffmpeg.error", { error: error.message }); throw new Error(`音频提取失败: ${error.message}`); } // 检查音频文件是否生成成功 if (!existsSync(audioPath)) { throw new Error("音频文件提取失败"); } } // 再次检查提取后的音频文件大小 const audioFileSize = statSync(audioPath).size; if (audioFileSize === 0) { throw new Error("提取的音频文件为空"); } Log.info("extractAudioAndRecognize.audioReady", { audioPath, fileSize: audioFileSize }); // 【修复】使用设置的 ASR 模型进行语音识别 let recognitionText = ''; const textPath = audioPath.replace(extname(audioPath), '.txt'); if (asrModelKey) { // 使用用户设置的 ASR 模型 - 通过前端serverStore调用 Log.info("extractAudioAndRecognize.usingServerModel", { asrModelKey, audioPath }); try { // 获取主窗口以进行IPC通信 const mainWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; if (!mainWindow) { throw new Error("无法获取主窗口,将使用FUNASR降级方案"); } // 创建一个Promise来等待前端的ASR调用响应 const asrResult = await new Promise((resolve, reject) => { let timeoutHandle: NodeJS.Timeout | null = null; let listenerAttached = false; const handleResult = (_event: any, result: any) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } // 移除监听器避免重复触发 if (listenerAttached) { ipcMain.removeListener("ipAgent:asrRecognizeResult", handleResult); listenerAttached = false; } if (result && result.success) { resolve(result.data); } else { reject(new Error(result?.message || "ASR识别失败")); } }; // 先发送请求,然后监听响应 // 发送请求给前端,让前端使用serverStore调用ASR Log.info("extractAudioAndRecognize.beforeSendingRequest", { asrModelKey, audioPath, mainWindowAvailable: !!mainWindow }); mainWindow.webContents.send("ipAgent:performAsrRecognize", { asrModelKey, audioPath }); // 发送请求后才监听响应(避免竞争条件) ipcMain.on("ipAgent:asrRecognizeResult", handleResult); listenerAttached = true; Log.info("extractAudioAndRecognize.sentAsrRequestToFrontend", { asrModelKey, audioPath, timestamp: new Date().toISOString() }); // 设置超时(2分钟) timeoutHandle = setTimeout(() => { if (listenerAttached) { ipcMain.removeListener("ipAgent:asrRecognizeResult", handleResult); listenerAttached = false; } reject(new Error("ASR识别超时(2分钟)")); }, 120000); }); Log.info("extractAudioAndRecognize.serverModel.response", { code: asrResult.code, hasData: !!asrResult.data, dataType: asrResult.data?.type, hasDataData: !!asrResult.data?.data, hasRecords: !!asrResult.data?.data?.records }); // 【修复】ASR返回的数据结构是 data.data.records 而不是 data.result.records if (asrResult.code === 0 && asrResult.data?.type === 'success' && asrResult.data?.data?.records) { const records = asrResult.data.data.records || []; recognitionText = records .map((r: any) => r.text.trim()) .filter((text: string) => text.length > 0) .join('') .replace(/\s+/g, ''); // 去掉所有空格 Log.info("extractAudioAndRecognize.serverModel.success", { recordCount: records.length, textLength: recognitionText.length, textPreview: recognitionText.substring(0, 100) }); } else { throw new Error(`ASR识别失败: code=${asrResult.code}, type=${asrResult.data?.type}, hasRecords=${!!asrResult.data?.data?.records}, msg=${asrResult.msg || '未知错误'}`); } } catch (serverError) { Log.error("extractAudioAndRecognize.serverModel.error", serverError); // 失败时降级使用FUNASR Log.info("extractAudioAndRecognize.fallbackToFunasr", { reason: serverError instanceof Error ? serverError.message : serverError }); const funasrResult = await recognizeAudioWithFunasr(audioPath, { language: 'zh', enableWordTimestamp: true }); recognitionText = funasrResult.segments .map(seg => seg.text.trim()) .filter(text => text.length > 0) .join('') .replace(/\s+/g, ''); Log.info("extractAudioAndRecognize.funasr.fallback.success", { resultLength: recognitionText.length, resultPreview: recognitionText.substring(0, 100) }); } } else { // 降级方案:使用FUNASR进行语音识别 Log.info("extractAudioAndRecognize.funasr.start", { audioPath }); const funasrResult = await recognizeAudioWithFunasr(audioPath, { language: 'zh', enableWordTimestamp: true }); // 将FUNASR结果转换为识别的文本 // 【修复】去掉所有空格,与"声音-语音识别"功能保持一致 recognitionText = funasrResult.segments .map(seg => seg.text.trim()) .filter(text => text.length > 0) .join('') .replace(/\s+/g, ''); // 去掉所有空格(包括字与字之间的空格) Log.info("extractAudioAndRecognize.funasr.success", { resultLength: recognitionText.length, resultPreview: recognitionText.substring(0, 100), segmentCount: funasrResult.segments.length }); } // 保存识别文本到文件(与音频同目录,扩展名改为 .txt) writeFileSync(textPath, recognitionText, 'utf-8'); Log.info("extractAudioAndRecognize.success", { resultLength: recognitionText.length, resultPreview: recognitionText.substring(0, 100), textPath: textPath, usedModel: asrModelKey || 'funasr-fallback' }); Log.info("extractAudioAndRecognize.returningResult", { message: "即将返回识别结果", textLength: recognitionText.length }); return { text: recognitionText, textPath: textPath }; } catch (error) { Log.error("extractAudioAndRecognize.error", error); throw new Error(`音频提取和识别失败: ${error instanceof Error ? error.message : error}`); } } /** * 调用文本模型API */ async function callTextModelAPI(prompt: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiKey: string }): Promise { try { const requestBody = { model: modelConfig.modelId, messages: [ { role: "user", content: prompt } ], max_tokens: 1000, temperature: 0.7 }; // 记录完整的API请求信息 Log.info("callTextModelAPI.REQUEST", { apiUrl: modelConfig.apiUrl, providerId: modelConfig.providerId, modelId: modelConfig.modelId, promptLength: prompt.length, hasApiKey: !!modelConfig.apiKey }); Log.info("callTextModelAPI.REQUEST_FULL_PROMPT", prompt); Log.info("callTextModelAPI.REQUEST_FULL_PAYLOAD", JSON.stringify(requestBody, null, 2)); const response = await fetch(modelConfig.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { const error = await response.text(); Log.error("callTextModelAPI.API_ERROR", { status: response.status, statusText: response.statusText, errorResponse: error }); throw new Error(`API请求失败: ${response.status} ${response.statusText}`); } const result = await response.json(); const content = result.choices?.[0]?.message?.content || result.content || ''; // 记录完整的API响应 Log.info("callTextModelAPI.RESPONSE_FULL", { rawResponse: JSON.stringify(result, null, 2) }); Log.info("callTextModelAPI.RESPONSE_CONTENT", content); if (!content) { Log.error("callTextModelAPI.EMPTY_CONTENT", { response: JSON.stringify(result, null, 2) }); } Log.info("callTextModelAPI.success", { responseLength: content.length, responsePreview: content.substring(0, 100) }); return content.trim(); } catch (error) { Log.error("callTextModelAPI.error", error); throw new Error(`文本模型API调用失败: ${error instanceof Error ? error.message : error}`); } } /** * 直接调用豆包API * @param prompt 提示词 * @param config API配置 * @returns API响应内容 */ async function callDoubaoAPI(prompt: string, config: { apiUrl: string, apiKey: string, modelId: string }): Promise { try { Log.info("ipAgent.callDoubaoAPI.start", { apiUrl: config.apiUrl, modelId: config.modelId, promptLength: prompt.length }); const requestBody = { model: config.modelId, messages: [ { role: "user", content: prompt } ], max_tokens: 500, temperature: 0.7 }; Log.info("ipAgent.callDoubaoAPI.request", { body: JSON.stringify(requestBody, null, 2) }); const response = await fetch(config.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.apiKey}` }, body: JSON.stringify(requestBody) }); Log.info("ipAgent.callDoubaoAPI.response", { status: response.status, statusText: response.statusText }); if (!response.ok) { const errorText = await response.text(); throw new Error(`豆包API调用失败 (${response.status}): ${errorText}`); } const responseData = await response.json(); Log.info("ipAgent.callDoubaoAPI.responseData", { fullResponse: JSON.stringify(responseData, null, 2) }); // 提取内容 const content = responseData.choices?.[0]?.message?.content?.trim(); if (!content) { throw new Error('豆包API返回内容为空'); } Log.info("ipAgent.callDoubaoAPI.success", { contentLength: content.length, contentPreview: content.substring(0, 100) }); return content; } catch (error: any) { Log.error("ipAgent.callDoubaoAPI.error", error); throw new Error(`豆包API调用失败: ${error.message}`); } } /** * 获取视频页面内容 * @param videoUrl 视频URL * @returns 页面文本内容 */ async function fetchVideoPageContent(videoUrl: string): Promise { try { Log.info("ipAgent.fetchVideoPageContent", { videoUrl }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 30000); // 30秒超时 const response = await fetch(videoUrl, { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1' }, signal: controller.signal }); clearTimeout(timeout); if (!response.ok) { throw new Error(`获取页面失败: ${response.status} ${response.statusText}`); } const html = await response.text(); // 提取页面中的文本内容 const pageContent = extractTextFromHtml(html); Log.info("ipAgent.fetchVideoPageContent.success", { contentLength: pageContent.length, htmlLength: html.length }); return pageContent; } catch (error: any) { Log.error("ipAgent.fetchVideoPageContent.error", error); throw new Error(`获取视频页面内容失败: ${error.message}`); } } /** * 从HTML中提取文本内容 * @param html HTML字符串 * @returns 提取的文本内容 */ function extractTextFromHtml(html: string): string { // 移除HTML标签,只保留文本内容 let text = html .replace(/]*>[\s\S]*?<\/script>/gi, '') // 移除脚本 .replace(/]*>[\s\S]*?<\/style>/gi, '') // 移除样式 .replace(/<[^>]+>/g, ' ') // 移除HTML标签 .replace(/\s+/g, ' ') // 合并空白字符 .trim(); // 尝试提取常见的视频文案元素 const patterns = [ // 抖音常见的文案选择器(从已移除的标签中提取) /['"]([^'"]{10,100}?)['"](?=[^a-zA-Z0-9]|$)/g, // 引号内的文本 /文案[::]\s*([^\n]{10,200})/gi, // "文案: xxx" /标题[::]\s*([^\n]{10,200})/gi, // "标题: xxx" /描述[::]\s*([^\n]{10,200})/gi, // "描述: xxx" /内容[::]\s*([^\n]{10,200})/gi, // "内容: xxx" ]; let extractedTexts: string[] = []; for (const pattern of patterns) { const matches = html.match(pattern); if (matches) { extractedTexts = extractedTexts.concat( matches.map(match => match.replace(/^['"]|['"]$/g, '').trim()) ); } } // 如果找到了特定的文案内容,优先使用 if (extractedTexts.length > 0) { // 去重并过滤 const uniqueTexts = [...new Set(extractedTexts)] .filter(text => text.length >= 10 && text.length <= 500) .filter(text => !/^\d+$/.test(text)) // 排除纯数字 .filter(text => !/^[a-zA-Z\s]+$/.test(text)); // 排除纯英文 if (uniqueTexts.length > 0) { return uniqueTexts.join('\n'); } } // 如果没有找到特定文案,使用清理后的页面文本 // 取前500个字符作为主要内容 return text.substring(0, 500); } /** * 使用豆包模型分析内容 * @param content 原始内容 * @param modelConfig 模型配置 * @param rewritePrompt 用户提示词 * @returns 分析结果 */ async function analyzeContentWithDoubao( content: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, rewritePrompt: string ): Promise { try { Log.info("ipAgent.analyzeContentWithDoubao", { contentLength: content.length, modelId: modelConfig.modelId }); // 验证模型配置 if (!modelConfig || !modelConfig.providerId || !modelConfig.modelId) { throw new Error("未提供有效的模型配置"); } if (!modelConfig.apiUrl) { throw new Error("模型API地址未配置"); } if (!modelConfig.apiKey) { throw new Error("模型API密钥未配置"); } // 构建豆包专用的提示词 let prompt = rewritePrompt || `你是一个专业的视频文案分析助手。请分析以下从视频页面提取的HTML内容,识别并提取出其中的视频文案、标题、描述或主要文本内容。 要求: 1. 识别页面中的视频标题、文案描述、字幕文本等内容 2. 过滤掉HTML标签、CSS样式、JavaScript代码等无关内容 3. 提取有意义的中文文本内容 4. 如果页面内容混乱或不完整,请基于可识别的关键信息推断并整理出合理的视频文案 5. 输出应该是一个连贯、完整的视频文案 页面内容: {{content}} 请直接输出整理后的视频文案(只输出文案内容,不要其他说明):`; // 替换变量 prompt = prompt.replace(/\{\{content\}\}/g, content); prompt = prompt.replace(/\{content\}/g, content); Log.info("ipAgent.analyzeContentWithDoubao.config", { apiUrl: modelConfig.apiUrl, modelId: modelConfig.modelId, providerId: modelConfig.providerId, promptLength: prompt.length }); // 调用豆包模型API const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 60000); // 60秒超时 let requestBody: any; // 火山引擎豆包模型使用标准的OpenAI格式 requestBody = { model: modelConfig.modelId, messages: [ { role: "user", content: prompt } ], max_tokens: 2000, temperature: 0.7, top_p: 0.9 }; const aiResponse = await fetch(modelConfig.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${modelConfig.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody), signal: controller.signal }); clearTimeout(timeout); if (!aiResponse.ok) { const errorText = await aiResponse.text(); Log.error("ipAgent.analyzeContentWithDoubao.httpError", { status: aiResponse.status, statusText: aiResponse.statusText, error: errorText.substring(0, 500) }); throw new Error(`豆包模型调用失败 (HTTP ${aiResponse.status}): ${errorText.substring(0, 200)}`); } const aiData = await aiResponse.json(); // 详细记录API响应结构以便调试 Log.info("ipAgent.analyzeContentWithDoubao.responseStructure", { hasChoices: !!aiData.choices, hasResult: !!aiData.result, hasText: !!aiData.text, hasContent: !!aiData.content, hasOutput: !!aiData.output, dataKeys: Object.keys(aiData), fullResponse: JSON.stringify(aiData, null, 2) }); // 尝试多种响应格式的解析 let analyzedText = aiData.choices?.[0]?.message?.content?.trim() || aiData.result?.trim() || aiData.text?.trim() || aiData.content?.trim() || aiData.output?.text?.trim() || aiData.data?.content?.trim() || aiData.message?.content?.trim(); // 如果还是没有找到,尝试更深层级的查找 if (!analyzedText) { for (const key in aiData) { const value = aiData[key]; if (typeof value === 'object' && value !== null) { if (value.content && typeof value.content === 'string') { analyzedText = value.content.trim(); break; } if (value.text && typeof value.text === 'string') { analyzedText = value.text.trim(); break; } if (value.result && typeof value.result === 'string') { analyzedText = value.result.trim(); break; } } if (typeof value === 'string' && value.length > 10) { analyzedText = value.trim(); break; } } } if (!analyzedText) { Log.error("ipAgent.analyzeContentWithDoubao.emptyResponse", { aiData: aiData, responseType: typeof aiData, responseKeys: Object.keys(aiData) }); throw new Error(`豆包模型返回内容为空。响应结构: ${JSON.stringify(Object.keys(aiData))}`); } Log.info("ipAgent.analyzeContentWithDoubao.success", { resultLength: analyzedText.length, resultPreview: analyzedText.substring(0, 100) }); return analyzedText; } catch (error: any) { Log.error("ipAgent.analyzeContentWithDoubao.error", error); throw new Error(`豆包模型分析失败: ${error.message}`); } } // 新版字幕生成接口(使用ZimuShengcheng服务) // 流程:前端执行字幕处理 -> 主进程执行FFmpeg -> 返回结果 ipcMain.handle("ipAgent:generateSubtitleV2", async (event, params: { videoPath: string, videoWidth: number, videoHeight: number, userText: string, funasrSegments: any[], subtitleStyle: any, keywordGroups: any[], keywordRenderMode?: 'floating' | 'inline-emphasis', fontDir: string, outputPath: string }) => { try { const { videoPath, videoWidth, videoHeight, userText, funasrSegments, subtitleStyle, keywordGroups, keywordRenderMode, fontDir, outputPath } = params; Log.info("ipAgent.generateSubtitleV2.start", { videoPath, videoWidth, videoHeight, hasUserText: !!userText, segmentsCount: funasrSegments.length, hasKeywordGroups: keywordGroups.length > 0 }); // 获取主窗口以进行IPC通信 const mainWindow = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]; if (!mainWindow) { throw new Error("无法获取主窗口"); } // 第一步:请求前端执行字幕生成逻辑 Log.info("ipAgent.generateSubtitleV2.requestFrontend", { step: "requesting frontend to process subtitle" }); // 创建一个Promise来等待前端的响应 const processingResult = await new Promise((resolve, reject) => { let timeoutHandle: NodeJS.Timeout | null = null; const handleResult = (_event: any, result: any) => { if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } if (result && result.success) { resolve(result); } else { reject(new Error(result?.message || "字幕处理失败")); } }; // 使用 ipcMain.once() 来监听前端的一次性响应 ipcMain.once("ipAgent:generateSubtitleV2Result", handleResult); // 设置超时 timeoutHandle = setTimeout(() => { ipcMain.removeListener("ipAgent:generateSubtitleV2Result", handleResult); reject(new Error("前端字幕处理超时(5分钟)")); }, 300000); // 5分钟超时 // 发送请求给前端 mainWindow.webContents.send("ipAgent:performSubtitleGeneration", { videoPath, videoWidth, videoHeight, userText, funasrSegments, subtitleStyle, keywordGroups, keywordRenderMode, fontDir, outputPath }); Log.info("ipAgent.generateSubtitleV2.sentToFrontend", { timestamp: new Date().toISOString() }); }); Log.info("ipAgent.generateSubtitleV2.frontendCompleted", { assNormalPath: processingResult.assNormalPath, assEffectPath: processingResult.assEffectPath, ffmpegCommand: processingResult.ffmpegCommand?.substring(0, 100) + "..." }); // 第二步:如果前端生成了FFmpeg命令,执行它 if (processingResult.ffmpegCommand) { Log.info("ipAgent.generateSubtitleV2.executingFFmpeg", { command: processingResult.ffmpegCommand.substring(0, 100) + "..." }); const execResult = await new Promise((resolve, reject) => { // 🔧 修复:准备环境变量,确保 FFmpeg 能找到所有依赖 const env = buildRuntimeProcessEnv(); // ✅ 添加 FFmpeg 目录到 PATH,确保 Windows 下的 DLL 依赖能被找到 const ffmpegPath = getFFmpegExecutablePath(); const ffmpegDir = path.dirname(ffmpegPath); const pathSep = process.platform === 'win32' ? ';' : ':'; if (!env['PATH']) { env['PATH'] = ffmpegDir; } else if (!env['PATH'].includes(ffmpegDir)) { env['PATH'] = ffmpegDir + pathSep + env['PATH']; } Log.info("ipAgent.generateSubtitleV2.preparingEnv", { ffmpegDir, pathSet: env['PATH']?.substring(0, 100) + "..." }); const child = spawn('cmd', ['/c', processingResult.ffmpegCommand], { maxBuffer: 100 * 1024 * 1024, shell: true, env // ✅ 关键修复:传递环境变量给子进程 }); let stdout = ''; let stderr = ''; child.stdout?.on('data', (data: any) => { const dataString = data.toString(); stdout += dataString; Log.info("ipAgent.generateSubtitleV2.ffmpegStdout", dataString.substring(0, 200)); }); child.stderr?.on('data', (data: any) => { const dataString = data.toString(); stderr += dataString; Log.info("ipAgent.generateSubtitleV2.ffmpegStderr", dataString.substring(0, 200)); }); child.on('close', (code: number) => { if (code === 0) { Log.info("ipAgent.generateSubtitleV2.ffmpegSuccess", { code, outputPath }); resolve({ success: true, code }); } else { Log.error("ipAgent.generateSubtitleV2.ffmpegError", { code, stderr: stderr.substring(0, 500) }); reject(new Error(`FFmpeg执行失败,代码: ${code}`)); } }); child.on('error', (err: any) => { Log.error("ipAgent.generateSubtitleV2.ffmpegProcessError", err); reject(err); }); }); } // 返回最终结果 const result = { success: true, outputPath: outputPath, message: "字幕生成成功", statistics: processingResult.statistics }; Log.info("ipAgent.generateSubtitleV2.success", { outputPath: result.outputPath, statistics: result.statistics }); return result; } catch (error: any) { Log.error("ipAgent.generateSubtitleV2.error", { errorType: error?.constructor?.name, errorMessage: error?.message, errorStack: error?.stack?.substring(0, 500) }); return { success: false, message: "新版字幕生成失败:" + (error?.message || "未知错误"), error: error?.message }; } }); // 获取视频时长 ipcMain.handle("ipAgent:getVideoDuration", async (event, params: { videoPath: string }) => { try { let { videoPath } = params; // 验证输入 if (!videoPath || typeof videoPath !== 'string') { Log.error("ipAgent.getVideoDuration.invalidInput", { videoPath }); return { success: false, message: "无效的视频路径" }; } // 移除file://前缀 videoPath = videoPath.replace(/^file:\/\//, ''); // 验证文件存在 if (!fs.existsSync(videoPath)) { Log.error("ipAgent.getVideoDuration.fileNotFound", { videoPath }); return { success: false, message: "视频文件不存在" }; } Log.info("ipAgent.getVideoDuration", { videoPath, message: "获取视频时长..." }); // 使用FFmpeg获取视频时长 const ffmpegArgs = [ '-i', videoPath ]; let result = ''; try { result = await execFFmpegCommand(ffmpegArgs); } catch (err: any) { // FFmpeg命令会失败,但我们从错误信息中提取Duration result = err.message || err.toString(); } // 从输出中提取Duration: HH:MM:SS.ms格式 const durationMatch = result.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/); if (durationMatch) { const hours = parseInt(durationMatch[1], 10); const minutes = parseInt(durationMatch[2], 10); const seconds = parseFloat(durationMatch[3]); const duration = hours * 3600 + minutes * 60 + seconds; Log.info("ipAgent.getVideoDuration.success", { duration, message: "成功获取视频时长" }); return { success: true, duration: duration }; } Log.warn("ipAgent.getVideoDuration.couldNotExtract", { videoPath }); return { success: false, message: "无法从视频中提取时长信息" }; } catch (error: any) { Log.error("ipAgent.getVideoDuration.error", error); return { success: false, message: "获取视频时长失败: " + (error?.message || error) }; } }); // 执行视频混剪处理 ipcMain.handle("ipAgent:processVideoMixCut", async (event, params: { inputVideo: string, replacements: Array<{ startTime: number, duration: number, materialPath: string, materialDuration: number, materialStartOffset: number, reason: string, // 🔧 新增:每个素材的配置 displayMode?: 'pip' | 'fullscreen', pipPosition?: string, pipSizePercent?: number, pipScaleMode?: string, // 🆕 新增:原视频最小化配置 originalVideoMinimized?: { enabled: boolean, shape: 'square' | 'circle', position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center', sizePercent: number } }>, outputPath: string, videoResolution: { width: number; height: number }, videoDuration: number, // 原视频时长,用于限制输出 displayMode?: 'pip' | 'fullscreen' // 显示模式: pip=画中画, fullscreen=全屏替换 (已废弃,优先使用per-replacement配置) }) => { try { let { inputVideo, replacements, outputPath, videoResolution, videoDuration, displayMode = 'pip' } = params; // 验证输入 if (!inputVideo || !Array.isArray(replacements) || !outputPath) { return { success: false, message: "参数不完整" }; } // 移除file://前缀 inputVideo = inputVideo.replace(/^file:\/\//, ''); // 验证文件存在 if (!fs.existsSync(inputVideo)) { return { success: false, message: "输入视频文件不存在" }; } // 验证素材文件 for (const replacement of replacements) { if (!fs.existsSync(replacement.materialPath)) { return { success: false, message: `素材文件不存在: ${replacement.materialPath}` }; } } Log.info("ipAgent.processVideoMixCut", { inputVideo, replacementCount: replacements.length, outputPath, displayMode: displayMode, message: "开始混剪处理..." }); // 构建FFmpeg filter_complex命令 let filterComplex = ''; let inputFiles = [inputVideo]; let currentStream = '[0:v]'; // 🔧 修复:处理重叠替换点,当后续替换点开始时立即停止前序替换点 // 计算每个替换点的实际结束时间(考虑后续替换点的干扰) const adjustedReplacements = replacements.map((r, i) => { let actualEndTime = r.startTime + Math.min(r.duration, r.materialDuration); // 检查是否有后续替换点在当前替换点的播放期间开始 for (let j = i + 1; j < replacements.length; j++) { const nextR = replacements[j]; // 如果后续替换点在当前替换点的播放期间开始,立即停止当前替换点 if (nextR.startTime < actualEndTime) { actualEndTime = nextR.startTime; } } return { ...r, actualEndTime }; }); console.log('[processVideoMixCut] 📊 替换点时间调整详情:'); adjustedReplacements.forEach((r, i) => { const originalEnd = r.startTime + Math.min(r.duration, r.materialDuration); console.log(`[${i}] startTime=${r.startTime.toFixed(3)}, originalEnd=${originalEnd.toFixed(3)}, actualEnd=${r.actualEndTime.toFixed(3)}`); }); for (let i = 0; i < adjustedReplacements.length; i++) { const r = adjustedReplacements[i]; const inputIndex = i + 1; const materialStream = `[${inputIndex}:v]`; const trimmedMaterialStream = `[m_trim${i}]`; const processedMaterialStream = `[m${i}]`; // 🔧 修复:优先使用 per-replacement 配置,再使用全局 displayMode const mode = r.displayMode || displayMode || 'fullscreen'; // 素材预处理:根据显示模式缩放 let scaleFilter = ''; let overlayParam = ''; if (mode === 'fullscreen') { // 全屏替换模式:素材缩放到原视频分辨率,全屏显示,居中 // 使用模糊背景填充空余部分(模糊度:25) scaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height},boxblur=25:2`; overlayParam = '0:0'; // 左上角对齐 } else { // 画中画模式:根据配置缩放和定位 const pipSizePercent = r.pipSizePercent || 30; const pipWidth = Math.round(videoResolution.width * pipSizePercent / 100); const pipHeight = Math.round(videoResolution.height * pipSizePercent / 100); const pipScaleMode = r.pipScaleMode || 'fit'; // 根据缩放模式生成 scale filter let pipScaleFilter = ''; if (pipScaleMode === 'fit') { // 保持比例 + 黑边 pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=decrease`; } else if (pipScaleMode === 'fill') { // 保持比例 + 裁剪 pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=increase,crop=${pipWidth}:${pipHeight}`; } else { // stretch - 强制拉伸 pipScaleFilter = `scale=${pipWidth}:${pipHeight}`; } // 计算 overlay 位置 const pipPos = r.pipPosition || 'bottom-right'; let pipX = 0, pipY = 0; switch (pipPos) { case 'top-left': pipX = 10; pipY = 10; break; case 'top-center': pipX = (videoResolution.width - pipWidth) / 2; pipY = 10; break; case 'top-right': pipX = videoResolution.width - pipWidth - 10; pipY = 10; break; case 'center-left': pipX = 10; pipY = (videoResolution.height - pipHeight) / 2; break; case 'center': pipX = (videoResolution.width - pipWidth) / 2; pipY = (videoResolution.height - pipHeight) / 2; break; case 'center-right': pipX = videoResolution.width - pipWidth - 10; pipY = (videoResolution.height - pipHeight) / 2; break; case 'bottom-left': pipX = 10; pipY = videoResolution.height - pipHeight - 10; break; case 'bottom-center': pipX = (videoResolution.width - pipWidth) / 2; pipY = videoResolution.height - pipHeight - 10; break; case 'bottom-right': default: pipX = videoResolution.width - pipWidth - 10; pipY = videoResolution.height - pipHeight - 10; break; } scaleFilter = pipScaleFilter; overlayParam = `${Math.round(pipX)}:${Math.round(pipY)}`; } // 🔧 修复:区分图片和视频处理 const actualDuration = Math.min(r.duration, r.materialDuration); // 判断是否为图片素材 if (r.isImage) { // 对于图片:使用 fps 将静态图片转换为视频流 // fps=25 表示 25 帧/秒,实际时长由 setpts 控制 const fpsFilter = `fps=25`; const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`; filterComplex += `${materialStream}${fpsFilter},${setptsFilter}${trimmedMaterialStream};`; // 全屏模式:根据是否开启原视频最小化选择不同的缩放方式 if (mode === 'fullscreen') { // 🔧 修复:开启原视频最小化时,素材需要填满屏幕(increase+crop) const originalMinimized = r.originalVideoMinimized; if (originalMinimized?.enabled) { // 素材全屏铺满:裁剪填充,无黑边 const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`; filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`; } else { // 素材保持比例缩放,留黑边(配合模糊背景) const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`; filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`; } } else { filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`; } } else { // 对于视频:使用 trim 提取片段,然后调整时间戳 // 1. trim 提取素材片段(从 materialStartOffset 开始) const trimFilter = `trim=start=${r.materialStartOffset.toFixed(3)}:duration=${actualDuration.toFixed(3)}`; // 2. setpts 调整时间戳:先重置到0,再偏移到输出时间 r.startTime // 这样素材的时间戳就与 overlay enable 的时间范围对齐了 // 例如:素材 0-4s → PTS-STARTPTS → 0-4s → +12.68/TB → 12.68-16.68s const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`; filterComplex += `${materialStream}${trimFilter},${setptsFilter}${trimmedMaterialStream};`; // 全屏模式:根据是否开启原视频最小化选择不同的缩放方式 if (mode === 'fullscreen') { // 🔧 修复:开启原视频最小化时,素材需要填满屏幕(increase+crop) const originalMinimized = r.originalVideoMinimized; if (originalMinimized?.enabled) { // 素材全屏铺满:裁剪填充,无黑边 const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`; filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`; } else { // 素材保持比例缩放,留黑边(配合模糊背景) const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`; filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`; } } else { filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`; } } // overlay:在指定时间段显示素材 const outputStream = `[v${i}]`; // 🔧 修复:使用半开区间 [start, end) 避免边界重叠 // between() 是闭区间会导致两个素材在交界点同时显示 // 改用 gte(t,start)*lt(t,end) 实现半开区间,确保后续素材开始时前序素材立即停止 const enableExpr = `gte(t,${r.startTime.toFixed(3)})*lt(t,${r.actualEndTime.toFixed(3)})`; // 🔧 全屏模式处理 if (mode === 'fullscreen') { const originalMinimized = r.originalVideoMinimized; if (originalMinimized?.enabled) { // 🆕 场景:素材全屏 + 原视频小窗口 const { shape, position, sizePercent } = originalMinimized; const originalSize = Math.floor(videoResolution.width * (sizePercent / 100)); // 1. 使用split将原视频流分成两份 const splitStream1 = `[split${i}_1]`; const splitStream2 = `[split${i}_2]`; filterComplex += `${currentStream}split=2${splitStream1}${splitStream2};`; // 2. 从第一份生成原视频小窗口(裁剪填满,无黑边) const origStream = `[orig${i}]`; let originalScaled = `${splitStream1}scale=${originalSize}:${originalSize}:force_original_aspect_ratio=increase,crop=${originalSize}:${originalSize}`; // 3. 如果是圆形,添加圆形mask if (shape === 'circle') { const radius = originalSize / 2; // 转换为RGBA,然后使用geq创建圆形遮罩,保持RGB不变,只修改Alpha originalScaled += `,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='if(lte(hypot(X-${radius},Y-${radius}),${radius}),255,0)'`; } originalScaled += origStream; filterComplex += originalScaled + ';'; // 4. 从第二份叠加素材全屏 const tmpStream = `[tmp${i}]`; filterComplex += `${splitStream2}${processedMaterialStream}overlay=0:0:enable='${enableExpr}'${tmpStream};`; // 5. 计算位置坐标 const padding = 50; let x = 0, y = 0; switch (position) { case 'top-left': x = padding; y = padding; break; case 'top-right': x = videoResolution.width - originalSize - padding; y = padding; break; case 'bottom-left': x = padding; y = videoResolution.height - originalSize - padding; break; case 'bottom-right': x = videoResolution.width - originalSize - padding; y = videoResolution.height - originalSize - padding; break; case 'center': x = (videoResolution.width - originalSize) / 2; y = (videoResolution.height - originalSize) / 2; break; } // 6. 叠加原视频小窗口到tmpStream上 filterComplex += `${tmpStream}${origStream}overlay=${Math.round(x)}:${Math.round(y)}:enable='${enableExpr}'${outputStream};`; } else { // 原有的全屏替换逻辑(模糊背景) const blurredBgStream = `[blurred_bg${i}]`; filterComplex += `${currentStream}boxblur=25:2:enable='${enableExpr}'${blurredBgStream};`; filterComplex += `${blurredBgStream}${processedMaterialStream}overlay=(W-w)/2:(H-h)/2:enable='${enableExpr}'${outputStream};`; } } else { filterComplex += `${currentStream}${processedMaterialStream}overlay=${overlayParam}:enable='${enableExpr}'${outputStream};`; } inputFiles.push(r.materialPath); currentStream = outputStream; } // 移除最后的分号 filterComplex = filterComplex.slice(0, -1); // 添加trim滤镜限制输出时长为原视频时长 // 这确保输出视频不会因为素材或其他原因而增长 filterComplex += `; ${currentStream}trim=start=0:duration=${videoDuration}[final_video]`; // 构建FFmpeg命令 const ffmpegArgs = [ ...inputFiles.flatMap(f => ['-i', f]), '-filter_complex', filterComplex, '-map', '[final_video]', // 使用trim后的最终视频流 '-map', '0:a?', // 原视频的音频 '-c:a', 'copy', // 音频直接复制不重新编码 '-c:v', 'libx264', // 视频使用H.264编码 '-preset', 'medium', '-shortest', // 输出时长为最短的流(这里应该是原视频) '-y', outputPath ]; Log.info("ipAgent.processVideoMixCut.command", { filterComplex: filterComplex, fullCommand: ffmpegArgs.join(' '), args: ffmpegArgs.length }); // 🔧 调试:输出完整的 filter_complex 命令到控制台 console.log('[processVideoMixCut] filter_complex:', filterComplex); console.log('[processVideoMixCut] 替换点详情:'); replacements.forEach((r, idx) => { console.log(` [${idx}] ${r.startTime.toFixed(3)}s - ${(r.startTime + Math.min(r.duration, r.materialDuration)).toFixed(3)}s (${Math.min(r.duration, r.materialDuration).toFixed(3)}s) - ${r.materialPath}`); }); await execFFmpegCommand(ffmpegArgs); Log.info("ipAgent.processVideoMixCut.success", { outputPath, message: "混剪处理完成" }); return { success: true, outputVideo: outputPath, message: "混剪处理完成" }; } catch (error: any) { Log.error("ipAgent.processVideoMixCut.error", error); return { success: false, message: "混剪处理失败: " + (error?.message || error) }; } }); // ========== 视频仿写优化 - 新增处理器 ========== // 处理抖音分享文本(完整流程:下载视频 + 语音识别 + 仿写) ipcMain.handle("ipAgent:processDouyinShare", async (event, params: { shareText: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, rewriteConfig?: { style?: 'casual' | 'professional' | 'emotional' | 'humorous', length?: 'short' | 'medium' | 'long', keepHashtags?: boolean, keepEmoji?: boolean }, customPrompt?: string }) => { try { const { shareText, modelConfig, rewriteConfig, customPrompt } = params; Log.info("ipAgent.processDouyinShare", { textLength: shareText.length, modelId: modelConfig?.modelId, rewriteConfig, mode: 'complete-with-video-and-asr' // 标记使用完整流程 }); // 动态导入集成模块 const { VideoRewriteIntegration } = await import('./videoRewriteIntegration'); // 使用完整流程:下载视频 + 音频识别 + 改写 const result = await VideoRewriteIntegration.processDouyinShareComplete( shareText, modelConfig, rewriteConfig, customPrompt, (status: string) => { Log.info("ipAgent.processDouyinShare.progress", { status }); } ); if (result.success) { Log.info("ipAgent.processDouyinShare.success", { videoUrl: result.original?.videoUrl, originalLength: result.original?.description?.length, rewrittenLength: result.rewritten?.description?.length, method: 'video-download-and-asr' }); } else { Log.error("ipAgent.processDouyinShare.failed", { error: result.error }); } return result; } catch (error) { Log.error("ipAgent.processDouyinShare.error", error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // 完整的视频处理流程:下载 + 音频识别 + 文案改写 ipcMain.handle("ipAgent:processDouyinShareComplete", async (event, params: { shareText: string, modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, rewriteConfig?: { style?: 'casual' | 'professional' | 'emotional' | 'humorous', length?: 'short' | 'medium' | 'long', keepHashtags?: boolean, keepEmoji?: boolean }, customPrompt?: string }) => { try { const { shareText, modelConfig, rewriteConfig, customPrompt } = params; Log.info("ipAgent.processDouyinShareComplete", { textLength: shareText.length, modelId: modelConfig?.modelId, rewriteConfig }); // 动态导入集成模块 const { VideoRewriteIntegration } = await import('./videoRewriteIntegration'); const result = await VideoRewriteIntegration.processDouyinShareComplete( shareText, modelConfig, rewriteConfig, customPrompt, (status: string) => { // 可以通过 WebSocket 或其他方式发送进度更新 Log.info("ipAgent.processDouyinShareComplete.progress", { status }); } ); if (result.success) { Log.info("ipAgent.processDouyinShareComplete.success", { videoUrl: result.original?.videoUrl, originalLength: result.original?.description?.length, rewrittenLength: result.rewritten?.description?.length }); } else { Log.error("ipAgent.processDouyinShareComplete.failed", { error: result.error }); } return result; } catch (error) { Log.error("ipAgent.processDouyinShareComplete.error", error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // 批量处理抖音分享文本 ipcMain.handle("ipAgent:processBatchDouyinShares", async (event, params: { shareTexts: string[], modelConfig: { providerId: string, modelId: string, apiUrl: string, apiHost: string, apiKey: string, type: string }, rewriteConfig?: { style?: 'casual' | 'professional' | 'emotional' | 'humorous', length?: 'short' | 'medium' | 'long', keepHashtags?: boolean, keepEmoji?: boolean }, customPrompt?: string }) => { try { const { shareTexts, modelConfig, rewriteConfig, customPrompt } = params; Log.info("ipAgent.processBatchDouyinShares", { count: shareTexts.length, modelId: modelConfig?.modelId }); // 动态导入集成模块 const { VideoRewriteIntegration } = await import('./videoRewriteIntegration'); const results = await VideoRewriteIntegration.processBatchDouyinShares( shareTexts, modelConfig, rewriteConfig, customPrompt ); const successCount = results.filter(r => r.success).length; Log.info("ipAgent.processBatchDouyinShares.complete", { total: results.length, success: successCount, failed: results.length - successCount }); return { success: true, results }; } catch (error) { Log.error("ipAgent.processBatchDouyinShares.error", error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // 导出所有设置 ipcMain.handle('ipAgent:exportSettings', async (event, data) => { try { // 如果前端传来了数据,使用前端传的数据(整合了所有配置) // 如果没传(旧逻辑),则只读取 ipAgent/settings.json let settingsToExport = data; if (!settingsToExport) { const settingsPath = getSettingsFilePath(); if (fs.existsSync(settingsPath)) { const content = fs.readFileSync(settingsPath, 'utf-8'); settingsToExport = JSON.parse(content); } else { settingsToExport = {}; } } // 弹出保存对话框 const result = await dialog.showSaveDialog({ title: '导出设置', defaultPath: `ip-agent-config-${new Date().toISOString().slice(0, 10)}.json`, filters: [ { name: 'JSON配置文件', extensions: ['json'] } ] }); if (!result.canceled && result.filePath) { // 保存设置到用户选择的文件 fs.writeFileSync(result.filePath, JSON.stringify(settingsToExport, null, 2), 'utf-8'); Log.info('ipAgent.exportSettings.success', { filePath: result.filePath }); return { success: true, filePath: result.filePath }; } return { success: false, error: '用户取消导出' }; } catch (error) { Log.error('ipAgent.exportSettings.error', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // 导入所有设置 ipcMain.handle('ipAgent:importSettings', async () => { try { // 弹出文件选择对话框 const result = await dialog.showOpenDialog({ title: '导入设置', filters: [ { name: 'JSON配置文件', extensions: ['json'] } ], properties: ['openFile'] }); if (result.canceled || !result.filePaths || result.filePaths.length === 0) { return { success: false, error: '用户取消导入' }; } const importPath = result.filePaths[0]; // 读取配置文件 const content = fs.readFileSync(importPath, 'utf-8'); const importedSettings = JSON.parse(content); // 保存到设置文件 const settingsPath = getSettingsFilePath(); ensureSettingsDir(); fs.writeFileSync(settingsPath, JSON.stringify(importedSettings, null, 2), 'utf-8'); Log.info('ipAgent.importSettings.success', { filePath: importPath }); return { success: true, filePath: importPath, settings: importedSettings }; } catch (error) { Log.error('ipAgent.importSettings.error', error); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); }; // 结束 registerHandlers 函数 export default { init: () => { registerHandlers(); registerCoverTemplateHandlers(); registerSubtitleTemplateHandlers(); Log.info("ipAgent.module.initialized"); console.log("ipAgent.module.initialized"); console.log('ipAgent module initialized, handlers should already be registered'); } };