✨ feat(subtitle_rendering): WIP: 添加视频字幕样式和导出功能
This commit is contained in:
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user