242 lines
7.1 KiB
JavaScript
242 lines
7.1 KiB
JavaScript
"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 生成超时" };
|
||
};
|