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,
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);

View File

@@ -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,
};