This commit is contained in:
2026-03-05 21:01:34 +08:00
parent 27d1c53b49
commit c07a61c424
21 changed files with 3061 additions and 1465 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
<template>
<div class="generate-step">
<div class="step-header">
<div class="step-indicator">3</div>
<h3 class="step-title">生成数字人视频</h3>
</div>
<!-- 生成摘要 -->
<div class="generate-summary">
<div class="summary-item">
<VideoCameraOutlined class="summary-icon" />
<div class="summary-content">
<span class="summary-label">视频素材</span>
<span class="summary-value">{{ videoName }}</span>
</div>
</div>
<div class="summary-item">
<SoundOutlined class="summary-icon" />
<div class="summary-content">
<span class="summary-label">配音音色</span>
<span class="summary-value">{{ store.voice?.name || '未选择' }}</span>
</div>
</div>
<div class="summary-item">
<FileTextOutlined class="summary-icon" />
<div class="summary-content">
<span class="summary-label">文案字数</span>
<span class="summary-value">{{ store.text?.length || 0 }} </span>
</div>
</div>
<div class="summary-item">
<ClockCircleOutlined class="summary-icon" />
<div class="summary-content">
<span class="summary-label">人脸时长</span>
<span class="summary-value">{{ formatDurationMs(store.faceDurationMs) }}</span>
</div>
</div>
<div class="summary-item">
<AudioOutlined class="summary-icon" />
<div class="summary-content">
<span class="summary-label">音频时长</span>
<span class="summary-value">{{ formatDurationMs(store.audioDurationMs) }}</span>
</div>
</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"
/>
<!-- 积分预估 -->
<div class="points-section">
<div class="points-row">
<span class="points-label">预计消耗积分</span>
<span class="points-value">{{ estimatedPoints }} 积分</span>
</div>
<div class="points-row">
<span class="points-label">当前余额</span>
<span class="points-value">{{ userStore.remainingPoints.toLocaleString() }} 积分</span>
</div>
</div>
<!-- 生成按钮 -->
<div class="action-section">
<a-button
v-if="!store.isDone"
type="primary"
size="large"
:disabled="!store.canGenerate"
:loading="store.createStep === 'creating'"
block
@click="store.createTask"
class="action-btn"
>
<template v-if="store.createStep === 'creating'">
正在创建任务...
</template>
<template v-else>
<PlayCircleOutlined /> 生成数字人视频
</template>
</a-button>
<!-- 成功状态 -->
<div v-if="store.isDone" class="success-result">
<CheckCircleFilled class="success-icon" />
<h4>任务已提交成功</h4>
<p>请在任务中心查看生成进度</p>
<a-button type="primary" @click="store.reset">
重新生成
</a-button>
</div>
<!-- 错误状态 -->
<div v-if="store.createStep === 'error'" class="error-result">
<ExclamationCircleFilled class="error-icon" />
<span>{{ store.error }}</span>
<a-button type="link" @click="store.retry">重试</a-button>
</div>
</div>
<!-- 导航按钮 -->
<div v-if="!store.isDone" class="nav-buttons">
<a-button size="large" @click="store.goPrevPhase">
<LeftOutlined /> 上一步
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
VideoCameraOutlined,
SoundOutlined,
FileTextOutlined,
ClockCircleOutlined,
AudioOutlined,
PlayCircleOutlined,
CheckCircleFilled,
ExclamationCircleFilled,
LeftOutlined,
} from '@ant-design/icons-vue'
import TimelinePanel from './TimelinePanel.vue'
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
import { useUserStore } from '@/stores/user'
import { usePointsConfigStore } from '@/stores/pointsConfig'
import { formatDurationMs } from '../utils/format'
const store = useDigitalHumanStore()
const userStore = useUserStore()
const pointsConfigStore = usePointsConfigStore()
const videoName = computed(() => {
return store.selectedVideo?.fileName || store.videoFile?.name || '未选择'
})
const estimatedPoints = computed(() => {
const points = pointsConfigStore.getConsumePoints('kling')
return points ?? 150
})
</script>
<style scoped lang="less">
.generate-step {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
}
.step-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.step-indicator {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: #fff;
border-radius: 8px 0 12px 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.step-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.generate-summary {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: #f8fafc;
border-radius: 8px;
}
.summary-icon {
font-size: 18px;
color: #3b82f6;
}
.summary-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary-label {
font-size: 11px;
color: #94a3b8;
}
.summary-value {
font-size: 13px;
font-weight: 500;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
.points-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #f8fafc;
border-radius: 8px;
margin-top: 16px;
}
.points-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.points-label {
font-size: 12px;
color: #64748b;
}
.points-value {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.action-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.action-btn {
height: 44px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
&.ant-btn-primary {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border: none;
&:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.35);
}
&:disabled {
background: #d1d5db;
}
}
}
.success-result {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
background: #f0fdf4;
border-radius: 10px;
text-align: center;
.success-icon {
font-size: 48px;
color: #22c55e;
margin-bottom: 12px;
}
h4 {
font-size: 16px;
font-weight: 600;
color: #166534;
margin: 0 0 8px 0;
}
p {
font-size: 13px;
color: #15803d;
margin: 0 0 16px 0;
}
}
.error-result {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #fee2e2;
border-radius: 6px;
font-size: 13px;
color: #dc2626;
.error-icon {
font-size: 16px;
}
}
.nav-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
.ant-btn {
flex: 1;
height: 44px;
font-size: 14px;
font-weight: 500;
}
}
@media (max-width: 480px) {
.generate-summary {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="step-navigation">
<div
v-for="(s, idx) in steps"
:key="s.key"
class="step-item"
:class="{
active: currentPhase === s.key,
done: isStepDone(s.key),
clickable: canNavigateTo(s.key)
}"
@click="handleNavigate(s.key)"
>
<div class="step-number">
<CheckOutlined v-if="isStepDone(s.key)" />
<span v-else>{{ idx + 1 }}</span>
</div>
<span class="step-label">{{ s.label }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckOutlined } from '@ant-design/icons-vue'
import type { PipelinePhase } from '../types/identify-face'
interface Props {
currentPhase: PipelinePhase
videoReady: boolean
audioReady: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'navigate', phase: PipelinePhase): void
}>()
const steps = [
{ key: 'select-video' as const, label: '选择视频' },
{ key: 'add-voice' as const, label: '添加配音' },
{ key: 'generate' as const, label: '生成视频' },
]
function isStepDone(key: PipelinePhase): boolean {
if (key === 'select-video') return props.videoReady
if (key === 'add-voice') return props.audioReady
return false
}
function canNavigateTo(key: PipelinePhase): boolean {
// 可以点击返回已完成的步骤
if (key === 'select-video') return true
if (key === 'add-voice') return props.videoReady
if (key === 'generate') return props.audioReady
return false
}
function handleNavigate(key: PipelinePhase) {
if (canNavigateTo(key)) {
emit('navigate', key)
}
}
</script>
<style scoped lang="less">
.step-navigation {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 24px;
padding: 16px 0;
border-bottom: 1px solid #e2e8f0;
}
.step-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
border-radius: 20px;
cursor: default;
transition: all 0.2s ease;
position: relative;
&:not(:last-child)::after {
content: '';
position: absolute;
right: -20px;
width: 40px;
height: 2px;
background: #e2e8f0;
}
&:not(:last-child).done::after {
background: #22c55e;
}
&.clickable {
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
&.active {
.step-number {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
.step-label {
color: #1e293b;
font-weight: 600;
}
}
&.done {
.step-number {
background: #22c55e;
color: white;
}
.step-label {
color: #22c55e;
}
}
}
.step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: #e2e8f0;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.step-label {
font-size: 14px;
color: #94a3b8;
transition: all 0.2s ease;
}
@media (max-width: 640px) {
.step-navigation {
gap: 0;
}
.step-item {
padding: 8px 12px;
&::after {
width: 20px;
right: -10px;
}
}
.step-label {
display: none;
}
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="timeline-panel">
<div class="timeline-header">
<span class="timeline-title">时间轴对比</span>
<span v-if="showDurations" class="duration-info">
人脸: {{ formatDuration(faceDurationMs) }}
<template v-if="audioDurationMs > 0">
| 音频: {{ formatDuration(audioDurationMs) }}
</template>
</span>
</div>
<!-- 刻度尺 -->
<div class="timeline-ruler">
<div
v-for="mark in rulerMarks"
:key="mark.time"
class="ruler-mark"
:style="{ left: mark.position + '%' }"
>
<span class="ruler-label">{{ mark.label }}</span>
<span class="ruler-tick"></span>
</div>
</div>
<!-- 轨道区域 -->
<div class="timeline-tracks">
<!-- 视频轨道 -->
<div class="track video-track">
<span class="track-icon">📹</span>
<span class="track-label">视频</span>
<div class="track-bar">
<div
class="track-fill video-fill"
:style="{ width: videoBarWidth + '%' }"
>
<span class="track-time">{{ formatDuration(faceDurationMs) }}</span>
</div>
</div>
</div>
<!-- 音频轨道 -->
<div class="track audio-track">
<span class="track-icon">🎵</span>
<span class="track-label">音频</span>
<div class="track-bar">
<div
v-if="audioDurationMs > 0"
class="track-fill audio-fill"
:class="{ 'audio-exceed': isExceed }"
:style="{ width: audioBarWidth + '%' }"
>
<span class="track-time">{{ formatDuration(audioDurationMs) }}</span>
</div>
<span v-else class="track-placeholder">等待生成音频</span>
</div>
</div>
</div>
<!-- 时长差异提示 -->
<div v-if="audioDurationMs > 0" class="timeline-diff" :class="diffStatus">
<template v-if="diffStatus === 'match'">
<CheckCircleOutlined class="diff-icon" />
<span>时长匹配良好</span>
</template>
<template v-else-if="diffStatus === 'exceed'">
<ExclamationCircleOutlined class="diff-icon" />
<span>音频超出 {{ formatDuration(diffMs) }}请缩短文案</span>
</template>
<template v-else-if="diffStatus === 'short'">
<InfoCircleOutlined class="diff-icon" />
<span>音频较短可适当增加文案</span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CheckCircleOutlined, ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
import { formatDurationMs } from '../utils/format'
interface Props {
faceDurationMs: number
audioDurationMs: number
faceStartTime?: number
faceEndTime?: number
}
const props = withDefaults(defineProps<Props>(), {
faceStartTime: 0,
faceEndTime: 0,
})
const maxDuration = computed(() => {
const durations = [props.faceDurationMs, props.audioDurationMs].filter(d => d > 0)
return Math.max(...durations, 1000)
})
const videoBarWidth = computed(() =>
Math.min(100, (props.faceDurationMs / maxDuration.value) * 100)
)
const audioBarWidth = computed(() =>
Math.min(100, (props.audioDurationMs / maxDuration.value) * 100)
)
const isExceed = computed(() => props.audioDurationMs > props.faceDurationMs)
const diffMs = computed(() => Math.abs(props.audioDurationMs - props.faceDurationMs))
const diffStatus = computed(() => {
if (props.audioDurationMs === 0) return 'none'
if (props.audioDurationMs > props.faceDurationMs) return 'exceed'
if (props.audioDurationMs < props.faceDurationMs * 0.5) return 'short'
return 'match'
})
const showDurations = computed(() => props.faceDurationMs > 0)
const rulerMarks = computed(() => {
if (maxDuration.value <= 0) return []
const marks: Array<{ time: number; label: string; position: number }> = []
const interval = calculateInterval(maxDuration.value)
const count = Math.ceil(maxDuration.value / interval)
for (let i = 0; i <= count; i++) {
const time = i * interval
const position = (time / maxDuration.value) * 100
if (position <= 100) {
marks.push({
time,
label: `${(time / 1000).toFixed(0)}s`,
position,
})
}
}
return marks
})
function calculateInterval(duration: number): number {
const seconds = duration / 1000
if (seconds <= 10) return 2000
if (seconds <= 30) return 5000
if (seconds <= 60) return 10000
return 15000
}
const formatDuration = formatDurationMs
</script>
<style scoped lang="less">
.timeline-panel {
background: #f8fafc;
border-radius: 10px;
padding: 16px;
margin-top: 16px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.timeline-title {
font-size: 14px;
font-weight: 600;
color: #1e293b;
}
.duration-info {
font-size: 12px;
color: #64748b;
}
}
// 刻度尺
.timeline-ruler {
position: relative;
height: 20px;
margin-bottom: 8px;
margin-left: 70px; // 为图标和标签留空间
}
.ruler-mark {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.ruler-label {
font-size: 10px;
color: #94a3b8;
margin-bottom: 2px;
}
.ruler-tick {
display: block;
width: 1px;
height: 4px;
background: #cbd5e1;
}
// 轨道区域
.timeline-tracks {
display: flex;
flex-direction: column;
gap: 8px;
}
.track {
display: flex;
align-items: center;
gap: 8px;
.track-icon {
font-size: 14px;
width: 20px;
text-align: center;
}
.track-label {
width: 34px;
font-size: 12px;
color: #64748b;
flex-shrink: 0;
}
.track-bar {
flex: 1;
height: 24px;
background: #e2e8f0;
border-radius: 4px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
}
.track-fill {
height: 100%;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.3s ease;
}
.track-time {
font-size: 11px;
color: white;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.track-placeholder {
font-size: 11px;
color: #94a3b8;
padding-left: 12px;
}
}
.video-fill {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
}
.audio-fill {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
&.audio-exceed {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
// 差异提示
.timeline-diff {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
.diff-icon {
font-size: 14px;
}
&.match {
background: #dcfce7;
color: #166534;
}
&.exceed {
background: #fee2e2;
color: #dc2626;
}
&.short {
background: #fef3c7;
color: #92400e;
}
}
</style>

View File

@@ -0,0 +1,472 @@
<template>
<div class="video-select-step">
<div class="step-header">
<div class="step-indicator">1</div>
<h3 class="step-title">选择视频素材</h3>
</div>
<!-- 视频来源选项 -->
<div class="video-options">
<div
class="video-option-card"
:class="{ selected: store.videoSource === 'upload' }"
@click="store.selectUploadMode"
>
<div class="option-icon">
<CloudUploadOutlined />
</div>
<div class="option-content">
<h4>上传新视频</h4>
<p>支持 MP4MOV 格式</p>
</div>
</div>
<div
class="video-option-card"
:class="{ selected: store.videoSource === 'select' }"
@click="store.selectLibraryMode"
>
<div class="option-icon">
<FolderOutlined />
</div>
<div class="option-content">
<h4>从素材库选择</h4>
<p>选择已上传的视频</p>
</div>
</div>
</div>
<!-- 上传区域 -->
<div
v-if="store.videoSource === 'upload'"
class="upload-zone"
:class="{ dragover: dragOver }"
@drop.prevent="handleDrop"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
>
<input
ref="fileInput"
type="file"
accept=".mp4,.mov"
class="file-input"
@change="handleFileSelect"
/>
<div v-if="!store.videoPreviewUrl" class="upload-placeholder" @click="triggerFileSelect">
<CloudUploadOutlined class="upload-icon" />
<span class="upload-text">点击上传新视频</span>
<span class="upload-hint">支持 MP4MOV 格式 >3</span>
<button class="select-file-btn" @click.stop="triggerFileSelect">选择文件</button>
</div>
<div v-else class="upload-preview">
<video
:src="store.videoPreviewUrl"
controls
playsinline
preload="metadata"
class="preview-video"
></video>
<div class="preview-actions">
<span class="preview-filename">{{ store.videoFile?.name }}</span>
<button class="change-btn" @click="clearVideo">更换</button>
</div>
</div>
</div>
<!-- 已选视频预览素材库 -->
<div v-if="store.selectedVideo && store.videoSource === 'select'" class="selected-preview">
<div class="preview-thumb">
<img
:src="getVideoPreviewUrl(store.selectedVideo)"
:alt="store.selectedVideo.fileName"
/>
</div>
<div class="preview-info">
<div class="preview-name">{{ store.selectedVideo.fileName }}</div>
<div class="preview-meta">
<span>{{ formatDuration(store.selectedVideo.duration) }}</span>
<span class="divider">|</span>
<span>{{ formatFileSize(store.selectedVideo.fileSize) }}</span>
</div>
</div>
<button class="change-btn" @click="clearVideo">更换</button>
</div>
<!-- 识别状态 -->
<div v-if="store.videoStep !== 'idle'" class="recognize-status" :class="store.videoStep">
<div v-if="store.videoStep === 'uploading'" class="status-content loading">
<a-spin size="small" />
<span>正在上传视频...</span>
</div>
<div v-else-if="store.videoStep === 'recognizing'" class="status-content loading">
<a-spin size="small" />
<span>正在识别人脸...</span>
</div>
<div v-else-if="store.videoStep === 'recognized'" class="status-content success">
<CheckCircleOutlined />
<span>识别成功人脸时长: {{ formatDurationMs(store.faceDurationMs) }}</span>
</div>
<div v-else-if="store.videoStep === 'error'" class="status-content error">
<ExclamationCircleOutlined />
<span>{{ store.error }}</span>
<a-button size="small" type="link" @click="store.retry">重试</a-button>
</div>
</div>
<!-- 下一步按钮 -->
<div v-if="store.isVideoReady" class="step-actions">
<a-button type="primary" size="large" block @click="store.goNextPhase">
下一步添加配音
<RightOutlined />
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
CloudUploadOutlined,
FolderOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RightOutlined,
} from '@ant-design/icons-vue'
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
import { formatDuration, formatDurationMs, formatFileSize } from '../utils/format'
const store = useDigitalHumanStore()
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
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 getVideoPreviewUrl(video: any): string {
if (video.coverBase64) {
return video.coverBase64.startsWith('data:')
? video.coverBase64
: `data:image/jpeg;base64,${video.coverBase64}`
}
return video.imgUrl || video.coverUrl || ''
}
</script>
<style scoped lang="less">
.video-select-step {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
}
.step-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.step-indicator {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
border-radius: 8px 0 12px 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.step-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.video-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.video-option-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 2px dashed #e2e8f0;
border-radius: 10px;
background: #f8fafc;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #3b82f6;
background: #eff6ff;
}
&.selected {
border-style: solid;
border-color: #2563eb;
background: #eff6ff;
}
}
.option-icon {
font-size: 20px;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: #fff;
border-radius: 6px;
}
.option-content {
flex: 1;
h4 {
font-size: 13px;
font-weight: 500;
color: #1e293b;
margin: 0 0 2px 0;
}
p {
font-size: 11px;
color: #94a3b8;
margin: 0;
}
}
.upload-zone {
min-height: 160px;
border: 2px dashed #e2e8f0;
border-radius: 10px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&.dragover {
border-color: #3b82f6;
background: #eff6ff;
}
}
.file-input {
display: none;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
cursor: pointer;
}
.upload-icon {
font-size: 32px;
color: #94a3b8;
}
.upload-text {
font-size: 14px;
font-weight: 500;
color: #1e293b;
}
.upload-hint {
font-size: 12px;
color: #94a3b8;
}
.select-file-btn {
margin-top: 8px;
padding: 6px 16px;
font-size: 12px;
font-weight: 500;
color: #3b82f6;
border: 1px solid #3b82f6;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(59, 130, 246, 0.1);
}
}
.upload-preview {
width: 100%;
padding: 12px;
}
.preview-video {
width: 100%;
max-height: 200px;
border-radius: 8px;
background: #1e293b;
}
.preview-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
}
.preview-filename {
font-size: 12px;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.selected-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f1f5f9;
border-radius: 8px;
margin-bottom: 16px;
}
.preview-thumb {
width: 80px;
height: 45px;
border-radius: 6px;
overflow: hidden;
background: #1e293b;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.preview-info {
flex: 1;
min-width: 0;
}
.preview-name {
font-size: 13px;
font-weight: 500;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-meta {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
.divider {
margin: 0 6px;
}
}
.change-btn {
padding: 4px 10px;
font-size: 11px;
color: #64748b;
border: none;
background: transparent;
cursor: pointer;
text-decoration: underline;
flex-shrink: 0;
&:hover {
color: #3b82f6;
}
}
.recognize-status {
margin-top: 16px;
padding: 12px 16px;
border-radius: 8px;
background: #f8fafc;
&.recognized {
background: #dcfce7;
}
&.error {
background: #fee2e2;
}
}
.status-content {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
&.loading {
color: #64748b;
}
&.success {
color: #166534;
}
&.error {
color: #dc2626;
}
}
.step-actions {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div class="voice-config-step">
<div class="step-header">
<div class="step-indicator">2</div>
<h3 class="step-title">添加配音</h3>
</div>
<!-- 文案输入 -->
<div class="text-section">
<label class="section-label">播报文案</label>
<a-textarea
v-model:value="store.text"
:placeholder="placeholder"
:rows="4"
:maxlength="4000"
:show-count="true"
class="text-input"
:bordered="false"
/>
<div class="text-meta">
<span>当前字数{{ store.text?.length || 0 }} </span>
<span>建议字数{{ suggestedChars }} </span>
</div>
</div>
<!-- 音色选择 -->
<div class="voice-section">
<label class="section-label">选择音色</label>
<VoiceSelector
:synth-text="store.text"
:speech-rate="store.speechRate"
@select="store.setVoice"
/>
</div>
<!-- 语速调节 -->
<div class="rate-section">
<label class="section-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"
/>
<!-- 生成音频按钮 -->
<div class="action-section">
<a-button
type="primary"
size="large"
:loading="store.audioStep === 'generating'"
:disabled="!canGenerateAudio"
block
@click="store.generateAudio"
class="action-btn"
>
<template v-if="store.audioStep === 'generated'">
<ReloadOutlined /> 重新生成音频
</template>
<template v-else>
<SoundOutlined /> 生成音频
</template>
</a-button>
<div v-if="store.audioStep === 'generated'" class="audio-result">
<CheckCircleOutlined class="success-icon" />
<span>音频已生成时长: {{ formatDurationMs(store.audioDurationMs) }}</span>
</div>
<div v-if="store.audioStep === 'error'" class="audio-error">
<ExclamationCircleOutlined class="error-icon" />
<span>{{ store.error }}</span>
<a-button size="small" type="link" @click="store.retry">重试</a-button>
</div>
</div>
<!-- 导航按钮 -->
<div v-if="store.isAudioReady" class="nav-buttons">
<a-button size="large" @click="store.goPrevPhase">
<LeftOutlined /> 上一步
</a-button>
<a-button
type="primary"
size="large"
:disabled="!store.canGenerate"
@click="store.goNextPhase"
>
下一步生成视频 <RightOutlined />
</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
SoundOutlined,
ReloadOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue'
import VoiceSelector from '@/components/VoiceSelector.vue'
import TimelinePanel from './TimelinePanel.vue'
import { useDigitalHumanStore } from '../stores/useDigitalHumanStore'
import { formatDurationMs } from '../utils/format'
const store = useDigitalHumanStore()
const suggestedChars = computed(() => {
return Math.floor((store.faceDurationMs || 10000) / 1000 * 4)
})
const placeholder = computed(() => {
if (store.faceDurationMs > 0) {
return `请输入播报文案,建议不超过 ${suggestedChars.value} 字以确保与视频匹配`
}
return '请输入你想让角色说话的内容'
})
const canGenerateAudio = computed(() => {
return store.text.trim() && store.voice && store.isVideoReady
})
const rateMarks = {
0.5: '0.5x',
1.0: '1.0x',
1.5: '1.5x',
2.0: '2.0x',
}
</script>
<style scoped lang="less">
.voice-config-step {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
}
.step-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.step-indicator {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
border-radius: 8px 0 12px 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
}
.step-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0;
}
.section-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #64748b;
margin-bottom: 8px;
}
.text-section {
margin-bottom: 20px;
}
.text-input {
width: 100%;
:deep(.ant-input) {
border: none;
border-radius: 8px;
font-size: 14px;
padding: 16px 12px;
background: #f8fafc;
&:focus {
background: #fff;
box-shadow: 0 0 0 1px #e2e8f0;
}
}
}
.text-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #94a3b8;
margin-top: 8px;
}
.voice-section {
margin-bottom: 20px;
}
.rate-section {
margin-bottom: 20px;
}
.rate-control {
display: flex;
align-items: center;
gap: 16px;
}
.rate-slider {
flex: 1;
:deep(.ant-slider-mark-text) {
font-size: 10px;
color: #94a3b8;
}
}
.rate-value {
font-size: 14px;
font-weight: 600;
color: #1e293b;
min-width: 40px;
text-align: right;
}
.action-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.action-btn {
height: 44px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
&.ant-btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
&:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
}
&:disabled {
background: #d1d5db;
}
}
}
.audio-result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
background: #dcfce7;
border-radius: 6px;
font-size: 13px;
color: #166534;
.success-icon {
font-size: 16px;
}
}
.audio-error {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
background: #fee2e2;
border-radius: 6px;
font-size: 13px;
color: #dc2626;
.error-icon {
font-size: 16px;
}
}
.nav-buttons {
display: flex;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
.ant-btn {
flex: 1;
height: 44px;
font-size: 14px;
font-weight: 500;
}
}
</style>

View File

@@ -1,24 +1,23 @@
/**
* @fileoverview 数字人合成 Store - 单一状态管理
* @fileoverview 数字人合成 Store - 三步骤分步流程
*
* 设计理念:
* 1. 单一状态源 - 所有状态集中管理
* 2. 简单直观 - 一个 generate() 方法完成全流程
* 3. 易于调试 - 断点打在这里即可
* 1. 三步骤流程:选择视频 → 添加配音 → 生成视频
* 2. 自动识别:选择视频后自动触发人脸识别
* 3. 时间轴可视化:实时对比视频和音频时长
*/
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { message } from 'ant-design-vue'
import { VoiceService } from '@/api/voice'
import { uploadAndIdentifyVideo, identifyUploadedVideo, createLipSyncTask } from '@/api/kling'
import { identifyFace, createLipSyncTask } from '@/api/kling'
import { MaterialService } from '@/api/material'
import { useUpload } from '@/composables/useUpload'
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
import type { VoiceMeta, Video } from '../types/identify-face'
import type { VoiceMeta, Video, PipelinePhase, VideoStep, AudioStep, CreateStep, TimelineData } from '../types/identify-face'
// ========== 类型定义 ==========
/** 流程步骤 */
export type GenerateStep = 'idle' | 'uploading' | 'recognizing' | 'generating' | 'creating' | 'done' | 'error'
// ========== 内部类型定义 ==========
/** 音频数据 */
interface AudioData {
@@ -39,7 +38,7 @@ interface IdentifyData {
// ========== Store 定义 ==========
export const useDigitalHumanStore = defineStore('digitalHuman', () => {
// ==================== 状态 ====================
// ==================== 基础状态 ====================
/** 文案内容 */
const text = ref('')
@@ -62,9 +61,6 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 视频预览URL */
const videoPreviewUrl = ref('')
/** 当前步骤 */
const step = ref<GenerateStep>('idle')
/** 错误信息 */
const error = ref('')
@@ -77,27 +73,71 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
/** 视频选择器可见性 */
const videoSelectorVisible = ref(false)
// ==================== 三步骤流程状态(新增) ====================
/** 当前主阶段 */
const phase = ref<PipelinePhase>('select-video')
/** 视频步骤状态 */
const videoStep = ref<VideoStep>('idle')
/** 音频步骤状态 */
const audioStep = ref<AudioStep>('idle')
/** 生成步骤状态 */
const createStep = ref<CreateStep>('idle')
/** 时间轴数据 */
const timeline = ref<TimelineData | null>(null)
// ==================== 兼容性状态(保留原 step 状态) ====================
/** 兼容旧流程的状态 */
const step = computed(() => {
// 映射新状态到旧状态
if (videoStep.value === 'uploading') return 'uploading'
if (videoStep.value === 'recognizing') return 'recognizing'
if (audioStep.value === 'generating') return 'generating'
if (createStep.value === 'creating') return 'creating'
if (createStep.value === 'done') return 'done'
if (videoStep.value === 'error' || audioStep.value === 'error' || createStep.value === 'error') return 'error'
return 'idle'
})
// ==================== 计算属性 ====================
/** 是否有视频 */
const hasVideo = computed(() => !!(videoFile.value || selectedVideo.value))
/** 是否可以生成 */
const canGenerate = computed(() => {
if (step.value !== 'idle') return false
return !!(text.value.trim() && voice.value && hasVideo.value)
/** 视频阶段是否完成 */
const isVideoReady = computed(() => videoStep.value === 'recognized')
/** 音频阶段是否完成 */
const isAudioReady = computed(() => audioStep.value === 'generated')
/** 是否可以进入下一步 */
const canGoNext = computed(() => {
if (phase.value === 'select-video') return isVideoReady.value
if (phase.value === 'add-voice') return isAudioReady.value
return false
})
/** 是否正在处理 */
const isBusy = computed(() =>
['uploading', 'recognizing', 'generating', 'creating'].includes(step.value)
)
/** 是否可以生成步骤3 */
const canGenerate = computed(() => {
if (!isVideoReady.value || !isAudioReady.value) return false
if (!timeline.value) return false
// 音频时长不能超过人脸时长
return timeline.value.audioDurationMs <= timeline.value.videoDurationMs
})
/** 是否完成 */
const isDone = computed(() => step.value === 'done')
/** 是否失败 */
const isFailed = computed(() => step.value === 'error')
/** 时间轴匹配状态 */
const timelineMatch = computed(() => {
if (!timeline.value || timeline.value.audioDurationMs === 0) return 'none'
const { videoDurationMs, audioDurationMs } = timeline.value
if (audioDurationMs > videoDurationMs) return 'exceed'
if (audioDurationMs < videoDurationMs * 0.3) return 'too-short'
return 'match'
})
/** 人脸时长(ms) */
const faceDurationMs = computed(() => {
@@ -105,36 +145,49 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
return identifyData.value.faceEndTime - identifyData.value.faceStartTime
})
/** 音频时长(ms) */
const audioDurationMs = computed(() => audioData.value?.durationMs || 0)
/** 是否正在处理 */
const isBusy = computed(() =>
['uploading', 'recognizing'].includes(videoStep.value) ||
audioStep.value === 'generating' ||
createStep.value === 'creating'
)
/** 是否完成 */
const isDone = computed(() => createStep.value === 'done')
/** 是否失败 */
const isFailed = computed(() =>
videoStep.value === 'error' ||
audioStep.value === 'error' ||
createStep.value === 'error'
)
/** 步骤进度 (0-100) */
const progress = computed(() => {
const stepProgress: Record<GenerateStep, number> = {
idle: 0,
uploading: 20,
recognizing: 40,
generating: 60,
creating: 80,
done: 100,
error: 0,
}
return stepProgress[step.value]
if (createStep.value === 'done') return 100
if (createStep.value === 'creating') return 80
if (audioStep.value === 'generated') return 60
if (audioStep.value === 'generating') return 50
if (videoStep.value === 'recognized') return 40
if (videoStep.value === 'recognizing') return 30
if (videoStep.value === 'uploading') return 20
return 0
})
/** 步骤标签 */
const stepLabel = computed(() => {
const labels: Record<GenerateStep, string> = {
idle: '准备就绪',
uploading: '上传视频',
recognizing: '识别人脸',
generating: '生成音频',
creating: '创建任务',
done: '完成',
error: '失败',
}
return labels[step.value]
if (createStep.value === 'creating') return '创建任务'
if (audioStep.value === 'generating') return '生成音频'
if (videoStep.value === 'recognizing') return '识别人脸'
if (videoStep.value === 'uploading') return '上传视频'
if (createStep.value === 'done') return '完成'
return '准备就绪'
})
// ==================== 方法 ====================
// ==================== 方法:基础设置 ====================
/** 设置音色 */
function setVoice(v: VoiceMeta) {
@@ -157,7 +210,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
resetProcess()
}
/** 处理文件上传 */
// ==================== 方法步骤1 - 视频选择与识别 ====================
/** 处理文件上传(上传后自动识别) */
async function handleFileUpload(file: File) {
if (!file.name.match(/\.(mp4|mov)$/i)) {
message.error('仅支持 MP4 和 MOV 格式')
@@ -173,25 +228,276 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoPreviewUrl.value = URL.createObjectURL(file)
selectedVideo.value = null
videoSource.value = 'upload'
resetProcess()
// 自动识别
await recognizeVideo()
}
/** 从素材库选择视频 */
function selectVideo(video: Video) {
/** 从素材库选择视频(选择后自动识别) */
async function selectVideo(video: Video) {
selectedVideo.value = video
videoPreviewUrl.value = video.fileUrl
videoFile.value = null
videoSource.value = 'select'
videoSelectorVisible.value = false
resetProcess()
// 素材列表返回的 fileUrl 已带签名,直接使用
videoPreviewUrl.value = video.fileUrl
// 自动识别
await recognizeVideo()
}
/** 识别视频步骤1核心方法 */
async function recognizeVideo() {
if (!hasVideo.value) return
videoStep.value = videoFile.value ? 'uploading' : 'recognizing'
error.value = ''
try {
let result: IdentifyData
if (selectedVideo.value) {
// 素材库视频 - 直接识别
videoStep.value = 'recognizing'
result = await recognizeExistingVideo(selectedVideo.value)
} else if (videoFile.value) {
// 上传新视频 - 上传并识别
result = await uploadAndRecognizeVideo(videoFile.value)
} else {
throw new Error('请先选择视频')
}
identifyData.value = result
videoStep.value = 'recognized'
// 更新时间轴
timeline.value = {
videoDurationMs: result.faceEndTime - result.faceStartTime,
audioDurationMs: 0,
faceStartTime: result.faceStartTime,
faceEndTime: result.faceEndTime,
}
message.success('人脸识别成功')
} catch (err: any) {
videoStep.value = 'error'
error.value = err.message || '识别失败'
message.error(error.value)
}
}
/** 上传并识别新视频 */
async function uploadAndRecognizeVideo(file: File): Promise<IdentifyData> {
// 1. 使用 useUpload 直传文件
const { upload } = useUpload()
const fileId = await upload(file, { fileCategory: 'digital_human' } as any)
// 2. 获取播放 URL
const urlRes = await MaterialService.getVideoPlayUrl(Number(fileId))
if (urlRes.code !== 0 || !urlRes.data) {
throw new Error(urlRes.msg || '获取播放链接失败')
}
// 3. 执行人脸识别
videoStep.value = 'recognizing'
return performFaceRecognition(fileId, urlRes.data, true)
}
/** 识别已存在的视频 */
async function recognizeExistingVideo(video: Video): Promise<IdentifyData> {
// 素材列表返回的 fileUrl 已带签名,直接使用
return performFaceRecognition(video.id, video.fileUrl, false)
}
/** 执行人脸识别 */
async function performFaceRecognition(fileId: number | string, videoUrl: string, isUploadedFile: boolean): Promise<IdentifyData> {
const identifyRes = await identifyFace({ video_url: videoUrl })
if (identifyRes.code !== 0) {
throw new Error(identifyRes.msg || '识别失败')
}
const faceData = identifyRes.data.data?.face_data?.[0]
const startTime = faceData?.start_time || 0
const endTime = faceData?.end_time || 0
return {
fileId: String(fileId),
sessionId: identifyRes.data.sessionId,
faceId: faceData?.face_id || '',
faceStartTime: isUploadedFile ? Math.round(startTime) : startTime,
faceEndTime: isUploadedFile ? Math.round(endTime) : endTime,
}
}
// ==================== 方法步骤2 - 音频生成 ====================
/** 生成音频步骤2核心方法 */
async function generateAudio() {
if (!text.value.trim()) {
message.warning('请输入文案内容')
return
}
if (!voice.value) {
message.warning('请选择音色')
return
}
if (!isVideoReady.value) {
message.warning('请先完成视频识别')
return
}
audioStep.value = 'generating'
error.value = ''
try {
const voiceId = voice.value.rawId ?? extractId(voice.value.id)
const res = await VoiceService.synthesize({
inputText: text.value,
voiceConfigId: voiceId,
speechRate: speechRate.value,
audioFormat: 'mp3',
providerType: DEFAULT_VOICE_PROVIDER,
} as any)
if (res.code !== 0 || !res.data?.audioBase64) {
throw new Error(res.msg || '音频生成失败')
}
const durationMs = await parseAudioDuration(res.data.audioBase64)
audioData.value = {
audioBase64: res.data.audioBase64,
format: 'mp3',
durationMs,
}
audioStep.value = 'generated'
// 更新时间轴
if (timeline.value) {
timeline.value.audioDurationMs = durationMs
}
// 检查时长是否匹配
if (durationMs > faceDurationMs.value) {
message.warning(`音频时长(${(durationMs/1000).toFixed(1)}秒)超过人脸时长,请缩短文案`)
} else {
message.success('音频生成成功')
}
} catch (err: any) {
audioStep.value = 'error'
error.value = err.message || '音频生成失败'
message.error(error.value)
}
}
// ==================== 方法步骤3 - 创建任务 ====================
/** 创建任务步骤3核心方法 */
async function createTask() {
if (!identifyData.value) {
message.warning('请先完成视频识别')
return
}
if (!audioData.value) {
message.warning('请先生成音频')
return
}
// 时长校验
if (audioData.value.durationMs > faceDurationMs.value) {
message.error('音频时长超过人脸时长,请缩短文案后重试')
return
}
createStep.value = 'creating'
error.value = ''
try {
const voiceId = voice.value!.rawId ?? extractId(voice.value!.id)
const taskRes = await createLipSyncTask({
taskName: `数字人任务_${Date.now()}`,
videoFileId: identifyData.value.fileId,
inputText: text.value,
speechRate: speechRate.value,
volume: 0,
guidanceScale: 1,
seed: 8888,
kling_session_id: identifyData.value.sessionId,
kling_face_id: identifyData.value.faceId,
kling_face_start_time: identifyData.value.faceStartTime,
kling_face_end_time: identifyData.value.faceEndTime,
ai_provider: 'kling',
voiceConfigId: voiceId,
pre_generated_audio: {
audioBase64: audioData.value.audioBase64,
format: audioData.value.format,
},
sound_end_time: audioData.value.durationMs,
})
if (taskRes.code !== 0) {
throw new Error(taskRes.msg || '任务创建失败')
}
createStep.value = 'done'
message.success('任务已提交,请在任务中心查看生成进度')
} catch (err: any) {
createStep.value = 'error'
error.value = err.message || '任务创建失败'
message.error(error.value)
}
}
// ==================== 方法:阶段导航 ====================
/** 切换到下一阶段 */
function goNextPhase() {
if (phase.value === 'select-video' && isVideoReady.value) {
phase.value = 'add-voice'
} else if (phase.value === 'add-voice' && isAudioReady.value) {
phase.value = 'generate'
}
}
/** 返回上一阶段 */
function goPrevPhase() {
if (phase.value === 'add-voice') {
phase.value = 'select-video'
} else if (phase.value === 'generate') {
phase.value = 'add-voice'
}
}
/** 跳转到指定阶段 */
function goToPhase(newPhase: PipelinePhase) {
// 只能跳转到已完成的阶段或当前阶段的下一阶段
if (newPhase === 'select-video') {
phase.value = newPhase
} else if (newPhase === 'add-voice' && isVideoReady.value) {
phase.value = newPhase
} else if (newPhase === 'generate' && isAudioReady.value) {
phase.value = newPhase
}
}
// ==================== 方法:重置 ====================
/** 重置流程状态 */
function resetProcess() {
step.value = 'idle'
videoStep.value = 'idle'
audioStep.value = 'idle'
createStep.value = 'idle'
error.value = ''
identifyData.value = null
audioData.value = null
timeline.value = null
phase.value = 'select-video'
}
/** 完全重置 */
@@ -208,13 +514,49 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoFile.value = null
selectedVideo.value = null
videoPreviewUrl.value = ''
step.value = 'idle'
error.value = ''
identifyData.value = null
audioData.value = null
videoSelectorVisible.value = false
resetProcess()
}
/** 重试当前步骤 */
function retry() {
error.value = ''
if (videoStep.value === 'error') {
videoStep.value = 'idle'
recognizeVideo()
} else if (audioStep.value === 'error') {
audioStep.value = 'idle'
generateAudio()
} else if (createStep.value === 'error') {
createStep.value = 'idle'
createTask()
}
}
// ==================== 方法:兼容旧流程 ====================
/** 一键生成(兼容旧版) */
async function generate() {
// 步骤1
if (!isVideoReady.value) {
await recognizeVideo()
if (videoStep.value === 'error') return
}
// 步骤2
if (!isAudioReady.value) {
await generateAudio()
if (audioStep.value === 'error') return
}
// 步骤3
await createTask()
}
// ==================== 工具方法 ====================
/** 解析音频时长 */
async function parseAudioDuration(base64Data: string): Promise<number> {
const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data
@@ -248,7 +590,6 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
lastDuration = duration
// 只在 canplaythrough 时 resolve此时时长最准确
if (source === 'canplaythrough') {
resolved = true
cleanup()
@@ -256,19 +597,9 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
}
}
// VBR MP3 早期事件估算不准,等待 canplaythrough
audio.ondurationchange = () => {
tryResolve(audio.duration, 'durationchange')
}
audio.oncanplay = () => {
tryResolve(audio.duration, 'canplay')
}
audio.oncanplaythrough = () => {
tryResolve(audio.duration, 'canplaythrough')
}
audio.ondurationchange = () => tryResolve(audio.duration, 'durationchange')
audio.oncanplay = () => tryResolve(audio.duration, 'canplay')
audio.oncanplaythrough = () => tryResolve(audio.duration, 'canplaythrough')
audio.onerror = () => {
if (!resolved) {
if (lastDuration > 0) {
@@ -287,129 +618,10 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
})
}
/** 生成数字人视频 - 主流程 */
async function generate() {
// 校验
if (!text.value.trim()) {
message.warning('请输入文案内容')
return
}
if (!voice.value) {
message.warning('请选择音色')
return
}
if (!hasVideo.value) {
message.warning('请先选择视频')
return
}
try {
// ===== 步骤1: 上传并识别 =====
step.value = videoFile.value ? 'uploading' : 'recognizing'
let identifyResult: IdentifyData
if (selectedVideo.value) {
// 素材库视频 - 直接识别
step.value = 'recognizing'
const res = await identifyUploadedVideo(selectedVideo.value) as any
identifyResult = {
fileId: String(selectedVideo.value.fileId),
sessionId: res.data.sessionId,
faceId: res.data.faceId || '',
faceStartTime: res.data.startTime || 0,
faceEndTime: res.data.endTime || 0,
}
} else {
// 上传新视频 - 上传并识别
const res = await uploadAndIdentifyVideo(videoFile.value!) as any
identifyResult = {
fileId: String(res.data.fileId),
sessionId: res.data.sessionId,
faceId: res.data.faceId || '',
faceStartTime: res.data.startTime || 0,
faceEndTime: res.data.endTime || 0,
}
}
identifyData.value = identifyResult
// ===== 步骤2: 生成音频 =====
step.value = 'generating'
const voiceId = voice.value.rawId ?? extractId(voice.value.id)
const res = await VoiceService.synthesize({
inputText: text.value,
voiceConfigId: voiceId,
speechRate: speechRate.value,
audioFormat: 'mp3',
providerType: DEFAULT_VOICE_PROVIDER,
} as any)
if (res.code !== 0 || !res.data?.audioBase64) {
throw new Error(res.msg || '音频生成失败')
}
const durationMs = await parseAudioDuration(res.data.audioBase64)
audioData.value = {
audioBase64: res.data.audioBase64,
format: 'mp3',
durationMs,
}
// ===== 步骤3: 校验时长 =====
const videoDurationMs = identifyResult.faceEndTime - identifyResult.faceStartTime
if (durationMs > videoDurationMs) {
throw new Error(`音频时长(${(durationMs/1000).toFixed(1)}秒)超过人脸时长(${(videoDurationMs/1000).toFixed(1)}秒)`)
}
// ===== 步骤4: 创建任务 =====
step.value = 'creating'
const taskRes = await createLipSyncTask({
taskName: `数字人任务_${Date.now()}`,
videoFileId: identifyResult.fileId,
inputText: text.value,
speechRate: speechRate.value,
volume: 0,
guidanceScale: 1,
seed: 8888,
kling_session_id: identifyResult.sessionId,
kling_face_id: identifyResult.faceId,
kling_face_start_time: identifyResult.faceStartTime,
kling_face_end_time: identifyResult.faceEndTime,
ai_provider: 'kling',
voiceConfigId: voiceId,
pre_generated_audio: {
audioBase64: audioData.value.audioBase64,
format: audioData.value.format,
},
sound_end_time: audioData.value.durationMs,
})
if (taskRes.code !== 0) {
throw new Error(taskRes.msg || '任务创建失败')
}
step.value = 'done'
message.success('任务已提交,请在任务中心查看生成进度')
} catch (err: any) {
step.value = 'error'
error.value = err.message || '生成失败'
message.error(error.value)
}
}
/** 重试 */
function retry() {
if (step.value === 'error') {
resetProcess()
}
}
// ==================== 导出 ====================
return {
// 状态
// 基础状态
text,
speechRate,
voice,
@@ -417,32 +629,64 @@ export const useDigitalHumanStore = defineStore('digitalHuman', () => {
videoFile,
selectedVideo,
videoPreviewUrl,
step,
error,
identifyData,
audioData,
videoSelectorVisible,
// 三步骤流程状态(新增)
phase,
videoStep,
audioStep,
createStep,
timeline,
// 兼容旧状态
step,
// 计算属性
hasVideo,
isVideoReady,
isAudioReady,
canGoNext,
canGenerate,
timelineMatch,
faceDurationMs,
audioDurationMs,
isBusy,
isDone,
isFailed,
faceDurationMs,
progress,
stepLabel,
// 方法
// 方法:基础
setVoice,
selectUploadMode,
selectLibraryMode,
// 方法步骤1
handleFileUpload,
selectVideo,
recognizeVideo,
// 方法步骤2
generateAudio,
// 方法步骤3
createTask,
// 方法:导航
goNextPhase,
goPrevPhase,
goToPhase,
// 方法:重置
resetProcess,
reset,
generate,
retry,
// 兼容旧方法
generate,
}
})

View File

@@ -3,6 +3,33 @@
* @author Claude Code
*/
// ========== 三步骤流程状态 ==========
/** 主流程阶段 */
export type PipelinePhase = 'select-video' | 'add-voice' | 'generate'
/** 视频步骤状态 */
export type VideoStep = 'idle' | 'uploading' | 'recognizing' | 'recognized' | 'error'
/** 音频步骤状态 */
export type AudioStep = 'idle' | 'generating' | 'generated' | 'error'
/** 生成步骤状态 */
export type CreateStep = 'idle' | 'creating' | 'done' | 'error'
/** 时间轴匹配状态 */
export type TimelineMatchStatus = 'match' | 'exceed' | 'too-short' | 'none'
/** 时间轴数据 */
export interface TimelineData {
videoDurationMs: number // 视频人脸时长
audioDurationMs: number // 音频时长
faceStartTime: number // 人脸起始时间
faceEndTime: number // 人脸结束时间
}
// ========== 原有类型定义 ==========
/**
* 视频状态接口
*/
@@ -27,7 +54,7 @@ export interface Video {
fileSize: number
duration: number
coverBase64?: string
previewUrl?: string
imgUrl?: string
coverUrl?: string
}

View File

@@ -0,0 +1,41 @@
/**
* 数字人模块格式化工具函数
*/
/**
* 格式化毫秒时长为 m:ss 格式
* @param ms 毫秒数
* @returns 格式化后的时间字符串,如 "1:30"
*/
export function formatDurationMs(ms: number): string {
if (!ms || ms <= 0) return '0:00'
const seconds = Math.floor(ms / 1000)
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${String(secs).padStart(2, '0')}`
}
/**
* 格式化秒数为 mm:ss 格式
* @param seconds 秒数
* @returns 格式化后的时间字符串,如 "01:30"
*/
export function formatDuration(seconds: number): string {
if (!seconds) return '--:--'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
}
/**
* 格式化文件大小
* @param bytes 字节数
* @returns 格式化后的大小字符串,如 "1.5 MB"
*/
export function formatFileSize(bytes: number): string {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]
}

View File

@@ -152,8 +152,8 @@
<!-- 预览图 -->
<div class="material-item__preview">
<img
v-if="file.previewUrl"
:src="file.previewUrl"
v-if="file.imgUrl"
:src="file.imgUrl"
:alt="file.fileName"
@error="handleImageError"
loading="lazy"

View File

@@ -159,8 +159,8 @@
<template #item="{ element: candidate, index: cIndex }">
<div class="candidate-thumb">
<img
v-if="getFileById(candidate.fileId)?.previewUrl"
:src="getFileById(candidate.fileId).previewUrl"
v-if="getFileById(candidate.fileId)?.imgUrl"
:src="getFileById(candidate.fileId).imgUrl"
/>
<div v-else class="thumb-placeholder">
<VideoCameraOutlined />

View File

@@ -69,7 +69,7 @@
@dblclick="handleQuickConfirm(file)"
>
<div class="card-cover">
<img v-if="file.previewUrl" :src="file.previewUrl" />
<img v-if="file.imgUrl" :src="file.imgUrl" />
<div v-else class="cover-placeholder">
<VideoCameraOutlined />
</div>