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 { 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; private axios: AxiosInstance; constructor(config?: Partial) { // 默认配置(云端模式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 { 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 { 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 { 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 { 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} 秒`); } }