Files
sionrui/frontend/app/web-gold/src/views/kling/IdentifyFace.vue
2026-03-05 22:58:31 +08:00

1023 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="notion-page">
<!-- 主内容区域 -->
<div class="main-content">
<!-- 左侧视频模块 -->
<section class="content-block">
<div class="block-header">
<span class="block-emoji">📹</span>
<h3 class="block-title">视频素材</h3>
</div>
<!-- 视频来源选项 -->
<div class="source-toggle">
<button
class="toggle-btn"
:class="{ active: store.videoSource === 'upload' }"
@click="store.selectUploadMode"
>
<CloudUploadOutlined />
<span>上传视频</span>
</button>
<button
class="toggle-btn"
:class="{ active: store.videoSource === 'select' }"
@click="store.selectLibraryMode"
>
<FolderOutlined />
<span>素材库</span>
</button>
</div>
<!-- 上传区域 -->
<div
v-if="store.videoSource === 'upload'"
class="upload-area"
:class="{ dragover: dragOver, 'has-video': store.videoPreviewUrl }"
@drop.prevent="handleDrop"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
>
<input
ref="fileInput"
type="file"
accept=".mp4,.mov"
class="hidden-input"
@change="handleFileSelect"
/>
<div v-if="!store.videoPreviewUrl" class="upload-empty" @click="triggerFileSelect">
<div class="upload-icon-wrapper">
<CloudUploadOutlined class="upload-icon" />
</div>
<div class="upload-text">点击上传或拖拽文件到此处</div>
<div class="upload-hint">支持 MP4MOV 格式视频需大于 3 </div>
</div>
<div v-else class="upload-preview">
<video
:src="store.videoPreviewUrl"
controls
playsinline
preload="metadata"
class="preview-video"
></video>
<button class="replace-btn" @click="clearVideo">
<ReloadOutlined />
<span>更换视频</span>
</button>
</div>
</div>
<!-- 已选视频预览素材库 -->
<div v-if="store.selectedVideo && store.videoSource === 'select'" class="library-preview">
<video
v-if="store.videoPreviewUrl"
:src="store.videoPreviewUrl"
controls
playsinline
preload="metadata"
class="preview-video"
></video>
<div class="video-meta">
<div class="video-name" :title="store.selectedVideo.fileName">{{ store.selectedVideo.fileName }}</div>
<div class="video-info">
{{ formatDuration(store.selectedVideo.duration) }} · {{ formatFileSize(store.selectedVideo.fileSize) }}
</div>
</div>
<button class="replace-btn" @click="clearVideo">
<ReloadOutlined />
<span>更换</span>
</button>
</div>
<!-- 识别状态 -->
<div v-if="store.videoStep !== 'idle'" class="process-status" :class="store.videoStep">
<div v-if="store.videoStep === 'uploading'" class="status-row">
<a-spin size="small" />
<span>正在上传视频...</span>
</div>
<div v-else-if="store.videoStep === 'recognizing'" class="status-row">
<a-spin size="small" />
<span>正在识别人脸...</span>
</div>
<div v-else-if="store.videoStep === 'recognized'" class="status-row success">
<CheckCircleOutlined />
<span>识别成功 · 人脸时长 {{ formatDurationMs(store.faceDurationMs) }}</span>
</div>
<div v-else-if="store.videoStep === 'error'" class="status-row error">
<ExclamationCircleOutlined />
<span>{{ store.error }}</span>
<button class="link-btn" @click="store.retry">重试</button>
</div>
</div>
<!-- 生成按钮 - 放在左侧面板底部 -->
<div class="generate-action">
<!-- 成功状态 -->
<div v-if="store.isDone" class="result-inline success">
<CheckCircleFilled />
<span>任务已提交</span>
<button class="inline-btn" @click="store.reset">重新生成</button>
</div>
<!-- 错误状态 -->
<div v-else-if="store.createStep === 'error'" class="result-inline error">
<ExclamationCircleFilled />
<span>{{ store.error }}</span>
<button class="link-btn" @click="store.retry">重试</button>
</div>
<!-- 生成按钮 -->
<button
v-else
class="generate-btn"
:class="{ disabled: !store.canGenerate }"
:disabled="!store.canGenerate"
@click="store.createTask"
>
<div class="btn-glow"></div>
<div class="btn-left">
<template v-if="store.createStep === 'creating'">
<LoadingOutlined class="btn-spin" spin />
<span>正在创建...</span>
</template>
<template v-else>
<PlayCircleOutlined class="btn-icon" />
<span>生成视频</span>
</template>
</div>
<div class="btn-right">
<span class="cost-num">{{ estimatedPoints }}</span>
<span class="cost-label">积分</span>
</div>
</button>
</div>
</section>
<!-- 右侧配音文案模块 -->
<section class="content-block">
<div class="block-header">
<span class="block-emoji">🎙</span>
<h3 class="block-title">配音文案</h3>
</div>
<!-- 文案输入 -->
<div class="input-section">
<div class="label-row">
<label class="input-label">播报文案</label>
<button class="generate-text-btn" @click="openTextGeneratePopup">
<EditOutlined />
<span>AI 生成</span>
</button>
</div>
<a-textarea
v-model:value="store.text"
:placeholder="placeholder"
:rows="6"
:maxlength="4000"
:show-count="true"
class="notion-textarea"
:bordered="false"
/>
<div class="input-footer">
<span>{{ store.text?.length || 0 }} </span>
<span v-if="store.faceDurationMs > 0">· 建议 {{ suggestedChars }} </span>
</div>
</div>
<!-- 文案生成浮窗 -->
<TextGeneratePopup
v-model:visible="textGenerateVisible"
@success="handleTextGenerated"
/>
<!-- 音色选择 -->
<div class="input-section">
<label class="input-label">选择音色</label>
<VoiceSelector
:synth-text="store.text"
:speech-rate="store.speechRate"
@select="store.setVoice"
@audio-generated="handleAudioGenerated"
/>
</div>
<!-- 语速调节 -->
<div class="input-section">
<label class="input-label">语速调节</label>
<div class="rate-control">
<a-slider
v-model:value="store.speechRate"
:min="0.5"
:max="2.0"
:step="0.1"
:marks="rateMarks"
class="rate-slider"
/>
<span class="rate-value">{{ store.speechRate.toFixed(1) }}x</span>
</div>
</div>
<!-- 时间轴对比 -->
<TimelinePanel
v-if="store.timeline"
:face-duration-ms="store.timeline.videoDurationMs"
:audio-duration-ms="store.timeline.audioDurationMs"
:face-start-time="store.timeline.faceStartTime"
:face-end-time="store.timeline.faceEndTime"
/>
</section>
</div>
</div>
<!-- 视频选择器弹窗 -->
<VideoSelector
v-model:open="store.videoSelectorVisible"
@select="store.selectVideo"
/>
</FullWidthLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
import VideoSelector from '@/components/VideoSelector.vue'
import VoiceSelector from '@/components/VoiceSelector.vue'
import TimelinePanel from './components/TimelinePanel.vue'
import TextGeneratePopup from './components/TextGeneratePopup.vue'
import {
CloudUploadOutlined,
FolderOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
ReloadOutlined,
PlayCircleOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
EditOutlined,
} from '@ant-design/icons-vue'
import { useDigitalHumanStore } from './stores/useDigitalHumanStore'
import { useUserStore } from '@/stores/user'
import { usePointsConfigStore } from '@/stores/pointsConfig'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
import { formatDuration, formatDurationMs, formatFileSize } from './utils/format'
const store = useDigitalHumanStore()
const userStore = useUserStore()
const pointsConfigStore = usePointsConfigStore()
const voiceStore = useVoiceCopyStore()
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const textGenerateVisible = ref(false)
const estimatedPoints = computed(() => {
const points = pointsConfigStore.getConsumePoints('kling')
return points ?? 150
})
const suggestedChars = computed(() => {
return Math.floor((store.faceDurationMs || 10000) / 1000 * 4)
})
const placeholder = computed(() =>
store.faceDurationMs > 0
? `请输入播报文案,建议不超过 ${suggestedChars.value} 字以确保与视频匹配`
: '请输入你想让角色说话的内容'
)
const rateMarks = {
0.5: '0.5x',
1.0: '1.0x',
1.5: '1.5x',
2.0: '2.0x',
}
function triggerFileSelect() {
fileInput.value?.click()
}
function handleFileSelect(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
store.handleFileUpload(file)
}
}
function handleDrop(e: DragEvent) {
dragOver.value = false
const file = e.dataTransfer?.files[0]
if (file) {
store.handleFileUpload(file)
}
}
function clearVideo() {
if (store.videoPreviewUrl?.startsWith('blob:')) {
URL.revokeObjectURL(store.videoPreviewUrl)
}
store.videoFile = null
store.selectedVideo = null
store.videoPreviewUrl = ''
store.videoSource = null
store.resetProcess()
}
function handleAudioGenerated(data: { durationMs: number; audioBase64: string }) {
if (store.timeline && data.durationMs > 0) {
store.timeline.audioDurationMs = data.durationMs
}
if (data.audioBase64) {
store.audioData = {
audioBase64: data.audioBase64,
format: 'mp3',
durationMs: data.durationMs
}
store.audioStep = 'generated'
}
}
function openTextGeneratePopup() {
textGenerateVisible.value = true
}
function handleTextGenerated(text: string) {
store.text = text
}
onMounted(async () => {
await Promise.all([
voiceStore.refresh(),
userStore.fetchUserProfile(),
pointsConfigStore.loadConfig(),
])
})
</script>
<style scoped lang="less">
// ========================================
// Notion + VoiceSelector 蓝紫主题
// ========================================
// Color Palette - 与 VoiceSelector 协调
@bg-page: #fafbfc;
@bg-block: #ffffff;
@text-primary: #1e293b;
@text-secondary: #64748b;
@text-tertiary: #94a3b8;
@border-light: rgba(59, 130, 246, 0.1);
@border-medium: rgba(59, 130, 246, 0.2);
// 蓝紫渐变主题色 (与 VoiceSelector 一致)
@accent-blue: #3b82f6;
@accent-purple: #8b5cf6;
@accent-gradient: linear-gradient(135deg, @accent-blue 0%, @accent-purple 100%);
@accent-green: #10b981;
@accent-red: #ef4444;
@accent-orange: #f59e0b;
// Shadows - 柔和蓝调
@shadow-sm: 0 1px 2px rgba(59, 130, 246, 0.04);
@shadow-md: 0 4px 12px rgba(59, 130, 246, 0.08);
@shadow-lg: 0 8px 24px rgba(59, 130, 246, 0.12);
// Typography
@font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 'Apple Color Emoji', Arial, sans-serif;
.notion-page {
min-height: 100vh;
background: @bg-page;
padding: 48px 64px;
max-width: 1400px;
margin: 0 auto;
@media (max-width: 1024px) {
padding: 32px 24px;
}
}
// Page Header
.page-header {
margin-bottom: 40px;
.header-icon {
font-size: 56px;
margin-bottom: 16px;
line-height: 1;
}
.header-title {
font-family: @font-sans;
font-size: 40px;
font-weight: 700;
color: @text-primary;
margin: 0 0 8px 0;
letter-spacing: -0.5px;
line-height: 1.2;
}
.header-desc {
font-size: 16px;
color: @text-secondary;
margin: 0;
line-height: 1.5;
}
}
// Main Content Layout
.main-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
// Content Block
.content-block {
background: @bg-block;
border-radius: 12px;
padding: 24px;
box-shadow: @shadow-md;
border: 1px solid @border-light;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
&:hover {
box-shadow: @shadow-lg;
border-color: rgba(59, 130, 246, 0.2);
}
}
// Block Header
.block-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(59, 130, 246, 0.08);
.block-emoji {
font-size: 20px;
line-height: 1;
}
.block-title {
font-size: 15px;
font-weight: 600;
color: @text-primary;
margin: 0;
}
}
// Source Toggle
.source-toggle {
display: flex;
gap: 8px;
margin-bottom: 20px;
background: @bg-page;
padding: 4px;
border-radius: 6px;
}
.toggle-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: @text-secondary;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: @text-primary;
background: rgba(59, 130, 246, 0.05);
}
&.active {
background: @bg-block;
color: @accent-blue;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.12);
}
}
// Upload Area
.upload-area {
min-height: 200px;
border: 1.5px dashed rgba(59, 130, 246, 0.2);
border-radius: 10px;
background: rgba(59, 130, 246, 0.02);
transition: all 0.25s ease;
&.dragover {
border-color: @accent-blue;
background: rgba(59, 130, 246, 0.06);
}
&.has-video {
border-style: solid;
border-color: rgba(59, 130, 246, 0.1);
background: @bg-block;
}
}
.hidden-input {
display: none;
}
.upload-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
cursor: pointer;
padding: 24px;
}
.upload-icon-wrapper {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(59, 130, 246, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
transition: all 0.25s ease;
.upload-icon {
font-size: 24px;
color: @accent-blue;
}
}
.upload-empty:hover .upload-icon-wrapper {
background: rgba(59, 130, 246, 0.12);
transform: scale(1.05);
}
.upload-text {
font-size: 15px;
font-weight: 500;
color: @text-primary;
margin-bottom: 6px;
}
.upload-hint {
font-size: 13px;
color: @text-tertiary;
}
.upload-preview {
padding: 16px;
}
.preview-video {
width: 100%;
max-height: 260px;
border-radius: 4px;
background: #191919;
}
.replace-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: @text-secondary;
background: transparent;
border: 1px solid rgba(59, 130, 246, 0.15);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: @accent-blue;
border-color: @accent-blue;
background: rgba(59, 130, 246, 0.05);
}
}
// Library Video Preview
.library-preview {
padding: 16px;
background: rgba(59, 130, 246, 0.03);
border-radius: 10px;
border: 1px solid rgba(59, 130, 246, 0.1);
.preview-video {
width: 100%;
max-height: 260px;
border-radius: 4px;
background: #191919;
}
.video-meta {
margin-top: 12px;
}
.replace-btn {
margin-top: 8px;
}
}
.video-thumbnail {
width: 120px;
height: 68px;
border-radius: 4px;
overflow: hidden;
background: #191919;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.video-meta {
flex: 1;
min-width: 0;
}
.video-name {
font-size: 14px;
font-weight: 500;
color: @text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
max-width: 180px;
}
.video-info {
font-size: 12px;
color: @text-tertiary;
}
// Process Status
.process-status {
margin-top: 14px;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.04);
border-radius: 8px;
&.recognized {
background: rgba(16, 185, 129, 0.08);
}
&.error {
background: rgba(239, 68, 68, 0.08);
}
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: @text-secondary;
&.success {
color: @accent-green;
}
&.error {
color: @accent-red;
}
}
.link-btn {
margin-left: auto;
padding: 0;
font-size: 13px;
font-weight: 500;
color: @accent-blue;
background: none;
border: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
// Input Section
.input-section {
margin-bottom: 24px;
}
.input-label {
display: block;
font-size: 12px;
font-weight: 600;
color: @text-secondary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.notion-textarea {
width: 100%;
:deep(.ant-input) {
border: none;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
padding: 14px 16px;
background: rgba(59, 130, 246, 0.03);
color: @text-primary;
&::placeholder {
color: @text-tertiary;
}
&:focus {
background: @bg-block;
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
}
}
:deep(.ant-input-data-count) {
color: @text-tertiary;
font-size: 12px;
}
}
.input-footer {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: @text-tertiary;
margin-top: 8px;
}
.generate-text-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: @accent-blue;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.06);
border-color: rgba(59, 130, 246, 0.3);
}
}
// Rate Control
.rate-control {
display: flex;
align-items: center;
gap: 16px;
}
.rate-slider {
flex: 1;
:deep(.ant-slider-rail) {
background: rgba(59, 130, 246, 0.1);
}
:deep(.ant-slider-track) {
background: @accent-gradient;
}
:deep(.ant-slider-handle) {
border-color: @accent-blue;
&:hover, &:focus {
border-color: @accent-blue;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
}
}
:deep(.ant-slider-mark-text) {
font-size: 11px;
color: @text-tertiary;
}
}
.rate-value {
font-size: 14px;
font-weight: 600;
color: @text-primary;
min-width: 40px;
text-align: right;
}
// 生成按钮 - 左右布局,右下角
.generate-action {
margin-top: auto;
padding-top: 16px;
display: flex;
justify-content: flex-end;
}
.result-banner {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
&.success {
background: rgba(16, 185, 129, 0.08);
color: @accent-green;
}
&.error {
background: rgba(239, 68, 68, 0.08);
color: @accent-red;
}
.banner-icon {
font-size: 16px;
flex-shrink: 0;
}
.banner-text {
flex: 1;
text-align: right;
}
.retry-btn {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid currentColor;
background: transparent;
color: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
&:hover {
background: rgba(16, 185, 129, 0.1);
}
}
}
.generate-btn {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border: none;
border-radius: 8px;
background: @accent-gradient;
color: white;
cursor: pointer;
overflow: hidden;
transition: all 0.25s ease;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.25);
&:hover:not(.disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.35);
}
&:active:not(.disabled) {
transform: translateY(0);
}
&.disabled {
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
cursor: not-allowed;
box-shadow: 0 2px 8px rgba(148, 163, 184, 0.25);
}
.btn-glow {
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.15),
transparent
);
animation: glow-slide 3s ease-in-out infinite;
}
.btn-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
position: relative;
z-index: 1;
}
.btn-icon {
font-size: 15px;
}
.btn-spin {
font-size: 13px;
animation: spin 1s linear infinite;
}
.btn-right {
display: flex;
align-items: baseline;
gap: 2px;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.15);
border-radius: 10px;
font-size: 10px;
position: relative;
z-index: 1;
}
.cost-num {
font-size: 12px;
font-weight: 700;
}
.cost-label {
opacity: 0.9;
}
}
@keyframes glow-slide {
0% {
left: -100%;
}
50%, 100% {
left: 100%;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.link-btn {
padding: 0;
font-size: 13px;
font-weight: 500;
color: inherit;
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
opacity: 0.8;
}
}
</style>