This commit is contained in:
949036910@qq.com
2026-05-27 20:40:22 +08:00
parent b43118112a
commit 8fc42958c3
4 changed files with 418 additions and 42 deletions

View File

@@ -1,8 +1,24 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed, ref } from "vue";
import {
SUBTITLE_FONT_OPTIONS,
buildSubtitlePreviewBoxStyle,
} from "../../config/subtitleStylePresets.js";
const config = defineModel("config", { type: Object, required: true }); const config = defineModel("config", { type: Object, required: true });
const importInputRef = ref(null);
const newGroupName = ref("");
const editingGroupIndex = ref(-1);
const keywordInputs = ref({});
const renderModeOptions = [
{ label: "内联字幕", value: "inline-emphasis" },
{ label: "浮动特效", value: "floating" },
];
const builtinGroupNames = new Set(["行动词", "情感词", "描述词", "重点词/成语词"]);
const enableKeywordEffects = computed({ const enableKeywordEffects = computed({
get: () => config.value.enableKeywordEffects !== false, get: () => config.value.enableKeywordEffects !== false,
set: (v) => { set: (v) => {
@@ -17,7 +33,128 @@ const keywordRenderMode = computed({
}, },
}); });
const groups = computed(() => config.value.keywordGroupsStyles || []); const groups = computed({
get: () => config.value.keywordGroupsStyles || [],
set: (value) => {
config.value.keywordGroupsStyles = value;
},
});
function ensureGroups() {
if (!Array.isArray(config.value.keywordGroupsStyles)) {
config.value.keywordGroupsStyles = [];
}
return config.value.keywordGroupsStyles;
}
function ensureStyleOverride(group) {
if (!group.styleOverride) {
group.styleOverride = {
fontName: config.value.subtitleStyle?.fontName || "胡晓波男神体",
fontSize: Math.max(Number(config.value.subtitleStyle?.fontSize) || 24, 24),
fontColor: group.color || "#FFFF00",
outlineColor: "#000000",
outlineWidth: 2,
};
}
if (!group.color) group.color = group.styleOverride.fontColor || "#1890ff";
return group.styleOverride;
}
function createGroup() {
const name = newGroupName.value.trim();
if (!name) return;
const list = ensureGroups();
list.push({
groupName: name,
description: `模板分组${name}`,
keywords: [],
styleOverride: {
fontName: config.value.subtitleStyle?.fontName || "胡晓波男神体",
fontSize: 24,
fontColor: "#FFFF00",
outlineColor: "#000000",
outlineWidth: 2,
},
effectId: null,
effectDuration: null,
color: "#1890ff",
soundEffectId: null,
soundEffectConfig: null,
});
newGroupName.value = "";
editingGroupIndex.value = list.length - 1;
}
function removeGroup(index) {
const group = groups.value[index];
if (!group || builtinGroupNames.has(group.groupName)) return;
if (!window.confirm(`确定删除分组「${group.groupName || `分组 ${index + 1}`}」?`)) {
return;
}
groups.value.splice(index, 1);
if (editingGroupIndex.value === index) editingGroupIndex.value = -1;
}
function groupKeywords(group) {
return group.keywords || group.words || [];
}
function addKeyword(group, index) {
const word = String(keywordInputs.value[index] || "").trim();
if (!word) return;
if (!Array.isArray(group.keywords)) group.keywords = groupKeywords(group).slice();
if (!group.keywords.includes(word)) group.keywords.push(word);
keywordInputs.value[index] = "";
}
function removeKeyword(group, keyword) {
group.keywords = groupKeywords(group).filter((item) => item !== keyword);
}
function groupPreviewStyle(group) {
return buildSubtitlePreviewBoxStyle({
...(config.value.subtitleStyle || {}),
...(group.styleOverride || {}),
});
}
function exportGroups() {
const blob = new Blob(
[
JSON.stringify(
{
version: "1.0.0",
exportedAt: new Date().toISOString(),
keywordGroupsStyles: groups.value,
},
null,
2,
),
],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "keyword-groups-config.json";
a.click();
URL.revokeObjectURL(url);
}
async function importGroups(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const data = JSON.parse(await file.text());
const list = data.keywordGroupsStyles || data.groups || data;
if (!Array.isArray(list)) {
window.alert("无效的关键词分组配置");
return;
}
groups.value = list;
editingGroupIndex.value = -1;
}
</script> </script>
<template> <template>
@@ -49,10 +186,7 @@ const groups = computed(() => config.value.keywordGroupsStyles || []);
</div> </div>
<SelectButton <SelectButton
v-model="keywordRenderMode" v-model="keywordRenderMode"
:options="[ :options="renderModeOptions"
{ label: '内联字幕', value: 'inline-emphasis' },
{ label: '浮动特效', value: 'floating' },
]"
option-label="label" option-label="label"
option-value="value" option-value="value"
size="small" size="small"
@@ -80,23 +214,181 @@ const groups = computed(() => config.value.keywordGroupsStyles || []);
</div> </div>
<div class="rounded-lg border border-slate-600/40 bg-slate-900/40 p-3"> <div class="rounded-lg border border-slate-600/40 bg-slate-900/40 p-3">
<h6 class="mb-3 text-sm font-medium text-slate-200">关键词分组{{ groups.length }}</h6> <h6 class="mb-3 text-sm font-medium text-slate-200">关键词分组管理</h6>
<div class="max-h-48 space-y-2 overflow-y-auto">
<div class="mb-4 rounded bg-slate-800/60 p-3">
<div class="flex gap-2">
<InputText
v-model="newGroupName"
size="small"
class="flex-1"
placeholder="输入分组名称"
@keydown.enter="createGroup"
/>
<Button label="创建分组" size="small" @click="createGroup" />
</div>
</div>
<div class="space-y-3">
<div class="text-sm font-medium text-slate-200">已有分组 ({{ groups.length }})</div>
<div <div
v-for="(group, i) in groups" v-for="(group, i) in groups"
:key="i" :key="`${group.groupName || 'group'}-${i}`"
class="rounded border border-slate-600/30 bg-slate-800/50 p-2 text-sm" class="rounded-lg border border-slate-600/40 bg-slate-800/50 p-3"
:style="{ borderLeftWidth: '4px', borderLeftColor: group.color || '#3b82f6' }" :style="{ borderLeftWidth: '4px', borderLeftColor: group.color || '#1890ff' }"
> >
<div class="font-medium text-slate-200">{{ group.groupName || `分组 ${i + 1}` }}</div> <div class="mb-2 flex items-start justify-between gap-2">
<div v-if="group.styleOverride?.fontName" class="mt-1 text-xs text-slate-400"> <div class="min-w-0 flex-1">
强调字体{{ group.styleOverride.fontName }} · <div class="flex items-center gap-2 font-medium text-slate-100">
{{ group.styleOverride.fontColor || group.color }} <span>{{ group.groupName || `分组 ${i + 1}` }}</span>
</div>
<InputText
v-model="group.description"
size="small"
class="mt-2 w-full"
placeholder="分组描述"
/>
</div>
<div class="flex shrink-0 gap-2">
<Button
:label="editingGroupIndex === i ? '收起样式' : '编辑样式'"
size="small"
text
@click="
ensureStyleOverride(group);
editingGroupIndex = editingGroupIndex === i ? -1 : i;
"
/>
<Button
v-if="!builtinGroupNames.has(group.groupName)"
label="删除"
size="small"
text
severity="danger"
@click="removeGroup(i)"
/>
</div>
</div>
<div class="mb-2">
<div class="mb-1 text-xs text-slate-400">关键词 ({{ groupKeywords(group).length }})</div>
<div v-if="groupKeywords(group).length" class="flex flex-wrap gap-1">
<Tag
v-for="keyword in groupKeywords(group)"
:key="keyword"
:value="keyword"
rounded
:style="{
backgroundColor: group.color || '#1890ff',
color: '#fff',
border: 'none',
}"
class="cursor-pointer"
@click="removeKeyword(group, keyword)"
/>
</div>
<div v-else class="text-xs text-slate-500">暂无关键词</div>
<div class="mt-2 flex gap-2">
<InputText
v-model="keywordInputs[i]"
size="small"
class="flex-1"
placeholder="添加关键词"
@keydown.enter="addKeyword(group, i)"
/>
<Button label="添加" size="small" severity="secondary" @click="addKeyword(group, i)" />
</div>
</div>
<div
v-if="editingGroupIndex === i"
class="mt-3 rounded-lg border border-slate-600/40 bg-white p-3 text-slate-800"
>
<div class="mb-3 text-sm font-medium">分组样式</div>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<label class="mb-1 block text-xs text-slate-500">字体</label>
<Select
v-model="group.styleOverride.fontName"
:options="SUBTITLE_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">字体大小</label>
<InputNumber
v-model="group.styleOverride.fontSize"
:min="12"
:max="120"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">标签颜色</label>
<div class="flex items-center gap-2">
<input v-model="group.color" type="color" class="h-8 w-10" />
<InputText v-model="group.color" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">文字颜色</label>
<div class="flex items-center gap-2">
<input v-model="group.styleOverride.fontColor" type="color" class="h-8 w-10" />
<InputText v-model="group.styleOverride.fontColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">描边颜色</label>
<div class="flex items-center gap-2">
<input v-model="group.styleOverride.outlineColor" type="color" class="h-8 w-10" />
<InputText v-model="group.styleOverride.outlineColor" size="small" class="flex-1" />
</div>
</div>
<div class="col-span-2">
<label class="mb-1 block text-xs text-slate-500">描边宽度</label>
<div class="flex items-center gap-2">
<Slider
v-model="group.styleOverride.outlineWidth"
:min="0"
:max="10"
class="flex-1"
/>
<span class="w-10 text-right font-mono text-sm">
{{ group.styleOverride.outlineWidth }}px
</span>
</div>
</div>
</div>
<div class="mt-3 rounded bg-slate-800 p-3 text-center">
<span :style="groupPreviewStyle(group)">示例关键词</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<p class="mt-2 text-xs text-slate-500">分组样式编辑与关键词标记将在后续版本开放</p>
<div class="mt-4 flex gap-2">
<Button label="导出配置" size="small" severity="secondary" @click="exportGroups" />
<Button
label="导入配置"
size="small"
severity="secondary"
@click="importInputRef?.click()"
/>
</div>
</div> </div>
</div> </div>
<input
ref="importInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="importGroups"
/>
</div> </div>
</template> </template>

View File

@@ -58,6 +58,13 @@ const noBackground = computed({
}, },
}); });
const shadowBlurPreview = computed({
get: () => subtitleStyle.value?.shadowBlur ?? 4,
set: (value) => {
if (subtitleStyle.value) subtitleStyle.value.shadowBlur = value;
},
});
const previewStyle = computed(() => const previewStyle = computed(() =>
buildSubtitlePreviewBoxStyle(subtitleStyle.value), buildSubtitlePreviewBoxStyle(subtitleStyle.value),
); );
@@ -189,6 +196,24 @@ function applyPreset(preset) {
<InputText v-model="subtitleStyle.shadowColor" size="small" class="flex-1" /> <InputText v-model="subtitleStyle.shadowColor" size="small" class="flex-1" />
</div> </div>
</div> </div>
<div class="col-span-2">
<label class="mb-2 block text-sm text-slate-400">阴影模糊仅预览有效</label>
<div class="flex items-center gap-2">
<Slider
v-model="shadowBlurPreview"
:min="0"
:max="20"
class="flex-1"
disabled
/>
<span class="w-10 text-right font-mono text-sm text-slate-400">
{{ shadowBlurPreview }}px
</span>
</div>
<p class="mt-1 text-xs text-slate-400">
FFmpeg 不支持阴影模糊实际视频中仅显示阴影偏移效果
</p>
</div>
</template> </template>
<div class="col-span-2"> <div class="col-span-2">
<label class="mb-2 block text-sm text-slate-600">背景颜色</label> <label class="mb-2 block text-sm text-slate-600">背景颜色</label>

View File

@@ -20,6 +20,13 @@ const enableBackground = computed({
}, },
}); });
const backgroundOpacityPercent = computed({
get: () => Math.round((Number(line.value.backgroundOpacity) || 0) * 100),
set: (value) => {
line.value.backgroundOpacity = Math.max(0, Math.min(100, Number(value) || 0)) / 100;
},
});
const enableShadow = computed({ const enableShadow = computed({
get: () => (line.value.shadowOffset ?? 0) > 0, get: () => (line.value.shadowOffset ?? 0) > 0,
set: (on) => { set: (on) => {
@@ -44,7 +51,7 @@ const previewStyle = computed(() => {
} }
return { return {
fontFamily: line.value.fontName ? `'${line.value.fontName}'` : "优设标题黑", fontFamily: line.value.fontName ? `'${line.value.fontName}'` : "优设标题黑",
fontSize: `${Math.min(Number(line.value.fontSize) || 80, 48)}px`, fontSize: `${Number(line.value.fontSize) || 80}px`,
color: line.value.fontColor || "#fff", color: line.value.fontColor || "#fff",
backgroundColor: backgroundColor:
enableBackground.value && line.value.backgroundColor !== "transparent" enableBackground.value && line.value.backgroundColor !== "transparent"
@@ -52,6 +59,8 @@ const previewStyle = computed(() => {
: "transparent", : "transparent",
textShadow: shadows.join(", ") || "none", textShadow: shadows.join(", ") || "none",
padding: "8px 16px", padding: "8px 16px",
display: "inline-block",
lineHeight: 1.12,
}; };
}); });
</script> </script>
@@ -108,6 +117,22 @@ const previewStyle = computed(() => {
<Checkbox v-model="enableBackground" binary /> <Checkbox v-model="enableBackground" binary />
启用背景 启用背景
</label> </label>
<div v-if="enableBackground" class="mt-2 grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-xs text-slate-500">背景颜色</label>
<div class="flex items-center gap-2">
<input v-model="line.backgroundColor" type="color" class="h-8 w-10" />
<InputText v-model="line.backgroundColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">背景透明度</label>
<div class="flex items-center gap-2">
<Slider v-model="backgroundOpacityPercent" :min="0" :max="100" class="flex-1" />
<span class="w-10 text-right text-xs">{{ backgroundOpacityPercent }}%</span>
</div>
</div>
</div>
</div> </div>
<div> <div>
@@ -116,17 +141,26 @@ const previewStyle = computed(() => {
<Checkbox v-model="enableShadow" binary /> <Checkbox v-model="enableShadow" binary />
启用阴影 启用阴影
</label> </label>
<div v-if="enableShadow" class="flex items-center gap-2"> <div v-if="enableShadow" class="space-y-2">
<label class="text-xs text-slate-500">阴影偏移</label> <div class="flex items-center gap-2">
<Slider v-model="line.shadowOffset" :min="1" :max="10" class="flex-1" /> <label class="text-xs text-slate-500">阴影偏移</label>
<input v-model="line.shadowColor" type="color" class="h-8 w-10" /> <Slider v-model="line.shadowOffset" :min="1" :max="10" class="flex-1" />
<span class="w-8 text-right text-xs">{{ line.shadowOffset }}</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">阴影颜色</label>
<input v-model="line.shadowColor" type="color" class="h-8 w-10" />
<InputText v-model="line.shadowColor" size="small" class="flex-1" />
</div>
</div> </div>
</div> </div>
<div> <div>
<div class="mb-2 text-sm font-medium text-slate-700">效果预览</div> <div class="mb-2 text-sm font-medium text-slate-700">效果预览</div>
<div class="rounded bg-slate-100 py-4 text-center" :style="previewStyle"> <div class="overflow-hidden rounded bg-slate-100 py-4 text-center">
{{ line.text || "标题文字" }} <div :style="previewStyle">
{{ line.text || "标题文字" }}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref } from "vue"; import { computed, ref, watch } from "vue";
import TitleLineStyleEditor from "./TitleLineStyleEditor.vue"; import TitleLineStyleEditor from "./TitleLineStyleEditor.vue";
import { syncTitleLinesFromReference } from "../../utils/subtitleTemplateDraft.js"; import { syncTitleLinesFromReference } from "../../utils/subtitleTemplateDraft.js";
@@ -37,6 +37,19 @@ function reloadFromReference() {
} }
const linePreviews = computed(() => titleConfig.value?.lines || []); const linePreviews = computed(() => titleConfig.value?.lines || []);
const maxCharMarks = [8, 12, 15, 20];
function markLeft(value) {
return `${((value - 8) / (20 - 8)) * 100}%`;
}
watch(
() => titleConfig.value?.maxCharsPerLine,
() => {
reloadFromReference();
},
);
</script> </script>
<template> <template>
@@ -57,36 +70,48 @@ const linePreviews = computed(() => titleConfig.value?.lines || []);
</div> </div>
</div> </div>
<div class="grid gap-4 md:grid-cols-2"> <div class="space-y-4">
<div> <div class="flex items-center gap-3">
<label class="mb-1 block text-xs text-slate-400">每行最大字符数</label> <div class="w-28 shrink-0 text-sm text-slate-300">每行最大字符数</div>
<InputNumber <div class="relative flex-1 pb-5">
v-model="titleConfig.maxCharsPerLine" <Slider
:min="8" v-model="titleConfig.maxCharsPerLine"
:max="20" :min="8"
size="small" :max="20"
class="w-full" :step="1"
/> class="w-full"
/>
<div class="pointer-events-none absolute inset-x-0 top-6 text-xs text-slate-500">
<span
v-for="mark in maxCharMarks"
:key="mark"
class="absolute -translate-x-1/2"
:style="{ left: markLeft(mark) }"
>
{{ mark }}
</span>
</div>
</div>
</div> </div>
<div> <div class="flex items-center gap-3">
<label class="mb-1 block text-xs text-slate-400">行间距%</label> <div class="w-28 shrink-0 text-sm text-slate-300">行间距%</div>
<InputNumber <InputNumber
v-model="titleConfig.lineSpacingPercent" v-model="titleConfig.lineSpacingPercent"
:min="3" :min="3"
:max="15" :max="15"
size="small" class="w-32"
class="w-full"
/> />
<span class="text-xs text-slate-500">相对于视频高度</span>
</div> </div>
<div> <div class="flex items-center gap-3">
<label class="mb-1 block text-xs text-slate-400">顶部边距%</label> <div class="w-28 shrink-0 text-sm text-slate-300">顶部边距%</div>
<InputNumber <InputNumber
v-model="titleConfig.topMarginPercent" v-model="titleConfig.topMarginPercent"
:min="2" :min="2"
:max="20" :max="20"
size="small" class="w-32"
class="w-full"
/> />
<span class="text-xs text-slate-500">相对于视频高度</span>
</div> </div>
</div> </div>