1
This commit is contained in:
46
src-tauri/src/commands/fs_util.rs
Normal file
46
src-tauri/src/commands/fs_util.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
//! 本地文件读取(供前端播放 Node 生成的临时音频等)
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
fn normalize_path(path: &str) -> PathBuf {
|
||||||
|
Path::new(path)
|
||||||
|
.components()
|
||||||
|
.filter(|c| !matches!(c, Component::ParentDir))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅允许读取临时目录下的试听/流水线产物,避免任意路径读取。
|
||||||
|
fn is_allowed_local_media(path: &Path) -> bool {
|
||||||
|
let lower = path.to_string_lossy().to_lowercase();
|
||||||
|
if lower.contains("aiclient-voice-preview-cache")
|
||||||
|
|| lower.contains("aiclient-node-pipeline")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Ok(temp) = std::env::temp_dir().canonicalize() {
|
||||||
|
if let Ok(canonical) = path.canonicalize() {
|
||||||
|
return canonical.starts_with(&temp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取本地文件为 base64(用于 `<audio src="data:audio/wav;base64,...">`)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_local_file_base64(path: String) -> Result<String, String> {
|
||||||
|
let normalized = normalize_path(path.trim());
|
||||||
|
if normalized.as_os_str().is_empty() {
|
||||||
|
return Err("路径为空".into());
|
||||||
|
}
|
||||||
|
if !normalized.is_file() {
|
||||||
|
return Err(format!("文件不存在: {}", normalized.display()));
|
||||||
|
}
|
||||||
|
if !is_allowed_local_media(&normalized) {
|
||||||
|
return Err("不允许读取该路径下的文件".into());
|
||||||
|
}
|
||||||
|
let bytes = tokio::fs::read(&normalized)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("读取文件失败: {e}"))?;
|
||||||
|
Ok(STANDARD.encode(bytes))
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod app_config;
|
pub mod app_config;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod fs_util;
|
||||||
pub mod nodejs;
|
pub mod nodejs;
|
||||||
pub mod quickjs;
|
pub mod quickjs;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub fn run() {
|
|||||||
commands::nodejs::run_nodejs_script,
|
commands::nodejs::run_nodejs_script,
|
||||||
commands::nodejs::run_nodejs_script_source,
|
commands::nodejs::run_nodejs_script_source,
|
||||||
commands::nodejs::list_nodejs_scripts,
|
commands::nodejs::list_nodejs_scripts,
|
||||||
|
commands::fs_util::read_local_file_base64,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
listCloneVoices,
|
listCloneVoices,
|
||||||
deleteCloneVoice,
|
deleteCloneVoice,
|
||||||
} from "../../services/soundPromptStorage.js";
|
} from "../../services/soundPromptStorage.js";
|
||||||
|
import { previewVoice } from "../../services/voicePreview.js";
|
||||||
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
@@ -21,6 +22,12 @@ const cloneVoices = ref([]);
|
|||||||
const cloneLoading = ref(false);
|
const cloneLoading = ref(false);
|
||||||
const editDialogRef = ref(null);
|
const editDialogRef = ref(null);
|
||||||
|
|
||||||
|
const previewingKey = ref(null);
|
||||||
|
const previewPanelKey = ref(null);
|
||||||
|
const previewAudioSrc = ref("");
|
||||||
|
const previewError = ref("");
|
||||||
|
const audioRef = ref(null);
|
||||||
|
|
||||||
const filteredSystemVoices = computed(() =>
|
const filteredSystemVoices = computed(() =>
|
||||||
filterSystemVoices(voiceCategory.value),
|
filterSystemVoices(voiceCategory.value),
|
||||||
);
|
);
|
||||||
@@ -39,9 +46,22 @@ watch(voiceManageModalVisible, (open) => {
|
|||||||
activeTab.value = "system";
|
activeTab.value = "system";
|
||||||
voiceCategory.value = "putonghua";
|
voiceCategory.value = "putonghua";
|
||||||
loadCloneVoices();
|
loadCloneVoices();
|
||||||
|
} else {
|
||||||
|
stopPreview();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function stopPreview() {
|
||||||
|
previewingKey.value = null;
|
||||||
|
previewPanelKey.value = null;
|
||||||
|
previewAudioSrc.value = "";
|
||||||
|
previewError.value = "";
|
||||||
|
if (audioRef.value) {
|
||||||
|
audioRef.value.pause();
|
||||||
|
audioRef.value.removeAttribute("src");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
workflow.closeVoiceManageDialog();
|
workflow.closeVoiceManageDialog();
|
||||||
}
|
}
|
||||||
@@ -64,12 +84,62 @@ function onDeleteClone(record) {
|
|||||||
if (selectedVoiceId.value === record.id) {
|
if (selectedVoiceId.value === record.id) {
|
||||||
workflow.selectVoice("sys_Cherry");
|
workflow.selectVoice("sys_Cherry");
|
||||||
}
|
}
|
||||||
|
if (previewPanelKey.value === record.id) {
|
||||||
|
stopPreview();
|
||||||
|
}
|
||||||
loadCloneVoices();
|
loadCloneVoices();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripVoiceIdPrefix(id) {
|
function stripVoiceIdPrefix(id) {
|
||||||
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
|
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runPreview(opts) {
|
||||||
|
previewError.value = "";
|
||||||
|
previewingKey.value = opts.listKey;
|
||||||
|
const result = await previewVoice(opts);
|
||||||
|
previewingKey.value = null;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
previewError.value = result.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewPanelKey.value = opts.listKey;
|
||||||
|
previewAudioSrc.value = result.audioSrc;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
audioRef.value?.play()?.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewSystem(voice) {
|
||||||
|
runPreview({
|
||||||
|
listKey: voice.id,
|
||||||
|
title: voice.title,
|
||||||
|
aliyunVoiceId: voice.aliyunVoiceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreviewClone(item) {
|
||||||
|
const voiceId = String(item.content?.aliyunVoiceId || "").trim();
|
||||||
|
if (!voiceId) {
|
||||||
|
previewError.value = "请先配置音色 ID 后再试听";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runPreview({
|
||||||
|
listKey: item.id,
|
||||||
|
title: item.title,
|
||||||
|
aliyunVoiceId: voiceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewing(key) {
|
||||||
|
return previewingKey.value === key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPreviewPlayer(key) {
|
||||||
|
return previewPanelKey.value === key && previewAudioSrc.value;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -82,6 +152,8 @@ function stripVoiceIdPrefix(id) {
|
|||||||
class="voice-manage-dialog"
|
class="voice-manage-dialog"
|
||||||
@hide="onClose"
|
@hide="onClose"
|
||||||
>
|
>
|
||||||
|
<audio ref="audioRef" class="hidden" :src="previewAudioSrc || undefined" />
|
||||||
|
|
||||||
<Tabs v-model:value="activeTab">
|
<Tabs v-model:value="activeTab">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="system">系统音色</Tab>
|
<Tab value="system">系统音色</Tab>
|
||||||
@@ -113,6 +185,16 @@ function stripVoiceIdPrefix(id) {
|
|||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-500">音色ID: {{ voice.aliyunVoiceId }}</p>
|
<p class="mt-2 text-xs text-slate-500">音色ID: {{ voice.aliyunVoiceId }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
label="试听"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-volume-up"
|
||||||
|
:loading="isPreviewing(voice.id)"
|
||||||
|
:disabled="Boolean(previewingKey && !isPreviewing(voice.id))"
|
||||||
|
@click="onPreviewSystem(voice)"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="选择"
|
label="选择"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -121,6 +203,18 @@ function stripVoiceIdPrefix(id) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showPreviewPlayer(voice.id)"
|
||||||
|
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
:src="previewAudioSrc"
|
||||||
|
controls
|
||||||
|
class="h-8 w-full"
|
||||||
|
autoplay
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="filteredSystemVoices.length === 0"
|
v-if="filteredSystemVoices.length === 0"
|
||||||
class="py-8 text-center text-sm text-slate-500"
|
class="py-8 text-center text-sm text-slate-500"
|
||||||
@@ -170,6 +264,18 @@ function stripVoiceIdPrefix(id) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-1">
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button
|
||||||
|
label="试听"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-volume-up"
|
||||||
|
:loading="isPreviewing(item.id)"
|
||||||
|
:disabled="
|
||||||
|
!item.content?.aliyunVoiceId ||
|
||||||
|
Boolean(previewingKey && !isPreviewing(item.id))
|
||||||
|
"
|
||||||
|
@click="onPreviewClone(item)"
|
||||||
|
/>
|
||||||
<Button label="选择" size="small" @click="onSelectVoice(item.id)" />
|
<Button label="选择" size="small" @click="onSelectVoice(item.id)" />
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
@@ -187,11 +293,20 @@ function stripVoiceIdPrefix(id) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showPreviewPlayer(item.id)"
|
||||||
|
class="mt-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-2 py-2"
|
||||||
|
>
|
||||||
|
<audio :src="previewAudioSrc" controls class="h-8 w-full" autoplay />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<p v-if="previewError" class="mt-2 text-sm text-red-400">{{ previewError }}</p>
|
||||||
|
|
||||||
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
|
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
85
src/services/voicePreview.js
Normal file
85
src/services/voicePreview.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||||
|
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
||||||
|
|
||||||
|
const VOICE_PREVIEW_SCRIPT = "voice_preview.js";
|
||||||
|
|
||||||
|
/** @type {Map<string, string>} cacheKey → data:audio URL */
|
||||||
|
const previewUrlCache = new Map();
|
||||||
|
|
||||||
|
function mimeFromPath(filePath) {
|
||||||
|
const lower = String(filePath || "").toLowerCase();
|
||||||
|
if (lower.endsWith(".mp3")) return "audio/mpeg";
|
||||||
|
if (lower.endsWith(".ogg")) return "audio/ogg";
|
||||||
|
return "audio/wav";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Node 写入的本地音频转为可在 WebView 播放的 URL(避免 asset.localhost 不可用)
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
async function localAudioToPlayableUrl(filePath) {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("请在 Tauri 桌面端使用试听功能");
|
||||||
|
}
|
||||||
|
const base64 = await invoke("read_local_file_base64", { path: filePath });
|
||||||
|
const mime = mimeFromPath(filePath);
|
||||||
|
return `data:${mime};base64,${base64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音色试听
|
||||||
|
* @param {{ listKey: string, title: string, aliyunVoiceId?: string, selectedVoiceId?: string }} opts
|
||||||
|
*/
|
||||||
|
export async function previewVoice(opts) {
|
||||||
|
const cfg = await ensureAppConfigReady();
|
||||||
|
if (!cfg.ok) {
|
||||||
|
return { ok: false, message: cfg.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice = String(
|
||||||
|
opts.aliyunVoiceId ||
|
||||||
|
resolveAliyunVoiceId(opts.selectedVoiceId || ""),
|
||||||
|
).trim();
|
||||||
|
if (!voice) {
|
||||||
|
return { ok: false, message: "音色 ID 无效" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = opts.listKey || "";
|
||||||
|
const memCached = cacheKey ? previewUrlCache.get(cacheKey) : null;
|
||||||
|
if (memCached) {
|
||||||
|
return { ok: true, audioSrc: memCached, cached: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke("run_nodejs_script", {
|
||||||
|
scriptName: VOICE_PREVIEW_SCRIPT,
|
||||||
|
params: {
|
||||||
|
voice,
|
||||||
|
title: opts.title || "",
|
||||||
|
cacheKey: cacheKey || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.success || !result?.audioPath) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: String(result?.error || "试听失败,请重试"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioSrc = await localAudioToPlayableUrl(result.audioPath);
|
||||||
|
if (cacheKey) {
|
||||||
|
previewUrlCache.set(cacheKey, audioSrc);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
audioSrc,
|
||||||
|
audioPath: result.audioPath,
|
||||||
|
cached: Boolean(result.cached),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||||
|
return { ok: false, message: `试听失败: ${msg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user