This commit is contained in:
949036910@qq.com
2026-06-01 22:47:04 +08:00
parent 4dada0c1a0
commit d637088a34
2 changed files with 119 additions and 15 deletions

View File

@@ -18,6 +18,7 @@ const {
normalizeDisplayText, normalizeDisplayText,
stripKeywordsFromDisplay, stripKeywordsFromDisplay,
filterMatchesForRecord, filterMatchesForRecord,
isInlineKeywordRenderMode,
} = require("./subtitle_keyword_drawtext.js"); } = require("./subtitle_keyword_drawtext.js");
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-subtitle-bgm"); 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 }; 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 style = normalizeBurnStyle(styleInput);
const width = Math.max(320, Number(videoWidth) || 1920); const width = Math.max(320, Number(videoWidth) || 1920);
const height = Math.max(320, Number(videoHeight) || 1080); 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 end = formatAssTime(Math.max(r.end, r.start + 500));
const display = normalizeDisplayText(r.text); const display = normalizeDisplayText(r.text);
const segMatches = filterMatchesForRecord(r, keywordMatches); const segMatches = filterMatchesForRecord(r, keywordMatches);
const baseDisplay = let text;
segMatches.length > 0 if (inlineKeywords && segMatches.length > 0) {
? stripKeywordsFromDisplay(display, segMatches) text = buildInlineAssTextForRecord(
: display; display,
const text = assEscapeText(wrapSubtitleText(baseDisplay || " ", style.maxCharsPerLine)); 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 ""; if (!text.replace(/\\N/g, "").trim()) return "";
return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`; return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`;
}).filter(Boolean); }).filter(Boolean);
@@ -781,14 +868,20 @@ globalThis.__nodejsMain = async function main(params) {
let keywordMatches = []; let keywordMatches = [];
const enableKeywords = p.enableKeywordEffects !== false; const enableKeywords = p.enableKeywordEffects !== false;
const keywordGroups = buildKeywordGroups(p); const keywordGroups = buildKeywordGroups(p);
const useInlineKeywords =
enableKeywords &&
keywordGroups.length > 0 &&
isInlineKeywordRenderMode(p.keywordRenderMode);
if (enableKeywords && keywordGroups.length > 0) { if (enableKeywords && keywordGroups.length > 0) {
keywordMatches = matchKeywordsInRecords(records, keywordGroups); keywordMatches = matchKeywordsInRecords(records, keywordGroups);
logSubtitle("info", "关键词匹配", { logSubtitle("info", "关键词匹配", {
groups: keywordGroups.length, groups: keywordGroups.length,
matches: keywordMatches.length, matches: keywordMatches.length,
inlineEmphasis: useInlineKeywords,
keywords: keywordMatches.slice(0, 8).map((m) => m.keyword), keywords: keywordMatches.slice(0, 8).map((m) => m.keyword),
}); });
if (keywordMatches.length > 0) { if (keywordMatches.length > 0 && !useInlineKeywords) {
keywordDrawtextFilter = buildKeywordDrawtextFilter(keywordMatches, { keywordDrawtextFilter = buildKeywordDrawtextFilter(keywordMatches, {
videoWidth: width, videoWidth: width,
videoHeight: height, videoHeight: height,
@@ -804,14 +897,18 @@ globalThis.__nodejsMain = async function main(params) {
const assPath = makeOutputPath("subtitle", "ass"); const assPath = makeOutputPath("subtitle", "ass");
fs.writeFileSync( fs.writeFileSync(
assPath, assPath,
buildAssContent(records, burnStyle, width, height, keywordMatches), buildAssContent(records, burnStyle, width, height, keywordMatches, {
inlineKeywords: useInlineKeywords,
}),
"utf8", "utf8",
); );
globalThis.__native?.emitProgress?.( globalThis.__native?.emitProgress?.(
keywordDrawtextFilter keywordDrawtextFilter
? "正在烧录字幕并叠加关键词特效…" ? "正在烧录字幕并叠加关键词特效…"
: "正在烧录字幕到视频…", : useInlineKeywords && keywordMatches.length > 0
? "正在烧录字幕(关键词内联高亮)…"
: "正在烧录字幕到视频…",
); );
current = await addSubtitleToVideo(current, assPath, keywordDrawtextFilter); current = await addSubtitleToVideo(current, assPath, keywordDrawtextFilter);
pipelineNative.deleteFile(audioPath); pipelineNative.deleteFile(audioPath);

View File

@@ -356,13 +356,19 @@ function stripKeywordsFromDisplay(displayText, matches) {
return chars.join("").trim(); return chars.join("").trim();
} }
/** @param {{ start: number, end: number }} record 毫秒 */ /** @param {{ start: number, end: number, text?: string }} record 毫秒 */
function filterMatchesForRecord(record, matches) { function filterMatchesForRecord(record, matches) {
const segStart = record.start / 1000; const display = normalizeDisplayText(record.text);
const segEnd = record.end / 1000; if (!display) return [];
return matches.filter( return matches.filter((m) => m.lineText === display);
(m) => m.startSec >= segStart - 0.02 && m.endSec <= segEnd + 0.02, }
);
/**
* inline-emphasis关键词用 ASS 内联样式,与正文同行同序(不用 drawtext 叠层)
* @param {string} [mode]
*/
function isInlineKeywordRenderMode(mode) {
return String(mode || "inline-emphasis") === "inline-emphasis";
} }
module.exports = { module.exports = {
@@ -372,5 +378,6 @@ module.exports = {
normalizeDisplayText, normalizeDisplayText,
stripKeywordsFromDisplay, stripKeywordsFromDisplay,
filterMatchesForRecord, filterMatchesForRecord,
isInlineKeywordRenderMode,
resolveFontFile, resolveFontFile,
}; };