11
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
use crate::ffmpeg;
|
use crate::ffmpeg;
|
||||||
|
|
||||||
fn normalize_path(path: &str) -> PathBuf {
|
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("aiclient-cover-previews")
|
||||||
|| lower.contains("video-material")
|
|| lower.contains("video-material")
|
||||||
|| lower.contains("avatars")
|
|| lower.contains("avatars")
|
||||||
|
|| lower.contains("voice_clones")
|
||||||
|
|| lower.contains("aiclient-voice-ref")
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -126,6 +130,119 @@ pub async fn copy_local_file(source_path: String, dest_path: String) -> Result<(
|
|||||||
Ok(())
|
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<PathBuf, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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 结果「另存为」)。
|
/// 将文本写入用户指定路径(供 ASR 结果「另存为」)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn write_local_text_file(path: String, content: String) -> Result<(), String> {
|
pub async fn write_local_text_file(path: String, content: String) -> Result<(), String> {
|
||||||
|
|||||||
@@ -132,9 +132,12 @@ pub fn run() {
|
|||||||
commands::nodejs::list_nodejs_scripts,
|
commands::nodejs::list_nodejs_scripts,
|
||||||
commands::nodejs::stop_nodejs_script,
|
commands::nodejs::stop_nodejs_script,
|
||||||
commands::fs_util::read_local_file_base64,
|
commands::fs_util::read_local_file_base64,
|
||||||
|
commands::fs_util::write_local_file_base64,
|
||||||
commands::fs_util::get_media_duration_seconds,
|
commands::fs_util::get_media_duration_seconds,
|
||||||
commands::fs_util::copy_local_file,
|
commands::fs_util::copy_local_file,
|
||||||
commands::fs_util::write_local_text_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::fs_util::reveal_local_file_in_folder,
|
||||||
commands::avatar::list_avatars,
|
commands::avatar::list_avatars,
|
||||||
commands::avatar::insert_avatar,
|
commands::avatar::insert_avatar,
|
||||||
|
|||||||
181
src/components/voice/VoiceReferenceRecorder.vue
Normal file
181
src/components/voice/VoiceReferenceRecorder.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||||
|
import { localAudioToPlayableUrl } from "../../services/localAudio.js";
|
||||||
|
import {
|
||||||
|
createTempReferencePath,
|
||||||
|
getAudioDurationSeconds,
|
||||||
|
pickReferenceAudioFile,
|
||||||
|
writeBlobToPath,
|
||||||
|
} from "../../services/voiceReferenceAudio.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const referencePath = ref(props.modelValue || "");
|
||||||
|
const previewSrc = ref("");
|
||||||
|
const recording = ref(false);
|
||||||
|
const recordError = ref("");
|
||||||
|
const durationSec = ref(0);
|
||||||
|
|
||||||
|
let mediaStream = null;
|
||||||
|
let mediaRecorder = null;
|
||||||
|
let recordChunks = [];
|
||||||
|
|
||||||
|
const durationLabel = computed(() => {
|
||||||
|
if (!durationSec.value) return "";
|
||||||
|
return `${durationSec.value.toFixed(1)} 秒`;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (path) => {
|
||||||
|
referencePath.value = path || "";
|
||||||
|
await refreshPreview();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopRecording();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshPreview() {
|
||||||
|
const p = referencePath.value;
|
||||||
|
if (!p) {
|
||||||
|
previewSrc.value = "";
|
||||||
|
durationSec.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
previewSrc.value = await localAudioToPlayableUrl(p);
|
||||||
|
durationSec.value = await getAudioDurationSeconds(p);
|
||||||
|
} catch {
|
||||||
|
previewSrc.value = "";
|
||||||
|
durationSec.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPath(path) {
|
||||||
|
referencePath.value = path || "";
|
||||||
|
emit("update:modelValue", referencePath.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickFile() {
|
||||||
|
recordError.value = "";
|
||||||
|
const path = await pickReferenceAudioFile();
|
||||||
|
if (!path) return;
|
||||||
|
setPath(path);
|
||||||
|
await refreshPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRecording() {
|
||||||
|
recordError.value = "";
|
||||||
|
try {
|
||||||
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
recordChunks = [];
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||||
|
? "audio/webm;codecs=opus"
|
||||||
|
: "audio/webm";
|
||||||
|
mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
if (e.data?.size) recordChunks.push(e.data);
|
||||||
|
};
|
||||||
|
mediaRecorder.onstop = async () => {
|
||||||
|
try {
|
||||||
|
const blob = new Blob(recordChunks, { type: mimeType });
|
||||||
|
const ext = mimeType.includes("webm") ? "webm" : "wav";
|
||||||
|
const tempPath = await createTempReferencePath(ext);
|
||||||
|
await writeBlobToPath(tempPath, blob);
|
||||||
|
setPath(tempPath);
|
||||||
|
await refreshPreview();
|
||||||
|
} catch (err) {
|
||||||
|
recordError.value =
|
||||||
|
err instanceof Error ? err.message : "保存录音失败";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaRecorder.start();
|
||||||
|
recording.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
recordError.value =
|
||||||
|
err instanceof Error ? err.message : "无法访问麦克风,请检查权限";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (mediaRecorder && mediaRecorder.state !== "inactive") {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
if (mediaStream) {
|
||||||
|
mediaStream.getTracks().forEach((t) => t.stop());
|
||||||
|
mediaStream = null;
|
||||||
|
}
|
||||||
|
mediaRecorder = null;
|
||||||
|
recording.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecord() {
|
||||||
|
if (recording.value) stopRecording();
|
||||||
|
else startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function exportReferencePath() {
|
||||||
|
return referencePath.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
exportReferencePath,
|
||||||
|
getDurationSeconds: () => durationSec.value,
|
||||||
|
refreshPreview,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="voice-reference-recorder space-y-3">
|
||||||
|
<Message severity="info" :closable="false" class="text-sm">
|
||||||
|
参考声音控制在 6~20s,保证声音清晰可见
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-blue-500/40 bg-slate-900/40 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
:label="recording ? '停止录音' : '开始录音'"
|
||||||
|
size="small"
|
||||||
|
:severity="recording ? 'danger' : 'secondary'"
|
||||||
|
:icon="recording ? 'pi pi-stop-circle' : 'pi pi-circle'"
|
||||||
|
@click="toggleRecord"
|
||||||
|
/>
|
||||||
|
<span v-if="durationLabel" class="text-xs text-slate-400">
|
||||||
|
时长 {{ durationLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previewSrc" class="mt-3">
|
||||||
|
<audio :src="previewSrc" controls class="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 text-sm text-slate-400">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<i class="pi pi-info-circle text-xs" />
|
||||||
|
支持 wav/mp3 格式
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
label="选择声音文件"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-upload"
|
||||||
|
@click="onPickFile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="recordError" class="text-xs text-red-400">{{ recordError }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import { computed, ref } from "vue";
|
|||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue";
|
import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue";
|
||||||
|
import { revealLocalFileInFolder } from "../../services/fileExport.js";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
const {
|
const {
|
||||||
@@ -13,6 +14,8 @@ const {
|
|||||||
subtitleTemplateId,
|
subtitleTemplateId,
|
||||||
subtitleBgmGenerating,
|
subtitleBgmGenerating,
|
||||||
generatedVideoPath,
|
generatedVideoPath,
|
||||||
|
subtitlePreviewVideoPath,
|
||||||
|
subtitlePreviewVideoSrc,
|
||||||
bgmPreviewSrc,
|
bgmPreviewSrc,
|
||||||
} = storeToRefs(workflow);
|
} = storeToRefs(workflow);
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ const feedback = ref({ severity: "", message: "" });
|
|||||||
const templateDialogVisible = ref(false);
|
const templateDialogVisible = ref(false);
|
||||||
|
|
||||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||||
|
const hasSubtitleVideoPreview = computed(() =>
|
||||||
|
Boolean(subtitlePreviewVideoSrc.value),
|
||||||
|
);
|
||||||
|
|
||||||
const canGenerate = computed(
|
const canGenerate = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -57,6 +63,21 @@ async function onPreviewBgm() {
|
|||||||
const result = await workflow.previewBgm();
|
const result = await workflow.previewBgm();
|
||||||
if (result?.message) showFeedback(result);
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -154,6 +175,33 @@ async function onPreviewBgm() {
|
|||||||
@click="onGenerate"
|
@click="onGenerate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="dashboard-preview-box mt-3">
|
||||||
|
<div class="mb-2 flex items-center gap-0.5">
|
||||||
|
<span class="text-xs text-slate-400">字幕视频预览</span>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-folder-open"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
:disabled="!subtitlePreviewVideoPath"
|
||||||
|
aria-label="在资源管理器中打开"
|
||||||
|
@click="onOpenVideoInExplorer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-aspect-video">
|
||||||
|
<video
|
||||||
|
v-if="hasSubtitleVideoPreview"
|
||||||
|
:key="subtitlePreviewVideoSrc"
|
||||||
|
:src="subtitlePreviewVideoSrc"
|
||||||
|
controls
|
||||||
|
class="h-full w-full rounded object-contain"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-xs text-gray-400">
|
||||||
|
{{ hasSourceVideo ? "生成字幕后将在此预览" : "请先在步骤 02 生成口播视频" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SubtitleTemplateDialog
|
<SubtitleTemplateDialog
|
||||||
v-model:visible="templateDialogVisible"
|
v-model:visible="templateDialogVisible"
|
||||||
v-model:selected-id="subtitleTemplateId"
|
v-model:selected-id="subtitleTemplateId"
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import {
|
|||||||
updateCloneVoice,
|
updateCloneVoice,
|
||||||
cloneVoiceTitleExists,
|
cloneVoiceTitleExists,
|
||||||
} from "../../services/soundPromptStorage.js";
|
} from "../../services/soundPromptStorage.js";
|
||||||
|
import {
|
||||||
|
persistVoiceReference,
|
||||||
|
validateReferenceDuration,
|
||||||
|
} from "../../services/voiceReferenceAudio.js";
|
||||||
|
import { enrollCloneVoice } from "../../services/voiceCloneEnroll.js";
|
||||||
|
import {
|
||||||
|
stripVoiceIdPrefix,
|
||||||
|
formatVoiceIdWithPrefix,
|
||||||
|
} from "../../utils/voiceIdPrefix.js";
|
||||||
|
import VoiceReferenceRecorder from "../voice/VoiceReferenceRecorder.vue";
|
||||||
|
|
||||||
const emit = defineEmits(["saved"]);
|
const emit = defineEmits(["saved"]);
|
||||||
|
|
||||||
@@ -12,17 +22,28 @@ const visible = ref(false);
|
|||||||
const editingId = ref(null);
|
const editingId = ref(null);
|
||||||
const name = ref("");
|
const name = ref("");
|
||||||
const promptText = ref("");
|
const promptText = ref("");
|
||||||
const aliyunVoiceId = ref("");
|
const referencePath = ref("");
|
||||||
|
const voiceIdInput = ref("");
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const enrolling = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
|
const recorderRef = ref(null);
|
||||||
|
|
||||||
const isEdit = computed(() => Boolean(editingId.value));
|
const isEdit = computed(() => Boolean(editingId.value));
|
||||||
|
|
||||||
|
const voiceIdModel = computed({
|
||||||
|
get: () => voiceIdInput.value,
|
||||||
|
set: (v) => {
|
||||||
|
voiceIdInput.value = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
name.value = "";
|
name.value = "";
|
||||||
promptText.value = "";
|
promptText.value = "";
|
||||||
aliyunVoiceId.value = "";
|
referencePath.value = "";
|
||||||
|
voiceIdInput.value = "";
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +53,14 @@ function openAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string } }} record
|
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string, url?: string } }} record
|
||||||
*/
|
*/
|
||||||
function openEdit(record) {
|
function openEdit(record) {
|
||||||
editingId.value = record.id;
|
editingId.value = record.id;
|
||||||
name.value = record.title || "";
|
name.value = record.title || "";
|
||||||
promptText.value = record.content?.promptText || "";
|
promptText.value = record.content?.promptText || "";
|
||||||
aliyunVoiceId.value = record.content?.aliyunVoiceId || "";
|
referencePath.value = record.content?.url || "";
|
||||||
|
voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || "");
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
}
|
}
|
||||||
@@ -47,11 +69,86 @@ function onCancel() {
|
|||||||
visible.value = false;
|
visible.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSave() {
|
async function resolveReferenceForSave() {
|
||||||
|
const path =
|
||||||
|
referencePath.value ||
|
||||||
|
(await recorderRef.value?.exportReferencePath?.()) ||
|
||||||
|
"";
|
||||||
|
if (!path) {
|
||||||
|
return { ok: false, message: "请录制声音或选择声音文件" };
|
||||||
|
}
|
||||||
|
const duration = await recorderRef.value?.getDurationSeconds?.();
|
||||||
|
const check = validateReferenceDuration(duration);
|
||||||
|
if (!check.ok) return check;
|
||||||
|
|
||||||
|
const storageId =
|
||||||
|
editingId.value ||
|
||||||
|
`clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
try {
|
||||||
|
const persisted = await persistVoiceReference(path, storageId);
|
||||||
|
return { ok: true, path: persisted, storageId };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: err instanceof Error ? err.message : "保存参考音频失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEnroll() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
const title = name.value.trim();
|
const title = name.value.trim();
|
||||||
if (!title) {
|
if (!title) {
|
||||||
errorMessage.value = "请输入音色名称";
|
errorMessage.value = "请先输入音色名称";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path =
|
||||||
|
referencePath.value ||
|
||||||
|
(await recorderRef.value?.exportReferencePath?.()) ||
|
||||||
|
"";
|
||||||
|
if (!path) {
|
||||||
|
errorMessage.value = "请先录制或上传音频";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = await recorderRef.value?.getDurationSeconds?.();
|
||||||
|
const check = validateReferenceDuration(duration, { forEnroll: true });
|
||||||
|
if (!check.ok) {
|
||||||
|
errorMessage.value = check.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enrolling.value = true;
|
||||||
|
try {
|
||||||
|
const result = await enrollCloneVoice({
|
||||||
|
name: title,
|
||||||
|
audioPath: path,
|
||||||
|
existingVoiceId: voiceIdInput.value
|
||||||
|
? formatVoiceIdWithPrefix(voiceIdInput.value)
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
errorMessage.value = result.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
voiceIdInput.value = stripVoiceIdPrefix(result.voiceId);
|
||||||
|
errorMessage.value = "";
|
||||||
|
// 成功提示由 message 展示
|
||||||
|
successFlash.value = result.message;
|
||||||
|
} finally {
|
||||||
|
enrolling.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successFlash = ref("");
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
successFlash.value = "";
|
||||||
|
const title = name.value.trim();
|
||||||
|
if (!title) {
|
||||||
|
errorMessage.value = "请输入名称";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cloneVoiceTitleExists(title, editingId.value)) {
|
if (cloneVoiceTitleExists(title, editingId.value)) {
|
||||||
@@ -61,19 +158,32 @@ async function onSave() {
|
|||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
const refResult = await resolveReferenceForSave();
|
||||||
|
if (!refResult.ok) {
|
||||||
|
errorMessage.value = refResult.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliyunVoiceId = formatVoiceIdWithPrefix(
|
||||||
|
voiceIdInput.value.trim(),
|
||||||
|
voiceIdInput.value,
|
||||||
|
);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title,
|
title,
|
||||||
content: {
|
content: {
|
||||||
promptText: promptText.value.trim(),
|
promptText: promptText.value.trim(),
|
||||||
aliyunVoiceId: aliyunVoiceId.value.trim(),
|
aliyunVoiceId,
|
||||||
url: "",
|
url: refResult.path,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
updateCloneVoice(editingId.value, payload);
|
updateCloneVoice(editingId.value, payload);
|
||||||
} else {
|
} else {
|
||||||
addCloneVoice(payload);
|
addCloneVoice(payload, refResult.storageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
emit("saved");
|
emit("saved");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -92,42 +202,103 @@ defineExpose({ openAdd, openEdit });
|
|||||||
v-model:visible="visible"
|
v-model:visible="visible"
|
||||||
modal
|
modal
|
||||||
:header="isEdit ? '编辑音色' : '添加音色'"
|
:header="isEdit ? '编辑音色' : '添加音色'"
|
||||||
:style="{ width: '520px' }"
|
:style="{ width: '800px' }"
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
class="voice-clone-edit-dialog"
|
class="voice-clone-edit-dialog"
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="max-h-[60vh] overflow-y-auto">
|
||||||
<div>
|
<div class="flex flex-col gap-4 lg:flex-row">
|
||||||
<div class="dashboard-field-label mb-1">名称 <span class="text-red-400">*</span></div>
|
<div class="w-full shrink-0 space-y-4 lg:w-1/2 lg:pr-4">
|
||||||
<InputText v-model="name" class="w-full" size="small" placeholder="例如:我的克隆音色" />
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">
|
||||||
|
名称 <span class="text-red-400">*</span>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-model="name"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
placeholder="例如:我的克隆音色"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">
|
||||||
|
参考声音 <span class="text-red-400">*</span>
|
||||||
|
</div>
|
||||||
|
<VoiceReferenceRecorder
|
||||||
|
ref="recorderRef"
|
||||||
|
v-model="referencePath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">参考文字</div>
|
||||||
|
<InputText
|
||||||
|
v-model="promptText"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
部分模型需要使用,可选填写参考声音的文字内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">音色 ID(可选)</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<InputText
|
||||||
|
v-model="voiceIdModel"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
size="small"
|
||||||
|
placeholder="1111-voice-202603232"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="一键复刻"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
icon="pi pi-bolt"
|
||||||
|
:loading="enrolling"
|
||||||
|
:disabled="enrolling || saving"
|
||||||
|
@click="onEnroll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
仅限复刻音色。如果没有复刻 ID,请留空(将使用默认音色)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-lg font-bold text-slate-100">音色说明</div>
|
||||||
|
<div
|
||||||
|
class="mt-2 rounded-lg bg-slate-800/60 p-3 text-xs leading-6 text-slate-300"
|
||||||
|
>
|
||||||
|
<div>1. 请在安静的环境下进行录音,避免噪音干扰</div>
|
||||||
|
<div>2. 请使用标准普通话,吐字清晰,语速适当</div>
|
||||||
|
<div>3. 录音时长控制在 6~20秒 最佳,最多不超过 20 秒</div>
|
||||||
|
<div>4. 录制完成后先试听看是否达到要求再提交</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div class="dashboard-field-label mb-1">参考文字</div>
|
<p
|
||||||
<InputText
|
v-if="successFlash"
|
||||||
v-model="promptText"
|
class="mt-3 text-sm text-emerald-400"
|
||||||
class="w-full"
|
>
|
||||||
size="small"
|
{{ successFlash }}
|
||||||
placeholder="可选,部分模型需要参考文字"
|
</p>
|
||||||
/>
|
<p
|
||||||
<p class="mt-1 text-xs text-slate-500">部分模型需要使用,可选填写参考声音的文字内容</p>
|
v-if="errorMessage"
|
||||||
</div>
|
class="mt-3 text-sm text-red-400"
|
||||||
<div>
|
>
|
||||||
<div class="dashboard-field-label mb-1">音色 ID(可选)</div>
|
{{ errorMessage }}
|
||||||
<InputText
|
</p>
|
||||||
v-model="aliyunVoiceId"
|
|
||||||
class="w-full"
|
|
||||||
size="small"
|
|
||||||
placeholder="复刻成功后填入,留空则使用默认音色"
|
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
仅限复刻音色。参考音频录制与一键复刻功能后续版本支持。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="取消" severity="secondary" @click="onCancel" />
|
<Button label="取消" severity="secondary" @click="onCancel" />
|
||||||
<Button label="保存" :loading="saving" @click="onSave" />
|
<Button label="保存" :loading="saving" :disabled="enrolling" @click="onSave" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
deleteCloneVoice,
|
deleteCloneVoice,
|
||||||
} from "../../services/soundPromptStorage.js";
|
} from "../../services/soundPromptStorage.js";
|
||||||
import { previewVoice } from "../../services/voicePreview.js";
|
import { previewVoice } from "../../services/voicePreview.js";
|
||||||
|
import { stripVoiceIdPrefix } from "../../utils/voiceIdPrefix.js";
|
||||||
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -140,10 +141,6 @@ function onDeleteClone(record) {
|
|||||||
loadCloneVoices();
|
loadCloneVoices();
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripVoiceIdPrefix(id) {
|
|
||||||
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runPreview(opts) {
|
async function runPreview(opts) {
|
||||||
previewError.value = "";
|
previewError.value = "";
|
||||||
previewingKey.value = opts.listKey;
|
previewingKey.value = opts.listKey;
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ function saveCloneVoices(list) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ title: string, content?: SoundPromptRecord['content'] }} payload
|
* @param {{ title: string, content?: SoundPromptRecord['content'] }} payload
|
||||||
|
* @param {string} [presetId] 可选,与参考音频文件名一致
|
||||||
* @returns {SoundPromptRecord}
|
* @returns {SoundPromptRecord}
|
||||||
*/
|
*/
|
||||||
export function addCloneVoice(payload) {
|
export function addCloneVoice(payload, presetId = null) {
|
||||||
const list = listCloneVoices();
|
const list = listCloneVoices();
|
||||||
const record = {
|
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(),
|
title: String(payload.title || "").trim(),
|
||||||
content: {
|
content: {
|
||||||
url: payload.content?.url || "",
|
url: payload.content?.url || "",
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||||
import {
|
import { getSubtitleTemplate } from "../config/subtitleTemplates.js";
|
||||||
buildFfmpegForceStyle,
|
|
||||||
getSubtitleTemplate,
|
|
||||||
} from "../config/subtitleTemplates.js";
|
|
||||||
import { getSubtitleTemplateById } from "./subtitleTemplateCatalog.js";
|
import { getSubtitleTemplateById } from "./subtitleTemplateCatalog.js";
|
||||||
import { templateToFfmpegStyle } from "../utils/subtitlePreviewStyle.js";
|
import { buildSubtitleBurnStyle } from "../utils/subtitlePreviewStyle.js";
|
||||||
import { importGeneratedVideo } from "./videoDb.js";
|
|
||||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||||
|
|
||||||
|
/** @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store */
|
||||||
|
export function clearSubtitlePreview(store) {
|
||||||
|
store.subtitlePreviewVideoPath = "";
|
||||||
|
store.subtitlePreviewVideoSrc = "";
|
||||||
|
}
|
||||||
|
|
||||||
const SUBTITLE_BGM_SCRIPT = "subtitle_bgm_generate.js";
|
const SUBTITLE_BGM_SCRIPT = "subtitle_bgm_generate.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,12 +34,6 @@ export async function pickBgmFile() {
|
|||||||
return String(selected);
|
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<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||||
*/
|
*/
|
||||||
@@ -63,16 +59,29 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const templateId = store.subtitleTemplateId || "template_system_11";
|
const templateId = store.subtitleTemplateId || "template_system_11";
|
||||||
let tpl = getSubtitleTemplate(templateId);
|
|
||||||
const fullTemplate =
|
const fullTemplate =
|
||||||
store.subtitleTemplate?.id === templateId
|
store.subtitleTemplate?.id === templateId
|
||||||
? store.subtitleTemplate
|
? store.subtitleTemplate
|
||||||
: await getSubtitleTemplateById(templateId);
|
: await getSubtitleTemplateById(templateId);
|
||||||
const fromSystem = fullTemplate ? templateToFfmpegStyle(fullTemplate) : null;
|
let subtitleStyle = fullTemplate ? buildSubtitleBurnStyle(fullTemplate) : null;
|
||||||
if (fromSystem) tpl = fromSystem;
|
if (!subtitleStyle) {
|
||||||
const subtitleForceStyle = buildFfmpegForceStyle(tpl);
|
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;
|
store.subtitleBgmGenerating = true;
|
||||||
|
clearSubtitlePreview(store);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke("run_nodejs_script", {
|
const result = await invoke("run_nodejs_script", {
|
||||||
@@ -82,7 +91,7 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
|||||||
autoSubtitle: store.autoSubtitle,
|
autoSubtitle: store.autoSubtitle,
|
||||||
smartSubtitle: store.smartSubtitle,
|
smartSubtitle: store.smartSubtitle,
|
||||||
scriptContent: store.scriptContent || "",
|
scriptContent: store.scriptContent || "",
|
||||||
subtitleForceStyle,
|
subtitleStyle,
|
||||||
bgmEnabled: store.bgmEnabled,
|
bgmEnabled: store.bgmEnabled,
|
||||||
bgmPath: store.bgmPath || null,
|
bgmPath: store.bgmPath || null,
|
||||||
bgmVolume: store.bgmVolume,
|
bgmVolume: store.bgmVolume,
|
||||||
@@ -100,24 +109,11 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
|||||||
store.subtitleSrtPath = result.srtPath;
|
store.subtitleSrtPath = result.srtPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseName =
|
const previewPath = String(result.videoPath || "").trim();
|
||||||
store.videoHistory.find((v) => v.id === store.currentVideoId)?.name ||
|
store.subtitlePreviewVideoPath = previewPath;
|
||||||
"口播视频";
|
store.subtitlePreviewVideoSrc = previewPath
|
||||||
const displayName = `${baseName} · 字幕BGM · ${formatHistoryTime()}`;
|
? localVideoToPlayableUrl(previewPath)
|
||||||
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();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
import { loadAppConfigMap } from "../config/videoPipeline.js";
|
import { loadAppConfigMap } from "../config/videoPipeline.js";
|
||||||
import { useAvatarStore } from "../stores/avatar.js";
|
import { useAvatarStore } from "../stores/avatar.js";
|
||||||
|
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
||||||
import { importGeneratedVideo } from "./videoDb.js";
|
import { importGeneratedVideo } from "./videoDb.js";
|
||||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||||
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
||||||
@@ -92,6 +93,7 @@ export async function executeGenerateTalkingVideo(store) {
|
|||||||
store.generatedVideoPath = record.filePath;
|
store.generatedVideoPath = record.filePath;
|
||||||
store.generatedVideoSrc = videoSrc;
|
store.generatedVideoSrc = videoSrc;
|
||||||
store.currentVideoId = record.id;
|
store.currentVideoId = record.id;
|
||||||
|
clearSubtitlePreview(store);
|
||||||
await store.refreshVideoHistory();
|
await store.refreshVideoHistory();
|
||||||
|
|
||||||
return { ok: true, message: "口播视频生成完成" };
|
return { ok: true, message: "口播视频生成完成" };
|
||||||
@@ -184,4 +186,5 @@ export async function selectVideoHistory(store, videoId) {
|
|||||||
item.videoSrc || localVideoToPlayableUrl(item.filePath);
|
item.videoSrc || localVideoToPlayableUrl(item.filePath);
|
||||||
store.processedVideoPath = "";
|
store.processedVideoPath = "";
|
||||||
store.processedVideoSrc = "";
|
store.processedVideoSrc = "";
|
||||||
|
clearSubtitlePreview(store);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
|
import { clearSubtitlePreview } from "./subtitleBgmGenerate.js";
|
||||||
import { importGeneratedVideo } from "./videoDb.js";
|
import { importGeneratedVideo } from "./videoDb.js";
|
||||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ export async function executeAutoProcessVideo(store) {
|
|||||||
store.processedVideoPath = record.filePath;
|
store.processedVideoPath = record.filePath;
|
||||||
store.processedVideoSrc = videoSrc;
|
store.processedVideoSrc = videoSrc;
|
||||||
store.currentVideoId = record.id;
|
store.currentVideoId = record.id;
|
||||||
|
clearSubtitlePreview(store);
|
||||||
await store.refreshVideoHistory();
|
await store.refreshVideoHistory();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
46
src/services/voiceCloneEnroll.js
Normal file
46
src/services/voiceCloneEnroll.js
Normal file
@@ -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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/services/voiceReferenceAudio.js
Normal file
102
src/services/voiceReferenceAudio.js
Normal file
@@ -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<string | null>}
|
||||||
|
*/
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -276,6 +276,11 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
subtitleSrtPath: "",
|
subtitleSrtPath: "",
|
||||||
|
|
||||||
|
/** 步骤 05 字幕/BGM 预览(临时目录,不入库) */
|
||||||
|
subtitlePreviewVideoPath: "",
|
||||||
|
|
||||||
|
subtitlePreviewVideoSrc: "",
|
||||||
|
|
||||||
bgmPath: "",
|
bgmPath: "",
|
||||||
|
|
||||||
bgmPreviewSrc: "",
|
bgmPreviewSrc: "",
|
||||||
|
|||||||
@@ -253,14 +253,37 @@ export function resolveSubtitlePreviewImage(path) {
|
|||||||
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
|
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
|
||||||
*/
|
*/
|
||||||
export function templateToFfmpegStyle(template) {
|
export function templateToFfmpegStyle(template) {
|
||||||
const style = template?.config?.subtitleStyle;
|
const burn = buildSubtitleBurnStyle(template);
|
||||||
if (!style) return null;
|
if (!burn) return null;
|
||||||
return {
|
return {
|
||||||
fontName: String(style.fontName || "Microsoft YaHei"),
|
fontName: burn.fontName,
|
||||||
fontSize: Number(style.fontSize) || 24,
|
fontSize: burn.fontSize,
|
||||||
primaryColor: String(style.fontColor || "#FFFFFF"),
|
primaryColor: burn.fontColor,
|
||||||
outlineColor: String(style.outlineColor || "#000000"),
|
outlineColor: burn.outlineColor,
|
||||||
outline: Number(style.outlineWidth) || 2,
|
outline: burn.outlineWidth,
|
||||||
marginV: 40,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
25
src/utils/voiceIdPrefix.js
Normal file
25
src/utils/voiceIdPrefix.js
Normal file
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user