Files
xsh-assistant-next/app/components/aigc/generation/SRTEditor.vue

627 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-xs',
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: 'error',
})
} 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] || '0'),
minutes: parseInt(timeParts[1] || '0'),
seconds: parseInt(timeParts[2] || '0'),
milliseconds: parseInt(parts[1] || '0'),
}
}
const formatTimeToDayjs = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0]?.split(':') || []
return dayjs()
.hour(parseInt(timeParts[0] || '0'))
.minute(parseInt(timeParts[1] || '0'))
.second(parseInt(timeParts[2] || '0'))
.millisecond(parseInt(parts[1] || '0'))
}
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: 'success',
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: 'error',
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-xs'
? {
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:open="isDrawerActive"
:dismissible="!modified"
:ui="{
wrapper: 'max-w-lg',
body: 'flex flex-col flex-1 overflow-hidden',
}"
>
<template #content>
<UCard
class="flex flex-1 flex-col overflow-hidden"
:ui="{
body: 'overflow-auto flex-1',
}"
>
<template #header>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="tabler:x"
class="absolute end-5 top-5 z-10 flex sm:hidden"
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="text-primary flex items-center justify-center"
>
<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="overshadow flex h-full flex-col gap-2 overflow-hidden overscroll-y-none"
>
<div class="relative aspect-video w-full flex-1">
<div
class="subtitle absolute inset-x-0 mx-auto w-fit font-sans font-bold"
: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-xs'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
{{ subtitles.find((sub) => sub.active)?.text }}
</div>
<video
controls
ref="videoElement"
class="rounded-xs"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
/>
</div>
<UAccordion
:items="[{ label: '字幕选项' }]"
color="gray"
size="lg"
>
<template #content>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<div class="flex w-full flex-col justify-center">
<div
class="relative aspect-video w-full overflow-hidden rounded-md"
>
<img
class="h-full w-full rounded-md object-cover"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="subtitle absolute bottom-0 left-1/2 -translate-x-1/2 transform font-sans font-bold"
: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-xs'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
字幕样式预览
</span>
</div>
<span class="text-2xs font-medium italic opacity-50 mt-1">
字幕预览仅供参考以实际渲染效果为准
</span>
</div>
<UForm
:schema="subtitleStyleSchema"
:state="subtitleStyleState"
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormField
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
{
label: '黑色',
value: '#000',
},
{
label: '白色',
value: '#fff',
},
]"
value-key="value"
v-model="subtitleStyleState.color"
/>
</UFormField>
<UFormField
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
{
label: '阴影',
value: 'shadow-xs',
},
{
label: '描边',
value: 'stroke',
},
]"
value-key="value"
v-model="subtitleStyleState.effect"
/>
</UFormField>
</div>
<UFormField
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<USlider
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormField>
<UFormField
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<USlider
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormField>
</UForm>
</div>
</template>
</UAccordion>
<ul
class="relative flex-1 space-y-0.5 overflow-y-auto scroll-smooth px-0.5 pb-[100%]"
>
<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 items-center justify-end gap-2">
<span
v-if="modified"
class="text-sm font-medium text-yellow-500"
>
已更改但未保存
</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>
</template>
</USlideover>
</div>
</template>
<style scoped>
@reference '@/assets/css/main.css';
.overshadow {
@apply relative;
}
.overshadow:after {
content: '';
inset: 80% 0 0;
position: absolute;
@apply bg-linear-to-b pointer-events-none from-transparent to-white dark:to-neutral-950;
}
.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>