485 lines
14 KiB
TypeScript
485 lines
14 KiB
TypeScript
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} 秒`);
|
||
}
|
||
}
|