Initial clean project import
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
export const DataService = {
|
||||
async saveFile(
|
||||
file: string,
|
||||
option?: {
|
||||
ext?: string;
|
||||
saveGroup?: string;
|
||||
}
|
||||
) {
|
||||
return await window.$mapi.file.hubSave(file, {
|
||||
...option,
|
||||
});
|
||||
},
|
||||
async saveBuffer(ext: string, data: Uint8Array, saveGroup?: string) {
|
||||
const path = await window.$mapi.file.temp(ext);
|
||||
await window.$mapi.file.writeBuffer(path, data, {
|
||||
isDataPath: false,
|
||||
});
|
||||
const result = await this.saveFile(path, {saveGroup});
|
||||
await window.$mapi.file.deletes(path, {
|
||||
isDataPath: false,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
// @ts-nocheck
|
||||
import type { KeywordGroup } from '../types/smartSubtitle';
|
||||
|
||||
/**
|
||||
* 关键词分组服务 - 用于数据库持久化和查询
|
||||
*/
|
||||
class KeywordGroupService {
|
||||
/**
|
||||
* 清理关键词分组对象 - 移除不可序列化的内容
|
||||
*/
|
||||
private cleanGroup(group: KeywordGroup): KeywordGroup {
|
||||
// 确保 keywords 是纯数组,不是 Proxy
|
||||
let cleanedKeywords: any[] = [];
|
||||
if (Array.isArray(group.keywords)) {
|
||||
cleanedKeywords = group.keywords.map(kw => {
|
||||
if (typeof kw === 'string') {
|
||||
return { keyword: kw, enabled: true };
|
||||
} else if (typeof kw === 'object' && kw !== null) {
|
||||
return {
|
||||
keyword: String(kw.keyword || ''),
|
||||
enabled: Boolean(kw.enabled !== false)
|
||||
};
|
||||
}
|
||||
return { keyword: '', enabled: true };
|
||||
});
|
||||
}
|
||||
|
||||
// 创建完全新的纯 JavaScript 对象
|
||||
const cleanedGroup = {
|
||||
id: String(group.id || ''),
|
||||
name: String(group.name || ''),
|
||||
description: group.description ? String(group.description) : undefined,
|
||||
keywords: cleanedKeywords,
|
||||
color: group.color ? String(group.color) : undefined,
|
||||
effectId: group.effectId ? String(group.effectId) : undefined,
|
||||
effectDuration: typeof group.effectDuration === 'number' ? group.effectDuration : undefined, // 🆕 特效时长
|
||||
stickerId: group.stickerId ? String(group.stickerId) : undefined, // 🆕 贴纸ID
|
||||
stickerConfig: group.stickerConfig ? JSON.parse(JSON.stringify(group.stickerConfig)) : undefined, // 🔧 深度克隆移除Proxy
|
||||
soundEffectId: group.soundEffectId ? String(group.soundEffectId) : undefined, // 🆕 音效ID
|
||||
soundEffectConfig: group.soundEffectConfig ? JSON.parse(JSON.stringify(group.soundEffectConfig)) : undefined, // 🔧 深度克隆移除Proxy
|
||||
styleOverride: group.styleOverride ? JSON.parse(JSON.stringify(group.styleOverride)) : undefined,
|
||||
effectConfig: group.effectConfig ? JSON.parse(JSON.stringify(group.effectConfig)) : undefined,
|
||||
styleConfig: group.styleConfig ? JSON.parse(JSON.stringify(group.styleConfig)) : undefined,
|
||||
createdAt: typeof group.createdAt === 'number' ? group.createdAt : Date.now(),
|
||||
updatedAt: typeof group.updatedAt === 'number' ? group.updatedAt : Date.now()
|
||||
};
|
||||
|
||||
// 🔍 在返回前记录完整的 cleanedGroup 对象,确保 stickerId 被保留
|
||||
console.log('[KeywordGroupService.cleanGroup] 📦 完整的 cleanedGroup:', JSON.stringify({
|
||||
id: cleanedGroup.id,
|
||||
name: cleanedGroup.name,
|
||||
stickerId: cleanedGroup.stickerId,
|
||||
stickerConfig: cleanedGroup.stickerConfig
|
||||
}, null, 2));
|
||||
|
||||
// 清理 styleOverride - 确保只包含基本类型
|
||||
if (group.styleOverride && typeof group.styleOverride === 'object') {
|
||||
cleanedGroup.styleOverride = {
|
||||
fontName: group.styleOverride.fontName ? String(group.styleOverride.fontName) : undefined,
|
||||
fontSize: typeof group.styleOverride.fontSize === 'number' ? group.styleOverride.fontSize : undefined,
|
||||
fontColor: group.styleOverride.fontColor ? String(group.styleOverride.fontColor) : undefined,
|
||||
outlineColor: group.styleOverride.outlineColor ? String(group.styleOverride.outlineColor) : undefined,
|
||||
outlineWidth: typeof group.styleOverride.outlineWidth === 'number' ? group.styleOverride.outlineWidth : undefined,
|
||||
backgroundColor: group.styleOverride.backgroundColor ? String(group.styleOverride.backgroundColor) : undefined,
|
||||
backgroundOpacity: typeof group.styleOverride.backgroundOpacity === 'number' ? group.styleOverride.backgroundOpacity : undefined,
|
||||
position: group.styleOverride.position ? String(group.styleOverride.position) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[KeywordGroupService.cleanGroup] 清理后的对象:', {
|
||||
id: cleanedGroup.id,
|
||||
name: cleanedGroup.name,
|
||||
keywordsCount: cleanedGroup.keywords.length,
|
||||
hasStyleOverride: !!cleanedGroup.styleOverride,
|
||||
// 🔍 关键:检查贴纸和音效字段
|
||||
stickerId: cleanedGroup.stickerId,
|
||||
hasStickerConfig: !!cleanedGroup.stickerConfig,
|
||||
soundEffectId: cleanedGroup.soundEffectId,
|
||||
hasSoundEffectConfig: !!cleanedGroup.soundEffectConfig,
|
||||
effectDuration: cleanedGroup.effectDuration
|
||||
});
|
||||
|
||||
return cleanedGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有关键词分组
|
||||
*/
|
||||
async getAllGroups(): Promise<KeywordGroup[]> {
|
||||
try {
|
||||
// 检查 IPC 接口是否存在
|
||||
if (!window.$mapi) {
|
||||
console.error('[KeywordGroupService.getAllGroups] ❌ window.$mapi 未定义');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!window.$mapi.keywordGroup) {
|
||||
console.error('[KeywordGroupService.getAllGroups] ❌ window.$mapi.keywordGroup 未定义');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!window.$mapi.keywordGroup.getAllGroups) {
|
||||
console.error('[KeywordGroupService.getAllGroups] ❌ getAllGroups 方法未定义');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('[KeywordGroupService.getAllGroups] 准备调用 IPC getAllGroups...');
|
||||
const result = await window.$mapi.keywordGroup.getAllGroups();
|
||||
|
||||
console.log('[KeywordGroupService.getAllGroups] IPC 返回结果:', {
|
||||
isArray: Array.isArray(result),
|
||||
length: result?.length || 0,
|
||||
groups: Array.isArray(result) ? result.map(g => ({ name: g.name, id: g.id })) : 'not an array'
|
||||
});
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
console.warn('[KeywordGroupService.getAllGroups] ⚠️ 返回值不是数组:', typeof result);
|
||||
return [];
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[KeywordGroupService.getAllGroups] ❌ 异常错误:', {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存关键词分组(新增或更新)
|
||||
*/
|
||||
async saveGroup(group: KeywordGroup): Promise<{ success: boolean; id?: string; message?: string }> {
|
||||
try {
|
||||
// 检查 window.$mapi 是否存在
|
||||
if (!window.$mapi) {
|
||||
console.error('[KeywordGroupService] ❌ window.$mapi 未定义!IPC 连接失败');
|
||||
return { success: false, message: 'IPC 未初始化' };
|
||||
}
|
||||
|
||||
if (!window.$mapi.keywordGroup) {
|
||||
console.error('[KeywordGroupService] ❌ window.$mapi.keywordGroup 未定义!');
|
||||
return { success: false, message: 'keywordGroup IPC 未初始化' };
|
||||
}
|
||||
|
||||
// 清理对象以确保IPC兼容性
|
||||
const cleanedGroup = this.cleanGroup(group);
|
||||
console.log('[KeywordGroupService.saveGroup] 清理后的分组对象:', {
|
||||
id: cleanedGroup.id,
|
||||
name: cleanedGroup.name,
|
||||
keywordsCount: cleanedGroup.keywords?.length || 0,
|
||||
hasStyleOverride: !!cleanedGroup.styleOverride
|
||||
});
|
||||
|
||||
console.log('[KeywordGroupService.saveGroup] 准备通过 IPC 保存分组...');
|
||||
const result = await window.$mapi.keywordGroup.saveGroup(cleanedGroup);
|
||||
|
||||
console.log('[KeywordGroupService.saveGroup] IPC 返回结果:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[KeywordGroupService.saveGroup] ✅ 分组保存成功, ID:', result.id);
|
||||
} else {
|
||||
console.error('[KeywordGroupService.saveGroup] ❌ IPC 返回失败:', result.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[KeywordGroupService.saveGroup] ❌ 异常错误:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
errorObject: error
|
||||
});
|
||||
return { success: false, message: '保存失败: ' + error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存关键词分组
|
||||
*/
|
||||
async saveGroups(groups: KeywordGroup[]): Promise<{ success: boolean; failedCount?: number }> {
|
||||
try {
|
||||
let failedCount = 0;
|
||||
for (const group of groups) {
|
||||
const result = await this.saveGroup(group);
|
||||
if (!result.success) {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
failedCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('批量保存关键词分组失败:', error);
|
||||
return { success: false, failedCount: groups.length };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除关键词分组
|
||||
*/
|
||||
async deleteGroup(groupId: string): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
return await window.$mapi.keywordGroup.deleteGroup(groupId);
|
||||
} catch (error) {
|
||||
console.error('删除关键词分组失败:', error);
|
||||
return { success: false, message: '删除失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询关键词所属的分组
|
||||
*/
|
||||
async queryKeyword(keyword: string): Promise<KeywordGroup | null> {
|
||||
try {
|
||||
return await window.$mapi.keywordGroup.queryKeyword(keyword);
|
||||
} catch (error) {
|
||||
console.error('查询关键词所属分组失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询关键词
|
||||
*/
|
||||
async queryKeywordBatch(keywords: string[]): Promise<Map<string, KeywordGroup>> {
|
||||
try {
|
||||
const result = await window.$mapi.keywordGroup.queryKeywordBatch(keywords);
|
||||
const map = new Map<string, KeywordGroup>();
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const item of result) {
|
||||
map.set(item.keyword, item.group);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.error('批量查询关键词所属分组失败:', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库加载分组并同步到本地存储
|
||||
*/
|
||||
async syncGroupsFromDatabase(): Promise<KeywordGroup[]> {
|
||||
try {
|
||||
const groups = await this.getAllGroups();
|
||||
// 保存到本地存储以供离线使用
|
||||
const STORAGE_KEY = 'zhenqianba_keyword_groups';
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(groups));
|
||||
return groups;
|
||||
} catch (error) {
|
||||
console.error('从数据库同步分组失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步本地分组到数据库
|
||||
*/
|
||||
async syncGroupsToDatabase(groups: KeywordGroup[]): Promise<{ success: boolean; failedCount?: number }> {
|
||||
return this.saveGroups(groups);
|
||||
}
|
||||
}
|
||||
|
||||
export default new KeywordGroupService();
|
||||
@@ -0,0 +1,57 @@
|
||||
import {useServerStore} from "../store/modules/server";
|
||||
import {EnumServerType} from "../types/Server";
|
||||
import {Dialog} from "../lib/dialog";
|
||||
import {t} from "../lang";
|
||||
|
||||
const serverStore = useServerStore();
|
||||
export const PermissionService = {
|
||||
async checkForTask(
|
||||
biz: string,
|
||||
data: {
|
||||
serverName: string;
|
||||
serverVersion: string;
|
||||
}
|
||||
) {
|
||||
// 云端模式:跳过权限检查
|
||||
if (data.serverName?.startsWith('__CLOUD')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const server = await serverStore.getByNameVersion(data.serverName, data.serverVersion);
|
||||
if (!server) {
|
||||
throw "ServerNotFound";
|
||||
}
|
||||
if (server.type === EnumServerType.CLOUD) {
|
||||
Dialog.loadingOn(t("正在提交"));
|
||||
const user = await window.$mapi.user.get();
|
||||
if (!user.user.id) {
|
||||
Dialog.loadingOff();
|
||||
window.$mapi.user.open().then();
|
||||
return false;
|
||||
}
|
||||
const res = await window.$mapi.user.apiPost(
|
||||
"aigcpanel/task/check",
|
||||
{
|
||||
model: data.serverName,
|
||||
version: data.serverVersion,
|
||||
},
|
||||
{
|
||||
throwException: false,
|
||||
}
|
||||
);
|
||||
Dialog.loadingOff();
|
||||
if (res.code) {
|
||||
Dialog.tipError(res.msg);
|
||||
setTimeout(() => {
|
||||
if (res.data && res.data.type) {
|
||||
if ("CreditNotEnough" === res.data.type) {
|
||||
window.$mapi.user.open().then();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* RunningHub 云端服务
|
||||
* 前端服务层,提供云端合成相关操作
|
||||
*/
|
||||
|
||||
export class RunningHubService {
|
||||
/**
|
||||
* 上传文件到RunningHub
|
||||
* @param filePath 本地文件路径
|
||||
* @param fileType 文件类型 (video | audio)
|
||||
* @returns 返回文件在服务器上的fileName
|
||||
*/
|
||||
static async uploadFile(filePath: string, fileType: 'video' | 'audio'): Promise<string> {
|
||||
const result = await window.$mapi.runninghub.uploadFile({
|
||||
filePath,
|
||||
fileType
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `上传${fileType}文件失败`);
|
||||
}
|
||||
|
||||
return result.fileName!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建云端合成任务
|
||||
* @param videoFileName 视频文件名
|
||||
* @param audioFileName 音频文件名
|
||||
* @returns 返回任务ID
|
||||
*/
|
||||
static async createTask(videoFileName: string, audioFileName: string): Promise<string> {
|
||||
const result = await window.$mapi.runninghub.createTask({
|
||||
videoFileName,
|
||||
audioFileName
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '创建任务失败');
|
||||
}
|
||||
|
||||
return result.taskId!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
* @param taskId 任务ID
|
||||
* @returns 返回任务查询结果
|
||||
*/
|
||||
static async queryTask(taskId: string): Promise<any> {
|
||||
const result = await window.$mapi.runninghub.queryTask({
|
||||
taskId
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '查询任务失败');
|
||||
}
|
||||
|
||||
return result.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询等待任务完成
|
||||
* @param taskId 任务ID
|
||||
* @param maxRetries 最大重试次数
|
||||
* @param interval 查询间隔(毫秒)
|
||||
* @returns 返回最终结果
|
||||
*/
|
||||
static async waitForTaskComplete(
|
||||
taskId: string,
|
||||
maxRetries: number = 120,
|
||||
interval: number = 1000
|
||||
): Promise<any> {
|
||||
const result = await window.$mapi.runninghub.waitForTaskComplete({
|
||||
taskId,
|
||||
maxRetries,
|
||||
interval
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '等待任务完成失败');
|
||||
}
|
||||
|
||||
return result.result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { t } from "../lang";
|
||||
import { StorageService } from "./StorageService";
|
||||
|
||||
const uploadAudioToRunningHub = async (filePath: string): Promise<string> => {
|
||||
const uploadResult = await window.$mapi.runninghub.uploadFile({
|
||||
filePath,
|
||||
fileType: "audio",
|
||||
});
|
||||
|
||||
if (!uploadResult.success || !uploadResult.fileName) {
|
||||
throw new Error(uploadResult.error || t("RunningHub音频上传失败"));
|
||||
}
|
||||
|
||||
return uploadResult.fileName;
|
||||
};
|
||||
|
||||
const resolveSystemVoiceAudioFileName = async (promptId: string): Promise<string> => {
|
||||
const preCacheResult = await window.$mapi.aliyun.cosyvoice.getPreCachedVoicePath(promptId);
|
||||
if (!preCacheResult.success || !preCacheResult.path) {
|
||||
throw new Error(t("系统音色本地文件不存在,请重新打包或清理音色缓存后重试"));
|
||||
}
|
||||
|
||||
return uploadAudioToRunningHub(preCacheResult.path);
|
||||
};
|
||||
|
||||
const resolveCustomVoiceAudioFileName = async (promptId: number | string): Promise<string> => {
|
||||
const prompt = await StorageService.get(promptId as any);
|
||||
if (!prompt) {
|
||||
throw new Error(t("声音音色不存在"));
|
||||
}
|
||||
|
||||
if (prompt.content?.runninghubAudioFileName) {
|
||||
return prompt.content.runninghubAudioFileName;
|
||||
}
|
||||
|
||||
const audioPath = prompt.content?.url || prompt.content?.audioPath;
|
||||
if (!audioPath) {
|
||||
throw new Error(t("声音音色没有可用于RunningHub的音频文件"));
|
||||
}
|
||||
|
||||
const fileName = await uploadAudioToRunningHub(audioPath);
|
||||
prompt.content.runninghubAudioFileName = fileName;
|
||||
if (prompt.id) {
|
||||
await StorageService.update(prompt.id, prompt);
|
||||
}
|
||||
|
||||
return fileName;
|
||||
};
|
||||
|
||||
export const resolveRunningHubAudioFileName = async (
|
||||
promptId: number | string | undefined,
|
||||
): Promise<string> => {
|
||||
if (!promptId) {
|
||||
throw new Error(t("请先选择声音音色"));
|
||||
}
|
||||
|
||||
const promptKey = String(promptId);
|
||||
if (promptKey.startsWith("sys_")) {
|
||||
return resolveSystemVoiceAudioFileName(promptKey);
|
||||
}
|
||||
|
||||
return resolveCustomVoiceAudioFileName(promptId);
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 智能字幕处理服务
|
||||
* 在 VideoIPAgent.vue 中使用
|
||||
*/
|
||||
|
||||
import { subtitleProcessor } from '../lib/smartSubtitle/SubtitleProcessor';
|
||||
import { keywordGroupManager } from '../lib/smartSubtitle/KeywordGroupManager';
|
||||
import type { SubtitleProcessConfig, ProcessedSubtitle, KeywordGroup } from '../types/smartSubtitle';
|
||||
|
||||
/**
|
||||
* 处理智能字幕
|
||||
*/
|
||||
export async function processSmartSubtitle(
|
||||
text: string,
|
||||
config: SubtitleProcessConfig
|
||||
): Promise<ProcessedSubtitle> {
|
||||
try {
|
||||
console.log('[SmartSubtitleService] 开始处理智能字幕');
|
||||
console.log('[SmartSubtitleService] 文本:', text);
|
||||
console.log('[SmartSubtitleService] 配置:', config);
|
||||
|
||||
// 调用主处理器
|
||||
const result = await subtitleProcessor.processSubtitle(text, config);
|
||||
|
||||
console.log('[SmartSubtitleService] 处理完成');
|
||||
console.log('[SmartSubtitleService] 排版行数:', result.layout.lineCount);
|
||||
console.log('[SmartSubtitleService] 分类词汇:', result.classification.statistics.totalWords);
|
||||
console.log('[SmartSubtitleService] 分批数量:', result.batches.length);
|
||||
console.log('[SmartSubtitleService] 总特效数:', result.effectAssignments.length);
|
||||
console.log('[SmartSubtitleService] 处理耗时:', result.processingTime.toFixed(2), 'ms');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[SmartSubtitleService] 处理失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速处理智能字幕(不使用AI)
|
||||
*/
|
||||
export function processSmartSubtitleQuick(
|
||||
text: string,
|
||||
config: SubtitleProcessConfig
|
||||
): ProcessedSubtitle {
|
||||
try {
|
||||
console.log('[SmartSubtitleService] 快速处理字幕...');
|
||||
|
||||
const result = subtitleProcessor.processQuick(text, config);
|
||||
|
||||
console.log('[SmartSubtitleService] 快速处理完成');
|
||||
console.log('[SmartSubtitleService] 耗时:', result.processingTime.toFixed(2), 'ms');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[SmartSubtitleService] 快速处理失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐配置
|
||||
*/
|
||||
export function getRecommendedConfig(
|
||||
screenWidth: number,
|
||||
screenHeight: number
|
||||
): SubtitleProcessConfig {
|
||||
return subtitleProcessor.getRecommendedConfig(screenWidth, screenHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证处理结果
|
||||
*/
|
||||
export function validateResult(result: ProcessedSubtitle): {
|
||||
valid: boolean;
|
||||
issues: string[];
|
||||
} {
|
||||
return subtitleProcessor.validateResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出结果为JSON
|
||||
*/
|
||||
export function exportResultAsJSON(result: ProcessedSubtitle): string {
|
||||
return subtitleProcessor.exportAsJSON(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成处理统计报告
|
||||
*/
|
||||
export function generateStatisticsReport(result: ProcessedSubtitle): string {
|
||||
const stats = {
|
||||
文本长度: result.originalText.length,
|
||||
排版行数: result.layout.lineCount,
|
||||
分类词汇: result.classification.statistics.totalWords,
|
||||
分批数量: result.batches.length,
|
||||
总特效数: result.effectAssignments.length,
|
||||
动画冲突: result.animationTimeline.conflicts.length,
|
||||
总时长: `${result.totalDuration}ms`,
|
||||
处理耗时: `${result.processingTime.toFixed(2)}ms`,
|
||||
是否有效: result.isValid ? '✓' : '✗',
|
||||
警告数: result.warnings.length,
|
||||
错误数: result.errors.length
|
||||
};
|
||||
|
||||
let report = '=== 智能字幕处理统计报告 ===\n\n';
|
||||
|
||||
for (const [key, value] of Object.entries(stats)) {
|
||||
report += `${key}: ${value}\n`;
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
report += '\n=== 警告信息 ===\n';
|
||||
result.warnings.forEach(w => {
|
||||
report += `⚠️ ${w}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
report += '\n=== 错误信息 ===\n';
|
||||
result.errors.forEach(e => {
|
||||
report += `❌ ${e}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成字幕摘要(用于UI显示)
|
||||
*/
|
||||
export function generateSubtitleSummary(result: ProcessedSubtitle): string {
|
||||
const lines = [];
|
||||
|
||||
// 排版信息
|
||||
if (result.layout.lineCount > 0) {
|
||||
lines.push(`📝 排版: ${result.layout.lineCount}行,宽${result.layout.totalWidth.toFixed(0)}px`);
|
||||
}
|
||||
|
||||
// 分类信息
|
||||
const categoryDist = result.classification.statistics.categoryDistribution;
|
||||
const categories = Object.entries(categoryDist)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([cat, count]) => `${cat}(${count})`)
|
||||
.join(', ');
|
||||
if (categories) {
|
||||
lines.push(`🏷️ 分类: ${categories}`);
|
||||
}
|
||||
|
||||
// 分批信息
|
||||
if (result.batches.length > 1) {
|
||||
const avgDuration = result.batches.reduce((sum, b) => sum + b.duration, 0) / result.batches.length;
|
||||
lines.push(`📊 分批: ${result.batches.length}批,平均时长${avgDuration.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
// 特效信息
|
||||
if (result.effectAssignments.length > 0) {
|
||||
const effectCount = result.effectAssignments.reduce((sum, a) => sum + a.selectedEffects.length, 0);
|
||||
lines.push(`✨ 特效: ${effectCount}个`);
|
||||
}
|
||||
|
||||
// 动画信息
|
||||
if (result.animationTimeline.conflicts.length > 0) {
|
||||
lines.push(`⚠️ 动画冲突: ${result.animationTimeline.conflicts.length}个`);
|
||||
} else {
|
||||
lines.push(`✅ 动画冲突: 0个`);
|
||||
}
|
||||
|
||||
// 时长信息
|
||||
const seconds = result.totalDuration / 1000;
|
||||
lines.push(`⏱️ 总时长: ${seconds.toFixed(2)}s`);
|
||||
|
||||
return lines.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词分组持久化相关方法
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取所有关键词分组
|
||||
*/
|
||||
export function getAllKeywordGroups(): KeywordGroup[] {
|
||||
return keywordGroupManager.getAllGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存或更新关键词分组
|
||||
*/
|
||||
export function saveKeywordGroup(group: KeywordGroup): void {
|
||||
const existing = keywordGroupManager.getGroup(group.id);
|
||||
if (existing) {
|
||||
keywordGroupManager.updateGroup(group.id, group);
|
||||
} else {
|
||||
keywordGroupManager.addGroup(group);
|
||||
}
|
||||
console.log(`[SmartSubtitleService] 关键词分组已保存: ${group.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除关键词分组
|
||||
*/
|
||||
export function deleteKeywordGroup(groupId: string): boolean {
|
||||
return keywordGroupManager.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询关键词所属的分组
|
||||
*/
|
||||
export function queryKeywordGroup(keyword: string) {
|
||||
return keywordGroupManager.queryKeyword(keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询关键词所属的分组
|
||||
*/
|
||||
export function queryKeywordGroupBatch(keywords: string[]) {
|
||||
return keywordGroupManager.queryKeywords(keywords);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将关键词分组序列化为JSON字符串(用于保存到数据库)
|
||||
*/
|
||||
export function serializeKeywordGroups(): string {
|
||||
const groups = keywordGroupManager.getAllGroups();
|
||||
return JSON.stringify(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON字符串反序列化关键词分组(从数据库加载)
|
||||
*/
|
||||
export function deserializeKeywordGroups(jsonStr: string): KeywordGroup[] {
|
||||
try {
|
||||
const groups = JSON.parse(jsonStr) as KeywordGroup[];
|
||||
return groups;
|
||||
} catch (error) {
|
||||
console.error('[SmartSubtitleService] 反序列化关键词分组失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从JSON字符串加载关键词分组到内存
|
||||
*/
|
||||
export function loadKeywordGroupsFromJSON(jsonStr: string): void {
|
||||
try {
|
||||
const groups = deserializeKeywordGroups(jsonStr);
|
||||
keywordGroupManager.clear();
|
||||
groups.forEach(group => {
|
||||
keywordGroupManager.addGroup(group);
|
||||
});
|
||||
console.log(`[SmartSubtitleService] 已从JSON加载${groups.length}个关键词分组`);
|
||||
} catch (error) {
|
||||
console.error('[SmartSubtitleService] 加载关键词分组失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取关键词分组统计信息
|
||||
*/
|
||||
export function getKeywordGroupStatistics() {
|
||||
return keywordGroupManager.getStatistics();
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 音效生成器
|
||||
* 负责根据关键词匹配结果和音效配置生成音效时间表和FFmpeg命令
|
||||
*/
|
||||
|
||||
import type {
|
||||
ArrangedSubtitle,
|
||||
KeywordMatch
|
||||
} from '@/service/ZimuShengcheng.types';
|
||||
import type { KeywordGroup } from '@/types/smartSubtitle';
|
||||
import type { SoundEffectParams } from '@/types/soundEffect';
|
||||
import { SoundEffectLibrary } from '@/lib/subtitle/effects/SoundEffectLibrary';
|
||||
|
||||
/**
|
||||
* 音效实例(用于生成)
|
||||
*/
|
||||
interface SoundEffectInstance {
|
||||
effectId: string;
|
||||
params: SoundEffectParams;
|
||||
keyword: string;
|
||||
triggerTime: number; // 秒
|
||||
duration: number; // 毫秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 音效生成器类
|
||||
*/
|
||||
export class SoundEffectGenerator {
|
||||
/**
|
||||
* 为字幕生成音效配置
|
||||
* 🆕 修改:同时获取音效文件路径,用于后续 FFmpeg 混音
|
||||
*/
|
||||
static async generateSoundTrack(
|
||||
arrangedSubtitles: ArrangedSubtitle[],
|
||||
keywordMatches: KeywordMatch[],
|
||||
keywordGroups: KeywordGroup[]
|
||||
): Promise<{
|
||||
ffmpegAudioFilters: string[];
|
||||
soundInstances: (SoundEffectInstance & { filePath?: string })[];
|
||||
}> {
|
||||
// 1. 构建音效时间表
|
||||
const soundTimeline = this.buildSoundEffectTimeline(
|
||||
keywordMatches,
|
||||
keywordGroups
|
||||
);
|
||||
|
||||
// 2. 获取音效文件路径
|
||||
const soundInstances: (SoundEffectInstance & { filePath?: string })[] = [];
|
||||
|
||||
for (const instance of soundTimeline) {
|
||||
// 🆕 获取音效文件路径
|
||||
let filePath: string | undefined;
|
||||
try {
|
||||
// 🔍 诊断日志
|
||||
const hasWindow = typeof window !== 'undefined';
|
||||
const hasMapi = hasWindow && !!(window as any).$mapi;
|
||||
const hasSoundEffect = hasMapi && !!(window as any).$mapi.soundEffect;
|
||||
const hasGetFilePath = hasSoundEffect && typeof (window as any).$mapi.soundEffect.getFilePath === 'function';
|
||||
|
||||
console.log(`[SoundEffectGenerator] 🔍 API 检测: ${instance.effectId}`, {
|
||||
hasWindow,
|
||||
hasMapi,
|
||||
hasSoundEffect,
|
||||
hasGetFilePath,
|
||||
soundEffectMethods: hasSoundEffect ? Object.keys((window as any).$mapi.soundEffect) : 'N/A'
|
||||
});
|
||||
|
||||
if (hasGetFilePath) {
|
||||
const result = await (window as any).$mapi.soundEffect.getFilePath(instance.effectId, undefined);
|
||||
console.log(`[SoundEffectGenerator] 🔍 getFilePath 返回结果:`, result);
|
||||
if (result.success && result.filePath) {
|
||||
filePath = result.filePath;
|
||||
console.log(`[SoundEffectGenerator] ✅ 获取音效文件路径: ${instance.effectId} -> ${filePath}`);
|
||||
} else {
|
||||
console.warn(`[SoundEffectGenerator] ⚠️ 音效文件路径不存在: ${instance.effectId}`, result.message);
|
||||
}
|
||||
} else {
|
||||
console.error(`[SoundEffectGenerator] ❌ $mapi.soundEffect.getFilePath 不可用!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SoundEffectGenerator] ❌ 获取音效文件路径失败: ${instance.effectId}`, error);
|
||||
}
|
||||
|
||||
// 🔧 由于使用预置音效文件,不再需要生成 FFmpeg 合成命令
|
||||
// 音效混音将在 ZimuShengcheng.executeFFmpeg 中通过 audioMixConfig 实现
|
||||
if (!filePath) {
|
||||
throw new Error(`内置音效文件不存在或映射错误: ${instance.effectId}`);
|
||||
}
|
||||
|
||||
if (filePath) {
|
||||
soundInstances.push({
|
||||
...instance,
|
||||
filePath // 🆕 添加文件路径
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SoundEffectGenerator] 生成了 ${soundInstances.length} 个有效音效实例`);
|
||||
|
||||
return {
|
||||
ffmpegAudioFilters: [], // 预置音效文件不需要 FFmpeg 合成命令
|
||||
soundInstances
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建音效时间表
|
||||
* 将关键词匹配结果转换为音效实例列表
|
||||
*/
|
||||
private static buildSoundEffectTimeline(
|
||||
keywordMatches: KeywordMatch[],
|
||||
keywordGroups: KeywordGroup[]
|
||||
): SoundEffectInstance[] {
|
||||
const timeline: SoundEffectInstance[] = [];
|
||||
|
||||
for (const match of keywordMatches) {
|
||||
// 查找该关键词所属的分组
|
||||
const group = keywordGroups.find((g) => g.id === match.groupId);
|
||||
|
||||
if (!group || !group.soundEffectId) {
|
||||
// 没有配置音效,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算触发时间
|
||||
const triggerTime = this.calculateTriggerTime(
|
||||
match.startTime,
|
||||
match.endTime,
|
||||
group.soundEffectConfig?.triggerTime || 'start'
|
||||
);
|
||||
|
||||
// 获取音效参数
|
||||
const params = SoundEffectLibrary.mergeParams(
|
||||
group.soundEffectId,
|
||||
group.soundEffectConfig
|
||||
);
|
||||
|
||||
// 创建音效实例
|
||||
const instance: SoundEffectInstance = {
|
||||
effectId: group.soundEffectId,
|
||||
params,
|
||||
keyword: match.keyword,
|
||||
triggerTime,
|
||||
duration: match.effectDuration || 1000 // 默认 1 秒
|
||||
};
|
||||
|
||||
timeline.push(instance);
|
||||
|
||||
console.log(`[SoundEffectGenerator] 添加音效: ${match.keyword} @ ${triggerTime}s`);
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算触发时间
|
||||
* @note FunASR 时间戳是毫秒,需要转换为秒供 FFmpeg adelay 使用
|
||||
*/
|
||||
private static calculateTriggerTime(
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
triggerPoint: 'start' | 'middle' | 'end'
|
||||
): number {
|
||||
let triggerMs: number;
|
||||
switch (triggerPoint) {
|
||||
case 'start':
|
||||
triggerMs = startTime;
|
||||
break;
|
||||
case 'middle':
|
||||
triggerMs = (startTime + endTime) / 2;
|
||||
break;
|
||||
case 'end':
|
||||
triggerMs = endTime;
|
||||
break;
|
||||
default:
|
||||
triggerMs = startTime;
|
||||
}
|
||||
// 🔧 FunASR 时间戳是毫秒,转换为秒
|
||||
return triggerMs / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 FFmpeg 音频混音滤镜
|
||||
* 用于将多个音效混音到原视频的音轨
|
||||
*/
|
||||
static buildAudioMixFilters(
|
||||
soundInstances: SoundEffectInstance[],
|
||||
masterVolume: number = 0.7
|
||||
): string {
|
||||
if (soundInstances.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 按触发时间排序
|
||||
const sortedInstances = [...soundInstances].sort(
|
||||
(a, b) => a.triggerTime - b.triggerTime
|
||||
);
|
||||
|
||||
// 构建 adelay 和 volume 滤镜链
|
||||
const filterChains: string[] = [];
|
||||
|
||||
for (const instance of sortedInstances) {
|
||||
// 计算延迟(毫秒)
|
||||
const delayMs = Math.floor(instance.triggerTime * 1000);
|
||||
|
||||
// 构建单个音效的滤镜链
|
||||
let filter = '';
|
||||
|
||||
// 添加延迟
|
||||
if (delayMs > 0) {
|
||||
filter += `adelay=${delayMs}|${delayMs},`;
|
||||
}
|
||||
|
||||
// 添加音量调整
|
||||
const volume = (instance.params.volume || 0.7) * masterVolume;
|
||||
filter += `volume=${volume}`;
|
||||
|
||||
// 添加淡入淡出
|
||||
const fadeInMs = instance.params.fadeIn || 0;
|
||||
const fadeOutMs = instance.params.fadeOut || 0;
|
||||
|
||||
if (fadeInMs > 0) {
|
||||
const fadeInSec = fadeInMs / 1000;
|
||||
filter += `,afade=t=in:st=${delayMs / 1000}:d=${fadeInSec}`;
|
||||
}
|
||||
|
||||
if (fadeOutMs > 0) {
|
||||
const fadeOutStartSec = delayMs / 1000 + (instance.duration || 1000) / 1000;
|
||||
const fadeOutSec = fadeOutMs / 1000;
|
||||
filter += `,afade=t=out:st=${fadeOutStartSec}:d=${fadeOutSec}`;
|
||||
}
|
||||
|
||||
filterChains.push(filter);
|
||||
}
|
||||
|
||||
return filterChains.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证音效配置
|
||||
*/
|
||||
static validateSoundConfig(
|
||||
keywordGroups: KeywordGroup[]
|
||||
): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const group of keywordGroups) {
|
||||
if (!group.soundEffectId) {
|
||||
continue; // 没有配置音效,不需要验证
|
||||
}
|
||||
|
||||
// 检查音效ID是否有效
|
||||
const effect = SoundEffectLibrary.getBuiltinEffect(group.soundEffectId);
|
||||
if (!effect) {
|
||||
errors.push(`分组 "${group.name}" 的音效ID无效: ${group.soundEffectId}`);
|
||||
}
|
||||
|
||||
// 检查音效参数
|
||||
const params = group.soundEffectConfig;
|
||||
if (params) {
|
||||
if (params.volume !== undefined && (params.volume < 0 || params.volume > 1)) {
|
||||
errors.push(
|
||||
`分组 "${group.name}" 的音量值无效: ${params.volume}(应在 0-1 之间)`
|
||||
);
|
||||
}
|
||||
|
||||
if (params.pitch !== undefined && (params.pitch < 0.5 || params.pitch > 2)) {
|
||||
errors.push(
|
||||
`分组 "${group.name}" 的音调值无效: ${params.pitch}(应在 0.5-2 之间)`
|
||||
);
|
||||
}
|
||||
|
||||
if (params.fadeIn !== undefined && (params.fadeIn < 0 || params.fadeIn > 1000)) {
|
||||
errors.push(
|
||||
`分组 "${group.name}" 的淡入时间无效: ${params.fadeIn}ms(应在 0-1000ms 之间)`
|
||||
);
|
||||
}
|
||||
|
||||
if (params.fadeOut !== undefined && (params.fadeOut < 0 || params.fadeOut > 1000)) {
|
||||
errors.push(
|
||||
`分组 "${group.name}" 的淡出时间无效: ${params.fadeOut}ms(应在 0-1000ms 之间)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default SoundEffectGenerator;
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 音效服务 - 前端业务逻辑层
|
||||
*/
|
||||
|
||||
import type {
|
||||
SoundEffectMetadata,
|
||||
UserSoundEffect,
|
||||
SoundEffectParams
|
||||
} from '@/types/soundEffect';
|
||||
|
||||
/**
|
||||
* 音效服务类
|
||||
*/
|
||||
export class SoundEffectService {
|
||||
/**
|
||||
* 获取所有内置音效
|
||||
*/
|
||||
static async getBuiltinEffects(): Promise<SoundEffectMetadata[]> {
|
||||
try {
|
||||
const effects = await (window as any).$mapi.soundEffect.getBuiltinEffects();
|
||||
return effects || [];
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 获取内置音效失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 获取所有音效(内置 + 用户自定义)
|
||||
*/
|
||||
static async getAllEffects(): Promise<SoundEffectMetadata[]> {
|
||||
try {
|
||||
const effects = await (window as any).$mapi.soundEffect.getAllEffects();
|
||||
return effects || [];
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 获取所有音效失败:', error);
|
||||
// 降级到只返回内置音效
|
||||
return this.getBuiltinEffects();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分类获取内置音效
|
||||
*/
|
||||
static async getEffectsByCategory(
|
||||
category: 'basic' | 'motion' | 'visual' | 'advanced'
|
||||
): Promise<SoundEffectMetadata[]> {
|
||||
const allEffects = await this.getBuiltinEffects();
|
||||
return allEffects.filter((effect) => effect.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有用户自定义音效
|
||||
*/
|
||||
static async getUserEffects(): Promise<UserSoundEffect[]> {
|
||||
try {
|
||||
const effects = await (window as any).$mapi.soundEffect.getUserEffects();
|
||||
return effects || [];
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 获取用户音效失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入用户音效文件
|
||||
*/
|
||||
static async importEffect(
|
||||
filePath: string,
|
||||
name: string,
|
||||
description?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
id?: string;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
return await (window as any).$mapi.soundEffect.importUserEffect(
|
||||
filePath,
|
||||
name,
|
||||
description
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 导入音效失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `导入失败: ${error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户音效
|
||||
*/
|
||||
static async deleteEffect(effectId: string): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
return await (window as any).$mapi.soundEffect.deleteUserEffect(effectId);
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 删除音效失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: `删除失败: ${error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成音效文件
|
||||
*/
|
||||
static async generateEffect(
|
||||
effectId: string,
|
||||
params?: SoundEffectParams,
|
||||
outputPath?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const result = await (window as any).$mapi.soundEffect.generateEffect(
|
||||
effectId,
|
||||
params,
|
||||
outputPath
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
path: result.audioFile
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 生成音效失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: `生成失败: ${error}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览音效
|
||||
* @param effectId 音效ID(内置或用户自定义,用户音效使用 user_ 前缀)
|
||||
* @param userEffectId 已弃用,保留用于兼容
|
||||
*/
|
||||
static async previewEffect(
|
||||
effectId?: string,
|
||||
userEffectId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 统一使用 effectId,兼容旧的 userEffectId 参数
|
||||
const id = effectId || userEffectId;
|
||||
|
||||
if (!id) {
|
||||
console.warn('[SoundEffectService] 预览音效失败: 需要提供 effectId');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SoundEffectService] 预览音效: ${id}`);
|
||||
|
||||
const result = await (window as any).$mapi.soundEffect.previewEffect(id);
|
||||
|
||||
if (!result.success) {
|
||||
console.warn('[SoundEffectService] 预览音效失败:', result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Web Audio API 播放音效
|
||||
if (result.audioPath) {
|
||||
await this.playAudioFile(result.audioPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 预览音效失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放音频文件
|
||||
*/
|
||||
private static async playAudioFile(filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(`file://${filePath}`);
|
||||
audio.volume = 0.7;
|
||||
|
||||
audio.onended = () => resolve();
|
||||
audio.onerror = (error) => reject(error);
|
||||
|
||||
audio.play().catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并默认参数和用户参数
|
||||
*/
|
||||
static mergeParams(
|
||||
defaultParams: SoundEffectParams,
|
||||
userParams?: SoundEffectParams
|
||||
): SoundEffectParams {
|
||||
return {
|
||||
...defaultParams,
|
||||
...userParams
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 获取音效文件路径
|
||||
*/
|
||||
static async getEffectFilePath(effectId?: string, userEffectId?: string): Promise<{
|
||||
success: boolean;
|
||||
filePath?: string;
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
return await (window as any).$mapi.soundEffect.getFilePath(effectId, userEffectId);
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 获取音效路径失败:', error);
|
||||
return { success: false, message: `获取失败: ${error}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 批量获取音效文件路径(用于视频生成)
|
||||
*/
|
||||
static async getBatchFilePaths(soundInstances: any[]): Promise<{
|
||||
success: boolean;
|
||||
results?: { effectId: string; filePath: string | null; triggerTime: number }[];
|
||||
message?: string;
|
||||
}> {
|
||||
try {
|
||||
return await (window as any).$mapi.soundEffect.getBatchFilePaths(soundInstances);
|
||||
} catch (error) {
|
||||
console.error('[SoundEffectService] 批量获取音效路径失败:', error);
|
||||
return { success: false, message: `获取失败: ${error}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SoundEffectService;
|
||||
@@ -0,0 +1,149 @@
|
||||
export type StorageBiz = "SoundPrompt" | "LiveAvatar" | "LiveKnowledge" | "LiveEvent" | "LiveTalk";
|
||||
|
||||
export type StorageRecord = {
|
||||
id?: number;
|
||||
|
||||
biz: StorageBiz;
|
||||
|
||||
sort?: number;
|
||||
title?: string;
|
||||
content?: any;
|
||||
|
||||
runtime?: StorageRuntime;
|
||||
};
|
||||
|
||||
export type StorageRuntime = {};
|
||||
|
||||
export const StorageService = {
|
||||
tableName() {
|
||||
return "data_storage";
|
||||
},
|
||||
decodeRecord(record: StorageRecord): StorageRecord | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
content: JSON.parse(record.content ? record.content : "{}"),
|
||||
} as StorageRecord;
|
||||
},
|
||||
encodeRecord(record: StorageRecord): StorageRecord {
|
||||
if ("content" in record) {
|
||||
record.content = JSON.stringify(record.content || {});
|
||||
}
|
||||
return record;
|
||||
},
|
||||
async getByTitle(biz: StorageBiz, title: string): Promise<StorageRecord | null> {
|
||||
const record: any = await window.$mapi.db.first(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?
|
||||
AND title = ?`,
|
||||
[biz, title]
|
||||
);
|
||||
return this.decodeRecord(record);
|
||||
},
|
||||
async get(id: number): Promise<StorageRecord | null> {
|
||||
const record: any = await window.$mapi.db.first(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
return this.decodeRecord(record);
|
||||
},
|
||||
async listByIds(ids: number[]): Promise<StorageRecord[]> {
|
||||
if (!ids || ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const records: StorageRecord[] = await window.$mapi.db.select(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE id IN (${ids.join(",")})`,
|
||||
[]
|
||||
);
|
||||
return records.map(this.decodeRecord) as StorageRecord[];
|
||||
},
|
||||
async list(biz: StorageBiz): Promise<StorageRecord[]> {
|
||||
const records: StorageRecord[] = await window.$mapi.db.select(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?
|
||||
ORDER BY id DESC`,
|
||||
[biz]
|
||||
);
|
||||
return records.map(this.decodeRecord) as StorageRecord[];
|
||||
},
|
||||
async add(biz: StorageBiz, record: Partial<StorageRecord>) {
|
||||
const fields = ["biz", "title", "sort", "content"];
|
||||
record["biz"] = biz;
|
||||
record = this.encodeRecord(record as StorageRecord);
|
||||
const values = fields.map(f => record[f]);
|
||||
const valuesPlaceholder = fields.map(f => "?");
|
||||
const id = await window.$mapi.db.insert(
|
||||
`INSERT INTO ${this.tableName()} (${fields.join(",")})
|
||||
VALUES (${valuesPlaceholder.join(",")})`,
|
||||
values
|
||||
);
|
||||
},
|
||||
async update(id: number, record: Partial<StorageRecord>) {
|
||||
record = this.encodeRecord(record as StorageRecord);
|
||||
const fields = Object.keys(record);
|
||||
const values = fields.map(f => record[f]);
|
||||
const set = fields.map(f => `${f} = ?`).join(",");
|
||||
return await window.$mapi.db.execute(
|
||||
`UPDATE ${this.tableName()}
|
||||
SET ${set}
|
||||
WHERE id = ?`,
|
||||
[...values, id]
|
||||
);
|
||||
},
|
||||
async addOrUpdate(biz: StorageBiz, id: number, record: Partial<StorageRecord>) {
|
||||
if (!id) {
|
||||
await this.add(biz, record);
|
||||
} else {
|
||||
await this.update(id, record);
|
||||
}
|
||||
},
|
||||
async delete(record: StorageRecord) {
|
||||
const filesForClean: string[] = [];
|
||||
if (record.content) {
|
||||
if (record.content.url) {
|
||||
filesForClean.push(record.content.url);
|
||||
}
|
||||
}
|
||||
for (const file of filesForClean) {
|
||||
await window.$mapi.file.deletes(file);
|
||||
}
|
||||
await window.$mapi.db.delete(
|
||||
`DELETE
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[record.id]
|
||||
);
|
||||
},
|
||||
async clear(biz: StorageBiz) {
|
||||
await window.$mapi.db.delete(
|
||||
`DELETE
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?`,
|
||||
[biz]
|
||||
);
|
||||
},
|
||||
async count(biz: StorageBiz, startTime: number = 0, endTime: number = 0) {
|
||||
let sql = `SELECT COUNT(*) as cnt
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?`;
|
||||
let params: any[] = [biz];
|
||||
if (startTime > 0) {
|
||||
sql += ` AND createdAt >= ?`;
|
||||
params.push(startTime);
|
||||
}
|
||||
if (endTime > 0) {
|
||||
sql += ` AND createdAt <= ?`;
|
||||
params.push(endTime);
|
||||
}
|
||||
const result = await window.$mapi.db.first(sql, params);
|
||||
return result.cnt;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { SubtitleStyle } from '../pages/Video/components/SubtitleStyleSelector.vue';
|
||||
|
||||
/**
|
||||
* 字幕样式服务 - 用于数据库持久化和查询
|
||||
*/
|
||||
class StyleService {
|
||||
/**
|
||||
* 清理字幕样式对象 - 移除不可序列化的内容
|
||||
*/
|
||||
private cleanStyle(style: any): any {
|
||||
if (!style || typeof style !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建完全新的纯 JavaScript 对象,确保不包含 Proxy
|
||||
const cleanedStyle = {
|
||||
id: style.id ? String(style.id) : 'custom',
|
||||
name: style.name ? String(style.name) : '自定义样式',
|
||||
description: style.description ? String(style.description) : '完全自定义的字幕样式',
|
||||
preview: style.preview ? String(style.preview) : '示例文字',
|
||||
fontName: style.fontName ? String(style.fontName) : 'NotoSerifCJK-VF',
|
||||
fontSize: typeof style.fontSize === 'number' ? style.fontSize : 48,
|
||||
fontColor: style.fontColor ? String(style.fontColor) : '#FFFFFF',
|
||||
outlineColor: style.outlineColor ? String(style.outlineColor) : '#000000',
|
||||
outlineWidth: typeof style.outlineWidth === 'number' ? style.outlineWidth : 3,
|
||||
shadowOffset: typeof style.shadowOffset === 'number' ? style.shadowOffset : 2,
|
||||
backgroundColor: style.backgroundColor ? String(style.backgroundColor) : 'transparent',
|
||||
backgroundOpacity: typeof style.backgroundOpacity === 'number' ? style.backgroundOpacity : 0,
|
||||
position: (style.position === 'top' || style.position === 'center' || style.position === 'bottom') ? style.position : 'bottom',
|
||||
alignment: (style.alignment === 'center' || style.alignment === 'left' || style.alignment === 'right') ? style.alignment : 'center'
|
||||
};
|
||||
|
||||
console.log('[StyleService.cleanStyle] 清理后的对象:', {
|
||||
id: cleanedStyle.id,
|
||||
name: cleanedStyle.name,
|
||||
fontColor: cleanedStyle.fontColor
|
||||
});
|
||||
|
||||
return cleanedStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前样式(支持按 styleId 获取)
|
||||
*/
|
||||
async getCurrentStyle(styleId?: string): Promise<any> {
|
||||
try {
|
||||
// 检查 IPC 接口是否存在
|
||||
if (!window.$mapi) {
|
||||
console.error('[StyleService.getCurrentStyle] ❌ window.$mapi 未定义');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!window.$mapi.style) {
|
||||
console.error('[StyleService.getCurrentStyle] ❌ window.$mapi.style 未定义');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!window.$mapi.style.getCurrentStyle) {
|
||||
console.error('[StyleService.getCurrentStyle] ❌ getCurrentStyle 方法未定义');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[StyleService.getCurrentStyle] 准备调用 IPC getCurrentStyle...', { styleId });
|
||||
const result = await window.$mapi.style.getCurrentStyle(styleId);
|
||||
|
||||
console.log('[StyleService.getCurrentStyle] IPC 返回结果:', result);
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
console.error('[StyleService.getCurrentStyle] ❌ 异常错误:', {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按ID获取样式(新增方法,支持多个样式记录)
|
||||
*/
|
||||
async getStyleById(styleId: string): Promise<any> {
|
||||
try {
|
||||
if (!window.$mapi) {
|
||||
console.error('[StyleService.getStyleById] ❌ window.$mapi 未定义');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!window.$mapi.style) {
|
||||
console.error('[StyleService.getStyleById] ❌ window.$mapi.style 未定义');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!window.$mapi.style.getStyleById) {
|
||||
console.error('[StyleService.getStyleById] ❌ getStyleById 方法未定义');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[StyleService.getStyleById] 准备调用 IPC getStyleById...', { styleId });
|
||||
const result = await window.$mapi.style.getStyleById(styleId);
|
||||
|
||||
console.log('[StyleService.getStyleById] IPC 返回结果:', result);
|
||||
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
console.error('[StyleService.getStyleById] ❌ 异常错误:', {
|
||||
message: error?.message,
|
||||
stack: error?.stack
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存样式(新增或更新,支持多个样式记录)
|
||||
*/
|
||||
async saveStyle(style: any, styleId?: string): Promise<{ success: boolean; id?: string; message?: string }> {
|
||||
try {
|
||||
// 检查 window.$mapi 是否存在
|
||||
if (!window.$mapi) {
|
||||
console.error('[StyleService.saveStyle] ❌ window.$mapi 未定义!IPC 连接失败');
|
||||
return { success: false, message: 'IPC 未初始化' };
|
||||
}
|
||||
|
||||
if (!window.$mapi.style) {
|
||||
console.error('[StyleService.saveStyle] ❌ window.$mapi.style 未定义!');
|
||||
return { success: false, message: 'style IPC 未初始化' };
|
||||
}
|
||||
|
||||
// 清理对象以确保IPC兼容性
|
||||
const cleanedStyle = this.cleanStyle(style);
|
||||
console.log('[StyleService.saveStyle] 清理后的样式对象:', {
|
||||
id: cleanedStyle.id,
|
||||
name: cleanedStyle.name,
|
||||
fontColor: cleanedStyle.fontColor
|
||||
});
|
||||
|
||||
console.log('[StyleService.saveStyle] 准备通过 IPC 保存样式...', {
|
||||
providedStyleId: styleId,
|
||||
styleName: cleanedStyle.name
|
||||
});
|
||||
const result = await window.$mapi.style.saveStyle(cleanedStyle, styleId);
|
||||
|
||||
console.log('[StyleService.saveStyle] IPC 返回结果:', result);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[StyleService.saveStyle] ✅ 样式保存成功, ID:', result.id);
|
||||
} else {
|
||||
console.error('[StyleService.saveStyle] ❌ IPC 返回失败:', result.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[StyleService.saveStyle] ❌ 异常错误:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
errorObject: error
|
||||
});
|
||||
return { success: false, message: '保存失败: ' + error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除样式
|
||||
*/
|
||||
async deleteStyle(styleId: string): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
return await window.$mapi.style.deleteStyle(styleId);
|
||||
} catch (error) {
|
||||
console.error('[StyleService] 删除样式失败:', error);
|
||||
return { success: false, message: '删除失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库加载样式
|
||||
*/
|
||||
async loadStyleFromDatabase(): Promise<any> {
|
||||
try {
|
||||
const style = await this.getCurrentStyle();
|
||||
return style || null;
|
||||
} catch (error) {
|
||||
console.error('[StyleService] 从数据库加载样式失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步样式到数据库
|
||||
*/
|
||||
async syncStyleToDatabase(style: any): Promise<{ success: boolean; message?: string }> {
|
||||
return this.saveStyle(style);
|
||||
}
|
||||
}
|
||||
|
||||
export default new StyleService();
|
||||
@@ -0,0 +1,265 @@
|
||||
// @ts-nocheck
|
||||
import {TimeUtil} from "../lib/util";
|
||||
|
||||
export type TaskLogLevel = "info" | "warn" | "error" | "debug";
|
||||
|
||||
export type TaskLogRecord = {
|
||||
id?: number;
|
||||
taskId: number | string;
|
||||
timestamp: number;
|
||||
level: TaskLogLevel;
|
||||
message: string;
|
||||
processName?: string; // 进程名称
|
||||
processPath?: string; // 文件路径
|
||||
progress?: number; // 进度 0-100
|
||||
metadata?: any; // 其他元数据
|
||||
createdAt?: number;
|
||||
};
|
||||
|
||||
export const TaskLogService = {
|
||||
tableName() {
|
||||
return "data_task_log";
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化任务日志表
|
||||
*/
|
||||
async initTable() {
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 500; // 500ms
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await window.$mapi.db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.tableName()} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
taskId INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
processName TEXT,
|
||||
processPath TEXT,
|
||||
progress INTEGER DEFAULT 0,
|
||||
metadata TEXT,
|
||||
createdAt INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// 创建索引以提升查询性能
|
||||
await window.$mapi.db.execute(`
|
||||
CREATE INDEX IF NOT EXISTS idx_task_log_taskId
|
||||
ON ${this.tableName()} (taskId)
|
||||
`);
|
||||
|
||||
await window.$mapi.db.execute(`
|
||||
CREATE INDEX IF NOT EXISTS idx_task_log_timestamp
|
||||
ON ${this.tableName()} (timestamp)
|
||||
`);
|
||||
|
||||
console.log("TaskLogService: 任务日志表初始化完成");
|
||||
return; // 成功则退出
|
||||
} catch (error: any) {
|
||||
// 检查是否是数据库未初始化错误(可能是字符串或Error对象)
|
||||
const errorStr = String(error);
|
||||
const errorMessage = error?.message || errorStr;
|
||||
const isDbNotInitialized =
|
||||
errorStr === "DBNotInitialized" ||
|
||||
error === "DBNotInitialized" ||
|
||||
errorMessage?.includes("DBNotInitialized") ||
|
||||
errorMessage?.includes("Error invoking remote method 'db:execute': DBNotInitialized");
|
||||
|
||||
if (isDbNotInitialized) {
|
||||
if (i < maxRetries - 1) {
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
continue;
|
||||
} else {
|
||||
console.error("TaskLogService: 数据库初始化超时,请稍后重试");
|
||||
}
|
||||
} else {
|
||||
// 其他错误直接抛出
|
||||
console.error("TaskLogService: 初始化任务日志表失败", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 编码日志记录
|
||||
*/
|
||||
encodeRecord(record: TaskLogRecord): TaskLogRecord {
|
||||
const encoded = {...record};
|
||||
if ("metadata" in encoded && encoded.metadata) {
|
||||
encoded.metadata = JSON.stringify(encoded.metadata);
|
||||
}
|
||||
return encoded;
|
||||
},
|
||||
|
||||
/**
|
||||
* 解码日志记录
|
||||
*/
|
||||
decodeRecord(record: TaskLogRecord): TaskLogRecord | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
metadata: record.metadata ? JSON.parse(record.metadata as any) : null,
|
||||
} as TaskLogRecord;
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加任务日志
|
||||
*/
|
||||
async addLog(log: Omit<TaskLogRecord, "id" | "createdAt" | "timestamp">): Promise<number> {
|
||||
const record: TaskLogRecord = {
|
||||
...log,
|
||||
timestamp: log.timestamp || TimeUtil.timestampMS(),
|
||||
createdAt: TimeUtil.timestamp(),
|
||||
};
|
||||
|
||||
const encoded = this.encodeRecord(record);
|
||||
|
||||
const fields = [
|
||||
"taskId",
|
||||
"timestamp",
|
||||
"level",
|
||||
"message",
|
||||
"processName",
|
||||
"processPath",
|
||||
"progress",
|
||||
"metadata",
|
||||
"createdAt",
|
||||
];
|
||||
|
||||
const values = fields.map(f => encoded[f] ?? null);
|
||||
const placeholders = fields.map(() => "?").join(",");
|
||||
|
||||
const id = await window.$mapi.db.insert(
|
||||
`INSERT INTO ${this.tableName()} (${fields.join(",")})
|
||||
VALUES (${placeholders})`,
|
||||
values
|
||||
);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量添加日志
|
||||
*/
|
||||
async addLogs(logs: Omit<TaskLogRecord, "id" | "createdAt" | "timestamp">[]): Promise<void> {
|
||||
if (!logs || logs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const log of logs) {
|
||||
await this.addLog(log);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取指定任务的所有日志
|
||||
*/
|
||||
async getLogsByTaskId(taskId: number | string): Promise<TaskLogRecord[]> {
|
||||
const records: TaskLogRecord[] = await window.$mapi.db.select(
|
||||
`SELECT * FROM ${this.tableName()}
|
||||
WHERE taskId = ?
|
||||
ORDER BY timestamp ASC`,
|
||||
[taskId]
|
||||
);
|
||||
return records.map(this.decodeRecord).filter(r => r !== null) as TaskLogRecord[];
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取指定任务的最新N条日志
|
||||
*/
|
||||
async getRecentLogs(taskId: number | string, limit: number = 50): Promise<TaskLogRecord[]> {
|
||||
const records: TaskLogRecord[] = await window.$mapi.db.select(
|
||||
`SELECT * FROM ${this.tableName()}
|
||||
WHERE taskId = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?`,
|
||||
[taskId, limit]
|
||||
);
|
||||
return records.map(this.decodeRecord).filter(r => r !== null).reverse() as TaskLogRecord[];
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据日志级别筛选日志
|
||||
*/
|
||||
async getLogsByLevel(
|
||||
taskId: number | string,
|
||||
level: TaskLogLevel
|
||||
): Promise<TaskLogRecord[]> {
|
||||
const records: TaskLogRecord[] = await window.$mapi.db.select(
|
||||
`SELECT * FROM ${this.tableName()}
|
||||
WHERE taskId = ? AND level = ?
|
||||
ORDER BY timestamp ASC`,
|
||||
[taskId, level]
|
||||
);
|
||||
return records.map(this.decodeRecord).filter(r => r !== null) as TaskLogRecord[];
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除指定任务的所有日志
|
||||
*/
|
||||
async deleteLogsByTaskId(taskId: number | string): Promise<number> {
|
||||
return await window.$mapi.db.execute(
|
||||
`DELETE FROM ${this.tableName()}
|
||||
WHERE taskId = ?`,
|
||||
[taskId]
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除指定时间之前的日志(清理旧日志)
|
||||
*/
|
||||
async deleteOldLogs(beforeTimestamp: number): Promise<number> {
|
||||
return await window.$mapi.db.execute(
|
||||
`DELETE FROM ${this.tableName()}
|
||||
WHERE timestamp < ?`,
|
||||
[beforeTimestamp]
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取任务的最新进度
|
||||
*/
|
||||
async getLatestProgress(taskId: number | string): Promise<number | null> {
|
||||
const record: any = await window.$mapi.db.first(
|
||||
`SELECT progress FROM ${this.tableName()}
|
||||
WHERE taskId = ? AND progress IS NOT NULL
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1`,
|
||||
[taskId]
|
||||
);
|
||||
return record ? record.progress : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 统计任务日志数量
|
||||
*/
|
||||
async count(taskId?: number | string, level?: TaskLogLevel): Promise<number> {
|
||||
let sql = `SELECT COUNT(*) as cnt FROM ${this.tableName()}`;
|
||||
const params: any[] = [];
|
||||
const wheres: string[] = [];
|
||||
|
||||
if (taskId !== undefined) {
|
||||
wheres.push("taskId = ?");
|
||||
params.push(taskId);
|
||||
}
|
||||
|
||||
if (level) {
|
||||
wheres.push("level = ?");
|
||||
params.push(level);
|
||||
}
|
||||
|
||||
if (wheres.length > 0) {
|
||||
sql += " WHERE " + wheres.join(" AND ");
|
||||
}
|
||||
|
||||
const result = await window.$mapi.db.first(sql, params);
|
||||
return result ? result.cnt : 0;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,478 @@
|
||||
import {TimeUtil} from "../lib/util";
|
||||
import {TaskChangeType, useTaskStore} from "../store/modules/task";
|
||||
import {groupThrottle} from "../lib/groupThrottle";
|
||||
import {t} from "../lang";
|
||||
import {TaskLogService, TaskLogLevel} from "./TaskLogService";
|
||||
|
||||
// 【修复】延迟初始化 taskStore,避免在模块加载时就访问未初始化的 store
|
||||
let taskStore: any = null;
|
||||
function getTaskStore() {
|
||||
if (!taskStore) {
|
||||
taskStore = useTaskStore();
|
||||
}
|
||||
return taskStore;
|
||||
}
|
||||
|
||||
// 🔧 为 decodeRecord 添加缓存,避免重复 JSON.parse
|
||||
const decodeCache = new Map<number | string, TaskRecord>();
|
||||
|
||||
export type TaskBiz =
|
||||
| never
|
||||
| "SoundGenerate"
|
||||
| "SoundAsr"
|
||||
| "VideoGen"
|
||||
// sound apps
|
||||
| "LongTextTts"
|
||||
| "SubtitleTts"
|
||||
| "SoundReplace"
|
||||
// video apps
|
||||
| "TextToImage"
|
||||
| "ImageToImage"
|
||||
| "VideoGenFlow"
|
||||
// video processing apps
|
||||
| "VideoAsr"
|
||||
| "VideoProcess"
|
||||
| "VideoSubtitle"
|
||||
| "VideoBGM"
|
||||
| "VideoCover"
|
||||
| "VideoPublish"
|
||||
| "TextGen";
|
||||
|
||||
export type TaskJobResultStepStatus = undefined | "queue" | "pending" | "running" | "success" | "fail";
|
||||
|
||||
export enum TaskType {
|
||||
User = 1,
|
||||
System = 2,
|
||||
}
|
||||
|
||||
export type TaskRecord<MODEL_CONFIG extends any = any, JOB_RESULT extends any = any> = {
|
||||
id?: number;
|
||||
|
||||
biz: TaskBiz;
|
||||
type?: TaskType;
|
||||
|
||||
title: string;
|
||||
|
||||
status?: "queue" | "wait" | "pending" | "running" | "querying" | "success" | "fail" | "failed" | "stopped" | "cancelled" | "pause" | "delete";
|
||||
statusMsg?: string;
|
||||
startTime?: number;
|
||||
endTime?: number | undefined;
|
||||
|
||||
serverName: string;
|
||||
serverTitle: string;
|
||||
serverVersion: string;
|
||||
|
||||
param?: any;
|
||||
jobResult?: JOB_RESULT;
|
||||
modelConfig?: MODEL_CONFIG;
|
||||
result?: {
|
||||
percent: number
|
||||
} & any;
|
||||
|
||||
runtime?: TaskRuntime;
|
||||
};
|
||||
|
||||
export type TaskRuntime = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
// deep merge two object, newData has higher priority
|
||||
// if value is array, replace it directly
|
||||
const mergeData = (oldData: any, newData: any) => {
|
||||
if (typeof oldData !== "object" || oldData === null) {
|
||||
return newData;
|
||||
}
|
||||
if (typeof newData !== "object" || newData === null) {
|
||||
return newData;
|
||||
}
|
||||
const result = {};
|
||||
for (const key in oldData) {
|
||||
if (key in newData) {
|
||||
if (Array.isArray(newData[key])) {
|
||||
result[key] = newData[key];
|
||||
} else if (typeof newData[key] === "object" && newData[key] !== null) {
|
||||
result[key] = mergeData(oldData[key], newData[key]);
|
||||
} else {
|
||||
result[key] = newData[key];
|
||||
}
|
||||
} else {
|
||||
result[key] = oldData[key];
|
||||
}
|
||||
}
|
||||
for (const key in newData) {
|
||||
if (!(key in oldData)) {
|
||||
result[key] = newData[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
type CleanerFunctionType = (record: TaskRecord) => Promise<{
|
||||
files: string[];
|
||||
}>;
|
||||
|
||||
const cleanersMap = new Map<TaskBiz, CleanerFunctionType>();
|
||||
|
||||
export const TaskService = {
|
||||
tableName() {
|
||||
return "data_task";
|
||||
},
|
||||
registerCleaner(biz: TaskBiz, cleaner: CleanerFunctionType) {
|
||||
cleanersMap.set(biz, cleaner);
|
||||
},
|
||||
/**
|
||||
* 记录任务日志
|
||||
*/
|
||||
async log(
|
||||
taskId: number | string,
|
||||
level: TaskLogLevel,
|
||||
message: string,
|
||||
options?: {
|
||||
processName?: string;
|
||||
processPath?: string;
|
||||
progress?: number;
|
||||
metadata?: any;
|
||||
}
|
||||
): Promise<number> {
|
||||
return await TaskLogService.addLog({
|
||||
taskId,
|
||||
level,
|
||||
message,
|
||||
processName: options?.processName,
|
||||
processPath: options?.processPath,
|
||||
progress: options?.progress,
|
||||
metadata: options?.metadata,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 获取任务日志
|
||||
*/
|
||||
async getLogs(taskId: number | string, limit?: number) {
|
||||
if (limit) {
|
||||
return await TaskLogService.getRecentLogs(taskId, limit);
|
||||
}
|
||||
return await TaskLogService.getLogsByTaskId(taskId);
|
||||
},
|
||||
/**
|
||||
* 获取任务最新进度
|
||||
*/
|
||||
async getProgress(taskId: number | string): Promise<number | null> {
|
||||
return await TaskLogService.getLatestProgress(taskId);
|
||||
},
|
||||
decodeRecord(record: TaskRecord): TaskRecord | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
// 🔧 检查缓存,避免重复 JSON.parse
|
||||
if (record.id && decodeCache.has(record.id)) {
|
||||
return decodeCache.get(record.id)!;
|
||||
}
|
||||
const decoded = {
|
||||
...record,
|
||||
param: JSON.parse(record.param ? record.param : "{}"),
|
||||
jobResult: JSON.parse(record.jobResult ? record.jobResult : "{}"),
|
||||
modelConfig: JSON.parse(record.modelConfig ? record.modelConfig : "{}"),
|
||||
result: JSON.parse(record.result ? record.result : "{}"),
|
||||
} as TaskRecord;
|
||||
// 存储到缓存
|
||||
if (decoded.id) {
|
||||
decodeCache.set(decoded.id, decoded);
|
||||
}
|
||||
return decoded;
|
||||
},
|
||||
encodeRecord(record: TaskRecord): TaskRecord {
|
||||
if ("param" in record) {
|
||||
record.param = JSON.stringify(record.param || {});
|
||||
}
|
||||
if ("jobResult" in record) {
|
||||
record.jobResult = JSON.stringify(record.jobResult || {});
|
||||
}
|
||||
if ("modelConfig" in record) {
|
||||
record.modelConfig = JSON.stringify(record.modelConfig || {});
|
||||
}
|
||||
if ("result" in record) {
|
||||
record.result = JSON.stringify(record.result || {});
|
||||
}
|
||||
return record;
|
||||
},
|
||||
async get(id: number | string): Promise<TaskRecord | null> {
|
||||
const record: any = await window.$mapi.db.first(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
return this.decodeRecord(record);
|
||||
},
|
||||
async list(biz: TaskBiz, type: TaskType = TaskType.User, limit?: number): Promise<TaskRecord[]> {
|
||||
let sql = `SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?
|
||||
AND type = ?
|
||||
ORDER BY id DESC`;
|
||||
|
||||
const params: any[] = [biz, type];
|
||||
|
||||
if (limit && limit > 0) {
|
||||
sql += ` LIMIT ?`;
|
||||
params.push(limit);
|
||||
}
|
||||
|
||||
const records: TaskRecord[] = await window.$mapi.db.select(sql, params);
|
||||
return records.map(this.decodeRecord) as TaskRecord[];
|
||||
},
|
||||
async listByStatus(
|
||||
biz: TaskBiz,
|
||||
statusList: ("queue" | "wait" | "running" | "success" | "fail")[]
|
||||
): Promise<TaskRecord[]> {
|
||||
if (!statusList || statusList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const records: TaskRecord[] = await window.$mapi.db.select(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?
|
||||
AND status IN (${statusList.map(() => "?").join(",")})
|
||||
ORDER BY id DESC`,
|
||||
[biz, ...statusList]
|
||||
);
|
||||
return records.map(this.decodeRecord) as TaskRecord[];
|
||||
},
|
||||
async restoreForTask(biz: TaskBiz) {
|
||||
const records: TaskRecord[] = await window.$mapi.db.select(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE biz = ?
|
||||
AND (status = 'running' OR status = 'wait' OR status = 'queue')
|
||||
ORDER BY id DESC`,
|
||||
[biz]
|
||||
);
|
||||
// console.log('TaskService.restoreForTask', records.length)
|
||||
for (let record of records) {
|
||||
await getTaskStore().dispatch(
|
||||
record.biz,
|
||||
record.id as any,
|
||||
{},
|
||||
{
|
||||
status: "queue",
|
||||
runStart: record.startTime,
|
||||
queryInterval: 5 * 1000,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
async submit(record: TaskRecord) {
|
||||
record.status = "queue";
|
||||
record.startTime = TimeUtil.timestampMS();
|
||||
const fields = [
|
||||
"biz",
|
||||
"type",
|
||||
"title",
|
||||
"status",
|
||||
"statusMsg",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"serverName",
|
||||
"serverTitle",
|
||||
"serverVersion",
|
||||
"param",
|
||||
"modelConfig",
|
||||
];
|
||||
if (!("type" in record)) {
|
||||
record.type = TaskType.User;
|
||||
}
|
||||
record = this.encodeRecord(record);
|
||||
const values = fields.map(f => record[f]);
|
||||
const valuesPlaceholder = fields.map(f => "?");
|
||||
const id = await window.$mapi.db.insert(
|
||||
`INSERT INTO ${this.tableName()} (${fields.join(",")})
|
||||
VALUES (${valuesPlaceholder.join(",")})`,
|
||||
values
|
||||
);
|
||||
await getTaskStore().dispatch(
|
||||
record.biz,
|
||||
id,
|
||||
{},
|
||||
{
|
||||
queryInterval: 5 * 1000,
|
||||
}
|
||||
);
|
||||
return id;
|
||||
},
|
||||
async create(recordOrTitle: TaskRecord | string, biz?: TaskBiz, modelConfig?: any, param?: any) {
|
||||
if (typeof recordOrTitle !== "string") {
|
||||
return this.submit(recordOrTitle);
|
||||
}
|
||||
return this.submit({
|
||||
title: recordOrTitle,
|
||||
biz: biz!,
|
||||
serverName: modelConfig?.serverName || modelConfig?.serverKey || "",
|
||||
serverTitle: modelConfig?.serverTitle || modelConfig?.serverName || modelConfig?.serverKey || "",
|
||||
serverVersion: modelConfig?.serverVersion || "",
|
||||
modelConfig,
|
||||
param: param || {},
|
||||
});
|
||||
},
|
||||
updatePercent: groupThrottle(async (id: string, percent: number) => {
|
||||
// console.log('TaskService.updatePercent', {id, percent});
|
||||
const {updates, biz} = await TaskService.update(id, {result: {percent}});
|
||||
getTaskStore().fireChange({
|
||||
biz: biz!, bizId: id,
|
||||
}, 'change');
|
||||
}, 3000, {
|
||||
trailing: true,
|
||||
}),
|
||||
cancelCheck: (biz: TaskBiz, bizId: string) => {
|
||||
if (getTaskStore().shouldCancel(biz, bizId)) {
|
||||
throw t('任务已取消');
|
||||
}
|
||||
},
|
||||
updateDelayAndFireChange: groupThrottle(async (
|
||||
id: string,
|
||||
record: Partial<TaskRecord>,
|
||||
fireChangeType: TaskChangeType = "running",
|
||||
option?: {
|
||||
mergeResult?: boolean;
|
||||
}
|
||||
) => {
|
||||
const {updates, biz} = await TaskService.update(id, record, option);
|
||||
getTaskStore().fireChange({biz: biz!, bizId: id}, fireChangeType);
|
||||
}, 1000, {
|
||||
trailing: true,
|
||||
}),
|
||||
async update(
|
||||
id: number | string,
|
||||
record: Partial<TaskRecord>,
|
||||
option?: {
|
||||
mergeResult?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
updates: number,
|
||||
biz: string | null,
|
||||
}> {
|
||||
option = Object.assign(
|
||||
{
|
||||
mergeResult: true,
|
||||
},
|
||||
option
|
||||
);
|
||||
let recordOld: TaskRecord | null = null;
|
||||
|
||||
// ✅ 如果要更新 status,需要获取旧状态以便发送事件
|
||||
if ("result" in record || "jobResult" in record || "startTime" in record || "status" in record) {
|
||||
recordOld = await this.get(id);
|
||||
if (option.mergeResult) {
|
||||
if ("result" in record) {
|
||||
record.result = mergeData(recordOld?.result, record.result);
|
||||
}
|
||||
if ("jobResult" in record) {
|
||||
record.jobResult = mergeData(recordOld?.jobResult, record.jobResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 记录状态变化
|
||||
const oldStatus = recordOld?.status;
|
||||
const newStatus = record.status;
|
||||
|
||||
record = this.encodeRecord(record as TaskRecord);
|
||||
const fields = Object.keys(record);
|
||||
const values = fields.map(f => record[f]);
|
||||
const set = fields.map(f => `${f} = ?`).join(",");
|
||||
const updates = await window.$mapi.db.execute(
|
||||
`UPDATE ${this.tableName()}
|
||||
SET ${set}
|
||||
WHERE id = ?`,
|
||||
[...values, id]
|
||||
);
|
||||
// 🔧 更新后清除缓存,确保下次读取时重新解析
|
||||
decodeCache.delete(id);
|
||||
|
||||
// ✅ 如果状态发生变化,发送事件通知
|
||||
if (newStatus && oldStatus !== newStatus) {
|
||||
try {
|
||||
// 检查 ipcRenderer 是否可用
|
||||
if (typeof (window as any).ipcRenderer !== 'undefined') {
|
||||
const ipcRenderer = (window as any).ipcRenderer;
|
||||
await ipcRenderer.invoke('task:emitStatusChange', id, oldStatus, newStatus, {
|
||||
statusMsg: record.statusMsg,
|
||||
result: record.result
|
||||
});
|
||||
console.log(`[TaskService] 已发送状态变化事件: ${id} ${oldStatus} → ${newStatus}`);
|
||||
} else {
|
||||
console.warn('[TaskService] ipcRenderer 不可用,无法发送状态变化事件');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[TaskService] 发送状态变化事件失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updates,
|
||||
biz: recordOld ? recordOld.biz : null,
|
||||
}
|
||||
},
|
||||
async delete(record: TaskRecord) {
|
||||
const filesForClean: string[] = [];
|
||||
if (record.result) {
|
||||
// collection files from result
|
||||
for (const k in record.result) {
|
||||
if (record.result[k] && typeof record.result[k] === "string") {
|
||||
if (await window.$mapi.file.isHubFile(record.result[k])) {
|
||||
filesForClean.push(record.result[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const cleaner = cleanersMap.get(record.biz);
|
||||
if (cleaner) {
|
||||
const {files} = await cleaner(record);
|
||||
if (files && files.length > 0) {
|
||||
filesForClean.push(...files);
|
||||
}
|
||||
}
|
||||
for (const file of filesForClean) {
|
||||
await window.$mapi.file.deletes(file);
|
||||
}
|
||||
await window.$mapi.db.delete(
|
||||
`DELETE
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[record.id]
|
||||
);
|
||||
// 🔧 删除后清除缓存
|
||||
if (record.id) {
|
||||
decodeCache.delete(record.id);
|
||||
}
|
||||
},
|
||||
async count(
|
||||
biz: TaskBiz | null,
|
||||
startTime: number = 0,
|
||||
endTime: number = 0,
|
||||
type: TaskType = TaskType.User
|
||||
): Promise<number> {
|
||||
let sql = `SELECT COUNT(*) as cnt
|
||||
FROM ${this.tableName()}`;
|
||||
const params: any[] = [];
|
||||
const wheres: string[] = [];
|
||||
if (biz) {
|
||||
wheres.push("biz = ?");
|
||||
params.push(biz);
|
||||
}
|
||||
if (startTime > 0) {
|
||||
wheres.push("createdAt >= ?");
|
||||
params.push(startTime);
|
||||
}
|
||||
if (endTime > 0) {
|
||||
wheres.push("createdAt <= ?");
|
||||
params.push(endTime);
|
||||
}
|
||||
wheres.push("type = ?");
|
||||
params.push(type);
|
||||
if (wheres.length > 0) {
|
||||
sql += " WHERE " + wheres.join(" AND ");
|
||||
}
|
||||
const result = await window.$mapi.db.first(sql, params);
|
||||
return result.cnt;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { DataService } from "./DataService";
|
||||
|
||||
export type VideoMaterialRecord = {
|
||||
id?: number;
|
||||
name: string;
|
||||
path: string;
|
||||
type: "image" | "video";
|
||||
info: any;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "bmp", "webp"];
|
||||
const VIDEO_EXTENSIONS = ["mp4", "mov", "avi", "mkv", "webm"];
|
||||
|
||||
type VideoCodecInfo = {
|
||||
codecName?: string;
|
||||
pixFmt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
type PreparedImportFile = {
|
||||
path: string;
|
||||
info: Record<string, any>;
|
||||
saveExt?: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const VideoMaterialService = {
|
||||
tableName() {
|
||||
return "data_video_material";
|
||||
},
|
||||
decodeRecord(record: any): VideoMaterialRecord | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
info: record.info ? JSON.parse(record.info) : {},
|
||||
} as VideoMaterialRecord;
|
||||
},
|
||||
encodeRecord(record: Partial<VideoMaterialRecord>) {
|
||||
const nextRecord = { ...record } as any;
|
||||
if ("info" in nextRecord) {
|
||||
nextRecord.info = JSON.stringify(nextRecord.info || {});
|
||||
}
|
||||
return nextRecord;
|
||||
},
|
||||
getMaterialType(filePath: string): "image" | "video" {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
return IMAGE_EXTENSIONS.includes(ext) ? "image" : "video";
|
||||
},
|
||||
getMaterialName(filePath: string) {
|
||||
const fileName = filePath.split(/[\\/]/).pop() || "未命名素材";
|
||||
return fileName.replace(/\.[^/.]+$/, "") || fileName;
|
||||
},
|
||||
getExtension(filePath: string): string {
|
||||
return filePath.split(".").pop()?.toLowerCase() || "";
|
||||
},
|
||||
isVideoFile(filePath: string): boolean {
|
||||
return VIDEO_EXTENSIONS.includes(this.getExtension(filePath));
|
||||
},
|
||||
async probeVideoCodec(filePath: string): Promise<VideoCodecInfo | null> {
|
||||
try {
|
||||
const output = await window.$mapi.app.spawnBinary("ffprobe", [
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name,pix_fmt,width,height,duration",
|
||||
"-of",
|
||||
"json",
|
||||
filePath,
|
||||
]);
|
||||
const parsed = JSON.parse(output || "{}");
|
||||
const stream = Array.isArray(parsed.streams) ? parsed.streams[0] : null;
|
||||
if (!stream) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
codecName: stream.codec_name,
|
||||
pixFmt: stream.pix_fmt,
|
||||
width: Number(stream.width) || undefined,
|
||||
height: Number(stream.height) || undefined,
|
||||
duration: Number(stream.duration) || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("[VideoMaterialService] probe video codec failed:", filePath, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
needsCompatTranscode(filePath: string, info: VideoCodecInfo | null): boolean {
|
||||
if (!info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.getExtension(filePath) !== "mp4"
|
||||
|| (info.codecName || "").toLowerCase() !== "h264"
|
||||
|| (info.pixFmt || "").toLowerCase() !== "yuv420p";
|
||||
},
|
||||
async transcodeToPreviewCompatibleMp4(filePath: string): Promise<string> {
|
||||
const outputPath = await window.$mapi.file.temp("mp4", "video-material-compatible");
|
||||
const args = [
|
||||
"-y",
|
||||
"-i",
|
||||
filePath,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
outputPath,
|
||||
];
|
||||
try {
|
||||
await window.$mapi.app.spawnBinary("ffmpeg", args);
|
||||
} catch (error) {
|
||||
await window.$mapi.file.deletes(outputPath, {
|
||||
isDataPath: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return outputPath;
|
||||
},
|
||||
async prepareImportFile(filePath: string): Promise<PreparedImportFile> {
|
||||
const info: Record<string, any> = {
|
||||
sourcePath: filePath,
|
||||
};
|
||||
|
||||
if (!this.isVideoFile(filePath)) {
|
||||
return { path: filePath, info };
|
||||
}
|
||||
|
||||
const codecInfo = await this.probeVideoCodec(filePath);
|
||||
if (codecInfo) {
|
||||
info.originalVideoCodec = codecInfo.codecName;
|
||||
info.originalPixFmt = codecInfo.pixFmt;
|
||||
info.width = codecInfo.width;
|
||||
info.height = codecInfo.height;
|
||||
info.duration = codecInfo.duration;
|
||||
}
|
||||
|
||||
if (!this.needsCompatTranscode(filePath, codecInfo)) {
|
||||
return { path: filePath, info };
|
||||
}
|
||||
|
||||
const compatiblePath = await this.transcodeToPreviewCompatibleMp4(filePath);
|
||||
return {
|
||||
path: compatiblePath,
|
||||
info: {
|
||||
...info,
|
||||
transcodedForPreview: true,
|
||||
previewVideoCodec: "h264",
|
||||
previewPixFmt: "yuv420p",
|
||||
},
|
||||
saveExt: "mp4",
|
||||
cleanup: async () => {
|
||||
await window.$mapi.file.deletes(compatiblePath, {
|
||||
isDataPath: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
async list(): Promise<VideoMaterialRecord[]> {
|
||||
const records = await window.$mapi.db.select(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
ORDER BY updatedAt DESC, id DESC`
|
||||
);
|
||||
return records.map((record: any) => this.decodeRecord(record)).filter(Boolean) as VideoMaterialRecord[];
|
||||
},
|
||||
async insert(record: VideoMaterialRecord) {
|
||||
const nextRecord = this.encodeRecord(record);
|
||||
const fields = Object.keys(nextRecord).join(", ");
|
||||
const values = Object.values(nextRecord);
|
||||
const valuePlaceholders = values.map(() => "?").join(", ");
|
||||
return await window.$mapi.db.insert(
|
||||
`INSERT INTO ${this.tableName()} (${fields})
|
||||
VALUES (${valuePlaceholders})`,
|
||||
values
|
||||
);
|
||||
},
|
||||
async update(id: number, record: Partial<VideoMaterialRecord>) {
|
||||
const nextRecord = this.encodeRecord({
|
||||
...record,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
const fields = Object.keys(nextRecord)
|
||||
.map(key => `${key} = ?`)
|
||||
.join(", ");
|
||||
const values = Object.values(nextRecord);
|
||||
values.push(id);
|
||||
return await window.$mapi.db.update(
|
||||
`UPDATE ${this.tableName()}
|
||||
SET ${fields}
|
||||
WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
},
|
||||
async delete(record: VideoMaterialRecord) {
|
||||
if (record.path) {
|
||||
await window.$mapi.file.hubDelete(record.path);
|
||||
}
|
||||
await window.$mapi.db.delete(
|
||||
`DELETE
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[record.id]
|
||||
);
|
||||
},
|
||||
async importFiles(filePaths: string[]) {
|
||||
const ids: number[] = [];
|
||||
for (const filePath of filePaths) {
|
||||
const prepared = await this.prepareImportFile(filePath);
|
||||
try {
|
||||
const savedPath = await DataService.saveFile(prepared.path, {
|
||||
saveGroup: "video-material",
|
||||
ext: prepared.saveExt,
|
||||
});
|
||||
const now = Date.now();
|
||||
const id = await this.insert({
|
||||
name: this.getMaterialName(filePath),
|
||||
path: savedPath,
|
||||
type: this.getMaterialType(filePath),
|
||||
info: prepared.info,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
ids.push(id);
|
||||
} finally {
|
||||
if (prepared.cleanup) {
|
||||
await prepared.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
export type VideoTemplateRecord = {
|
||||
id?: number;
|
||||
name: string;
|
||||
video: string;
|
||||
info: any;
|
||||
};
|
||||
|
||||
export const VideoTemplateService = {
|
||||
tableName() {
|
||||
return "data_video_template";
|
||||
},
|
||||
decodeRecord(record: VideoTemplateRecord): VideoTemplateRecord | null {
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
info: record.info ? JSON.parse(record.info) : {},
|
||||
} as VideoTemplateRecord;
|
||||
},
|
||||
encodeRecord(record: VideoTemplateRecord): VideoTemplateRecord {
|
||||
if ("info" in record) {
|
||||
record.info = JSON.stringify(record.info || {});
|
||||
}
|
||||
return record;
|
||||
},
|
||||
async get(id: number): Promise<VideoTemplateRecord | null> {
|
||||
const record: any = await window.$mapi.db.first(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
return this.decodeRecord(record);
|
||||
},
|
||||
async getByName(name: string): Promise<VideoTemplateRecord | null> {
|
||||
const record: any = await window.$mapi.db.first(
|
||||
`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
WHERE name = ?`,
|
||||
[name]
|
||||
);
|
||||
return this.decodeRecord(record);
|
||||
},
|
||||
async list(): Promise<VideoTemplateRecord[]> {
|
||||
const records: VideoTemplateRecord[] = await window.$mapi.db.select(`SELECT *
|
||||
FROM ${this.tableName()}
|
||||
ORDER BY id DESC`);
|
||||
return records.map(this.decodeRecord) as VideoTemplateRecord[];
|
||||
},
|
||||
async insert(record: VideoTemplateRecord) {
|
||||
record = this.encodeRecord(record);
|
||||
const fields = Object.keys(record).join(", ");
|
||||
const values = Object.values(record);
|
||||
const valuePlaceholders = values.map(() => "?").join(", ");
|
||||
return await window.$mapi.db.insert(
|
||||
`INSERT INTO ${this.tableName()} (${fields})
|
||||
VALUES (${valuePlaceholders})`,
|
||||
values
|
||||
);
|
||||
},
|
||||
async delete(record: VideoTemplateRecord) {
|
||||
if (record.video) {
|
||||
await window.$mapi.file.hubDelete(record.video);
|
||||
}
|
||||
await window.$mapi.db.delete(
|
||||
`DELETE
|
||||
FROM ${this.tableName()}
|
||||
WHERE id = ?`,
|
||||
[record.id]
|
||||
);
|
||||
},
|
||||
async update(id: number, record: Partial<VideoTemplateRecord>) {
|
||||
record = this.encodeRecord(record as VideoTemplateRecord);
|
||||
const fields = Object.keys(record)
|
||||
.map(key => `${key} = ?`)
|
||||
.join(", ");
|
||||
const values = Object.values(record);
|
||||
values.push(id);
|
||||
return await window.$mapi.db.update(
|
||||
`UPDATE ${this.tableName()}
|
||||
SET ${fields}
|
||||
WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,467 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* 字幕生成服务类型定义
|
||||
* 用于新的字幕生成系统 ZimuShengcheng
|
||||
*/
|
||||
|
||||
import type { SubtitleStyle } from '@/pages/Video/components/SubtitleStyleSelector.vue';
|
||||
import type { KeywordGroup, EffectType } from '@/types/smartSubtitle';
|
||||
|
||||
// ==================== 配置接口 ====================
|
||||
|
||||
/**
|
||||
* 关键词组的位置配置
|
||||
* 用于为不同类型的关键词(重点词、描述词等)单独设置显示坐标
|
||||
*/
|
||||
export interface KeywordGroupPositionConfig {
|
||||
groupName: string; // 关键词组名称(如"重点词"、"描述词")
|
||||
effectX?: number; // 特效字幕X坐标(0-100%,可选)
|
||||
effectY?: number; // 特效字幕Y坐标(0-100%,可选)
|
||||
safeDistance?: number; // 避让时的安全距离(像素值,可选)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字幕避让配置
|
||||
*/
|
||||
export interface AvoidanceConfig {
|
||||
safeDistance: number; // 全局安全距离(像素,默认20)
|
||||
preferDirection: 'right' | 'top'; // 优先避让方向(默认'right')
|
||||
}
|
||||
|
||||
/**
|
||||
* 字幕生成主配置
|
||||
*/
|
||||
export interface ZimuConfig {
|
||||
videoPath: string; // 视频文件路径
|
||||
videoWidth: number; // 视频宽度(分辨率)
|
||||
videoHeight: number; // 视频高度(分辨率)
|
||||
userText: string; // 用户输入的初始文案(用于修正)`r`n skipTextCorrection?: boolean; // 已是最终切段结果时,跳过文本校正
|
||||
skipTextCorrection?: boolean; // 已是最终切段结果时,跳过文本校正
|
||||
funasrSegments: FunasrSegment[]; // FUNASR识别结果(带时间戳)
|
||||
subtitleStyle: SubtitleStyle; // 基础字幕样式
|
||||
keywordGroups: KeywordGroup[]; // 关键词分组配置
|
||||
keywordRenderMode?: 'floating' | 'inline-emphasis';
|
||||
fontDir: string; // 自定义字体目录
|
||||
outputPath: string; // 输出视频路径
|
||||
minSubtitleDuration?: number; // 🔧 修复:字幕最小显示时长(秒),如果字幕时长小于此值则延长至此值
|
||||
normalSubtitleX?: number; // 普通字幕水平位置(0-100%)
|
||||
normalSubtitleY?: number; // 普通字幕垂直位置(0-100%)
|
||||
effectSubtitleX?: number; // 特效字幕水平位置(0-100%,作为默认值)
|
||||
effectSubtitleY?: number; // 特效字幕垂直位置(0-100%,作为默认值)
|
||||
disableSmartLayout?: boolean; // 禁用智能布局,使用固定位置
|
||||
|
||||
// 🆕 新增:按关键词组配置位置
|
||||
keywordGroupPositions?: KeywordGroupPositionConfig[]; // 关键词组位置配置列表
|
||||
avoidanceConfig?: AvoidanceConfig; // 避让配置
|
||||
|
||||
// 🆕 音效系统配置
|
||||
keywordRenderMode?: 'floating' | 'inline-emphasis';
|
||||
soundEffectSettings?: {
|
||||
enabled: boolean; // 是否启用音效
|
||||
masterVolume?: number; // 主音量 0-1,默认 0.7
|
||||
mixMode?: 'overlay' | 'replace'; // 叠加或替换原音频
|
||||
};
|
||||
|
||||
// ✨ 标题字幕配置
|
||||
titleSubtitleConfig?: any; // TitleSubtitleConfig类型,避免循环依赖
|
||||
videoDuration?: number; // 视频总时长(秒),用于标题字幕显示
|
||||
|
||||
// 🆕 新增:画中画混剪配置
|
||||
videoMixCutSettings?: any; // 画中画配置对象(VideoMixCutConfig类型)
|
||||
}
|
||||
|
||||
/**
|
||||
* FUNASR识别结果片段
|
||||
*/
|
||||
export interface FunasrSegment {
|
||||
text: string; // 识别的文本
|
||||
start: number; // 开始时间(秒)
|
||||
end: number; // 结束时间(秒)
|
||||
words?: WordTimestamp[]; // 词级时间戳(可选)
|
||||
timestamp?: number[][]; // 字级时间戳(可选)
|
||||
}
|
||||
|
||||
/**
|
||||
* 词级时间戳
|
||||
*/
|
||||
export interface WordTimestamp {
|
||||
word: string; // 词
|
||||
start: number; // 开始时间(秒)
|
||||
end: number; // 结束时间(秒)
|
||||
confidence?: number; // 置信度(0-1)
|
||||
}
|
||||
|
||||
// ==================== 文案修正 ====================
|
||||
|
||||
/**
|
||||
* 修正后的文本片段
|
||||
*/
|
||||
export interface CorrectedSegment {
|
||||
charTimings?: Array<{ start: number; end: number }>;
|
||||
text: string; // 修正后的文本
|
||||
originalText: string; // ASR原始文本
|
||||
start: number; // 开始时间(秒)
|
||||
end: number; // 结束时间(秒)
|
||||
corrected: boolean; // 是否被修正
|
||||
confidence?: number; // 修正置信度
|
||||
}
|
||||
|
||||
/**
|
||||
* Levenshtein匹配结果
|
||||
*/
|
||||
export interface MatchResult {
|
||||
alignments: Alignment[]; // 字符对齐结果
|
||||
distance: number; // 编辑距离
|
||||
similarity: number; // 相似度(0-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字符对齐
|
||||
*/
|
||||
export interface Alignment {
|
||||
userChar: string; // 用户文本字符
|
||||
asrChar: string; // ASR文本字符
|
||||
operation: 'match' | 'replace' | 'insert' | 'delete'; // 操作类型
|
||||
userIndex: number; // 用户文本索引
|
||||
asrIndex: number; // ASR文本索引
|
||||
}
|
||||
|
||||
// ==================== 关键词匹配 ====================
|
||||
|
||||
/**
|
||||
* 关键词匹配结果
|
||||
*/
|
||||
export interface KeywordMatch {
|
||||
keyword: string; // 关键词文本
|
||||
startIndex: number; // 在文本中的起始索引
|
||||
endIndex: number; // 在文本中的结束索引
|
||||
startTime: number; // 开始时间(秒)
|
||||
endTime: number; // 结束时间(秒)
|
||||
groupId: string; // 所属分组ID
|
||||
groupName: string; // 所属分组名称
|
||||
effectType: EffectType; // 特效类型
|
||||
isAnimated: boolean; // 是否为带动画的特效
|
||||
style: KeywordStyle; // 关键词样式配置
|
||||
// 🔧 修复:新增完整的特效配置字段
|
||||
effectConfig?: any; // 特效配置对象(来自关键词分组)
|
||||
effectStyle?: KeywordStyle; // 特效样式(来自关键词分组的styleConfig)
|
||||
effectIntensity?: number; // 特效强度(0-1)
|
||||
effectDuration?: number; // 特效持续时间(毫秒)
|
||||
// 🆕 贴纸相关字段
|
||||
stickerId?: string; // 贴纸ID
|
||||
stickerConfig?: { // 贴纸配置
|
||||
offsetX?: number; // X轴偏移
|
||||
offsetY?: number; // Y轴偏移
|
||||
scale?: number; // 缩放比例
|
||||
opacity?: number; // 透明度
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词样式配置
|
||||
*/
|
||||
export interface KeywordStyle {
|
||||
fontName?: string; // 字体名称
|
||||
fontSize: number; // 字体大小
|
||||
fontColor: string; // 字体颜色(#RRGGBB)
|
||||
outlineColor: string; // 描边颜色
|
||||
outlineWidth: number; // 描边宽度
|
||||
shadowColor?: string; // 阴影颜色
|
||||
shadowBlur?: number; // 阴影模糊度
|
||||
backgroundColor?: string; // 背景颜色
|
||||
backgroundOpacity?: number; // 背景透明度(0-1)
|
||||
}
|
||||
|
||||
// ==================== 字幕排列 ====================
|
||||
|
||||
/**
|
||||
* 排列后的字幕
|
||||
*/
|
||||
export interface ArrangedSubtitle {
|
||||
type: SubtitleType; // 字幕类型
|
||||
text: string; // 显示文本
|
||||
start: number; // 开始时间(秒)
|
||||
end: number; // 结束时间(秒)
|
||||
position: Position; // 屏幕位置
|
||||
style: RenderStyle; // 渲染样式
|
||||
layer: RenderLayer; // 渲染层
|
||||
animationConfig?: AnimationConfig; // 动画配置(如果有)
|
||||
|
||||
// 🆕 新增:用于碰撞检测和避让逻辑
|
||||
width?: number; // 字幕宽度(像素,估算值)
|
||||
height?: number; // 字幕高度(像素,估算值)
|
||||
keywordGroup?: string; // 关键词组名称(仅特效字幕,用于分组避让)
|
||||
effectiveEnd?: number; // 实际结束时间(秒,考虑动画时长)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字幕类型
|
||||
*/
|
||||
export type SubtitleType = 'normal' | 'effect_simple' | 'effect_complex';
|
||||
|
||||
/**
|
||||
* 屏幕位置
|
||||
*/
|
||||
export interface Position {
|
||||
x: number; // X坐标(像素)
|
||||
y: number; // Y坐标(像素)
|
||||
alignment?: 'left' | 'center' | 'right'; // 对齐方式
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染样式
|
||||
*/
|
||||
export interface RenderStyle {
|
||||
fontName: string; // 字体名称
|
||||
fontSize: number; // 字体大小
|
||||
fontColor: string; // 字体颜色
|
||||
outlineColor: string; // 描边颜色
|
||||
outlineWidth: number; // 描边宽度
|
||||
backgroundColor?: string; // 背景颜色
|
||||
backgroundOpacity?: number; // 背景透明度
|
||||
shadowOffset?: number; // 阴影偏移
|
||||
shadowColor?: string; // 阴影颜色
|
||||
shadowBlur?: number; // 阴影模糊度
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染层
|
||||
*/
|
||||
export type RenderLayer = 'ass_normal' | 'ass_effect' | 'drawtext';
|
||||
|
||||
/**
|
||||
* 动画配置
|
||||
*/
|
||||
export interface AnimationConfig {
|
||||
type: AnimationType; // 动画类型
|
||||
duration: number; // 持续时间(毫秒)
|
||||
delay: number; // 延迟时间(毫秒)
|
||||
intensity: number; // 强度(0-1)
|
||||
parameters?: Record<string, any>; // 特定参数
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画类型
|
||||
*/
|
||||
export type AnimationType =
|
||||
| 'scale' // 缩放
|
||||
| 'fade' // 淡入淡出
|
||||
| 'slide' // 滑动
|
||||
| 'bounce' // 弹跳
|
||||
| 'rotate' // 旋转
|
||||
| 'wave' // 波浪
|
||||
| 'particle' // 粒子
|
||||
| 'glow' // 发光
|
||||
| 'shake'; // 抖动
|
||||
|
||||
// ==================== ASS生成 ====================
|
||||
|
||||
/**
|
||||
* ASS文件配置
|
||||
*/
|
||||
export interface ASSFileConfig {
|
||||
width: number; // 视频宽度
|
||||
height: number; // 视频高度
|
||||
fontDir?: string; // 字体目录
|
||||
}
|
||||
|
||||
/**
|
||||
* ASS样式配置
|
||||
*/
|
||||
export interface ASSStyleConfig {
|
||||
name: string; // 样式名称
|
||||
fontname: string; // 字体名称
|
||||
fontsize: number; // 字号
|
||||
primaryColour: string; // 主要颜色(ASS格式:&HAABBGGRR)
|
||||
secondaryColour: string; // 次要颜色
|
||||
outlineColour: string; // 描边颜色
|
||||
backColour: string; // 背景颜色
|
||||
bold: number; // 粗体(0或1)
|
||||
italic: number; // 斜体(0或1)
|
||||
borderStyle: number; // 边框样式
|
||||
outline: number; // 描边宽度
|
||||
shadow: number; // 阴影偏移
|
||||
alignment: number; // 对齐方式(数字键盘布局)
|
||||
marginL: number; // 左边距
|
||||
marginR: number; // 右边距
|
||||
marginV: number; // 垂直边距
|
||||
scaleX?: number; // X轴缩放(百分比)
|
||||
scaleY?: number; // Y轴缩放(百分比)
|
||||
}
|
||||
|
||||
/**
|
||||
* ASS对话事件
|
||||
*/
|
||||
export interface ASSDialogueEvent {
|
||||
layer: number; // 层级
|
||||
start: string; // 开始时间(ASS格式:H:MM:SS.mm)
|
||||
end: string; // 结束时间
|
||||
style: string; // 样式名称
|
||||
text: string; // 显示文本
|
||||
effectId?: string; // 特效ID
|
||||
effectConfig?: any; // 特效配置
|
||||
stickerId?: string; // 🆕 贴纸ID
|
||||
stickerConfig?: { // 🆕 贴纸配置
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
scale?: number;
|
||||
opacity?: number;
|
||||
};
|
||||
marginL?: number; // 左边距(覆盖样式)
|
||||
marginR?: number; // 右边距(覆盖样式)
|
||||
marginV?: number; // 垂直边距(覆盖样式)
|
||||
}
|
||||
|
||||
// ==================== drawtext生成 ====================
|
||||
|
||||
/**
|
||||
* drawtext命令配置
|
||||
*/
|
||||
export interface DrawtextConfig {
|
||||
fontfile: string; // 字体文件路径
|
||||
text: string; // 显示文本
|
||||
x: string | number; // X坐标(支持表达式)
|
||||
y: string | number; // Y坐标(支持表达式)
|
||||
fontsize: string | number; // 字号(支持表达式,如"50*(1+0.2*sin(4*PI*(t-0.1)))")
|
||||
fontcolor: string; // 字体颜色(0xRRGGBB)
|
||||
borderw: number; // 描边宽度
|
||||
bordercolor: string; // 描边颜色
|
||||
enable: string; // 启用时间表达式
|
||||
alpha?: string; // 透明度表达式
|
||||
shadowcolor?: string; // 阴影颜色
|
||||
shadowx?: number; // 阴影X偏移
|
||||
shadowy?: number; // 阴影Y偏移
|
||||
// 🔧 新增:宽度限制参数,防止字幕溢出屏幕
|
||||
boxw?: number; // 文本框宽度(超过此宽度时自动换行)
|
||||
line_spacing?: number; // 行间距
|
||||
fix_bounds?: number; // 是否强制限制文本在指定范围内(0或1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 粒子效果配置
|
||||
*/
|
||||
export interface ParticleConfig {
|
||||
text: string; // 文本
|
||||
centerX: number; // 中心X坐标
|
||||
centerY: number; // 中心Y坐标
|
||||
startTime: number; // 开始时间(秒)
|
||||
duration: number; // 持续时间(秒)
|
||||
particleCount: number; // 粒子数量
|
||||
radius: number; // 爆炸半径
|
||||
fontsize: number; // 字号
|
||||
fontcolor: string; // 颜色
|
||||
}
|
||||
|
||||
/**
|
||||
* 波浪效果配置
|
||||
*/
|
||||
export interface WaveConfig {
|
||||
text: string; // 文本
|
||||
centerX: number; // 中心X坐标
|
||||
baseY: number; // 基准Y坐标
|
||||
startTime: number; // 开始时间(秒)
|
||||
duration: number; // 持续时间(秒)
|
||||
amplitude: number; // 振幅
|
||||
frequency: number; // 频率
|
||||
fontsize: number; // 字号
|
||||
fontcolor: string; // 颜色
|
||||
}
|
||||
|
||||
// ==================== 布局计算 ====================
|
||||
|
||||
/**
|
||||
* 布局约束
|
||||
*/
|
||||
export interface LayoutConstraints {
|
||||
maxLines: number; // 最大行数(默认2)
|
||||
maxCharsPerLine: number; // 每行最大字符数(动态计算)
|
||||
lineHeight: number; // 行高(fontSize * 1.5)
|
||||
horizontalMargin: number; // 水平边距
|
||||
verticalMargin: number; // 垂直边距
|
||||
effectSpacing: number; // 特效字幕间距(字符数)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字幕行
|
||||
*/
|
||||
export interface SubtitleLine {
|
||||
subtitles: ArrangedSubtitle[]; // 该行的所有字幕
|
||||
startTime: number; // 行开始时间
|
||||
endTime: number; // 行结束时间
|
||||
yPosition: number; // Y坐标
|
||||
totalChars: number; // 总字符数
|
||||
}
|
||||
|
||||
/**
|
||||
* 字幕帧(时间段)
|
||||
*/
|
||||
export interface SubtitleFrame {
|
||||
lines: SubtitleLine[]; // 该帧的所有行
|
||||
startTime: number; // 帧开始时间
|
||||
endTime: number; // 帧结束时间
|
||||
}
|
||||
|
||||
// ==================== FFmpeg ====================
|
||||
|
||||
/**
|
||||
* FFmpeg命令构建结果
|
||||
*/
|
||||
export interface FFmpegCommand {
|
||||
fullCommand: string; // 完整命令
|
||||
filters: string[]; // 滤镜列表
|
||||
assFiles: string[]; // ASS文件路径
|
||||
tempFiles: string[]; // 临时文件(需要清理)
|
||||
// 🆕 音效混音配置
|
||||
audioMixConfig?: {
|
||||
inputFiles: string[]; // 音效文件路径列表
|
||||
mixFilter: string; // 音频混音滤镜
|
||||
} | null;
|
||||
stickerInputs?: string[]; // 🆕 贴纸输入参数
|
||||
}
|
||||
|
||||
// ==================== 工具函数返回类型 ====================
|
||||
|
||||
/**
|
||||
* 颜色转换结果
|
||||
*/
|
||||
export interface ColorConversion {
|
||||
hex: string; // #RRGGBB
|
||||
ass: string; // &HAABBGGRR
|
||||
drawtext: string; // 0xRRGGBB
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间格式转换结果
|
||||
*/
|
||||
export interface TimeConversion {
|
||||
seconds: number; // 秒(浮点数)
|
||||
assFormat: string; // ASS格式:H:MM:SS.mm
|
||||
drawtextEnable: string; // drawtext enable表达式
|
||||
}
|
||||
|
||||
// ==================== 生成结果 ====================
|
||||
|
||||
/**
|
||||
* 字幕生成结果
|
||||
*/
|
||||
export interface GenerationResult {
|
||||
success: boolean; // 是否成功
|
||||
outputPath?: string; // 输出文件路径
|
||||
srtPath?: string; // SRT字幕文件路径(用于展示和下载)
|
||||
message?: string; // 消息
|
||||
error?: Error; // 错误(如果失败)
|
||||
statistics?: GenerationStatistics; // 统计信息
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成统计信息
|
||||
*/
|
||||
export interface GenerationStatistics {
|
||||
totalSegments: number; // 总段数
|
||||
correctedCount: number; // 修正的段数
|
||||
keywordCount: number; // 关键词数量
|
||||
normalSubtitles: number; // 普通字幕数量
|
||||
effectSubtitles: number; // 特效字幕数量
|
||||
renderTime: number; // 渲染时间(秒)
|
||||
processingTime: number; // 总处理时间(秒)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 前端认证服务
|
||||
* 负责与后端 IPC 通信,处理用户注册、登录、Token 管理等
|
||||
*/
|
||||
|
||||
const AUTH_TOKEN_KEY = 'auth_token';
|
||||
const AUTH_USER_KEY = 'auth_user';
|
||||
const AUTH_SUBSCRIPTION_KEY = 'auth_subscription';
|
||||
const AUTH_SESSION_FLAG = 'auth_session_active';
|
||||
const AUTH_REMEMBER_ME_KEY = 'auth_remember_me';
|
||||
|
||||
// 🔧 应用启动时清除登录状态(确保每次启动都需要重新登录)
|
||||
// 使用 sessionStorage 标记当前会话,如果没有标记说明是新启动
|
||||
// 💡 新增:如果用户选择了"记住我",则不清除登录状态
|
||||
if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined') {
|
||||
const isSessionActive = sessionStorage.getItem(AUTH_SESSION_FLAG);
|
||||
const rememberMe = localStorage.getItem(AUTH_REMEMBER_ME_KEY);
|
||||
|
||||
if (!isSessionActive) {
|
||||
// 检查是否勾选了"记住我"
|
||||
if (rememberMe !== 'true') {
|
||||
// 新会话,且用户未选择记住我,清除之前的登录状态
|
||||
console.log('[Auth] 新会话启动,清除之前的登录状态');
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
localStorage.removeItem(AUTH_SUBSCRIPTION_KEY);
|
||||
} else {
|
||||
console.log('[Auth] 新会话启动,但用户选择了记住我,保留登录状态');
|
||||
}
|
||||
// 标记当前会话
|
||||
sessionStorage.setItem(AUTH_SESSION_FLAG, 'true');
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
success: boolean;
|
||||
token?: string;
|
||||
user?: any;
|
||||
subscription?: any;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface VerifyResult {
|
||||
valid: boolean;
|
||||
payload?: any;
|
||||
kicked?: boolean;
|
||||
message?: string;
|
||||
apiUnavailable?: boolean;
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
invite_code?: string;
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 window.$mapi 是否可用
|
||||
*/
|
||||
const ensureApiAvailable = async (): Promise<boolean> => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (typeof window !== 'undefined' && window['$mapi'] && window['$mapi'].auth) {
|
||||
return true;
|
||||
}
|
||||
if (i === 0) {
|
||||
const keys = typeof window !== 'undefined' ? Object.keys(window).filter(k => k.startsWith('$') || k.startsWith('__')) : [];
|
||||
console.warn(`[Auth.ensureApiAvailable] window.$mapi 不存在, 等待重试 (${i + 1}/10), window特殊键:`, keys);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
console.error('[Auth.ensureApiAvailable] 等待超时,window.$mapi 仍未就绪');
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearAuthStorage = () => {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
localStorage.removeItem(AUTH_USER_KEY);
|
||||
localStorage.removeItem(AUTH_SUBSCRIPTION_KEY);
|
||||
};
|
||||
|
||||
const refreshRemoteConfigAfterAuth = async () => {
|
||||
try {
|
||||
const systemConfig = await import('./systemConfig');
|
||||
systemConfig.clearConfigCache();
|
||||
const config = await systemConfig.fetchSystemConfig();
|
||||
(window as any).__systemConfig = config;
|
||||
} catch (error) {
|
||||
console.warn('[Auth] 刷新渲染进程系统配置失败:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window['$mapi']?.runninghub?.refreshConfig?.();
|
||||
if (result && !result.success) {
|
||||
console.warn('[Auth] 刷新主进程 RunningHub 配置失败:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Auth] 刷新主进程 RunningHub 配置异常:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
const register = async (data: RegisterData): Promise<AuthResult> => {
|
||||
try {
|
||||
if (!await ensureApiAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '认证 API 不可用'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await window['$mapi'].auth.register(data);
|
||||
|
||||
if (result.success && result.token) {
|
||||
// 保存 Token 和用户信息
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, result.token);
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(result.user));
|
||||
localStorage.setItem(AUTH_SUBSCRIPTION_KEY, JSON.stringify(result.subscription));
|
||||
await refreshRemoteConfigAfterAuth();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '注册失败'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
const login = async (data: LoginData): Promise<AuthResult> => {
|
||||
try {
|
||||
if (!await ensureApiAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '认证 API 不可用'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await window['$mapi'].auth.login(data);
|
||||
|
||||
if (result.success && result.token) {
|
||||
// 保存 Token 和用户信息
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, result.token);
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(result.user));
|
||||
localStorage.setItem(AUTH_SUBSCRIPTION_KEY, JSON.stringify(result.subscription));
|
||||
|
||||
// 💡 保存"记住我"的选择
|
||||
if (data.rememberMe) {
|
||||
localStorage.setItem(AUTH_REMEMBER_ME_KEY, 'true');
|
||||
localStorage.setItem('auth_saved_email', data.email);
|
||||
localStorage.setItem('auth_saved_password', data.password);
|
||||
console.log('[Auth] 用户选择了记住我,已保存登录状态');
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_REMEMBER_ME_KEY);
|
||||
localStorage.removeItem('auth_saved_email');
|
||||
localStorage.removeItem('auth_saved_password');
|
||||
console.log('[Auth] 用户未选择记住我,登录状态仅保留到应用关闭');
|
||||
}
|
||||
await refreshRemoteConfigAfterAuth();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '登录失败'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
clearAuthStorage();
|
||||
localStorage.removeItem(AUTH_REMEMBER_ME_KEY);
|
||||
localStorage.removeItem('auth_saved_email');
|
||||
localStorage.removeItem('auth_saved_password');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '已登出'
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '登出失败'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取存储的 Token
|
||||
*/
|
||||
const getToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取存储的用户信息
|
||||
*/
|
||||
const getUser = (): any | null => {
|
||||
try {
|
||||
const userStr = localStorage.getItem(AUTH_USER_KEY);
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取存储的订阅信息
|
||||
*/
|
||||
const getSubscription = (): any | null => {
|
||||
try {
|
||||
const subStr = localStorage.getItem(AUTH_SUBSCRIPTION_KEY);
|
||||
return subStr ? JSON.parse(subStr) : null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证 Token 有效性
|
||||
*/
|
||||
const verifyToken = async (token?: string): Promise<VerifyResult> => {
|
||||
try {
|
||||
if (!await ensureApiAvailable()) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '认证 API 不可用',
|
||||
apiUnavailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenToVerify = token || getToken();
|
||||
if (!tokenToVerify) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Token 不存在'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await window['$mapi'].auth.verifyToken(tokenToVerify);
|
||||
|
||||
if (!result.valid) {
|
||||
clearAuthStorage();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
valid: false,
|
||||
message: error.message || 'Token 验证失败'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息(从后端)
|
||||
*/
|
||||
const getCurrentUser = async (token?: string) => {
|
||||
try {
|
||||
if (!await ensureApiAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '认证 API 不可用'
|
||||
};
|
||||
}
|
||||
|
||||
const tokenToUse = token || getToken();
|
||||
if (!tokenToUse) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Token 不存在'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await window['$mapi'].auth.getCurrentUser(tokenToUse);
|
||||
|
||||
if (result.success) {
|
||||
// 更新本地缓存
|
||||
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(result.user));
|
||||
localStorage.setItem(AUTH_SUBSCRIPTION_KEY, JSON.stringify(result.subscription));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '获取用户信息失败'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查订阅状态
|
||||
*/
|
||||
const checkSubscriptionStatus = async (userId?: number) => {
|
||||
try {
|
||||
if (!await ensureApiAvailable()) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isExpired: true
|
||||
};
|
||||
}
|
||||
|
||||
// 如果没有提供 userId,从本地用户信息获取
|
||||
const user = userId ? { id: userId } : getUser();
|
||||
if (!user || !user.id) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isExpired: true,
|
||||
message: '用户不存在'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await window['$mapi'].auth.checkSubscriptionStatus(user.id);
|
||||
|
||||
if (result.subscription) {
|
||||
localStorage.setItem(AUTH_SUBSCRIPTION_KEY, JSON.stringify(result.subscription));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isExpired: true,
|
||||
message: error.message || '检查订阅状态失败'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否已登录
|
||||
*/
|
||||
const isAuthenticated = (): boolean => {
|
||||
return !!getToken() && !!getUser();
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查用户是否为管理员
|
||||
*/
|
||||
const isAdmin = (): boolean => {
|
||||
const user = getUser();
|
||||
return user?.role === 'admin';
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否可以访问应用(已登录 + 订阅未过期)
|
||||
*/
|
||||
const canAccess = async (): Promise<boolean> => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const verifyResult = await verifyToken(token);
|
||||
if (!verifyResult.valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await getCurrentUser(token);
|
||||
if (!result.success || !result.subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(result.subscription.expires_at);
|
||||
const now = new Date();
|
||||
return now <= expiresAt;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取剩余天数
|
||||
*/
|
||||
const getRemainingDays = (): number => {
|
||||
const subscription = getSubscription();
|
||||
if (!subscription) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(subscription.expires_at);
|
||||
const now = new Date();
|
||||
const diffTime = expiresAt.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return diffDays > 0 ? diffDays : 0;
|
||||
};
|
||||
|
||||
export default {
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
getToken,
|
||||
getUser,
|
||||
getSubscription,
|
||||
verifyToken,
|
||||
getCurrentUser,
|
||||
checkSubscriptionStatus,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
canAccess,
|
||||
getRemainingDays
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 全局IPC字幕生成监听器
|
||||
* 负责处理来自主进程的字幕生成请求
|
||||
* 在应用启动时注册,确保在后端发送消息时能正确接收
|
||||
*/
|
||||
|
||||
// 🔧 通过 window.ipcRenderer 访问 IPC
|
||||
import { ZimuShengcheng } from '@/service/ZimuShengcheng';
|
||||
|
||||
const ipcRenderer = (window as any).ipcRenderer;
|
||||
|
||||
/**
|
||||
* 设置全局的字幕生成IPC监听器
|
||||
* 这个函数应该在应用启动时调用,在App.vue挂载前
|
||||
*/
|
||||
export function setupGlobalSubtitleListener() {
|
||||
try {
|
||||
console.log('[ipcSubtitleListener] 🔧 开始设置全局字幕生成IPC监听器...');
|
||||
|
||||
// ⚠️ 检查 ipcRenderer 是否可用(前端环境可能不可用)
|
||||
if (typeof (window as any).ipcRenderer === 'undefined') {
|
||||
console.warn('[ipcSubtitleListener] ⚠️ ipcRenderer 不可用,跳过设置');
|
||||
return;
|
||||
}
|
||||
|
||||
const ipcRenderer = (window as any).ipcRenderer;
|
||||
|
||||
// 移除可能存在的旧监听器,避免重复
|
||||
ipcRenderer.removeAllListeners('ipAgent:performSubtitleGeneration');
|
||||
console.log('[ipcSubtitleListener] ✓ 已移除旧监听器');
|
||||
|
||||
// 监听来自主进程的字幕生成请求
|
||||
ipcRenderer.on('ipAgent:performSubtitleGeneration', async (event: any, params: any) => {
|
||||
console.log('[ipAgent:performSubtitleGeneration] 收到主进程请求:', {
|
||||
videoPath: params.videoPath,
|
||||
userText: params.userText?.substring(0, 50) + '...',
|
||||
segmentsCount: params.funasrSegments?.length || 0
|
||||
});
|
||||
|
||||
try {
|
||||
// 创建ZimuShengcheng实例,执行完整的七步流程
|
||||
const service = new ZimuShengcheng();
|
||||
|
||||
console.log('[ipAgent:performSubtitleGeneration] 开始执行ZimuShengcheng.generate()');
|
||||
|
||||
// 这里调用新的ZimuShengcheng服务,完成完整的七步流程:
|
||||
// 1. 文案修正
|
||||
// 2. 关键词匹配
|
||||
// 3. 智能排列
|
||||
// 4. 生成ASS文件
|
||||
// 5. 生成drawtext命令
|
||||
// 6. 构建FFmpeg命令
|
||||
// 7. 执行FFmpeg
|
||||
const result = await service.generate({
|
||||
videoPath: params.videoPath,
|
||||
videoWidth: params.videoWidth,
|
||||
videoHeight: params.videoHeight,
|
||||
userText: params.userText,
|
||||
funasrSegments: params.funasrSegments,
|
||||
subtitleStyle: params.subtitleStyle,
|
||||
keywordGroups: params.keywordGroups,
|
||||
keywordRenderMode: params.keywordRenderMode,
|
||||
fontDir: params.fontDir,
|
||||
outputPath: params.outputPath,
|
||||
// ✨ 新增:标题字幕配置
|
||||
titleSubtitleConfig: params.titleSubtitleConfig,
|
||||
// ✨ 新增:视频时长
|
||||
videoDuration: params.videoDuration
|
||||
});
|
||||
|
||||
console.log('[ipAgent:performSubtitleGeneration] ZimuShengcheng执行完成:', {
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
message: result.message
|
||||
});
|
||||
|
||||
// 返回结果给主进程
|
||||
// 使用ipcRenderer.send(),发送给主进程的ipcMain.once监听器
|
||||
ipcRenderer.send('ipAgent:generateSubtitleV2Result', {
|
||||
success: result.success,
|
||||
outputPath: result.outputPath,
|
||||
message: result.message,
|
||||
statistics: result.statistics
|
||||
// 注意: FFmpeg命令已在ZimuShengcheng.generate()中执行,
|
||||
// 不需要返回ffmpegCommand让主进程再执行一次
|
||||
});
|
||||
|
||||
console.log('[ipAgent:performSubtitleGeneration] 已向主进程发送结果');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[ipAgent:performSubtitleGeneration] 字幕生成失败:', error);
|
||||
|
||||
// 返回错误结果给主进程
|
||||
ipcRenderer.send('ipAgent:generateSubtitleV2Result', {
|
||||
success: false,
|
||||
message: '字幕生成失败: ' + (error?.message || '未知错误'),
|
||||
error: error?.message
|
||||
});
|
||||
|
||||
console.log('[ipAgent:performSubtitleGeneration] 已向主进程发送错误结果');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[ipcSubtitleListener] ✅ 全局IPC监听器设置完成');
|
||||
} catch (error: any) {
|
||||
console.error('[ipcSubtitleListener] ❌ 设置监听器失败:', error);
|
||||
console.error('[ipcSubtitleListener] 错误堆栈:', error?.stack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { AppConfig } from '../config'
|
||||
|
||||
export interface SystemConfig {
|
||||
aliyun_bailian_api_key: string
|
||||
aliyun_bailian_base_url: string
|
||||
aliyun_oss_access_key_id: string
|
||||
aliyun_oss_access_key_secret: string
|
||||
aliyun_oss_bucket: string
|
||||
aliyun_oss_region: string
|
||||
volcengine_api_key: string
|
||||
volcengine_model: string
|
||||
runninghub_api_key: string
|
||||
runninghub_workflow_id_1: string
|
||||
runninghub_workflow_id_2: string
|
||||
runninghub_tts_webapp_id_v2: string
|
||||
runninghub_tts_webapp_id_v3: string
|
||||
runninghub_tts_v2_audio_node: string
|
||||
runninghub_tts_v2_text_node: string
|
||||
runninghub_tts_v2_emotion_node: string
|
||||
cloud_beauty_base_url: string
|
||||
cloud_beauty_api_key: string
|
||||
cloud_beauty_default_mode: string
|
||||
cloud_beauty_enabled: string
|
||||
}
|
||||
|
||||
const FALLBACK_CONFIG: SystemConfig = {
|
||||
aliyun_bailian_api_key: '',
|
||||
aliyun_bailian_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
aliyun_oss_access_key_id: '',
|
||||
aliyun_oss_access_key_secret: '',
|
||||
aliyun_oss_bucket: '',
|
||||
aliyun_oss_region: 'oss-cn-beijing',
|
||||
volcengine_api_key: '',
|
||||
volcengine_model: 'doubao-seed-2-0-mini-260215',
|
||||
runninghub_api_key: '',
|
||||
runninghub_workflow_id_1: '2013514129943826433',
|
||||
runninghub_workflow_id_2: '2013514129943826433',
|
||||
runninghub_tts_webapp_id_v2: '1965614643077070850',
|
||||
runninghub_tts_webapp_id_v3: '2028779949334728706',
|
||||
runninghub_tts_v2_audio_node: '13',
|
||||
runninghub_tts_v2_text_node: '14',
|
||||
runninghub_tts_v2_emotion_node: '15',
|
||||
cloud_beauty_base_url: '',
|
||||
cloud_beauty_api_key: '',
|
||||
cloud_beauty_default_mode: '256m',
|
||||
cloud_beauty_enabled: 'false'
|
||||
}
|
||||
|
||||
let cachedConfig: SystemConfig | null = null
|
||||
let lastFetchTime = 0
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return AppConfig.apiBaseUrl.replace(/\/api\/?$/, '')
|
||||
}
|
||||
|
||||
export async function fetchSystemConfig(): Promise<SystemConfig> {
|
||||
const now = Date.now()
|
||||
|
||||
try {
|
||||
const baseUrl = getBaseUrl()
|
||||
const response = await fetch(`${baseUrl}/api/system/config/public?_t=${now}`, {
|
||||
cache: 'no-store'
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.config) {
|
||||
cachedConfig = { ...FALLBACK_CONFIG, ...data.config }
|
||||
lastFetchTime = now
|
||||
return cachedConfig
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[SystemConfig] 获取系统配置失败,使用兜底配置:', error)
|
||||
}
|
||||
|
||||
return cachedConfig || FALLBACK_CONFIG
|
||||
}
|
||||
|
||||
export function getSystemConfigSync(): SystemConfig {
|
||||
return cachedConfig || FALLBACK_CONFIG
|
||||
}
|
||||
|
||||
export function clearConfigCache(): void {
|
||||
cachedConfig = null
|
||||
lastFetchTime = 0
|
||||
}
|
||||
|
||||
export async function getAliyunBailianApiKey(): Promise<string> {
|
||||
const config = await fetchSystemConfig()
|
||||
return config.aliyun_bailian_api_key
|
||||
}
|
||||
|
||||
export async function getAliyunOssConfig(): Promise<{
|
||||
accessKeyId: string
|
||||
accessKeySecret: string
|
||||
bucket: string
|
||||
region: string
|
||||
}> {
|
||||
const config = await fetchSystemConfig()
|
||||
return {
|
||||
accessKeyId: config.aliyun_oss_access_key_id,
|
||||
accessKeySecret: config.aliyun_oss_access_key_secret,
|
||||
bucket: config.aliyun_oss_bucket,
|
||||
region: config.aliyun_oss_region
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVolcengineConfig(): Promise<{
|
||||
apiKey: string
|
||||
model: string
|
||||
}> {
|
||||
const config = await fetchSystemConfig()
|
||||
return {
|
||||
apiKey: config.volcengine_api_key,
|
||||
model: config.volcengine_model
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRunningHubApiKey(): Promise<string> {
|
||||
const config = await fetchSystemConfig()
|
||||
return config.runninghub_api_key
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 单元测试:ZimuShengcheng的各个组件
|
||||
*/
|
||||
|
||||
import { LevenshteinMatcher } from '@/lib/algorithms/Levenshtein';
|
||||
import { ASSGenerator } from '@/lib/subtitle/ASSGenerator';
|
||||
import { DrawtextGenerator } from '@/lib/subtitle/DrawtextGenerator';
|
||||
|
||||
// 测试数据
|
||||
const testUserText = "这是测试文案,包含关键词";
|
||||
const testAsrSegments = [
|
||||
{ text: "这是测试文案", start: 0, end: 2, words: [] },
|
||||
{ text: "包含关键词", start: 2, end: 4, words: [] }
|
||||
];
|
||||
|
||||
console.log('\n=== 测试1: Levenshtein文案修正 ===');
|
||||
try {
|
||||
const corrected = LevenshteinMatcher.correctText(testUserText, testAsrSegments);
|
||||
console.log('成功: 文案修正成功');
|
||||
console.log(' 修正后的段数:', corrected.length);
|
||||
corrected.forEach((seg: any, idx: number) => {
|
||||
console.log(` [${idx}] "${seg.text}" (${seg.start}s - ${seg.end}s, 修正:${seg.corrected})`);
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('失败: 文案修正失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n=== 测试2: ASS颜色转换 ===');
|
||||
try {
|
||||
const assColor = ASSGenerator.convertColorToASS('#FFFFFF', 1);
|
||||
console.log('成功: 颜色转换成功');
|
||||
console.log(' #FFFFFF(完全不透明) ->', assColor);
|
||||
|
||||
const assColorTransparent = ASSGenerator.convertColorToASS('#000000', 0);
|
||||
console.log(' #000000(完全透明) ->', assColorTransparent);
|
||||
} catch (error: any) {
|
||||
console.error('失败: 颜色转换失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n=== 测试3: ASS样式生成 ===');
|
||||
try {
|
||||
const testStyle = {
|
||||
fontName: 'Microsoft YaHei Bold',
|
||||
fontSize: 48,
|
||||
fontColor: '#FFFFFF',
|
||||
outlineColor: '#000000',
|
||||
outlineWidth: 3,
|
||||
backgroundColor: 'transparent',
|
||||
backgroundOpacity: 0,
|
||||
shadowOffset: 2
|
||||
};
|
||||
|
||||
const assStyleConfig = ASSGenerator.convertRenderStyleToASS('TestStyle', testStyle, 1080);
|
||||
console.log('成功: ASS样式生成成功');
|
||||
console.log(' 样式名称:', assStyleConfig.name);
|
||||
console.log(' 字体:', assStyleConfig.fontname);
|
||||
console.log(' 大小:', assStyleConfig.fontsize);
|
||||
} catch (error: any) {
|
||||
console.error('失败: ASS样式生成失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n=== 测试4: drawtext命令生成 ===');
|
||||
try {
|
||||
const drawtextCmd = DrawtextGenerator.generateBasic({
|
||||
fontfile: 'C:/Windows/Fonts/msyhbd.ttc',
|
||||
text: '测试字幕',
|
||||
x: 960,
|
||||
y: 1000,
|
||||
fontsize: 48,
|
||||
fontcolor: '0xFFFFFF',
|
||||
borderw: 3,
|
||||
bordercolor: '0x000000',
|
||||
enable: 'between(t,0,2)'
|
||||
});
|
||||
|
||||
console.log('成功: drawtext命令生成成功');
|
||||
console.log(' 命令长度:', drawtextCmd.length);
|
||||
} catch (error: any) {
|
||||
console.error('失败: drawtext命令生成失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n测试完成');
|
||||
Reference in New Issue
Block a user