This commit is contained in:
fengchuanhn@gmail.com
2026-05-25 02:47:32 +08:00
parent b702aeff54
commit 8f05a8e414
24 changed files with 5680 additions and 35 deletions

View File

@@ -0,0 +1,464 @@
<script setup>
import { computed } from "vue";
import {
buildBaseTextStyle,
buildTitleLineStyle,
injectAccentMarkers,
lineScaleFactor,
parseSubtitleLine,
resolveSubtitlePreviewImage,
} from "../../utils/subtitlePreviewStyle.js";
const props = defineProps({
templateName: { type: String, default: "" },
subtitleStyle: { type: Object, default: null },
titleSubtitleConfig: { type: Object, default: null },
keywordGroupsStyles: { type: Array, default: () => [] },
keywordRenderMode: { type: String, default: "inline-emphasis" },
previewConfig: { type: Object, default: null },
isSelected: { type: Boolean, default: false },
previewScale: { type: Number, default: 0.33 },
isSystem: { type: Boolean, default: false },
showActions: { type: Boolean, default: true },
showMeta: { type: Boolean, default: true },
cardClickable: { type: Boolean, default: true },
});
const emit = defineEmits(["select", "edit", "clone", "export", "delete"]);
const inlineEmphasis = computed(
() => props.keywordRenderMode === "inline-emphasis",
);
const previewBg = computed(() => {
const path = props.previewConfig?.backgroundImage;
return resolveSubtitlePreviewImage(path) || "";
});
const bgStyle = computed(() => ({
objectPosition: props.previewConfig?.backgroundPosition || "center 28%",
}));
const overlayStyle = computed(() => {
const start =
props.previewConfig?.overlayStartColor || "rgba(0, 0, 0, 0.12)";
const end = props.previewConfig?.overlayEndColor || "rgba(0, 0, 0, 0.56)";
return {
background: `linear-gradient(180deg, ${start} 0%, rgba(15, 23, 42, 0.04) 38%, ${end} 100%)`,
};
});
const titleTopStyle = computed(() => ({
top: `${props.titleSubtitleConfig?.topMarginPercent ?? 7}%`,
}));
const titleLines = computed(() => {
const cfg = props.titleSubtitleConfig;
if (!cfg?.enabled || !cfg.lines?.length) return [];
const texts = props.previewConfig?.titleTexts?.filter((t) => t?.trim());
return cfg.lines.map((line, i) => ({
...line,
text: texts?.[i] || line.text,
}));
});
const hasTitle = computed(() => titleLines.value.length > 0);
const keywordAccentStyle = computed(() => {
const groups = props.keywordGroupsStyles || [];
const accent =
groups
.map((g) => g?.styleOverride)
.find(
(s) =>
s?.fontColor && s.fontColor !== props.subtitleStyle?.fontColor,
) ||
groups[0]?.styleOverride ||
props.subtitleStyle;
return buildBaseTextStyle(
{
...accent,
fontColor: accent?.fontColor || "#ffe600",
outlineColor: accent?.outlineColor || "#111111",
fontSize: Math.max(
Number(accent?.fontSize) || 0,
(Number(props.subtitleStyle?.fontSize) || 0) + 4,
Math.round((Number(props.subtitleStyle?.fontSize) || 0) * 1.1),
),
},
{
previewScale: props.previewScale,
minFontSize: 14,
scaleMultiplier: 1.02,
maxStrokeWidth: 0.9,
},
);
});
const normalStyle = computed(() =>
buildBaseTextStyle(props.subtitleStyle, {
previewScale: props.previewScale,
minFontSize: hasTitle.value
? inlineEmphasis.value
? 16
: 15
: inlineEmphasis.value
? 13
: 12,
scaleMultiplier: hasTitle.value
? inlineEmphasis.value
? 1.12
: 1.02
: inlineEmphasis.value
? 0.98
: 0.88,
maxStrokeWidth: hasTitle.value ? 0.9 : 0.82,
}),
);
const subtitleLines = computed(() => {
const fromConfig = props.previewConfig?.subtitleTexts?.filter((t) =>
t?.trim(),
);
const fallbackInline = [
"生活不会辜负认真[努力]的人",
"用心前行日子总会[温柔]以待",
];
const fallbackClassic = [
"这套模板展示普通字幕",
"下方只保留统一颜色",
];
const base =
fromConfig?.length > 0
? fromConfig
: inlineEmphasis.value
? fallbackInline
: fallbackClassic;
const lines = inlineEmphasis.value
? base.map((line) => injectAccentMarkers(line, true))
: base;
return lines.map((line) => parseSubtitleLine(line, inlineEmphasis.value));
});
const alignment = computed(
() => props.subtitleStyle?.alignment || "center",
);
const subtitleBoxStyle = computed(() => {
if (alignment.value === "left") {
return {
left: "0",
right: "0",
transform: "none",
width: "100%",
paddingLeft: "10%",
paddingRight: "10%",
alignItems: "stretch",
};
}
if (alignment.value === "right") {
return {
left: "0",
right: "0",
transform: "none",
width: "100%",
paddingLeft: "10%",
paddingRight: "10%",
alignItems: "stretch",
};
}
return {
left: "50%",
transform: "translateX(-50%)",
width: "84%",
alignItems: "center",
};
});
function lineWrapStyle(segments) {
const scale = lineScaleFactor(segments, hasTitle.value, inlineEmphasis.value);
if (alignment.value === "left") {
return {
textAlign: "left",
maxWidth: "100%",
width: "100%",
transform: scale < 0.999 ? `scale(${scale})` : "none",
transformOrigin: "left center",
};
}
if (alignment.value === "right") {
return {
textAlign: "right",
maxWidth: "100%",
width: "100%",
transform: scale < 0.999 ? `scale(${scale})` : "none",
transformOrigin: "right center",
};
}
return {
textAlign: "center",
maxWidth: "100%",
width: "100%",
transform: scale < 0.999 ? `scale(${scale})` : "none",
transformOrigin: "center center",
};
}
function segmentStyle(segment, segments) {
const scale = lineScaleFactor(segments, hasTitle.value, inlineEmphasis.value);
const minSize = hasTitle.value ? (inlineEmphasis.value ? 11 : 10) : 10;
const applyScale = (style) => {
const px = parseFloat(String(style.fontSize || "0"));
if (!px || Number.isNaN(px)) return style;
if (scale >= 0.999) return style;
return {
...style,
fontSize: `${Math.max(minSize, Math.round(px * scale * 10) / 10)}px`,
};
};
if (!segment.accent || !inlineEmphasis.value) {
return applyScale(normalStyle.value);
}
return applyScale({
...keywordAccentStyle.value,
transform: "translateY(-0.06em)",
});
}
function onCardClick() {
if (props.cardClickable) emit("select");
}
</script>
<template>
<div
class="subtitle-preview-card"
:class="{ 'is-selected': isSelected, 'is-static': !cardClickable }"
@click="onCardClick"
>
<div class="preview-shell">
<div class="preview-container">
<img
v-if="previewBg"
:src="previewBg"
class="preview-bg"
:style="bgStyle"
alt="preview"
/>
<div class="preview-overlay" :style="overlayStyle" />
<div
v-if="hasTitle"
class="title-subtitle-preview"
:style="titleTopStyle"
>
<div
v-for="(line, index) in titleLines"
:key="index"
class="title-line-wrap"
style="justify-content: center; padding-left: 2%; padding-right: 2%"
>
<div
class="title-line"
:style="buildTitleLineStyle(line, index, titleLines.length, previewScale)"
>
{{ line.text || `标题 ${index + 1}` }}
</div>
</div>
</div>
<div class="normal-subtitle-preview" :style="subtitleBoxStyle">
<div
v-for="(segments, lineIndex) in subtitleLines"
:key="lineIndex"
class="subtitle-line"
:style="lineWrapStyle(segments)"
>
<span
v-for="(segment, segIndex) in segments"
:key="`${lineIndex}-${segIndex}`"
class="subtitle-segment"
:class="{
'subtitle-segment--accent': segment.accent && inlineEmphasis,
}"
:style="segmentStyle(segment, segments)"
>
{{ segment.text }}
</span>
</div>
</div>
</div>
</div>
<div v-if="showMeta || showActions" class="template-info">
<div v-if="showMeta" class="template-title-row">
<span class="template-name">{{ templateName || "未命名模板" }}</span>
<span v-if="isSelected" class="selected-tag">已选</span>
<span v-else-if="isSystem" class="system-tag">系统</span>
</div>
<div v-if="showActions" class="template-actions">
<Button
label="编辑"
size="small"
icon="pi pi-pencil"
@click.stop="emit('edit')"
/>
<Button
label="复制"
size="small"
outlined
icon="pi pi-copy"
@click.stop="emit('clone')"
/>
<Button
label="导出"
size="small"
outlined
icon="pi pi-download"
@click.stop="emit('export')"
/>
<Button
v-if="!isSystem"
label="删除"
size="small"
outlined
severity="danger"
icon="pi pi-trash"
@click.stop="emit('delete')"
/>
</div>
</div>
</div>
</template>
<style scoped>
.subtitle-preview-card {
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 0.75rem;
overflow: hidden;
background: rgba(15, 23, 42, 0.45);
transition:
border-color 0.2s,
box-shadow 0.2s;
cursor: pointer;
}
.subtitle-preview-card.is-static {
cursor: default;
}
.subtitle-preview-card.is-selected {
border-color: rgba(96, 165, 250, 0.85);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
}
.preview-shell {
padding: 0.5rem;
}
.preview-container {
position: relative;
aspect-ratio: 9 / 16;
border-radius: 0.5rem;
overflow: hidden;
background: #0f172a;
}
.preview-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
.title-subtitle-preview {
position: absolute;
left: 0;
right: 0;
z-index: 2;
display: flex;
flex-direction: column;
gap: 0.12rem;
}
.title-line-wrap {
display: flex;
}
.title-line {
width: 100%;
}
.normal-subtitle-preview {
position: absolute;
bottom: 8%;
z-index: 2;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.subtitle-line {
line-height: 1.2;
}
.subtitle-segment {
display: inline;
}
.template-info {
padding: 0.65rem 0.75rem 0.75rem;
border-top: 1px solid rgba(148, 163, 184, 0.15);
}
.template-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
min-height: 1.25rem;
}
.template-name {
font-size: 0.875rem;
font-weight: 500;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.system-tag,
.selected-tag {
flex-shrink: 0;
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
}
.system-tag {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.selected-tag {
background: rgba(16, 185, 129, 0.2);
color: #6ee7b7;
}
.template-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.template-actions :deep(.p-button) {
padding: 0.2rem 0.45rem;
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,239 @@
<script setup>
import { computed, ref, watch } from "vue";
import SubtitlePreviewCard from "./SubtitlePreviewCard.vue";
import {
cloneSubtitleTemplate,
downloadSubtitleTemplatesJson,
isSystemSubtitleTemplateId,
loadAllSubtitleTemplates,
loadCustomSubtitleTemplates,
parseImportedSubtitleTemplates,
saveCustomSubtitleTemplates,
} from "../../services/subtitleTemplateCatalog.js";
const visible = defineModel("visible", { type: Boolean, default: false });
const props = defineProps({
selectedId: { type: String, default: "" },
});
const emit = defineEmits(["select", "update:selectedId"]);
const templates = ref([]);
const loading = ref(false);
const feedback = ref({ severity: "", message: "" });
const fileInputRef = ref(null);
const selectedTemplateId = computed({
get: () => props.selectedId,
set: (id) => emit("update:selectedId", id),
});
async function refreshTemplates() {
loading.value = true;
feedback.value = { severity: "", message: "" };
try {
templates.value = await loadAllSubtitleTemplates();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
feedback.value = { severity: "error", message: `加载模板失败:${msg}` };
templates.value = [];
} finally {
loading.value = false;
}
}
watch(visible, (open) => {
if (open) refreshTemplates();
});
function showMsg(severity, message) {
feedback.value = { severity, message };
}
function selectTemplate(template) {
selectedTemplateId.value = template.id;
emit("select", template);
visible.value = false;
}
function onExportAll() {
downloadSubtitleTemplatesJson(templates.value, "subtitle-templates-all.json");
showMsg("success", "已导出全部模板");
}
function onExportOne(template) {
downloadSubtitleTemplatesJson([template], `${template.name || template.id}.json`);
showMsg("success", `已导出「${template.name}`);
}
function onImportClick() {
fileInputRef.value?.click();
}
async function onImportFile(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const imported = await parseImportedSubtitleTemplates(file);
const custom = loadCustomSubtitleTemplates();
const merged = [...custom];
for (const item of imported) {
const idx = merged.findIndex((t) => t.id === item.id);
if (idx >= 0) merged[idx] = item;
else merged.push(item);
}
saveCustomSubtitleTemplates(merged);
await refreshTemplates();
showMsg("success", `已导入 ${imported.length} 个模板`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showMsg("error", `导入失败:${msg}`);
}
}
function onNewTemplate() {
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", "已创建新模板,可在列表中继续编辑样式");
}
function onClone(template) {
const created = cloneSubtitleTemplate(template);
const custom = loadCustomSubtitleTemplates();
custom.unshift(created);
saveCustomSubtitleTemplates(custom);
refreshTemplates();
showMsg("success", `已复制为「${created.name}`);
}
function onDelete(template) {
if (isSystemSubtitleTemplateId(template.id)) {
showMsg("error", "系统模板不可删除");
return;
}
if (!window.confirm(`确定删除模板「${template.name}」?`)) return;
const custom = loadCustomSubtitleTemplates().filter((t) => t.id !== template.id);
saveCustomSubtitleTemplates(custom);
if (selectedTemplateId.value === template.id) {
selectedTemplateId.value = "template_system_11";
}
refreshTemplates();
showMsg("success", "模板已删除");
}
function onEdit(template) {
selectTemplate(template);
showMsg("success", "已选用该模板;完整样式编辑器后续版本提供");
}
</script>
<template>
<Dialog
v-model:visible="visible"
header="字幕设置"
modal
:style="{ width: '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="rounded-lg border border-blue-500/30 bg-gradient-to-r from-blue-900/20 to-indigo-900/20 p-4"
>
<div class="mb-4 flex items-center justify-between gap-2">
<h5 class="font-medium text-slate-100">字幕模板</h5>
<div class="flex flex-wrap gap-2">
<Button
label="导出全部"
size="small"
icon="pi pi-download"
:disabled="!templates.length"
@click="onExportAll"
/>
<Button
label="导入模板"
size="small"
outlined
icon="pi pi-upload"
@click="onImportClick"
/>
<Button
label="新建模板"
size="small"
icon="pi pi-plus"
@click="onNewTemplate"
/>
</div>
</div>
<p
v-if="feedback.message"
class="mb-3 text-sm"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div v-if="loading" class="py-12 text-center text-sm text-slate-400">
正在加载字幕模板
</div>
<div
v-else-if="templates.length"
class="grid grid-cols-2 gap-4 lg:grid-cols-4"
>
<SubtitlePreviewCard
v-for="tpl in templates"
:key="tpl.id"
:template-name="tpl.name"
:subtitle-style="tpl.config?.subtitleStyle"
:title-subtitle-config="tpl.config?.titleSubtitleConfig"
:keyword-groups-styles="tpl.config?.keywordGroupsStyles"
:keyword-render-mode="tpl.config?.keywordRenderMode"
:preview-config="tpl.config?.previewConfig"
:is-selected="selectedTemplateId === tpl.id"
:preview-scale="0.33"
:is-system="tpl.is_system === 1 || tpl.isSystem || isSystemSubtitleTemplateId(tpl.id)"
@select="selectTemplate(tpl)"
@edit="onEdit(tpl)"
@clone="onClone(tpl)"
@export="onExportOne(tpl)"
@delete="onDelete(tpl)"
/>
</div>
<div
v-else
class="rounded-lg border border-dashed border-slate-600 py-10 text-center text-sm text-slate-400"
>
<div class="mb-2 text-lg">📝</div>
<div>暂无字幕模板点击新建模板创建</div>
</div>
</div>
</div>
</div>
<input
ref="fileInputRef"
type="file"
accept="application/json,.json"
class="hidden"
@change="onImportFile"
/>
</Dialog>
</template>

View File

@@ -2,7 +2,7 @@
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { useWorkflowStore } from "../../stores/workflow.js";
import { SUBTITLE_TEMPLATES } from "../../config/subtitleTemplates.js";
import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue";
const workflow = useWorkflowStore();
const {
@@ -19,11 +19,6 @@ const {
const feedback = ref({ severity: "", message: "" });
const templateDialogVisible = ref(false);
const templateOptions = SUBTITLE_TEMPLATES.map((t) => ({
label: t.label,
value: t.id,
}));
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
const canGenerate = computed(
@@ -42,6 +37,11 @@ function showFeedback(result) {
};
}
function onSelectTemplate(template) {
workflow.subtitleTemplateId = template.id;
workflow.subtitleTemplate = template;
}
async function onGenerate() {
feedback.value = { severity: "", message: "" };
showFeedback(await workflow.generateSubtitleAndBgm());
@@ -82,6 +82,7 @@ async function onPreviewBgm() {
label="模板选择"
size="small"
outlined
icon="pi pi-palette"
:disabled="!autoSubtitle"
@click="templateDialogVisible = true"
/>
@@ -153,28 +154,10 @@ async function onPreviewBgm() {
@click="onGenerate"
/>
<Dialog
<SubtitleTemplateDialog
v-model:visible="templateDialogVisible"
header="字幕模板"
modal
:style="{ width: '22rem' }"
>
<div class="flex flex-col gap-2">
<div
v-for="opt in templateOptions"
:key="opt.value"
class="flex items-center gap-2"
>
<RadioButton
v-model="subtitleTemplateId"
:input-id="`tpl-${opt.value}`"
:value="opt.value"
/>
<label :for="`tpl-${opt.value}`" class="cursor-pointer text-sm">
{{ opt.label }}
</label>
</div>
</div>
</Dialog>
v-model:selected-id="subtitleTemplateId"
@select="onSelectTemplate"
/>
</DashboardCard>
</template>