Files
yaoyaoai/scripts/nodejs/pipeline_native.js
fengchuanhn@gmail.com 3db327d93b 22
2026-05-17 12:54:13 +08:00

322 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* Node 流水线宿主能力(对齐 QuickJS __native / Rust js_runtime/native.rs
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { spawn } = require("child_process");
const https = require("https");
const http = require("http");
const TEMP_DIR = path.join(os.tmpdir(), "aiclient-node-pipeline");
fs.mkdirSync(TEMP_DIR, { recursive: true });
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function tempPath(prefix, ext) {
return path.join(TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext || "tmp"}`);
}
function deleteFile(p) {
try {
if (p && fs.existsSync(p)) fs.unlinkSync(p);
} catch (_) {
/* ignore */
}
}
function runCommand(cmd, args, timeoutMs = 300000) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => {
stderr += d.toString();
});
const timer = setTimeout(() => {
child.kill();
reject(new Error(`命令超时: ${cmd}`));
}, timeoutMs);
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`${cmd} 退出码 ${code}: ${stderr.slice(0, 500)}`));
});
});
}
function locateFfmpeg() {
if (process.env.AICLIENT_FFMPEG_PATH && fs.existsSync(process.env.AICLIENT_FFMPEG_PATH)) {
return process.env.AICLIENT_FFMPEG_PATH;
}
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
}
async function ffmpegExtractAudio(videoPath) {
const dest = tempPath("audio", "wav");
const ffmpeg = locateFfmpeg();
await runCommand(ffmpeg, [
"-y",
"-i",
videoPath,
"-vn",
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
dest,
]);
return dest;
}
function rfc1123Date() {
return new Date().toUTCString();
}
function signOss(secret, str) {
return crypto.createHmac("sha1", secret).update(str).digest("base64");
}
async function aliyunOssUpload(localPath, cfg) {
const ak = cfg.accessKeyId;
const sk = cfg.accessKeySecret;
const bucket = cfg.bucket;
const region = cfg.region;
if (!ak || !sk || !bucket || !region) {
return { success: false, error: "OSS 配置不完整" };
}
const objectKey = `audio/${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
const host = `${bucket}.${region}.aliyuncs.com`;
const endpoint = (cfg.endpoint || `https://${host}`).replace(/\/$/, "");
const url = `${endpoint}/${objectKey}`;
const body = fs.readFileSync(localPath);
const date = rfc1123Date();
const contentType = "audio/wav";
const canonicalizedResource = `/${bucket}/${objectKey}`;
const strToSign = `PUT\n\n${contentType}\n${date}\n${canonicalizedResource}`;
const signature = signOss(sk, strToSign);
const authorization = `OSS ${ak}:${signature}`;
return new Promise((resolve) => {
const u = new URL(url);
const opts = {
hostname: u.hostname,
port: u.port || 443,
path: u.pathname,
method: "PUT",
headers: {
Date: date,
"Content-Type": contentType,
Authorization: authorization,
"Content-Length": body.length,
},
};
const req = https.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ success: true, url });
} else {
resolve({
success: false,
error: `OSS HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString().slice(0, 200)}`,
});
}
});
});
req.on("error", (e) => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { _raw: text };
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
return data;
}
async function aliyunAsrFiletrans(audioUrl, opts) {
const apiKey = opts.apiKey || opts.api_key;
const model = opts.model || "qwen3-asr-flash-filetrans";
if (!apiKey) {
return { success: false, error: "缺少 DashScope apiKey" };
}
const submit = await fetchJson(
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
body: JSON.stringify({
model,
input: { file_urls: [audioUrl] },
parameters: {},
}),
}
);
const taskId = submit?.output?.task_id;
if (!taskId) {
return { success: false, error: `ASR 提交失败: ${JSON.stringify(submit).slice(0, 200)}` };
}
for (let i = 0; i < 60; i++) {
await sleep(5000);
const poll = await fetchJson(
`https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
const status = poll?.output?.task_status;
if (status === "SUCCEEDED") {
const transUrl = poll?.output?.results?.[0]?.transcription_url;
if (!transUrl) {
return { success: false, error: "ASR 成功但无 transcription_url" };
}
const trans = await fetchJson(transUrl);
let fullText = "";
if (trans.transcripts && trans.transcripts.length) {
fullText = trans.transcripts.map((t) => t.text || "").filter(Boolean).join("\n");
} else if (trans.text) {
fullText = trans.text;
}
return { success: true, text: fullText };
}
if (status === "FAILED" || status === "UNKNOWN") {
return {
success: false,
error: poll?.output?.message || "ASR 任务失败",
};
}
}
return { success: false, error: "ASR 轮询超时" };
}
async function localAsr(audioPath, info) {
const baseUrl = (info.baseUrl || info.localPath || "").replace(/\/$/, "");
if (!baseUrl) {
return { success: false, error: "未配置本地 ASR 服务地址" };
}
const asrPath = info.asrPath || "/asr";
const url = `${baseUrl}${asrPath.startsWith("/") ? asrPath : `/${asrPath}`}`;
const boundary = `----aiclient${Date.now()}`;
const fileBuf = fs.readFileSync(audioPath);
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`,
"utf8"
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
const body = Buffer.concat([head, fileBuf, tail]);
const u = new URL(url);
const client = u.protocol === "https:" ? https : http;
return new Promise((resolve) => {
const req = client.request(
{
hostname: u.hostname,
port: u.port || (u.protocol === "https:" ? 443 : 80),
path: u.pathname + u.search,
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": body.length,
},
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
const data = JSON.parse(raw);
if (data.code === 0 || data.success) {
const records =
data.data?.data?.records ||
data.data?.records ||
data.records ||
[];
const text = Array.isArray(records)
? records.map((r) => r.text || "").join("")
: data.text || data.data?.text || "";
resolve({ success: true, text: String(text) });
} else {
resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) });
}
} catch (e) {
resolve({ success: false, error: e.message });
}
});
}
);
req.on("error", (e) => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
async function openaiChat(req) {
let apiUrl = (req.apiUrl || "").replace(/\/$/, "");
if (!apiUrl.includes("/chat/completions")) {
apiUrl = apiUrl.endsWith("/v1")
? `${apiUrl}/chat/completions`
: `${apiUrl}/v1/chat/completions`;
}
const body = {
model: req.model,
messages: req.messages,
stream: false,
temperature: typeof req.temperature === "number" ? req.temperature : 0.8,
max_tokens: req.max_tokens || req.maxTokens || 2000,
};
try {
const data = await fetchJson(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${req.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const content = data?.choices?.[0]?.message?.content;
if (!content) {
return { success: false, error: "模型返回为空" };
}
return { success: true, content: String(content).trim() };
} catch (e) {
return { success: false, error: e.message };
}
}
module.exports = {
tempPath,
deleteFile,
ffmpegExtractAudio,
aliyunOssUpload,
aliyunAsrFiletrans,
localAsr,
openaiChat,
};