Files
sionrui/frontend/app/web-gold/src/views/kling/IdentifyFace.vue
2026-01-18 00:34:04 +08:00

914 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<FullWidthLayout :show-padding="false">
<div class="kling-page">
<div class="kling-content">
<!-- 左侧配置 -->
<div class="upload-panel">
<!-- 文案输入 -->
<div class="section">
<h3>文案</h3>
<a-textarea
v-model:value="ttsText"
:placeholder="textareaPlaceholder"
:rows="4"
:maxlength="maxTextLength"
:show-count="true"
class="tts-textarea"
/>
<div v-if="identifyState.identified && faceDuration > 0" class="text-hint">
<span class="hint-icon">💡</span>
<span>视频中人脸出现时长约 {{ (faceDuration / 1000).toFixed(1) }} 建议文案不超过 {{ suggestedMaxChars }} </span>
</div>
</div>
<!-- 音色选择 -->
<div class="section">
<h3>音色</h3>
<VoiceSelector ref="voiceSelectorRef" @select="handleVoiceSelect" />
</div>
<!-- TTS 控制 -->
<div class="section">
<h3>语音控制</h3>
<div class="control-group">
<div class="control-label">语速</div>
<div class="slider-card">
<div class="slider-info">
<div class="slider-value">{{ speechRateDisplay }}</div>
<button class="reset-btn" @click="speechRate = 1">重置</button>
</div>
<a-slider
v-model:value="speechRate"
:min="0.5"
:max="2"
:step="0.1"
:marks="speechRateMarks"
:tooltip-open="false"
/>
</div>
</div>
</div>
<!-- 视频选择 -->
<div class="section">
<h3>视频</h3>
<!-- 双选项卡片 -->
<div class="video-selection-cards">
<!-- 上传选项 -->
<div
class="video-option-card"
:class="{ selected: videoState.videoSource === 'upload' }"
@click="handleSelectUpload"
>
<div class="card-icon">
<UploadOutlined />
</div>
<div class="card-content">
<h4>点击上传新视频</h4>
<p>支持 MP4MOV 格式</p>
</div>
</div>
<!-- 选择选项 -->
<div
class="video-option-card"
:class="{ selected: videoState.videoSource === 'select' }"
@click="handleSelectFromLibrary"
>
<div class="card-icon">
<PictureOutlined />
</div>
<div class="card-content">
<h4>从素材库选择</h4>
<p>选择已上传的视频</p>
</div>
</div>
</div>
<!-- 已选择视频的预览 -->
<div v-if="videoState.selectedVideo" class="selected-video-preview">
<div class="preview-thumbnail">
<img
:src="getVideoPreviewUrl(videoState.selectedVideo)"
:alt="videoState.selectedVideo.fileName"
/>
<div class="video-duration-badge">
{{ formatDuration(videoState.selectedVideo.duration) }}
</div>
</div>
<div class="preview-info">
<div class="video-title">{{ videoState.selectedVideo.fileName }}</div>
<div class="video-meta">
<span>{{ formatFileSize(videoState.selectedVideo.fileSize) }}</span>
<span>{{ formatDuration(videoState.selectedVideo.duration) }}</span>
</div>
</div>
<a-button type="link" size="small" @click="replaceVideo">
更换
</a-button>
</div>
<!-- 上传区域仅在选择上传时显示 -->
<div
v-if="videoState.videoSource === 'upload'"
class="upload-zone"
@drop.prevent="handleDrop"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
>
<input ref="fileInput" type="file" accept=".mp4,.mov" style="display: none" @change="handleFileSelect" />
<div v-if="!videoState.uploadedVideo" class="upload-placeholder">
<h3>拖拽或点击上传视频文件</h3>
<p>支持 MP4MOV</p>
<a-button type="primary" size="large" @click="triggerFileSelect">
选择文件
</a-button>
</div>
<div v-else class="video-preview">
<video :src="videoState.uploadedVideo" controls class="preview-video"></video>
<p>{{ videoState.videoFile?.name }}</p>
<a-button type="link" size="small" @click="replaceVideo">
更换
</a-button>
</div>
</div>
</div>
<!-- 素材校验结果 -->
<div v-if="materialValidation.videoDuration > 0 && materialValidation.audioDuration > 0" class="section">
<h3>素材校验</h3>
<div class="validation-result" :class="{ 'validation-passed': materialValidation.isValid, 'validation-failed': !materialValidation.isValid }">
<div class="validation-status">
<span class="status-icon">{{ materialValidation.isValid ? '✅' : '❌' }}</span>
<span class="status-text">{{ materialValidation.isValid ? '校验通过' : '校验失败' }}</span>
</div>
<!-- 时长对比进度条 -->
<div class="duration-comparison">
<div class="duration-bar">
<div class="duration-label">
<span>音频时长</span>
<span class="duration-value">{{ (materialValidation.audioDuration / 1000).toFixed(1) }}s</span>
</div>
<div class="progress-bar audio-bar">
<div
class="progress-fill"
:style="{ width: `${(materialValidation.audioDuration / Math.max(materialValidation.videoDuration, materialValidation.audioDuration)) * 100}%` }"
></div>
</div>
</div>
<div class="duration-bar">
<div class="duration-label">
<span>视频时长</span>
<span class="duration-value">{{ (materialValidation.videoDuration / 1000).toFixed(1) }}s</span>
</div>
<div class="progress-bar video-bar">
<div
class="progress-fill"
:class="{ 'success': materialValidation.isValid, 'error': !materialValidation.isValid }"
:style="{ width: `${(materialValidation.videoDuration / Math.max(materialValidation.videoDuration, materialValidation.audioDuration)) * 100}%` }"
></div>
</div>
</div>
</div>
<!-- 失败提示和建议 -->
<div v-if="!materialValidation.isValid" class="validation-error">
<p class="error-message">
视频时长必须大于音频时长才能生成数字人视频
</p>
<div class="quick-actions">
<a-button size="small" @click="replaceVideo">更换视频</a-button>
<a-button size="small" @click="handleSimplifyScript">精简文案</a-button>
</div>
</div>
</div>
</div>
<!-- 配音生成与校验仅在识别后显示 -->
<div v-if="identifyState.identified" class="section audio-generation-section">
<h3>配音生成与校验</h3>
<!-- 生成配音按钮 -->
<div class="generate-audio-row">
<a-button
type="default"
size="large"
:disabled="!canGenerateAudio"
:loading="audioState.generating"
block
@click="generateAudio"
>
{{ audioState.generating ? '生成中...' : '生成配音(用于校验时长)' }}
</a-button>
</div>
<!-- 音频预览生成后显示 -->
<div v-if="audioState.generated" class="audio-preview">
<div class="audio-info">
<h4>生成的配音</h4>
<div class="duration-info">
<span class="label">音频时长</span>
<span class="value">{{ (audioState.durationMs / 1000).toFixed(1) }} </span>
</div>
<div class="duration-info">
<span class="label">人脸区间</span>
<span class="value">{{ (faceDuration / 1000).toFixed(1) }} </span>
</div>
<div class="duration-info" :class="{ 'validation-passed': audioState.validationPassed, 'validation-failed': !audioState.validationPassed }">
<span class="label">校验结果</span>
<span class="value">
{{ audioState.validationPassed ? '✅ 通过' : '❌ 不通过需至少2秒重合' }}
</span>
</div>
</div>
<!-- 音频播放器 -->
<div class="audio-player">
<audio
v-if="audioState.generated.audioBase64"
:src="`data:audio/mp3;base64,${audioState.generated.audioBase64}`"
controls
class="audio-element"
/>
<audio
v-else-if="audioState.generated.audioUrl"
:src="audioState.generated.audioUrl"
controls
class="audio-element"
/>
</div>
<!-- 重新生成按钮 -->
<div class="regenerate-row">
<a-button
type="link"
size="small"
@click="generateAudio"
:loading="audioState.generating"
>
重新生成
</a-button>
</div>
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<a-button
type="primary"
size="large"
:disabled="!canGenerate"
block
@click="generateDigitalHuman"
>
生成数字人视频
</a-button>
<!-- 添加提示信息 -->
<div v-if="canGenerate && !audioState.validationPassed" class="generate-hint">
<span class="hint-icon"></span>
<span>请先生成配音并通过时长校验</span>
</div>
</div>
</div>
<!-- 右侧结果 -->
<ResultPanel @videoLoaded="handleVideoLoaded" />
</div>
<!-- 视频选择器弹窗 -->
<VideoSelector v-model:open="videoState.selectorVisible" @select="handleVideoSelect" />
</div>
</FullWidthLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { UploadOutlined, PictureOutlined } from '@ant-design/icons-vue'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import VideoSelector from '@/components/VideoSelector.vue'
import VoiceSelector from '@/components/VoiceSelector.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
// Controller Hook
import { useIdentifyFaceController } from './hooks/useIdentifyFaceController'
const voiceStore = useVoiceCopyStore()
const voiceSelectorRef: any = ref(null)
const dragOver = ref(false)
// ==================== 初始化 Controller ====================
// Controller 内部直接创建和管理两个子 Hook
const controller = useIdentifyFaceController()
// 解构 controller 以简化模板调用
const {
// 语音生成相关
ttsText,
speechRate,
audioState,
canGenerateAudio,
suggestedMaxChars,
generateAudio,
// 数字人生成相关
videoState,
identifyState,
materialValidation,
faceDuration,
getVideoPreviewUrl,
// 计算属性
canGenerate,
maxTextLength,
textareaPlaceholder,
speechRateMarks,
speechRateDisplay,
// 事件处理方法
handleVoiceSelect,
handleFileSelect,
handleDrop,
triggerFileSelect,
handleSelectUpload,
handleSelectFromLibrary,
handleVideoSelect,
handleSimplifyScript,
handleVideoLoaded,
replaceVideo,
generateDigitalHuman,
// UI 辅助方法
formatDuration,
formatFileSize,
} = controller
// ==================== 生命周期 ====================
onMounted(async () => {
await voiceStore.refresh()
// 设置VoiceSelector的试听文本和语速
if (voiceSelectorRef.value) {
voiceSelectorRef.value.setPreviewText(ttsText.value)
voiceSelectorRef.value.setPreviewSpeechRate(speechRate.value)
}
})
</script>
<style scoped lang="less">
/* ========== 页面布局 ========== */
.kling-page {
padding: 0;
background: var(--bg-secondary);
min-height: 100vh;
}
.kling-content {
display: grid;
grid-template-columns: 480px 1fr;
gap: 32px;
margin: 0 auto;
}
/* 优化左侧配置面板 */
.upload-panel {
background: var(--bg-primary);
border-radius: 16px;
padding: 28px;
height: fit-content;
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
overflow-y: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
/* 自定义滚动条 */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-light);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
}
/* ========== 基础组件 ========== */
.section {
margin-bottom: 24px;
}
.section h3 {
color: var(--text-primary);
margin-bottom: 12px;
font-size: 16px;
font-weight: 600;
}
.card-content h4,
.audio-info h4 {
color: var(--text-primary);
font-size: 14px;
margin-bottom: 12px;
}
.card-content p,
.duration-label span:first-child {
color: var(--text-secondary);
font-size: 13px;
}
.card-content p {
margin: 0;
}
/* ========== 表单控件 ========== */
.tts-textarea {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
color: var(--text-primary);
:deep(.ant-input) {
background: transparent;
border: none;
color: var(--text-primary);
}
}
.text-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 12px 16px;
background: rgba(var(--color-primary), 0.1);
border: 1px solid rgba(var(--color-primary), 0.3);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.hint-icon {
font-size: 16px;
}
/* ========== 控制面板 ========== */
.control-group {
margin-bottom: 16px;
}
.control-label {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.slider-card {
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 12px 16px;
background: var(--bg-secondary);
}
.slider-info {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.slider-value {
font-size: 14px;
font-weight: 600;
color: var(--color-primary);
}
.reset-btn {
padding: 4px 12px;
border: 1px solid var(--border-light);
background: var(--bg-primary);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
/* ========== 视频选择 ========== */
.video-selection-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.video-option-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--border-light);
border-radius: 8px;
background: var(--bg-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--color-primary);
background: var(--bg-secondary);
}
&.selected {
border-color: var(--color-primary);
background: rgba(var(--color-primary), 0.1);
}
}
.card-icon {
font-size: 24px;
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: rgba(var(--color-primary), 0.1);
border-radius: 8px;
flex-shrink: 0;
}
/* ========== 视频预览 ========== */
.selected-video-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
margin-bottom: 16px;
}
.preview-thumbnail {
position: relative;
width: 80px;
height: 45px;
border-radius: 6px;
overflow: hidden;
background: #374151;
flex-shrink: 0;
}
.preview-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-duration-badge {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.8);
color: var(--text-primary);
padding: 2px 4px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
}
.preview-info {
flex: 1;
min-width: 0;
}
.video-title {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-secondary);
}
/* ========== 上传区域 ========== */
.upload-zone {
min-height: 280px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--border-light);
border-radius: 8px;
background: var(--bg-secondary);
&.drag-over {
border-color: var(--color-primary);
background: rgba(var(--color-primary), 0.1);
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 20px;
text-align: center;
h3 {
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
margin: 0;
}
p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
}
.ant-btn {
padding: 8px 24px;
font-size: 14px;
border-radius: 6px;
}
}
.video-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 20px;
text-align: center;
p {
color: var(--text-secondary);
font-size: 14px;
margin: 0;
padding: 8px 16px;
background: var(--bg-primary);
border-radius: 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.preview-video {
width: 100%;
max-height: 280px;
border-radius: 8px;
}
/* ========== 验证结果 ========== */
.validation-result {
padding: 16px;
background: var(--bg-primary);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.validation-result.validation-passed {
border-color: var(--color-success);
background: rgba(var(--color-success), 0.05);
}
.validation-result.validation-failed {
border-color: var(--color-error);
background: rgba(var(--color-error), 0.05);
}
.validation-status {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 6px;
}
.status-icon {
font-size: 18px;
}
.status-text {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
/* ========== 时长对比进度条 ========== */
.duration-comparison {
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 6px;
}
.duration-bar {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.duration-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
}
.duration-value {
color: var(--text-primary);
font-weight: 600;
font-size: 13px;
padding: 4px 8px;
background: var(--bg-primary);
border-radius: 4px;
}
.progress-bar {
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
.audio-bar .progress-fill {
background: var(--color-primary);
}
.video-bar .progress-fill.success {
background: var(--color-success);
}
.video-bar .progress-fill.error {
background: var(--color-error);
}
/* ========== 错误提示 ========== */
.validation-error {
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 6px;
}
.error-message {
color: var(--color-error);
font-size: 13px;
margin: 0 0 12px 0;
}
.quick-actions {
display: flex;
gap: 8px;
}
/* ========== 音频生成 ========== */
.audio-generation-section {
margin-bottom: 24px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.generate-audio-row {
margin-bottom: 16px;
}
.audio-preview {
padding: 16px;
background: var(--bg-primary);
border-radius: 8px;
}
.duration-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
}
.duration-info .label {
color: var(--text-secondary);
}
.duration-info .value {
color: var(--text-primary);
font-weight: 600;
}
.duration-info.validation-passed .value {
color: var(--color-success);
}
.duration-info.validation-failed .value {
color: var(--color-error);
}
.audio-player {
margin: 16px 0;
}
.audio-element {
width: 100%;
}
.regenerate-row {
text-align: center;
margin-top: 12px;
}
/* ========== 操作按钮 ========== */
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.action-buttons .ant-btn[type="primary"] {
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
}
.generate-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(var(--color-warning), 0.1);
border: 1px solid rgba(var(--color-warning), 0.3);
border-radius: 6px;
font-size: 13px;
color: var(--color-warning);
}
/* ========== 响应式 ========== */
@media (max-width: 1024px) {
.kling-content {
grid-template-columns: 1fr;
padding: 16px;
gap: 20px;
}
.upload-panel {
position: static;
padding: 20px;
}
.video-selection-cards {
grid-template-columns: 1fr;
gap: 12px;
}
.upload-zone {
min-height: 240px;
}
}
</style>