This commit is contained in:
fengchuanhn@gmail.com
2026-05-27 00:21:23 +08:00
parent 8f05a8e414
commit b43118112a
9 changed files with 1084 additions and 24 deletions

View File

@@ -0,0 +1,102 @@
<script setup>
import { computed } from "vue";
const config = defineModel("config", { type: Object, required: true });
const enableKeywordEffects = computed({
get: () => config.value.enableKeywordEffects !== false,
set: (v) => {
config.value.enableKeywordEffects = v;
},
});
const keywordRenderMode = computed({
get: () => config.value.keywordRenderMode || "inline-emphasis",
set: (v) => {
config.value.keywordRenderMode = v;
},
});
const groups = computed(() => config.value.keywordGroupsStyles || []);
</script>
<template>
<div class="rounded-lg border border-blue-500/30 bg-gradient-to-r from-blue-900/15 to-blue-900/10 p-4">
<div class="mb-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<label class="flex items-center gap-2">
<Checkbox v-model="enableKeywordEffects" binary />
<span class="font-medium text-slate-100">关键词智能识别与特效</span>
</label>
<Tag
:value="enableKeywordEffects ? '已启用' : '未启用'"
:severity="enableKeywordEffects ? 'info' : 'secondary'"
/>
</div>
<p class="mt-1 pl-6 text-xs text-slate-400">
勾选后启用关键词特效功能AI 自动识别字幕中的关键词并添加醒目的放大特效
</p>
</div>
<div v-if="enableKeywordEffects" class="mt-4 space-y-4">
<div class="rounded-lg border border-slate-600/40 bg-slate-900/40 p-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h6 class="text-sm font-medium text-slate-200">关键词呈现方式</h6>
<p class="mt-1 text-xs text-slate-400">
内联字幕用于新模板浮动特效用于旧模板
</p>
</div>
<SelectButton
v-model="keywordRenderMode"
:options="[
{ label: '内联字幕', value: 'inline-emphasis' },
{ label: '浮动特效', value: 'floating' },
]"
option-label="label"
option-value="value"
size="small"
/>
</div>
<p
class="mt-3 rounded-md border px-3 py-2 text-xs"
:class="
keywordRenderMode === 'inline-emphasis'
? 'border-blue-500/40 bg-blue-900/20 text-blue-200'
: 'border-amber-500/40 bg-amber-900/20 text-amber-200'
"
>
{{
keywordRenderMode === "inline-emphasis"
? "关键词会直接嵌在普通字幕里放大显示,不再单独飞出"
: "关键词会按旧模板方式单独触发浮动/动画特效"
}}
</p>
</div>
<div class="flex items-start gap-2 rounded border border-blue-500/30 bg-blue-900/20 p-3 text-xs text-blue-200">
<i class="pi pi-info-circle mt-0.5" />
<span>关键词将根据字幕内容自动识别和匹配生成视频时会自动应用特效</span>
</div>
<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>
<div class="max-h-48 space-y-2 overflow-y-auto">
<div
v-for="(group, i) in groups"
:key="i"
class="rounded border border-slate-600/30 bg-slate-800/50 p-2 text-sm"
:style="{ borderLeftWidth: '4px', borderLeftColor: group.color || '#3b82f6' }"
>
<div class="font-medium text-slate-200">{{ group.groupName || `分组 ${i + 1}` }}</div>
<div v-if="group.styleOverride?.fontName" class="mt-1 text-xs text-slate-400">
强调字体{{ group.styleOverride.fontName }} ·
{{ group.styleOverride.fontColor || group.color }}
</div>
</div>
</div>
<p class="mt-2 text-xs text-slate-500">分组样式编辑与关键词标记将在后续版本开放</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,239 @@
<script setup>
import { computed } from "vue";
import {
SUBTITLE_FONT_OPTIONS,
SUBTITLE_STYLE_PRESETS,
buildSubtitlePreviewBoxStyle,
} from "../../config/subtitleStylePresets.js";
const config = defineModel("config", { type: Object, required: true });
const subtitleStyle = computed({
get: () => config.value.subtitleStyle,
set: (v) => {
config.value.subtitleStyle = v;
},
});
const fontSizeScale = computed({
get: () => config.value.subtitleFontSizeScale ?? 100,
set: (v) => {
config.value.subtitleFontSizeScale = v;
},
});
const subtitlePosition = computed({
get: () => config.value.subtitlePosition || subtitleStyle.value?.position || "bottom",
set: (v) => {
config.value.subtitlePosition = v;
if (subtitleStyle.value) subtitleStyle.value.position = v;
},
});
const noShadow = computed({
get: () => !subtitleStyle.value?.shadowOffset,
set: (checked) => {
if (!subtitleStyle.value) return;
subtitleStyle.value.shadowOffset = checked ? 0 : 2;
if (!checked && !subtitleStyle.value.shadowBlur) {
subtitleStyle.value.shadowBlur = 4;
}
},
});
const noBackground = computed({
get: () =>
!subtitleStyle.value?.backgroundColor ||
subtitleStyle.value.backgroundColor === "transparent" ||
(subtitleStyle.value.backgroundOpacity ?? 0) <= 0,
set: (checked) => {
if (!subtitleStyle.value) return;
if (checked) {
subtitleStyle.value.backgroundColor = "transparent";
subtitleStyle.value.backgroundOpacity = 0;
} else {
subtitleStyle.value.backgroundColor = "#000000";
subtitleStyle.value.backgroundOpacity = 0.7;
}
},
});
const previewStyle = computed(() =>
buildSubtitlePreviewBoxStyle(subtitleStyle.value),
);
function applyPreset(preset) {
Object.assign(subtitleStyle.value, preset.style);
}
</script>
<template>
<div class="subtitle-style-selector space-y-4">
<div class="rounded-lg border border-slate-600/50 bg-slate-800/40 p-4">
<div class="mb-3 flex items-center justify-between">
<label class="text-sm font-medium text-slate-200">字体大小</label>
<span class="text-sm font-bold text-blue-400">{{ fontSizeScale }}%</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-400"></span>
<Slider v-model="fontSizeScale" :min="20" :max="150" :step="5" class="flex-1" />
<span class="text-xs text-slate-400"></span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
v-for="n in [30, 50, 75, 100]"
:key="n"
:label="`${n}%`"
size="small"
severity="secondary"
@click="fontSizeScale = n"
/>
</div>
</div>
<div class="rounded-lg border border-slate-600/50 bg-slate-800/40 p-4">
<label class="mb-3 block text-sm font-medium text-slate-200">字幕位置</label>
<div class="flex gap-2">
<Button
label="上"
size="small"
:outlined="subtitlePosition !== 'top'"
@click="subtitlePosition = 'top'"
/>
<Button
label="中"
size="small"
:outlined="subtitlePosition !== 'center'"
@click="subtitlePosition = 'center'"
/>
<Button
label="下"
size="small"
:outlined="subtitlePosition !== 'bottom'"
@click="subtitlePosition = 'bottom'"
/>
</div>
</div>
<div class="rounded-lg border border-slate-600/50 bg-white p-4 text-slate-900">
<h5 class="mb-4 text-sm font-medium">自定义字幕样式</h5>
<div class="mb-4 rounded-lg bg-slate-800 p-4">
<div class="mb-3 text-center text-xs text-slate-400">效果预览</div>
<div class="flex min-h-[120px] items-center justify-center">
<div class="preview-text inline-block" :style="previewStyle">示例字幕文字</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="mb-2 block text-sm text-slate-600">字体</label>
<Select
v-model="subtitleStyle.fontName"
:options="SUBTITLE_FONT_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
size="small"
/>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">文字颜色</label>
<div class="flex items-center gap-2">
<input v-model="subtitleStyle.fontColor" type="color" class="h-8 w-10" />
<InputText v-model="subtitleStyle.fontColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">描边颜色</label>
<div class="flex items-center gap-2">
<input v-model="subtitleStyle.outlineColor" type="color" class="h-8 w-10" />
<InputText v-model="subtitleStyle.outlineColor" size="small" class="flex-1" />
</div>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">描边宽度</label>
<div class="flex items-center gap-2">
<Slider
v-model="subtitleStyle.outlineWidth"
:min="0"
:max="10"
:step="1"
class="flex-1"
/>
<span class="w-10 text-right font-mono text-sm">{{ subtitleStyle.outlineWidth }}px</span>
</div>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 text-sm text-slate-700">
<Checkbox v-model="noShadow" binary />
无阴影
</label>
</div>
<template v-if="!noShadow">
<div>
<label class="mb-2 block text-sm text-slate-600">阴影偏移</label>
<div class="flex items-center gap-2">
<Slider
v-model="subtitleStyle.shadowOffset"
:min="1"
:max="10"
class="flex-1"
/>
<span class="w-10 text-right font-mono text-sm">{{ subtitleStyle.shadowOffset }}px</span>
</div>
</div>
<div>
<label class="mb-2 block text-sm text-slate-600">阴影颜色</label>
<div class="flex items-center gap-2">
<input v-model="subtitleStyle.shadowColor" type="color" class="h-8 w-10" />
<InputText v-model="subtitleStyle.shadowColor" size="small" class="flex-1" />
</div>
</div>
</template>
<div class="col-span-2">
<label class="mb-2 block text-sm text-slate-600">背景颜色</label>
<div class="flex items-center gap-2">
<input
v-model="subtitleStyle.backgroundColor"
type="color"
class="h-8 w-10"
:disabled="noBackground"
/>
<InputText
v-model="subtitleStyle.backgroundColor"
size="small"
class="flex-1"
:disabled="noBackground"
/>
</div>
<label class="mt-2 flex items-center gap-2 text-sm text-slate-700">
<Checkbox v-model="noBackground" binary />
无背景
</label>
<p class="mt-1 text-xs text-slate-500">
背景和描边可同时使用后端支持 BorderStyle=4
</p>
</div>
</div>
<div class="mt-4">
<label class="mb-2 block text-sm text-slate-600">快速预设</label>
<div class="flex flex-wrap gap-2">
<Button
v-for="preset in SUBTITLE_STYLE_PRESETS"
:key="preset.label"
:label="preset.label"
size="small"
severity="secondary"
@click="applyPreset(preset)"
/>
</div>
</div>
</div>
<div class="rounded-lg border border-blue-500/30 bg-blue-900/20 p-3 text-sm text-blue-200">
<strong>提示</strong>
编辑完成后请点击对话框底部的保存模板按钮保存所有设置包括字幕样式
</div>
</div>
</template>

View File

@@ -1,6 +1,9 @@
<script setup>
import { computed, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import SubtitlePreviewCard from "./SubtitlePreviewCard.vue";
import SubtitleTemplateEditor from "./SubtitleTemplateEditor.vue";
import {
cloneSubtitleTemplate,
downloadSubtitleTemplatesJson,
@@ -9,7 +12,12 @@ import {
loadCustomSubtitleTemplates,
parseImportedSubtitleTemplates,
saveCustomSubtitleTemplates,
upsertCustomSubtitleTemplate,
} from "../../services/subtitleTemplateCatalog.js";
import {
createEditDraft,
syncTitleLinesFromReference,
} from "../../utils/subtitleTemplateDraft.js";
const visible = defineModel("visible", { type: Boolean, default: false });
@@ -19,16 +27,27 @@ const props = defineProps({
const emit = defineEmits(["select", "update:selectedId"]);
const workflow = useWorkflowStore();
const { titleGenerated } = storeToRefs(workflow);
const templates = ref([]);
const loading = ref(false);
const feedback = ref({ severity: "", message: "" });
const fileInputRef = ref(null);
const editMode = ref(false);
const editingDraft = ref(null);
const saveDialogVisible = ref(false);
const saveTemplateName = ref("");
const saveTemplateDesc = ref("");
const selectedTemplateId = computed({
get: () => props.selectedId,
set: (id) => emit("update:selectedId", id),
});
const dialogHeader = computed(() => (editMode.value ? "字幕设置" : "字幕设置"));
async function refreshTemplates() {
loading.value = true;
feedback.value = { severity: "", message: "" };
@@ -44,7 +63,11 @@ async function refreshTemplates() {
}
watch(visible, (open) => {
if (open) refreshTemplates();
if (open) {
editMode.value = false;
editingDraft.value = null;
refreshTemplates();
}
});
function showMsg(severity, message) {
@@ -94,31 +117,20 @@ async function onImportFile(event) {
}
function onNewTemplate() {
const base = templates.value.find((t) => t.id === "template_system_11") || templates.value[0];
const base =
templates.value.find((t) => t.id === "template_system_11") || templates.value[0];
if (!base) {
showMsg("error", "暂无可用模板作为新建基础");
return;
}
const created = cloneSubtitleTemplate({
...base,
name: "新建模板",
});
const custom = loadCustomSubtitleTemplates();
custom.unshift(created);
saveCustomSubtitleTemplates(custom);
refreshTemplates();
selectedTemplateId.value = created.id;
emit("select", created);
showMsg("success", "已创建新模板,可在列表中继续编辑样式");
const created = cloneSubtitleTemplate({ ...base, name: "新建模板" });
enterEditMode(created, true);
}
function onClone(template) {
const created = cloneSubtitleTemplate(template);
const custom = loadCustomSubtitleTemplates();
custom.unshift(created);
saveCustomSubtitleTemplates(custom);
refreshTemplates();
showMsg("success", `已复制为「${created.name}`);
enterEditMode(created, true);
showMsg("success", `已复制为「${created.name}」,请编辑后保存`);
}
function onDelete(template) {
@@ -136,22 +148,76 @@ function onDelete(template) {
showMsg("success", "模板已删除");
}
function enterEditMode(template, isNew = false) {
editingDraft.value = createEditDraft(template);
if (titleGenerated.value?.trim()) {
syncTitleLinesFromReference(editingDraft.value, titleGenerated.value);
}
editMode.value = true;
saveTemplateName.value = isNew ? template.name : template.name;
saveTemplateDesc.value = template.description || "";
feedback.value = { severity: "", message: "" };
}
function onEdit(template) {
selectTemplate(template);
showMsg("success", "已选用该模板;完整样式编辑器后续版本提供");
enterEditMode(template);
}
function cancelEdit() {
editMode.value = false;
editingDraft.value = null;
}
function openSaveDialog() {
if (!editingDraft.value) return;
const isSystem = isSystemSubtitleTemplateId(editingDraft.value.id);
saveTemplateName.value = isSystem
? `${editingDraft.value.name} 副本`
: editingDraft.value.name;
saveTemplateDesc.value = editingDraft.value.description || "";
saveDialogVisible.value = true;
}
function confirmSaveTemplate() {
const name = saveTemplateName.value.trim();
if (!name) {
showMsg("error", "请输入模板名称");
return;
}
const draft = editingDraft.value;
const isSystem = isSystemSubtitleTemplateId(draft.id);
const toSave = {
...draft,
name,
description: saveTemplateDesc.value.trim(),
isSystem: false,
is_system: 0,
};
if (isSystem) {
toSave.id = `custom_${Date.now()}`;
toSave.createdAt = Date.now();
}
const saved = upsertCustomSubtitleTemplate(toSave);
saveDialogVisible.value = false;
selectedTemplateId.value = saved.id;
emit("select", saved);
editMode.value = false;
editingDraft.value = null;
refreshTemplates();
showMsg("success", `模板「${saved.name}」已保存`);
}
</script>
<template>
<Dialog
v-model:visible="visible"
header="字幕设置"
:header="dialogHeader"
modal
:style="{ width: 'min(1120px, 96vw)' }"
:style="{ width: editMode ? 'min(1200px, 98vw)' : 'min(1120px, 96vw)' }"
:content-style="{ padding: 0 }"
>
<div class="flex max-h-[70vh] flex-col">
<div class="flex-1 space-y-4 overflow-y-auto p-4">
<div class="flex max-h-[78vh] flex-col">
<div v-if="!editMode" class="flex-1 overflow-y-auto p-4">
<div
class="rounded-lg border border-blue-500/30 bg-gradient-to-r from-blue-900/20 to-indigo-900/20 p-4"
>
@@ -226,7 +292,21 @@ function onEdit(template) {
</div>
</div>
</div>
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 pb-4">
<SubtitleTemplateEditor
v-model:draft="editingDraft"
:reference-title="titleGenerated"
/>
</div>
</div>
<template v-if="editMode" #footer>
<div class="flex w-full justify-end gap-2">
<Button label="返回列表" severity="secondary" outlined @click="cancelEdit" />
<Button label="保存模板" icon="pi pi-save" @click="openSaveDialog" />
</div>
</template>
<input
ref="fileInputRef"
@@ -236,4 +316,34 @@ function onEdit(template) {
@change="onImportFile"
/>
</Dialog>
<Dialog
v-model:visible="saveDialogVisible"
header="保存字幕模板"
modal
:style="{ width: '28rem' }"
>
<div class="flex flex-col gap-3">
<div>
<label class="mb-1 block text-sm font-medium text-slate-200">模板名称</label>
<InputText v-model="saveTemplateName" placeholder="请输入模板名称" class="w-full" />
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-200">模板描述可选</label>
<Textarea
v-model="saveTemplateDesc"
placeholder="请输入模板描述"
rows="3"
class="w-full"
/>
</div>
<p class="text-xs text-slate-400">
保存内容包括字幕样式位置与大小关键词特效开关标题字幕配置关键词分组等
</p>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="saveDialogVisible = false" />
<Button label="保存" icon="pi pi-check" @click="confirmSaveTemplate" />
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
import { computed } from "vue";
import SubtitlePreviewCard from "./SubtitlePreviewCard.vue";
import SubtitleStyleSelector from "./SubtitleStyleSelector.vue";
import TitleSubtitleSettings from "./TitleSubtitleSettings.vue";
import KeywordEffectsSettings from "./KeywordEffectsSettings.vue";
const draft = defineModel("draft", { type: Object, required: true });
defineProps({
referenceTitle: { type: String, default: "" },
});
const config = computed({
get: () => draft.value.config,
set: (v) => {
draft.value.config = v;
},
});
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div class="shrink-0 border-b border-slate-700/80 pb-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-blue-400">正在编辑: {{ draft.name }}</span>
<Tag value="编辑模式" severity="info" />
</div>
</div>
<p class="mt-1 text-xs text-slate-500">修改完成后请点击底部的保存模板按钮</p>
</div>
<div class="mt-4 flex min-h-0 flex-1 gap-6">
<div class="sticky top-0 z-10 w-[320px] shrink-0 self-start">
<div class="mb-2 text-center text-xs font-medium text-slate-500">实时预览</div>
<SubtitlePreviewCard
:template-name="draft.name"
:subtitle-style="config.subtitleStyle"
:title-subtitle-config="config.titleSubtitleConfig"
:keyword-groups-styles="config.keywordGroupsStyles"
:keyword-render-mode="config.keywordRenderMode"
:preview-config="config.previewConfig"
:preview-scale="0.42"
:show-actions="false"
:show-meta="false"
:card-clickable="false"
/>
</div>
<div class="min-w-0 flex-1 space-y-6 overflow-y-auto pb-4 pr-1">
<SubtitleStyleSelector v-model:config="config" />
<hr class="border-slate-700" />
<TitleSubtitleSettings v-model:config="config" :reference-title="referenceTitle" />
<hr class="border-slate-700" />
<KeywordEffectsSettings v-model:config="config" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script setup>
import { computed } from "vue";
import { SUBTITLE_FONT_OPTIONS } from "../../config/subtitleStylePresets.js";
const line = defineModel("line", { type: Object, required: true });
const enableBackground = computed({
get: () =>
line.value.backgroundColor &&
line.value.backgroundColor !== "transparent" &&
(line.value.backgroundOpacity ?? 0) > 0,
set: (on) => {
if (on) {
line.value.backgroundColor = line.value.backgroundColor || "#000000";
line.value.backgroundOpacity = line.value.backgroundOpacity || 0.5;
} else {
line.value.backgroundColor = "transparent";
line.value.backgroundOpacity = 0;
}
},
});
const enableShadow = computed({
get: () => (line.value.shadowOffset ?? 0) > 0,
set: (on) => {
line.value.shadowOffset = on ? line.value.shadowOffset || 2 : 0;
},
});
const previewStyle = computed(() => {
const outline = Number(line.value.outlineWidth) || 0;
const offset = Number(line.value.shadowOffset) || 0;
const shadows = [];
if (outline > 0) {
const c = line.value.outlineColor || "#000";
for (let x = -outline; x <= outline; x++) {
for (let y = -outline; y <= outline; y++) {
if (x || y) shadows.push(`${x}px ${y}px 0 ${c}`);
}
}
}
if (offset > 0) {
shadows.push(`${offset}px ${offset}px 0 ${line.value.shadowColor || "#000"}`);
}
return {
fontFamily: line.value.fontName ? `'${line.value.fontName}'` : "优设标题黑",
fontSize: `${Math.min(Number(line.value.fontSize) || 80, 48)}px`,
color: line.value.fontColor || "#fff",
backgroundColor:
enableBackground.value && line.value.backgroundColor !== "transparent"
? line.value.backgroundColor
: "transparent",
textShadow: shadows.join(", ") || "none",
padding: "8px 16px",
};
});
</script>
<template>
<div class="line-style-editor space-y-4 text-slate-800">
<div>
<div class="mb-2 text-sm font-medium text-slate-700">字体设置</div>
<div class="space-y-3">
<div>
<label class="mb-1 block text-xs text-slate-500">字体</label>
<Select
v-model="line.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="line.fontSize" :min="24" :max="120" class="w-full" size="small" />
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">颜色设置</div>
<div class="flex items-center gap-2">
<input v-model="line.fontColor" type="color" class="h-8 w-10" />
<span class="font-mono text-xs">{{ line.fontColor }}</span>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">描边设置</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">描边宽度</label>
<Slider v-model="line.outlineWidth" :min="0" :max="10" class="flex-1" />
<span class="w-8 text-right text-xs">{{ line.outlineWidth }}</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-slate-500">描边颜色</label>
<input v-model="line.outlineColor" type="color" class="h-8 w-10" />
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">背景设置</div>
<label class="flex items-center gap-2 text-sm">
<Checkbox v-model="enableBackground" binary />
启用背景
</label>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-700">阴影设置可选</div>
<label class="mb-2 flex items-center gap-2 text-sm">
<Checkbox v-model="enableShadow" binary />
启用阴影
</label>
<div v-if="enableShadow" class="flex items-center gap-2">
<label class="text-xs text-slate-500">阴影偏移</label>
<Slider v-model="line.shadowOffset" :min="1" :max="10" class="flex-1" />
<input v-model="line.shadowColor" type="color" class="h-8 w-10" />
</div>
</div>
<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">
{{ line.text || "标题文字" }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script setup>
import { computed, ref } from "vue";
import TitleLineStyleEditor from "./TitleLineStyleEditor.vue";
import { syncTitleLinesFromReference } from "../../utils/subtitleTemplateDraft.js";
const config = defineModel("config", { type: Object, required: true });
const props = defineProps({
referenceTitle: { type: String, default: "" },
});
const titleConfig = computed({
get: () => config.value.titleSubtitleConfig,
set: (v) => {
config.value.titleSubtitleConfig = v;
},
});
const enabled = computed({
get: () => titleConfig.value?.enabled !== false,
set: (v) => {
titleConfig.value.enabled = v;
},
});
const openLineIndex = ref(0);
const displayTitle = computed(
() => props.referenceTitle?.trim() || titleConfig.value?.sourceTitle || "(暂无标题,请先在步骤 04 生成)",
);
function reloadFromReference() {
syncTitleLinesFromReference(
{ config: config.value },
props.referenceTitle || titleConfig.value?.sourceTitle,
);
}
const linePreviews = computed(() => titleConfig.value?.lines || []);
</script>
<template>
<div class="title-subtitle-settings space-y-4 rounded-lg border border-slate-600/40 bg-slate-800/30 p-4">
<div class="flex items-center gap-3">
<Checkbox v-model="enabled" binary />
<span class="font-medium text-slate-100">标题字幕</span>
</div>
<div v-if="enabled" class="space-y-4">
<div>
<div class="mb-2 text-xs text-slate-400">
当前标题自动读取步骤 04标题标签关键词生成结果
</div>
<div class="flex flex-wrap items-center gap-2 rounded bg-slate-900/50 p-3">
<div class="flex-1 text-sm text-slate-200">{{ displayTitle }}</div>
<Button label="重新加载" size="small" severity="secondary" @click="reloadFromReference" />
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs text-slate-400">每行最大字符数</label>
<InputNumber
v-model="titleConfig.maxCharsPerLine"
:min="8"
:max="20"
size="small"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">行间距%</label>
<InputNumber
v-model="titleConfig.lineSpacingPercent"
:min="3"
:max="15"
size="small"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-slate-400">顶部边距%</label>
<InputNumber
v-model="titleConfig.topMarginPercent"
:min="2"
:max="20"
size="small"
class="w-full"
/>
</div>
</div>
<div v-if="linePreviews.length">
<div class="mb-2 text-xs text-slate-400">分行预览 {{ linePreviews.length }} </div>
<div class="space-y-1">
<div
v-for="(line, i) in linePreviews"
:key="i"
class="rounded bg-slate-900/40 px-2 py-1 text-sm text-slate-300"
>
<span class="text-slate-500">{{ i + 1 }}</span>{{ line.text }}
</div>
</div>
</div>
<div>
<div class="mb-2 text-sm font-medium text-slate-200">每行样式配置</div>
<div class="space-y-2">
<div
v-for="(line, index) in titleConfig.lines"
:key="index"
class="overflow-hidden rounded-lg border border-slate-600/50"
>
<button
type="button"
class="flex w-full items-center justify-between bg-slate-900/50 px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-900/70"
@click="openLineIndex = openLineIndex === index ? -1 : index"
>
<span>{{ index + 1 }}{{ line.text || "(空)" }}</span>
<i
class="pi text-xs"
:class="openLineIndex === index ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<div v-show="openLineIndex === index" class="border-t border-slate-600/40 bg-white p-3">
<TitleLineStyleEditor v-model:line="titleConfig.lines[index]" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,162 @@
/** 字幕样式字体与快速预设(对齐 Electron SubtitleStyleSelector */
export const SUBTITLE_FONT_OPTIONS = [
{ label: "胡晓波男神体", value: "胡晓波男神体" },
{ label: "优设标题黑", value: "优设标题黑" },
{ label: "微软雅黑", value: "Microsoft YaHei" },
{ label: "微软雅黑 UI", value: "Microsoft YaHei UI" },
{ label: "月星楷", value: "月星楷" },
{ label: "USMCCyuanjiantecu", value: "USMCCyuanjiantecu" },
{ label: "庞门正道标题体", value: "庞门正道标题体免费版" },
{ label: "霞鹜文楷", value: "霞鹜文楷-Light" },
{ label: "文鼎PL简中楷", value: "文鼎PL简中楷" },
{ label: "杨任东竹石体", value: "杨任东竹石体" },
{ label: "千图厚黑体", value: "千图厚黑体" },
{ label: "Arial", value: "Arial" },
];
export const SUBTITLE_STYLE_PRESETS = [
{
label: "黑底白字",
style: {
fontColor: "#FFFFFF",
backgroundColor: "#000000",
backgroundOpacity: 0.8,
outlineWidth: 0,
},
},
{
label: "白底黑字",
style: {
fontColor: "#000000",
backgroundColor: "#FFFFFF",
backgroundOpacity: 0.9,
outlineWidth: 0,
},
},
{
label: "抖音风格",
style: {
fontColor: "#FFFFFF",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 3,
shadowOffset: 2,
shadowColor: "#000000",
},
},
{
label: "红底白字",
style: {
fontColor: "#FFFFFF",
backgroundColor: "#FF0000",
backgroundOpacity: 0.85,
outlineWidth: 0,
},
},
{
label: "黄底黑字",
style: {
fontColor: "#000000",
backgroundColor: "#FFD700",
backgroundOpacity: 0.9,
outlineWidth: 0,
},
},
{
label: "蓝底白字",
style: {
fontColor: "#FFFFFF",
backgroundColor: "#2563EB",
backgroundOpacity: 0.85,
outlineWidth: 0,
},
},
{
label: "绿色高亮",
style: {
fontColor: "#00FF88",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 2,
},
},
{
label: "紫色高亮",
style: {
fontColor: "#E879F9",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 2,
},
},
{
label: "立体阴影",
style: {
fontColor: "#FFFFFF",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineWidth: 0,
shadowOffset: 3,
shadowBlur: 4,
shadowColor: "#000000",
},
},
{
label: "霓虹发光",
style: {
fontColor: "#00FFFF",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#FF00FF",
outlineWidth: 2,
shadowOffset: 0,
shadowBlur: 8,
shadowColor: "#00FFFF",
},
},
];
/**
* @param {Record<string, unknown>} style
*/
export function buildSubtitlePreviewBoxStyle(style) {
const fontSize = Number(style?.fontSize) || 35;
const outline = Number(style?.outlineWidth) || 0;
const offset = Number(style?.shadowOffset) || 0;
const shadows = [];
if (outline > 0) {
const c = style?.outlineColor || "#000000";
const w = Math.max(1, outline);
shadows.push(
`${w}px 0 0 ${c}`,
`-${w}px 0 0 ${c}`,
`0 ${w}px 0 ${c}`,
`0 -${w}px 0 ${c}`,
);
}
if (offset > 0) {
shadows.push(
`${offset}px ${offset}px ${Number(style?.shadowBlur) || 4}px ${style?.shadowColor || "#000000"}`,
);
}
const bg =
style?.backgroundColor &&
style.backgroundColor !== "transparent" &&
(Number(style?.backgroundOpacity) ?? 0) > 0
? style.backgroundColor
: "transparent";
return {
fontFamily: style?.fontName ? `'${style.fontName}'` : "Microsoft YaHei UI",
fontSize: `${Math.min(fontSize, 42)}px`,
color: style?.fontColor || "#FFFFFF",
backgroundColor: bg,
padding: bg !== "transparent" ? "4px 12px" : "0",
borderRadius: bg !== "transparent" ? "4px" : "0",
textShadow: shadows.length ? shadows.join(", ") : "0 1px 2px rgba(0,0,0,0.5)",
fontWeight: "bold",
};
}

View File

@@ -50,6 +50,26 @@ export function saveCustomSubtitleTemplates(templates) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
}
/**
* 保存或更新自定义模板
* @param {SubtitleTemplateItem} template
* @returns {SubtitleTemplateItem}
*/
export function upsertCustomSubtitleTemplate(template) {
const custom = loadCustomSubtitleTemplates();
const item = {
...template,
isSystem: false,
is_system: 0,
createdAt: template.createdAt || Date.now(),
};
const idx = custom.findIndex((t) => t.id === item.id);
if (idx >= 0) custom[idx] = item;
else custom.unshift(item);
saveCustomSubtitleTemplates(custom);
return item;
}
/**
* @returns {Promise<SubtitleTemplateItem[]>}
*/

View File

@@ -0,0 +1,101 @@
/**
* @template T
* @param {T} value
* @returns {T}
*/
export function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
/**
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
*/
export function createEditDraft(template) {
const draft = deepClone(template);
if (!draft.config) draft.config = {};
if (!draft.config.subtitleStyle) {
draft.config.subtitleStyle = {
fontName: "Microsoft YaHei",
fontSize: 35,
fontColor: "#FFFFFF",
outlineColor: "#000000",
outlineWidth: 0,
backgroundColor: "transparent",
backgroundOpacity: 0,
position: "bottom",
shadowOffset: 2,
shadowBlur: 4,
shadowColor: "#000000",
};
}
if (draft.config.subtitleFontSizeScale == null) {
draft.config.subtitleFontSizeScale = 100;
}
if (!draft.config.subtitlePosition) {
draft.config.subtitlePosition = draft.config.subtitleStyle.position || "bottom";
}
if (!draft.config.titleSubtitleConfig) {
draft.config.titleSubtitleConfig = {
enabled: false,
lines: [],
maxCharsPerLine: 8,
lineSpacingPercent: 5,
topMarginPercent: 5,
};
}
if (!Array.isArray(draft.config.keywordGroupsStyles)) {
draft.config.keywordGroupsStyles = [];
}
if (draft.config.enableKeywordEffects == null) {
draft.config.enableKeywordEffects = true;
}
if (!draft.config.keywordRenderMode) {
draft.config.keywordRenderMode = "inline-emphasis";
}
return draft;
}
/**
* @param {string} title
* @param {number} maxChars
*/
export function splitTitleLines(title, maxChars = 8) {
const text = String(title || "").trim();
if (!text) return [];
const lines = [];
for (let i = 0; i < text.length; i += maxChars) {
lines.push(text.slice(i, i + maxChars));
}
return lines;
}
/**
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} draft
* @param {string} [referenceTitle]
*/
export function syncTitleLinesFromReference(draft, referenceTitle) {
const cfg = draft.config.titleSubtitleConfig;
const max = cfg.maxCharsPerLine || 8;
const parts = splitTitleLines(referenceTitle || cfg.sourceTitle || "", max);
const previewTitles = draft.config.previewConfig?.titleTexts;
const texts =
previewTitles?.length > 0 ? previewTitles : parts.length ? parts : ["示例标题第一行", "示例标题第二行"];
const baseLine = cfg.lines?.[0] || {
fontName: "优设标题黑",
fontSize: 80,
fontColor: "#ffff00",
backgroundColor: "transparent",
backgroundOpacity: 0,
outlineColor: "#000000",
outlineWidth: 1,
shadowColor: "#000000",
shadowOffset: 2,
};
cfg.lines = texts.map((text, i) => ({
...deepClone(cfg.lines?.[i] || baseLine),
text,
}));
cfg.sourceTitle = referenceTitle || cfg.sourceTitle || texts.join("");
if (!draft.config.previewConfig) draft.config.previewConfig = {};
draft.config.previewConfig.titleTexts = texts;
}