7398 lines
326 KiB
TypeScript
7398 lines
326 KiB
TypeScript
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<string>();
|
||
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<string, { color: string, fontName?: string }>();
|
||
|
||
// 精确匹配{\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<string, string> = 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<string>(); // 追踪已被关键词分组处理的关键词
|
||
const processedKeywordsInThisLine = new Set<string>(); // 【关键修复】追踪当前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<number, { groupId: string, groupIndex: number }>();
|
||
|
||
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<string>();
|
||
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<void>((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<any>((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<string> {
|
||
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<string> {
|
||
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<string> {
|
||
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(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // 移除脚本
|
||
.replace(/<style[^>]*>[\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<string> {
|
||
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');
|
||
}
|
||
};
|