This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 18:40:03 +08:00
parent 09f37e3206
commit 1325ff92e5
5 changed files with 865 additions and 0 deletions

View File

@@ -0,0 +1,405 @@
"use strict";
/**
* 口播视频云端RunningHub OpenAPI v2对齐 Electron VideoGen.runCloudMode
* 配置desktop_configs → AICLIENT_CFG_*
* VIDEO_GEN_MODE=online
* RUNNINGHUB_API_KEY
* RUNNINGHUB_WORKFLOW_ID
* RUNNINGHUB_BASE_URL可选默认 https://www.runninghub.cn
* RUNNINGHUB_INSTANCE_TYPE可选default | plus
* RUNNINGHUB_NODES可选 JSON节点 field 映射)
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const http = require("http");
const https = require("https");
const { spawnSync } = require("child_process");
const desktopConfig = require("./desktop_config.js");
const DEFAULT_BASE = "https://www.runninghub.cn";
const POLL_MS = 3000;
const MAX_POLLS = 600;
const DEFAULT_NODES = {
videoNode: "88",
audioNode: "15",
durationNode: "60",
fpsNode: "63",
widthNode: "67",
heightNode: "69",
};
function cfg(key, fallback = "") {
return desktopConfig.get(key, fallback);
}
function parseNodes() {
const raw = cfg("RUNNINGHUB_NODES", "");
if (!raw) return { ...DEFAULT_NODES };
try {
const parsed = JSON.parse(raw);
return { ...DEFAULT_NODES, ...parsed };
} catch {
return { ...DEFAULT_NODES };
}
}
function apiConfig() {
const apiKey = cfg("RUNNINGHUB_API_KEY");
const workflowId =
cfg("RUNNINGHUB_WORKFLOW_ID") || cfg("RUNNINGHUB_WORKFLOW_ID_1") || "";
const baseUrl = String(cfg("RUNNINGHUB_BASE_URL", DEFAULT_BASE))
.trim()
.replace(/\/+$/, "");
const instanceType = cfg("RUNNINGHUB_INSTANCE_TYPE", "default") || "default";
return { apiKey, workflowId, baseUrl, instanceType, nodes: parseNodes() };
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function requestBuffer(method, urlStr, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const lib = url.protocol === "https:" ? https : http;
const payload = body ? Buffer.from(body) : null;
const opts = {
method,
hostname: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
path: url.pathname + url.search,
headers: {
Host: url.hostname,
...headers,
},
};
if (payload) {
opts.headers["Content-Length"] = payload.length;
}
const req = lib.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const buf = Buffer.concat(chunks);
let data;
const text = buf.toString("utf8");
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
resolve({ status: res.statusCode || 0, data, text });
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
function postMultipart(urlStr, filePath, apiKey) {
return new Promise((resolve, reject) => {
const boundary = `----RunningHub${Date.now()}`;
const fileName = path.basename(filePath);
const fileData = fs.readFileSync(filePath);
const head = Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, fileData, tail]);
const url = new URL(urlStr);
const lib = url.protocol === "https:" ? https : http;
const opts = {
method: "POST",
hostname: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
path: url.pathname + url.search,
headers: {
Host: url.hostname,
Authorization: `Bearer ${apiKey}`,
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": body.length,
},
};
const req = lib.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const text = Buffer.concat(chunks).toString("utf8");
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (res.statusCode >= 400) {
reject(
new Error(
data.message || data.msg || data.errorMessage || `HTTP ${res.statusCode}`,
),
);
return;
}
resolve(data);
});
});
req.on("error", reject);
req.write(body);
req.end();
});
}
async function uploadFile(baseUrl, apiKey, filePath) {
const url = `${baseUrl}/openapi/v2/media/upload/binary`;
const data = await postMultipart(url, filePath, apiKey);
if (data.code !== 0 && data.code !== undefined) {
throw new Error(data.message || data.msg || "上传失败");
}
const fileName = data.data?.fileName || data.fileName;
if (!fileName) {
throw new Error(`上传未返回 fileName: ${JSON.stringify(data).slice(0, 300)}`);
}
return fileName;
}
function probeAudioDuration(audioPath) {
const ffprobe = process.env.AICLIENT_FFPROBE_PATH || "ffprobe";
const r = spawnSync(
ffprobe,
[
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
audioPath,
],
{ encoding: "utf8", windowsHide: true },
);
if (r.status !== 0) return 0;
const n = parseFloat(String(r.stdout || "").trim());
return Number.isFinite(n) && n > 0 ? n : 0;
}
async function createTask(config, videoFileName, audioFileName, audioDuration) {
const { apiKey, workflowId, baseUrl, instanceType, nodes } = config;
const nodeInfoList = [
{
nodeId: nodes.videoNode,
fieldName: "video",
fieldValue: videoFileName,
description: "请导入视频",
},
{
nodeId: nodes.audioNode,
fieldName: "audio",
fieldValue: audioFileName,
description: "请导入新的音频",
},
];
if (audioDuration > 0 && nodes.durationNode) {
nodeInfoList.push({
nodeId: nodes.durationNode,
fieldName: "value",
fieldValue: String(Math.ceil(audioDuration)),
description: "视频时长设置(秒)",
});
}
if (nodes.fpsNode) {
nodeInfoList.push({
nodeId: nodes.fpsNode,
fieldName: "int",
fieldValue: "30",
description: "视频帧率",
});
}
if (nodes.widthNode) {
nodeInfoList.push({
nodeId: nodes.widthNode,
fieldName: "int",
fieldValue: "576",
description: "视频宽度",
});
}
if (nodes.heightNode) {
nodeInfoList.push({
nodeId: nodes.heightNode,
fieldName: "int",
fieldValue: "1024",
description: "视频高度",
});
}
const payload = JSON.stringify({
randomSeed: true,
nodeInfoList,
instanceType,
retainSeconds: 0,
usePersonalQueue: false,
});
const url = `${baseUrl}/openapi/v2/run/ai-app/${workflowId}`;
const { status, data } = await requestBuffer("POST", url, payload, {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
});
if (status >= 400) {
throw new Error(
data.errorMessage || data.message || data.msg || `创建任务失败 HTTP ${status}`,
);
}
const taskId = data.taskId;
if (!taskId) {
throw new Error(data.errorMessage || "创建任务失败:未返回 taskId");
}
return taskId;
}
async function queryTask(config, taskId) {
const url = `${config.baseUrl}/openapi/v2/query`;
const { data } = await requestBuffer(
"POST",
url,
JSON.stringify({ taskId }),
{
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
);
return {
status: data.status,
errorMessage: data.errorMessage,
results: data.results,
};
}
async function waitForTask(config, taskId) {
for (let i = 0; i < MAX_POLLS; i++) {
const result = await queryTask(config, taskId);
if (result.status === "SUCCESS") {
return result;
}
if (result.status === "FAILED") {
throw new Error(result.errorMessage || "RunningHub 任务失败");
}
await sleep(POLL_MS);
}
throw new Error(`RunningHub 任务超时(已等待 ${(MAX_POLLS * POLL_MS) / 60000} 分钟)`);
}
function pickResultUrl(results) {
if (!Array.isArray(results) || !results.length) return "";
const first = results[0];
if (typeof first === "string") return first;
return first.url || first.fileUrl || first.outputUrl || "";
}
function downloadFile(url, destPath) {
return new Promise((resolve, reject) => {
const client = url.startsWith("https") ? https : http;
const file = fs.createWriteStream(destPath);
client
.get(url, (res) => {
if (
res.statusCode &&
res.statusCode >= 300 &&
res.statusCode < 400 &&
res.headers.location
) {
file.close();
return downloadFile(res.headers.location, destPath).then(resolve).catch(reject);
}
if (res.statusCode !== 200) {
reject(new Error(`下载视频失败 HTTP ${res.statusCode}`));
return;
}
res.pipe(file);
file.on("finish", () => file.close(() => resolve(destPath)));
})
.on("error", reject);
});
}
async function ensureLocalVideo(videoUrl) {
const raw = String(videoUrl || "").trim();
if (!raw) throw new Error("未获取到视频地址");
if (!/^https?:\/\//i.test(raw)) {
if (raw.startsWith("file://")) return raw.replace(/^file:\/\//, "");
return raw;
}
const dir = path.join(os.tmpdir(), "aiclient-runninghub");
fs.mkdirSync(dir, { recursive: true });
const dest = path.join(dir, `output_${Date.now()}.mp4`);
await downloadFile(raw, dest);
return dest;
}
globalThis.__nodejsMain = async function (params) {
const p = params || {};
const mode = String(cfg("VIDEO_GEN_MODE", "")).trim().toLowerCase();
if (mode && mode !== "online") {
return {
success: false,
error: `runninghub_generate.js 仅用于 VIDEO_GEN_MODE=online当前为 ${mode}`,
};
}
const config = apiConfig();
if (!config.apiKey) {
return {
success: false,
error: "缺少 RUNNINGHUB_API_KEY请在配置管理中设置 RunningHub API 密钥",
};
}
if (!config.workflowId) {
return {
success: false,
error: "缺少 RUNNINGHUB_WORKFLOW_ID请在配置管理中设置工作流 ID",
};
}
const audioPath = String(p.audioPath || "").trim();
const videoPath = String(p.avatarVideoPath || "").trim();
if (!audioPath || !fs.existsSync(audioPath)) {
return { success: false, error: "缺少或找不到音频文件" };
}
if (!videoPath || !fs.existsSync(videoPath)) {
return { success: false, error: "缺少或找不到形象视频" };
}
let audioDuration = Number(p.audioDuration);
if (!Number.isFinite(audioDuration) || audioDuration <= 0) {
audioDuration = probeAudioDuration(audioPath);
}
try {
const videoFileName = await uploadFile(config.baseUrl, config.apiKey, videoPath);
const audioFileName = await uploadFile(config.baseUrl, config.apiKey, audioPath);
const taskId = await createTask(
config,
videoFileName,
audioFileName,
audioDuration,
);
const done = await waitForTask(config, taskId);
const remoteUrl = pickResultUrl(done.results);
if (!remoteUrl) {
return {
success: false,
error: `任务完成但未返回视频 URL: ${JSON.stringify(done).slice(0, 400)}`,
};
}
const localPath = await ensureLocalVideo(remoteUrl);
return { success: true, videoPath: localPath, taskId };
} catch (e) {
return { success: false, error: e.message || String(e) };
}
};