From 1325ff92e588d1f84681a2684f3e7e1593e68a00 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Mon, 18 May 2026 18:40:03 +0800 Subject: [PATCH] 1 --- .env.example | 2 + scripts/nodejs/digital_human_generate.js | 155 ++++++++ scripts/nodejs/digital_human_process.py | 62 +++ scripts/nodejs/infinitetalk_api_generate.js | 241 ++++++++++++ scripts/nodejs/runninghub_generate.js | 405 ++++++++++++++++++++ 5 files changed, 865 insertions(+) create mode 100644 scripts/nodejs/digital_human_generate.js create mode 100644 scripts/nodejs/digital_human_process.py create mode 100644 scripts/nodejs/infinitetalk_api_generate.js create mode 100644 scripts/nodejs/runninghub_generate.js diff --git a/.env.example b/.env.example index 0d10dab..32520ba 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,5 @@ CORS_ORIGINS=http://localhost:1420,tauri://localhost # 桌面端配置写入 MySQL 表 desktop_configs(name / value),非本文件环境变量。 # 登录后 GET /api/v1/appConfig 返回全部键值;Tauri 注入 Node 环境变量 AICLIENT_CFG_。 # 示例键名: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)。 diff --git a/scripts/nodejs/digital_human_generate.js b/scripts/nodejs/digital_human_generate.js new file mode 100644 index 0000000..fc5c683 --- /dev/null +++ b/scripts/nodejs/digital_human_generate.js @@ -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) }; + } +}; diff --git a/scripts/nodejs/digital_human_process.py b/scripts/nodejs/digital_human_process.py new file mode 100644 index 0000000..09a4d13 --- /dev/null +++ b/scripts/nodejs/digital_human_process.py @@ -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() diff --git a/scripts/nodejs/infinitetalk_api_generate.js b/scripts/nodejs/infinitetalk_api_generate.js new file mode 100644 index 0000000..a5bd87d --- /dev/null +++ b/scripts/nodejs/infinitetalk_api_generate.js @@ -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 生成超时" }; +}; diff --git a/scripts/nodejs/runninghub_generate.js b/scripts/nodejs/runninghub_generate.js new file mode 100644 index 0000000..b685ec7 --- /dev/null +++ b/scripts/nodejs/runninghub_generate.js @@ -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) }; + } +};