From d637088a341e209598c53a35b12d4d1fca47218d Mon Sep 17 00:00:00 2001 From: "949036910@qq.com" <> Date: Mon, 1 Jun 2026 22:47:04 +0800 Subject: [PATCH] 11 --- scripts/nodejs/subtitle_bgm_generate.js | 115 ++++++++++++++++++-- scripts/nodejs/subtitle_keyword_drawtext.js | 19 +++- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/scripts/nodejs/subtitle_bgm_generate.js b/scripts/nodejs/subtitle_bgm_generate.js index 655f28c..7c92fad 100644 --- a/scripts/nodejs/subtitle_bgm_generate.js +++ b/scripts/nodejs/subtitle_bgm_generate.js @@ -18,6 +18,7 @@ const { normalizeDisplayText, stripKeywordsFromDisplay, filterMatchesForRecord, + isInlineKeywordRenderMode, } = require("./subtitle_keyword_drawtext.js"); const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-subtitle-bgm"); @@ -474,7 +475,81 @@ function resolveAssLayout(position, videoHeight) { return { alignment: 2, marginL: 120, marginR: 120, marginV }; } -function buildAssContent(records, styleInput, videoWidth, videoHeight, keywordMatches = []) { +function wrapAssKeywordInline(text, style, baseFontSize) { + const primary = hexToAssColor(style.fontColor); + const outline = hexToAssColor(style.outlineColor); + const bord = Math.max(0, Math.round(Number(style.outlineWidth) || 3)); + const fs = + style.fontSize > 0 + ? Math.round(Number(style.fontSize)) + : Math.round(baseFontSize * 1.12); + const inner = assEscapeText(text); + return `{\\c${primary}\\3c${outline}\\bord${bord}\\fs${fs}}${inner}{\\r}`; +} + +function buildInlineAssLine(lineText, lineMatches, baseFontSize) { + if (!lineMatches.length) return assEscapeText(lineText); + const sorted = [...lineMatches].sort( + (a, b) => a.occurrenceStart - b.occurrenceStart, + ); + let out = ""; + let pos = 0; + for (const m of sorted) { + const start = Math.max(0, m.occurrenceStart); + const end = Math.min(lineText.length, m.occurrenceEnd); + if (end <= start || start < pos) continue; + if (start > pos) { + out += assEscapeText(lineText.slice(pos, start)); + } + out += wrapAssKeywordInline( + lineText.slice(start, end), + m.style || {}, + baseFontSize, + ); + pos = end; + } + if (pos < lineText.length) out += assEscapeText(lineText.slice(pos)); + return out; +} + +/** + * 在 ASS 正文内联高亮关键词(保持换行与字序,对齐 Electron inline-emphasis) + */ +function buildInlineAssTextForRecord( + displayText, + segMatches, + maxCharsPerLine, + baseFontSize, +) { + const plain = String(displayText || "").trim(); + if (!plain) return assEscapeText(" "); + const lines = smartLineBreak(plain, maxCharsPerLine); + let charOffset = 0; + const assLines = lines.map((line) => { + const lineStart = charOffset; + const lineEnd = charOffset + line.length; + const lineMatches = segMatches + .filter((m) => m.occurrenceStart < lineEnd && m.occurrenceEnd > lineStart) + .map((m) => ({ + ...m, + occurrenceStart: Math.max(0, m.occurrenceStart - lineStart), + occurrenceEnd: Math.min(line.length, m.occurrenceEnd - lineStart), + })); + charOffset = lineEnd; + return buildInlineAssLine(line, lineMatches, baseFontSize); + }); + return assLines.join("\\N"); +} + +function buildAssContent( + records, + styleInput, + videoWidth, + videoHeight, + keywordMatches = [], + options = {}, +) { + const inlineKeywords = options.inlineKeywords === true; const style = normalizeBurnStyle(styleInput); const width = Math.max(320, Number(videoWidth) || 1920); const height = Math.max(320, Number(videoHeight) || 1080); @@ -529,11 +604,23 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight, keywordMa const end = formatAssTime(Math.max(r.end, r.start + 500)); const display = normalizeDisplayText(r.text); const segMatches = filterMatchesForRecord(r, keywordMatches); - const baseDisplay = - segMatches.length > 0 - ? stripKeywordsFromDisplay(display, segMatches) - : display; - const text = assEscapeText(wrapSubtitleText(baseDisplay || " ", style.maxCharsPerLine)); + let text; + if (inlineKeywords && segMatches.length > 0) { + text = buildInlineAssTextForRecord( + display, + segMatches, + style.maxCharsPerLine, + fontSize, + ); + } else { + const baseDisplay = + segMatches.length > 0 + ? stripKeywordsFromDisplay(display, segMatches) + : display; + text = assEscapeText( + wrapSubtitleText(baseDisplay || " ", style.maxCharsPerLine), + ); + } if (!text.replace(/\\N/g, "").trim()) return ""; return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`; }).filter(Boolean); @@ -781,14 +868,20 @@ globalThis.__nodejsMain = async function main(params) { let keywordMatches = []; const enableKeywords = p.enableKeywordEffects !== false; const keywordGroups = buildKeywordGroups(p); + const useInlineKeywords = + enableKeywords && + keywordGroups.length > 0 && + isInlineKeywordRenderMode(p.keywordRenderMode); + if (enableKeywords && keywordGroups.length > 0) { keywordMatches = matchKeywordsInRecords(records, keywordGroups); logSubtitle("info", "关键词匹配", { groups: keywordGroups.length, matches: keywordMatches.length, + inlineEmphasis: useInlineKeywords, keywords: keywordMatches.slice(0, 8).map((m) => m.keyword), }); - if (keywordMatches.length > 0) { + if (keywordMatches.length > 0 && !useInlineKeywords) { keywordDrawtextFilter = buildKeywordDrawtextFilter(keywordMatches, { videoWidth: width, videoHeight: height, @@ -804,14 +897,18 @@ globalThis.__nodejsMain = async function main(params) { const assPath = makeOutputPath("subtitle", "ass"); fs.writeFileSync( assPath, - buildAssContent(records, burnStyle, width, height, keywordMatches), + buildAssContent(records, burnStyle, width, height, keywordMatches, { + inlineKeywords: useInlineKeywords, + }), "utf8", ); globalThis.__native?.emitProgress?.( keywordDrawtextFilter ? "正在烧录字幕并叠加关键词特效…" - : "正在烧录字幕到视频…", + : useInlineKeywords && keywordMatches.length > 0 + ? "正在烧录字幕(关键词内联高亮)…" + : "正在烧录字幕到视频…", ); current = await addSubtitleToVideo(current, assPath, keywordDrawtextFilter); pipelineNative.deleteFile(audioPath); diff --git a/scripts/nodejs/subtitle_keyword_drawtext.js b/scripts/nodejs/subtitle_keyword_drawtext.js index 662366b..c33a78c 100644 --- a/scripts/nodejs/subtitle_keyword_drawtext.js +++ b/scripts/nodejs/subtitle_keyword_drawtext.js @@ -356,13 +356,19 @@ function stripKeywordsFromDisplay(displayText, matches) { return chars.join("").trim(); } -/** @param {{ start: number, end: number }} record 毫秒 */ +/** @param {{ start: number, end: number, text?: string }} record 毫秒 */ function filterMatchesForRecord(record, matches) { - const segStart = record.start / 1000; - const segEnd = record.end / 1000; - return matches.filter( - (m) => m.startSec >= segStart - 0.02 && m.endSec <= segEnd + 0.02, - ); + const display = normalizeDisplayText(record.text); + if (!display) return []; + return matches.filter((m) => m.lineText === display); +} + +/** + * inline-emphasis:关键词用 ASS 内联样式,与正文同行同序(不用 drawtext 叠层) + * @param {string} [mode] + */ +function isInlineKeywordRenderMode(mode) { + return String(mode || "inline-emphasis") === "inline-emphasis"; } module.exports = { @@ -372,5 +378,6 @@ module.exports = { normalizeDisplayText, stripKeywordsFromDisplay, filterMatchesForRecord, + isInlineKeywordRenderMode, resolveFontFile, };