From 30e019d3459e4dbc9c65f1a9c6a1cf5a03596e78 Mon Sep 17 00:00:00 2001 From: "949036910@qq.com" <> Date: Fri, 29 May 2026 23:16:42 +0800 Subject: [PATCH] 11 --- src-tauri/src/commands/fs_util.rs | 117 +++++++++ src-tauri/src/lib.rs | 3 + .../voice/VoiceReferenceRecorder.vue | 181 +++++++++++++ .../workflow/Step05SubtitleMusic.vue | 48 ++++ .../workflow/VoiceCloneEditDialog.vue | 247 +++++++++++++++--- src/components/workflow/VoiceManageDialog.vue | 5 +- src/services/soundPromptStorage.js | 7 +- src/services/subtitleBgmGenerate.js | 66 +++-- src/services/talkingVideoGenerate.js | 3 + src/services/videoEditProcess.js | 2 + src/services/voiceCloneEnroll.js | 46 ++++ src/services/voiceReferenceAudio.js | 102 ++++++++ src/stores/workflow.js | 5 + src/utils/subtitlePreviewStyle.js | 37 ++- src/utils/voiceIdPrefix.js | 25 ++ 15 files changed, 808 insertions(+), 86 deletions(-) create mode 100644 src/components/voice/VoiceReferenceRecorder.vue create mode 100644 src/services/voiceCloneEnroll.js create mode 100644 src/services/voiceReferenceAudio.js create mode 100644 src/utils/voiceIdPrefix.js diff --git a/src-tauri/src/commands/fs_util.rs b/src-tauri/src/commands/fs_util.rs index e6877ac..092dfae 100644 --- a/src-tauri/src/commands/fs_util.rs +++ b/src-tauri/src/commands/fs_util.rs @@ -3,6 +3,8 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; use std::path::{Component, Path, PathBuf}; +use tauri::Manager; + use crate::ffmpeg; fn normalize_path(path: &str) -> PathBuf { @@ -28,6 +30,8 @@ fn is_allowed_local_media(path: &Path) -> bool { || lower.contains("aiclient-cover-previews") || lower.contains("video-material") || lower.contains("avatars") + || lower.contains("voice_clones") + || lower.contains("aiclient-voice-ref") { return true; } @@ -126,6 +130,119 @@ pub async fn copy_local_file(source_path: String, dest_path: String) -> Result<( Ok(()) } +/// 将 base64 写入受信任目录下的二进制文件(如录音 wav)。 +#[tauri::command] +pub async fn write_local_file_base64(path: String, base64_data: String) -> Result<(), String> { + let dest = normalize_path(path.trim()); + if dest.as_os_str().is_empty() { + return Err("路径为空".into()); + } + if !is_allowed_local_media(&dest) { + return Err("不允许写入该路径".into()); + } + if let Some(parent) = dest.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("创建目录失败: {e}"))?; + } + } + let bytes = STANDARD + .decode(base64_data.trim()) + .map_err(|e| format!("base64 解码失败: {e}"))?; + tokio::fs::write(&dest, &bytes) + .await + .map_err(|e| format!("保存文件失败: {e}"))?; + Ok(()) +} + +fn voice_clones_dir(app: &tauri::AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("无法获取应用数据目录: {e}"))? + .join("voice_clones"); + Ok(dir) +} + +/// 生成临时参考音频路径(录音/上传中转)。 +#[tauri::command] +pub async fn temp_voice_reference_path(ext: String) -> Result { + let ext = ext.trim().trim_start_matches('.'); + let ext = if ext.is_empty() { "webm" } 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 name = format!( + "ref_{}_{}.{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0), + rand_simple(), + ext + ); + Ok(dir.join(name).to_string_lossy().into_owned()) +} + +fn rand_simple() -> u32 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::time::SystemTime::now().hash(&mut h); + (h.finish() & 0xffff) as u32 +} + +/// 将参考音频复制到应用数据目录 `voice_clones/{voice_id}{ext}`。 +#[tauri::command] +pub async fn import_voice_clone_reference( + app: tauri::AppHandle, + source_path: String, + voice_id: String, +) -> Result { + let source = normalize_path(source_path.trim()); + if !source.is_file() { + return Err(format!("源文件不存在: {}", source.display())); + } + let allowed_source = is_allowed_local_media(&source) + || source + .to_string_lossy() + .to_lowercase() + .contains("voice_clones"); + if !allowed_source { + if let Ok(temp) = std::env::temp_dir().canonicalize() { + if let Ok(canonical) = source.canonicalize() { + if !canonical.starts_with(&temp) { + return Err("不允许导入该路径下的文件".into()); + } + } else { + return Err("不允许导入该路径下的文件".into()); + } + } else { + return Err("不允许导入该路径下的文件".into()); + } + } + + let id = voice_id.trim(); + if id.is_empty() { + return Err("voice_id 为空".into()); + } + let ext = source + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("wav"); + let ext = if ext.is_empty() { "wav" } else { ext }; + let dir = voice_clones_dir(&app)?; + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| format!("创建目录失败: {e}"))?; + let dest = dir.join(format!("{id}.{ext}")); + tokio::fs::copy(&source, &dest) + .await + .map_err(|e| format!("保存参考音频失败: {e}"))?; + Ok(dest.to_string_lossy().into_owned()) +} + /// 将文本写入用户指定路径(供 ASR 结果「另存为」)。 #[tauri::command] pub async fn write_local_text_file(path: String, content: String) -> Result<(), String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 734511a..e7eec6b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,9 +132,12 @@ pub fn run() { commands::nodejs::list_nodejs_scripts, commands::nodejs::stop_nodejs_script, commands::fs_util::read_local_file_base64, + commands::fs_util::write_local_file_base64, commands::fs_util::get_media_duration_seconds, commands::fs_util::copy_local_file, commands::fs_util::write_local_text_file, + commands::fs_util::temp_voice_reference_path, + commands::fs_util::import_voice_clone_reference, commands::fs_util::reveal_local_file_in_folder, commands::avatar::list_avatars, commands::avatar::insert_avatar, diff --git a/src/components/voice/VoiceReferenceRecorder.vue b/src/components/voice/VoiceReferenceRecorder.vue new file mode 100644 index 0000000..6be560f --- /dev/null +++ b/src/components/voice/VoiceReferenceRecorder.vue @@ -0,0 +1,181 @@ + + + diff --git a/src/components/workflow/Step05SubtitleMusic.vue b/src/components/workflow/Step05SubtitleMusic.vue index 4bdbb45..bf0f2ac 100644 --- a/src/components/workflow/Step05SubtitleMusic.vue +++ b/src/components/workflow/Step05SubtitleMusic.vue @@ -3,6 +3,7 @@ import { computed, ref } from "vue"; import { storeToRefs } from "pinia"; import { useWorkflowStore } from "../../stores/workflow.js"; import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue"; +import { revealLocalFileInFolder } from "../../services/fileExport.js"; const workflow = useWorkflowStore(); const { @@ -13,6 +14,8 @@ const { subtitleTemplateId, subtitleBgmGenerating, generatedVideoPath, + subtitlePreviewVideoPath, + subtitlePreviewVideoSrc, bgmPreviewSrc, } = storeToRefs(workflow); @@ -20,6 +23,9 @@ const feedback = ref({ severity: "", message: "" }); const templateDialogVisible = ref(false); const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value)); +const hasSubtitleVideoPreview = computed(() => + Boolean(subtitlePreviewVideoSrc.value), +); const canGenerate = computed( () => @@ -57,6 +63,21 @@ async function onPreviewBgm() { const result = await workflow.previewBgm(); if (result?.message) showFeedback(result); } + +async function onOpenVideoInExplorer() { + if (!subtitlePreviewVideoPath.value) { + feedback.value = { severity: "error", message: "暂无字幕预览视频" }; + return; + } + try { + await revealLocalFileInFolder(subtitlePreviewVideoPath.value); + } catch (err) { + feedback.value = { + severity: "error", + message: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/components/workflow/VoiceManageDialog.vue b/src/components/workflow/VoiceManageDialog.vue index 53e8e85..7f00e4c 100644 --- a/src/components/workflow/VoiceManageDialog.vue +++ b/src/components/workflow/VoiceManageDialog.vue @@ -11,6 +11,7 @@ import { deleteCloneVoice, } from "../../services/soundPromptStorage.js"; import { previewVoice } from "../../services/voicePreview.js"; +import { stripVoiceIdPrefix } from "../../utils/voiceIdPrefix.js"; import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue"; const props = defineProps({ @@ -140,10 +141,6 @@ function onDeleteClone(record) { loadCloneVoices(); } -function stripVoiceIdPrefix(id) { - return String(id || "").replace(/^cosyvoice-v3-flash-/, ""); -} - async function runPreview(opts) { previewError.value = ""; previewingKey.value = opts.listKey; diff --git a/src/services/soundPromptStorage.js b/src/services/soundPromptStorage.js index 9247384..364e868 100644 --- a/src/services/soundPromptStorage.js +++ b/src/services/soundPromptStorage.js @@ -27,12 +27,15 @@ function saveCloneVoices(list) { /** * @param {{ title: string, content?: SoundPromptRecord['content'] }} payload + * @param {string} [presetId] 可选,与参考音频文件名一致 * @returns {SoundPromptRecord} */ -export function addCloneVoice(payload) { +export function addCloneVoice(payload, presetId = null) { const list = listCloneVoices(); const record = { - id: `clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + id: + presetId || + `clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, title: String(payload.title || "").trim(), content: { url: payload.content?.url || "", diff --git a/src/services/subtitleBgmGenerate.js b/src/services/subtitleBgmGenerate.js index c32ee04..8aa8bc3 100644 --- a/src/services/subtitleBgmGenerate.js +++ b/src/services/subtitleBgmGenerate.js @@ -2,16 +2,18 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { ensureAppConfigReady } from "../config/videoPipeline.js"; -import { - buildFfmpegForceStyle, - getSubtitleTemplate, -} from "../config/subtitleTemplates.js"; +import { getSubtitleTemplate } from "../config/subtitleTemplates.js"; import { getSubtitleTemplateById } from "./subtitleTemplateCatalog.js"; -import { templateToFfmpegStyle } from "../utils/subtitlePreviewStyle.js"; -import { importGeneratedVideo } from "./videoDb.js"; +import { buildSubtitleBurnStyle } from "../utils/subtitlePreviewStyle.js"; import { localAudioToPlayableUrl } from "./localAudio.js"; import { localVideoToPlayableUrl } from "./localVideo.js"; +/** @param {ReturnType} store */ +export function clearSubtitlePreview(store) { + store.subtitlePreviewVideoPath = ""; + store.subtitlePreviewVideoSrc = ""; +} + const SUBTITLE_BGM_SCRIPT = "subtitle_bgm_generate.js"; /** @@ -32,12 +34,6 @@ export async function pickBgmFile() { return String(selected); } -function formatHistoryTime() { - const d = new Date(); - const p = (n) => String(n).padStart(2, "0"); - return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; -} - /** * @param {ReturnType} store */ @@ -63,16 +59,29 @@ export async function executeGenerateSubtitleAndBgm(store) { } const templateId = store.subtitleTemplateId || "template_system_11"; - let tpl = getSubtitleTemplate(templateId); const fullTemplate = store.subtitleTemplate?.id === templateId ? store.subtitleTemplate : await getSubtitleTemplateById(templateId); - const fromSystem = fullTemplate ? templateToFfmpegStyle(fullTemplate) : null; - if (fromSystem) tpl = fromSystem; - const subtitleForceStyle = buildFfmpegForceStyle(tpl); + let subtitleStyle = fullTemplate ? buildSubtitleBurnStyle(fullTemplate) : null; + if (!subtitleStyle) { + const fallback = getSubtitleTemplate(templateId); + subtitleStyle = { + fontName: fallback.fontName, + fontSize: fallback.fontSize, + fontColor: fallback.primaryColor, + outlineColor: fallback.outlineColor, + outlineWidth: fallback.outline, + backgroundColor: "transparent", + backgroundOpacity: 0, + position: "bottom", + fontSizeScale: 100, + maxCharsPerLine: 20, + }; + } store.subtitleBgmGenerating = true; + clearSubtitlePreview(store); try { const result = await invoke("run_nodejs_script", { @@ -82,7 +91,7 @@ export async function executeGenerateSubtitleAndBgm(store) { autoSubtitle: store.autoSubtitle, smartSubtitle: store.smartSubtitle, scriptContent: store.scriptContent || "", - subtitleForceStyle, + subtitleStyle, bgmEnabled: store.bgmEnabled, bgmPath: store.bgmPath || null, bgmVolume: store.bgmVolume, @@ -100,24 +109,11 @@ export async function executeGenerateSubtitleAndBgm(store) { store.subtitleSrtPath = result.srtPath; } - const baseName = - store.videoHistory.find((v) => v.id === store.currentVideoId)?.name || - "口播视频"; - const displayName = `${baseName} · 字幕BGM · ${formatHistoryTime()}`; - const record = await importGeneratedVideo( - result.videoPath, - displayName, - store.avatarSelect ?? null, - store.currentAudioId ?? null, - ); - const videoSrc = localVideoToPlayableUrl(record.filePath); - - store.generatedVideoPath = record.filePath; - store.generatedVideoSrc = videoSrc; - store.processedVideoPath = record.filePath; - store.processedVideoSrc = videoSrc; - store.currentVideoId = record.id; - await store.refreshVideoHistory(); + const previewPath = String(result.videoPath || "").trim(); + store.subtitlePreviewVideoPath = previewPath; + store.subtitlePreviewVideoSrc = previewPath + ? localVideoToPlayableUrl(previewPath) + : ""; return { ok: true, diff --git a/src/services/talkingVideoGenerate.js b/src/services/talkingVideoGenerate.js index 1eb349d..a6d77ca 100644 --- a/src/services/talkingVideoGenerate.js +++ b/src/services/talkingVideoGenerate.js @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { loadAppConfigMap } from "../config/videoPipeline.js"; import { useAvatarStore } from "../stores/avatar.js"; +import { clearSubtitlePreview } from "./subtitleBgmGenerate.js"; import { importGeneratedVideo } from "./videoDb.js"; import { localVideoToPlayableUrl } from "./localVideo.js"; import { getMediaDurationSeconds } from "./mediaDuration.js"; @@ -92,6 +93,7 @@ export async function executeGenerateTalkingVideo(store) { store.generatedVideoPath = record.filePath; store.generatedVideoSrc = videoSrc; store.currentVideoId = record.id; + clearSubtitlePreview(store); await store.refreshVideoHistory(); return { ok: true, message: "口播视频生成完成" }; @@ -184,4 +186,5 @@ export async function selectVideoHistory(store, videoId) { item.videoSrc || localVideoToPlayableUrl(item.filePath); store.processedVideoPath = ""; store.processedVideoSrc = ""; + clearSubtitlePreview(store); } diff --git a/src/services/videoEditProcess.js b/src/services/videoEditProcess.js index d0f2532..9e7777a 100644 --- a/src/services/videoEditProcess.js +++ b/src/services/videoEditProcess.js @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; +import { clearSubtitlePreview } from "./subtitleBgmGenerate.js"; import { importGeneratedVideo } from "./videoDb.js"; import { localVideoToPlayableUrl } from "./localVideo.js"; @@ -162,6 +163,7 @@ export async function executeAutoProcessVideo(store) { store.processedVideoPath = record.filePath; store.processedVideoSrc = videoSrc; store.currentVideoId = record.id; + clearSubtitlePreview(store); await store.refreshVideoHistory(); return { diff --git a/src/services/voiceCloneEnroll.js b/src/services/voiceCloneEnroll.js new file mode 100644 index 0000000..490278a --- /dev/null +++ b/src/services/voiceCloneEnroll.js @@ -0,0 +1,46 @@ +import { invoke } from "@tauri-apps/api/core"; + +import { ensureAppConfigReady } from "../config/videoPipeline.js"; +import { formatVoiceIdWithPrefix } from "../utils/voiceIdPrefix.js"; + +const VOICE_CLONE_ENROLL_SCRIPT = "voice_clone_enroll.js"; + +/** + * @param {{ name: string, audioPath: string, existingVoiceId?: string }} opts + */ +export async function enrollCloneVoice(opts) { + const name = String(opts.name || "").trim(); + const audioPath = String(opts.audioPath || "").trim(); + if (!name) return { ok: false, message: "请先输入音色名称" }; + if (!audioPath) return { ok: false, message: "请先录制或上传参考声音" }; + + const cfg = await ensureAppConfigReady(); + if (!cfg.ok) return { ok: false, message: cfg.message }; + + try { + const result = await invoke("run_nodejs_script", { + scriptName: VOICE_CLONE_ENROLL_SCRIPT, + params: { audioPath, name }, + }); + + if (!result?.success || !result?.voiceId) { + return { + ok: false, + message: String(result?.error || "复刻失败,请重试"), + }; + } + + const voiceId = formatVoiceIdWithPrefix( + result.voiceId, + opts.existingVoiceId || result.voiceId, + ); + return { + ok: true, + voiceId, + message: "复刻成功!音色 ID 已自动填入", + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err || "未知错误"); + return { ok: false, message: `调用复刻服务异常:${msg}` }; + } +} diff --git a/src/services/voiceReferenceAudio.js b/src/services/voiceReferenceAudio.js new file mode 100644 index 0000000..66ada93 --- /dev/null +++ b/src/services/voiceReferenceAudio.js @@ -0,0 +1,102 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { isTauri } from "@tauri-apps/api/core"; + +const REF_MIN_SEC = 6; +const REF_MAX_SEC = 20; +const ENROLL_MAX_SEC = 60; + +/** + * @param {string} filePath + */ +export async function getAudioDurationSeconds(filePath) { + if (!filePath || !isTauri()) return 0; + try { + return await invoke("get_media_duration_seconds", { path: filePath }); + } catch { + return 0; + } +} + +/** + * @param {number} durationSec + * @param {{ forEnroll?: boolean }} [opts] + */ +export function validateReferenceDuration(durationSec, opts = {}) { + const d = Number(durationSec) || 0; + const max = opts.forEnroll ? ENROLL_MAX_SEC : REF_MAX_SEC; + if (d < REF_MIN_SEC) { + return { + ok: false, + message: opts.forEnroll + ? `复刻音频时长需在 ${REF_MIN_SEC}~${ENROLL_MAX_SEC} 秒之间` + : `参考声音需要大于 ${REF_MIN_SEC} 秒小于 ${REF_MAX_SEC} 秒,保证声音清晰可见`, + }; + } + if (d > max) { + return { + ok: false, + message: opts.forEnroll + ? `复刻音频时长需在 ${REF_MIN_SEC}~${ENROLL_MAX_SEC} 秒之间` + : `参考声音需要大于 ${REF_MIN_SEC} 秒小于 ${REF_MAX_SEC} 秒,保证声音清晰可见`, + }; + } + return { ok: true, message: "" }; +} + +/** + * @returns {Promise} + */ +export async function pickReferenceAudioFile() { + const selected = await open({ + multiple: false, + filters: [ + { + name: "音频", + extensions: ["wav", "mp3", "m4a", "flac", "ogg", "webm"], + }, + ], + }); + if (selected == null) return null; + if (Array.isArray(selected)) return selected[0] || null; + return String(selected); +} + +/** + * 将临时录音/上传文件持久化到 app_data/voice_clones + * @param {string} sourcePath + * @param {string} voiceId + */ +export async function persistVoiceReference(sourcePath, voiceId) { + return invoke("import_voice_clone_reference", { + sourcePath, + voiceId, + }); +} + +/** + * @param {string} destPath + * @param {Blob} blob + */ +export async function writeBlobToPath(destPath, blob) { + const buf = await blob.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + const base64 = btoa(binary); + await invoke("write_local_file_base64", { + path: destPath, + base64Data: base64, + }); + return destPath; +} + +/** + * @param {string} [ext] + */ +export async function createTempReferencePath(ext = "webm") { + return invoke("temp_voice_reference_path", { ext }); +} diff --git a/src/stores/workflow.js b/src/stores/workflow.js index f767e7b..caf24bd 100644 --- a/src/stores/workflow.js +++ b/src/stores/workflow.js @@ -276,6 +276,11 @@ export const useWorkflowStore = defineStore("workflow", { subtitleSrtPath: "", + /** 步骤 05 字幕/BGM 预览(临时目录,不入库) */ + subtitlePreviewVideoPath: "", + + subtitlePreviewVideoSrc: "", + bgmPath: "", bgmPreviewSrc: "", diff --git a/src/utils/subtitlePreviewStyle.js b/src/utils/subtitlePreviewStyle.js index 94c6a5e..803ef08 100644 --- a/src/utils/subtitlePreviewStyle.js +++ b/src/utils/subtitlePreviewStyle.js @@ -253,14 +253,37 @@ export function resolveSubtitlePreviewImage(path) { * @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template */ export function templateToFfmpegStyle(template) { - const style = template?.config?.subtitleStyle; - if (!style) return null; + const burn = buildSubtitleBurnStyle(template); + if (!burn) return null; return { - fontName: String(style.fontName || "Microsoft YaHei"), - fontSize: Number(style.fontSize) || 24, - primaryColor: String(style.fontColor || "#FFFFFF"), - outlineColor: String(style.outlineColor || "#000000"), - outline: Number(style.outlineWidth) || 2, + fontName: burn.fontName, + fontSize: burn.fontSize, + primaryColor: burn.fontColor, + outlineColor: burn.outlineColor, + outline: burn.outlineWidth, marginV: 40, }; } + +/** + * 烧录字幕参数(对齐 Electron ZimuShengcheng:PlayRes=视频尺寸、每行字数限制) + * @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem | null | undefined} template + */ +export function buildSubtitleBurnStyle(template) { + const style = template?.config?.subtitleStyle; + if (!style) return null; + const outlineWidth = Number(style.outlineWidth); + return { + fontName: String(style.fontName || "Microsoft YaHei"), + fontSize: Number(style.fontSize) || 35, + fontColor: String(style.fontColor || "#FFFFFF"), + outlineColor: String(style.outlineColor || "#000000"), + outlineWidth: Number.isFinite(outlineWidth) ? outlineWidth : 2, + backgroundColor: String(style.backgroundColor || "transparent"), + backgroundOpacity: Number(style.backgroundOpacity) ?? 0, + position: + template?.config?.subtitlePosition || style.position || "bottom", + fontSizeScale: Number(template?.config?.subtitleFontSizeScale) || 100, + maxCharsPerLine: 20, + }; +} diff --git a/src/utils/voiceIdPrefix.js b/src/utils/voiceIdPrefix.js new file mode 100644 index 0000000..0de954c --- /dev/null +++ b/src/utils/voiceIdPrefix.js @@ -0,0 +1,25 @@ +/** 克隆音色 ID 前缀(对齐 Electron SoundPromptEditDialog) */ +const VOICE_ID_PREFIXES = ["qwen-tts-vc-", "cosyvoice-v3-flash-"]; + +/** + * @param {string} voiceId + */ +export function stripVoiceIdPrefix(voiceId) { + const raw = String(voiceId || ""); + const prefix = VOICE_ID_PREFIXES.find((p) => raw.startsWith(p)); + return prefix ? raw.slice(prefix.length) : raw; +} + +/** + * @param {string} value + * @param {string} [existingFullId] + */ +export function formatVoiceIdWithPrefix(value, existingFullId = "") { + const raw = String(value || "").trim(); + if (!raw) return ""; + if (VOICE_ID_PREFIXES.some((p) => raw.startsWith(p))) return raw; + const existing = String(existingFullId || ""); + const prefix = + VOICE_ID_PREFIXES.find((p) => existing.startsWith(p)) || "qwen-tts-vc-"; + return `${prefix}${raw}`; +}