11
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user