1
This commit is contained in:
@@ -25,3 +25,5 @@ CORS_ORIGINS=http://localhost:1420,tauri://localhost
|
|||||||
# 桌面端配置写入 MySQL 表 desktop_configs(name / value),非本文件环境变量。
|
# 桌面端配置写入 MySQL 表 desktop_configs(name / value),非本文件环境变量。
|
||||||
# 登录后 GET /api/v1/appConfig 返回全部键值;Tauri 注入 Node 环境变量 AICLIENT_CFG_<NAME>。
|
# 登录后 GET /api/v1/appConfig 返回全部键值;Tauri 注入 Node 环境变量 AICLIENT_CFG_<NAME>。
|
||||||
# 示例键名:LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIG(JSON)等。
|
# 示例键名:LLM_API_KEY、LLM_API_URL、LLM_MODEL_ID、ASR_MODE、DASHSCOPE_API_KEY、OSS_CONFIG(JSON)等。
|
||||||
|
# 口播视频:VIDEO_GEN_MODE=online|local|gradio;online 时需 RUNNINGHUB_API_KEY、RUNNINGHUB_WORKFLOW_ID;
|
||||||
|
# 可选 RUNNINGHUB_BASE_URL、RUNNINGHUB_INSTANCE_TYPE、RUNNINGHUB_NODES(JSON)。
|
||||||
|
|||||||
155
scripts/nodejs/digital_human_generate.js
Normal file
155
scripts/nodejs/digital_human_generate.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 口播视频生成:调用 Gradio 数字人 API(对齐 Electron compshare:digitalHumanProcess)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const os = require("os");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const desktopConfig = require("./desktop_config.js");
|
||||||
|
|
||||||
|
const TEMP_DIR = path.join(os.tmpdir(), "aiclient-digital-human");
|
||||||
|
|
||||||
|
function downloadFile(url, destPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = url.startsWith("https") ? require("https") : require("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(videoPath) {
|
||||||
|
const raw = String(videoPath || "").trim();
|
||||||
|
if (!raw) throw new Error("视频路径为空");
|
||||||
|
if (/^https?:\/\//i.test(raw)) {
|
||||||
|
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||||
|
const ext = raw.includes(".mp4") ? ".mp4" : ".mp4";
|
||||||
|
const dest = path.join(TEMP_DIR, `remote_${Date.now()}${ext}`);
|
||||||
|
await downloadFile(raw, dest);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
if (raw.startsWith("file://")) {
|
||||||
|
return raw.replace(/^file:\/\//, "");
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPythonDigitalHuman(apiUrl, audioFile, videoFile) {
|
||||||
|
const scriptPath = path.join(__dirname, "digital_human_process.py");
|
||||||
|
const py = process.env.AICLIENT_PYTHON || (process.platform === "win32" ? "python" : "python3");
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(py, [scriptPath, apiUrl, audioFile, videoFile], {
|
||||||
|
cwd: __dirname,
|
||||||
|
env: process.env,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout.on("data", (d) => {
|
||||||
|
stdout += d.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (d) => {
|
||||||
|
stderr += d.toString();
|
||||||
|
});
|
||||||
|
child.on("error", (err) => {
|
||||||
|
resolve({ success: false, error: err.message });
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
child.kill();
|
||||||
|
resolve({ success: false, error: "数字人处理超时(10 分钟)" });
|
||||||
|
}, 600000);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== 0 || !stdout.trim()) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: stderr.trim() || `数字人脚本退出码 ${code}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const lines = stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const jsonLine = [...lines]
|
||||||
|
.reverse()
|
||||||
|
.find((l) => l.startsWith("{") && l.endsWith("}"));
|
||||||
|
if (!jsonLine) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: `解析结果失败: ${stdout.slice(0, 200)}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(JSON.parse(jsonLine));
|
||||||
|
} catch (e) {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: `解析 JSON 失败: ${e.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__nodejsMain = async function (params) {
|
||||||
|
const p = params || {};
|
||||||
|
const apiUrl =
|
||||||
|
desktopConfig.get("DIGITAL_HUMAN_API_URL") ||
|
||||||
|
desktopConfig.get("VIDEO_GEN_API_URL") ||
|
||||||
|
desktopConfig.get("DIGITAL_HUMAN_GRADIO_URL");
|
||||||
|
if (!apiUrl) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"缺少 DIGITAL_HUMAN_API_URL(或 VIDEO_GEN_API_URL),请在配置管理中设置数字人 Gradio 服务地址",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioFile = String(p.audioPath || "").trim();
|
||||||
|
const videoFile = String(p.avatarVideoPath || "").trim();
|
||||||
|
if (!audioFile) {
|
||||||
|
return { success: false, error: "缺少音频文件,请先生成语音" };
|
||||||
|
}
|
||||||
|
if (!videoFile) {
|
||||||
|
return { success: false, error: "缺少形象视频,请先选择形象" };
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(audioFile)) {
|
||||||
|
return { success: false, error: `音频文件不存在: ${audioFile}` };
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(videoFile)) {
|
||||||
|
return { success: false, error: `形象视频不存在: ${videoFile}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runPythonDigitalHuman(apiUrl, audioFile, videoFile);
|
||||||
|
if (!result?.success || !result?.videoPath) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: String(result?.error || "口播视频生成失败"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localPath = await ensureLocalVideo(result.videoPath);
|
||||||
|
return { success: true, videoPath: localPath };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.message || String(e) };
|
||||||
|
}
|
||||||
|
};
|
||||||
62
scripts/nodejs/digital_human_process.py
Normal file
62
scripts/nodejs/digital_human_process.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
数字人处理脚本(对齐 zhenqianba digital_human_process.py)
|
||||||
|
调用 Gradio API:音频 + 形象视频 → 口播视频
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "参数不足: 需要 api_url, audio_file, video_file",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
api_url = sys.argv[1]
|
||||||
|
audio_file = sys.argv[2]
|
||||||
|
video_file = sys.argv[3]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gradio_client import Client, handle_file
|
||||||
|
|
||||||
|
client = Client(api_url)
|
||||||
|
result = client.predict(
|
||||||
|
audio_file=handle_file(audio_file),
|
||||||
|
video_file={"video": handle_file(video_file)},
|
||||||
|
api_name="/process_single",
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and result.get("video"):
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"videoPath": result["video"],
|
||||||
|
"subtitles": result.get("subtitles"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"success": False, "error": f"未获取到视频结果: {str(result)[:500]}"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"success": False, "error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
241
scripts/nodejs/infinitetalk_api_generate.js
Normal file
241
scripts/nodejs/infinitetalk_api_generate.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用本地 InfiniteTalk FastAPI(python-runtime/main.py)
|
||||||
|
* 配置:INFINITETALK_API_URL,默认 http://127.0.0.1:8765
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const os = require("os");
|
||||||
|
const http = require("http");
|
||||||
|
const https = require("https");
|
||||||
|
const desktopConfig = require("./desktop_config.js");
|
||||||
|
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const MAX_WAIT_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function apiBase() {
|
||||||
|
const raw =
|
||||||
|
desktopConfig.get("INFINITETALK_API_URL") ||
|
||||||
|
desktopConfig.get("DIGITAL_HUMAN_API_URL") ||
|
||||||
|
"http://127.0.0.1:8765";
|
||||||
|
return String(raw).trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestJson(method, urlPath, body, headers = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const base = apiBase();
|
||||||
|
const url = new URL(urlPath.startsWith("http") ? urlPath : `${base}${urlPath}`);
|
||||||
|
const lib = url.protocol === "https:" ? https : http;
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
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.detail?.message ||
|
||||||
|
data.message ||
|
||||||
|
data.detail ||
|
||||||
|
`HTTP ${res.statusCode}: ${text.slice(0, 300)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function postMultipart(urlPath, fields, files) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const boundary = `----Aiclient${Date.now()}`;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(fields)) {
|
||||||
|
parts.push(
|
||||||
|
Buffer.from(
|
||||||
|
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const { name, filePath, filename, contentType } of files) {
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
parts.push(
|
||||||
|
Buffer.from(
|
||||||
|
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
parts.push(data);
|
||||||
|
parts.push(Buffer.from("\r\n"));
|
||||||
|
}
|
||||||
|
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
||||||
|
const body = Buffer.concat(parts);
|
||||||
|
|
||||||
|
const base = apiBase();
|
||||||
|
const url = new URL(urlPath.startsWith("http") ? urlPath : `${base}${urlPath}`);
|
||||||
|
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: {
|
||||||
|
"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.detail?.message ||
|
||||||
|
data.message ||
|
||||||
|
`HTTP ${res.statusCode}: ${text.slice(0, 300)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(urlPath, destPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const base = apiBase();
|
||||||
|
const url = new URL(urlPath.startsWith("http") ? urlPath : `${base}${urlPath}`);
|
||||||
|
const lib = url.protocol === "https:" ? https : http;
|
||||||
|
const file = fs.createWriteStream(destPath);
|
||||||
|
lib
|
||||||
|
.get(url.href, (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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessMime(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === ".mp3") return "audio/mpeg";
|
||||||
|
if (ext === ".wav") return "audio/wav";
|
||||||
|
if (ext === ".mov") return "video/quicktime";
|
||||||
|
return "video/mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__nodejsMain = async function (params) {
|
||||||
|
const audioPath = String(params?.audioPath || "").trim();
|
||||||
|
const avatarVideoPath = String(params?.avatarVideoPath || "").trim();
|
||||||
|
if (!audioPath || !fs.existsSync(audioPath)) {
|
||||||
|
return { success: false, error: "缺少或找不到音频文件" };
|
||||||
|
}
|
||||||
|
if (!avatarVideoPath || !fs.existsSync(avatarVideoPath)) {
|
||||||
|
return { success: false, error: "缺少或找不到形象视频" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const health = await requestJson("GET", "/health");
|
||||||
|
if (!health.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `InfiniteTalk 服务未就绪: ${(health.issues || []).join("; ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `无法连接 InfiniteTalk API (${apiBase()}): ${e.message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = await postMultipart(
|
||||||
|
"/api/v1/talking-video",
|
||||||
|
{ prompt: String(params?.prompt || "") },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "video",
|
||||||
|
filePath: avatarVideoPath,
|
||||||
|
filename: path.basename(avatarVideoPath),
|
||||||
|
contentType: guessMime(avatarVideoPath),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "audio",
|
||||||
|
filePath: audioPath,
|
||||||
|
filename: path.basename(audioPath),
|
||||||
|
contentType: guessMime(audioPath),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jobId = create.job_id;
|
||||||
|
if (!jobId) {
|
||||||
|
return { success: false, error: "未返回 job_id" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < MAX_WAIT_MS) {
|
||||||
|
const st = await requestJson("GET", `/api/v1/talking-video/${jobId}`);
|
||||||
|
if (st.status === "succeeded") {
|
||||||
|
const outDir = path.join(os.tmpdir(), "aiclient-infinitetalk");
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
const dest = path.join(outDir, `talking_${jobId}.mp4`);
|
||||||
|
await downloadFile(`/api/v1/talking-video/${jobId}/download`, dest);
|
||||||
|
return { success: true, videoPath: dest };
|
||||||
|
}
|
||||||
|
if (st.status === "failed") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: String(st.message || "InfiniteTalk 任务失败"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await sleep(POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: "InfiniteTalk 生成超时" };
|
||||||
|
};
|
||||||
405
scripts/nodejs/runninghub_generate.js
Normal file
405
scripts/nodejs/runninghub_generate.js
Normal 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) };
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user