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

485 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
}
}