This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 15:11:59 +08:00
parent 4771397301
commit cdbf605205
25 changed files with 1497 additions and 112 deletions

View File

@@ -0,0 +1,151 @@
<script setup>
import { ref } from "vue";
import { useAvatarStore } from "../../stores/avatar.js";
import { pickVideoFile, importAvatarVideo } from "../../services/avatarVideo.js";
import { localVideoToPlayableUrl } from "../../services/localVideo.js";
import { avatarNameExists } from "../../services/avatarDb.js";
const emit = defineEmits(["saved"]);
const avatarStore = useAvatarStore();
const visible = ref(false);
const name = ref("");
const localSourcePath = ref("");
const previewSrc = ref("");
const saving = ref(false);
const errorMessage = ref("");
function resetForm() {
name.value = "";
localSourcePath.value = "";
previewSrc.value = "";
errorMessage.value = "";
}
function openAdd() {
resetForm();
visible.value = true;
}
async function onPickVideo() {
errorMessage.value = "";
try {
const path = await pickVideoFile();
if (!path) return;
localSourcePath.value = path;
previewSrc.value = localVideoToPlayableUrl(path);
} catch (err) {
errorMessage.value =
err instanceof Error ? err.message : String(err || "选择文件失败");
}
}
function onCancel() {
visible.value = false;
}
async function onSave() {
errorMessage.value = "";
const title = name.value.trim();
if (!title) {
errorMessage.value = "请输入名称";
return;
}
if (await avatarNameExists(title)) {
errorMessage.value = "名称重复";
return;
}
if (!localSourcePath.value) {
errorMessage.value = "请选择视频";
return;
}
saving.value = true;
try {
const savedPath = await importAvatarVideo(localSourcePath.value);
await avatarStore.addTemplate({ name: title, filePath: savedPath });
visible.value = false;
emit("saved");
} catch (err) {
errorMessage.value =
err instanceof Error ? err.message : String(err || "保存失败");
} finally {
saving.value = false;
}
}
defineExpose({ openAdd });
</script>
<template>
<Dialog
v-model:visible="visible"
modal
header="添加视频形象"
:style="{ width: '720px' }"
:draggable="false"
class="avatar-edit-dialog"
>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-3">
<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>
<div
v-if="!previewSrc"
class="flex h-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 text-center text-sm text-slate-400 transition-colors hover:border-blue-400/40 hover:bg-white/10"
@click="onPickVideo"
>
<i class="pi pi-video mb-2 text-2xl text-slate-500" />
<span>点击选择视频文件</span>
<span class="mt-1 text-xs">支持 mp4 / mov / webm </span>
</div>
<div v-else class="space-y-2">
<div class="overflow-hidden rounded-lg bg-black p-1">
<video :src="previewSrc" controls class="max-h-48 w-full" />
</div>
<Button
label="重新选择"
size="small"
severity="secondary"
outlined
class="w-full"
@click="onPickVideo"
/>
</div>
</div>
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
</div>
<div class="text-sm text-slate-400">
<div class="mb-2 text-base font-medium text-slate-200">形象示例</div>
<div class="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">正脸自拍</div>
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">可张口闭口</div>
<div class="rounded-lg bg-red-500/10 p-2 text-red-400">面部有干扰</div>
</div>
<div class="dashboard-field-label mb-2">形象视频要求</div>
<ul class="space-y-1.5 text-xs leading-relaxed">
<li>视频时长建议 1030 格式 MP4分辨率 1080p4K</li>
<li>每一帧需正面露脸无遮挡且仅出现同一人脸</li>
<li>建议闭口或微微张口张口幅度不宜过大</li>
<li>可正常语气循环说一二三四五六七八九等文字</li>
</ul>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" @click="onCancel" />
<Button label="保存" :loading="saving" @click="onSave" />
</template>
</Dialog>
</template>

View File

@@ -1,10 +1,12 @@
<script setup>
import { computed, ref } from "vue";
import { computed, ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import { useAvatarStore } from "../../stores/avatar.js";
import VoiceManageDialog from "./VoiceManageDialog.vue";
const workflow = useWorkflowStore();
const avatarStore = useAvatarStore();
const {
voiceEmotion,
speechRate,
@@ -22,7 +24,7 @@ const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
const audioHistoryOptions = computed(() =>
audioHistory.value.map((item) => ({
label: item.label,
label: item.name,
value: item.id,
})),
);
@@ -44,10 +46,15 @@ async function onGenerateSpeech() {
}
}
function onPickAudioHistory() {
async function onPickAudioHistory() {
if (!currentAudioId.value) return;
workflow.pickAudioHistory(currentAudioId.value);
await workflow.pickAudioHistory(currentAudioId.value);
}
onMounted(() => {
avatarStore.refresh();
workflow.refreshAudioHistory();
});
</script>
<template>
@@ -139,7 +146,7 @@ function onPickAudioHistory() {
<div class="mb-2 text-xs text-slate-400">选择形象</div>
<Select
v-model="avatarSelect"
:options="workflow.avatarOptions"
:options="avatarStore.selectOptions"
option-label="label"
option-value="value"
placeholder="请选择形象"

View File

@@ -26,7 +26,7 @@ const mainChildren = [
name: "avatar",
component: placeholder,
component: () => import("../views/AvatarView.vue"),
meta: { title: "形象" },

35
src/services/audioDb.js Normal file
View File

@@ -0,0 +1,35 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
function ensureTauri() {
if (!isTauri()) {
throw new Error("语音历史需在 Tauri 桌面端使用");
}
}
/**
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} GeneratedAudioRecord
*/
/** @returns {Promise<GeneratedAudioRecord[]>} */
export async function listGeneratedAudios() {
ensureTauri();
return invoke("list_generated_audios");
}
/**
* @param {string} name 可为空,空时由后端填入当前时间
* @param {string} filePath
* @returns {Promise<GeneratedAudioRecord>}
*/
export async function insertGeneratedAudio(name, filePath) {
ensureTauri();
return invoke("insert_generated_audio", { name, filePath });
}
/**
* @param {number} id
*/
export async function deleteGeneratedAudio(id) {
ensureTauri();
return invoke("delete_generated_audio", { id });
}

89
src/services/avatarDb.js Normal file
View File

@@ -0,0 +1,89 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
const LEGACY_STORAGE_KEY = "aiclient_video_templates";
function ensureTauri() {
if (!isTauri()) {
throw new Error("形象管理需在 Tauri 桌面端使用");
}
}
/**
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} AvatarRecord
*/
/** @returns {Promise<AvatarRecord[]>} */
export async function listAvatars() {
ensureTauri();
return invoke("list_avatars");
}
/**
* @param {string} name
* @param {string} filePath
* @returns {Promise<AvatarRecord>}
*/
export async function insertAvatar(name, filePath) {
ensureTauri();
return invoke("insert_avatar", { name, filePath });
}
/**
* @param {number} id
* @param {string} name
*/
export async function updateAvatarName(id, name) {
ensureTauri();
return invoke("update_avatar_name", { id, name });
}
/**
* @param {number} id
*/
export async function deleteAvatar(id) {
ensureTauri();
return invoke("delete_avatar", { id });
}
/**
* @param {string} name
* @param {number | null} [excludeId]
*/
export async function avatarNameExists(name, excludeId = null) {
ensureTauri();
return invoke("avatar_name_exists", {
name,
excludeId: excludeId ?? null,
});
}
/** 将旧版 localStorage 数据迁入 SQLite仅当库为空且存在 legacy 数据时) */
export async function migrateLegacyAvatarsIfNeeded() {
ensureTauri();
const current = await listAvatars();
if (current.length > 0) return;
let legacy = [];
try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
legacy = Array.isArray(parsed) ? parsed : [];
}
} catch {
legacy = [];
}
if (!legacy.length) return;
for (const item of legacy) {
const name = String(item.name || "").trim();
const filePath = String(item.video || item.filePath || "").trim();
if (!name || !filePath) continue;
try {
await insertAvatar(name, filePath);
} catch {
/* 重名等跳过 */
}
}
localStorage.removeItem(LEGACY_STORAGE_KEY);
}

View File

@@ -0,0 +1,32 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
/**
* @returns {Promise<string | null>}
*/
export async function pickVideoFile() {
if (!isTauri()) {
throw new Error("请在 Tauri 桌面端选择视频文件");
}
const selected = await open({
multiple: false,
filters: [
{
name: "视频",
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "avi"],
},
],
});
if (selected == null) return null;
if (Array.isArray(selected)) return selected[0] || null;
return String(selected);
}
/**
* @param {string} sourcePath
* @returns {Promise<string>} 应用内保存路径
*/
export async function importAvatarVideo(sourcePath) {
return invoke("import_avatar_video", { sourcePath });
}

View File

@@ -0,0 +1,12 @@
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
/**
* 本地视频文件转为 WebView 可播放地址
* @param {string} filePath
*/
export function localVideoToPlayableUrl(filePath) {
if (!isTauri()) {
throw new Error("请在 Tauri 桌面端预览视频");
}
return convertFileSrc(filePath);
}

View File

@@ -1,99 +1,96 @@
import { invoke } from "@tauri-apps/api/core";
import { ensureAppConfigReady } from "../config/videoPipeline.js";
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
import { localAudioToPlayableUrl } from "./localAudio.js";
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
/**
* 生成语音(对齐 Electron generateAudio CosyVoice
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
*/
export async function executeGenerateSpeech(store) {
const text = String(store.scriptContent || "").trim();
if (!text) {
return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
}
if (!store.selectedVoiceId) {
return { ok: false, message: "请先选择音色" };
}
const cfg = await ensureAppConfigReady();
if (!cfg.ok) {
return { ok: false, message: cfg.message };
}
const voice = resolveAliyunVoiceId(store.selectedVoiceId);
const instruction = buildTtsInstruction(
store.voiceLanguage,
store.selectedVoiceId,
store.voiceEmotion,
store.speechRate,
);
const languageType = buildTtsLanguageType(store.voiceLanguage);
store.speechGenerating = true;
try {
const result = await invoke("run_nodejs_script", {
scriptName: VOICE_GENERATE_SCRIPT,
params: {
text,
voice,
instruction,
languageType: languageType || null,
format: "mp3",
},
});
if (!result?.success || !result?.audioPath) {
return {
ok: false,
message: String(result?.error || "语音生成失败,请重试"),
};
}
const audioSrc = await localAudioToPlayableUrl(result.audioPath);
const label = `${getVoiceDisplayLabel(store.selectedVoiceId)} · ${formatHistoryTime()}`;
const entry = {
id: `audio_${Date.now()}`,
label,
audioPath: result.audioPath,
audioSrc,
createdAt: Date.now(),
};
store.generatedAudioPath = result.audioPath;
store.generatedAudioSrc = audioSrc;
store.currentAudioId = entry.id;
store.audioHistory = [entry, ...store.audioHistory].slice(0, 50);
return { ok: true, message: "语音生成完成" };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err || "未知错误");
return { ok: false, message: `语音生成失败: ${msg}` };
} finally {
store.speechGenerating = false;
}
}
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 {string} audioId
*/
export function selectAudioHistory(store, audioId) {
const item = store.audioHistory.find((h) => h.id === audioId);
if (!item) return;
store.currentAudioId = item.id;
store.generatedAudioPath = item.audioPath;
store.generatedAudioSrc = item.audioSrc;
}
import { invoke } from "@tauri-apps/api/core";
import { ensureAppConfigReady } from "../config/videoPipeline.js";
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
import { localAudioToPlayableUrl } from "./localAudio.js";
import { insertGeneratedAudio } from "./audioDb.js";
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
/**
* 生成语音(对齐 Electron generateAudio CosyVoice
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
*/
export async function executeGenerateSpeech(store) {
const text = String(store.scriptContent || "").trim();
if (!text) {
return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
}
if (!store.selectedVoiceId) {
return { ok: false, message: "请先选择音色" };
}
const cfg = await ensureAppConfigReady();
if (!cfg.ok) {
return { ok: false, message: cfg.message };
}
const voice = resolveAliyunVoiceId(store.selectedVoiceId);
const instruction = buildTtsInstruction(
store.voiceLanguage,
store.selectedVoiceId,
store.voiceEmotion,
store.speechRate,
);
const languageType = buildTtsLanguageType(store.voiceLanguage);
store.speechGenerating = true;
try {
const result = await invoke("run_nodejs_script", {
scriptName: VOICE_GENERATE_SCRIPT,
params: {
text,
voice,
instruction,
languageType: languageType || null,

75
src/stores/avatar.js Normal file
View File

@@ -0,0 +1,75 @@
import { defineStore, acceptHMRUpdate } from "pinia";
import {
listAvatars,
insertAvatar,
updateAvatarName,
deleteAvatar,
avatarNameExists,
migrateLegacyAvatarsIfNeeded,
} from "../services/avatarDb.js";
export const useAvatarStore = defineStore("avatar", {
state: () => ({
templates: [],
loading: false,
}),
getters: {
selectOptions: (state) => [
{ label: "请选择形象", value: null },
...state.templates.map((t) => ({
label: t.name,
value: t.id,
})),
],
},
actions: {
async refresh() {
this.loading = true;
try {
await migrateLegacyAvatarsIfNeeded();
this.templates = await listAvatars();
} finally {
this.loading = false;
}
},
/**
* @param {{ name: string, filePath: string }} payload
*/
async addTemplate(payload) {
const record = await insertAvatar(payload.name, payload.filePath);
this.templates = await listAvatars();
return record;
},
/**
* @param {number} id
* @param {string} name
*/
async renameTemplate(id, name) {
const exists = await avatarNameExists(name, id);
if (exists) {
return { ok: false, message: "名称重复" };
}
try {
await updateAvatarName(id, name);
this.templates = await listAvatars();
return { ok: true };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, message: msg || "重命名失败" };
}
},
async removeTemplate(item) {
await deleteAvatar(item.id);
this.templates = await listAvatars();
},
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAvatarStore, import.meta.hot));
}

View File

@@ -21,6 +21,8 @@ import {
executeGenerateSpeech,
selectAudioHistory,
} from "../services/voiceGenerate.js";
import { listGeneratedAudios } from "../services/audioDb.js";
import { localAudioToPlayableUrl } from "../services/localAudio.js";
@@ -165,7 +167,7 @@ export const useWorkflowStore = defineStore("workflow", {
currentAudioId: null,
/** @type {Array<{ id: string, label: string, audioPath: string, audioSrc: string, createdAt: number }>} */
/** @type {Array<{ id: number, name: string, filePath: string, createdAt: string, audioSrc?: string }>} */
audioHistory: [],
@@ -451,10 +453,27 @@ export const useWorkflowStore = defineStore("workflow", {
},
pickAudioHistory(audioId) {
selectAudioHistory(this, audioId);
async refreshAudioHistory() {
try {
const rows = await listGeneratedAudios();
this.audioHistory = await Promise.all(
rows.map(async (row) => {
let audioSrc = "";
try {
audioSrc = await localAudioToPlayableUrl(row.filePath);
} catch {
audioSrc = "";
}
return { ...row, audioSrc };
}),
);
} catch {
this.audioHistory = [];
}
},
async pickAudioHistory(audioId) {
await selectAudioHistory(this, audioId);
},
resetAll() {

185
src/views/AvatarView.vue Normal file
View File

@@ -0,0 +1,185 @@
<script setup>
import { ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useAvatarStore } from "../stores/avatar.js";
import AvatarEditDialog from "../components/avatar/AvatarEditDialog.vue";
import { localVideoToPlayableUrl } from "../services/localVideo.js";
const avatarStore = useAvatarStore();
const { templates, loading } = storeToRefs(avatarStore);
const editDialogRef = ref(null);
const feedback = ref({ severity: "", message: "" });
const editingId = ref(null);
const editingName = ref("");
onMounted(() => {
avatarStore.refresh();
});
function showFeedback(severity, message) {
feedback.value = { severity, message };
}
function videoSrc(item) {
if (!item?.filePath) return "";
try {
return localVideoToPlayableUrl(item.filePath);
} catch {
return "";
}
}
function onAdd() {
feedback.value = { severity: "", message: "" };
editDialogRef.value?.openAdd();
}
function onCloud() {
showFeedback("warn", "云端形象功能即将上线");
}
function startRename(item) {
editingId.value = item.id;
editingName.value = item.name;
}
function cancelRename() {
editingId.value = null;
editingName.value = "";
}
async function commitRename(item) {
const result = await avatarStore.renameTemplate(item.id, editingName.value);
if (!result.ok) {
showFeedback("error", result.message || "重命名失败");
return;
}
cancelRename();
}
async function onDelete(item) {
if (!window.confirm(`确认删除形象「${item.name}」?`)) return;
await avatarStore.removeTemplate(item);
showFeedback("success", "已删除");
}
function onSaved() {
showFeedback("success", "形象已添加");
}
</script>
<template>
<div class="avatar-page custom-scrollbar h-full overflow-y-auto p-5">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-100">数字人形象</h1>
<p class="mt-1 text-sm text-slate-500">管理多个数字人形象</p>
</div>
<div class="flex flex-wrap gap-2">
<Button
label="云端形象"
size="small"
severity="secondary"
outlined
icon="pi pi-cloud"
@click="onCloud"
/>
<Button label="添加" size="small" icon="pi pi-plus" @click="onAdd" />
</div>
</div>
<p
v-if="feedback.message"
class="mb-3 text-sm"
:class="{
'text-red-400': feedback.severity === 'error',
'text-amber-400': feedback.severity === 'warn',
'text-emerald-400': feedback.severity === 'success',
}"
>
{{ feedback.message }}
</p>
<p v-if="loading" class="py-12 text-center text-sm text-slate-500">加载中...</p>
<p
v-else-if="templates.length === 0"
class="rounded-xl border border-white/10 bg-white/5 py-16 text-center text-sm text-slate-500"
>
暂无形象点击添加上传视频
</p>
<div v-else class="-mx-2 flex flex-wrap">
<div
v-for="item in templates"
:key="item.id"
class="w-full p-2 md:w-1/2 xl:w-1/3"
>
<div
class="rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-shadow hover:border-purple-500/40 hover:shadow-md"
>
<div class="mb-3 flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div
v-if="editingId !== item.id"
class="inline-flex max-w-full items-center rounded-full bg-blue-500/15 px-2 py-1"
>
<span
class="cursor-pointer truncate text-sm font-medium text-slate-100"
:title="item.name"
@click="startRename(item)"
>
{{ item.name }}
</span>
<button
type="button"
class="ml-2 shrink-0 text-xs text-slate-500 hover:text-blue-400"
aria-label="重命名"
@click="startRename(item)"
>
<i class="pi pi-pencil" />
</button>
</div>
<div v-else class="flex gap-1">
<InputText v-model="editingName" size="small" class="flex-1" />
<Button
icon="pi pi-check"
size="small"
severity="success"
aria-label="确认"
@click="commitRename(item)"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
aria-label="取消"
@click="cancelRename"
/>
</div>
</div>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
text
aria-label="删除"
@click="onDelete(item)"
/>
</div>
<div class="overflow-hidden rounded-lg bg-black p-1">
<video
v-if="videoSrc(item)"
:src="videoSrc(item)"
controls
class="aspect-video w-full bg-black object-contain"
/>
<p v-else class="py-12 text-center text-xs text-slate-500">无法预览视频</p>
</div>
</div>
</div>
</div>
<AvatarEditDialog ref="editDialogRef" @saved="onSaved" />
</div>
</template>