1
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
4543
public/subtitle-templates/system-templates.json
Normal file
464
src/components/subtitle/SubtitlePreviewCard.vue
Normal 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>
|
||||||
239
src/components/subtitle/SubtitleTemplateDialog.vue
Normal 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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
import { SUBTITLE_TEMPLATES } from "../../config/subtitleTemplates.js";
|
import SubtitleTemplateDialog from "../subtitle/SubtitleTemplateDialog.vue";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
const {
|
const {
|
||||||
@@ -19,11 +19,6 @@ const {
|
|||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
const templateDialogVisible = ref(false);
|
const templateDialogVisible = ref(false);
|
||||||
|
|
||||||
const templateOptions = SUBTITLE_TEMPLATES.map((t) => ({
|
|
||||||
label: t.label,
|
|
||||||
value: t.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||||
|
|
||||||
const canGenerate = computed(
|
const canGenerate = computed(
|
||||||
@@ -42,6 +37,11 @@ function showFeedback(result) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSelectTemplate(template) {
|
||||||
|
workflow.subtitleTemplateId = template.id;
|
||||||
|
workflow.subtitleTemplate = template;
|
||||||
|
}
|
||||||
|
|
||||||
async function onGenerate() {
|
async function onGenerate() {
|
||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
showFeedback(await workflow.generateSubtitleAndBgm());
|
showFeedback(await workflow.generateSubtitleAndBgm());
|
||||||
@@ -82,6 +82,7 @@ async function onPreviewBgm() {
|
|||||||
label="模板选择"
|
label="模板选择"
|
||||||
size="small"
|
size="small"
|
||||||
outlined
|
outlined
|
||||||
|
icon="pi pi-palette"
|
||||||
:disabled="!autoSubtitle"
|
:disabled="!autoSubtitle"
|
||||||
@click="templateDialogVisible = true"
|
@click="templateDialogVisible = true"
|
||||||
/>
|
/>
|
||||||
@@ -153,28 +154,10 @@ async function onPreviewBgm() {
|
|||||||
@click="onGenerate"
|
@click="onGenerate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<SubtitleTemplateDialog
|
||||||
v-model:visible="templateDialogVisible"
|
v-model:visible="templateDialogVisible"
|
||||||
header="字幕模板"
|
v-model:selected-id="subtitleTemplateId"
|
||||||
modal
|
@select="onSelectTemplate"
|
||||||
: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>
|
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
buildFfmpegForceStyle,
|
buildFfmpegForceStyle,
|
||||||
getSubtitleTemplate,
|
getSubtitleTemplate,
|
||||||
} from "../config/subtitleTemplates.js";
|
} from "../config/subtitleTemplates.js";
|
||||||
|
import { getSubtitleTemplateById } from "./subtitleTemplateCatalog.js";
|
||||||
|
import { templateToFfmpegStyle } from "../utils/subtitlePreviewStyle.js";
|
||||||
import { importGeneratedVideo } from "./videoDb.js";
|
import { importGeneratedVideo } from "./videoDb.js";
|
||||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||||
@@ -60,7 +62,14 @@ export async function executeGenerateSubtitleAndBgm(store) {
|
|||||||
return { ok: false, message: "已启用背景音乐,请先点击「选择音乐」" };
|
return { ok: false, message: "已启用背景音乐,请先点击「选择音乐」" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tpl = getSubtitleTemplate(store.subtitleTemplateId || "default");
|
const templateId = store.subtitleTemplateId || "template_system_11";
|
||||||
|
let tpl = getSubtitleTemplate(templateId);
|
||||||
|
const fullTemplate =
|
||||||
|
store.subtitleTemplate?.id === templateId
|
||||||
|
? store.subtitleTemplate
|
||||||
|
: await getSubtitleTemplateById(templateId);
|
||||||
|
const fromSystem = fullTemplate ? templateToFfmpegStyle(fullTemplate) : null;
|
||||||
|
if (fromSystem) tpl = fromSystem;
|
||||||
const subtitleForceStyle = buildFfmpegForceStyle(tpl);
|
const subtitleForceStyle = buildFfmpegForceStyle(tpl);
|
||||||
|
|
||||||
store.subtitleBgmGenerating = true;
|
store.subtitleBgmGenerating = true;
|
||||||
|
|||||||
138
src/services/subtitleTemplateCatalog.js
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
const STORAGE_KEY = "aiclient_custom_subtitle_templates";
|
||||||
|
|
||||||
|
let systemTemplatesCache = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} SubtitleTemplateItem
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} [description]
|
||||||
|
* @property {Record<string, unknown>} config
|
||||||
|
* @property {boolean} [isSystem]
|
||||||
|
* @property {number} [is_system]
|
||||||
|
* @property {number} [createdAt]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<SubtitleTemplateItem[]>}
|
||||||
|
*/
|
||||||
|
export async function loadSystemSubtitleTemplates() {
|
||||||
|
if (systemTemplatesCache) return systemTemplatesCache;
|
||||||
|
const res = await fetch("/subtitle-templates/system-templates.json");
|
||||||
|
if (!res.ok) throw new Error("无法加载系统字幕模板");
|
||||||
|
const data = await res.json();
|
||||||
|
systemTemplatesCache = (data.subtitleTemplates || []).map((t) => ({
|
||||||
|
...t,
|
||||||
|
isSystem: true,
|
||||||
|
is_system: 1,
|
||||||
|
}));
|
||||||
|
return systemTemplatesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {SubtitleTemplateItem[]}
|
||||||
|
*/
|
||||||
|
export function loadCustomSubtitleTemplates() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {SubtitleTemplateItem[]} templates
|
||||||
|
*/
|
||||||
|
export function saveCustomSubtitleTemplates(templates) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<SubtitleTemplateItem[]>}
|
||||||
|
*/
|
||||||
|
export async function loadAllSubtitleTemplates() {
|
||||||
|
const system = await loadSystemSubtitleTemplates();
|
||||||
|
const custom = loadCustomSubtitleTemplates();
|
||||||
|
return [...system, ...custom];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<SubtitleTemplateItem | null>}
|
||||||
|
*/
|
||||||
|
export async function getSubtitleTemplateById(id) {
|
||||||
|
const all = await loadAllSubtitleTemplates();
|
||||||
|
return all.find((t) => t.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export function isSystemSubtitleTemplateId(id) {
|
||||||
|
return (
|
||||||
|
String(id || "").startsWith("template_system") ||
|
||||||
|
String(id || "").startsWith("system-")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {SubtitleTemplateItem} template
|
||||||
|
*/
|
||||||
|
export function cloneSubtitleTemplate(template) {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
...JSON.parse(JSON.stringify(template)),
|
||||||
|
id: `custom_${now}`,
|
||||||
|
name: `${template.name} 副本`,
|
||||||
|
isSystem: false,
|
||||||
|
is_system: 0,
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {SubtitleTemplateItem[]} templates
|
||||||
|
*/
|
||||||
|
export function downloadSubtitleTemplatesJson(templates, filename = "subtitle-templates.json") {
|
||||||
|
const blob = new Blob(
|
||||||
|
[
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
subtitleTemplates: templates,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ type: "application/json" },
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {File} file
|
||||||
|
* @returns {Promise<SubtitleTemplateItem[]>}
|
||||||
|
*/
|
||||||
|
export async function parseImportedSubtitleTemplates(file) {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const list = data.subtitleTemplates || data.templates || data;
|
||||||
|
if (!Array.isArray(list)) throw new Error("无效的模板文件格式");
|
||||||
|
return list.map((t, i) => ({
|
||||||
|
...t,
|
||||||
|
id: t.id || `imported_${Date.now()}_${i}`,
|
||||||
|
isSystem: false,
|
||||||
|
is_system: 0,
|
||||||
|
createdAt: t.createdAt || Date.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -262,10 +262,11 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
bgmVolume: 30,
|
bgmVolume: 30,
|
||||||
|
|
||||||
subtitleTemplate: null,
|
/** 字幕样式模板 id(系统模板见 subtitleTemplateCatalog) */
|
||||||
|
subtitleTemplateId: "template_system_11",
|
||||||
|
|
||||||
/** 字幕样式模板 id(见 subtitleTemplates.js) */
|
/** 当前选中的完整字幕模板(模板弹窗选用后写入) */
|
||||||
subtitleTemplateId: "default",
|
subtitleTemplate: null,
|
||||||
|
|
||||||
subtitleBgmGenerating: false,
|
subtitleBgmGenerating: false,
|
||||||
|
|
||||||
@@ -358,10 +359,12 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
|
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
|
||||||
|
|
||||||
subtitleTemplateLabel: (state) => {
|
subtitleTemplateLabel: (state) => {
|
||||||
const tpl = SUBTITLE_TEMPLATES.find(
|
if (state.subtitleTemplate?.name) return state.subtitleTemplate.name;
|
||||||
(t) => t.id === (state.subtitleTemplateId || "default"),
|
const legacy = SUBTITLE_TEMPLATES.find(
|
||||||
|
(t) => t.id === (state.subtitleTemplateId || "template_system_11"),
|
||||||
);
|
);
|
||||||
return tpl?.label || "未选择";
|
if (legacy) return legacy.label;
|
||||||
|
return state.subtitleTemplateId || "未选择";
|
||||||
},
|
},
|
||||||
|
|
||||||
coverTemplateLabel: (state) => {
|
coverTemplateLabel: (state) => {
|
||||||
|
|||||||
266
src/utils/subtitlePreviewStyle.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/** 字幕模板预览样式(对齐 Electron SubtitlePreviewCard) */
|
||||||
|
|
||||||
|
const ACCENT_KEYWORDS = [
|
||||||
|
"自己",
|
||||||
|
"节奏",
|
||||||
|
"答案",
|
||||||
|
"努力",
|
||||||
|
"温柔",
|
||||||
|
"快乐",
|
||||||
|
"热爱",
|
||||||
|
"从容",
|
||||||
|
"美好",
|
||||||
|
"重点",
|
||||||
|
"主动",
|
||||||
|
"执行",
|
||||||
|
"光",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined} hex
|
||||||
|
* @param {number | undefined} opacity
|
||||||
|
*/
|
||||||
|
export function colorWithOpacity(hex, opacity) {
|
||||||
|
if (!hex || hex === "transparent" || opacity === undefined || opacity <= 0) {
|
||||||
|
return "transparent";
|
||||||
|
}
|
||||||
|
const raw = hex.replace("#", "");
|
||||||
|
const base = raw.length === 8 ? raw.slice(0, 6) : raw;
|
||||||
|
if (base.length !== 6) return "transparent";
|
||||||
|
const r = parseInt(base.slice(0, 2), 16);
|
||||||
|
const g = parseInt(base.slice(2, 4), 16);
|
||||||
|
const b = parseInt(base.slice(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} color
|
||||||
|
* @param {number} alpha
|
||||||
|
*/
|
||||||
|
function shadowColor(color, alpha = 1) {
|
||||||
|
if (color === "transparent") return `rgba(0, 0, 0, ${alpha})`;
|
||||||
|
const raw = color.replace("#", "");
|
||||||
|
const base = raw.length === 8 ? raw.slice(0, 6) : raw;
|
||||||
|
if (base.length !== 6) return `rgba(0, 0, 0, ${alpha})`;
|
||||||
|
const r = parseInt(base.slice(0, 2), 16);
|
||||||
|
const g = parseInt(base.slice(2, 4), 16);
|
||||||
|
const b = parseInt(base.slice(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown> | null | undefined} style
|
||||||
|
* @param {{ previewScale?: number, minFontSize?: number, scaleMultiplier?: number, maxStrokeWidth?: number }} opts
|
||||||
|
*/
|
||||||
|
export function buildBaseTextStyle(style, opts = {}) {
|
||||||
|
const scale = opts.previewScale ?? 0.33;
|
||||||
|
const minFontSize = opts.minFontSize ?? 12;
|
||||||
|
const scaleMultiplier = opts.scaleMultiplier ?? 1;
|
||||||
|
const fontSize = Math.max(
|
||||||
|
minFontSize,
|
||||||
|
(Number(style?.fontSize) || 48) * scale * scaleMultiplier,
|
||||||
|
);
|
||||||
|
const outlineWidth = Math.max(0, (Number(style?.outlineWidth) || 0) * scale);
|
||||||
|
const shadowOffset = Math.max(0, (Number(style?.shadowOffset) || 0) * scale);
|
||||||
|
const shadowBlur = Math.max(0, (Number(style?.shadowBlur) || 0) * scale);
|
||||||
|
const bg = colorWithOpacity(
|
||||||
|
String(style?.backgroundColor || "transparent"),
|
||||||
|
Number(style?.backgroundOpacity ?? 0),
|
||||||
|
);
|
||||||
|
const maxStrokeWidth = opts.maxStrokeWidth ?? 1.6;
|
||||||
|
const stroke =
|
||||||
|
outlineWidth > 0
|
||||||
|
? Math.max(0.5, Math.min(maxStrokeWidth, outlineWidth * 0.55))
|
||||||
|
: 0;
|
||||||
|
const shadows = [];
|
||||||
|
if (stroke > 0) {
|
||||||
|
const c = shadowColor(String(style?.outlineColor || "#000000"), 0.78);
|
||||||
|
const v = Math.max(0.65, Math.min(0.95, stroke));
|
||||||
|
shadows.push(
|
||||||
|
`${v}px 0 0 ${c}`,
|
||||||
|
`-${v}px 0 0 ${c}`,
|
||||||
|
`0 ${v}px 0 ${c}`,
|
||||||
|
`0 -${v}px 0 ${c}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (shadowOffset > 0) {
|
||||||
|
shadows.push(
|
||||||
|
`${shadowOffset}px ${shadowOffset}px ${Math.max(1, shadowBlur)}px ${style?.shadowColor || "#000000"}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
shadows.push("0 1px 1px rgba(0, 0, 0, 0.18)");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fontFamily: style?.fontName ? `'${style.fontName}'` : "Microsoft YaHei UI",
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
color: String(style?.fontColor || "#FFFFFF"),
|
||||||
|
backgroundColor: bg,
|
||||||
|
padding: bg !== "transparent" ? "0.18em 0.44em" : "0",
|
||||||
|
borderRadius: bg !== "transparent" ? "0.28em" : "0",
|
||||||
|
boxDecorationBreak: "clone",
|
||||||
|
WebkitBoxDecorationBreak: "clone",
|
||||||
|
WebkitTextStroke: "0 transparent",
|
||||||
|
paintOrder: "stroke fill",
|
||||||
|
textShadow: shadows.length > 0 ? shadows.join(", ") : "none",
|
||||||
|
WebkitFontSmoothing: "antialiased",
|
||||||
|
textRendering: "optimizeLegibility",
|
||||||
|
transform: "translateZ(0)",
|
||||||
|
filter: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
*/
|
||||||
|
function textWeight(text) {
|
||||||
|
return Array.from(text || "").reduce((sum, ch) => {
|
||||||
|
if (/\s/.test(ch)) return sum + 0.28;
|
||||||
|
if (/[A-Za-z0-9]/.test(ch)) return sum + 0.58;
|
||||||
|
return sum + 1;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{ text: string }>} segments
|
||||||
|
* @param {boolean} hasTitle
|
||||||
|
* @param {boolean} inlineEmphasis
|
||||||
|
*/
|
||||||
|
export function lineScaleFactor(segments, hasTitle, inlineEmphasis) {
|
||||||
|
const weight = textWeight(segments.map((s) => s.text).join(""));
|
||||||
|
if (hasTitle) {
|
||||||
|
if (weight > 16) return 0.5;
|
||||||
|
if (weight > 14) return 0.58;
|
||||||
|
if (weight > 12) return 0.68;
|
||||||
|
if (weight > 10) return 0.78;
|
||||||
|
}
|
||||||
|
if (weight > 18) return 0.6;
|
||||||
|
if (weight > 16) return 0.7;
|
||||||
|
if (weight > 14) return 0.8;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} style
|
||||||
|
* @param {number} index
|
||||||
|
* @param {number} total
|
||||||
|
* @param {number} previewScale
|
||||||
|
*/
|
||||||
|
export function buildTitleLineStyle(style, index, total, previewScale = 0.33) {
|
||||||
|
const lineSpacing = (Number(style?.lineSpacingPercent) || 4.5) * previewScale;
|
||||||
|
const hasBg =
|
||||||
|
style?.backgroundColor &&
|
||||||
|
style.backgroundColor !== "transparent" &&
|
||||||
|
(Number(style?.backgroundOpacity) ?? 0) > 0;
|
||||||
|
const bg = colorWithOpacity(
|
||||||
|
String(style?.backgroundColor || "transparent"),
|
||||||
|
Number(style?.backgroundOpacity ?? 0),
|
||||||
|
);
|
||||||
|
const outline = Math.max(0, (Number(style?.outlineWidth) || 0) * previewScale * 1.7);
|
||||||
|
const shadow = Math.max(0, (Number(style?.shadowOffset) || 0) * previewScale * 1.9);
|
||||||
|
const padY = Math.max(4, Math.ceil((Number(style?.fontSize) || 56) * previewScale * 0.08));
|
||||||
|
const padX = Math.ceil(outline + shadow + padY);
|
||||||
|
const inner = Math.ceil(Math.max(outline * 0.85, shadow * 0.65, padY * 0.7));
|
||||||
|
return {
|
||||||
|
...buildBaseTextStyle(style, {
|
||||||
|
previewScale,
|
||||||
|
minFontSize: 12,
|
||||||
|
scaleMultiplier: 0.62,
|
||||||
|
maxStrokeWidth: 0.82,
|
||||||
|
}),
|
||||||
|
fontWeight: String(style?.fontWeight || "900"),
|
||||||
|
marginBottom: index < total - 1 ? `${lineSpacing}px` : "0",
|
||||||
|
lineHeight: "1.12",
|
||||||
|
letterSpacing: "-0.045em",
|
||||||
|
maxWidth: "100%",
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
wordBreak: "keep-all",
|
||||||
|
overflowWrap: "normal",
|
||||||
|
overflow: "hidden",
|
||||||
|
textAlign: "center",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
backgroundColor: hasBg ? bg : "transparent",
|
||||||
|
padding: hasBg
|
||||||
|
? `calc(0.18em + ${Math.max(1, Math.ceil(inner * 0.45))}px) calc(0.22em + ${Math.max(2, Math.ceil(padX * 0.18))}px) calc(0.22em + ${Math.max(1, Math.ceil(inner * 0.45))}px)`
|
||||||
|
: "0",
|
||||||
|
borderRadius: hasBg ? "0.18em" : "0",
|
||||||
|
boxShadow: "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} line
|
||||||
|
* @param {boolean} inlineEmphasis
|
||||||
|
*/
|
||||||
|
export function injectAccentMarkers(line, inlineEmphasis) {
|
||||||
|
if (!line || line.includes("[") || line.includes("]")) return line;
|
||||||
|
const keyword = ACCENT_KEYWORDS.find((k) => line.includes(k));
|
||||||
|
if (keyword) return line.replace(keyword, `[${keyword}]`);
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.length < 4) return trimmed;
|
||||||
|
const len = trimmed.length >= 8 ? 3 : 2;
|
||||||
|
const start = Math.max(1, Math.floor((trimmed.length - len) / 2));
|
||||||
|
return `${trimmed.slice(0, start)}[${trimmed.slice(start, start + len)}]${trimmed.slice(start + len)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} line
|
||||||
|
* @param {boolean} inlineEmphasis
|
||||||
|
* @returns {Array<{ text: string, accent: boolean }>}
|
||||||
|
*/
|
||||||
|
export function parseSubtitleLine(line, inlineEmphasis) {
|
||||||
|
if (!inlineEmphasis) {
|
||||||
|
return [{ text: line.replace(/\[|\]/g, ""), accent: false }];
|
||||||
|
}
|
||||||
|
const segments = [];
|
||||||
|
const re = /(\[[^\]]+\])/g;
|
||||||
|
let last = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(line)) !== null) {
|
||||||
|
const before = line.slice(last, match.index);
|
||||||
|
if (before) segments.push({ text: before, accent: false });
|
||||||
|
const inner = match[0].slice(1, -1);
|
||||||
|
if (inner) segments.push({ text: inner, accent: true });
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
const tail = line.slice(last);
|
||||||
|
if (tail) segments.push({ text: tail, accent: false });
|
||||||
|
return segments.length > 0
|
||||||
|
? segments
|
||||||
|
: [{ text: line.replace(/\[|\]/g, ""), accent: false }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
*/
|
||||||
|
export function resolveSubtitlePreviewImage(path) {
|
||||||
|
if (!path) return "";
|
||||||
|
if (/^https?:\/\//i.test(path) || path.startsWith("data:")) return path;
|
||||||
|
const normalized = path.replace(/\\/g, "/");
|
||||||
|
if (normalized.includes("subtitle-preview-")) {
|
||||||
|
const name = normalized.split("/").pop();
|
||||||
|
return `/subtitle-templates/previews/${name}`;
|
||||||
|
}
|
||||||
|
if (normalized.startsWith("extra/common/cover-templates/")) {
|
||||||
|
const name = normalized.split("/").pop();
|
||||||
|
return `/subtitle-templates/previews/${name}`;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} template
|
||||||
|
*/
|
||||||
|
export function templateToFfmpegStyle(template) {
|
||||||
|
const style = template?.config?.subtitleStyle;
|
||||||
|
if (!style) return null;
|
||||||
|
return {
|
||||||
|
fontName: String(style.fontName || "Microsoft YaHei"),
|
||||||
|
fontSize: Number(style.fontSize) || 24,
|
||||||
|
primaryColor: String(style.fontColor || "#FFFFFF"),
|
||||||
|
outlineColor: String(style.outlineColor || "#000000"),
|
||||||
|
outline: Number(style.outlineWidth) || 2,
|
||||||
|
marginV: 40,
|
||||||
|
};
|
||||||
|
}
|
||||||