Files
yaoyaoai/scripts/nodejs/runninghub_generate.js
fengchuanhn@gmail.com 7f57dfa9ed 11
2026-05-21 00:19:11 +08:00

457 lines
13 KiB
JavaScript
Raw 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.
"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 locateFfmpegBinary() {
const fromEnv = process.env.AICLIENT_FFMPEG_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
}
function locateFfprobeBinary() {
const fromEnv = process.env.AICLIENT_FFPROBE_PATH;
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
const ffmpeg = locateFfmpegBinary();
if (ffmpeg && ffmpeg !== "ffmpeg" && ffmpeg !== "ffmpeg.exe") {
const dir = path.dirname(ffmpeg);
const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe");
if (fs.existsSync(sibling)) return sibling;
}
return process.platform === "win32" ? "ffprobe.exe" : "ffprobe";
}
function parseDurationFromFfmpegStderr(text) {
const m = String(text || "").match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!m) return 0;
const h = parseFloat(m[1]);
const min = parseFloat(m[2]);
const s = parseFloat(m[3]);
if (!Number.isFinite(h + min + s)) return 0;
return h * 3600 + min * 60 + s;
}
function probeAudioDuration(audioPath) {
const ffprobe = locateFfprobeBinary();
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) {
const n = parseFloat(String(r.stdout || "").trim());
if (Number.isFinite(n) && n > 0) return n;
}
const ffmpeg = locateFfmpegBinary();
const r2 = spawnSync(ffmpeg, ["-i", audioPath], {
encoding: "utf8",
windowsHide: true,
});
const fromStderr = parseDurationFromFfmpegStderr(r2.stderr || "");
if (fromStderr > 0) return fromStderr;
return 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);
}
if (audioDuration <= 0) {
return {
success: false,
error:
"无法获取音频时长。云端口播需将时长传给 RunningHub请安装 ffmpeg/ffprobe或升级客户端后重试",
};
}
const durationSec = Math.ceil(audioDuration);
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,
durationSec,
);
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,
audioDuration: durationSec,
};
} catch (e) {
return { success: false, error: e.message || String(e) };
}
};