✨ feat(subtitle_rendering): WIP: 添加视频字幕样式和导出功能
This commit is contained in:
@@ -10,8 +10,6 @@ interface Subtitle {
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type SubtitleStyleEdit = Pick<SubtitleEmbeddingOptions, 'color' | 'fontSize' | 'bottomOffset' | 'strokeStyle' | 'textShadow'>
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object as PropType<resp.gen.CourseGenItem>,
|
||||
@@ -35,28 +33,16 @@ const videoElement = ref<HTMLVideoElement | null>(null)
|
||||
const subtitleStyleSchema = object({
|
||||
color: string().required(),
|
||||
fontSize: number().required(),
|
||||
effect: string().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>
|
||||
|
||||
const subtitleStyleState = reactive<SubtitleStyleEdit>({
|
||||
color: '#000',
|
||||
fontSize: 20,
|
||||
bottomOffset: 0,
|
||||
strokeStyle: 'none',
|
||||
textShadow: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
blur: 0,
|
||||
color: '#000',
|
||||
},
|
||||
const subtitleStyleState = reactive<subtitleStyleSchema>({
|
||||
color: '#fff',
|
||||
effect: 'shadow',
|
||||
fontSize: 24,
|
||||
bottomOffset: 12,
|
||||
})
|
||||
|
||||
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(() => {
|
||||
if (rawSrt.value) {
|
||||
parseSrt(rawSrt.value)
|
||||
@@ -250,11 +262,17 @@ defineExpose({
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow">
|
||||
<div class="relative">
|
||||
<div class="absolute w-fit mx-auto inset-x-0 bottom-3">
|
||||
<span class="text-white font-bold text-shadow-lg">
|
||||
{{ subtitles.find(sub => sub.active)?.text }}
|
||||
</span>
|
||||
<div class="relative overflow-hidden min-h-12">
|
||||
<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" />
|
||||
@@ -262,47 +280,51 @@ defineExpose({
|
||||
<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 justify-center items-center">
|
||||
<div class="border-2 dark:border-neutral-700 rounded-md w-full aspect-video relative overflow-hidden">
|
||||
<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" :style="{
|
||||
color: '#fff',
|
||||
textShadow: '0 0 5px #000',
|
||||
fontSize: '20px',
|
||||
lineHeight: '1',
|
||||
bottom: '10px',
|
||||
}">
|
||||
<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">
|
||||
<UFormGroup label="字幕颜色" name="fontColor" class="w-full" size="xs">
|
||||
<USelectMenu :options="[{
|
||||
label: '黑色',
|
||||
value: '#000',
|
||||
}, {
|
||||
label: '白色',
|
||||
value: '#fff',
|
||||
}]" option-attribute="label" value-attribute="value" />
|
||||
}]" option-attribute="label" value-attribute="value" v-model="subtitleStyleState.color" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="字幕效果" name="effect" class="w-full">
|
||||
<UFormGroup label="字幕效果" name="effect" class="w-full" size="xs">
|
||||
<USelectMenu :options="[{
|
||||
label: '阴影',
|
||||
value: 'shadow',
|
||||
}, {
|
||||
label: '描边',
|
||||
value: 'stroke',
|
||||
}]" option-attribute="label" value-attribute="value" />
|
||||
}]" option-attribute="label" value-attribute="value" v-model="subtitleStyleState.effect" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
<UFormGroup :label="`字幕大小`" name="fontSize">
|
||||
<URange :max="40" :min="20" :step="1" class="pt-4" size="sm" />
|
||||
<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="字幕偏移量" name="offset">
|
||||
<URange :max="30" :min="0" :step="1" class="pt-4" size="sm" />
|
||||
<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>
|
||||
@@ -334,6 +356,9 @@ defineExpose({
|
||||
<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="isSaving" variant="soft" icon="i-tabler-file-export" @click="exportVideo">
|
||||
导出视频
|
||||
</UButton>
|
||||
<UButton :disabled="!modified" :loading="isSaving" icon="i-tabler-device-floppy" @click="saveNewSubtitle">
|
||||
保存{{ isSaving ? '中' : '' }}
|
||||
</UButton>
|
||||
@@ -353,6 +378,17 @@ defineExpose({
|
||||
content: "";
|
||||
inset: 80% 0 0;
|
||||
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>
|
||||
@@ -23,8 +23,8 @@ export interface SubtitleEmbeddingOptions {
|
||||
blur: number;
|
||||
color: string;
|
||||
};
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
}
|
||||
|
||||
export const useVideoSubtitleEmbedding = async (
|
||||
@@ -50,12 +50,14 @@ export const useVideoSubtitleEmbedding = async (
|
||||
videoHeight: 1080,
|
||||
fontSize: 36,
|
||||
fontFamily: "Noto Sans SC",
|
||||
strokeStyle: "none",
|
||||
textShadow: {
|
||||
offsetX: 2,
|
||||
offsetY: 2,
|
||||
blur: 4,
|
||||
color: "rgba(0, 0, 0, 0.25)",
|
||||
},
|
||||
...options,
|
||||
})
|
||||
);
|
||||
srtSprite.time = { duration: 10e6, offset: 0 };
|
||||
|
||||
Reference in New Issue
Block a user