"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 生成超时" }; };