This commit is contained in:
949036910@qq.com
2026-05-29 23:16:42 +08:00
parent 595820c24a
commit 30e019d345
15 changed files with 808 additions and 86 deletions

View File

@@ -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<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 结果「另存为」)。
#[tauri::command]
pub async fn write_local_text_file(path: String, content: String) -> Result<(), String> {

View File

@@ -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,

View 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">
参考声音控制在 620s保证声音清晰可见
</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>

View File

@@ -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),
};
}
}
</script>
<template>
@@ -154,6 +175,33 @@ async function onPreviewBgm() {
@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
v-model:visible="templateDialogVisible"
v-model:selected-id="subtitleTemplateId"

View File

@@ -5,6 +5,16 @@ import {
updateCloneVoice,
cloneVoiceTitleExists,
} 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"]);
@@ -12,17 +22,28 @@ const visible = ref(false);
const editingId = ref(null);
const name = ref("");
const promptText = ref("");
const aliyunVoiceId = ref("");
const referencePath = ref("");
const voiceIdInput = ref("");
const saving = ref(false);
const enrolling = ref(false);
const errorMessage = ref("");
const recorderRef = ref(null);
const isEdit = computed(() => Boolean(editingId.value));
const voiceIdModel = computed({
get: () => voiceIdInput.value,
set: (v) => {
voiceIdInput.value = v;
},
});
function resetForm() {
editingId.value = null;
name.value = "";
promptText.value = "";
aliyunVoiceId.value = "";
referencePath.value = "";
voiceIdInput.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) {
editingId.value = record.id;
name.value = record.title || "";
promptText.value = record.content?.promptText || "";
aliyunVoiceId.value = record.content?.aliyunVoiceId || "";
referencePath.value = record.content?.url || "";
voiceIdInput.value = stripVoiceIdPrefix(record.content?.aliyunVoiceId || "");
errorMessage.value = "";
visible.value = true;
}
@@ -47,11 +69,86 @@ function onCancel() {
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 = "";
const title = name.value.trim();
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;
}
if (cloneVoiceTitleExists(title, editingId.value)) {
@@ -61,19 +158,32 @@ async function onSave() {
saving.value = true;
try {
const refResult = await resolveReferenceForSave();
if (!refResult.ok) {
errorMessage.value = refResult.message;
return;
}
const aliyunVoiceId = formatVoiceIdWithPrefix(
voiceIdInput.value.trim(),
voiceIdInput.value,
);
const payload = {
title,
content: {
promptText: promptText.value.trim(),
aliyunVoiceId: aliyunVoiceId.value.trim(),
url: "",
aliyunVoiceId,
url: refResult.path,
},
};
if (editingId.value) {
updateCloneVoice(editingId.value, payload);
} else {
addCloneVoice(payload);
addCloneVoice(payload, refResult.storageId);
}
visible.value = false;
emit("saved");
} catch (err) {
@@ -92,42 +202,103 @@ defineExpose({ openAdd, openEdit });
v-model:visible="visible"
modal
:header="isEdit ? '编辑音色' : '添加音色'"
:style="{ width: '520px' }"
:style="{ width: '800px' }"
:draggable="false"
class="voice-clone-edit-dialog"
>
<div class="space-y-3">
<div class="max-h-[60vh] overflow-y-auto">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="w-full shrink-0 space-y-4 lg:w-1/2 lg:pr-4">
<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 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>
<InputText
v-model="aliyunVoiceId"
class="w-full"
size="small"
placeholder="复刻成功后填入,留空则使用默认音色"
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 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. 录音时长控制在 620 最佳最多不超过 20 </div>
<div>4. 录制完成后先试听看是否达到要求再提交</div>
</div>
</div>
</div>
<p
v-if="successFlash"
class="mt-3 text-sm text-emerald-400"
>
{{ successFlash }}
</p>
<p
v-if="errorMessage"
class="mt-3 text-sm text-red-400"
>
{{ errorMessage }}
</p>
</div>
<template #footer>
<Button label="取消" severity="secondary" @click="onCancel" />
<Button label="保存" :loading="saving" @click="onSave" />
<Button label="保存" :loading="saving" :disabled="enrolling" @click="onSave" />
</template>
</Dialog>
</template>

View File

@@ -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;

View File

@@ -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 || "",

View File

@@ -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<typeof import('../stores/workflow.js').useWorkflowStore>} 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<typeof import('../stores/workflow.js').useWorkflowStore>} 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,

View File

@@ -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);
}

View File

@@ -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 {

View 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}` };
}
}

View 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 });
}

View File

@@ -276,6 +276,11 @@ export const useWorkflowStore = defineStore("workflow", {
subtitleSrtPath: "",
/** 步骤 05 字幕/BGM 预览(临时目录,不入库) */
subtitlePreviewVideoPath: "",
subtitlePreviewVideoSrc: "",
bgmPath: "",
bgmPreviewSrc: "",

View File

@@ -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 ZimuShengchengPlayRes=视频尺寸、每行字数限制)
* @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,
};
}

View 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}`;
}