1
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
src-tauri/binaries/
|
||||||
|
|||||||
779
src-tauri/Cargo.lock
generated
779
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,12 +5,7 @@ description = "A Tauri App"
|
|||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
# The `_lib` suffix may seem redundant but it is necessary
|
|
||||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
|
||||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
|
||||||
name = "tauri_app_lib"
|
name = "tauri_app_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
@@ -23,6 +18,57 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
"stream",
|
||||||
|
"gzip",
|
||||||
|
"brotli",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Embedded JS engine
|
||||||
|
rquickjs = { version = "0.9", features = [
|
||||||
|
"macro",
|
||||||
|
"futures",
|
||||||
|
"loader",
|
||||||
|
"array-buffer",
|
||||||
|
"chrono",
|
||||||
|
"parallel",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Text / parsing
|
||||||
|
regex = "1.10"
|
||||||
|
once_cell = "1.19"
|
||||||
|
|
||||||
|
# Crypto for Aliyun OSS HMAC-SHA1 signing
|
||||||
|
hmac = "0.12"
|
||||||
|
sha1 = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
urlencoding = "2.1"
|
||||||
|
|
||||||
|
# Time
|
||||||
|
chrono = { version = "0.4", default-features = false, features = [
|
||||||
|
"clock",
|
||||||
|
"std",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# Logging + errors
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
mime_guess = "2"
|
||||||
|
dirs = "5"
|
||||||
|
rand = "0.8"
|
||||||
|
tempfile = "3"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-unlisten",
|
||||||
"opener:default"
|
"opener:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
381
src-tauri/js/douyin_pipeline.js
Normal file
381
src-tauri/js/douyin_pipeline.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// douyin_pipeline.js
|
||||||
|
//
|
||||||
|
// Pure-JS port of the original `VideoRewriteIntegration.processDouyinShareComplete`
|
||||||
|
// pipeline. All host capabilities (network, fs, ffmpeg, OSS, ASR, AI chat,
|
||||||
|
// log, progress) come from the `__native` global injected by the Rust side
|
||||||
|
// (see `js_runtime/native.rs`). Nothing in this file should ever depend on
|
||||||
|
// Node.js, Electron or browser globals.
|
||||||
|
//
|
||||||
|
// Public entry point exposed to Rust:
|
||||||
|
// processDouyinShare(params) -> Promise<Result>
|
||||||
|
//
|
||||||
|
// `params` shape mirrors the original Electron IPC payload:
|
||||||
|
// {
|
||||||
|
// shareText: string,
|
||||||
|
// modelConfig: { providerId, modelId, apiUrl, apiKey,
|
||||||
|
// asrMode: "online" | "local",
|
||||||
|
// aliyunApiKey, ossConfig, asrServerInfo },
|
||||||
|
// rewriteConfig: object,
|
||||||
|
// customPrompt: string | null
|
||||||
|
// }
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const log = (level, msg, fields) => __native.log(level, msg, fields || null);
|
||||||
|
const progress = (s) => { try { __native.emitProgress(s); } catch (_) { /* ignore */ } };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DouyinContentParser (pure-JS, no host calls)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DouyinContentParser = {
|
||||||
|
// Extract a sharable URL from a raw "share text". Handles:
|
||||||
|
// - direct https://... links
|
||||||
|
// - "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxx/"
|
||||||
|
// - bare v.douyin.com short links
|
||||||
|
parseContent(shareText) {
|
||||||
|
if (!shareText || typeof shareText !== "string") {
|
||||||
|
return { success: false, error: "分享文本为空" };
|
||||||
|
}
|
||||||
|
const urlRegex = /https?:\/\/[^\s\u4e00-\u9fa5]+/g;
|
||||||
|
const matches = shareText.match(urlRegex);
|
||||||
|
if (!matches || matches.length === 0) {
|
||||||
|
return { success: false, error: "未在文本中识别到任何 URL" };
|
||||||
|
}
|
||||||
|
// Prefer douyin/iesdouyin domains, fallback to first match.
|
||||||
|
const douyinUrl =
|
||||||
|
matches.find((u) => /douyin\.com|iesdouyin\.com/i.test(u)) || matches[0];
|
||||||
|
return { success: true, content: { videoUrl: douyinUrl, raw: shareText } };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Whether `url` already points at an actual media file we can fetch directly.
|
||||||
|
isDirectVideoUrl(url) {
|
||||||
|
if (!url) return false;
|
||||||
|
return /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i.test(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Derive a short title (<= 20 chars) from a long description.
|
||||||
|
generateTitleFromDescription(desc) {
|
||||||
|
if (!desc) return "改写视频";
|
||||||
|
const firstLine = String(desc).split(/[\r\n]/)[0].trim();
|
||||||
|
const cleaned = firstLine.replace(/[#@].*$/g, "").trim();
|
||||||
|
return (cleaned || "改写视频").slice(0, 20);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DouyinVideoDownloader
|
||||||
|
// Resolves the final media URL (following 302 redirects on the share
|
||||||
|
// domain) and saves the binary to a host-managed temp file.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class DouyinVideoDownloader {
|
||||||
|
async downloadDirectVideo(url) {
|
||||||
|
log("info", "downloader.directVideo", { url });
|
||||||
|
const dest = __native.tempPath("video", "mp4");
|
||||||
|
const res = await __native.httpRequest({
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: { "User-Agent": this._ua() },
|
||||||
|
response_type: "file",
|
||||||
|
dest_path: dest,
|
||||||
|
timeout_ms: 120000,
|
||||||
|
});
|
||||||
|
if (!res || res.status >= 400) {
|
||||||
|
return { success: false, error: `直链下载失败 status=${res && res.status}` };
|
||||||
|
}
|
||||||
|
return { success: true, videoPath: dest };
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadDouyinVideo(shareUrl) {
|
||||||
|
log("info", "downloader.douyin", { shareUrl });
|
||||||
|
// Step 1: follow redirect on v.douyin.com -> iesdouyin.com to get itemId
|
||||||
|
let landing;
|
||||||
|
try {
|
||||||
|
landing = await __native.httpRequest({
|
||||||
|
method: "GET",
|
||||||
|
url: shareUrl,
|
||||||
|
headers: { "User-Agent": this._ua() },
|
||||||
|
response_type: "text",
|
||||||
|
follow_redirects: true,
|
||||||
|
timeout_ms: 30000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `解析跳转失败: ${e.message || e}` };
|
||||||
|
}
|
||||||
|
const finalUrl = (landing && landing.final_url) || shareUrl;
|
||||||
|
const itemId =
|
||||||
|
this._extract(/\/video\/(\d+)/, finalUrl) ||
|
||||||
|
this._extract(/item_ids?=(\d+)/, finalUrl) ||
|
||||||
|
this._extract(/modal_id=(\d+)/, finalUrl);
|
||||||
|
if (!itemId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "未能从分享链接中提取视频 itemId,可能是抖音改版",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Step 2: ask iesdouyin for the playable URL.
|
||||||
|
const apiUrl =
|
||||||
|
"https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=" + itemId;
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await __native.httpRequest({
|
||||||
|
method: "GET",
|
||||||
|
url: apiUrl,
|
||||||
|
headers: { "User-Agent": this._ua() },
|
||||||
|
response_type: "json",
|
||||||
|
timeout_ms: 30000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `获取视频信息失败: ${e.message || e}` };
|
||||||
|
}
|
||||||
|
const item =
|
||||||
|
info && info.body && info.body.item_list && info.body.item_list[0];
|
||||||
|
const playAddr =
|
||||||
|
item && item.video && item.video.play_addr && item.video.play_addr.url_list;
|
||||||
|
if (!playAddr || !playAddr.length) {
|
||||||
|
return { success: false, error: "iesdouyin 接口未返回播放地址" };
|
||||||
|
}
|
||||||
|
// Force HTTPS + replace `playwm` (watermarked) with `play` if present.
|
||||||
|
const mediaUrl = playAddr[0]
|
||||||
|
.replace(/^http:\/\//, "https://")
|
||||||
|
.replace("/playwm/", "/play/");
|
||||||
|
return this.downloadDirectVideo(mediaUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ua() {
|
||||||
|
return (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_extract(re, s) {
|
||||||
|
const m = String(s || "").match(re);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI rewrite (OpenAI-compatible chat completions)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const VideoRewriteOptimizer = {
|
||||||
|
buildRewritePrompt(content, rewriteConfig, customPrompt) {
|
||||||
|
if (customPrompt && String(customPrompt).trim().length > 0) {
|
||||||
|
return String(customPrompt).replace(/\{\{\s*content\s*\}\}/g, content);
|
||||||
|
}
|
||||||
|
const cfg = rewriteConfig || {};
|
||||||
|
const style = cfg.style || "短视频口播风格";
|
||||||
|
const tone = cfg.tone || "自然、口语化";
|
||||||
|
const lengthHint = cfg.length ? `控制在 ${cfg.length} 字以内` : "保持与原文相近的长度";
|
||||||
|
return [
|
||||||
|
"你是一名专业的短视频文案改写助手。",
|
||||||
|
`请基于下面这段视频转写文案,用【${style}】重新仿写一版。`,
|
||||||
|
`要求:语气 ${tone},${lengthHint},保留原意但避免与原文雷同;`,
|
||||||
|
"不要解释、不要加任何额外标题,直接输出仿写后的正文。",
|
||||||
|
"",
|
||||||
|
"【原文案】",
|
||||||
|
content,
|
||||||
|
].join("\n");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) {
|
||||||
|
if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) {
|
||||||
|
log("error", "rewrite.missingConfig");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const prompt = VideoRewriteOptimizer.buildRewritePrompt(
|
||||||
|
content,
|
||||||
|
rewriteConfig || {},
|
||||||
|
customPrompt
|
||||||
|
);
|
||||||
|
const result = await __native.openaiChat({
|
||||||
|
apiUrl: modelConfig.apiUrl,
|
||||||
|
apiKey: modelConfig.apiKey,
|
||||||
|
model: modelConfig.modelId,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "你是专业的短视频文案改写助手。" },
|
||||||
|
{ role: "user", content: prompt },
|
||||||
|
],
|
||||||
|
temperature: typeof modelConfig.temperature === "number" ? modelConfig.temperature : 0.8,
|
||||||
|
max_tokens: modelConfig.maxTokens || 1024,
|
||||||
|
});
|
||||||
|
if (!result || !result.success) {
|
||||||
|
log("error", "rewrite.failed", { error: result && result.error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (result.content || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VideoRewriteIntegration.processDouyinShareComplete
|
||||||
|
// The 1:1 port of the Electron version's pipeline.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function processDouyinShareComplete(shareText, modelConfig, rewriteConfig, customPrompt) {
|
||||||
|
let videoPath = null;
|
||||||
|
let audioPath = null;
|
||||||
|
try {
|
||||||
|
log("info", "pipeline.start", {
|
||||||
|
inputLength: shareText && shareText.length,
|
||||||
|
modelConfig: {
|
||||||
|
providerId: modelConfig && modelConfig.providerId,
|
||||||
|
modelId: modelConfig && modelConfig.modelId,
|
||||||
|
asrMode: modelConfig && modelConfig.asrMode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1) parse share text -> video URL
|
||||||
|
progress("正在解析视频URL...");
|
||||||
|
const parseResult = DouyinContentParser.parseContent(shareText);
|
||||||
|
if (!parseResult.success || !parseResult.content) {
|
||||||
|
return { success: false, error: parseResult.error || "内容解析失败" };
|
||||||
|
}
|
||||||
|
const { videoUrl } = parseResult.content;
|
||||||
|
log("info", "pipeline.urlExtracted", { videoUrl });
|
||||||
|
|
||||||
|
// 2) download video
|
||||||
|
progress("正在下载视频...");
|
||||||
|
const downloader = new DouyinVideoDownloader();
|
||||||
|
const downloadResult = DouyinContentParser.isDirectVideoUrl(videoUrl)
|
||||||
|
? await downloader.downloadDirectVideo(videoUrl)
|
||||||
|
: await downloader.downloadDouyinVideo(videoUrl);
|
||||||
|
if (!downloadResult.success || !downloadResult.videoPath) {
|
||||||
|
return { success: false, error: downloadResult.error || "视频下载失败" };
|
||||||
|
}
|
||||||
|
videoPath = downloadResult.videoPath;
|
||||||
|
log("info", "pipeline.videoDownloaded", { videoPath });
|
||||||
|
|
||||||
|
// 3) extract audio (ffmpeg sidecar)
|
||||||
|
progress("视频下载完成,正在提取音频...");
|
||||||
|
try {
|
||||||
|
audioPath = await __native.ffmpegExtractAudio(videoPath);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `音频提取失败: ${(e && e.message) || e}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
log("info", "pipeline.audioExtracted", { audioPath });
|
||||||
|
|
||||||
|
// 4) ASR
|
||||||
|
const asrMode = (modelConfig && modelConfig.asrMode) || "online";
|
||||||
|
let recognizedText = "";
|
||||||
|
if (asrMode === "online") {
|
||||||
|
progress("音频提取完成,正在上传到OSS...");
|
||||||
|
const ossConfig = modelConfig.ossConfig;
|
||||||
|
if (!ossConfig || !ossConfig.bucket) {
|
||||||
|
return { success: false, error: "未配置阿里云 OSS(bucket / region / key)" };
|
||||||
|
}
|
||||||
|
const upload = await __native.aliyunOssUpload(audioPath, ossConfig);
|
||||||
|
if (!upload || !upload.success || !upload.url) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `音频上传失败: ${(upload && upload.error) || "unknown"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 100) + "..." });
|
||||||
|
|
||||||
|
progress("音频上传完成,正在识别文案(在线模式)...");
|
||||||
|
const asr = await __native.aliyunAsrFiletrans(upload.url, {
|
||||||
|
apiKey: modelConfig.aliyunApiKey,
|
||||||
|
model: "qwen3-asr-flash-filetrans",
|
||||||
|
});
|
||||||
|
if (!asr || !asr.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `语音识别失败(在线模式): ${(asr && asr.error) || "unknown"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
recognizedText = asr.text || "";
|
||||||
|
log("info", "pipeline.asrOk", {
|
||||||
|
method: "aliyun-online",
|
||||||
|
len: recognizedText.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
progress("音频提取完成,正在识别文案(本地模式)...");
|
||||||
|
const info = modelConfig.asrServerInfo;
|
||||||
|
if (!info) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "未配置本地语音识别服务器,请在「设置」中配置",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const asr = await __native.localAsr(audioPath, info);
|
||||||
|
if (!asr || !asr.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `语音识别失败(本地模式): ${(asr && asr.error) || "unknown"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
recognizedText = asr.text || "";
|
||||||
|
log("info", "pipeline.asrOk", {
|
||||||
|
method: "local",
|
||||||
|
server: info.name,
|
||||||
|
len: recognizedText.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) rewrite
|
||||||
|
if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) {
|
||||||
|
return { success: false, error: "未配置AI模型,请先在设置中配置" };
|
||||||
|
}
|
||||||
|
progress("文案识别完成,正在改写...");
|
||||||
|
const rewrittenDescription = await rewriteWithAI(
|
||||||
|
recognizedText,
|
||||||
|
modelConfig,
|
||||||
|
rewriteConfig,
|
||||||
|
customPrompt
|
||||||
|
);
|
||||||
|
if (!rewrittenDescription) {
|
||||||
|
return { success: false, error: "AI仿写失败,请检查模型配置和网络连接" };
|
||||||
|
}
|
||||||
|
const rewrittenTitle = DouyinContentParser.generateTitleFromDescription(rewrittenDescription);
|
||||||
|
|
||||||
|
log("info", "pipeline.success", {
|
||||||
|
originalLength: recognizedText.length,
|
||||||
|
rewrittenLength: rewrittenDescription.length,
|
||||||
|
});
|
||||||
|
progress("完成!");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
original: {
|
||||||
|
videoUrl,
|
||||||
|
description: recognizedText,
|
||||||
|
hashtags: [],
|
||||||
|
title: "视频音频识别结果",
|
||||||
|
},
|
||||||
|
rewritten: {
|
||||||
|
description: rewrittenDescription,
|
||||||
|
title: rewrittenTitle,
|
||||||
|
fullContent: rewrittenDescription,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
log("error", "pipeline.error", { message: (e && e.message) || String(e) });
|
||||||
|
return { success: false, error: (e && e.message) || String(e) };
|
||||||
|
} finally {
|
||||||
|
// 6) cleanup local temp files
|
||||||
|
if (videoPath) {
|
||||||
|
try { __native.deleteFile(videoPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
if (audioPath) {
|
||||||
|
try { __native.deleteFile(audioPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap: register into the global script table.
|
||||||
|
// The Rust runtime looks the entry up by `scriptName` at invocation time
|
||||||
|
// (see commands/quickjs.rs + js_runtime/mod.rs).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
globalThis.__scripts = globalThis.__scripts || {};
|
||||||
|
globalThis.__scripts["process_douyin_share"] = function (params) {
|
||||||
|
const p = params || {};
|
||||||
|
return processDouyinShareComplete(
|
||||||
|
p.shareText,
|
||||||
|
p.modelConfig,
|
||||||
|
p.rewriteConfig,
|
||||||
|
p.customPrompt || null
|
||||||
|
);
|
||||||
|
};
|
||||||
165
src-tauri/src/asr.rs
Normal file
165
src-tauri/src/asr.rs
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
//! Aliyun DashScope file-trans ASR client (qwen3-asr-flash-filetrans).
|
||||||
|
//!
|
||||||
|
//! The dashscope async transcription flow has two steps:
|
||||||
|
//! 1. POST https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription
|
||||||
|
//! header `X-DashScope-Async: enable`
|
||||||
|
//! body { model, input: { file_urls: [audio_url] }, parameters: {...} }
|
||||||
|
//! => returns task_id
|
||||||
|
//! 2. GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}
|
||||||
|
//! poll until task_status in {SUCCEEDED, FAILED}
|
||||||
|
//!
|
||||||
|
//! On success the response carries one or more `transcription_url`s pointing
|
||||||
|
//! at JSON files containing the recognized sentences. We download the first
|
||||||
|
//! one and concatenate the sentence texts.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
const SUBMIT_URL: &str =
|
||||||
|
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription";
|
||||||
|
const TASK_URL_PREFIX: &str = "https://dashscope.aliyuncs.com/api/v1/tasks/";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AsrError {
|
||||||
|
#[error("missing api key")]
|
||||||
|
MissingApiKey,
|
||||||
|
#[error("http: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
#[error("dashscope: {0}")]
|
||||||
|
Api(String),
|
||||||
|
#[error("timeout waiting for asr task")]
|
||||||
|
Timeout,
|
||||||
|
#[error("json: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct AsrSentence {
|
||||||
|
#[serde(default)]
|
||||||
|
pub text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub begin_time: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub end_time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AsrResult {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sentences: Option<Vec<AsrSentence>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn transcribe(
|
||||||
|
audio_url: &str,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<AsrResult, AsrError> {
|
||||||
|
if api_key.trim().is_empty() {
|
||||||
|
return Err(AsrError::MissingApiKey);
|
||||||
|
}
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(60))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
// 1) submit
|
||||||
|
let submit_body = json!({
|
||||||
|
"model": model,
|
||||||
|
"input": { "file_urls": [audio_url] },
|
||||||
|
"parameters": {}
|
||||||
|
});
|
||||||
|
let submit: Value = client
|
||||||
|
.post(SUBMIT_URL)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.header("X-DashScope-Async", "enable")
|
||||||
|
.json(&submit_body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
let task_id = submit
|
||||||
|
.pointer("/output/task_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| AsrError::Api(format!("submit: no task_id, raw={submit}")))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// 2) poll
|
||||||
|
let max_polls = 60; // ~5 min @ 5s
|
||||||
|
let mut poll_resp: Value = Value::Null;
|
||||||
|
let mut succeeded = false;
|
||||||
|
for _ in 0..max_polls {
|
||||||
|
sleep(Duration::from_secs(5)).await;
|
||||||
|
poll_resp = client
|
||||||
|
.get(format!("{TASK_URL_PREFIX}{task_id}"))
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
let status = poll_resp
|
||||||
|
.pointer("/output/task_status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
match status {
|
||||||
|
"SUCCEEDED" => { succeeded = true; break; }
|
||||||
|
"FAILED" | "UNKNOWN" => {
|
||||||
|
let msg = poll_resp
|
||||||
|
.pointer("/output/message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("FAILED");
|
||||||
|
return Err(AsrError::Api(msg.to_string()));
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !succeeded {
|
||||||
|
return Err(AsrError::Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) fetch transcription file
|
||||||
|
let trans_url = poll_resp
|
||||||
|
.pointer("/output/results/0/transcription_url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or_else(|| AsrError::Api(format!("no transcription_url, raw={poll_resp}")))?
|
||||||
|
.to_string();
|
||||||
|
let trans_json: Value = client.get(&trans_url).send().await?.json().await?;
|
||||||
|
|
||||||
|
// dashscope transcription file shape:
|
||||||
|
// { "transcripts": [{ "text": "...", "sentences": [{text, begin_time, end_time}, ...] }] }
|
||||||
|
let mut full_text = String::new();
|
||||||
|
let mut sentences = Vec::<AsrSentence>::new();
|
||||||
|
if let Some(transcripts) = trans_json.get("transcripts").and_then(Value::as_array) {
|
||||||
|
for t in transcripts {
|
||||||
|
if let Some(text) = t.get("text").and_then(Value::as_str) {
|
||||||
|
if !full_text.is_empty() { full_text.push('\n'); }
|
||||||
|
full_text.push_str(text);
|
||||||
|
}
|
||||||
|
if let Some(arr) = t.get("sentences").and_then(Value::as_array) {
|
||||||
|
for s in arr {
|
||||||
|
sentences.push(AsrSentence {
|
||||||
|
text: s.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
|
||||||
|
begin_time: s.get("begin_time").and_then(Value::as_i64),
|
||||||
|
end_time: s.get("end_time").and_then(Value::as_i64),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(text) = trans_json.get("text").and_then(Value::as_str) {
|
||||||
|
full_text.push_str(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AsrResult {
|
||||||
|
success: true,
|
||||||
|
text: Some(full_text),
|
||||||
|
sentences: if sentences.is_empty() { None } else { Some(sentences) },
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
95
src-tauri/src/chat.rs
Normal file
95
src-tauri/src/chat.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
//! Minimal OpenAI-compatible chat completions client.
|
||||||
|
//! Used for the rewrite step.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ChatError {
|
||||||
|
#[error("missing api url or key")]
|
||||||
|
MissingConfig,
|
||||||
|
#[error("http: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
#[error("api: status={status}, body={body}")]
|
||||||
|
Api { status: u16, body: String },
|
||||||
|
#[error("api response had no choices")]
|
||||||
|
NoChoices,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
#[serde(rename = "apiUrl")]
|
||||||
|
pub api_url: String,
|
||||||
|
#[serde(rename = "apiKey")]
|
||||||
|
pub api_key: String,
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<ChatMessage>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
#[serde(default, rename = "max_tokens")]
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChatResponse {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn complete(req: &ChatRequest) -> Result<ChatResponse, ChatError> {
|
||||||
|
if req.api_url.trim().is_empty() || req.api_key.trim().is_empty() {
|
||||||
|
return Err(ChatError::MissingConfig);
|
||||||
|
}
|
||||||
|
// Accept both `https://host` (will append /v1/chat/completions) and the
|
||||||
|
// full URL.
|
||||||
|
let url = if req.api_url.contains("/chat/completions") {
|
||||||
|
req.api_url.trim_end_matches('/').to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/v1/chat/completions", req.api_url.trim_end_matches('/'))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut body = json!({
|
||||||
|
"model": req.model,
|
||||||
|
"messages": req.messages.iter().map(|m| json!({
|
||||||
|
"role": m.role,
|
||||||
|
"content": m.content,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"stream": false,
|
||||||
|
});
|
||||||
|
if let Some(t) = req.temperature { body["temperature"] = json!(t); }
|
||||||
|
if let Some(mt) = req.max_tokens { body["max_tokens"] = json!(mt); }
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()?;
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&req.api_key)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
let txt = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ChatError::Api { status, body: txt });
|
||||||
|
}
|
||||||
|
let v: Value = resp.json().await?;
|
||||||
|
let content = v
|
||||||
|
.pointer("/choices/0/message/content")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.ok_or(ChatError::NoChoices)?
|
||||||
|
.to_string();
|
||||||
|
Ok(ChatResponse { success: true, content: Some(content), error: None })
|
||||||
|
}
|
||||||
1
src-tauri/src/commands/mod.rs
Normal file
1
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod quickjs;
|
||||||
99
src-tauri/src/commands/quickjs.rs
Normal file
99
src-tauri/src/commands/quickjs.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! Generic Tauri commands for invoking any JS script registered in the
|
||||||
|
//! embedded QuickJS runtime. Replaces the per-pipeline command so adding a
|
||||||
|
//! new flow only requires:
|
||||||
|
//!
|
||||||
|
//! 1. Drop a new `.js` file into `src-tauri/js/`.
|
||||||
|
//! 2. Add it to `js_runtime::SCRIPT_FILES`.
|
||||||
|
//! 3. Inside the JS file, register one or more entries on
|
||||||
|
//! `globalThis.__scripts[<scriptName>]`.
|
||||||
|
//!
|
||||||
|
//! Frontend usage:
|
||||||
|
//!
|
||||||
|
//! ```ts
|
||||||
|
//! import { invoke } from "@tauri-apps/api/core";
|
||||||
|
//! import { listen } from "@tauri-apps/api/event";
|
||||||
|
//!
|
||||||
|
//! const off = await listen("quickjs:event", (e) => console.log(e.payload));
|
||||||
|
//! const result = await invoke("run_quickjs_script", {
|
||||||
|
//! scriptName: "process_douyin_share",
|
||||||
|
//! params: { shareText: "...", modelConfig: {...}, ... },
|
||||||
|
//! });
|
||||||
|
//! off();
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::{AppHandle, Emitter, Window};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::js_runtime::{self, PipelineEvent};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum FrontendEvent {
|
||||||
|
Progress {
|
||||||
|
#[serde(rename = "scriptName")]
|
||||||
|
script_name: String,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
|
Log {
|
||||||
|
#[serde(rename = "scriptName")]
|
||||||
|
script_name: String,
|
||||||
|
level: String,
|
||||||
|
msg: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
fields: Option<Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_NAME: &str = "quickjs:event";
|
||||||
|
|
||||||
|
/// Run an arbitrary registered QuickJS script by name.
|
||||||
|
///
|
||||||
|
/// `script_name` must already be registered on `globalThis.__scripts` by
|
||||||
|
/// one of the bundled `js/*.js` files. `params` is forwarded as-is to the
|
||||||
|
/// script entry function.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn run_quickjs_script(
|
||||||
|
app: AppHandle,
|
||||||
|
window: Window,
|
||||||
|
script_name: String,
|
||||||
|
params: Value,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel::<PipelineEvent>();
|
||||||
|
|
||||||
|
// Forward script events to the frontend window.
|
||||||
|
let app_for_emit = app.clone();
|
||||||
|
let win_label = window.label().to_string();
|
||||||
|
let script_for_emit = script_name.clone();
|
||||||
|
let pump = tokio::spawn(async move {
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
let payload: FrontendEvent = match ev {
|
||||||
|
PipelineEvent::Progress(s) => FrontendEvent::Progress {
|
||||||
|
script_name: script_for_emit.clone(),
|
||||||
|
status: s,
|
||||||
|
},
|
||||||
|
PipelineEvent::Log { level, msg, fields } => FrontendEvent::Log {
|
||||||
|
script_name: script_for_emit.clone(),
|
||||||
|
level,
|
||||||
|
msg,
|
||||||
|
fields,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _ = app_for_emit.emit_to(&win_label, EVENT_NAME, payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = js_runtime::run_script(script_name, params, tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
pump.await.ok();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostic helper — returns the list of registered script names.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_quickjs_scripts() -> Result<Vec<String>, String> {
|
||||||
|
js_runtime::list_scripts().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
83
src-tauri/src/ffmpeg.rs
Normal file
83
src-tauri/src/ffmpeg.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//! Minimal ffmpeg sidecar wrapper used by the Douyin pipeline.
|
||||||
|
//!
|
||||||
|
//! Resolution order for the ffmpeg binary:
|
||||||
|
//! 1. `AICLIENT_FFMPEG_PATH` env override
|
||||||
|
//! 2. `binaries/ffmpeg(.exe)` next to the running executable (production)
|
||||||
|
//! 3. `src-tauri/binaries/ffmpeg(.exe)` relative to CWD (development)
|
||||||
|
//! 4. plain `ffmpeg` from the user's PATH
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum FfmpegError {
|
||||||
|
#[error("ffmpeg binary not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("ffmpeg failed (status={status:?}): {stderr}")]
|
||||||
|
Failed { status: Option<i32>, stderr: String },
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exe_name() -> &'static str {
|
||||||
|
if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locate_ffmpeg() -> Option<PathBuf> {
|
||||||
|
if let Ok(p) = std::env::var("AICLIENT_FFMPEG_PATH") {
|
||||||
|
let pb = PathBuf::from(p);
|
||||||
|
if pb.exists() {
|
||||||
|
return Some(pb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(dir) = exe.parent() {
|
||||||
|
let candidate = dir.join("binaries").join(exe_name());
|
||||||
|
if candidate.exists() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
let flat = dir.join(exe_name());
|
||||||
|
if flat.exists() {
|
||||||
|
return Some(flat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dev = PathBuf::from("src-tauri/binaries").join(exe_name());
|
||||||
|
if dev.exists() {
|
||||||
|
return Some(dev);
|
||||||
|
}
|
||||||
|
let dev2 = PathBuf::from("binaries").join(exe_name());
|
||||||
|
if dev2.exists() {
|
||||||
|
return Some(dev2);
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(exe_name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the audio track from `video_path` and re-encode to 16 kHz mono WAV
|
||||||
|
/// (the format dashscope/funasr accept). Returns the produced wav path.
|
||||||
|
pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), FfmpegError> {
|
||||||
|
let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?;
|
||||||
|
let output = Command::new(&ffmpeg)
|
||||||
|
.arg("-y")
|
||||||
|
.arg("-i").arg(video_path)
|
||||||
|
.arg("-vn")
|
||||||
|
.arg("-ac").arg("1")
|
||||||
|
.arg("-ar").arg("16000")
|
||||||
|
.arg("-acodec").arg("pcm_s16le")
|
||||||
|
.arg(dest_wav)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?
|
||||||
|
.wait_with_output()
|
||||||
|
.await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(FfmpegError::Failed {
|
||||||
|
status: output.status.code(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
137
src-tauri/src/http.rs
Normal file
137
src-tauri/src/http.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
//! Generic HTTP helper exposed to the embedded QuickJS runtime.
|
||||||
|
//!
|
||||||
|
//! Designed to keep the JS side dumb — the JS layer only describes a request
|
||||||
|
//! and decides whether it wants the body as text/json/binary/file.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum HttpError {
|
||||||
|
#[error("invalid request: {0}")]
|
||||||
|
Invalid(String),
|
||||||
|
#[error("http: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct HttpRequestSpec {
|
||||||
|
#[serde(default = "default_method")]
|
||||||
|
pub method: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: Option<HashMap<String, String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: Option<Value>,
|
||||||
|
/// "text" | "json" | "binary" | "file"
|
||||||
|
#[serde(default = "default_response_type")]
|
||||||
|
pub response_type: String,
|
||||||
|
/// Required when `response_type == "file"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dest_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub follow_redirects: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_method() -> String { "GET".into() }
|
||||||
|
fn default_response_type() -> String { "text".into() }
|
||||||
|
fn default_true() -> bool { true }
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct HttpResponse {
|
||||||
|
pub status: u16,
|
||||||
|
pub final_url: String,
|
||||||
|
pub headers: HashMap<String, String>,
|
||||||
|
/// Present when response_type == "text"
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<Value>,
|
||||||
|
/// Present when response_type == "file" — the local path written to.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
/// Present when response_type == "binary" (base64-encoded).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body_base64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(spec: HttpRequestSpec) -> Result<HttpResponse, HttpError> {
|
||||||
|
let mut builder = reqwest::Client::builder();
|
||||||
|
if !spec.follow_redirects {
|
||||||
|
builder = builder.redirect(reqwest::redirect::Policy::none());
|
||||||
|
}
|
||||||
|
if let Some(ms) = spec.timeout_ms {
|
||||||
|
builder = builder.timeout(Duration::from_millis(ms));
|
||||||
|
}
|
||||||
|
let client = builder.build()?;
|
||||||
|
|
||||||
|
let method = reqwest::Method::from_bytes(spec.method.to_uppercase().as_bytes())
|
||||||
|
.map_err(|e| HttpError::Invalid(format!("bad method: {e}")))?;
|
||||||
|
|
||||||
|
let mut req = client.request(method, &spec.url);
|
||||||
|
if let Some(h) = &spec.headers {
|
||||||
|
for (k, v) in h { req = req.header(k, v); }
|
||||||
|
}
|
||||||
|
if let Some(b) = &spec.body {
|
||||||
|
req = match b {
|
||||||
|
Value::String(s) => req.body(s.clone()),
|
||||||
|
other => req.json(other),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req.send().await?;
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
let final_url = resp.url().to_string();
|
||||||
|
let headers = resp
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| v.to_str().ok().map(|s| (k.to_string(), s.to_string())))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = HttpResponse { status, final_url, headers, body: None, file_path: None, body_base64: None };
|
||||||
|
|
||||||
|
match spec.response_type.as_str() {
|
||||||
|
"json" => {
|
||||||
|
let v: Value = resp.json().await?;
|
||||||
|
out.body = Some(v);
|
||||||
|
}
|
||||||
|
"binary" => {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
out.body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||||
|
}
|
||||||
|
"file" => {
|
||||||
|
let dest = spec.dest_path.as_ref()
|
||||||
|
.ok_or_else(|| HttpError::Invalid("dest_path is required for response_type=file".into()))?;
|
||||||
|
let path = PathBuf::from(dest);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
let _ = tokio::fs::create_dir_all(parent).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut file = tokio::fs::File::create(&path).await?;
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk?;
|
||||||
|
file.write_all(&chunk).await?;
|
||||||
|
}
|
||||||
|
file.flush().await?;
|
||||||
|
out.file_path = Some(path.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let txt = resp.text().await?;
|
||||||
|
out.body = Some(Value::String(txt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
147
src-tauri/src/js_runtime/mod.rs
Normal file
147
src-tauri/src/js_runtime/mod.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
//! Embedded QuickJS runtime that runs *named* JS scripts.
|
||||||
|
//!
|
||||||
|
//! Each script file under `src-tauri/js/*.js` is compiled into the binary
|
||||||
|
//! via `include_str!`. At startup the runtime evaluates all of them in
|
||||||
|
//! order; each file is expected to register one or more handlers into the
|
||||||
|
//! global `__scripts` table:
|
||||||
|
//!
|
||||||
|
//! globalThis.__scripts = globalThis.__scripts || {};
|
||||||
|
//! globalThis.__scripts["my_script"] = function (params) { ... };
|
||||||
|
//!
|
||||||
|
//! The Rust side then resolves `__scripts[name]` at invocation time.
|
||||||
|
|
||||||
|
pub mod native;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rquickjs::{async_with, promise::Promise, AsyncContext, AsyncRuntime, CatchResultExt};
|
||||||
|
use serde_json::Value;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
|
||||||
|
use native::NativeBridge;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bundled script registry
|
||||||
|
//
|
||||||
|
// Add a new line here whenever you drop a new file into `src-tauri/js/`.
|
||||||
|
// The order matters only if scripts depend on each other; ours don't.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const BOOTSTRAP_JS: &str = "globalThis.__scripts = globalThis.__scripts || {};";
|
||||||
|
|
||||||
|
const SCRIPT_FILES: &[(&str, &str)] = &[
|
||||||
|
("douyin_pipeline.js", include_str!("../../js/douyin_pipeline.js")),
|
||||||
|
// ("my_next_pipeline.js", include_str!("../../js/my_next_pipeline.js")),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum JsError {
|
||||||
|
#[error("rquickjs: {0}")]
|
||||||
|
QuickJs(String),
|
||||||
|
#[error("serde: {0}")]
|
||||||
|
Serde(#[from] serde_json::Error),
|
||||||
|
#[error("unknown script: {0}")]
|
||||||
|
UnknownScript(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<rquickjs::Error> for JsError {
|
||||||
|
fn from(e: rquickjs::Error) -> Self { JsError::QuickJs(e.to_string()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress / log message produced by a running script.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PipelineEvent {
|
||||||
|
Progress(String),
|
||||||
|
Log {
|
||||||
|
level: String,
|
||||||
|
msg: String,
|
||||||
|
fields: Option<Value>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute the script registered under `script_name` and return its result.
|
||||||
|
pub async fn run_script(
|
||||||
|
script_name: String,
|
||||||
|
params: Value,
|
||||||
|
events: UnboundedSender<PipelineEvent>,
|
||||||
|
) -> Result<Value, JsError> {
|
||||||
|
let bridge = Arc::new(NativeBridge::new(events));
|
||||||
|
|
||||||
|
let runtime = AsyncRuntime::new()?;
|
||||||
|
let context = AsyncContext::full(&runtime).await?;
|
||||||
|
|
||||||
|
let result_json: Value = async_with!(context => |ctx| {
|
||||||
|
// 1) install the __native bridge
|
||||||
|
let native_obj = native::build_native_object(ctx.clone(), bridge.clone())
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("install native: {e}")))?;
|
||||||
|
ctx.globals().set("__native", native_obj)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("set __native: {e}")))?;
|
||||||
|
|
||||||
|
// 2) bootstrap + evaluate every registered script file
|
||||||
|
let _: () = ctx.eval(BOOTSTRAP_JS).catch(&ctx)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("eval bootstrap: {e}")))?;
|
||||||
|
for (file_name, source) in SCRIPT_FILES {
|
||||||
|
let _: () = ctx.eval(*source).catch(&ctx)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("eval {file_name}: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) look up __scripts[script_name]
|
||||||
|
let scripts: rquickjs::Object = ctx.globals().get("__scripts")
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("get __scripts: {e}")))?;
|
||||||
|
let entry: rquickjs::Function = scripts
|
||||||
|
.get(script_name.as_str())
|
||||||
|
.map_err(|_| JsError::UnknownScript(script_name.clone()))?;
|
||||||
|
|
||||||
|
// 4) call entry(params)
|
||||||
|
let params_str = serde_json::to_string(¶ms)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("serialize params: {e}")))?;
|
||||||
|
let params_value: rquickjs::Value = ctx.json_parse(params_str)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("parse params: {e}")))?;
|
||||||
|
|
||||||
|
// The entry may be sync (returns Value) or async (returns Promise).
|
||||||
|
// Cast the return value to Value first, then promote to Promise if it
|
||||||
|
// *is* a Promise; otherwise use it as-is.
|
||||||
|
let raw: rquickjs::Value = entry.call((params_value,))
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("call entry: {e}")))?;
|
||||||
|
let value: rquickjs::Value = match Promise::from_value(raw.clone()) {
|
||||||
|
Ok(p) => p.into_future().await
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("await {script_name}: {e}")))?,
|
||||||
|
Err(_) => raw,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5) JS value -> serde_json::Value via JSON.stringify
|
||||||
|
let json_global: rquickjs::Object = ctx.globals().get("JSON")
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("get JSON: {e}")))?;
|
||||||
|
let stringify: rquickjs::Function = json_global.get("stringify")
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("get JSON.stringify: {e}")))?;
|
||||||
|
let s: String = stringify.call((value,))
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("stringify result: {e}")))?;
|
||||||
|
let parsed: Value = serde_json::from_str(&s)?;
|
||||||
|
Ok::<Value, JsError>(parsed)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result_json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort list of names currently registered in `__scripts`.
|
||||||
|
/// Useful for diagnostics / a `list_quickjs_scripts` command.
|
||||||
|
pub async fn list_scripts() -> Result<Vec<String>, JsError> {
|
||||||
|
let runtime = AsyncRuntime::new()?;
|
||||||
|
let context = AsyncContext::full(&runtime).await?;
|
||||||
|
let names: Vec<String> = async_with!(context => |ctx| {
|
||||||
|
let _: () = ctx.eval(BOOTSTRAP_JS).catch(&ctx)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("bootstrap: {e}")))?;
|
||||||
|
for (file_name, src) in SCRIPT_FILES {
|
||||||
|
let _: () = ctx.eval(*src).catch(&ctx)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("eval {file_name}: {e}")))?;
|
||||||
|
}
|
||||||
|
let keys: String = ctx.eval(r#"Object.keys(globalThis.__scripts || {}).join(",")"#)
|
||||||
|
.map_err(|e| JsError::QuickJs(format!("collect keys: {e}")))?;
|
||||||
|
Ok::<Vec<String>, JsError>(
|
||||||
|
keys.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
295
src-tauri/src/js_runtime/native.rs
Normal file
295
src-tauri/src/js_runtime/native.rs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
//! `__native` bridge: the set of host functions exposed to the embedded
|
||||||
|
//! QuickJS pipeline.
|
||||||
|
//!
|
||||||
|
//! Conventions:
|
||||||
|
//! - Sync helpers (log, tempPath, deleteFile, ...) take/return primitives
|
||||||
|
//! and may panic-free `Func::from` directly.
|
||||||
|
//! - Async helpers always return a `String` (JSON-encoded result).
|
||||||
|
//! Failures are encoded as `{ "__error": "..." }`; a JS shim
|
||||||
|
//! installed at the end of `build_native_object` detects that shape
|
||||||
|
//! and `throw`s, so JS callers can use natural `try/catch`.
|
||||||
|
//! - On success, the JSON string is parsed by the same JS shim and
|
||||||
|
//! returned to the caller as a plain object.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rquickjs::{
|
||||||
|
function::{Async, Func},
|
||||||
|
Ctx, Object, Result as JsResult,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::sync::mpsc::UnboundedSender;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::PipelineEvent;
|
||||||
|
use crate::{asr, chat, ffmpeg, http, oss};
|
||||||
|
|
||||||
|
/// Shared host state passed by reference into every native function.
|
||||||
|
pub struct NativeBridge {
|
||||||
|
pub events: UnboundedSender<PipelineEvent>,
|
||||||
|
pub temp_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeBridge {
|
||||||
|
pub fn new(events: UnboundedSender<PipelineEvent>) -> Self {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aiclient-pipeline");
|
||||||
|
let _ = std::fs::create_dir_all(&temp_dir);
|
||||||
|
Self { events, temp_dir }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn err_json(msg: impl ToString) -> String {
|
||||||
|
json!({ "__error": msg.to_string() }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_to_json<'js>(v: &rquickjs::Value<'js>) -> Result<Value, String> {
|
||||||
|
if v.is_null() || v.is_undefined() {
|
||||||
|
return Ok(Value::Null);
|
||||||
|
}
|
||||||
|
let ctx = v.ctx().clone();
|
||||||
|
let json: rquickjs::Object = ctx.globals().get("JSON").map_err(|e| e.to_string())?;
|
||||||
|
let stringify: rquickjs::Function = json.get("stringify").map_err(|e| e.to_string())?;
|
||||||
|
let s: String = stringify
|
||||||
|
.call((v.clone(),))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
serde_json::from_str(&s).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Build the __native object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
pub fn build_native_object<'js>(
|
||||||
|
ctx: Ctx<'js>,
|
||||||
|
bridge: Arc<NativeBridge>,
|
||||||
|
) -> JsResult<Object<'js>> {
|
||||||
|
let obj = Object::new(ctx.clone())?;
|
||||||
|
|
||||||
|
// ---- sync helpers ----------------------------------------------------
|
||||||
|
{
|
||||||
|
let b = bridge.clone();
|
||||||
|
obj.set(
|
||||||
|
"log",
|
||||||
|
Func::from(move |level: String, msg: String, fields: Option<rquickjs::Value<'js>>| {
|
||||||
|
let fields_json = fields.and_then(|v| value_to_json(&v).ok());
|
||||||
|
let _ = b.events.send(PipelineEvent::Log {
|
||||||
|
level: level.clone(),
|
||||||
|
msg: msg.clone(),
|
||||||
|
fields: fields_json.clone(),
|
||||||
|
});
|
||||||
|
log::info!(target: "pipeline", "[{}] {} {:?}", level, msg, fields_json);
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let b = bridge.clone();
|
||||||
|
obj.set(
|
||||||
|
"emitProgress",
|
||||||
|
Func::from(move |status: String| {
|
||||||
|
let _ = b.events.send(PipelineEvent::Progress(status));
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let b = bridge.clone();
|
||||||
|
obj.set(
|
||||||
|
"tempPath",
|
||||||
|
Func::from(move |prefix: String, ext: Option<String>| -> String {
|
||||||
|
let ext = ext.unwrap_or_else(|| "tmp".into());
|
||||||
|
let name = format!("{}-{}.{}", prefix, Uuid::new_v4(), ext);
|
||||||
|
b.temp_dir.join(name).to_string_lossy().into_owned()
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
obj.set(
|
||||||
|
"deleteFile",
|
||||||
|
Func::from(|path: String| {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
obj.set(
|
||||||
|
"fileExists",
|
||||||
|
Func::from(|path: String| std::path::Path::new(&path).exists()),
|
||||||
|
)?;
|
||||||
|
obj.set(
|
||||||
|
"now",
|
||||||
|
Func::from(|| chrono::Utc::now().timestamp_millis() as f64),
|
||||||
|
)?;
|
||||||
|
obj.set("uuid", Func::from(|| Uuid::new_v4().to_string()))?;
|
||||||
|
|
||||||
|
// ---- async: HTTP -----------------------------------------------------
|
||||||
|
obj.set(
|
||||||
|
"httpRequest",
|
||||||
|
Func::from(Async(|spec: rquickjs::Value<'js>| {
|
||||||
|
let spec_json = value_to_json(&spec);
|
||||||
|
async move {
|
||||||
|
let spec_json = match spec_json {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return Ok::<String, rquickjs::Error>(err_json(format!("invalid http spec: {e}"))),
|
||||||
|
};
|
||||||
|
let parsed: http::HttpRequestSpec = match serde_json::from_value(spec_json) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return Ok(err_json(format!("http spec deserialize: {e}"))),
|
||||||
|
};
|
||||||
|
match http::execute(parsed).await {
|
||||||
|
Ok(resp) => Ok(serde_json::to_string(&resp).unwrap_or_else(|e| err_json(e))),
|
||||||
|
Err(e) => Ok(err_json(format!("http: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// ---- async: ffmpeg extract audio ------------------------------------
|
||||||
|
{
|
||||||
|
let b = bridge.clone();
|
||||||
|
obj.set(
|
||||||
|
"ffmpegExtractAudio",
|
||||||
|
Func::from(Async(move |video_path: String| {
|
||||||
|
let temp_dir = b.temp_dir.clone();
|
||||||
|
async move {
|
||||||
|
let dest = temp_dir.join(format!("audio-{}.wav", Uuid::new_v4()));
|
||||||
|
match ffmpeg::extract_audio(std::path::Path::new(&video_path), &dest).await {
|
||||||
|
Ok(()) => Ok::<String, rquickjs::Error>(
|
||||||
|
json!(dest.to_string_lossy()).to_string(),
|
||||||
|
),
|
||||||
|
Err(e) => Ok(err_json(format!("ffmpeg: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- async: aliyun OSS upload ---------------------------------------
|
||||||
|
obj.set(
|
||||||
|
"aliyunOssUpload",
|
||||||
|
Func::from(Async(|local: String, cfg: rquickjs::Value<'js>| {
|
||||||
|
let cfg_json = value_to_json(&cfg);
|
||||||
|
async move {
|
||||||
|
let cfg_json = match cfg_json {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return Ok::<String, rquickjs::Error>(
|
||||||
|
json!({"success": false, "error": format!("oss cfg: {e}")}).to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let cfg: oss::OssConfig = match serde_json::from_value(cfg_json) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => return Ok(
|
||||||
|
json!({"success": false, "error": format!("oss cfg parse: {e}")}).to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let key = format!("audio/{}.wav", Uuid::new_v4());
|
||||||
|
match oss::put_object(&cfg, std::path::Path::new(&local), &key, "audio/wav").await {
|
||||||
|
Ok(url) => Ok(json!({"success": true, "url": url}).to_string()),
|
||||||
|
Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// ---- async: aliyun dashscope ASR ------------------------------------
|
||||||
|
obj.set(
|
||||||
|
"aliyunAsrFiletrans",
|
||||||
|
Func::from(Async(|audio_url: String, opts: rquickjs::Value<'js>| {
|
||||||
|
let opts_json = value_to_json(&opts);
|
||||||
|
async move {
|
||||||
|
let opts_json = match opts_json {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return Ok::<String, rquickjs::Error>(
|
||||||
|
json!({"success": false, "error": format!("asr opts: {e}")}).to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AsrOpts {
|
||||||
|
#[serde(rename = "apiKey")]
|
||||||
|
api_key: String,
|
||||||
|
#[serde(default = "default_asr_model")]
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
fn default_asr_model() -> String { "qwen3-asr-flash-filetrans".into() }
|
||||||
|
let parsed: AsrOpts = match serde_json::from_value(opts_json) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return Ok(
|
||||||
|
json!({"success": false, "error": format!("asr opts parse: {e}")}).to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let result = match asr::transcribe(&audio_url, &parsed.api_key, &parsed.model).await {
|
||||||
|
Ok(r) => serde_json::to_value(r).unwrap_or(Value::Null),
|
||||||
|
Err(e) => json!({"success": false, "error": e.to_string()}),
|
||||||
|
};
|
||||||
|
Ok(result.to_string())
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// ---- async: local ASR (placeholder; user can plug in own server) ----
|
||||||
|
obj.set(
|
||||||
|
"localAsr",
|
||||||
|
Func::from(Async(|_audio_path: String, _info: rquickjs::Value<'js>| async move {
|
||||||
|
Ok::<String, rquickjs::Error>(
|
||||||
|
json!({
|
||||||
|
"success": false,
|
||||||
|
"error": "local ASR not implemented in Rust port — please call your local server explicitly"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// ---- async: OpenAI-compatible chat ----------------------------------
|
||||||
|
obj.set(
|
||||||
|
"openaiChat",
|
||||||
|
Func::from(Async(|req: rquickjs::Value<'js>| {
|
||||||
|
let req_json = value_to_json(&req);
|
||||||
|
async move {
|
||||||
|
let req_json = match req_json {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return Ok::<String, rquickjs::Error>(
|
||||||
|
json!({"success": false, "error": format!("chat req: {e}")}).to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let parsed: chat::ChatRequest = match serde_json::from_value(req_json) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => return Ok(
|
||||||
|
json!({"success": false, "error": format!("chat req parse: {e}")}).to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let result = match chat::complete(&parsed).await {
|
||||||
|
Ok(r) => serde_json::to_value(r).unwrap_or(Value::Null),
|
||||||
|
Err(e) => json!({"success": false, "error": e.to_string()}),
|
||||||
|
};
|
||||||
|
Ok(result.to_string())
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// The async functions above all return JSON strings. Wrap each one in
|
||||||
|
// a JS shim that JSON.parses the result and throws when the payload
|
||||||
|
// carries the `__error` discriminator.
|
||||||
|
let shim = r#"
|
||||||
|
(function () {
|
||||||
|
const wrap = (name) => {
|
||||||
|
const orig = __native[name];
|
||||||
|
__native[name] = async function (...args) {
|
||||||
|
const s = await orig.apply(__native, args);
|
||||||
|
if (typeof s !== "string") return s;
|
||||||
|
let v;
|
||||||
|
try { v = JSON.parse(s); } catch (_) { return s; }
|
||||||
|
if (v && typeof v === "object" && typeof v.__error === "string") {
|
||||||
|
throw new Error(v.__error);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
["httpRequest", "ffmpegExtractAudio", "aliyunOssUpload",
|
||||||
|
"aliyunAsrFiletrans", "localAsr", "openaiChat"].forEach(wrap);
|
||||||
|
})();
|
||||||
|
"#;
|
||||||
|
let _: () = ctx.eval(shim)?;
|
||||||
|
|
||||||
|
Ok(obj)
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
|
pub mod asr;
|
||||||
|
pub mod chat;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod ffmpeg;
|
||||||
|
pub mod http;
|
||||||
|
pub mod js_runtime;
|
||||||
|
pub mod oss;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
let _ = env_logger::try_init();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -11,6 +21,10 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::quickjs::run_quickjs_script,
|
||||||
|
commands::quickjs::list_quickjs_scripts,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
126
src-tauri/src/oss.rs
Normal file
126
src-tauri/src/oss.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! Minimal Aliyun OSS PUT-Object client.
|
||||||
|
//!
|
||||||
|
//! Only the subset required by the Douyin pipeline is implemented:
|
||||||
|
//! - PUT a single local file under `audio/<uuid>.<ext>`
|
||||||
|
//! - Sign with HMAC-SHA1 using AccessKey ID + Secret
|
||||||
|
//! - Return the public https URL
|
||||||
|
//!
|
||||||
|
//! No external `aliyun-oss` crate is used so that the dependency footprint
|
||||||
|
//! stays small.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||||
|
use chrono::Utc;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha1::Sha1;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum OssError {
|
||||||
|
#[error("missing field: {0}")]
|
||||||
|
MissingField(&'static str),
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("http: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
#[error("oss returned status={status}, body={body}")]
|
||||||
|
BadStatus { status: u16, body: String },
|
||||||
|
#[error("hmac error")]
|
||||||
|
Hmac,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OssConfig {
|
||||||
|
#[serde(rename = "accessKeyId")]
|
||||||
|
pub access_key_id: String,
|
||||||
|
#[serde(rename = "accessKeySecret")]
|
||||||
|
pub access_key_secret: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub region: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct OssUploadResult {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
|
|
||||||
|
fn rfc1123_now() -> String {
|
||||||
|
Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(secret: &str, str_to_sign: &str) -> Result<String, OssError> {
|
||||||
|
let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).map_err(|_| OssError::Hmac)?;
|
||||||
|
mac.update(str_to_sign.as_bytes());
|
||||||
|
Ok(B64.encode(mac.finalize().into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require<'a>(v: &'a str, name: &'static str) -> Result<&'a str, OssError> {
|
||||||
|
if v.trim().is_empty() {
|
||||||
|
Err(OssError::MissingField(name))
|
||||||
|
} else {
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_object(
|
||||||
|
cfg: &OssConfig,
|
||||||
|
local_path: &Path,
|
||||||
|
object_key: &str,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<String, OssError> {
|
||||||
|
let ak = require(&cfg.access_key_id, "accessKeyId")?;
|
||||||
|
let sk = require(&cfg.access_key_secret, "accessKeySecret")?;
|
||||||
|
let bucket = require(&cfg.bucket, "bucket")?;
|
||||||
|
let region = require(&cfg.region, "region")?;
|
||||||
|
|
||||||
|
let host = format!("{bucket}.{region}.aliyuncs.com");
|
||||||
|
let endpoint = cfg
|
||||||
|
.endpoint
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("https://{host}"));
|
||||||
|
let url = format!("{}/{}", endpoint.trim_end_matches('/'), object_key);
|
||||||
|
|
||||||
|
let body = tokio::fs::read(local_path).await?;
|
||||||
|
let date = rfc1123_now();
|
||||||
|
|
||||||
|
// String-to-sign for OSS V1:
|
||||||
|
// VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedOSSHeaders+CanonicalizedResource
|
||||||
|
let canonicalized_resource = format!("/{bucket}/{object_key}");
|
||||||
|
let str_to_sign = format!(
|
||||||
|
"PUT\n\n{ct}\n{date}\n{res}",
|
||||||
|
ct = content_type,
|
||||||
|
date = date,
|
||||||
|
res = canonicalized_resource
|
||||||
|
);
|
||||||
|
let signature = sign(sk, &str_to_sign)?;
|
||||||
|
let authorization = format!("OSS {ak}:{signature}");
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.build()
|
||||||
|
.map_err(OssError::Http)?;
|
||||||
|
let resp = client
|
||||||
|
.put(&url)
|
||||||
|
.header("Date", &date)
|
||||||
|
.header("Content-Type", content_type)
|
||||||
|
.header("Authorization", authorization)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = resp.status().as_u16();
|
||||||
|
if !(200..300).contains(&status) {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(OssError::BadStatus { status, body });
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
"resources": [
|
||||||
|
"binaries/*"
|
||||||
|
],
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": [
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { defineStore, acceptHMRUpdate } from "pinia";
|
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
const defaultPublishPlatforms = () => [
|
const defaultPublishPlatforms = () => [
|
||||||
{ label: "*音", checked: false, account: null },
|
{ label: "*音", checked: false, account: null },
|
||||||
@@ -153,7 +155,31 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
export async function processDouyinShare(shareText){
|
||||||
|
const result = await invoke("run_quickjs_script", {
|
||||||
|
scriptName:"douyin_pipeline.js",
|
||||||
|
params: {
|
||||||
|
shareText: "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxxx/",
|
||||||
|
modelConfig: {
|
||||||
|
providerId: "openai",
|
||||||
|
modelId: "gpt-4o-mini",
|
||||||
|
apiUrl: "https://api.openai.com", // 或 dashscope/openrouter 等任意兼容端点
|
||||||
|
apiKey: "sk-...",
|
||||||
|
asrMode: "online",
|
||||||
|
aliyunApiKey: "sk-dashscope-...",
|
||||||
|
ossConfig: {
|
||||||
|
accessKeyId: "...",
|
||||||
|
accessKeySecret: "...",
|
||||||
|
bucket: "my-bucket",
|
||||||
|
region: "oss-cn-hangzhou",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rewriteConfig: { style: "幽默口播", tone: "活泼" },
|
||||||
|
customPrompt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
export async function executeVideoRewrite(store) {
|
export async function executeVideoRewrite(store) {
|
||||||
const text = store.videoShareText?.trim();
|
const text = store.videoShareText?.trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|||||||
Reference in New Issue
Block a user