feat(subtitle_rendering): WIP: 添加视频字幕样式和导出功能

This commit is contained in:
2025-01-06 02:18:06 +08:00
parent be1d072b13
commit e4917d0053
2 changed files with 83 additions and 45 deletions

View File

@@ -10,8 +10,6 @@ interface Subtitle {
active?: boolean active?: boolean
} }
type SubtitleStyleEdit = Pick<SubtitleEmbeddingOptions, 'color' | 'fontSize' | 'bottomOffset' | 'strokeStyle' | 'textShadow'>
const props = defineProps({ const props = defineProps({
course: { course: {
type: Object as PropType<resp.gen.CourseGenItem>, type: Object as PropType<resp.gen.CourseGenItem>,
@@ -35,28 +33,16 @@ const videoElement = ref<HTMLVideoElement | null>(null)
const subtitleStyleSchema = object({ const subtitleStyleSchema = object({
color: string().required(), color: string().required(),
fontSize: number().required(), fontSize: number().required(),
effect: string().required(),
bottomOffset: number().required(), bottomOffset: number().required(),
strokeStyle: string().required(),
textShadow: object({
offsetX: number().required(),
offsetY: number().required(),
blur: number().required(),
color: string().required(),
}).required(),
}) })
type subtitleStyleSchema = InferType<typeof subtitleStyleSchema> type subtitleStyleSchema = InferType<typeof subtitleStyleSchema>
const subtitleStyleState = reactive<SubtitleStyleEdit>({ const subtitleStyleState = reactive<subtitleStyleSchema>({
color: '#000', color: '#fff',
fontSize: 20, effect: 'shadow',
bottomOffset: 0, fontSize: 24,
strokeStyle: 'none', bottomOffset: 12,
textShadow: {
offsetX: 0,
offsetY: 0,
blur: 0,
color: '#000',
},
}) })
const loadSrt = async () => { const loadSrt = async () => {
@@ -188,6 +174,32 @@ const saveNewSubtitle = () => {
}) })
} }
const exportVideo = () => {
isSaving.value = true
useVideoSubtitleEmbedding(props.course.video_url, props.course.subtitle_url, {
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize,
textShadow: subtitleStyleState.effect === 'shadow' ? {
offsetX: 2,
offsetY: 2,
blur: 4,
color: "rgba(0, 0, 0, 0.25)",
} : {
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(() => {
isSaving.value = false
})
}
onMounted(() => { onMounted(() => {
if (rawSrt.value) { if (rawSrt.value) {
parseSrt(rawSrt.value) parseSrt(rawSrt.value)
@@ -250,11 +262,17 @@ defineExpose({
</svg> </svg>
</div> </div>
<div v-else class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow"> <div v-else class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow">
<div class="relative"> <div class="relative overflow-hidden min-h-12">
<div class="absolute w-fit mx-auto inset-x-0 bottom-3"> <div class="absolute w-fit mx-auto inset-x-0 font-sans font-bold subtitle" :class="{
<span class="text-white font-bold text-shadow-lg"> '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 }} {{ subtitles.find(sub => sub.active)?.text }}
</span>
</div> </div>
<video controls ref="videoElement" class="rounded" style="-webkit-user-drag: none;" :src="course.video_url" <video controls ref="videoElement" class="rounded" style="-webkit-user-drag: none;" :src="course.video_url"
@timeupdate="syncSubtitles" /> @timeupdate="syncSubtitles" />
@@ -262,47 +280,51 @@ defineExpose({
<UAccordion :items="[{ label: '字幕选项' }]" color="gray" size="lg"> <UAccordion :items="[{ label: '字幕选项' }]" color="gray" size="lg">
<template #item> <template #item>
<div class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"> <div class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6">
<div class="w-full flex justify-center items-center"> <div class="w-full flex flex-col justify-center">
<div class="border-2 dark:border-neutral-700 rounded-md w-full aspect-video relative overflow-hidden"> <div class="rounded-md w-full aspect-video relative overflow-hidden">
<img class="object-cover w-full h-full rounded-md" <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" /> 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" :style="{ <span class="absolute font-sans font-bold bottom-0 left-1/2 transform -translate-x-1/2 subtitle"
color: '#fff', :class="{
textShadow: '0 0 5px #000', 'stroke': subtitleStyleState.effect === 'stroke',
fontSize: '20px', }" :style="{
lineHeight: '1', lineHeight: '1',
bottom: '10px', 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> </span>
</div> </div>
<span class="text-sm italic opacity-50">字幕预览仅供参考以实际渲染效果为准</span>
</div> </div>
<UForm :schema="subtitleStyleSchema" :state="subtitleStyleState" class="flex flex-col gap-4"> <UForm :schema="subtitleStyleSchema" :state="subtitleStyleState" class="flex flex-col gap-4">
<div class="flex gap-4"> <div class="flex gap-4">
<UFormGroup label="字幕颜色" name="fontColor" class="w-full"> <UFormGroup label="字幕颜色" name="fontColor" class="w-full" size="xs">
<USelectMenu :options="[{ <USelectMenu :options="[{
label: '黑色', label: '黑色',
value: '#000', value: '#000',
}, { }, {
label: '白色', label: '白色',
value: '#fff', value: '#fff',
}]" option-attribute="label" value-attribute="value" /> }]" option-attribute="label" value-attribute="value" v-model="subtitleStyleState.color" />
</UFormGroup> </UFormGroup>
<UFormGroup label="字幕效果" name="effect" class="w-full"> <UFormGroup label="字幕效果" name="effect" class="w-full" size="xs">
<USelectMenu :options="[{ <USelectMenu :options="[{
label: '阴影', label: '阴影',
value: 'shadow', value: 'shadow',
}, { }, {
label: '描边', label: '描边',
value: 'stroke', value: 'stroke',
}]" option-attribute="label" value-attribute="value" /> }]" option-attribute="label" value-attribute="value" v-model="subtitleStyleState.effect" />
</UFormGroup> </UFormGroup>
</div> </div>
<UFormGroup :label="`字幕大小`" name="fontSize"> <UFormGroup :label="`字幕大小 ${subtitleStyleState.fontSize}px`" name="fontSize" size="xs">
<URange :max="40" :min="20" :step="1" class="pt-4" size="sm" /> <URange :max="64" :min="20" :step="2" size="sm" v-model="subtitleStyleState.fontSize" />
</UFormGroup> </UFormGroup>
<UFormGroup label="字幕偏移量" name="offset"> <UFormGroup :label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`" name="offset" size="xs">
<URange :max="30" :min="0" :step="1" class="pt-4" size="sm" /> <URange :max="30" :min="0" :step="1" size="sm" v-model="subtitleStyleState.bottomOffset" />
</UFormGroup> </UFormGroup>
</UForm> </UForm>
</div> </div>
@@ -334,6 +356,9 @@ defineExpose({
<template #footer> <template #footer>
<div class="flex justify-end items-center gap-2"> <div class="flex justify-end items-center gap-2">
<span v-if="modified" class="text-sm text-yellow-500 font-medium">已更改但未保存</span> <span v-if="modified" class="text-sm text-yellow-500 font-medium">已更改但未保存</span>
<UButton :loading="isSaving" variant="soft" icon="i-tabler-file-export" @click="exportVideo">
导出视频
</UButton>
<UButton :disabled="!modified" :loading="isSaving" icon="i-tabler-device-floppy" @click="saveNewSubtitle"> <UButton :disabled="!modified" :loading="isSaving" icon="i-tabler-device-floppy" @click="saveNewSubtitle">
保存{{ isSaving ? '' : '' }} 保存{{ isSaving ? '' : '' }}
</UButton> </UButton>
@@ -353,6 +378,17 @@ defineExpose({
content: ""; content: "";
inset: 80% 0 0; inset: 80% 0 0;
position: absolute; position: absolute;
@apply bg-gradient-to-b from-transparent to-white dark:to-neutral-950; @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> </style>

View File

@@ -23,8 +23,8 @@ export interface SubtitleEmbeddingOptions {
blur: number; blur: number;
color: string; color: string;
}; };
videoWidth: number; videoWidth?: number;
videoHeight: number; videoHeight?: number;
} }
export const useVideoSubtitleEmbedding = async ( export const useVideoSubtitleEmbedding = async (
@@ -50,12 +50,14 @@ export const useVideoSubtitleEmbedding = async (
videoHeight: 1080, videoHeight: 1080,
fontSize: 36, fontSize: 36,
fontFamily: "Noto Sans SC", fontFamily: "Noto Sans SC",
strokeStyle: "none",
textShadow: { textShadow: {
offsetX: 2, offsetX: 2,
offsetY: 2, offsetY: 2,
blur: 4, blur: 4,
color: "rgba(0, 0, 0, 0.25)", color: "rgba(0, 0, 0, 0.25)",
}, },
...options,
}) })
); );
srtSprite.time = { duration: 10e6, offset: 0 }; srtSprite.time = { duration: 10e6, offset: 0 };