feat: 优化
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 数字人视频生成页面
|
||||
* 功能:用户上传视频,选择音色和文案,生成数字人视频
|
||||
* 优化:移除冗余状态、统一试听逻辑、优化错误处理、清理废弃代码
|
||||
* 试听优化:添加缓存机制,同一参数下第二次试听直接播放缓存,无需重复调用API
|
||||
*/
|
||||
defineOptions({ name: 'DigitalVideoPage' })
|
||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { InboxOutlined } from '@ant-design/icons-vue'
|
||||
import { InboxOutlined, SoundOutlined, LoadingOutlined } from '@ant-design/icons-vue'
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { MaterialService } from '@/api/material'
|
||||
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
|
||||
@@ -23,18 +29,18 @@ const currentTaskStep = ref('')
|
||||
const isSynthesizing = ref(false)
|
||||
const synthesizedAudio = ref(null)
|
||||
const previewLoadingVoiceId = ref('')
|
||||
const isPlayingPreview = ref(false) // 是否正在播放试听音频
|
||||
const playingPreviewVoiceId = ref('') // 当前正在试听的音色ID
|
||||
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
|
||||
const pollingInterval = ref(null) // 轮询间隔ID
|
||||
|
||||
// Base64音频缓存
|
||||
const audioBase64Cache = new Map()
|
||||
const AUDIO_CACHE_MAX_SIZE = 10 // 最多缓存10个音频
|
||||
// 试听音频缓存 - 按音色ID缓存,避免重复API调用
|
||||
const previewAudioCache = new Map()
|
||||
const MAX_PREVIEW_CACHE_SIZE = 50 // 最多缓存50个音色的试听音频
|
||||
|
||||
// TTS 配置
|
||||
const ttsText = ref('')
|
||||
const selectedTtsVoice = ref('')
|
||||
const speechRate = ref(1.0)
|
||||
const speechRate = ref(1.5)
|
||||
const instruction = ref('neutral') // 指令参数,用于控制音色风格
|
||||
const emotion = ref('neutral') // 情感参数
|
||||
const emotionActive = ref(false) // 是否激活情感tab(false为指令,true为情感)
|
||||
@@ -112,54 +118,131 @@ const selectVoiceProfile = (voice) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 音频播放
|
||||
// 音频试听 - 统一入口
|
||||
const playVoiceSample = async (voice) => {
|
||||
if (!voice) return
|
||||
// 防止重复点击
|
||||
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
|
||||
// 防止重复点击同一个音色
|
||||
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
|
||||
return
|
||||
}
|
||||
// 用户音色和系统音色都走实时试听流程
|
||||
return triggerVoicePreview(voice)
|
||||
}
|
||||
// 如果正在播放其他音色,先停止
|
||||
if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
|
||||
try {
|
||||
previewAudio?.pause?.()
|
||||
previewAudio = null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const triggerVoicePreview = async (voice) => {
|
||||
// 防止重复点击
|
||||
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
|
||||
previewLoadingVoiceId.value = voice.id
|
||||
playingPreviewVoiceId.value = voice.id
|
||||
|
||||
// 生成缓存key
|
||||
const cacheKey = generatePreviewCacheKey(voice)
|
||||
const cachedAudio = previewAudioCache.get(cacheKey)
|
||||
|
||||
// 如果有缓存,直接播放
|
||||
if (cachedAudio) {
|
||||
console.log('使用缓存音频:', voice.name)
|
||||
playCachedAudio(cachedAudio, resetPreviewState)
|
||||
return
|
||||
}
|
||||
|
||||
previewLoadingVoiceId.value = voice.id
|
||||
|
||||
// 没有缓存,调用API
|
||||
try {
|
||||
const params = buildPreviewParams(voice)
|
||||
if (!params) {
|
||||
previewLoadingVoiceId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const res = await VoiceService.preview(params)
|
||||
if (!params) return resetPreviewState()
|
||||
|
||||
if (res.code === 0 && res.data?.audioUrl) {
|
||||
isPlayingPreview.value = true
|
||||
playAudioPreview(res.data.audioUrl, {
|
||||
onEnded: () => {
|
||||
isPlayingPreview.value = false
|
||||
}
|
||||
})
|
||||
} else if (res.code === 0 && res.data?.audioBase64) {
|
||||
isPlayingPreview.value = true
|
||||
playAudioFromBase64(res.data.audioBase64, res.data.format)
|
||||
} else {
|
||||
// 调试:打印试听参数
|
||||
console.log(`试听音色: ${voice.name}, voiceId: ${voice.voiceId || voice.rawId}`)
|
||||
|
||||
const res = await VoiceService.preview(params)
|
||||
if (res.code !== 0) {
|
||||
message.error(res.msg || '试听失败')
|
||||
return resetPreviewState()
|
||||
}
|
||||
|
||||
// 调试:打印返回的音频信息
|
||||
if (res.data?.audioBase64) {
|
||||
console.log(`返回音频base64前32位: ${res.data.audioBase64.substring(0, 32)}...`)
|
||||
}
|
||||
|
||||
// 根据返回类型播放音频并缓存
|
||||
if (res.data?.audioUrl) {
|
||||
// URL方式暂不缓存
|
||||
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState })
|
||||
} else if (res.data?.audioBase64) {
|
||||
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
|
||||
playCachedAudio(audioData, resetPreviewState)
|
||||
} else {
|
||||
message.error('试听失败')
|
||||
resetPreviewState()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('preview error:', error)
|
||||
message.error('试听失败')
|
||||
} finally {
|
||||
previewLoadingVoiceId.value = ''
|
||||
resetPreviewState()
|
||||
}
|
||||
}
|
||||
|
||||
// 生成试听缓存key
|
||||
const generatePreviewCacheKey = (voice) => {
|
||||
const voiceId = voice.voiceId || voice.rawId || voice.id
|
||||
const text = ttsText.value.trim()
|
||||
const rate = speechRate.value
|
||||
const inst = instruction.value
|
||||
const emo = emotion.value
|
||||
return `${voiceId}:${text}:${rate}:${inst}:${emo}`
|
||||
}
|
||||
|
||||
// 解码base64并缓存
|
||||
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => {
|
||||
const byteCharacters = window.atob(audioBase64)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
const audioData = { blob, objectUrl, format }
|
||||
|
||||
// 缓存音频数据
|
||||
previewAudioCache.set(cacheKey, audioData)
|
||||
|
||||
// 管理缓存大小
|
||||
if (previewAudioCache.size > MAX_PREVIEW_CACHE_SIZE) {
|
||||
const firstKey = previewAudioCache.keys().next().value
|
||||
const oldData = previewAudioCache.get(firstKey)
|
||||
URL.revokeObjectURL(oldData.objectUrl)
|
||||
previewAudioCache.delete(firstKey)
|
||||
}
|
||||
|
||||
return audioData
|
||||
}
|
||||
|
||||
// 播放缓存的音频
|
||||
const playCachedAudio = (audioData, onEnded) => {
|
||||
// 清理旧的previewObjectUrl
|
||||
if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) {
|
||||
URL.revokeObjectURL(previewObjectUrl)
|
||||
}
|
||||
previewObjectUrl = audioData.objectUrl
|
||||
|
||||
playAudioPreview(previewObjectUrl, {
|
||||
revokeOnEnd: false, // 缓存音频不释放,保留给下次播放
|
||||
onEnded: () => {
|
||||
onEnded && onEnded()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 重置试听状态
|
||||
const resetPreviewState = () => {
|
||||
previewLoadingVoiceId.value = ''
|
||||
playingPreviewVoiceId.value = ''
|
||||
}
|
||||
|
||||
const buildPreviewParams = (voice) => {
|
||||
if (voice.source === 'user') {
|
||||
// 用户音色:使用voiceConfigId,不传instruction
|
||||
@@ -172,7 +255,8 @@ const buildPreviewParams = (voice) => {
|
||||
voiceConfigId: configId,
|
||||
inputText: ttsText.value,
|
||||
speechRate: speechRate.value || 1.0,
|
||||
audioFormat: 'mp3'
|
||||
audioFormat: 'mp3',
|
||||
timestamp: Date.now() // 添加时间戳确保每次请求不同
|
||||
}
|
||||
} else {
|
||||
// 系统音色:根据是否选择instruction或emotion来决定传递哪个参数
|
||||
@@ -180,7 +264,8 @@ const buildPreviewParams = (voice) => {
|
||||
voiceId: voice.voiceId,
|
||||
inputText: ttsText.value,
|
||||
speechRate: speechRate.value || 1.0,
|
||||
audioFormat: 'mp3'
|
||||
audioFormat: 'mp3',
|
||||
timestamp: Date.now() // 添加时间戳确保每次请求不同
|
||||
}
|
||||
|
||||
// instruction和emotion只能选一个传递
|
||||
@@ -199,7 +284,7 @@ const buildPreviewParams = (voice) => {
|
||||
const extractIdFromString = (idStr) => {
|
||||
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
|
||||
const extractedId = parseInt(idStr.replace('user-', ''))
|
||||
return isNaN(extractedId) ? null : extractedId
|
||||
return Number.isNaN(extractedId) ? null : extractedId
|
||||
}
|
||||
|
||||
// 语音合成
|
||||
@@ -498,7 +583,7 @@ const startPollingTask = () => {
|
||||
} catch (error) {
|
||||
console.error('polling error:', error)
|
||||
}
|
||||
}, 10000) // 每2秒轮询一次
|
||||
}, 10000) // 每10秒轮询一次
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
@@ -577,93 +662,67 @@ const getStepText = (step) => {
|
||||
|
||||
const playAudioPreview = (url, options = {}) => {
|
||||
if (!url) return message.warning('暂无可试听的音频')
|
||||
|
||||
|
||||
// 暂停之前的音频
|
||||
try {
|
||||
previewAudio?.pause?.()
|
||||
try {
|
||||
previewAudio?.pause?.()
|
||||
previewAudio = null
|
||||
} catch {}
|
||||
|
||||
const audio = new Audio(url)
|
||||
audio.play().then(() => {
|
||||
previewAudio = audio
|
||||
audio.onended = () => {
|
||||
// 调用自定义结束回调
|
||||
if (options.onEnded) {
|
||||
options.onEnded()
|
||||
}
|
||||
// 清理blob URL
|
||||
if (options.revokeOnEnd && url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url)
|
||||
previewObjectUrl === url && (previewObjectUrl = '')
|
||||
}
|
||||
previewAudio = null
|
||||
}
|
||||
audio.onerror = () => {
|
||||
// 播放失败时也要重置状态
|
||||
if (options.onEnded) {
|
||||
options.onEnded()
|
||||
}
|
||||
previewAudio = null
|
||||
message.error('播放失败')
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('播放失败:', err)
|
||||
// 播放失败时重置状态
|
||||
if (options.onEnded) {
|
||||
options.onEnded()
|
||||
const cleanup = () => {
|
||||
if (options.revokeOnEnd && url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url)
|
||||
previewObjectUrl === url && (previewObjectUrl = '')
|
||||
}
|
||||
previewAudio = null
|
||||
message.error('播放失败')
|
||||
})
|
||||
options.onEnded && options.onEnded()
|
||||
}
|
||||
|
||||
audio.play()
|
||||
.then(() => {
|
||||
previewAudio = audio
|
||||
audio.onended = cleanup
|
||||
audio.onerror = () => {
|
||||
console.error('播放失败')
|
||||
cleanup()
|
||||
message.error('播放失败')
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('播放失败:', err)
|
||||
cleanup()
|
||||
message.error('播放失败')
|
||||
})
|
||||
}
|
||||
|
||||
// 播放Base64音频(用于合成音频播放)
|
||||
const playAudioFromBase64 = (audioBase64, format = 'mp3', onEnded = null) => {
|
||||
try {
|
||||
// 检查缓存
|
||||
const cacheKey = `${audioBase64.substring(0, 32)}_${format}` // 使用base64前32位作为缓存键
|
||||
let objectUrl = audioBase64Cache.get(cacheKey)
|
||||
|
||||
if (!objectUrl) {
|
||||
// 解码base64并创建blob
|
||||
const byteCharacters = window.atob(audioBase64)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
// 管理缓存大小
|
||||
if (audioBase64Cache.size >= AUDIO_CACHE_MAX_SIZE) {
|
||||
// 清理最早的缓存
|
||||
const firstKey = audioBase64Cache.keys().next().value
|
||||
const oldUrl = audioBase64Cache.get(firstKey)
|
||||
URL.revokeObjectURL(oldUrl)
|
||||
audioBase64Cache.delete(firstKey)
|
||||
}
|
||||
|
||||
// 存储到缓存
|
||||
audioBase64Cache.set(cacheKey, objectUrl)
|
||||
// 解码base64并创建blob
|
||||
const byteCharacters = window.atob(audioBase64)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
|
||||
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
// 清理旧的previewObjectUrl
|
||||
if (previewObjectUrl && previewObjectUrl !== objectUrl) {
|
||||
if (previewObjectUrl) {
|
||||
URL.revokeObjectURL(previewObjectUrl)
|
||||
}
|
||||
previewObjectUrl = objectUrl
|
||||
|
||||
playAudioPreview(previewObjectUrl, {
|
||||
revokeOnEnd: false, // 缓存模式下不立即释放
|
||||
revokeOnEnd: true, // 合成音频播放结束后释放URL
|
||||
onEnded: () => {
|
||||
isPlayingPreview.value = false
|
||||
onEnded && onEnded()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Base64播放失败:', error)
|
||||
isPlayingPreview.value = false
|
||||
message.error('音频播放失败')
|
||||
onEnded && onEnded()
|
||||
}
|
||||
@@ -684,15 +743,23 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 停止音频播放
|
||||
previewAudio?.pause?.()
|
||||
previewAudio = null
|
||||
// 清理所有缓存的ObjectURL
|
||||
audioBase64Cache.forEach(url => URL.revokeObjectURL(url))
|
||||
audioBase64Cache.clear()
|
||||
|
||||
// 清理音频URL
|
||||
previewObjectUrl && URL.revokeObjectURL(previewObjectUrl)
|
||||
|
||||
// 清理试听缓存
|
||||
previewAudioCache.forEach(audioData => {
|
||||
URL.revokeObjectURL(audioData.objectUrl)
|
||||
})
|
||||
previewAudioCache.clear()
|
||||
|
||||
// 重置播放状态
|
||||
isPlayingPreview.value = false
|
||||
resetPreviewState()
|
||||
isPlayingSynthesized.value = false
|
||||
|
||||
// 清理轮询
|
||||
if (pollingInterval.value) {
|
||||
clearInterval(pollingInterval.value)
|
||||
@@ -720,6 +787,16 @@ watch([ttsText, selectedTtsVoice], () => {
|
||||
synthesizedAudio.value = null
|
||||
})
|
||||
|
||||
// 监听试听相关参数变化,清除缓存
|
||||
watch([ttsText, speechRate, instruction, emotion], () => {
|
||||
// 清除试听缓存
|
||||
previewAudioCache.forEach(audioData => {
|
||||
URL.revokeObjectURL(audioData.objectUrl)
|
||||
})
|
||||
previewAudioCache.clear()
|
||||
console.log('试听参数已变化,清除缓存')
|
||||
})
|
||||
|
||||
// 音频实例
|
||||
let previewAudio = null
|
||||
let previewObjectUrl = ''
|
||||
@@ -780,11 +857,28 @@ let previewObjectUrl = ''
|
||||
<a-button
|
||||
size="small"
|
||||
type="link"
|
||||
:loading="previewLoadingVoiceId === voice.id"
|
||||
:disabled="previewLoadingVoiceId === voice.id || isPlayingPreview"
|
||||
class="preview-btn"
|
||||
:disabled="previewLoadingVoiceId === voice.id"
|
||||
@click.stop="playVoiceSample(voice)"
|
||||
>
|
||||
{{ previewLoadingVoiceId === voice.id ? '加载中...' : (isPlayingPreview ? '播放中...' : '试听') }}
|
||||
<template #icon>
|
||||
<SoundOutlined
|
||||
v-if="!previewLoadingVoiceId && !playingPreviewVoiceId"
|
||||
class="preview-icon"
|
||||
/>
|
||||
<LoadingOutlined
|
||||
v-else-if="previewLoadingVoiceId === voice.id"
|
||||
class="preview-icon spinning"
|
||||
/>
|
||||
<SoundOutlined
|
||||
v-else-if="playingPreviewVoiceId === voice.id"
|
||||
class="preview-icon playing"
|
||||
/>
|
||||
<SoundOutlined
|
||||
v-else
|
||||
class="preview-icon idle"
|
||||
/>
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -867,40 +961,6 @@ let previewObjectUrl = ''
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语音合成 -->
|
||||
<!-- <div class="tts-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="isSynthesizing"
|
||||
@click="handleSynthesizeVoice"
|
||||
>
|
||||
{{ isSynthesizing ? '语音合成中...' : '生成语音' }}
|
||||
</a-button>
|
||||
|
||||
<div v-if="synthesizedAudio" class="synth-audio-card">
|
||||
<div class="synth-audio-title">已生成语音</div>
|
||||
<div class="synth-audio-meta">
|
||||
<span>格式:{{ (synthesizedAudio.format || 'mp3').toUpperCase() }}</span>
|
||||
<span v-if="synthesizedAudio.audioBase64">Base64编码</span>
|
||||
</div>
|
||||
<div class="synth-audio-actions">
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="isPlayingSynthesized"
|
||||
:disabled="isPlayingSynthesized"
|
||||
@click="playSynthesizedAudio"
|
||||
>
|
||||
{{ isPlayingSynthesized ? '播放中...' : '试听' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="synth-audio-hint">
|
||||
先生成语音,再上传视频,即可开始混剪
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 视频上传 -->
|
||||
<div class="video-section">
|
||||
<div class="section-label">视频</div>
|
||||
@@ -1136,6 +1196,56 @@ let previewObjectUrl = ''
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.preview-icon {
|
||||
font-size: 18px;
|
||||
color: var(--color-text);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* 空闲状态(当前没有试听但有其他试听在进行) */
|
||||
.preview-icon.idle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 加载中旋转动画 */
|
||||
.preview-icon.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放中脉冲动画 */
|
||||
.preview-icon.playing {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-tag {
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 20px;
|
||||
@@ -1244,43 +1354,6 @@ let previewObjectUrl = ''
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tts-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.synth-audio-card {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.synth-audio-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.synth-audio-meta {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.synth-audio-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.synth-audio-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user