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="请选择形象"