"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) }; } };