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

624 lines
18 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',
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>