This commit is contained in:
949036910@qq.com
2026-06-01 22:29:34 +08:00
parent e983de38c0
commit 80366499fe
9 changed files with 299 additions and 13 deletions

View File

@@ -1,12 +1,34 @@
<script setup>
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import {
SUBTITLE_FONT_OPTIONS,
buildSubtitlePreviewBoxStyle,
} from "../../config/subtitleStylePresets.js";
import { useWorkflowStore } from "../../stores/workflow.js";
import {
BUILTIN_KEYWORD_GROUP_NAMES,
syncWorkflowKeywordsIntoConfig,
workflowStoreToKeywordFields,
} from "../../utils/workflowKeywordGroups.js";
const config = defineModel("config", { type: Object, required: true });
const workflow = useWorkflowStore();
const { keywordsFocus, keywordsDescribe, keywordsAction, keywordsEmotion } =
storeToRefs(workflow);
watch(
[keywordsFocus, keywordsDescribe, keywordsAction, keywordsEmotion],
() => {
syncWorkflowKeywordsIntoConfig(
config.value,
workflowStoreToKeywordFields(workflow),
);
},
{ immediate: true },
);
const importInputRef = ref(null);
const newGroupName = ref("");
const editingGroupIndex = ref(-1);
@@ -17,7 +39,7 @@ const renderModeOptions = [
{ label: "浮动特效", value: "floating" },
];
const builtinGroupNames = new Set(["行动词", "情感词", "描述词", "重点词/成语词"]);
const builtinGroupNames = BUILTIN_KEYWORD_GROUP_NAMES;
const enableKeywordEffects = computed({
get: () => config.value.enableKeywordEffects !== false,
@@ -215,6 +237,9 @@ async function importGroups(event) {
<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">关键词分组管理</h6>
<p class="mb-3 text-xs text-amber-200/90">
重点词/描述词/行动词/情感词的内容来自步骤 04标题标签关键词此处仅编辑各分组样式与特效修改关键词请回到步骤 04
</p>
<div class="mb-4 rounded bg-slate-800/60 p-3">
<div class="flex gap-2">
@@ -284,12 +309,21 @@ async function importGroups(event) {
color: '#fff',
border: 'none',
}"
class="cursor-pointer"
@click="removeKeyword(group, keyword)"
:class="
builtinGroupNames.has(group.groupName)
? 'cursor-default'
: 'cursor-pointer'
"
@click="
!builtinGroupNames.has(group.groupName) &&
removeKeyword(group, keyword)
"
/>
</div>
<div v-else class="text-xs text-slate-500">暂无关键词</div>
<div class="mt-2 flex gap-2">
<div v-else class="text-xs text-slate-500">
{{ builtinGroupNames.has(group.groupName) ? "请先在步骤 04 生成关键词" : "暂无关键词" }}
</div>
<div v-if="!builtinGroupNames.has(group.groupName)" class="mt-2 flex gap-2">
<InputText
v-model="keywordInputs[i]"
size="small"

View File

@@ -14,6 +14,8 @@ const props = defineProps({
subtitleStyle: { type: Object, default: null },
titleSubtitleConfig: { type: Object, default: null },
keywordGroupsStyles: { type: Array, default: () => [] },
/** 步骤 04 关键词,用于预览高亮 */
accentKeywords: { type: Array, default: () => [] },
keywordRenderMode: { type: String, default: "inline-emphasis" },
previewConfig: { type: Object, default: null },
isSelected: { type: Boolean, default: false },
@@ -95,6 +97,10 @@ const keywordAccentStyle = computed(() => {
);
});
const accentList = computed(() =>
(props.accentKeywords || []).filter((k) => String(k || "").trim()),
);
const normalStyle = computed(() =>
buildBaseTextStyle(props.subtitleStyle, {
previewScale: props.previewScale,
@@ -135,7 +141,7 @@ const subtitleLines = computed(() => {
? fallbackInline
: fallbackClassic;
const lines = inlineEmphasis.value
? base.map((line) => injectAccentMarkers(line, true))
? base.map((line) => injectAccentMarkers(line, true, accentList.value))
: base;
return lines.map((line) => parseSubtitleLine(line, inlineEmphasis.value));
});

View File

@@ -16,8 +16,15 @@ import {
} from "../../services/subtitleTemplateCatalog.js";
import {
createEditDraft,
deepClone,
syncTitleLinesFromReference,
} from "../../utils/subtitleTemplateDraft.js";
import {
collectWorkflowAccentKeywords,
syncWorkflowKeywordsIntoConfig,
syncWorkflowKeywordsIntoDraft,
workflowStoreToKeywordFields,
} from "../../utils/workflowKeywordGroups.js";
const visible = defineModel("visible", { type: Boolean, default: false });
@@ -30,6 +37,8 @@ const emit = defineEmits(["select", "update:selectedId"]);
const workflow = useWorkflowStore();
const { titleGenerated } = storeToRefs(workflow);
const step4AccentKeywords = computed(() => collectWorkflowAccentKeywords(workflow));
const templates = ref([]);
const loading = ref(false);
const feedback = ref({ severity: "", message: "" });
@@ -75,8 +84,13 @@ function showMsg(severity, message) {
}
function selectTemplate(template) {
selectedTemplateId.value = template.id;
emit("select", template);
const picked = deepClone(template);
syncWorkflowKeywordsIntoConfig(
picked.config,
workflowStoreToKeywordFields(workflow),
);
selectedTemplateId.value = picked.id;
emit("select", picked);
visible.value = false;
}
@@ -150,6 +164,10 @@ function onDelete(template) {
function enterEditMode(template, isNew = false) {
editingDraft.value = createEditDraft(template);
syncWorkflowKeywordsIntoDraft(
editingDraft.value,
workflowStoreToKeywordFields(workflow),
);
if (titleGenerated.value?.trim()) {
syncTitleLinesFromReference(editingDraft.value, titleGenerated.value);
}
@@ -270,6 +288,7 @@ function confirmSaveTemplate() {
:subtitle-style="tpl.config?.subtitleStyle"
:title-subtitle-config="tpl.config?.titleSubtitleConfig"
:keyword-groups-styles="tpl.config?.keywordGroupsStyles"
:accent-keywords="step4AccentKeywords"
:keyword-render-mode="tpl.config?.keywordRenderMode"
:preview-config="tpl.config?.previewConfig"
:is-selected="selectedTemplateId === tpl.id"

View File

@@ -1,5 +1,7 @@
<script setup>
import { computed } from "vue";
import { useWorkflowStore } from "../../stores/workflow.js";
import { collectWorkflowAccentKeywords } from "../../utils/workflowKeywordGroups.js";
import SubtitlePreviewCard from "./SubtitlePreviewCard.vue";
import SubtitleStyleSelector from "./SubtitleStyleSelector.vue";
import TitleSubtitleSettings from "./TitleSubtitleSettings.vue";
@@ -7,6 +9,9 @@ import KeywordEffectsSettings from "./KeywordEffectsSettings.vue";
const draft = defineModel("draft", { type: Object, required: true });
const workflow = useWorkflowStore();
const step4AccentKeywords = computed(() => collectWorkflowAccentKeywords(workflow));
defineProps({
referenceTitle: { type: String, default: "" },
});
@@ -39,6 +44,7 @@ const config = computed({
:subtitle-style="config.subtitleStyle"
:title-subtitle-config="config.titleSubtitleConfig"
:keyword-groups-styles="config.keywordGroupsStyles"
:accent-keywords="step4AccentKeywords"
:keyword-render-mode="config.keywordRenderMode"
:preview-config="config.previewConfig"
:preview-scale="0.42"

View File

@@ -101,7 +101,7 @@ async function onOpenVideoInExplorer() {
用步骤 01 文案替换识别文本保留语音时间轴
</p>
<p class="dashboard-muted mt-1 pl-6 text-xs">
步骤 04 生成的关键词将以高亮 drawtext 叠加pulse 缩放特效
步骤 04 关键词按模板分组样式烧录重点词/描述词/行动词/情感词
</p>
<div class="mt-2 flex flex-wrap items-center gap-2">

View File

@@ -5,6 +5,7 @@ import { ensureAppConfigReady } from "../config/videoPipeline.js";
import { getSubtitleTemplate } from "../config/subtitleTemplates.js";
import { getSubtitleTemplateById } from "./subtitleTemplateCatalog.js";
import { buildSubtitleBurnStyle } from "../utils/subtitlePreviewStyle.js";
import { buildKeywordGroupsForBurn } from "../utils/workflowKeywordGroups.js";
import { localAudioToPlayableUrl } from "./localAudio.js";
import { localVideoToPlayableUrl } from "./localVideo.js";
@@ -82,6 +83,10 @@ export async function executeGenerateSubtitleAndBgm(store) {
};
}
const enableKeywordEffects =
fullTemplate?.config?.enableKeywordEffects !== false;
const keywordGroups = buildKeywordGroupsForBurn(fullTemplate, store);
store.subtitleBgmGenerating = true;
clearSubtitlePreview(store);
@@ -94,7 +99,8 @@ export async function executeGenerateSubtitleAndBgm(store) {
smartSubtitle: store.smartSubtitle,
scriptContent: store.scriptContent || "",
subtitleStyle,
enableKeywordEffects: true,
enableKeywordEffects,
keywordGroups,
keywordsFocus: store.keywordsFocus || "",
keywordsDescribe: store.keywordsDescribe || "",
keywordsAction: store.keywordsAction || "",

View File

@@ -195,9 +195,13 @@ export function buildTitleLineStyle(style, index, total, previewScale = 0.33) {
* @param {string} line
* @param {boolean} inlineEmphasis
*/
export function injectAccentMarkers(line, inlineEmphasis) {
export function injectAccentMarkers(line, inlineEmphasis, accentKeywords = []) {
if (!line || line.includes("[") || line.includes("]")) return line;
const keyword = ACCENT_KEYWORDS.find((k) => line.includes(k));
const pool =
Array.isArray(accentKeywords) && accentKeywords.length
? accentKeywords
: ACCENT_KEYWORDS;
const keyword = pool.find((k) => k && line.includes(k));
if (keyword) return line.replace(keyword, `[${keyword}]`);
const trimmed = line.trim();
if (trimmed.length < 4) return trimmed;

View File

@@ -1,3 +1,5 @@
import { ensureBuiltinKeywordGroups } from "./workflowKeywordGroups.js";
/**
* @template T
* @param {T} value
@@ -52,6 +54,7 @@ export function createEditDraft(template) {
if (!draft.config.keywordRenderMode) {
draft.config.keywordRenderMode = "inline-emphasis";
}
ensureBuiltinKeywordGroups(draft.config);
return draft;
}

View File

@@ -0,0 +1,208 @@
/**
* 步骤 04 关键词 ↔ 字幕模板 keywordGroupsStyles对齐 Electron 四分组)
*/
/** @template T @param {T} value @returns {T} */
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
/** @typedef {{ id: string, groupName: string, storeField: string }} Step4GroupDef */
/** @type {Step4GroupDef[]} */
export const STEP4_KEYWORD_GROUP_DEFS = [
{ id: "focus", groupName: "重点词/成语词", storeField: "keywordsFocus" },
{ id: "describe", groupName: "描述词", storeField: "keywordsDescribe" },
{ id: "action", groupName: "行动词", storeField: "keywordsAction" },
{ id: "emotion", groupName: "情感词", storeField: "keywordsEmotion" },
];
export const BUILTIN_KEYWORD_GROUP_NAMES = new Set(
STEP4_KEYWORD_GROUP_DEFS.map((d) => d.groupName),
);
const DEFAULT_BUILTIN_STYLES = {
"重点词/成语词": {
fontColor: "#FFD700",
outlineColor: "#FF8C00",
outlineWidth: 4,
fontSize: 28,
},
描述词: {
fontColor: "#5AC8FA",
outlineColor: "#007AFF",
outlineWidth: 3,
fontSize: 26,
},
行动词: {
fontColor: "#34C759",
outlineColor: "#248A3D",
outlineWidth: 3,
fontSize: 26,
},
情感词: {
fontColor: "#FF6B6B",
outlineColor: "#C92A2A",
outlineWidth: 3,
fontSize: 26,
},
};
/**
* @param {string} text
* @returns {string[]}
*/
export function parseKeywordLines(text) {
return String(text || "")
.split(/[\n,,、]+/)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem | Record<string, unknown>} templateOrConfig
* @returns {Record<string, unknown>}
*/
function getConfig(templateOrConfig) {
if (!templateOrConfig) return {};
if (templateOrConfig.config) return templateOrConfig.config;
return templateOrConfig;
}
/**
* @param {Step4GroupDef} def
* @param {Record<string, unknown>} [subtitleStyle]
*/
function createDefaultBuiltinGroup(def, subtitleStyle) {
const base = DEFAULT_BUILTIN_STYLES[def.groupName] || DEFAULT_BUILTIN_STYLES["重点词/成语词"];
return {
groupName: def.groupName,
description: `步骤04 · ${def.groupName}`,
keywords: [],
color: base.fontColor,
effectId: "pulse-scale",
effectDuration: null,
styleOverride: {
fontName: subtitleStyle?.fontName || "Microsoft YaHei",
fontSize: base.fontSize,
fontColor: base.fontColor,
outlineColor: base.outlineColor,
outlineWidth: base.outlineWidth,
},
soundEffectId: null,
soundEffectConfig: null,
};
}
/**
* @param {Record<string, unknown>} config
*/
export function ensureBuiltinKeywordGroups(config) {
const cfg = config || {};
if (!Array.isArray(cfg.keywordGroupsStyles)) {
cfg.keywordGroupsStyles = [];
}
const list = cfg.keywordGroupsStyles;
const subtitleStyle = cfg.subtitleStyle || {};
for (const def of STEP4_KEYWORD_GROUP_DEFS) {
let group = list.find((g) => g?.groupName === def.groupName);
if (!group) {
group = createDefaultBuiltinGroup(def, subtitleStyle);
list.push(group);
}
if (!group.styleOverride) {
group.styleOverride = createDefaultBuiltinGroup(def, subtitleStyle).styleOverride;
}
if (!group.color && group.styleOverride?.fontColor) {
group.color = group.styleOverride.fontColor;
}
}
return list;
}
/**
* 将步骤 04 关键词写入模板分组(仅更新 keywords保留样式
* @param {Record<string, unknown>} config
* @param {Record<string, string>} storeFields keywordsFocus 等
*/
export function syncWorkflowKeywordsIntoConfig(config, storeFields) {
const groups = ensureBuiltinKeywordGroups(config);
for (const def of STEP4_KEYWORD_GROUP_DEFS) {
const group = groups.find((g) => g.groupName === def.groupName);
if (!group) continue;
const lines = parseKeywordLines(storeFields[def.storeField]);
group.keywords = lines;
}
return groups;
}
/**
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem} draft
* @param {Record<string, string>} storeFields
*/
export function syncWorkflowKeywordsIntoDraft(draft, storeFields) {
if (!draft?.config) return;
syncWorkflowKeywordsIntoConfig(draft.config, storeFields);
}
/**
* @param {Record<string, unknown>} store pinia workflow state
*/
export function workflowStoreToKeywordFields(store) {
return {
keywordsFocus: store.keywordsFocus || "",
keywordsDescribe: store.keywordsDescribe || "",
keywordsAction: store.keywordsAction || "",
keywordsEmotion: store.keywordsEmotion || "",
};
}
/**
* 预览用:合并四组全部关键词
* @param {Record<string, unknown>} store
* @returns {string[]}
*/
export function collectWorkflowAccentKeywords(store) {
const fields = workflowStoreToKeywordFields(store);
const all = [];
for (const def of STEP4_KEYWORD_GROUP_DEFS) {
all.push(...parseKeywordLines(fields[def.storeField]));
}
return [...new Set(all)];
}
/**
* 生成字幕时步骤04 词 + 模板分组样式
* @param {import('../services/subtitleTemplateCatalog.js').SubtitleTemplateItem | null} template
* @param {Record<string, unknown>} store
* @returns {Array<Record<string, unknown>>}
*/
export function buildKeywordGroupsForBurn(template, store) {
const config = getConfig(template);
if (config.enableKeywordEffects === false) return [];
const fields = workflowStoreToKeywordFields(store);
const groups = syncWorkflowKeywordsIntoConfig(deepClone(config), fields);
return groups
.filter((g) => Array.isArray(g.keywords) && g.keywords.length > 0)
.map((g) => ({
id: g.id || g.groupName,
groupName: g.groupName,
name: g.groupName,
effectId: g.effectId || "pulse-scale",
styleOverride: g.styleOverride || {},
styleConfig: g.styleOverride || {},
keywords: [...g.keywords],
}));
}
/**
* @param {Array<Record<string, unknown>>} keywordGroupsStyles
* @param {string} groupName
*/
export function findKeywordGroupStyle(keywordGroupsStyles, groupName) {
const list = Array.isArray(keywordGroupsStyles) ? keywordGroupsStyles : [];
return list.find((g) => g?.groupName === groupName) || null;
}