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
+484
View File
@@ -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}`);
}
}
+560
View File
@@ -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: '害怕担忧' }
];
}
+702
View File
@@ -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 };
}
});
}
+57
View File
@@ -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);
}
};