Files
2026-06-19 18:45:55 +08:00

703 lines
22 KiB
TypeScript

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 };
}
});
}