refactor(deps): migrate to nuxt v4

This commit is contained in:
2026-02-10 00:31:04 +08:00
parent f1b9cea060
commit 880b85f75d
88 changed files with 80 additions and 60 deletions

View File

@@ -0,0 +1,528 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import dayjs from 'dayjs'
import { useDownload } from '~/composables/useDownload'
import gsap from 'gsap'
const toast = useToast()
const loginState = useLoginState()
const { metaSymbol } = useShortcuts()
const srtEditor = ref()
const props = defineProps({
course: {
type: Object as PropType<resp.gen.CourseGenItem>,
required: true,
},
})
const emit = defineEmits(['delete'])
defineShortcuts({
p: {
handler: () => {
if (isDropdownOpen.value && isDownloadable.value) {
isPreviewModalOpen.value = true
}
},
},
meta_d: {
handler: () => {
if (isDropdownOpen.value && isDownloadable.value) {
srtEditor.value.open()
isDropdownOpen.value = false
}
},
},
meta_s: {
handler: async () => {
if (isDropdownOpen.value && isDownloadable.value) {
await startDownload(
await fetchCourseSubtitleUrl(props.course),
`眩生花微课_${props.course.title}_${props.course.task_id}.srt`
)
}
},
},
delete: {
handler: () => {
if (isDropdownOpen.value) {
emit('delete', props.course.task_id)
}
},
},
})
const isDropdownOpen = ref(false)
const isPreviewModalOpen = ref(false)
const stateDisplay = computed(() => {
if (props.course.progress === -1)
return {
color: 'red',
text: '失败',
}
if (props.course.progress === 100)
return {
color: 'green',
text: '完成',
}
return {
color: 'blue',
text: !!props.course.progress
? `${tweenedGenerateProgress.value.toFixed(0)}%`
: '队列中',
}
})
const isFailed = computed(() => props.course.progress === -1)
const isDownloadable = computed(
() => !isFailed.value && props.course.progress === 100
)
const generateProgress = computed(() => {
return props.course.progress || 0
})
const tweenedGenerateProgress = ref(0)
watch(
generateProgress,
(newValue) => {
gsap.to(tweenedGenerateProgress, {
duration: 5,
value: newValue,
})
},
{
immediate: true,
}
)
const downloadProgress = ref(0)
const startDownload = async (url: string, filename: string) => {
downloadProgress.value = 0
const { download, progressEmitter } = useDownload(url, filename)
progressEmitter.on('done', () => {
downloadProgress.value = 100
toast.add({
title: '下载完成',
description: '资源下载已完成',
color: 'green',
icon: 'i-tabler-check',
})
})
progressEmitter.on('progress', (progress) => {
downloadProgress.value = progress
})
progressEmitter.on('error', (err) => {
downloadProgress.value = 0
toast.add({
title: '下载失败',
description: err.message || '下载失败,未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
download()
}
const copyTaskId = (extraMessage?: string) => {
navigator.clipboard.writeText(
props.course.task_id + (extraMessage ? ` ${extraMessage}` : '')
)
toast.add({
title: '复制成功',
description: '已复制任务 ID',
color: 'green',
icon: 'i-tabler-check',
})
}
const isCombinationModalOpen = ref(false)
const combinationState = ref<0 | 1 | undefined>(0)
const onCombination = async () => {
isCombinationModalOpen.value = true
combinationState.value = undefined
const srtResponse = await (
await fetch(await fetchCourseSubtitleUrl(props.course))
).blob()
if (!srtResponse) {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
const srtBlob = new Blob([srtResponse], { type: 'text/plain' })
const srtUrl = URL.createObjectURL(srtBlob)
useVideoSubtitleEmbedding(props.course.video_url, srtUrl)
.then((src) => {
startDownload(
src,
`眩生花微课_${props.course.title}_${props.course.task_id}_combinated.mp4`
)
combinationState.value = 1
})
.catch((err) => {
toast.add({
title: '嵌入字幕失败',
description: err.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
combinationState.value = 0
})
.finally(() => {
setTimeout(() => {
combinationState.value = 0
isCombinationModalOpen.value = false
}, 3000)
})
}
const onRetryClick = (course: resp.gen.CourseGenItem) => {
useFetchWrapped<
req.gen.CourseGenCreate & AuthedRequest,
BaseResponse<resp.gen.CourseGenCreate>
>('App.Digital_Convert.Create', {
token: loginState.token!,
user_id: loginState.user.id,
task_title: course.title,
gen_server: 'main',
speed: 2 - course.speed,
ppt_url: course.ppt_url,
digital_human_id: course.digital_human_id,
custom_video: '[]',
opening_url: course.opening_url || '',
ending_url: course.opening_url || '',
}).then(
(res) => {
if (res.data.record_status === 1) {
toast.add({
title: '重试已提交',
description: '已加入生成队列',
color: 'green',
icon: 'i-tabler-check',
})
// delete
emit('delete', course.task_id)
} else {
toast.add({
title: '提交重试失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
},
(err) => {
toast.add({
title: '提交重试失败',
description: err.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
)
}
</script>
<template>
<div
class="w-full rounded-xl border border-neutral-200 dark:border-neutral-700 hover:shadow transition overflow-hidden"
>
<div class="relative w-full aspect-video group">
<NuxtImg
class="w-full aspect-video object-cover pointer-events-none absolute inset-0"
v-if="!!course.video_cover"
:src="course.video_cover"
alt="image"
loading="lazy"
/>
<div
v-else
class="absolute inset-0 bg-gradient-to-br from-purple-400 to-primary-400 flex justify-center items-center pattern"
>
<Icon
v-if="isFailed"
class="text-white text-[64px] opacity-50"
name="i-tabler-alert-triangle"
/>
<Icon
v-else
class="text-white text-[64px] animate-pulse"
name="i-tabler-photo-video"
/>
</div>
<div class="absolute inset-2 flex justify-end items-start">
<UTooltip
:prevent="course.progress > -1"
:text="course.message || ''"
>
<UBadge
:color="stateDisplay.color"
:variant="isFailed ? 'solid' : 'subtle'"
class="shadow"
size="sm"
>
<Icon
v-if="isFailed"
class="text-base mr-0.5"
name="i-tabler-alert-triangle"
/>
{{ stateDisplay.text }}
</UBadge>
</UTooltip>
</div>
<div
v-if="isDownloadable"
class="absolute inset-0 bg-black/10 backdrop-blur-md flex justify-center items-center 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="px-2 pt-1 pb-2 flex justify-between">
<div class="flex-1 overflow-hidden pt-1">
<h1
:title="course.title"
class="inline-flex items-center text-sm font-medium overflow-hidden text-ellipsis text-nowrap"
>
<Icon
class="text-base"
name="i-tabler-book-2"
/>
<span class="pl-0.5">{{ course.title }}</span>
</h1>
<p class="text-xs pt-0.5 text-neutral-400 space-x-2">
<span>
{{ dayjs(course.create_time * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</span>
<button
v-if="course.task_id"
class="hover:text-primary font-medium"
tabindex="-1"
:title="course.task_id"
@click="
copyTaskId(
isFailed ? `\n\n${course.message}\n${course.ppt_url}` : ''
)
"
>
{{ isFailed ? '复制错误报告' : '复制 ID' }}
</button>
</p>
</div>
<div class="flex items-center gap-1">
<UButtonGroup>
<!-- <UButton
v-if="isFailed"
color="white"
:disabled="!isFailed"
label="重试"
leading-icon="i-tabler-refresh"
size="xs"
@click="onRetryClick(course)"
/>
<UButton
v-else
color="white"
:disabled="!isDownloadable"
label="下载"
leading-icon="i-tabler-download"
size="xs"
@click="onCombination"
/> -->
<UButton
color="white"
:disabled="!isFailed && !isDownloadable"
:label="isFailed ? '重试' : isDownloadable ? '下载' : '生成中'"
:leading-icon="isFailed ? 'i-tabler-refresh' : 'i-tabler-download'"
size="xs"
@click="
() => {
if (isFailed) {
onRetryClick(course)
} else {
onCombination()
}
}
"
/>
<!-- retry -->
<UDropdown
v-model:open="isDropdownOpen"
:items="[
[
{
label: '下载原视频',
icon: 'i-tabler-file-plus',
disabled: !isDownloadable,
click: () =>
startDownload(
course.video_url,
`眩生花微课_${props.course.title}_${props.course.task_id}.mp4`
),
},
{
label: '预览课程',
icon: 'i-tabler-play',
shortcuts: ['P'],
disabled: !isDownloadable,
click: () => (isPreviewModalOpen = true),
},
{
label: '编辑字幕',
icon: 'i-solar-subtitles-linear',
shortcuts: [metaSymbol, 'D'],
disabled: !isDownloadable,
click: () => {
srtEditor.open()
isDropdownOpen = false
},
},
{
label: '下载字幕',
icon: 'i-tabler-file-download',
shortcuts: [metaSymbol, 'S'],
disabled: !isDownloadable,
click: async () => {
await startDownload(
await fetchCourseSubtitleUrl(course),
`眩生花微课_${props.course.title}_${props.course.task_id}.srt`
)
},
},
],
[
{
label: '删除记录',
icon: 'i-tabler-trash-x',
shortcuts: ['Delete'],
click: () => emit('delete', course.task_id),
},
],
]"
:popper="{ placement: 'bottom-end' }"
>
<UButton
:disabled="course.progress > 1 && course.progress < 100"
color="white"
size="xs"
trailing-icon="i-tabler-dots"
/>
</UDropdown>
</UButtonGroup>
</div>
</div>
<UModal
v-model="isPreviewModalOpen"
:ui="{ width: 'w-full sm:max-w-4xl' }"
>
<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"
>
{{ course.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="course.video_url"
/>
</UCard>
</UModal>
<UModal v-model="isCombinationModalOpen">
<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"
>
{{ course.title }}
</p>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isCombinationModalOpen = false"
/>
</div>
</template>
<UProgress
animation="carousel"
:value="combinationState"
:max="['嵌入字幕中', '合并完成,开始下载']"
>
<template #step-0="{ step }">
<span class="inline-flex items-center gap-1 text-emerald-500">
<UIcon name="tabler:text-caption" />
{{ step }}
</span>
</template>
<template #step-1="{ step }">
<span class="inline-flex items-center gap-1 text-primary-500">
<UIcon name="tabler:paperclip" />
{{ step }}
</span>
</template>
</UProgress>
</UCard>
</UModal>
<AigcGenerationSRTEditor
ref="srtEditor"
:course="course"
/>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,627 @@
<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>

View File

@@ -0,0 +1,623 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import { encode } from '@monosky/base64'
import { object, string, number, type InferType } from 'yup'
interface Subtitle {
start: string
end: string
text: string
active?: boolean
}
const props = defineProps({
course: {
type: Object as PropType<resp.gen.CourseGenItem>,
required: true,
},
})
const dayjs = useDayjs()
const toast = useToast()
const loginState = useLoginState()
const isDrawerActive = ref(false)
const isLoading = ref(true)
const isSaving = ref(false)
const rawSrt = ref<string | null>(null)
const subtitles = ref<Subtitle[]>([])
const modified = ref(false)
const isExporting = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
const subtitleStyleSchema = object({
color: string().required(),
fontSize: number().required(),
effect: string().required(),
bottomOffset: number().required(),
})
type subtitleStyleSchema = InferType<typeof subtitleStyleSchema>
const subtitleStyleState = reactive<subtitleStyleSchema>({
color: '#fff',
effect: 'shadow',
fontSize: 24,
bottomOffset: 12,
})
const loadSrt = async () => {
isLoading.value = true
try {
// const response = await fetch(props.course.subtitle_url)
const response = await fetch(await fetchCourseSubtitleUrl(props.course))
const text = await response.text()
rawSrt.value = text
parseSrt(text)
} catch (err) {
toast.add({
title: '加载字幕失败',
description: `${err}` || '未知错误',
color: 'red',
})
} finally {
isLoading.value = false
}
}
const parseSrt = (srt: string) => {
const lines = srt.split(/\r?\n/)
const regex = /(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/
let subtitle: Subtitle | null = null
lines.forEach((line) => {
if (/^\d+$/.test(line.trim())) return
const match = line.match(regex)
if (match) {
if (subtitle) {
subtitles.value.push(subtitle)
}
subtitle = {
start: match[1],
end: match[2],
text: '',
}
} else if (subtitle) {
subtitle.text += line.trim() ? line : ''
}
})
if (subtitle) {
subtitles.value.push(subtitle)
}
}
const generateSrt = () => {
return subtitles.value
.map((subtitle, index) => {
return `${index + 1}\n${subtitle.start} --> ${subtitle.end}\n${
subtitle.text
}\n`
})
.join('\n')
}
const formatTime = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0].split(':')
return {
hours: parseInt(timeParts[0]),
minutes: parseInt(timeParts[1]),
seconds: parseInt(timeParts[2]),
milliseconds: parseInt(parts[1]),
}
}
const formatTimeToDayjs = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0].split(':')
return dayjs()
.hour(parseInt(timeParts[0]))
.minute(parseInt(timeParts[1]))
.second(parseInt(timeParts[2]))
.millisecond(parseInt(parts[1]))
}
const syncSubtitles = () => {
if (!videoElement.value) return
const currentTime = videoElement.value.currentTime * 1000 // convert to milliseconds
subtitles.value.forEach((subtitle) => {
const start = formatTime(subtitle.start)
const end = formatTime(subtitle.end)
const startTime =
(start.hours * 3600 + start.minutes * 60 + start.seconds) * 1000 +
start.milliseconds
const endTime =
(end.hours * 3600 + end.minutes * 60 + end.seconds) * 1000 +
end.milliseconds
subtitle.active = currentTime >= startTime && currentTime <= endTime
// scroll active subtitle into view
if (subtitle.active) {
const element = document.getElementById(
`subtitle-${subtitles.value.indexOf(subtitle)}`
)!
const parent = element?.parentElement
// scroll element to the center of parent
parent?.scrollTo({
top: element.offsetTop,
})
}
})
}
const onSubtitleInputClick = (subtitle: Subtitle) => {
if (!videoElement.value) return
if (!subtitle.active) {
videoElement.value.currentTime =
formatTime(subtitle.start).hours * 3600 +
formatTime(subtitle.start).minutes * 60 +
formatTime(subtitle.start).seconds +
1
}
videoElement.value.pause()
}
const saveNewSubtitle = () => {
isSaving.value = true
const encodedSubtitle = encode(generateSrt())
useFetchWrapped<
req.gen.CourseSubtitleCreate & AuthedRequest,
BaseResponse<resp.gen.CourseSubtitleCreate>
>('App.Digital_VideoSubtitle.CreateFile', {
token: loginState.token!,
user_id: loginState.user.id,
sub_type: 1,
sub_content: encodedSubtitle,
task_id: props.course?.task_id,
})
.then((_) => {
modified.value = false
toast.add({
color: 'green',
title: '字幕已保存',
description: '修改后的字幕文件已保存',
})
})
.finally(() => {
isSaving.value = false
})
}
const exportVideo = async () => {
isExporting.value = true
const srtResponse = await (
await fetch(await fetchCourseSubtitleUrl(props.course))
).blob()
if (!srtResponse) {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
const srtBlob = new Blob([srtResponse], { type: 'text/plain' })
const srtUrl = URL.createObjectURL(srtBlob)
useVideoSubtitleEmbedding(props.course.video_url, srtUrl, {
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize,
textShadow:
subtitleStyleState.effect === 'shadow'
? {
offsetX: 2,
offsetY: 2,
blur: 6,
color: 'rgba(0, 0, 0, 0.35)',
}
: {
offsetX: 0,
offsetY: 0,
blur: 0,
color: 'transparent',
},
strokeStyle: subtitleStyleState.effect === 'stroke' ? '#000 2px' : 'none',
bottomOffset: subtitleStyleState.bottomOffset,
})
.then((blobUrl) => {
const { download } = useDownload(blobUrl, 'combined_video.mp4')
download()
})
.finally(() => {
isExporting.value = false
})
}
onMounted(() => {
if (rawSrt.value) {
parseSrt(rawSrt.value)
}
})
defineExpose({
open() {
isDrawerActive.value = true
if (!rawSrt.value) loadSrt()
},
close() {
isDrawerActive.value = false
},
})
</script>
<template>
<div>
<USlideover
v-model="isDrawerActive"
:prevent-close="modified"
:ui="{ width: 'max-w-lg' }"
>
<UCard
class="flex flex-col flex-1 overflow-hidden"
:ui="{
body: { base: 'overflow-auto flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<UButton
color="gray"
variant="ghost"
size="sm"
icon="tabler:x"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isDrawerActive = false"
/>
<div class="flex flex-col">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
字幕编辑器
</h3>
<h3
class="text-xs font-semibold text-blue-500"
v-if="course.title"
>
{{ course.title }}
</h3>
</div>
</template>
<div
v-if="isLoading"
class="flex justify-center items-center text-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur
in="SourceGraphic"
result="y"
stdDeviation="1"
/>
<feColorMatrix
in="y"
result="z"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"
/>
<feBlend
in="SourceGraphic"
in2="z"
/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle
cx="5"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="5;8;5"
/>
</circle>
<circle
cx="19"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="19;16;19"
/>
</circle>
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"
/>
</g>
</svg>
</div>
<div
v-else
class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow"
>
<div class="relative w-full aspect-video flex-1">
<div
class="absolute w-fit mx-auto inset-x-0 font-sans font-bold subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
{{ subtitles.find((sub) => sub.active)?.text }}
</div>
<video
controls
ref="videoElement"
class="rounded"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
/>
</div>
<UAccordion
:items="[{ label: '字幕选项' }]"
color="gray"
size="lg"
>
<template #item>
<div
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<div class="w-full flex flex-col justify-center">
<div
class="rounded-md w-full aspect-video relative overflow-hidden"
>
<img
class="object-cover w-full h-full rounded-md"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="absolute font-sans font-bold bottom-0 left-1/2 transform -translate-x-1/2 subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
字幕样式预览
</span>
</div>
<span class="text-sm italic opacity-50">
字幕预览仅供参考以实际渲染效果为准
</span>
</div>
<UForm
:schema="subtitleStyleSchema"
:state="subtitleStyleState"
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormGroup
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:options="[
{
label: '黑色',
value: '#000',
},
{
label: '白色',
value: '#fff',
},
]"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.color"
/>
</UFormGroup>
<UFormGroup
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:options="[
{
label: '阴影',
value: 'shadow',
},
{
label: '描边',
value: 'stroke',
},
]"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.effect"
/>
</UFormGroup>
</div>
<UFormGroup
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<URange
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormGroup>
<UFormGroup
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<URange
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormGroup>
</UForm>
</div>
</template>
</UAccordion>
<ul
class="flex-1 px-0.5 pb-[100%] overflow-y-auto space-y-0.5 scroll-smooth relative"
>
<li
v-for="(subtitle, index) in subtitles"
:key="index"
:id="'subtitle-' + index"
>
<div :class="{ 'text-primary': subtitle.active }">
<span class="text-xs font-medium opacity-60">
{{ formatTimeToDayjs(subtitle.start).format('HH:mm:ss') }}
-
{{ formatTimeToDayjs(subtitle.end).format('HH:mm:ss') }}
<span class="opacity-50">
[{{
formatTimeToDayjs(subtitle.end).diff(
formatTimeToDayjs(subtitle.start),
'second'
)
}}s]
</span>
</span>
<UInput
v-model="subtitle.text"
class="w-full"
placeholder="请输入字幕内容"
:name="'subtitle-' + index"
:autofocus="false"
:color="subtitle.active ? 'primary' : undefined"
@click="onSubtitleInputClick(subtitle)"
@input="
() => {
if (!modified) modified = true
}
"
>
<template #trailing>
<UIcon
v-show="subtitle.active"
name="tabler:keyframe-align-vertical-filled"
/>
</template>
</UInput>
</div>
</li>
</ul>
</div>
<template #footer>
<div class="flex justify-end items-center gap-2">
<span
v-if="modified"
class="text-sm text-yellow-500 font-medium"
>
已更改但未保存
</span>
<UButton
:loading="isExporting"
variant="soft"
icon="i-tabler-file-export"
@click="exportVideo"
>
导出视频
</UButton>
<UButton
:disabled="isExporting || !modified"
:loading="isSaving"
icon="i-tabler-device-floppy"
@click="saveNewSubtitle"
>
保存{{ isSaving ? '中' : '' }}
</UButton>
</div>
</template>
</UCard>
</USlideover>
</div>
</template>
<style scoped>
.overshadow {
@apply relative;
}
.overshadow:after {
content: '';
inset: 80% 0 0;
position: absolute;
@apply bg-gradient-to-b from-transparent to-white dark:to-neutral-950 pointer-events-none;
}
.subtitle.stroke {
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000;
}
.subtitle.shadow {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
}
</style>

View File

@@ -0,0 +1,196 @@
<script lang="ts" setup>
defineProps({
type: {
type: String as PropType<'system' | 'user'>,
required: true,
},
data: {
type: Object as PropType<TitlesTemplate>,
required: true,
},
})
const emit = defineEmits({
'user-titles-request': (_titles: TitlesTemplate) => true,
'user-titles-delete': (_titles: TitlesTemplate) => true,
'system-titles-delete': (_titles: TitlesTemplate) => true,
})
const loginState = useLoginState()
const isPreviewModalOpen = ref(false)
const previewVideoUrl = ref<string | null>(null)
const previewVideo = (url: string) => {
previewVideoUrl.value = url
setTimeout(() => {
isPreviewModalOpen.value = true
}, 100)
}
const closePreview = () => {
isPreviewModalOpen.value = false
setTimeout(() => {
previewVideoUrl.value = null
}, 100)
}
</script>
<template>
<div
class="relative w-full flex flex-col rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden shadow-none hover:shadow transition-shadow"
>
<div class="relative w-full aspect-[16/9] group">
<NuxtImg
placeholder
placeholder-class="w-full aspect-[16/9] object-cover bg-neutral-200 dark:bg-neutral-800"
class="object-cover relative"
:src="data.opening_url"
/>
<div
class="absolute inset-0 bg-black/10 backdrop-blur-md opacity-0 group-hover:opacity-100 duration-300 flex flex-col gap-2 justify-center items-center"
>
<UButton
icon="tabler:play"
color="primary"
variant="soft"
label="预览片头"
@click="previewVideo(data.opening_file)"
/>
<UButton
icon="tabler:play"
color="primary"
variant="soft"
label="预览片尾"
@click="previewVideo(data.ending_file)"
/>
</div>
</div>
<div class="relative p-2 flex justify-between items-center gap-2">
<div class="flex-1">
<h1
class="text-base font-medium line-clamp-1"
:title="data.title"
>
{{ data.title }}
</h1>
<p class="text-xs font-medium text-gray-400">
{{ data.description }}
</p>
</div>
<div>
<UButtonGroup
size="xs"
v-if="type === 'system'"
>
<UButton
label="使用模板"
color="white"
@click="emit('user-titles-request', data)"
/>
<!-- <UButton
icon="tabler:trash"
color="red"
@click="emit('system-titles-delete', data)"
v-if="loginState.user.auth_code === 2"
/> -->
<UPopover v-if="loginState.user.auth_code === 2">
<UButton
icon="tabler:trash"
color="red"
/>
<template #panel="{ close }">
<div class="flex flex-col p-2 gap-2">
<p class="text-xs text-gray-500 dark:text-gray-400">
素材删除后不可恢复确认删除
</p>
<UButton
class="w-fit"
icon="tabler:trash"
label="确认删除"
color="red"
size="xs"
@click="emit('system-titles-delete', data)"
/>
</div>
</template>
</UPopover>
</UButtonGroup>
<div v-if="type === 'user'">
<!-- <UButton
icon="tabler:trash"
label="删除素材"
variant="soft"
color="red"
@click="emit('user-titles-delete', data)"
/> -->
<UPopover>
<UButton
icon="tabler:trash"
label="删除素材"
variant="soft"
color="red"
size="xs"
/>
<template #panel="{ close }">
<div class="flex flex-col p-2 gap-2">
<p class="text-xs text-gray-500 dark:text-gray-400">
素材删除后不可恢复确认删除
</p>
<UButton
class="w-fit"
icon="tabler:trash"
label="确认删除"
color="red"
size="xs"
@click="emit('user-titles-delete', data)"
/>
</div>
</template>
</UPopover>
</div>
</div>
</div>
<UModal
v-model="isPreviewModalOpen"
:ui="{ width: 'w-full sm:max-w-4xl' }"
>
<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>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
<video
v-if="previewVideoUrl"
class="w-full rounded shadow"
controls
autoplay
:src="previewVideoUrl"
></video>
</UCard>
</UModal>
</div>
</template>
<style scoped></style>