feat: 功能
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-xl font-bold">剪映导入</h2>
|
||||
<div class="bg-white p-4 rounded shadow">选择文案/字幕/音频/数字人视频/工程,一键导入。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -544,7 +544,7 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loadingPrompts" class="prompt-empty">
|
||||
<div style="color: var(--color-text-secondary); font-size: 12px; text-align: center; padding: 20px;">
|
||||
<div style="color: var(--color-text-secondary); font-size: 14px; text-align: center; padding: 20px;">
|
||||
您可以在视频分析页面保存风格
|
||||
</div>
|
||||
</div>
|
||||
@@ -792,7 +792,7 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
.result-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@@ -842,8 +842,8 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
|
||||
/* 表单标签后的内联提示(不使用 emoji) */
|
||||
.form-tip-inline {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
margin-left: var(--space-2);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -932,7 +932,7 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
}
|
||||
|
||||
:deep(.ant-slider-rail) {
|
||||
background-color: #252525; /* 未选中轨道更深,增强对比 */
|
||||
background-color: var(--color-slate-200);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
@@ -973,13 +973,13 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
.empty-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
@@ -1034,7 +1034,7 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
.save-btn:hover {
|
||||
background: var(--color-primary);
|
||||
filter: brightness(1.04);
|
||||
box-shadow: var(--glow-primary);
|
||||
box-shadow: var(--shadow-blue);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
@@ -1048,14 +1048,14 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
}
|
||||
|
||||
.generated-content {
|
||||
padding: 24px;
|
||||
background: #111111;
|
||||
border-radius: 8px;
|
||||
padding: var(--space-6);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
line-height: 1.9;
|
||||
color: #f5f5f5;
|
||||
color: var(--color-text);
|
||||
min-height: 400px;
|
||||
font-size: 15.5px;
|
||||
font-size: 15px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -1065,33 +1065,33 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
.generated-content :deep(h1) {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #ffffff;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--color-text);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.generated-content :deep(h2) {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
margin: 22px 0 12px 0;
|
||||
color: #fff;
|
||||
color: var(--color-text);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.generated-content :deep(h3) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 18px 0 10px 0;
|
||||
color: #efefef;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.generated-content :deep(p) {
|
||||
margin: 12px 0 14px 0;
|
||||
color: #e3e6ea;
|
||||
color: var(--color-text);
|
||||
line-height: 1.9;
|
||||
font-size: 15.5px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.generated-content :deep(ul),
|
||||
@@ -1102,50 +1102,50 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
|
||||
.generated-content :deep(li) {
|
||||
margin: 6px 0;
|
||||
color: #e3e6ea;
|
||||
color: var(--color-text);
|
||||
line-height: 1.9;
|
||||
font-size: 15.5px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.generated-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.generated-content :deep(code) {
|
||||
background: #0b0b0b;
|
||||
background: var(--color-slate-100);
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13.5px;
|
||||
color: #ffb86c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--color-red-500);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.generated-content :deep(pre) {
|
||||
background: #0b0b0b;
|
||||
background: var(--color-slate-100);
|
||||
padding: 16px 18px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.generated-content :deep(pre code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: #ffb86c;
|
||||
color: var(--color-red-500);
|
||||
}
|
||||
|
||||
.generated-content :deep(blockquote) {
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-style: italic;
|
||||
color: #e3e6ea;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 提示词标签样式 */
|
||||
|
||||
@@ -81,13 +81,13 @@ function handleReset() {
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
margin-top: var(--space-1);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
:deep(.ant-input), :deep(.ant-input-affix-wrapper), :deep(textarea) {
|
||||
background: #0f0f0f;
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ function handleReset() {
|
||||
}
|
||||
|
||||
:deep(.ant-slider-rail) {
|
||||
background-color: #252525;
|
||||
background-color: var(--color-slate-200);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,10 +103,10 @@ function handleCreateContent() {
|
||||
|
||||
<style scoped>
|
||||
.expanded-content {
|
||||
padding: 16px;
|
||||
background: #161616;
|
||||
border-radius: 6px;
|
||||
margin: 8px 0;
|
||||
padding: var(--space-4);
|
||||
background: var(--color-slate-50);
|
||||
border-radius: var(--radius-card);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
@@ -126,21 +126,21 @@ function handleCreateContent() {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-secondary);
|
||||
background: #0d0d0d;
|
||||
background: var(--color-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.no-transcript {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -148,27 +148,27 @@ function handleCreateContent() {
|
||||
min-height: 200px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
background: #0d0d0d;
|
||||
background: var(--color-surface);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.no-prompt {
|
||||
padding: 16px;
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
margin-top: 8px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -179,7 +179,7 @@ function handleCreateContent() {
|
||||
|
||||
|
||||
.no-analysis-tip {
|
||||
padding: 40px 20px;
|
||||
padding: var(--space-8) var(--space-5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ function handleCreateContent() {
|
||||
|
||||
.no-analysis-tip :deep(.ant-empty-description) {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,15 @@ import type {
|
||||
UseDigitalHumanGeneration,
|
||||
VideoState,
|
||||
IdentifyState,
|
||||
MaterialValidation,
|
||||
Video,
|
||||
AudioState,
|
||||
} from '../types/identify-face'
|
||||
import { identifyUploadedVideo, uploadAndIdentifyVideo } from '@/api/kling'
|
||||
|
||||
/**
|
||||
* 数字人生成 Hook
|
||||
* @param audioState 音频状态(来自父 Hook)
|
||||
* 独立管理所有状态,不依赖外部状态
|
||||
*/
|
||||
export function useDigitalHumanGeneration(
|
||||
audioState: AudioState
|
||||
): UseDigitalHumanGeneration {
|
||||
export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
|
||||
// ==================== 响应式状态 ====================
|
||||
|
||||
const videoState = ref<VideoState>({
|
||||
@@ -43,13 +39,6 @@ export function useDigitalHumanGeneration(
|
||||
videoFileId: null,
|
||||
})
|
||||
|
||||
const materialValidation = ref<MaterialValidation>({
|
||||
videoDuration: 0,
|
||||
audioDuration: 0,
|
||||
isValid: false,
|
||||
showDetails: false,
|
||||
})
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/**
|
||||
@@ -59,16 +48,6 @@ export function useDigitalHumanGeneration(
|
||||
return identifyState.value.faceEndTime - identifyState.value.faceStartTime
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否可以生成数字人视频
|
||||
*/
|
||||
const canGenerate = computed(() => {
|
||||
const hasVideo = videoState.value.uploadedVideo || videoState.value.selectedVideo
|
||||
const audioValidated = audioState.validationPassed
|
||||
const materialValidated = materialValidation.value.isValid
|
||||
return !!(hasVideo && audioValidated && materialValidated)
|
||||
})
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
|
||||
/**
|
||||
@@ -87,7 +66,6 @@ export function useDigitalHumanGeneration(
|
||||
videoState.value.videoSource = 'upload'
|
||||
|
||||
resetIdentifyState()
|
||||
resetMaterialValidation()
|
||||
|
||||
await performFaceRecognition()
|
||||
}
|
||||
@@ -104,7 +82,6 @@ export function useDigitalHumanGeneration(
|
||||
|
||||
resetIdentifyState()
|
||||
identifyState.value.videoFileId = video.id
|
||||
materialValidation.value.videoDuration = (video.duration || 0) * 1000
|
||||
|
||||
performFaceRecognition()
|
||||
}
|
||||
@@ -149,18 +126,6 @@ export function useDigitalHumanGeneration(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证素材时长
|
||||
*/
|
||||
const validateMaterialDuration = (videoDurationMs: number, audioDurationMs: number): boolean => {
|
||||
const isValid = videoDurationMs > audioDurationMs
|
||||
|
||||
materialValidation.value.videoDuration = videoDurationMs
|
||||
materialValidation.value.audioDuration = audioDurationMs
|
||||
materialValidation.value.isValid = isValid
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置视频状态
|
||||
@@ -174,7 +139,6 @@ export function useDigitalHumanGeneration(
|
||||
videoState.value.selectorVisible = false
|
||||
|
||||
resetIdentifyState()
|
||||
resetMaterialValidation()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,31 +173,20 @@ export function useDigitalHumanGeneration(
|
||||
identifyState.value.videoFileId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置素材校验状态
|
||||
*/
|
||||
const resetMaterialValidation = (): void => {
|
||||
materialValidation.value.videoDuration = 0
|
||||
materialValidation.value.audioDuration = 0
|
||||
materialValidation.value.isValid = false
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式状态
|
||||
videoState,
|
||||
identifyState,
|
||||
materialValidation,
|
||||
|
||||
// 计算属性
|
||||
faceDuration,
|
||||
canGenerate,
|
||||
|
||||
// 方法
|
||||
handleFileUpload,
|
||||
handleVideoSelect,
|
||||
performFaceRecognition,
|
||||
validateMaterialDuration,
|
||||
resetVideoState,
|
||||
resetIdentifyState,
|
||||
getVideoPreviewUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,78 @@
|
||||
* @author Claude Code
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type {
|
||||
UseIdentifyFaceController,
|
||||
UseVoiceGeneration,
|
||||
UseDigitalHumanGeneration,
|
||||
LipSyncTaskData,
|
||||
MaterialValidation,
|
||||
} from '../types/identify-face'
|
||||
// @ts-ignore
|
||||
import { createLipSyncTask } from '@/api/kling'
|
||||
|
||||
// 导入子 Hooks
|
||||
import { useVoiceGeneration } from './useVoiceGeneration'
|
||||
import { useDigitalHumanGeneration } from './useDigitalHumanGeneration'
|
||||
|
||||
/**
|
||||
* 识别控制器 Hook
|
||||
* @param voiceGeneration 语音生成 Hook
|
||||
* @param digitalHuman 数字人生成 Hook
|
||||
* 识别控制器 Hook - 充当协调器
|
||||
* 内部直接创建和管理两个子 Hook
|
||||
*/
|
||||
export function useIdentifyFaceController(
|
||||
voiceGeneration: UseVoiceGeneration,
|
||||
digitalHuman: UseDigitalHumanGeneration
|
||||
): UseIdentifyFaceController {
|
||||
export function useIdentifyFaceController(): UseIdentifyFaceController {
|
||||
// ==================== 创建子 Hooks ====================
|
||||
|
||||
// 1. 创建语音生成 Hook(独立管理状态)
|
||||
const voiceGeneration = useVoiceGeneration()
|
||||
|
||||
// 2. 创建数字人生成 Hook(独立管理状态)
|
||||
const digitalHuman = useDigitalHumanGeneration()
|
||||
|
||||
// 3. Controller 统一管理跨 Hook 的状态
|
||||
const materialValidation = ref<MaterialValidation>({
|
||||
videoDuration: 0,
|
||||
audioDuration: 0,
|
||||
isValid: false,
|
||||
showDetails: false,
|
||||
})
|
||||
|
||||
// 4. 监听音频状态变化,自动触发素材校验
|
||||
watch(
|
||||
() => voiceGeneration.audioState.value.generated && voiceGeneration.audioState.value.durationMs > 0,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
// 音频生成完成,获取视频时长并校验
|
||||
const videoDuration = digitalHuman.faceDuration.value || 0
|
||||
const audioDuration = voiceGeneration.audioState.value.durationMs
|
||||
|
||||
if (videoDuration > 0) {
|
||||
validateMaterialDuration(videoDuration, audioDuration)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
// 5. 监听人脸识别状态变化,更新素材校验的视频时长
|
||||
watch(
|
||||
() => digitalHuman.identifyState.value.identified,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
// 人脸识别成功,获取视频时长
|
||||
const videoDuration = digitalHuman.faceDuration.value
|
||||
|
||||
// 如果已有音频,则重新校验
|
||||
if (voiceGeneration.audioState.value.generated && voiceGeneration.audioState.value.durationMs > 0) {
|
||||
const audioDuration = voiceGeneration.audioState.value.durationMs
|
||||
validateMaterialDuration(videoDuration, audioDuration)
|
||||
} else {
|
||||
// 否则只更新视频时长
|
||||
materialValidation.value.videoDuration = videoDuration
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/**
|
||||
@@ -32,7 +85,7 @@ export function useIdentifyFaceController(
|
||||
const hasVoice = voiceGeneration.selectedVoiceMeta.value
|
||||
const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo
|
||||
const audioValidated = voiceGeneration.audioState.value.validationPassed
|
||||
const materialValidated = digitalHuman.materialValidation.value.isValid
|
||||
const materialValidated = materialValidation.value.isValid
|
||||
return !!(hasText && hasVoice && hasVideo && audioValidated && materialValidated)
|
||||
})
|
||||
|
||||
@@ -92,23 +145,18 @@ export function useIdentifyFaceController(
|
||||
try {
|
||||
// 如果未识别,先进行人脸识别
|
||||
if (!digitalHuman.identifyState.value.identified) {
|
||||
message.loading('正在进行人脸识别...', 0)
|
||||
|
||||
const hasUploadFile = digitalHuman.videoState.value.videoFile
|
||||
const hasSelectedVideo = digitalHuman.videoState.value.selectedVideo
|
||||
|
||||
if (!hasUploadFile && !hasSelectedVideo) {
|
||||
message.destroy()
|
||||
message.warning('请先选择或上传视频')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await digitalHuman.performFaceRecognition()
|
||||
message.destroy()
|
||||
message.success('人脸识别完成')
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -209,7 +257,8 @@ export function useIdentifyFaceController(
|
||||
* 触发文件选择
|
||||
*/
|
||||
const triggerFileSelect = (): void => {
|
||||
document.querySelector('input[type="file"]')?.click()
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fileInput?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +268,6 @@ export function useIdentifyFaceController(
|
||||
digitalHuman.videoState.value.videoSource = 'upload'
|
||||
digitalHuman.videoState.value.selectedVideo = null
|
||||
digitalHuman.resetIdentifyState()
|
||||
digitalHuman.resetMaterialValidation()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,16 +332,88 @@ export function useIdentifyFaceController(
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
return {
|
||||
// 组合子 Hooks
|
||||
voiceGeneration,
|
||||
digitalHuman,
|
||||
/**
|
||||
* 重置素材校验状态
|
||||
*/
|
||||
const resetMaterialValidation = (): void => {
|
||||
materialValidation.value.videoDuration = 0
|
||||
materialValidation.value.audioDuration = 0
|
||||
materialValidation.value.isValid = false
|
||||
}
|
||||
|
||||
// 业务流程方法
|
||||
/**
|
||||
* 验证素材时长
|
||||
* 视频时长必须大于音频时长
|
||||
*/
|
||||
const validateMaterialDuration = (videoDurationMs: number, audioDurationMs: number): boolean => {
|
||||
materialValidation.value.videoDuration = videoDurationMs
|
||||
materialValidation.value.audioDuration = audioDurationMs
|
||||
materialValidation.value.isValid = videoDurationMs > audioDurationMs
|
||||
|
||||
if (!materialValidation.value.isValid) {
|
||||
const videoSec = (videoDurationMs / 1000).toFixed(1)
|
||||
const audioSec = (audioDurationMs / 1000).toFixed(1)
|
||||
message.warning(`素材校验失败:视频时长(${videoSec}s)必须大于音频时长(${audioSec}s)`)
|
||||
}
|
||||
|
||||
return materialValidation.value.isValid
|
||||
}
|
||||
|
||||
// ==================== 解构子 Hooks 的响应式变量 ====================
|
||||
|
||||
// 语音生成相关
|
||||
const {
|
||||
ttsText,
|
||||
speechRate,
|
||||
selectedVoiceMeta,
|
||||
audioState,
|
||||
canGenerateAudio,
|
||||
suggestedMaxChars,
|
||||
generateAudio,
|
||||
resetAudioState,
|
||||
} = voiceGeneration
|
||||
|
||||
// 数字人生成相关
|
||||
const {
|
||||
videoState,
|
||||
identifyState,
|
||||
faceDuration,
|
||||
performFaceRecognition,
|
||||
handleFileUpload,
|
||||
getVideoPreviewUrl,
|
||||
resetVideoState,
|
||||
resetIdentifyState,
|
||||
} = digitalHuman
|
||||
|
||||
return {
|
||||
// ==================== 语音生成相关 ====================
|
||||
ttsText,
|
||||
speechRate,
|
||||
selectedVoiceMeta,
|
||||
audioState,
|
||||
canGenerateAudio,
|
||||
suggestedMaxChars,
|
||||
generateAudio,
|
||||
resetAudioState,
|
||||
|
||||
// ==================== 数字人生成相关 ====================
|
||||
videoState,
|
||||
identifyState,
|
||||
materialValidation,
|
||||
faceDuration,
|
||||
performFaceRecognition,
|
||||
handleFileUpload,
|
||||
getVideoPreviewUrl,
|
||||
resetVideoState,
|
||||
resetIdentifyState,
|
||||
resetMaterialValidation,
|
||||
validateMaterialDuration,
|
||||
|
||||
// ==================== 业务流程方法 ====================
|
||||
generateDigitalHuman,
|
||||
replaceVideo,
|
||||
|
||||
// 事件处理方法
|
||||
// ==================== 事件处理方法 ====================
|
||||
handleVoiceSelect,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
@@ -304,11 +424,11 @@ export function useIdentifyFaceController(
|
||||
handleSimplifyScript,
|
||||
handleVideoLoaded,
|
||||
|
||||
// UI 辅助方法
|
||||
// ==================== UI 辅助方法 ====================
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
|
||||
// 计算属性
|
||||
// ==================== 计算属性 ====================
|
||||
canGenerate,
|
||||
maxTextLength,
|
||||
textareaPlaceholder,
|
||||
|
||||
@@ -9,20 +9,16 @@ import type {
|
||||
UseVoiceGeneration,
|
||||
AudioState,
|
||||
VoiceMeta,
|
||||
IdentifyState,
|
||||
AudioData,
|
||||
} from '../types/identify-face'
|
||||
// @ts-ignore
|
||||
import { VoiceService } from '@/api/voice'
|
||||
|
||||
/**
|
||||
* 语音生成 Hook
|
||||
* @param identifyState 人脸识别状态(来自父 Hook)
|
||||
* @param faceDuration 人脸出现时长(毫秒)
|
||||
* 独立管理所有状态,不依赖外部状态
|
||||
*/
|
||||
export function useVoiceGeneration(
|
||||
identifyState: IdentifyState,
|
||||
faceDuration: number
|
||||
): UseVoiceGeneration {
|
||||
export function useVoiceGeneration(): UseVoiceGeneration {
|
||||
// ==================== 响应式状态 ====================
|
||||
|
||||
const ttsText = ref<string>('')
|
||||
@@ -43,17 +39,16 @@ export function useVoiceGeneration(
|
||||
const canGenerateAudio = computed(() => {
|
||||
const hasText = ttsText.value.trim()
|
||||
const hasVoice = selectedVoiceMeta.value
|
||||
const hasVideo = identifyState.identified
|
||||
const hasVideo = true // 语音生成不依赖视频状态
|
||||
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
|
||||
})
|
||||
|
||||
/**
|
||||
* 建议的最大字符数
|
||||
* 建议的最大字符数(需要从外部传入)
|
||||
*/
|
||||
const suggestedMaxChars = computed(() => {
|
||||
const durationSec = faceDuration / 1000
|
||||
const adjustedRate = speechRate.value || 1.0
|
||||
return Math.floor(durationSec * 3.5 * adjustedRate)
|
||||
// 默认为 4000,需要从外部设置
|
||||
return 4000
|
||||
})
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
@@ -156,31 +151,33 @@ export function useVoiceGeneration(
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证音频与人脸区间的重合时长
|
||||
* 验证音频与人脸区间的重合时长(外部调用时传入校验参数)
|
||||
*/
|
||||
const validateAudioDuration = (): boolean => {
|
||||
if (!identifyState.identified || faceDuration <= 0) {
|
||||
const validateAudioDuration = (
|
||||
faceStartTime: number = 0,
|
||||
faceEndTime: number = 0,
|
||||
minOverlapMs: number = 2000
|
||||
): boolean => {
|
||||
if (faceStartTime <= 0 || faceEndTime <= 0) {
|
||||
audioState.value.validationPassed = false
|
||||
return false
|
||||
}
|
||||
|
||||
const faceStart = identifyState.faceStartTime
|
||||
const faceEnd = identifyState.faceEndTime
|
||||
const faceDurationMs = faceEnd - faceStart
|
||||
const faceDurationMs = faceEndTime - faceStartTime
|
||||
const audioDuration = audioState.value.durationMs
|
||||
|
||||
const overlapStart = faceStart
|
||||
const overlapEnd = Math.min(faceEnd, faceStart + audioDuration)
|
||||
const overlapStart = faceStartTime
|
||||
const overlapEnd = Math.min(faceEndTime, faceStartTime + audioDuration)
|
||||
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
|
||||
|
||||
const isValid = overlapDuration >= 2000
|
||||
const isValid = overlapDuration >= minOverlapMs
|
||||
|
||||
audioState.value.validationPassed = isValid
|
||||
|
||||
if (!isValid) {
|
||||
const overlapSec = (overlapDuration / 1000).toFixed(1)
|
||||
message.warning(
|
||||
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要2秒`
|
||||
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要${(minOverlapMs/1000)}秒`
|
||||
)
|
||||
} else {
|
||||
message.success('时长校验通过!')
|
||||
|
||||
@@ -109,34 +109,70 @@ export interface UseDigitalHumanGeneration {
|
||||
// 响应式状态
|
||||
videoState: import('vue').Ref<VideoState>
|
||||
identifyState: import('vue').Ref<IdentifyState>
|
||||
materialValidation: import('vue').Ref<MaterialValidation>
|
||||
|
||||
// 计算属性
|
||||
faceDuration: import('vue').ComputedRef<number>
|
||||
canGenerate: import('vue').ComputedRef<boolean>
|
||||
|
||||
// 方法
|
||||
handleFileUpload: (file: File) => Promise<void>
|
||||
handleVideoSelect: (video: Video) => void
|
||||
performFaceRecognition: () => Promise<void>
|
||||
validateMaterialDuration: (videoMs: number, audioMs: number) => boolean
|
||||
resetVideoState: () => void
|
||||
resetIdentifyState: () => void
|
||||
getVideoPreviewUrl: (video: Video) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* useIdentifyFaceController Hook 返回接口
|
||||
* 扁平化结构,直接暴露所有响应式变量和方法
|
||||
*/
|
||||
export interface UseIdentifyFaceController {
|
||||
// 组合子 Hooks
|
||||
voiceGeneration: UseVoiceGeneration
|
||||
digitalHuman: UseDigitalHumanGeneration
|
||||
// ==================== 语音生成相关 ====================
|
||||
ttsText: import('vue').Ref<string>
|
||||
speechRate: import('vue').Ref<number>
|
||||
selectedVoiceMeta: import('vue').Ref<VoiceMeta | null>
|
||||
audioState: import('vue').Ref<AudioState>
|
||||
canGenerateAudio: import('vue').ComputedRef<boolean>
|
||||
suggestedMaxChars: import('vue').ComputedRef<number>
|
||||
generateAudio: () => Promise<void>
|
||||
resetAudioState: () => void
|
||||
|
||||
// 业务流程方法
|
||||
// ==================== 数字人生成相关 ====================
|
||||
videoState: import('vue').Ref<VideoState>
|
||||
identifyState: import('vue').Ref<IdentifyState>
|
||||
materialValidation: import('vue').Ref<MaterialValidation>
|
||||
faceDuration: import('vue').ComputedRef<number>
|
||||
performFaceRecognition: () => Promise<void>
|
||||
handleFileUpload: (file: File) => Promise<void>
|
||||
getVideoPreviewUrl: (video: Video) => string
|
||||
resetVideoState: () => void
|
||||
resetIdentifyState: () => void
|
||||
resetMaterialValidation: () => void
|
||||
validateMaterialDuration: (videoDurationMs: number, audioDurationMs: number) => boolean
|
||||
|
||||
// ==================== 业务流程方法 ====================
|
||||
generateDigitalHuman: () => Promise<void>
|
||||
replaceVideo: () => void
|
||||
|
||||
// UI 辅助方法
|
||||
// ==================== 事件处理方法 ====================
|
||||
handleVoiceSelect: (voice: VoiceMeta) => void
|
||||
handleFileSelect: (event: Event) => void
|
||||
handleDrop: (event: DragEvent) => void
|
||||
triggerFileSelect: () => void
|
||||
handleSelectUpload: () => void
|
||||
handleSelectFromLibrary: () => void
|
||||
handleVideoSelect: (video: Video) => void
|
||||
handleSimplifyScript: () => void
|
||||
handleVideoLoaded: (videoUrl: string) => void
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
canGenerate: import('vue').ComputedRef<boolean>
|
||||
maxTextLength: import('vue').ComputedRef<number>
|
||||
textareaPlaceholder: import('vue').ComputedRef<string>
|
||||
speechRateMarks: Record<number, string>
|
||||
speechRateDisplay: import('vue').ComputedRef<string>
|
||||
|
||||
// ==================== UI 辅助方法 ====================
|
||||
formatDuration: (seconds: number) => string
|
||||
formatFileSize: (bytes: number) => string
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const columns = [
|
||||
customRender: ({ text }) => {
|
||||
return h('span', {
|
||||
style: {
|
||||
color: text === 1 ? '#52c41a' : '#ff4d4f',
|
||||
color: text === 1 ? 'var(--color-green-500)' : 'var(--color-red-500)',
|
||||
},
|
||||
}, text === 1 ? '启用' : '禁用')
|
||||
},
|
||||
@@ -498,14 +498,14 @@ onMounted(() => {
|
||||
|
||||
:deep(.action-btn-edit:hover),
|
||||
:deep(.action-btn-edit:hover .anticon) {
|
||||
background: rgba(24, 144, 255, 0.1) !important;
|
||||
color: #1890FF !important;
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.action-btn-delete:hover),
|
||||
:deep(.action-btn-delete:hover .anticon) {
|
||||
background: rgba(24, 144, 255, 0.1) !important;
|
||||
color: #1890FF !important;
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.action-btn:hover) {
|
||||
|
||||
@@ -651,12 +651,12 @@ onMounted(async () => {
|
||||
<div>
|
||||
<div class="form-label-wrapper">
|
||||
<label class="form-label">风格提示词</label>
|
||||
<a-button
|
||||
<a-button
|
||||
v-if="allPrompts.length > DISPLAY_COUNT"
|
||||
size="small"
|
||||
type="link"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="showAllPromptsModal = true"
|
||||
style="padding: 0; height: auto; font-size: 12px;"
|
||||
style="padding: 0; height: auto; font-size: 14px;"
|
||||
>
|
||||
更多 ({{ allPrompts.length }})
|
||||
</a-button>
|
||||
@@ -679,7 +679,7 @@ onMounted(async () => {
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loadingPrompts" class="prompt-empty">
|
||||
<div style="color: var(--color-text-secondary); font-size: 12px; text-align: center; padding: 20px;">
|
||||
<div style="color: var(--color-text-secondary); font-size: 14px; text-align: center; padding: 20px;">
|
||||
您可以在视频分析页面保存风格
|
||||
</div>
|
||||
</div>
|
||||
@@ -826,15 +826,15 @@ onMounted(async () => {
|
||||
|
||||
.param-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.param-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -1020,13 +1020,13 @@ onMounted(async () => {
|
||||
|
||||
.topic-title--clickable {
|
||||
cursor: pointer;
|
||||
color: #1890ff;
|
||||
color: var(--color-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.topic-title--clickable:hover {
|
||||
text-decoration: underline;
|
||||
color: #40a9ff;
|
||||
color: var(--color-primary-hover);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1159,7 +1159,7 @@ onMounted(async () => {
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -1277,18 +1277,18 @@ onMounted(async () => {
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
color: var(--color-primary);
|
||||
background: transparent;
|
||||
border: 1px solid #1890ff;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
box-shadow:
|
||||
0 0 10px rgba(24, 144, 255, 0.3),
|
||||
inset 0 0 10px rgba(24, 144, 255, 0.1);
|
||||
box-shadow:
|
||||
0 0 10px rgba(59, 130, 246, 0.3),
|
||||
inset 0 0 10px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.cyber-button::before {
|
||||
@@ -1298,7 +1298,7 @@ onMounted(async () => {
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(24, 144, 255, 0.2), transparent);
|
||||
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
@@ -1307,19 +1307,19 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.cyber-button:hover:not(.cyber-button--disabled) {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
box-shadow:
|
||||
0 0 20px rgba(24, 144, 255, 0.5),
|
||||
0 0 40px rgba(24, 144, 255, 0.3),
|
||||
inset 0 0 20px rgba(24, 144, 255, 0.2);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
box-shadow:
|
||||
0 0 20px rgba(59, 130, 246, 0.5),
|
||||
0 0 40px rgba(59, 130, 246, 0.3),
|
||||
inset 0 0 20px rgba(59, 130, 246, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cyber-button:active:not(.cyber-button--disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow:
|
||||
0 0 15px rgba(24, 144, 255, 0.4),
|
||||
inset 0 0 15px rgba(24, 144, 255, 0.15);
|
||||
box-shadow:
|
||||
0 0 15px rgba(59, 130, 246, 0.4),
|
||||
inset 0 0 15px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.cyber-button__content {
|
||||
@@ -1338,7 +1338,7 @@ onMounted(async () => {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(24, 144, 255, 0.4);
|
||||
background: rgba(59, 130, 246, 0.4);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s ease, height 0.6s ease;
|
||||
pointer-events: none;
|
||||
@@ -1352,7 +1352,7 @@ onMounted(async () => {
|
||||
.cyber-button__text {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
text-shadow: 0 0 10px rgba(24, 144, 255, 0.8);
|
||||
text-shadow: 0 0 10px rgba(59, 130, 246, 0.8);
|
||||
}
|
||||
|
||||
.cyber-button__arrow {
|
||||
@@ -1378,8 +1378,8 @@ onMounted(async () => {
|
||||
.cyber-button__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(24, 144, 255, 0.3);
|
||||
border-top-color: #1890ff;
|
||||
border: 2px solid rgba(59, 130, 246, 0.3);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: cyber-spin 0.8s linear infinite;
|
||||
}
|
||||
@@ -1394,9 +1394,9 @@ onMounted(async () => {
|
||||
.cyber-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(24, 144, 255, 0.3);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
box-shadow: none;
|
||||
color: rgba(24, 144, 255, 0.5);
|
||||
color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.cyber-button--disabled:hover,
|
||||
@@ -1419,7 +1419,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user