diff --git a/aiclient.code-workspace b/aiclient.code-workspace new file mode 100644 index 0000000..6bb14d3 --- /dev/null +++ b/aiclient.code-workspace @@ -0,0 +1,17 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../pythonbackend" + }, + { + "path": "../../Users/12996/AppData/Local/Programs/zhenqianba-v2/resources/app_debug" + }, + { + "path": "src-tauri/resources/resources-bundles/python-runtimebackup" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c0162f5..8c5d7ae 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -16,10 +16,15 @@ const APP_COMMANDS: &[&str] = &[ "run_nodejs_script_source", "list_nodejs_scripts", "read_local_file_base64", + "write_local_file_base64", "get_media_duration_seconds", "copy_local_file", "write_local_text_file", + "temp_voice_reference_path", + "stage_voice_reference_audio", + "import_voice_clone_reference", "reveal_local_file_in_folder", + "stop_nodejs_script", "list_avatars", "insert_avatar", "update_avatar_name", diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index 92dd1ae..c4f59c5 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -18,10 +18,15 @@ permissions = [ "allow-run-nodejs-script-source", "allow-list-nodejs-scripts", "allow-read-local-file-base64", + "allow-write-local-file-base64", "allow-get-media-duration-seconds", "allow-copy-local-file", "allow-write-local-text-file", + "allow-temp-voice-reference-path", + "allow-stage-voice-reference-audio", + "allow-import-voice-clone-reference", "allow-reveal-local-file-in-folder", + "allow-stop-nodejs-script", "allow-list-avatars", "allow-insert-avatar", "allow-update-avatar-name", diff --git a/src-tauri/src/commands/fs_util.rs b/src-tauri/src/commands/fs_util.rs index 092dfae..bf2e3a2 100644 --- a/src-tauri/src/commands/fs_util.rs +++ b/src-tauri/src/commands/fs_util.rs @@ -14,6 +14,46 @@ fn normalize_path(path: &str) -> PathBuf { .collect() } +/// 解析用户通过系统对话框选择的文件路径(兼容 file:// 与 /C:/ 形式)。 +fn resolve_user_selected_file(path: &str) -> Result { + let mut trimmed = path.trim(); + if trimmed.is_empty() { + return Err("路径为空".into()); + } + if let Some(rest) = trimmed.strip_prefix("file:///") { + trimmed = rest; + } else if let Some(rest) = trimmed.strip_prefix("file://") { + trimmed = rest; + } + // `/C:/Users/...` → `C:/Users/...` + if trimmed.len() >= 3 { + let bytes = trimmed.as_bytes(); + if bytes[0] == b'/' && bytes[2] == b':' { + trimmed = &trimmed[1..]; + } + } + + let direct = PathBuf::from(trimmed); + if direct.is_file() { + return Ok(direct); + } + let normalized = normalize_path(trimmed); + if normalized.is_file() { + return Ok(normalized); + } + if let Ok(canonical) = dunce::canonicalize(&direct) { + if canonical.is_file() { + return Ok(canonical); + } + } + if let Ok(canonical) = dunce::canonicalize(&normalized) { + if canonical.is_file() { + return Ok(canonical); + } + } + Err(format!("文件不存在: {trimmed}")) +} + /// 仅允许读取临时目录下的试听/流水线产物,避免任意路径读取。 fn is_allowed_local_media(path: &Path) -> bool { let lower = path.to_string_lossy().to_lowercase(); @@ -165,6 +205,34 @@ fn voice_clones_dir(app: &tauri::AppHandle) -> Result { Ok(dir) } +/// 将用户通过文件对话框选择的音频复制到临时目录,供时长探测与试听。 +#[tauri::command] +pub async fn stage_voice_reference_audio(source_path: String) -> Result { + let source = resolve_user_selected_file(&source_path)?; + let ext = source + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("wav"); + let ext = if ext.is_empty() { "wav" } else { ext }; + let dir = std::env::temp_dir().join("aiclient-voice-ref"); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| format!("创建临时目录失败: {e}"))?; + let dest = dir.join(format!( + "upload_{}_{}.{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0), + rand_simple(), + ext + )); + tokio::fs::copy(&source, &dest) + .await + .map_err(|e| format!("复制参考音频失败: {e}"))?; + Ok(dest.to_string_lossy().into_owned()) +} + /// 生成临时参考音频路径(录音/上传中转)。 #[tauri::command] pub async fn temp_voice_reference_path(ext: String) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7eec6b..c4c49f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -137,6 +137,7 @@ pub fn run() { commands::fs_util::copy_local_file, commands::fs_util::write_local_text_file, commands::fs_util::temp_voice_reference_path, + commands::fs_util::stage_voice_reference_audio, commands::fs_util::import_voice_clone_reference, commands::fs_util::reveal_local_file_in_folder, commands::avatar::list_avatars, diff --git a/src/components/voice/VoiceReferenceRecorder.vue b/src/components/voice/VoiceReferenceRecorder.vue index 6be560f..d7fde3a 100644 --- a/src/components/voice/VoiceReferenceRecorder.vue +++ b/src/components/voice/VoiceReferenceRecorder.vue @@ -4,6 +4,8 @@ import { localAudioToPlayableUrl } from "../../services/localAudio.js"; import { createTempReferencePath, getAudioDurationSeconds, + formatInvokeError, + importPickedReferenceAudio, pickReferenceAudioFile, writeBlobToPath, } from "../../services/voiceReferenceAudio.js"; @@ -65,10 +67,18 @@ function setPath(path) { async function onPickFile() { recordError.value = ""; - const path = await pickReferenceAudioFile(); - if (!path) return; - setPath(path); - await refreshPreview(); + try { + const picked = await pickReferenceAudioFile(); + if (!picked) return; + const path = await importPickedReferenceAudio(picked); + setPath(path); + await refreshPreview(); + if (!durationSec.value) { + recordError.value = "无法读取音频时长,请换用 wav/mp3 文件重试"; + } + } catch (err) { + recordError.value = formatInvokeError(err, "导入参考音频失败"); + } } async function startRecording() { @@ -128,9 +138,21 @@ async function exportReferencePath() { return referencePath.value; } +async function getDurationSeconds() { + const p = referencePath.value; + if (!p) return 0; + if (durationSec.value > 0) return durationSec.value; + try { + durationSec.value = await getAudioDurationSeconds(p); + } catch { + durationSec.value = 0; + } + return durationSec.value; +} + defineExpose({ exportReferencePath, - getDurationSeconds: () => durationSec.value, + getDurationSeconds, refreshPreview, }); diff --git a/src/components/workflow/VoiceCloneEditDialog.vue b/src/components/workflow/VoiceCloneEditDialog.vue index cfc5a5a..7046594 100644 --- a/src/components/workflow/VoiceCloneEditDialog.vue +++ b/src/components/workflow/VoiceCloneEditDialog.vue @@ -1,11 +1,12 @@