1、添加中英文版本

2、修复已知BUG
3、完善功能
4、添加minimax视频渠道
This commit is contained in:
Connor
2026-01-18 05:21:34 +08:00
parent bfba6342dc
commit d39759926e
52 changed files with 3456 additions and 2617 deletions

View File

@@ -21,24 +21,90 @@
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { setLanguage } from '@/locales'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { settingsAPI } from '@/api/settings'
const { locale } = useI18n()
const currentLang = ref(locale.value)
const loading = ref(false)
const currentLangText = computed(() => {
return currentLang.value === 'zh-CN' ? '中文' : 'English'
})
const handleCommand = (lang: string) => {
setLanguage(lang)
currentLang.value = lang
ElMessage.success(
lang === 'zh-CN'
? '语言已切换为中文'
: 'Language switched to English'
)
const handleCommand = async (lang: string) => {
if (loading.value) return
// 将 zh-CN/en-US 转换为 zh/en (后端格式)
const backendLang = lang === 'zh-CN' ? 'zh' : 'en'
const currentBackendLang = currentLang.value === 'zh-CN' ? 'zh' : 'en'
// 双语确认消息
const confirmMessage = backendLang === 'zh'
? `切换为中文后,后端生成的所有提示词、角色描述、场景描述等都将使用中文。是否继续?
After switching to Chinese, all prompts, character descriptions, scene descriptions generated by the backend will use Chinese. Continue?`
: `After switching to English, all prompts, character descriptions, scene descriptions generated by the backend will use English. Continue?
切换为英文后,后端生成的所有提示词、角色描述、场景描述等都将使用英文。是否继续?`
try {
await ElMessageBox.confirm(
confirmMessage,
'切换语言 / Switch Language',
{
confirmButtonText: '确定 / Confirm',
cancelButtonText: '取消 / Cancel',
type: 'warning',
dangerouslyUseHTMLString: false
}
)
loading.value = true
// 调用后端API更新语言设置
const res = await settingsAPI.updateLanguage(backendLang)
console.log('Backend language updated:', res)
// 更新前端语言
setLanguage(lang)
currentLang.value = lang
// 使用后端返回的双语消息request拦截器已经返回了data
ElMessage.success({
message: res?.message || (backendLang === 'zh' ? '语言已切换为中文' : 'Language switched to English'),
duration: 3000
})
} catch (error: any) {
if (error !== 'cancel') {
console.error('Failed to switch language:', error)
// 安全获取错误消息
let errorMessage = '未知错误'
if (error?.message) {
errorMessage = error.message
} else if (error?.response?.data?.error?.message) {
errorMessage = error.response.data.error.message
} else if (typeof error === 'string') {
errorMessage = error
}
// 双语错误提示
const errorMsg = currentBackendLang === 'zh'
? `切换语言失败: ${errorMessage}`
: `Failed to switch language: ${errorMessage}`
ElMessage.error({
message: errorMsg,
duration: 5000
})
}
} finally {
loading.value = false
}
}
</script>

View File

@@ -283,13 +283,9 @@ const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
id: 'chatfire',
name: 'Chatfire',
models: [
'gpt-4o',
'gemini-3-pro-preview',
'claude-sonnet-4-5-20250929',
'doubao-seed-1-8-251228',
'kimi-k2-thinking',
'gemini-3-pro',
'gemini-2.5-pro',
'gemini-3-pro-preview'
'doubao-seed-1-8-251228'
]
},
{
@@ -341,18 +337,43 @@ const providerConfigs: Record<AIServiceType, ProviderConfig[]> = {
'sora-pro'
]
},
{
id: 'minimax',
name: 'MiniMax 海螺',
models: [
'MiniMax-Hailuo-2.3',
'MiniMax-Hailuo-2.3-Fast',
'MiniMax-Hailuo-02'
]
},
{ id: 'openai', name: 'OpenAI', models: ['sora-2', 'sora-2-pro'] }
]
}
// 当前可用的厂商列表(显示所有配置的厂商)
const availableProviders = computed(() => {
// 返回当前service_type下的所有厂商
return providerConfigs[form.service_type] || []
})
// 当前可用的模型列表(从已激活的配置中获取)
const availableModels = computed(() => {
if (!form.provider) return []
const provider = availableProviders.value.find(p => p.id === form.provider)
return provider?.models || []
// 从已激活的配置中提取该 provider 的所有模型
const activeConfigsForProvider = configs.value.filter(
c => c.provider === form.provider &&
c.service_type === form.service_type &&
c.is_active
)
// 提取所有模型,去重
const models = new Set<string>()
activeConfigsForProvider.forEach(config => {
config.model.forEach(m => models.add(m))
})
return Array.from(models)
})
const fullEndpointExample = computed(() => {
@@ -379,6 +400,8 @@ const fullEndpointExample = computed(() => {
endpoint = '/video/generations'
} else if (provider === 'doubao' || provider === 'volcengine' || provider === 'volces') {
endpoint = '/contents/generations/tasks'
} else if (provider === 'minimax') {
endpoint = '/video_generation'
} else if (provider === 'openai') {
endpoint = '/videos'
} else {
@@ -585,9 +608,17 @@ const handleTabChange = (tabName: string | number) => {
const handleProviderChange = () => {
form.model = []
// 根据厂商自动设置 Base URL
if (form.provider === 'gemini' || form.provider === 'google') {
form.base_url = 'https://api.chatfire.site'
form.base_url = 'https://generativelanguage.googleapis.com'
} else if (form.provider === 'minimax') {
form.base_url = 'https://api.minimaxi.com/v1'
} else if (form.provider === 'volces' || form.provider === 'volcengine') {
form.base_url = 'https://ark.cn-beijing.volces.com/api/v3'
} else if (form.provider === 'openai') {
form.base_url = 'https://api.openai.com/v1'
} else {
// chatfire 和其他厂商
form.base_url = 'https://api.chatfire.site/v1'
}
@@ -812,6 +843,9 @@ watch(visible, (val) => {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
word-break: break-all;
overflow-wrap: break-word;
line-height: 1.5;
}
/* Dark mode */

View File

@@ -5,7 +5,7 @@
<!-- Left section: Logo + Left slot -->
<div class="header-left">
<router-link v-if="showLogo" to="/" class="logo">
<span class="logo-text">🎬 AI Drama</span>
<span class="logo-text">🎬 HuoBao Drama</span>
</router-link>
<!-- Left slot for business content | 左侧插槽用于业务内容 -->
<slot name="left" />

View File

@@ -5,7 +5,7 @@
<div class="header-content">
<div class="header-left">
<router-link to="/" class="logo">
<span class="logo-text">🎬 AI Drama</span>
<span class="logo-text">🎬 HuoBao Drama</span>
</router-link>
</div>
<div class="header-right">

View File

@@ -17,7 +17,7 @@
:class="{ active: currentShotIndex === index }"
@click="selectShot(index)"
>
<div class="scene-number">{{ shot.shot_number }}</div>
<div class="scene-number">{{ shot.storyboard_number }}</div>
<div class="scene-content">
<div class="scene-title">
<el-tag size="small" type="info">{{ shot.shot_type }}</el-tag>
@@ -36,7 +36,7 @@
<div class="center-panel">
<div class="preview-header">
<div class="header-info">
<el-tag type="info">镜头 {{ currentShot?.shot_number || '-' }}</el-tag>
<el-tag type="info">镜头 {{ currentShot?.storyboard_number || '-' }}</el-tag>
<span class="shot-type">{{ currentShot?.shot_type }}</span>
</div>
<div class="header-actions">
@@ -101,7 +101,7 @@
@click="selectShot(index)"
>
<div class="clip-content">
<span class="clip-number">{{ shot.shot_number }}</span>
<span class="clip-number">{{ shot.storyboard_number }}</span>
</div>
</div>
</div>
@@ -116,11 +116,11 @@
<div class="param-section" v-if="currentShot">
<div class="param-group">
<label>镜号</label>
<el-input v-model="currentShot.shot_number" disabled size="small" />
<el-input :model-value="currentShot.storyboard_number" disabled size="small" />
</div>
<div class="param-group">
<label>景别 (Scene)</label>
<el-select v-model="currentShot.shot_type" size="small" @change="handleShotUpdate">
<label>景别</label>
<el-select v-model="currentShot.shot_type" size="small" @change="handleShotUpdateImmediate">
<el-option label="特写" value="特写" />
<el-option label="近景" value="近景" />
<el-option label="中景" value="中景" />
@@ -128,14 +128,35 @@
<el-option label="远景" value="远景" />
</el-select>
</div>
<div class="param-group">
<label>镜头角度</label>
<el-select v-model="currentShot.angle" size="small" @change="handleShotUpdateImmediate">
<el-option label="平视" value="平视" />
<el-option label="仰视" value="仰视" />
<el-option label="俯视" value="俯视" />
<el-option label="侧面" value="侧面" />
<el-option label="背面" value="背面" />
</el-select>
</div>
<div class="param-group">
<label>运镜方式</label>
<el-select v-model="currentShot.movement" size="small" @change="handleShotUpdateImmediate">
<el-option label="固定镜头" value="固定镜头" />
<el-option label="推镜" value="推镜" />
<el-option label="拉镜" value="拉镜" />
<el-option label="摇镜" value="摇镜" />
<el-option label="跟镜" value="跟镜" />
<el-option label="移镜" value="移镜" />
</el-select>
</div>
<div class="param-row">
<div class="param-group">
<label>时间</label>
<el-input v-model="currentShot.time" size="small" @change="handleShotUpdate" />
<el-input v-model="currentShot.time" size="small" @blur="handleShotUpdateImmediate" />
</div>
<div class="param-group">
<label>地点</label>
<el-input v-model="currentShot.location" size="small" @change="handleShotUpdate" />
<el-input v-model="currentShot.location" size="small" @blur="handleShotUpdateImmediate" />
</div>
</div>
@@ -149,7 +170,7 @@
:rows="2"
size="small"
placeholder="角色对话或旁白"
@change="handleShotUpdate"
@blur="handleShotUpdateImmediate"
/>
</div>
<div class="param-group">
@@ -159,7 +180,7 @@
type="textarea"
:rows="2"
size="small"
@change="handleShotUpdate"
@blur="handleShotUpdateImmediate"
/>
</div>
<div class="param-group">
@@ -169,24 +190,32 @@
type="textarea"
:rows="2"
size="small"
@change="handleShotUpdate"
@blur="handleShotUpdateImmediate"
/>
</div>
<el-divider />
<el-divider />
<div class="param-group">
<label>情绪与强度</label>
<div class="param-row">
<el-input v-model="currentShot.emotion" size="small" placeholder="情绪" @change="handleShotUpdate" />
<el-select v-model="currentShot.emotion_intensity" size="small" @change="handleShotUpdate">
<el-option label="极强↑↑↑" value="3" />
<el-option label="强↑↑" value="2" />
<el-option label="中↑" value="1" />
<el-option label="平稳→" value="0" />
<el-option label="弱↓" value="-1" />
</el-select>
</div>
<label>环境氛围</label>
<el-input
v-model="currentShot.atmosphere"
type="textarea"
:rows="2"
size="small"
placeholder="描述光线、色调、声音环境等"
@blur="handleShotUpdateImmediate"
/>
</div>
<div class="param-group">
<label>时长</label>
<el-input-number
v-model="currentShot.duration"
:min="4"
:max="12"
size="small"
@change="handleShotUpdateImmediate"
/>
</div>
</div>
</el-tab-pane>
@@ -382,8 +411,9 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { debounce } from 'lodash-es'
import {
VideoPlay,
VideoPause,
@@ -402,21 +432,24 @@ import { videoAPI } from '@/api/video'
import { useRouter } from 'vue-router'
interface Storyboard {
id?: string
shot_number: string
shot_type: string
time: string
location: string
action: string
result: string
emotion: string
emotion_intensity: string
id?: string | number
storyboard_number: number
shot_type?: string
angle?: string
movement?: string
time?: string
location?: string
action?: string
result?: string
atmosphere?: string
dialogue?: string
duration?: number
background_url?: string
video_url?: string
camera_angle?: string
motion?: string
background_id?: string | number
scene_id?: string | number
title?: string
bgm_prompt?: string
sound_effect?: string
}
interface Background {
@@ -533,10 +566,101 @@ const autoSelectCharacters = () => {
}
}
const handleShotUpdate = () => {
if (currentShot.value) {
emit('update:storyboard', currentShot.value)
// 测试函数:不使用防抖,立即触发
const handleShotUpdateImmediate = async () => {
console.log('=== handleShotUpdate 被触发 ===')
if (!currentShot.value) {
console.warn('handleShotUpdate: currentShot.value is null')
return
}
if (!currentShot.value.id) {
console.warn('handleShotUpdate: currentShot.value.id is null or undefined', currentShot.value)
return
}
try {
// 构建更新数据,只发送有值的字段(包括空字符串)
const updateData: Record<string, any> = {}
if (currentShot.value.shot_type !== undefined) updateData.shot_type = currentShot.value.shot_type
if (currentShot.value.angle !== undefined) updateData.angle = currentShot.value.angle
if (currentShot.value.movement !== undefined) updateData.movement = currentShot.value.movement
if (currentShot.value.time !== undefined) updateData.time = currentShot.value.time
if (currentShot.value.location !== undefined) updateData.location = currentShot.value.location
if (currentShot.value.action !== undefined) updateData.action = currentShot.value.action
if (currentShot.value.dialogue !== undefined) updateData.dialogue = currentShot.value.dialogue
if (currentShot.value.result !== undefined) updateData.result = currentShot.value.result
if (currentShot.value.atmosphere !== undefined) updateData.atmosphere = currentShot.value.atmosphere
if (currentShot.value.duration !== undefined) updateData.duration = currentShot.value.duration
if (currentShot.value.title !== undefined) updateData.title = currentShot.value.title
if (currentShot.value.bgm_prompt !== undefined) updateData.bgm_prompt = currentShot.value.bgm_prompt
if (currentShot.value.sound_effect !== undefined) updateData.sound_effect = currentShot.value.sound_effect
console.log('调用更新接口:', {
storyboard_id: currentShot.value.id,
updateData
})
await dramaAPI.updateStoryboard(currentShot.value.id.toString(), updateData)
emit('update:storyboard', currentShot.value)
ElMessage.success('分镜更新成功')
} catch (error: any) {
console.error('更新分镜失败:', error)
ElMessage.error(error.message || '更新失败')
}
}
const handleShotUpdate = debounce(async () => {
if (!currentShot.value) {
console.warn('handleShotUpdate: currentShot.value is null')
return
}
if (!currentShot.value.id) {
console.warn('handleShotUpdate: currentShot.value.id is null or undefined', currentShot.value)
return
}
try {
// 构建更新数据,只发送有值的字段(包括空字符串)
const updateData: Record<string, any> = {}
if (currentShot.value.shot_type !== undefined) updateData.shot_type = currentShot.value.shot_type
if (currentShot.value.angle !== undefined) updateData.angle = currentShot.value.angle
if (currentShot.value.movement !== undefined) updateData.movement = currentShot.value.movement
if (currentShot.value.time !== undefined) updateData.time = currentShot.value.time
if (currentShot.value.location !== undefined) updateData.location = currentShot.value.location
if (currentShot.value.action !== undefined) updateData.action = currentShot.value.action
if (currentShot.value.dialogue !== undefined) updateData.dialogue = currentShot.value.dialogue
if (currentShot.value.result !== undefined) updateData.result = currentShot.value.result
if (currentShot.value.atmosphere !== undefined) updateData.atmosphere = currentShot.value.atmosphere
if (currentShot.value.duration !== undefined) updateData.duration = currentShot.value.duration
if (currentShot.value.title !== undefined) updateData.title = currentShot.value.title
if (currentShot.value.bgm_prompt !== undefined) updateData.bgm_prompt = currentShot.value.bgm_prompt
if (currentShot.value.sound_effect !== undefined) updateData.sound_effect = currentShot.value.sound_effect
console.log('调用更新接口:', {
storyboard_id: currentShot.value.id,
updateData
})
await dramaAPI.updateStoryboard(currentShot.value.id.toString(), updateData)
emit('update:storyboard', currentShot.value)
ElMessage.success('分镜更新成功')
} catch (error: any) {
console.error('更新分镜失败:', error)
ElMessage.error(error.message || '更新失败')
}
}, 500)
// 使用立即触发版本进行测试
const testUpdate = () => {
console.log('testUpdate 被调用')
handleShotUpdateImmediate()
}
const formatDuration = (seconds: number) => {
@@ -737,7 +861,6 @@ const loadBackgrounds = async () => {
}
// 加载可用角色列表
import { onMounted, watch } from 'vue'
onMounted(async () => {
// 加载背景数据
await loadBackgrounds()

View File

@@ -34,6 +34,14 @@
@timeupdate="handlePreviewTimeUpdate"
@ended="handlePreviewEnded"
/>
<!-- 音频播放器隐藏 -->
<audio
ref="audioPlayer"
:src="currentAudioUrl"
@loadedmetadata="handleAudioLoaded"
@ended="handleAudioEnded"
style="display: none;"
/>
<!-- 转场效果层 -->
<div
v-if="transitionState.active"
@@ -86,7 +94,7 @@
>
<div class="media-thumbnail" @click="previewScene(scene)">
<video :src="scene.video_url" />
<div class="media-duration">{{ scene.duration || '5.0' }}s</div>
<div class="media-duration">{{ scene.duration > 0 ? scene.duration.toFixed(1) : '?' }}s</div>
<el-button
class="delete-btn"
type="danger"
@@ -379,7 +387,8 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
VideoPlay, VideoPause, Plus, FolderAdd, ArrowLeft, ArrowRight,
Scissor, Connection, Setting, ZoomIn, ZoomOut, Refresh, Download, Delete
Scissor, Connection, Setting, ZoomIn, ZoomOut, Refresh, Download, Delete,
Close, VideoCamera, Check, Loading, Headset, Microphone
} from '@element-plus/icons-vue'
import { videoMerger, type MergeProgress } from '@/utils/videoMerger'
import { trimAndMergeVideos } from '@/utils/ffmpeg'
@@ -450,7 +459,7 @@ const availableStoryboards = computed(() => {
storyboard_num: a.storyboard_num,
storyboard_id: a.storyboard_id,
video_url: a.url,
duration: a.duration || 5,
duration: a.duration || 0,
name: a.name,
isAsset: true,
assetId: a.id
@@ -468,6 +477,7 @@ const audioClips = ref<AudioClip[]>([])
const selectedClipId = ref<string | null>(null)
const selectedAudioClipId = ref<string | null>(null)
const previewPlayer = ref<HTMLVideoElement | null>(null)
const audioPlayer = ref<HTMLAudioElement | null>(null)
const timelineContainer = ref<HTMLElement | null>(null)
const showAudioTrack = ref(true) // 是否显示音频轨道
@@ -537,6 +547,16 @@ const currentPreviewUrl = computed(() => {
return clip?.video_url || timelineClips.value[0]?.video_url || ''
})
// 当前音频URL
const currentAudioUrl = computed(() => {
if (audioClips.value.length === 0) return ''
// 根据当前时间找到应该播放的音频片段
const audioClip = audioClips.value.find(a =>
currentTime.value >= a.position && currentTime.value < a.position + a.duration
)
return audioClip?.audio_url || ''
})
const previewScene = (scene: Scene) => {
if (previewPlayer.value) {
previewPlayer.value.src = scene.video_url
@@ -557,6 +577,36 @@ const handlePreviewLoaded = () => {
}
}
const handleAudioLoaded = () => {
// 音频加载完成后跳转到正确的时间点
if (audioPlayer.value && audioClips.value.length > 0) {
const audioClip = audioClips.value.find(a =>
currentTime.value >= a.position && currentTime.value < a.position + a.duration
)
if (audioClip) {
const offsetInClip = currentTime.value - audioClip.position
audioPlayer.value.currentTime = audioClip.start_time + offsetInClip
}
}
}
const handleAudioEnded = () => {
// 音频自然结束,尝试播放下一个音频片段
const currentAudio = audioClips.value.find(a =>
currentTime.value >= a.position && currentTime.value < a.position + a.duration
)
if (currentAudio) {
const currentIndex = audioClips.value.findIndex(a => a.id === currentAudio.id)
const nextAudio = audioClips.value[currentIndex + 1]
if (nextAudio && isPlaying.value) {
// 有下一个音频片段且正在播放,继续
// 时间线会自动更新到下一个片段
}
}
}
const handlePreviewTimeUpdate = () => {
if (!isPlaying.value || !previewPlayer.value) return
@@ -615,11 +665,24 @@ const switchToClip = async (clip: TimelineClip) => {
// 暂停当前播放,避免冲突
previewPlayer.value.pause()
if (audioPlayer.value) {
audioPlayer.value.pause()
}
// 切换视频源
currentTime.value = clip.position
previewPlayer.value.src = clip.video_url
// 同步切换音频源
if (audioClips.value.length > 0 && audioPlayer.value) {
const audioClip = audioClips.value.find(a =>
clip.position >= a.position && clip.position < a.position + a.duration
)
if (audioClip) {
audioPlayer.value.src = audioClip.audio_url
}
}
// 等待视频加载
try {
await new Promise((resolve, reject) => {
@@ -656,6 +719,19 @@ const switchToClip = async (clip: TimelineClip) => {
if (isPlaying.value) {
await previewPlayer.value.play()
// 同步播放音频
if (audioClips.value.length > 0 && audioPlayer.value) {
const audioClip = audioClips.value.find(a =>
clip.position >= a.position && clip.position < a.position + a.duration
)
if (audioClip && audioPlayer.value.src) {
audioPlayer.value.currentTime = audioClip.start_time
audioPlayer.value.play().catch(err => {
console.warn('音频播放失败:', err)
})
}
}
}
} catch (error) {
console.error('切换视频片段失败:', error)
@@ -737,13 +813,43 @@ const handleTrackDrop = (event: DragEvent) => {
addClipToTimeline(scene)
}
const addClipToTimeline = (scene: Scene, insertAtPosition?: number) => {
const getVideoDuration = (videoUrl: string): Promise<number> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'metadata'
video.src = videoUrl
video.onloadedmetadata = () => {
const duration = video.duration
video.remove()
resolve(duration)
}
video.onerror = () => {
video.remove()
reject(new Error('Failed to load video'))
}
})
}
const addClipToTimeline = async (scene: Scene, insertAtPosition?: number) => {
// 获取视频真实时长
let videoDuration = scene.duration || 5
if (scene.video_url) {
try {
videoDuration = await getVideoDuration(scene.video_url)
} catch (error) {
console.warn('Failed to get video duration, using default or scene duration:', error)
videoDuration = scene.duration || 5
}
}
// 计算新片段的位置
let clipPosition: number
let insertAfterIndex: number | null = null
if (insertAtPosition !== undefined && timelineClips.value.length > 0) {
// 如果指定了插入位置找到应该插入的位置
// 如果指定了插入位置,找到应该插入的位置
clipPosition = insertAtPosition
} else if (selectedClipId.value && timelineClips.value.length > 0) {
// 如果有选中的片段,插入到选中片段之后
@@ -774,8 +880,8 @@ const addClipToTimeline = (scene: Scene, insertAtPosition?: number) => {
storyboard_number: scene.storyboard_number,
video_url: scene.video_url,
start_time: 0,
end_time: scene.duration || 5,
duration: scene.duration || 5,
end_time: videoDuration,
duration: videoDuration,
position: clipPosition,
order: timelineClips.value.length,
transition: {
@@ -805,7 +911,7 @@ const addClipToTimeline = (scene: Scene, insertAtPosition?: number) => {
}
// 一键添加全部场景
const addAllScenesInOrder = () => {
const addAllScenesInOrder = async () => {
if (availableStoryboards.value.length === 0) {
ElMessage.warning('没有可用的场景')
return
@@ -819,10 +925,10 @@ const addAllScenesInOrder = () => {
// 清空当前选中,让所有场景都添加到末尾
selectedClipId.value = null
// 批量添加
sortedScenes.forEach(scene => {
addClipToTimeline(scene)
})
// 批量添加(顺序添加以确保正确的时长)
for (const scene of sortedScenes) {
await addClipToTimeline(scene)
}
ElMessage.success(`已批量添加 ${sortedScenes.length} 个场景到时间线`)
}
@@ -965,29 +1071,73 @@ const extractAllAudio = async () => {
return
}
ElMessage.info('正在提取音频...')
// 清空现有音频
audioClips.value = []
// 为每个视频片段创建对应的音频片段
timelineClips.value.forEach((clip, index) => {
const audioClip: AudioClip = {
id: `audio_${Date.now()}_${index}`,
source_clip_id: clip.id,
audio_url: clip.video_url, // 实际应用中应该提取音频这里暂用视频URL
start_time: clip.start_time,
end_time: clip.end_time,
duration: clip.duration,
position: clip.position,
order: index,
volume: 1.0
}
audioClips.value.push(audioClip)
const loadingMessage = ElMessage.info({
message: '正在从视频中提取音频轨道,请稍候...',
duration: 0
})
updateAudioClipOrders()
ElMessage.success(`已提取 ${audioClips.value.length} 个音频片段`)
try {
// 清空现有音频
audioClips.value = []
// 收集所有视频URL
const videoUrls = timelineClips.value.map(clip => clip.video_url)
// 调用后端API批量提取音频
const { audioAPI } = await import('@/api/audio')
const response = await audioAPI.batchExtractAudio(videoUrls)
if (!response.results || response.results.length === 0) {
throw new Error('音频提取失败,未返回结果')
}
// 为每个视频片段创建对应的音频片段
timelineClips.value.forEach((clip, index) => {
const extractedAudio = response.results[index]
if (!extractedAudio) {
console.warn(`视频片段 ${index} 未能提取音频`)
return
}
// 验证音频时长
const audioDuration = extractedAudio.duration
if (!audioDuration || audioDuration <= 0) {
console.error(`音频片段 ${index} 时长无效:`, audioDuration)
throw new Error(`音频片段 ${index + 1} 时长无效`)
}
console.log(`音频片段 ${index}:`, {
video_duration: clip.duration,
audio_duration: audioDuration,
video_position: clip.position,
video_url: clip.video_url,
audio_url: extractedAudio.audio_url
})
const audioClip: AudioClip = {
id: `audio_${Date.now()}_${index}`,
source_clip_id: clip.id,
audio_url: extractedAudio.audio_url,
start_time: 0, // 音频从头开始播放
end_time: audioDuration, // 使用实际音频时长
duration: audioDuration, // 使用提取的音频时长
position: clip.position, // 和视频片段在时间轴上相同位置
order: index,
volume: 1.0
}
audioClips.value.push(audioClip)
})
updateAudioClipOrders()
loadingMessage.close()
ElMessage.success(`已成功提取 ${audioClips.value.length} 个音频片段`)
} catch (error: any) {
console.error('提取音频失败:', error)
loadingMessage.close()
ElMessage.error(error.message || '音频提取失败,请重试')
// 清空部分提取的音频
audioClips.value = []
}
}
const selectAudioClip = (audio: AudioClip) => {
@@ -1325,7 +1475,7 @@ const clickTimeline = (event: MouseEvent) => {
const seekToTime = (time: number) => {
currentTime.value = time
// 找到对应时间的片段并播放
// 找到对应时间的视频片段并播放
const clip = timelineClips.value.find(c =>
time >= c.position && time < c.position + c.duration
)
@@ -1344,6 +1494,33 @@ const seekToTime = (time: number) => {
previewPlayer.value.play()
}
}
// 同步音频播放器
if (audioClips.value.length > 0 && audioPlayer.value) {
const audioClip = audioClips.value.find(a =>
time >= a.position && time < a.position + a.duration
)
if (audioClip) {
// 切换音频源(如果需要)
if (audioPlayer.value.src !== audioClip.audio_url) {
audioPlayer.value.src = audioClip.audio_url
}
// 跳转到音频片段内的对应时间
const offsetInAudioClip = time - audioClip.position
audioPlayer.value.currentTime = audioClip.start_time + offsetInAudioClip
if (isPlaying.value) {
audioPlayer.value.play().catch(err => {
console.warn('音频播放失败:', err)
})
}
} else {
// 当前位置没有音频,暂停音频播放器
audioPlayer.value.pause()
}
}
}
// 播放控制
@@ -1355,7 +1532,7 @@ const playTimeline = () => {
isPlaying.value = true
// 找到当前时间对应的片段
// 找到当前时间对应的视频片段
const clip = timelineClips.value.find(c =>
currentTime.value >= c.position && currentTime.value < c.position + c.duration
)
@@ -1373,6 +1550,24 @@ const playTimeline = () => {
seekToTime(0)
previewPlayer.value?.play()
}
// 同时播放音频(如果有)
if (audioClips.value.length > 0 && audioPlayer.value) {
const audioClip = audioClips.value.find(a =>
currentTime.value >= a.position && currentTime.value < a.position + a.duration
)
if (audioClip) {
if (audioPlayer.value.src !== audioClip.audio_url) {
audioPlayer.value.src = audioClip.audio_url
}
const offsetInAudioClip = currentTime.value - audioClip.position
audioPlayer.value.currentTime = audioClip.start_time + offsetInAudioClip
audioPlayer.value.play().catch(err => {
console.warn('音频播放失败:', err)
})
}
}
}
const pauseTimeline = () => {
@@ -1380,6 +1575,10 @@ const pauseTimeline = () => {
if (previewPlayer.value) {
previewPlayer.value.pause()
}
// 同时暂停音频
if (audioPlayer.value) {
audioPlayer.value.pause()
}
}
const togglePlay = () => {