feat(ffmpeg): add ffmpeg-core.wasm for video processing capabilities
This commit is contained in:
@@ -15,13 +15,132 @@ const emit = defineEmits({
|
||||
const dayjs = useDayjs()
|
||||
const toast = useToast()
|
||||
|
||||
const isFailed = computed(() => {
|
||||
return props.video.progress === -1
|
||||
})
|
||||
const isPreviewModalOpen = ref(false)
|
||||
const isVideoBackgroundPreviewOpen = ref(false)
|
||||
const isFullContentOpen = ref(false)
|
||||
const downloadingState = reactive({
|
||||
subtitle: 0,
|
||||
video: 0,
|
||||
})
|
||||
|
||||
// 背景选择相关状态
|
||||
const selectedBackgroundFile = ref<File | null>(null)
|
||||
const selectedBackgroundPreview = ref<string>('')
|
||||
const isCombinatorLoading = ref(false)
|
||||
const compositingProgress = ref(0)
|
||||
const compositingPhase = ref<'loading' | 'analyzing' | 'preparing' | 'executing' | 'finalizing'>('loading')
|
||||
const combinatorError = ref<string>('')
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const compositedVideoBlob = ref<Blob | null>(null)
|
||||
|
||||
// 阶段显示文本
|
||||
const phaseText = computed(() => {
|
||||
const phaseMap: Record<typeof compositingPhase.value, string> = {
|
||||
'loading': '加载资源...',
|
||||
'analyzing': '分析图片...',
|
||||
'preparing': '准备合成...',
|
||||
'executing': '合成中...',
|
||||
'finalizing': '完成处理...',
|
||||
}
|
||||
return phaseMap[compositingPhase.value]
|
||||
})
|
||||
|
||||
const handleBackgroundFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
|
||||
if (!file) return
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.add({
|
||||
title: '文件类型错误',
|
||||
description: '请选择一个图片文件',
|
||||
color: 'red',
|
||||
icon: 'i-tabler-alert-triangle',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
selectedBackgroundFile.value = file
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
selectedBackgroundPreview.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
combinatorError.value = ''
|
||||
compositedVideoBlob.value = null
|
||||
}
|
||||
|
||||
const composeBackgroundVideo = async () => {
|
||||
if (!selectedBackgroundFile.value) {
|
||||
toast.add({
|
||||
title: '未选择图片',
|
||||
description: '请先选择一个背景图片',
|
||||
color: 'orange',
|
||||
icon: 'i-tabler-alert-circle',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isCombinatorLoading.value = true
|
||||
compositingProgress.value = 0
|
||||
combinatorError.value = ''
|
||||
|
||||
// 使用 FFmpeg WASM 进行视频背景合成
|
||||
const resultBlob = await useVideoBackgroundCompositing(
|
||||
props.video.video_alpha_url!,
|
||||
selectedBackgroundFile.value,
|
||||
{
|
||||
onProgress: (info) => {
|
||||
compositingProgress.value = info.progress
|
||||
compositingPhase.value = info.phase
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
compositedVideoBlob.value = resultBlob
|
||||
|
||||
toast.add({
|
||||
title: '合成成功',
|
||||
description: '背景已成功合成,可预览或下载',
|
||||
color: 'green',
|
||||
icon: 'i-tabler-check',
|
||||
})
|
||||
} catch (err: any) {
|
||||
combinatorError.value = err.message || '合成失败,请重试'
|
||||
toast.add({
|
||||
title: '合成失败',
|
||||
description: combinatorError.value,
|
||||
color: 'red',
|
||||
icon: 'i-tabler-alert-triangle',
|
||||
})
|
||||
} finally {
|
||||
isCombinatorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadCompositedVideo = () => {
|
||||
if (!compositedVideoBlob.value) return
|
||||
|
||||
const url = URL.createObjectURL(compositedVideoBlob.value)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${props.video.title || props.video.task_id}_合成.webm`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const compositedVideoUrl = computed(() => {
|
||||
return compositedVideoBlob.value ? URL.createObjectURL(compositedVideoBlob.value) : ''
|
||||
})
|
||||
|
||||
const startDownload = (url: string, filename: string) => {
|
||||
if (url.endsWith('.ass')) {
|
||||
downloadingState.subtitle = 0
|
||||
@@ -73,10 +192,6 @@ const startDownload = (url: string, filename: string) => {
|
||||
|
||||
download()
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
console.log('click delete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -84,11 +199,14 @@ const onClick = () => {
|
||||
class="w-full flex gap-2 rounded-xl border border-neutral-200 dark:border-neutral-700 hover:shadow transition overflow-hidden p-3"
|
||||
>
|
||||
<div class="flex-0 h-48 aspect-[10/16] flex flex-col items-center justify-center rounded-lg shadow overflow-hidden relative group">
|
||||
<div v-if="!video.video_cover" class="w-full h-full bg-primary flex flex-col justify-center items-center gap-2">
|
||||
<UIcon class="animate-spin text-4xl text-white" name="tabler:loader"/>
|
||||
<div v-if="!video.video_cover" class="w-full h-full flex flex-col justify-center items-center gap-2" :class="!isFailed ? 'bg-primary' : 'bg-rose-400'">
|
||||
<UIcon v-if="!isFailed" class="animate-spin text-4xl text-white" name="tabler:loader"/>
|
||||
<UIcon v-else class="text-4xl text-white" name="tabler:alert-triangle"/>
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-sm font-bold text-white/90">火速生成中</span>
|
||||
<span class="text-xs font-medium text-white/50">{{ video.progress }}%</span>
|
||||
<span class="text-sm font-bold text-white/90">
|
||||
{{ isFailed ? '生成失败' : '火速生成中...' }}
|
||||
</span>
|
||||
<span v-if="!isFailed" class="text-xs font-medium text-white/50">{{ video.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtImg v-else :src="video.video_cover" class="w-full h-full brightness-90 object-cover"/>
|
||||
@@ -148,15 +266,36 @@ const onClick = () => {
|
||||
variant="soft"
|
||||
@click="startDownload(video.subtitle!, (video.title || video.task_id) + '.ass')"
|
||||
/>
|
||||
<UButton
|
||||
:label="downloadingState.video > 0 && downloadingState.video < 100 ? `${downloadingState.video.toFixed(0)}%` : '视频'"
|
||||
:loading="downloadingState.video > 0 && downloadingState.video < 100"
|
||||
:disabled="!video.video_url"
|
||||
color="primary"
|
||||
leading-icon="i-tabler-download"
|
||||
variant="soft"
|
||||
@click="startDownload(video.video_url!, (video.title || video.task_id) + '.mp4')"
|
||||
/>
|
||||
<UDropdown
|
||||
:items="[
|
||||
[
|
||||
{
|
||||
label: '绿幕视频下载',
|
||||
icon: 'tabler:download',
|
||||
click: () => {
|
||||
startDownload(video.video_url!, (video.title || video.task_id) + '.mp4')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '合成背景图片',
|
||||
icon: 'tabler:background',
|
||||
click: () => {
|
||||
isVideoBackgroundPreviewOpen = true
|
||||
},
|
||||
disabled: !video.video_alpha_url
|
||||
},
|
||||
],
|
||||
]"
|
||||
>
|
||||
<UButton
|
||||
:label="downloadingState.video > 0 && downloadingState.video < 100 ? `${downloadingState.video.toFixed(0)}%` : '视频'"
|
||||
:loading="downloadingState.video > 0 && downloadingState.video < 100"
|
||||
:disabled="!video.video_url"
|
||||
color="primary"
|
||||
leading-icon="i-tabler-download"
|
||||
variant="soft"
|
||||
/>
|
||||
</UDropdown>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,6 +348,132 @@ const onClick = () => {
|
||||
<video class="w-full rounded shadow" controls autoplay :src="video.video_url" />
|
||||
</UCard>
|
||||
</UModal>
|
||||
<UModal v-model="isVideoBackgroundPreviewOpen">
|
||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden">
|
||||
<p>视频背景合成</p>
|
||||
<p class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis">
|
||||
{{ video.title }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton class="-my-1" color="gray" icon="i-tabler-x" variant="ghost" @click="isVideoBackgroundPreviewOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 背景图片选择区域 -->
|
||||
<div v-if="!compositedVideoBlob && !isCombinatorLoading" class="border-2 border-dashed border-neutral-200 dark:border-neutral-700 rounded-lg p-4">
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">选择背景图片</div>
|
||||
|
||||
<!-- 预览区域 -->
|
||||
<!-- <div v-if="selectedBackgroundPreview" class="relative w-full aspect-video rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800">
|
||||
<img :src="selectedBackgroundPreview" alt="背景预览" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="w-full aspect-video rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 flex flex-col items-center justify-center gap-2">
|
||||
<UIcon class="text-3xl text-neutral-400" name="tabler:photo" />
|
||||
<span class="text-xs text-neutral-400">点击选择图片</span>
|
||||
</div> -->
|
||||
|
||||
<!-- 文件输入 -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleBackgroundFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 选择按钮 -->
|
||||
<UButton
|
||||
block
|
||||
color="primary"
|
||||
icon="i-tabler-photo-plus"
|
||||
label="选择图片"
|
||||
variant="soft"
|
||||
@click="fileInputRef?.click()"
|
||||
/>
|
||||
|
||||
<!-- 选中的文件名 -->
|
||||
<div v-if="selectedBackgroundFile" class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
已选择: {{ selectedBackgroundFile.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<UAlert
|
||||
v-if="combinatorError"
|
||||
color="red"
|
||||
icon="i-tabler-alert-triangle"
|
||||
title="合成失败"
|
||||
:description="combinatorError"
|
||||
/>
|
||||
|
||||
<!-- 合成进度 -->
|
||||
<div v-if="isCombinatorLoading" class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ phaseText }}</span>
|
||||
<span class="text-xs text-neutral-500">{{ compositingProgress }}%</span>
|
||||
</div>
|
||||
<UProgress :value="compositingProgress" />
|
||||
</div>
|
||||
|
||||
<!-- 合成预览 -->
|
||||
<div v-if="compositedVideoBlob" class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">视频预览</div>
|
||||
<video
|
||||
class="w-full rounded-lg shadow bg-black"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
:src="compositedVideoUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
color="gray"
|
||||
label="取消"
|
||||
:disabled="isCombinatorLoading"
|
||||
@click="isVideoBackgroundPreviewOpen = false"
|
||||
/>
|
||||
<UButton
|
||||
v-if="compositedVideoBlob"
|
||||
color="gray"
|
||||
label="重新选择"
|
||||
@click="() => {
|
||||
selectedBackgroundFile = null
|
||||
selectedBackgroundPreview = ''
|
||||
compositedVideoBlob = null
|
||||
combinatorError = ''
|
||||
isCombinatorLoading = false
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
v-if="compositedVideoBlob"
|
||||
color="green"
|
||||
icon="i-tabler-download"
|
||||
label="下载合成视频"
|
||||
@click="downloadCompositedVideo"
|
||||
/>
|
||||
<UButton
|
||||
v-else
|
||||
:disabled="!selectedBackgroundFile || isCombinatorLoading"
|
||||
:loading="isCombinatorLoading"
|
||||
color="primary"
|
||||
icon="i-tabler-wand"
|
||||
:label="isCombinatorLoading ? '合成中' : '开始合成'"
|
||||
@click="composeBackgroundVideo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user