Initial clean project import

This commit is contained in:
cat-shark
2026-06-19 18:41:41 +08:00
commit a13b804c7a
1306 changed files with 220568 additions and 0 deletions
+24
View File
@@ -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;
},
};
+269
View File
@@ -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();
+57
View File
@@ -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;
},
};
+86
View File
@@ -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;
}
}
+63
View File
@@ -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);
};
+263
View File
@@ -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();
}
+297
View File
@@ -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;
+246
View File
@@ -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;
+149
View File
@@ -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;
},
};
+195
View File
@@ -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();
+265
View File
@@ -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;
},
};
+478
View File
@@ -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;
},
};
+253
View File
@@ -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;
},
};
+87
View File
@@ -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
+467
View File
@@ -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; // 总处理时间(秒)
}
+421
View File
@@ -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
};
+109
View File
@@ -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);
}
}
+121
View File
@@ -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
}
+82
View File
@@ -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测试完成');