Initial clean project import
This commit is contained in:
@@ -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} 秒`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user