628 lines
19 KiB
Vue
628 lines
19 KiB
Vue
<script lang="ts" setup>
|
|
import type { PropType } from 'vue'
|
|
|
|
const props = defineProps({
|
|
video: {
|
|
type: Object as PropType<GBVideoItem>,
|
|
required: true,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits({
|
|
delete: (video: GBVideoItem) => video,
|
|
})
|
|
|
|
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}_composited.mp4`
|
|
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
|
|
} else {
|
|
downloadingState.video = 0
|
|
}
|
|
|
|
const { download, progressEmitter } = useDownload(url, filename)
|
|
|
|
progressEmitter.on('progress', (progress) => {
|
|
if (url.endsWith('.ass')) {
|
|
downloadingState.subtitle = progress
|
|
} else {
|
|
downloadingState.video = progress
|
|
}
|
|
console.log(downloadingState)
|
|
})
|
|
|
|
progressEmitter.on('done', () => {
|
|
if (url.endsWith('.ass')) {
|
|
downloadingState.subtitle = 100
|
|
} else {
|
|
downloadingState.video = 100
|
|
}
|
|
toast.add({
|
|
title: '下载完成',
|
|
description: '资源下载已完成',
|
|
color: 'green',
|
|
icon: 'i-tabler-check',
|
|
})
|
|
})
|
|
|
|
progressEmitter.on('error', (err) => {
|
|
if (url.endsWith('.ass')) {
|
|
downloadingState.subtitle = 0
|
|
} else {
|
|
downloadingState.video = 0
|
|
}
|
|
toast.add({
|
|
title: '下载失败',
|
|
description: err.message || '下载失败,未知错误',
|
|
color: 'red',
|
|
icon: 'i-tabler-alert-triangle',
|
|
})
|
|
})
|
|
|
|
download()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
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 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">
|
|
{{ 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"
|
|
/>
|
|
<div
|
|
class="absolute inset-0 bg-black/10 backdrop-blur-md flex justify-center items-center rounded-lg opacity-0 group-hover:opacity-100 duration-300"
|
|
>
|
|
<div
|
|
class="rounded-full w-14 aspect-square bg-gray-300/50 backdrop-blur-md flex justify-center items-center cursor-pointer"
|
|
@click="isPreviewModalOpen = true"
|
|
>
|
|
<Icon
|
|
name="i-tabler-play"
|
|
class="text-white text-3xl"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 flex flex-col justify-between gap-2">
|
|
<div
|
|
class="flex-1 rounded-lg bg-neutral-100 dark:bg-neutral-800 p-2 px-2.5"
|
|
>
|
|
<ul class="grid grid-cols-2 gap-1.5">
|
|
<li class="col-span-2">
|
|
<!-- <h2 class="text-2xs font-medium text-primary-500">标题</h2>-->
|
|
<p class="text-sm font-bold line-clamp-1">
|
|
{{ video.title || '无标题' }}
|
|
</p>
|
|
</li>
|
|
<li class="">
|
|
<h2 class="text-2xs font-medium text-primary-500">完成时间</h2>
|
|
<p class="text-xs line-clamp-1">
|
|
{{
|
|
video.complete_time
|
|
? dayjs(video.complete_time * 1000).format(
|
|
'YYYY-MM-DD HH:mm:ss'
|
|
)
|
|
: '进行中'
|
|
}}
|
|
</p>
|
|
</li>
|
|
<li class="">
|
|
<h2 class="text-2xs font-medium text-primary-500">生成耗时</h2>
|
|
<p class="text-xs line-clamp-1">
|
|
{{
|
|
video.duration
|
|
? dayjs.duration(video.duration || 0).format('HH:mm:ss')
|
|
: '进行中'
|
|
}}
|
|
</p>
|
|
</li>
|
|
<li
|
|
class="col-span-2 cursor-pointer"
|
|
@click="isFullContentOpen = true"
|
|
>
|
|
<h2 class="text-2xs font-medium text-primary-500">驱动文本</h2>
|
|
<p class="text-xs line-clamp-4 text-justify">{{ video.content }}</p>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div
|
|
class="flex justify-end sm:justify-between items-center group flex-nowrap whitespace-nowrap"
|
|
>
|
|
<!-- <div-->
|
|
<!-- class="hidden sm:flex items-center gap-1 transition-all group-hover:opacity-0 group-hover:pointer-events-none">-->
|
|
<!-- <UIcon class="text-primary text-lg" name="i-tabler-user-square-rounded"/>-->
|
|
<!-- <p class="text-xs">数字人 {{ video.digital_human_id }}</p>-->
|
|
<!-- </div>-->
|
|
<div
|
|
class="w-fit hidden sm:flex items-center gap-1 transition-all group-hover:opacity-0 group-hover:pointer-events-none"
|
|
>
|
|
<p class="text-2xs text-neutral-400 dark:text-neutral-500">
|
|
{{ video.digital_human_id }}
|
|
</p>
|
|
</div>
|
|
<div class="space-x-2">
|
|
<UButton
|
|
class="transition-all sm:opacity-0 sm:translate-x-4 sm:pointer-events-none group-hover:opacity-100 group-hover:translate-x-0 group-hover:pointer-events-auto"
|
|
color="red"
|
|
icon="i-tabler-trash"
|
|
size="xs"
|
|
variant="soft"
|
|
@click="emit('delete', video)"
|
|
/>
|
|
<UButtonGroup size="xs">
|
|
<UButton
|
|
:label="
|
|
downloadingState.subtitle > 0 && downloadingState.subtitle < 100
|
|
? `${downloadingState.subtitle.toFixed(0)}%`
|
|
: '字幕'
|
|
"
|
|
:loading="
|
|
downloadingState.subtitle > 0 && downloadingState.subtitle < 100
|
|
"
|
|
:disabled="!video.subtitle"
|
|
color="primary"
|
|
leading-icon="i-tabler-file-download"
|
|
variant="soft"
|
|
@click="
|
|
startDownload(
|
|
video.subtitle!,
|
|
(video.title || video.task_id) + '.ass'
|
|
)
|
|
"
|
|
/>
|
|
<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>
|
|
</div>
|
|
<!-- Full video content -->
|
|
<UModal v-model="isFullContentOpen">
|
|
<UCard
|
|
:ui="{
|
|
ring: '',
|
|
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
|
|
}"
|
|
>
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<h3
|
|
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
|
|
>
|
|
{{ video.title || '无标题' }}
|
|
<span class="block text-xs text-primary">驱动内容</span>
|
|
</h3>
|
|
<UButton
|
|
color="gray"
|
|
icon="i-tabler-x"
|
|
variant="ghost"
|
|
@click="isFullContentOpen = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div>
|
|
<article class="prose">
|
|
<p class="text-justify">{{ video.content }}</p>
|
|
</article>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<UButton
|
|
color="primary"
|
|
@click="isFullContentOpen = false"
|
|
>
|
|
关闭
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</UModal>
|
|
<UModal v-model="isPreviewModalOpen">
|
|
<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="isPreviewModalOpen = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<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>
|
|
|
|
<style scoped></style>
|