11
This commit is contained in:
102
src/components/subtitle/KeywordEffectsSettings.vue
Normal file
102
src/components/subtitle/KeywordEffectsSettings.vue
Normal 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>
|
||||
239
src/components/subtitle/SubtitleStyleSelector.vue
Normal file
239
src/components/subtitle/SubtitleStyleSelector.vue
Normal 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>
|
||||
@@ -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,8 +292,22 @@ 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"
|
||||
type="file"
|
||||
@@ -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>
|
||||
|
||||
60
src/components/subtitle/SubtitleTemplateEditor.vue
Normal file
60
src/components/subtitle/SubtitleTemplateEditor.vue
Normal 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>
|
||||
133
src/components/subtitle/TitleLineStyleEditor.vue
Normal file
133
src/components/subtitle/TitleLineStyleEditor.vue
Normal 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>
|
||||
133
src/components/subtitle/TitleSubtitleSettings.vue
Normal file
133
src/components/subtitle/TitleSubtitleSettings.vue
Normal 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>
|
||||
162
src/config/subtitleStylePresets.js
Normal file
162
src/config/subtitleStylePresets.js
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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[]>}
|
||||
*/
|
||||
|
||||
101
src/utils/subtitleTemplateDraft.js
Normal file
101
src/utils/subtitleTemplateDraft.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user