Initial clean project import
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { spawn } from 'child_process';
|
||||
import logger from '../log/main';
|
||||
import { getElectronSystemConfigSync, fetchElectronSystemConfig } from '../systemConfig';
|
||||
import { getFFmpegExecutablePath, resourceExists } from '../../lib/resource-path';
|
||||
|
||||
/**
|
||||
* RunningHub API 配置
|
||||
*/
|
||||
export interface RunningHubConfig {
|
||||
apiKey: string;
|
||||
workflowId: string;
|
||||
baseUrl?: string;
|
||||
apiVersion?: 'v1' | 'v2';
|
||||
instanceType?: 'default' | 'plus';
|
||||
nodes?: {
|
||||
videoNode: string;
|
||||
audioNode: string;
|
||||
durationNode?: string;
|
||||
fpsNode?: string;
|
||||
widthNode?: string;
|
||||
heightNode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function runProcess(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, { windowsHide: true });
|
||||
let stderr = '';
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || `process exited with code ${code}`));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeAudioForRunningHubUpload(filePath: string): Promise<{ filePath: string; cleanupPath?: string }> {
|
||||
const ffmpegPath = getFFmpegExecutablePath();
|
||||
if (!resourceExists(ffmpegPath)) {
|
||||
logger.warn('[RunningHub] FFmpeg not found, upload original audio file', { filePath, ffmpegPath });
|
||||
return { filePath };
|
||||
}
|
||||
|
||||
const outputPath = path.join(
|
||||
os.tmpdir(),
|
||||
`runninghub_upload_${Date.now()}_${Math.random().toString(36).slice(2)}.wav`
|
||||
);
|
||||
|
||||
await runProcess(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-vn',
|
||||
'-ac', '1',
|
||||
'-ar', '24000',
|
||||
'-c:a', 'pcm_s16le',
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
const stats = fs.statSync(outputPath);
|
||||
if (stats.size <= 44) {
|
||||
fs.rmSync(outputPath, { force: true });
|
||||
throw new Error('normalized audio file is empty');
|
||||
}
|
||||
|
||||
logger.info('[RunningHub] Audio normalized before upload', {
|
||||
source: filePath,
|
||||
outputPath,
|
||||
outputSize: stats.size,
|
||||
});
|
||||
|
||||
return { filePath: outputPath, cleanupPath: outputPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务状态
|
||||
*/
|
||||
export type TaskStatus = 'QUEUED' | 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
/**
|
||||
* 任务查询结果
|
||||
*/
|
||||
export interface TaskQueryResult {
|
||||
taskId: string;
|
||||
status: TaskStatus;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
results?: Array<{
|
||||
url: string;
|
||||
outputType: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunningHub API 客户端
|
||||
*/
|
||||
export class RunningHubClient {
|
||||
private config: Required<RunningHubConfig>;
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor(config?: Partial<RunningHubConfig>) {
|
||||
// 默认配置(云端模式1)
|
||||
this.config = {
|
||||
apiKey: config?.apiKey || getElectronSystemConfigSync().runninghub_api_key,
|
||||
workflowId: config?.workflowId || '2013514129943826433',
|
||||
baseUrl: config?.baseUrl || 'https://www.runninghub.cn',
|
||||
apiVersion: config?.apiVersion || 'v2',
|
||||
instanceType: config?.instanceType || 'default',
|
||||
nodes: config?.nodes || {
|
||||
videoNode: '6',
|
||||
audioNode: '5'
|
||||
}
|
||||
};
|
||||
|
||||
// 创建axios实例
|
||||
this.axios = axios.create({
|
||||
baseURL: this.config.baseUrl,
|
||||
timeout: 600000, // 10分钟超时
|
||||
headers: {
|
||||
'Host': 'www.runninghub.cn'
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[RunningHub] 客户端已初始化', {
|
||||
baseUrl: this.config.baseUrl,
|
||||
workflowId: this.config.workflowId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到RunningHub
|
||||
* @param filePath 本地文件路径
|
||||
* @param fileType 文件类型 (video | audio)
|
||||
* @returns 返回文件在服务器上的fileName
|
||||
*/
|
||||
async uploadFile(filePath: string, fileType: 'video' | 'audio'): Promise<string> {
|
||||
logger.info(`[RunningHub] 开始上传${fileType}文件`, { filePath });
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSizeMB = stats.size / (1024 * 1024);
|
||||
if (fileSizeMB > 100) {
|
||||
throw new Error(`文件大小超过100MB限制: ${fileSizeMB.toFixed(2)}MB`);
|
||||
}
|
||||
|
||||
const FormDataClass = (global as any).FormData;
|
||||
if (!FormDataClass) {
|
||||
throw new Error('当前环境不支持全局 FormData');
|
||||
}
|
||||
|
||||
let uploadPath = filePath;
|
||||
let cleanupPath: string | undefined;
|
||||
if (fileType === 'audio') {
|
||||
const normalized = await normalizeAudioForRunningHubUpload(filePath);
|
||||
uploadPath = normalized.filePath;
|
||||
cleanupPath = normalized.cleanupPath;
|
||||
}
|
||||
|
||||
let fileName: string | null = null;
|
||||
|
||||
try {
|
||||
try {
|
||||
const formData = new FormDataClass();
|
||||
const fileBuffer = fs.readFileSync(uploadPath);
|
||||
const blob = new Blob([fileBuffer]);
|
||||
formData.append('file', blob, path.basename(uploadPath));
|
||||
|
||||
const response = await this.axios.post('/openapi/v2/media/upload/binary', formData, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
if (response.data.code === 0 && response.data.data?.fileName) {
|
||||
fileName = response.data.data.fileName;
|
||||
logger.info(`[RunningHub] v2接口上传${fileType}成功`, { fileName });
|
||||
} else {
|
||||
logger.warn(`[RunningHub] v2接口返回异常,尝试v1`, { code: response.data.code, msg: response.data.message || response.data.msg });
|
||||
}
|
||||
} catch (v2Err: any) {
|
||||
logger.warn(`[RunningHub] v2接口上传失败,回退到v1`, { error: v2Err.message });
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
const formData = new FormDataClass();
|
||||
const fileBuffer = fs.readFileSync(uploadPath);
|
||||
const blob = new Blob([fileBuffer]);
|
||||
formData.append('file', blob, path.basename(uploadPath));
|
||||
formData.append('apiKey', this.config.apiKey);
|
||||
|
||||
const response = await this.axios.post('/task/openapi/upload', formData, {
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity
|
||||
});
|
||||
|
||||
if (response.data.code !== 200 && response.data.code !== 0) {
|
||||
throw new Error(response.data.msg || response.data.message || 'v1上传失败');
|
||||
}
|
||||
|
||||
fileName = response.data.data?.fileName || response.data.data;
|
||||
if (typeof fileName !== 'string') {
|
||||
throw new Error('v1接口未返回有效fileName');
|
||||
}
|
||||
logger.info(`[RunningHub] v1接口上传${fileType}成功`, { fileName });
|
||||
}
|
||||
|
||||
return fileName;
|
||||
} finally {
|
||||
if (cleanupPath) {
|
||||
fs.rmSync(cleanupPath, { force: true });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data || error.response?.statusText || '';
|
||||
logger.error(`[RunningHub] ${fileType}文件上传失败`, {
|
||||
filePath,
|
||||
fileSize: fs.existsSync(filePath) ? (fs.statSync(filePath).size / (1024 * 1024)).toFixed(2) + 'MB' : 'unknown',
|
||||
httpStatus: error.response?.status,
|
||||
responseData: typeof detail === 'string' ? detail.substring(0, 500) : JSON.stringify(detail).substring(0, 500),
|
||||
error: error.message
|
||||
});
|
||||
throw new Error(`上传${fileType}文件失败: ${error.message}${error.response?.status ? ` (HTTP ${error.response?.status})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建云端合成任务
|
||||
* @param videoFileName 视频文件名
|
||||
* @param audioFileName 音频文件名
|
||||
* @param audioDuration 音频时长(秒)
|
||||
* @returns 返回任务ID
|
||||
*/
|
||||
async createTask(videoFileName: string, audioFileName: string, audioDuration?: number): Promise<string> {
|
||||
logger.info('[RunningHub] 开始创建任务', { videoFileName, audioFileName, audioDuration, workflowId: this.config.workflowId, apiVersion: this.config.apiVersion });
|
||||
|
||||
try {
|
||||
if (this.config.apiVersion === 'v2') {
|
||||
const nodeInfoList = [
|
||||
{
|
||||
nodeId: this.config.nodes.videoNode,
|
||||
fieldName: 'video',
|
||||
fieldValue: videoFileName,
|
||||
description: '请导入视频'
|
||||
},
|
||||
{
|
||||
nodeId: this.config.nodes.audioNode,
|
||||
fieldName: 'audio',
|
||||
fieldValue: audioFileName,
|
||||
description: '请导入新的音频'
|
||||
}
|
||||
];
|
||||
|
||||
if (audioDuration && this.config.nodes.durationNode) {
|
||||
nodeInfoList.push({
|
||||
nodeId: this.config.nodes.durationNode,
|
||||
fieldName: 'value',
|
||||
fieldValue: String(Math.ceil(audioDuration)),
|
||||
description: '视频时长设置(秒)'
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.nodes.fpsNode) {
|
||||
nodeInfoList.push({
|
||||
nodeId: this.config.nodes.fpsNode,
|
||||
fieldName: 'int',
|
||||
fieldValue: '30',
|
||||
description: '视频帧率'
|
||||
});
|
||||
}
|
||||
if (this.config.nodes.widthNode) {
|
||||
nodeInfoList.push({
|
||||
nodeId: this.config.nodes.widthNode,
|
||||
fieldName: 'int',
|
||||
fieldValue: '576',
|
||||
description: '视频宽度'
|
||||
});
|
||||
}
|
||||
if (this.config.nodes.heightNode) {
|
||||
nodeInfoList.push({
|
||||
nodeId: this.config.nodes.heightNode,
|
||||
fieldName: 'int',
|
||||
fieldValue: '1024',
|
||||
description: '视频高度'
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
randomSeed: true,
|
||||
nodeInfoList,
|
||||
instanceType: this.config.instanceType || 'default',
|
||||
retainSeconds: 0,
|
||||
usePersonalQueue: false
|
||||
};
|
||||
|
||||
logger.info('[RunningHub] v2 API请求payload', payload);
|
||||
|
||||
const response = await this.axios.post(`/openapi/v2/run/ai-app/${this.config.workflowId}`, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[RunningHub] v2 API响应', {
|
||||
status: response.status,
|
||||
data: response.data
|
||||
});
|
||||
|
||||
const taskId = response.data.taskId;
|
||||
|
||||
if (!taskId) {
|
||||
throw new Error(response.data.errorMessage || '创建任务失败:未返回taskId');
|
||||
}
|
||||
|
||||
logger.info('[RunningHub] 任务创建成功(v2)', { taskId, status: response.data.status });
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
apiKey: this.config.apiKey,
|
||||
workflowId: this.config.workflowId,
|
||||
nodeInfoList: [
|
||||
{
|
||||
nodeId: this.config.nodes.videoNode,
|
||||
fieldName: 'file',
|
||||
fieldValue: videoFileName
|
||||
},
|
||||
{
|
||||
nodeId: this.config.nodes.audioNode,
|
||||
fieldName: 'audio',
|
||||
fieldValue: audioFileName
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
logger.info('[RunningHub] v1 API请求payload', payload);
|
||||
|
||||
const response = await this.axios.post('/task/openapi/create', payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[RunningHub] v1 API响应', {
|
||||
status: response.status,
|
||||
data: response.data
|
||||
});
|
||||
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error(response.data.msg || '创建任务失败');
|
||||
}
|
||||
|
||||
const taskId = response.data.data.taskId;
|
||||
logger.info('[RunningHub] 任务创建成功(v1)', {
|
||||
taskId,
|
||||
status: response.data.data.taskStatus
|
||||
});
|
||||
|
||||
return taskId;
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub] 创建任务失败', {
|
||||
error: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
workflowId: this.config.workflowId,
|
||||
apiVersion: this.config.apiVersion
|
||||
});
|
||||
|
||||
let errorMsg = error.message;
|
||||
if (error.response?.data) {
|
||||
errorMsg = `${error.message} (${JSON.stringify(error.response.data)})`;
|
||||
}
|
||||
throw new Error(`创建任务失败: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
* @param taskId 任务ID
|
||||
* @returns 返回任务查询结果
|
||||
*/
|
||||
async queryTask(taskId: string): Promise<TaskQueryResult> {
|
||||
try {
|
||||
const response = await this.axios.post('/openapi/v2/query', {
|
||||
taskId
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
const result: TaskQueryResult = {
|
||||
taskId: response.data.taskId,
|
||||
status: response.data.status,
|
||||
errorCode: response.data.errorCode,
|
||||
errorMessage: response.data.errorMessage,
|
||||
results: response.data.results
|
||||
};
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub] 查询任务失败', {
|
||||
taskId,
|
||||
error: error.message
|
||||
});
|
||||
throw new Error(`查询任务失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询查询任务状态直到完成
|
||||
* @param taskId 任务ID
|
||||
* @param maxRetries 最大重试次数,默认360次(6分钟)
|
||||
* @param interval 查询间隔,默认1000ms
|
||||
* @param onProgress 进度回调函数
|
||||
* @returns 返回最终结果
|
||||
*/
|
||||
async waitForTaskComplete(
|
||||
taskId: string,
|
||||
maxRetries: number = 360,
|
||||
interval: number = 1000,
|
||||
onProgress?: (status: TaskStatus, retryCount: number) => void
|
||||
): Promise<TaskQueryResult> {
|
||||
logger.info('[RunningHub] 开始轮询任务状态', { taskId, maxRetries });
|
||||
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
const result = await this.queryTask(taskId);
|
||||
|
||||
// 调用进度回调
|
||||
if (onProgress) {
|
||||
onProgress(result.status, retryCount);
|
||||
}
|
||||
|
||||
// 任务完成
|
||||
if (result.status === 'SUCCESS') {
|
||||
logger.info('[RunningHub] 任务完成', {
|
||||
taskId,
|
||||
retryCount,
|
||||
results: result.results
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 任务失败
|
||||
if (result.status === 'FAILED') {
|
||||
logger.error('[RunningHub] 任务失败', {
|
||||
taskId,
|
||||
errorCode: result.errorCode,
|
||||
errorMessage: result.errorMessage
|
||||
});
|
||||
throw new Error(result.errorMessage || '任务执行失败');
|
||||
}
|
||||
|
||||
// 继续等待
|
||||
retryCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
// 超时
|
||||
throw new Error(`任务超时: 已等待 ${maxRetries * interval / 1000} 秒`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* RunningHub TTS API 语音合成模块
|
||||
* 文档: https://www.runninghub.cn/runninghub-api-doc-cn/api-279098421
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import logger from '../log/main';
|
||||
import { getFFmpegExecutablePath, resourceExists } from '../../lib/resource-path';
|
||||
import { fetchElectronSystemConfig } from '../systemConfig';
|
||||
|
||||
const BASE_URL = 'https://www.runninghub.cn';
|
||||
const WEBAPP_ID_V2 = '1965614643077070850'; // TTS V2 应用ID
|
||||
const WEBAPP_ID_V3 = '2028779949334728706'; // TTS V3 应用ID(中文最强)
|
||||
|
||||
const DEFAULT_TTS_V2_NODES = {
|
||||
audio: '13',
|
||||
text: '14',
|
||||
emotion: '15',
|
||||
};
|
||||
|
||||
export interface RunningHubConfig {
|
||||
apiKey: string;
|
||||
audioFileName: string; // 克隆音频文件名(需先上传到RunningHub)
|
||||
emotion?: string; // 情感描述,如"害羞的"、"开心的"
|
||||
outputPath?: string; // 自定义输出路径
|
||||
version?: 'v2' | 'v3'; // TTS版本:v2 或 v3(中文最强),默认v2
|
||||
}
|
||||
|
||||
const RUNNINGHUB_EMOTION_ALIASES: Record<string, string> = {
|
||||
'开心': '开心的',
|
||||
'悲伤': '悲伤的',
|
||||
'愤怒': '愤怒的',
|
||||
'恐惧': '恐惧的',
|
||||
'惊讶': '惊讶的',
|
||||
'厌恶': '厌恶的',
|
||||
'害羞': '害羞的',
|
||||
'温柔': '温柔的',
|
||||
'激动': '激动的',
|
||||
'平静': '平静的',
|
||||
'自然': '自然、平静、克制的',
|
||||
};
|
||||
|
||||
function normalizeEmotionPrompt(emotion?: string): string | undefined {
|
||||
const value = String(emotion || '').trim();
|
||||
if (!value || value === '无' || value.toLowerCase() === 'none') {
|
||||
return undefined;
|
||||
}
|
||||
return RUNNINGHUB_EMOTION_ALIASES[value] || value;
|
||||
}
|
||||
|
||||
function normalizeSpeed(speed?: number): number {
|
||||
const parsed = Number(speed);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(2, Math.max(0.5, parsed));
|
||||
}
|
||||
|
||||
function getRunningHubErrorText(error: any): string {
|
||||
const data = error?.response?.data;
|
||||
if (!data) {
|
||||
return error?.message || '';
|
||||
}
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
const nested = data.data && typeof data.data === 'object' ? data.data : {};
|
||||
return [
|
||||
data.msg,
|
||||
data.message,
|
||||
data.errorMessage,
|
||||
data.error,
|
||||
data.code,
|
||||
nested.msg,
|
||||
nested.message,
|
||||
nested.errorMessage,
|
||||
nested.error,
|
||||
nested.code,
|
||||
].filter(Boolean).join(' | ') || JSON.stringify(data);
|
||||
}
|
||||
|
||||
function isFieldMismatchForNode(error: any, nodeId?: string, fieldName?: string): boolean {
|
||||
const text = getRunningHubErrorText(error);
|
||||
return text.includes('NODE_INFO_MISMATCH')
|
||||
&& (!nodeId || text.includes(`nodeId=${nodeId}`))
|
||||
&& (!fieldName || text.includes(`fieldName=${fieldName}`));
|
||||
}
|
||||
|
||||
function buildAtempoFilterChain(speed: number): string {
|
||||
const filters: string[] = [];
|
||||
let remaining = speed;
|
||||
|
||||
while (remaining > 2) {
|
||||
filters.push('atempo=2');
|
||||
remaining /= 2;
|
||||
}
|
||||
while (remaining < 0.5) {
|
||||
filters.push('atempo=0.5');
|
||||
remaining /= 0.5;
|
||||
}
|
||||
|
||||
filters.push(`atempo=${remaining.toFixed(3)}`);
|
||||
return filters.join(',');
|
||||
}
|
||||
|
||||
function runFFmpeg(ffmpegPath: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(ffmpegPath, args);
|
||||
let stderr = '';
|
||||
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr || `FFmpeg exited with code ${code}`));
|
||||
});
|
||||
proc.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function adjustAudioSpeedIfNeeded(filePath: string, speed?: number): Promise<string> {
|
||||
const normalizedSpeed = normalizeSpeed(speed);
|
||||
if (Math.abs(normalizedSpeed - 1) < 0.01) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const ffmpegPath = getFFmpegExecutablePath();
|
||||
if (!resourceExists(ffmpegPath)) {
|
||||
logger.warn('[RunningHub] FFmpeg missing, skip speed post-process', {
|
||||
filePath,
|
||||
normalizedSpeed,
|
||||
});
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath) || '.mp3';
|
||||
const tempOutputPath = path.join(
|
||||
path.dirname(filePath),
|
||||
`${path.basename(filePath, ext)}_speed${ext}`
|
||||
);
|
||||
|
||||
await runFFmpeg(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', filePath,
|
||||
'-vn',
|
||||
'-filter:a', buildAtempoFilterChain(normalizedSpeed),
|
||||
tempOutputPath,
|
||||
]);
|
||||
|
||||
fs.rmSync(filePath, { force: true });
|
||||
fs.renameSync(tempOutputPath, filePath);
|
||||
|
||||
logger.info('[RunningHub] Speed post-process done', { filePath, normalizedSpeed });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export interface RunningHubResult {
|
||||
success: boolean;
|
||||
audioPath?: string;
|
||||
error?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起TTS任务
|
||||
*/
|
||||
async function submitTask(
|
||||
text: string,
|
||||
config: RunningHubConfig
|
||||
): Promise<{ success: boolean; taskId?: string; error?: string }> {
|
||||
try {
|
||||
const sysCfg = await fetchElectronSystemConfig();
|
||||
const version = config.version || 'v2';
|
||||
const webappId = version === 'v3'
|
||||
? (sysCfg.runninghub_tts_webapp_id_v3 || WEBAPP_ID_V3)
|
||||
: (sysCfg.runninghub_tts_webapp_id_v2 || WEBAPP_ID_V2);
|
||||
const v2Nodes = {
|
||||
audio: sysCfg.runninghub_tts_v2_audio_node || DEFAULT_TTS_V2_NODES.audio,
|
||||
text: sysCfg.runninghub_tts_v2_text_node || DEFAULT_TTS_V2_NODES.text,
|
||||
emotion: sysCfg.runninghub_tts_v2_emotion_node || DEFAULT_TTS_V2_NODES.emotion,
|
||||
};
|
||||
const normalizedEmotion = normalizeEmotionPrompt(config.emotion);
|
||||
const normalizedSpeed = normalizeSpeed((config as any).speed);
|
||||
|
||||
logger.info('[RunningHub] 提交TTS任务', {
|
||||
version,
|
||||
webappId,
|
||||
text: text.substring(0, 50),
|
||||
emotion: normalizedEmotion,
|
||||
speed: normalizedSpeed
|
||||
});
|
||||
|
||||
let nodeInfoList: any[];
|
||||
|
||||
if (version === 'v3') {
|
||||
// V3 节点配置(根据文档 2028779949334728706)
|
||||
nodeInfoList = [
|
||||
{
|
||||
nodeId: "5",
|
||||
fieldName: "audio",
|
||||
fieldValue: config.audioFileName,
|
||||
description: "需要克隆的语音"
|
||||
},
|
||||
{
|
||||
nodeId: "4",
|
||||
fieldName: "prompt",
|
||||
fieldValue: text,
|
||||
description: "文字"
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// V2 节点配置(原有配置)
|
||||
nodeInfoList = [
|
||||
{
|
||||
nodeId: v2Nodes.audio,
|
||||
fieldName: "audio",
|
||||
fieldValue: config.audioFileName,
|
||||
description: "上传克隆音频"
|
||||
},
|
||||
{
|
||||
nodeId: v2Nodes.text,
|
||||
fieldName: "value",
|
||||
fieldValue: text,
|
||||
description: "上传语音文本"
|
||||
}
|
||||
];
|
||||
|
||||
// V2 如果有情感描述,添加情感节点
|
||||
if (v2Nodes.emotion && normalizedEmotion) {
|
||||
nodeInfoList.push({
|
||||
nodeId: v2Nodes.emotion,
|
||||
fieldName: "value",
|
||||
fieldValue: normalizedEmotion || "",
|
||||
description: "情感描述"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const postTask = (nodes: any[]) => axios.post(
|
||||
`${BASE_URL}/openapi/v2/run/ai-app/${webappId}`,
|
||||
{
|
||||
nodeInfoList: nodes,
|
||||
instanceType: version === 'v2' ? "default" : "plus",
|
||||
usePersonalQueue: "false"
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Host': 'www.runninghub.cn',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await postTask(nodeInfoList);
|
||||
} catch (error: any) {
|
||||
if (version === 'v2' && v2Nodes.emotion && isFieldMismatchForNode(error, v2Nodes.emotion, 'value')) {
|
||||
const retryNodeInfoList = nodeInfoList.filter(node => String(node.nodeId) !== String(v2Nodes.emotion));
|
||||
logger.warn('[RunningHub] Emotion node value field mismatch, retry without emotion node', {
|
||||
emotionNode: v2Nodes.emotion,
|
||||
error: getRunningHubErrorText(error),
|
||||
});
|
||||
response = await postTask(retryNodeInfoList);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 V2 和 V3 的响应格式
|
||||
let taskId = response.data?.taskId || response.data?.data?.taskId;
|
||||
let status = response.data?.status || response.data?.data?.taskStatus;
|
||||
const hasEmotionNode = nodeInfoList.some(node => String(node.nodeId) === String(v2Nodes.emotion));
|
||||
if (!taskId && version === 'v2' && hasEmotionNode && isFieldMismatchForNode({ response: { data: response.data } }, v2Nodes.emotion, 'value')) {
|
||||
logger.warn('[RunningHub] Emotion node value field mismatch in response, retry without emotion node', {
|
||||
emotionNode: v2Nodes.emotion,
|
||||
error: getRunningHubErrorText({ response: { data: response.data } }),
|
||||
});
|
||||
response = await postTask(nodeInfoList.filter(node => String(node.nodeId) !== String(v2Nodes.emotion)));
|
||||
taskId = response.data?.taskId || response.data?.data?.taskId;
|
||||
status = response.data?.status || response.data?.data?.taskStatus;
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
logger.info('[RunningHub] 任务提交成功', {
|
||||
version,
|
||||
taskId,
|
||||
status
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
taskId
|
||||
};
|
||||
} else {
|
||||
logger.error('[RunningHub] 任务提交失败', response.data);
|
||||
return {
|
||||
success: false,
|
||||
error: response.data?.errorMessage || response.data?.msg || response.data?.message || '任务提交失败'
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub] 任务提交异常', error);
|
||||
const errorMsg = getRunningHubErrorText(error) || '未知错误';
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询任务结果
|
||||
*/
|
||||
async function queryTaskResult(
|
||||
taskId: string,
|
||||
apiKey: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
outputs?: any[];
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
logger.info('[RunningHub] 查询任务状态', { taskId });
|
||||
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/openapi/v2/query`,
|
||||
{ taskId, apiKey },
|
||||
{
|
||||
headers: {
|
||||
'Host': 'www.runninghub.cn',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
);
|
||||
|
||||
// 打印完整响应便于调试
|
||||
logger.info('[RunningHub] 查询响应原始数据', JSON.stringify(response.data));
|
||||
|
||||
// 尝试从不同字段获取状态(兼容不同API版本)
|
||||
// 尝试从不同字段获取状态(兼容不同API版本)
|
||||
const data = response.data?.data || response.data;
|
||||
const status = data?.taskStatus || data?.status;
|
||||
const outputs = data?.outputs || data?.results;
|
||||
|
||||
// API直接返回{status, results}格式,不需要检查code
|
||||
if (status) {
|
||||
return {
|
||||
success: true,
|
||||
status: status,
|
||||
outputs: outputs
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: response.data?.msg || response.data?.errorMessage || '查询失败'
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub] 查询任务异常', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.msg || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待任务完成
|
||||
*/
|
||||
async function waitForTaskCompletion(
|
||||
taskId: string,
|
||||
apiKey: string,
|
||||
maxWaitTime: number = 3600000, // 最长等待60分钟
|
||||
pollInterval: number = 3000 // 每3秒轮询一次
|
||||
): Promise<{ success: boolean; outputs?: any[]; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
const result = await queryTaskResult(taskId, apiKey);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
logger.info('[RunningHub] 任务状态', {
|
||||
taskId,
|
||||
status: result.status,
|
||||
elapsed: Date.now() - startTime
|
||||
});
|
||||
|
||||
if (result.status === 'SUCCESS' || result.status === 'COMPLETED') {
|
||||
return { success: true, outputs: result.outputs };
|
||||
}
|
||||
|
||||
if (result.status === 'FAILED' || result.status === 'ERROR') {
|
||||
return { success: false, error: '任务执行失败' };
|
||||
}
|
||||
|
||||
// 等待后继续轮询
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
return { success: false, error: '任务超时' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载音频文件
|
||||
*/
|
||||
async function downloadAudio(
|
||||
url: string,
|
||||
outputPath: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
// 确保输出目录存在
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, response.data);
|
||||
logger.info('[RunningHub] 音频下载成功', { outputPath });
|
||||
return true;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub] 音频下载失败', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RunningHub TTS 语音合成主函数
|
||||
*/
|
||||
export async function synthesizeSpeech(
|
||||
text: string,
|
||||
config: RunningHubConfig
|
||||
): Promise<RunningHubResult> {
|
||||
logger.info('[RunningHub] 开始语音合成', {
|
||||
text: text.substring(0, 50),
|
||||
audioFileName: config.audioFileName,
|
||||
emotion: normalizeEmotionPrompt(config.emotion),
|
||||
speed: normalizeSpeed((config as any).speed)
|
||||
});
|
||||
|
||||
// 1. 提交任务
|
||||
const submitResult = await submitTask(text, config);
|
||||
if (!submitResult.success || !submitResult.taskId) {
|
||||
return {
|
||||
success: false,
|
||||
error: submitResult.error || '任务提交失败'
|
||||
};
|
||||
}
|
||||
|
||||
const taskId = submitResult.taskId;
|
||||
|
||||
// 2. 等待任务完成
|
||||
const completionResult = await waitForTaskCompletion(taskId, config.apiKey);
|
||||
if (!completionResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: completionResult.error,
|
||||
taskId
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 获取音频URL
|
||||
const outputs = completionResult.outputs || [];
|
||||
let audioUrl: string | null = null;
|
||||
|
||||
// 遍历outputs找到音频文件
|
||||
for (const output of outputs) {
|
||||
// 使用url或fileUrl字段
|
||||
const url = output.url || output.fileUrl;
|
||||
if (url && (
|
||||
url.endsWith('.mp3') ||
|
||||
url.endsWith('.wav') ||
|
||||
url.endsWith('.flac') || // 添加flac支持
|
||||
output.outputType === 'flac' ||
|
||||
output.outputType === 'audio' ||
|
||||
output.fileType === 'audio'
|
||||
)) {
|
||||
audioUrl = url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!audioUrl) {
|
||||
logger.error('[RunningHub] 未找到音频输出', { outputs });
|
||||
return {
|
||||
success: false,
|
||||
error: '未找到音频输出',
|
||||
taskId
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 下载音频
|
||||
let outputPath: string;
|
||||
if (config.outputPath) {
|
||||
outputPath = config.outputPath;
|
||||
} else {
|
||||
const outputDir = path.join(app.getPath('userData'), 'temp', 'tts');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const ext = path.extname(audioUrl) || '.mp3';
|
||||
outputPath = path.join(outputDir, `runninghub_${Date.now()}${ext}`);
|
||||
}
|
||||
|
||||
const downloaded = await downloadAudio(audioUrl, outputPath);
|
||||
if (!downloaded) {
|
||||
return {
|
||||
success: false,
|
||||
error: '音频下载失败',
|
||||
taskId
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('[RunningHub] 语音合成完成', { outputPath, taskId });
|
||||
outputPath = await adjustAudioSpeedIfNeeded(outputPath, (config as any).speed);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioPath: outputPath,
|
||||
taskId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的情感描述列表
|
||||
*/
|
||||
export function getAvailableEmotions(): Array<{ id: string; name: string; desc: string }> {
|
||||
return [
|
||||
{ id: '', name: '无', desc: '不指定情感' },
|
||||
{ id: '开心的', name: '开心', desc: '愉快欢乐' },
|
||||
{ id: '悲伤的', name: '悲伤', desc: '伤心难过' },
|
||||
{ id: '愤怒的', name: '愤怒', desc: '生气恼怒' },
|
||||
{ id: '害羞的', name: '害羞', desc: '羞涩腼腆' },
|
||||
{ id: '温柔的', name: '温柔', desc: '轻柔亲切' },
|
||||
{ id: '激动的', name: '激动', desc: '激昂兴奋' },
|
||||
{ id: '平静的', name: '平静', desc: '冷静沉稳' },
|
||||
{ id: '惊讶的', name: '惊讶', desc: '吃惊意外' },
|
||||
{ id: '恐惧的', name: '恐惧', desc: '害怕担忧' }
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { RunningHubClient } from './client';
|
||||
import logger from '../log/main';
|
||||
import { getElectronSystemConfigSync, fetchElectronSystemConfig, clearElectronConfigCache } from '../systemConfig';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
let clientInstance1: RunningHubClient | null = null;
|
||||
let clientInstance2: RunningHubClient | null = null;
|
||||
let clientInstance1Key = '';
|
||||
let clientInstance2Key = '';
|
||||
|
||||
type CloudBeautySessionData = {
|
||||
task_id: string;
|
||||
video_upload_url?: string;
|
||||
audio_upload_url?: string;
|
||||
status_url?: string;
|
||||
status?: string;
|
||||
queue_position?: number | null;
|
||||
assigned_node_id?: string | null;
|
||||
};
|
||||
|
||||
type CloudBeautyAuthMode = 'none' | 'legacy' | 'full';
|
||||
|
||||
function buildCloudBeautyHeaders(
|
||||
apiKey: string,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
mode: CloudBeautyAuthMode = 'full'
|
||||
) {
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
if (mode === 'none' || !apiKey) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
headers['X-API-Key'] = apiKey;
|
||||
if (mode === 'full') {
|
||||
headers['ApiKey'] = apiKey;
|
||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function isCloudBeautyAuthError(error: any) {
|
||||
const status = error?.response?.status;
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
|
||||
async function cloudBeautyPostWithFallback(
|
||||
axios: any,
|
||||
url: string,
|
||||
data: any,
|
||||
params: {
|
||||
apiKey: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
timeout?: number;
|
||||
label: string;
|
||||
modes?: CloudBeautyAuthMode[];
|
||||
maxBodyLength?: number;
|
||||
maxContentLength?: number;
|
||||
}
|
||||
) {
|
||||
const modes = params.modes || ['full', 'legacy'];
|
||||
let lastError: any = null;
|
||||
|
||||
for (let i = 0; i < modes.length; i++) {
|
||||
const mode = modes[i];
|
||||
try {
|
||||
return await axios.post(url, data, {
|
||||
headers: buildCloudBeautyHeaders(params.apiKey, params.extraHeaders || {}, mode),
|
||||
timeout: params.timeout,
|
||||
maxBodyLength: params.maxBodyLength,
|
||||
maxContentLength: params.maxContentLength,
|
||||
});
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
if (!isCloudBeautyAuthError(error) || i === modes.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`[CloudBeauty] ${params.label} 鉴权模式回退`, {
|
||||
url,
|
||||
mode,
|
||||
status: error?.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function cloudBeautyGetWithFallback(
|
||||
axios: any,
|
||||
url: string,
|
||||
params: {
|
||||
apiKey: string;
|
||||
extraHeaders?: Record<string, string>;
|
||||
timeout?: number;
|
||||
label: string;
|
||||
modes?: CloudBeautyAuthMode[];
|
||||
}
|
||||
) {
|
||||
const modes = params.modes || ['full', 'legacy'];
|
||||
let lastError: any = null;
|
||||
|
||||
for (let i = 0; i < modes.length; i++) {
|
||||
const mode = modes[i];
|
||||
try {
|
||||
return await axios.get(url, {
|
||||
headers: buildCloudBeautyHeaders(params.apiKey, params.extraHeaders || {}, mode),
|
||||
timeout: params.timeout,
|
||||
});
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
if (!isCloudBeautyAuthError(error) || i === modes.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn(`[CloudBeauty] ${params.label} 鉴权模式回退`, {
|
||||
url,
|
||||
mode,
|
||||
status: error?.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function unwrapCloudBeautyPayload<T = any>(payload: any): T {
|
||||
return (payload?.data || payload || {}) as T;
|
||||
}
|
||||
|
||||
function isCloudBeautyFailureStatus(status?: string) {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
return ['failed', 'fail', 'error', 'rejected', 'cancelled'].includes(normalized);
|
||||
}
|
||||
|
||||
async function waitForCloudBeautyUploadUrls(params: {
|
||||
axios: any;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
taskId: string;
|
||||
statusUrl?: string;
|
||||
maxAttempts?: number;
|
||||
intervalMs?: number;
|
||||
}) {
|
||||
const pollUrl = params.statusUrl || `${params.baseUrl}/api/task/status/${params.taskId}`;
|
||||
const maxAttempts = params.maxAttempts ?? 120;
|
||||
const intervalMs = params.intervalMs ?? 2000;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const resp = await cloudBeautyGetWithFallback(params.axios, pollUrl, {
|
||||
apiKey: params.apiKey,
|
||||
timeout: 30000,
|
||||
label: '查询上传地址',
|
||||
modes: ['full', 'legacy'],
|
||||
});
|
||||
const statusData = unwrapCloudBeautyPayload<CloudBeautySessionData>(resp.data);
|
||||
|
||||
const uploadReady = !!statusData.video_upload_url && !!statusData.audio_upload_url;
|
||||
const nodeAssigned = !!statusData.assigned_node_id;
|
||||
|
||||
if (uploadReady && nodeAssigned) {
|
||||
return statusData;
|
||||
}
|
||||
|
||||
if (isCloudBeautyFailureStatus(statusData.status)) {
|
||||
throw new Error(`等待上传地址失败: ${JSON.stringify(statusData)}`);
|
||||
}
|
||||
|
||||
logger.info('[CloudBeauty] 上传地址尚未就绪,继续等待', {
|
||||
taskId: params.taskId,
|
||||
status: statusData.status,
|
||||
queuePosition: statusData.queue_position,
|
||||
uploadReady,
|
||||
assignedNodeId: statusData.assigned_node_id,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
});
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`等待上传地址超时: taskId=${params.taskId}`);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url: string): string {
|
||||
return (url || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string, kind: 'video' | 'audio'): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (kind === 'video') {
|
||||
if (ext === '.mov') return 'video/quicktime';
|
||||
if (ext === '.mkv') return 'video/x-matroska';
|
||||
if (ext === '.avi') return 'video/x-msvideo';
|
||||
if (ext === '.webm') return 'video/webm';
|
||||
return 'video/mp4';
|
||||
}
|
||||
|
||||
if (ext === '.wav') return 'audio/wav';
|
||||
if (ext === '.m4a') return 'audio/mp4';
|
||||
if (ext === '.aac') return 'audio/aac';
|
||||
if (ext === '.flac') return 'audio/flac';
|
||||
if (ext === '.ogg') return 'audio/ogg';
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
|
||||
const cloudBeautyApiModeCache = new Map<string, 'direct' | 'legacy'>();
|
||||
|
||||
async function refreshRunningHubConfig() {
|
||||
clearElectronConfigCache();
|
||||
clientInstance1 = null;
|
||||
clientInstance2 = null;
|
||||
clientInstance1Key = '';
|
||||
clientInstance2Key = '';
|
||||
cloudBeautyApiModeCache.clear();
|
||||
const config = await fetchElectronSystemConfig();
|
||||
logger.info('[RunningHub] 配置已刷新,客户端缓存已清空', {
|
||||
workflowId1: config.runninghub_workflow_id_1,
|
||||
workflowId2: config.runninghub_workflow_id_2,
|
||||
hasApiKey: !!config.runninghub_api_key,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
workflowId1: config.runninghub_workflow_id_1,
|
||||
workflowId2: config.runninghub_workflow_id_2,
|
||||
hasApiKey: !!config.runninghub_api_key,
|
||||
};
|
||||
}
|
||||
|
||||
async function detectCloudBeautyApiMode(baseUrl: string): Promise<'direct' | 'legacy'> {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const cachedMode = cloudBeautyApiModeCache.get(normalizedBaseUrl);
|
||||
if (cachedMode) {
|
||||
return cachedMode;
|
||||
}
|
||||
|
||||
const axios = (await import('axios')).default;
|
||||
try {
|
||||
const openapiResp = await axios.get(`${normalizedBaseUrl}/openapi.json`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const paths = openapiResp.data?.paths || {};
|
||||
const mode = paths['/api/session/create'] ? 'direct' : 'legacy';
|
||||
cloudBeautyApiModeCache.set(normalizedBaseUrl, mode);
|
||||
return mode;
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 404 || status === 405) {
|
||||
logger.info('[CloudBeauty] openapi.json unavailable, assume legacy mode', {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
status,
|
||||
});
|
||||
cloudBeautyApiModeCache.set(normalizedBaseUrl, 'legacy');
|
||||
return 'legacy';
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCloudBeautyDirectTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
videoPath: string;
|
||||
audioPath: string;
|
||||
humanWeight: string;
|
||||
}) {
|
||||
const axios = (await import('axios')).default;
|
||||
const FormData = (await import('form-data')).default;
|
||||
|
||||
const sessionBody = new URLSearchParams({
|
||||
human_weight: params.humanWeight,
|
||||
weight_sync: '0.5',
|
||||
});
|
||||
|
||||
logger.info('[CloudBeauty] 使用新接口创建直传会话', {
|
||||
baseUrl: params.baseUrl,
|
||||
humanWeight: params.humanWeight,
|
||||
});
|
||||
|
||||
const sessionResp = await cloudBeautyPostWithFallback(
|
||||
axios,
|
||||
`${params.baseUrl}/api/session/create`,
|
||||
sessionBody.toString(),
|
||||
{
|
||||
apiKey: params.apiKey,
|
||||
extraHeaders: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
timeout: 30000,
|
||||
label: '创建直传会话',
|
||||
modes: ['full', 'legacy'],
|
||||
}
|
||||
);
|
||||
|
||||
let sessionData = unwrapCloudBeautyPayload<CloudBeautySessionData>(sessionResp.data);
|
||||
if (
|
||||
sessionData.task_id &&
|
||||
(
|
||||
!sessionData.video_upload_url ||
|
||||
!sessionData.audio_upload_url ||
|
||||
!sessionData.assigned_node_id ||
|
||||
sessionData.status === 'queued_upload'
|
||||
) &&
|
||||
(sessionData.status_url || sessionData.status === 'queued_upload' || typeof sessionData.queue_position === 'number')
|
||||
) {
|
||||
logger.info('[CloudBeauty] 直传会话进入排队,等待上传地址', {
|
||||
taskId: sessionData.task_id,
|
||||
status: sessionData.status,
|
||||
queuePosition: sessionData.queue_position,
|
||||
assignedNodeId: sessionData.assigned_node_id,
|
||||
hasVideoUploadUrl: !!sessionData.video_upload_url,
|
||||
hasAudioUploadUrl: !!sessionData.audio_upload_url,
|
||||
statusUrl: sessionData.status_url,
|
||||
});
|
||||
sessionData = await waitForCloudBeautyUploadUrls({
|
||||
axios,
|
||||
baseUrl: params.baseUrl,
|
||||
apiKey: params.apiKey,
|
||||
taskId: sessionData.task_id,
|
||||
statusUrl: sessionData.status_url,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sessionData.task_id || !sessionData.video_upload_url || !sessionData.audio_upload_url || !sessionData.assigned_node_id) {
|
||||
throw new Error(`新接口返回数据不完整: ${JSON.stringify(sessionResp.data)}`);
|
||||
}
|
||||
|
||||
const uploadFile = async (uploadUrl: string, filePath: string, kind: 'video' | 'audio') => {
|
||||
logger.info('[CloudBeauty] 直传文件到 worker', {
|
||||
kind,
|
||||
uploadUrl,
|
||||
filePath,
|
||||
});
|
||||
|
||||
const createUploadForm = () => {
|
||||
const form = new FormData();
|
||||
form.append('file', fs.createReadStream(filePath), {
|
||||
filename: path.basename(filePath),
|
||||
contentType: getMimeType(filePath, kind),
|
||||
});
|
||||
return form;
|
||||
};
|
||||
|
||||
const modes: CloudBeautyAuthMode[] = ['none', 'legacy', 'full'];
|
||||
let lastError: any = null;
|
||||
|
||||
for (let i = 0; i < modes.length; i++) {
|
||||
const mode = modes[i];
|
||||
const form = createUploadForm();
|
||||
try {
|
||||
const uploadResp = await axios.post(uploadUrl, form, {
|
||||
headers: buildCloudBeautyHeaders(params.apiKey, form.getHeaders() as Record<string, string>, mode),
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
timeout: 10 * 60 * 1000,
|
||||
});
|
||||
return uploadResp.data;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
if (!isCloudBeautyAuthError(error) || i === modes.length - 1) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn('[CloudBeauty] worker 上传鉴权模式回退', {
|
||||
kind,
|
||||
uploadUrl,
|
||||
mode,
|
||||
status: error?.response?.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
const videoUploadResult = await uploadFile(sessionData.video_upload_url, params.videoPath, 'video');
|
||||
const audioUploadResult = await uploadFile(sessionData.audio_upload_url, params.audioPath, 'audio');
|
||||
|
||||
logger.info('[CloudBeauty] 新接口直传完成', {
|
||||
taskId: sessionData.task_id,
|
||||
videoUploadResult,
|
||||
audioUploadResult,
|
||||
});
|
||||
|
||||
return {
|
||||
taskId: sessionData.task_id,
|
||||
mode: 'direct' as const,
|
||||
};
|
||||
}
|
||||
|
||||
async function submitCloudBeautyLegacyTask(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
videoPath: string;
|
||||
audioPath: string;
|
||||
humanWeight: string;
|
||||
sysCfg: any;
|
||||
}) {
|
||||
const ossConfig = {
|
||||
accessKeyId: (params.sysCfg.aliyun_oss_access_key_id || '').trim(),
|
||||
accessKeySecret: (params.sysCfg.aliyun_oss_access_key_secret || '').trim(),
|
||||
bucket: (params.sysCfg.aliyun_oss_bucket || '').trim(),
|
||||
region: (params.sysCfg.aliyun_oss_region || '').trim(),
|
||||
endpoint: `https://${(params.sysCfg.aliyun_oss_region || '').trim()}.aliyuncs.com`
|
||||
};
|
||||
|
||||
if (!ossConfig.accessKeyId || !ossConfig.bucket) {
|
||||
return { success: false, error: 'OSS未配置,无法回退到旧接口模式' };
|
||||
}
|
||||
|
||||
logger.info('[CloudBeauty] 回退旧接口 URL 提交模式', {
|
||||
bucket: ossConfig.bucket,
|
||||
region: ossConfig.region,
|
||||
endpoint: ossConfig.endpoint,
|
||||
});
|
||||
|
||||
const { uploadToOss } = await import('../aliyun/oss');
|
||||
const axios = (await import('axios')).default;
|
||||
const FormData = (await import('form-data')).default;
|
||||
|
||||
const videoExt = path.extname(params.videoPath) || '.mp4';
|
||||
const audioExt = path.extname(params.audioPath) || '.mp3';
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||
|
||||
const videoOssResult = await uploadToOss(params.videoPath, ossConfig, `cloudBeauty/video/${timestamp}_${randomStr}${videoExt}`);
|
||||
if (!videoOssResult.success || !videoOssResult.url) {
|
||||
return { success: false, error: `视频上传OSS失败: ${videoOssResult.error}` };
|
||||
}
|
||||
|
||||
const audioOssResult = await uploadToOss(params.audioPath, ossConfig, `cloudBeauty/audio/${timestamp}_${randomStr}${audioExt}`);
|
||||
if (!audioOssResult.success || !audioOssResult.url) {
|
||||
return { success: false, error: `音频上传OSS失败: ${audioOssResult.error}` };
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('video_url', videoOssResult.url);
|
||||
form.append('audio_url', audioOssResult.url);
|
||||
form.append('human_weight', params.humanWeight);
|
||||
form.append('weight_sync', '0.5');
|
||||
|
||||
const resp = await cloudBeautyPostWithFallback(
|
||||
axios,
|
||||
`${params.baseUrl}/api/task/submit`,
|
||||
form,
|
||||
{
|
||||
apiKey: params.apiKey,
|
||||
extraHeaders: form.getHeaders() as Record<string, string>,
|
||||
timeout: 30000,
|
||||
label: '旧接口提交任务',
|
||||
modes: ['full', 'legacy'],
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
const taskId = resp.data?.data?.task_id;
|
||||
if (!taskId) {
|
||||
return { success: false, error: '未获取到task_id: ' + JSON.stringify(resp.data) };
|
||||
}
|
||||
|
||||
return { success: true, taskId };
|
||||
}
|
||||
|
||||
async function getClientAsync(mode?: string): Promise<RunningHubClient> {
|
||||
await fetchElectronSystemConfig();
|
||||
const sysCfg = getElectronSystemConfigSync();
|
||||
const workflowId1 = sysCfg.runninghub_workflow_id_1 || '2013514129943826433';
|
||||
const workflowId2 = sysCfg.runninghub_workflow_id_2 || '2013514129943826433';
|
||||
|
||||
logger.info('[RunningHub] 当前配置', {
|
||||
mode,
|
||||
workflowId1,
|
||||
workflowId2,
|
||||
source: sysCfg.runninghub_workflow_id_1 ? '后台' : '默认值'
|
||||
});
|
||||
|
||||
const commonNodes = {
|
||||
videoNode: '88',
|
||||
audioNode: '15',
|
||||
durationNode: '60',
|
||||
fpsNode: '63',
|
||||
widthNode: '67',
|
||||
heightNode: '69'
|
||||
};
|
||||
|
||||
if (mode === '__CLOUD_1942022968606355458__' || mode === 'mode2') {
|
||||
const configKey = `${sysCfg.runninghub_api_key}|${workflowId2}|plus`;
|
||||
if (!clientInstance2 || clientInstance2Key !== configKey) {
|
||||
clientInstance2 = new RunningHubClient({
|
||||
apiKey: sysCfg.runninghub_api_key,
|
||||
workflowId: workflowId2,
|
||||
apiVersion: 'v2',
|
||||
instanceType: 'plus',
|
||||
nodes: commonNodes
|
||||
});
|
||||
clientInstance2Key = configKey;
|
||||
logger.info('[RunningHub] 创建v2客户端(mode2)', { workflowId: workflowId2 });
|
||||
}
|
||||
return clientInstance2;
|
||||
}
|
||||
|
||||
const configKey = `${sysCfg.runninghub_api_key}|${workflowId1}|default`;
|
||||
if (!clientInstance1 || clientInstance1Key !== configKey) {
|
||||
clientInstance1 = new RunningHubClient({
|
||||
apiKey: sysCfg.runninghub_api_key,
|
||||
workflowId: workflowId1,
|
||||
apiVersion: 'v2',
|
||||
instanceType: 'default',
|
||||
nodes: commonNodes
|
||||
});
|
||||
clientInstance1Key = configKey;
|
||||
logger.info('[RunningHub] 创建v2客户端(mode1)', { workflowId: workflowId1 });
|
||||
}
|
||||
return clientInstance1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册RunningHub IPC handlers
|
||||
*/
|
||||
export function registerRunningHubHandlers() {
|
||||
|
||||
ipcMain.handle('runninghub:refreshConfig', async () => {
|
||||
try {
|
||||
return await refreshRunningHubConfig();
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub] 刷新配置失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 上传文件
|
||||
ipcMain.handle('runninghub:uploadFile', async (event, params) => {
|
||||
try {
|
||||
const { filePath, fileType, mode } = params;
|
||||
logger.info('[RunningHub:IPC] 上传文件', { filePath, fileType, mode });
|
||||
|
||||
const fileName = await (await getClientAsync(mode)).uploadFile(filePath, fileType);
|
||||
return { success: true, fileName };
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub:IPC] 上传文件失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 创建任务
|
||||
ipcMain.handle('runninghub:createTask', async (event, params) => {
|
||||
try {
|
||||
const { videoFileName, audioFileName, audioDuration, mode } = params;
|
||||
logger.info('[RunningHub:IPC] 创建任务', { videoFileName, audioFileName, audioDuration, mode });
|
||||
|
||||
const taskId = await (await getClientAsync(mode)).createTask(videoFileName, audioFileName, audioDuration);
|
||||
return { success: true, taskId };
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub:IPC] 创建任务失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 查询任务状态
|
||||
ipcMain.handle('runninghub:queryTask', async (event, params) => {
|
||||
try {
|
||||
const { taskId, mode } = params;
|
||||
|
||||
const result = await (await getClientAsync(mode)).queryTask(taskId);
|
||||
return { success: true, result };
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub:IPC] 查询任务失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 等待任务完成(带轮询)
|
||||
ipcMain.handle('runninghub:waitForTaskComplete', async (event, params) => {
|
||||
try {
|
||||
const { taskId, maxRetries, interval, mode } = params;
|
||||
logger.info('[RunningHub:IPC] 等待任务完成', { taskId, maxRetries, mode });
|
||||
|
||||
const result = await (await getClientAsync(mode)).waitForTaskComplete(
|
||||
taskId,
|
||||
maxRetries,
|
||||
interval,
|
||||
(status, retryCount) => {
|
||||
// 发送进度更新到渲染进程
|
||||
event.sender.send('runninghub:task:progress', {
|
||||
taskId,
|
||||
status,
|
||||
retryCount
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true, result };
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub:IPC] 等待任务完成失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 RunningHub TTS 语音合成
|
||||
ipcMain.handle('runninghub:tts:synthesize', async (event, params) => {
|
||||
try {
|
||||
const { synthesizeSpeech } = await import('./index');
|
||||
logger.info('[RunningHub:IPC] 开始TTS语音合成', {
|
||||
text: params.text?.substring(0, 50),
|
||||
emotion: params.config?.emotion
|
||||
});
|
||||
|
||||
const result = await synthesizeSpeech(params.text, params.config);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub:IPC] TTS语音合成失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 获取RunningHub可用情感列表
|
||||
ipcMain.handle('runninghub:tts:getEmotions', async (event) => {
|
||||
try {
|
||||
const { getAvailableEmotions } = await import('./index');
|
||||
return {
|
||||
success: true,
|
||||
emotions: getAvailableEmotions()
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('[RunningHub:IPC] 获取情感列表失败', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('[RunningHub:IPC] Handlers已注册');
|
||||
|
||||
ipcMain.handle('cloudBeauty:submitTask', async (event, params: {
|
||||
videoPath: string; audioPath: string; humanWeight: string;
|
||||
}) => {
|
||||
try {
|
||||
const sysCfg = await fetchElectronSystemConfig();
|
||||
const baseUrl = normalizeBaseUrl(sysCfg.cloud_beauty_base_url || '');
|
||||
const apiKey = sysCfg.cloud_beauty_api_key || '';
|
||||
if (!baseUrl || !apiKey) return { success: false, error: '云端美化服务未配置,请确认后台已填写 Base URL 和 API Key' };
|
||||
|
||||
const { videoPath, audioPath, humanWeight } = params;
|
||||
logger.info('[CloudBeauty] 提交任务', { videoPath, audioPath, humanWeight });
|
||||
|
||||
const detectedMode = await detectCloudBeautyApiMode(baseUrl);
|
||||
logger.info('[CloudBeauty] API mode detected', { baseUrl, detectedMode });
|
||||
if (detectedMode === 'direct') {
|
||||
const directResult = await submitCloudBeautyDirectTask({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
videoPath,
|
||||
audioPath,
|
||||
humanWeight,
|
||||
});
|
||||
return { success: true, taskId: directResult.taskId };
|
||||
}
|
||||
|
||||
return await submitCloudBeautyLegacyTask({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
videoPath,
|
||||
audioPath,
|
||||
humanWeight,
|
||||
sysCfg,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('[CloudBeauty] 提交任务失败', error);
|
||||
const errMsg = error.response
|
||||
? `提交失败(${error.response.status}): ${JSON.stringify(error.response.data)}`
|
||||
: error.message;
|
||||
return { success: false, error: errMsg };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('cloudBeauty:queryStatus', async (event, params: { taskId: string }) => {
|
||||
try {
|
||||
const sysCfg = await fetchElectronSystemConfig();
|
||||
const baseUrl = normalizeBaseUrl(sysCfg.cloud_beauty_base_url || '');
|
||||
|
||||
const axios = (await import('axios')).default;
|
||||
const apiKey = sysCfg.cloud_beauty_api_key || '';
|
||||
const resp = await cloudBeautyGetWithFallback(axios, `${baseUrl}/api/task/status/${params.taskId}`, {
|
||||
apiKey,
|
||||
timeout: 30000,
|
||||
label: '查询任务状态',
|
||||
modes: ['full', 'legacy'],
|
||||
});
|
||||
const data = unwrapCloudBeautyPayload(resp.data);
|
||||
|
||||
if (data?.download_url && data.download_url.startsWith('/')) {
|
||||
data.download_url = baseUrl + data.download_url;
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
export default {
|
||||
async refreshConfig() {
|
||||
return await ipcRenderer.invoke('runninghub:refreshConfig');
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件到RunningHub
|
||||
*/
|
||||
async uploadFile(params: { filePath: string; fileType: 'video' | 'audio'; mode?: string }) {
|
||||
return await ipcRenderer.invoke('runninghub:uploadFile', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建云端任务
|
||||
*/
|
||||
async createTask(params: { videoFileName: string; audioFileName: string; mode?: string }) {
|
||||
return await ipcRenderer.invoke('runninghub:createTask', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 查询任务状态
|
||||
*/
|
||||
async queryTask(params: { taskId: string; mode?: string }) {
|
||||
return await ipcRenderer.invoke('runninghub:queryTask', params);
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待任务完成(带轮询)
|
||||
*/
|
||||
async waitForTaskComplete(params: { taskId: string; maxRetries?: number; interval?: number; mode?: string }) {
|
||||
return await ipcRenderer.invoke('runninghub:waitForTaskComplete', params);
|
||||
},
|
||||
|
||||
async ttsSynthesize(params: {
|
||||
text: string;
|
||||
config: {
|
||||
apiKey: string;
|
||||
audioFileName: string;
|
||||
emotion?: string;
|
||||
speed?: number;
|
||||
outputPath?: string;
|
||||
version?: 'v2' | 'v3';
|
||||
};
|
||||
}) {
|
||||
return await ipcRenderer.invoke('runninghub:tts:synthesize', params);
|
||||
},
|
||||
|
||||
async cloudBeautySubmit(params: { videoPath: string; audioPath: string; humanWeight: string }) {
|
||||
return await ipcRenderer.invoke('cloudBeauty:submitTask', params);
|
||||
},
|
||||
|
||||
async cloudBeautyQueryStatus(params: { taskId: string }) {
|
||||
return await ipcRenderer.invoke('cloudBeauty:queryStatus', params);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user