111
This commit is contained in:
@@ -10,6 +10,15 @@ const { spawnSync } = require("child_process");
|
|||||||
|
|
||||||
const pipelineNative = require("./pipeline_native.js");
|
const pipelineNative = require("./pipeline_native.js");
|
||||||
const desktopConfig = require("./desktop_config.js");
|
const desktopConfig = require("./desktop_config.js");
|
||||||
|
const { correctTextWithLevenshtein, similarity: textSimilarity } = require("./subtitle_levenshtein.js");
|
||||||
|
const {
|
||||||
|
buildKeywordGroups,
|
||||||
|
matchKeywordsInRecords,
|
||||||
|
buildKeywordDrawtextFilter,
|
||||||
|
normalizeDisplayText,
|
||||||
|
stripKeywordsFromDisplay,
|
||||||
|
filterMatchesForRecord,
|
||||||
|
} = 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");
|
||||||
|
|
||||||
@@ -123,20 +132,63 @@ function normalizeAsrTimeToMs(value) {
|
|||||||
return Math.round(n * 1000);
|
return Math.round(n * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAsrRecords(records) {
|
/** 对齐 ZimuShengcheng.shouldTreatTimelineAsSeconds */
|
||||||
if (!Array.isArray(records)) return [];
|
function shouldTreatTimelineAsSeconds(records) {
|
||||||
const normalized = records
|
const valid = records.filter(
|
||||||
|
(r) => Number.isFinite(Number(r?.start)) && Number.isFinite(Number(r?.end)),
|
||||||
|
);
|
||||||
|
if (valid.length === 0) return false;
|
||||||
|
const maxEnd = Math.max(...valid.map((r) => Number(r.end)));
|
||||||
|
const maxDur = Math.max(...valid.map((r) => Number(r.end) - Number(r.start)));
|
||||||
|
return maxEnd <= 600 && maxDur <= 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFunasrSegmentsToMilliseconds(records) {
|
||||||
|
if (!Array.isArray(records) || records.length === 0) return [];
|
||||||
|
const mapped = records
|
||||||
.map((record) => {
|
.map((record) => {
|
||||||
const text = String(record?.text || record?.sentence || "").trim();
|
const text = String(record?.text || record?.sentence || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
let start = normalizeAsrTimeToMs(
|
return {
|
||||||
record.start ?? record.begin ?? record.beginTime ?? 0,
|
text,
|
||||||
);
|
start: Number(record.start ?? record.begin ?? record.beginTime ?? 0),
|
||||||
let end = normalizeAsrTimeToMs(
|
end: Number(record.end ?? record.endTime ?? 0),
|
||||||
record.end ?? record.endTime ?? start + 2000,
|
words: record.words,
|
||||||
);
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (shouldTreatTimelineAsSeconds(mapped)) {
|
||||||
|
logSubtitle("info", "检测到秒级时间轴,统一转换为毫秒");
|
||||||
|
return mapped.map((r) => ({
|
||||||
|
...r,
|
||||||
|
start: Math.round(r.start * 1000),
|
||||||
|
end: Math.round((r.end > r.start ? r.end : r.start + 2) * 1000),
|
||||||
|
words: Array.isArray(r.words)
|
||||||
|
? r.words.map((w) => ({
|
||||||
|
...w,
|
||||||
|
start: Math.round(Number(w.start ?? w.begin_time ?? 0) * 1000),
|
||||||
|
end: Math.round(Number(w.end ?? w.end_time ?? 0) * 1000),
|
||||||
|
}))
|
||||||
|
: r.words,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped.map((r) => {
|
||||||
|
let start = normalizeAsrTimeToMs(r.start);
|
||||||
|
let end = normalizeAsrTimeToMs(r.end || start + 2);
|
||||||
if (end <= start) end = start + 2000;
|
if (end <= start) end = start + 2000;
|
||||||
return { text, start, end };
|
return { text: r.text, start, end, words: r.words };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAsrRecords(records) {
|
||||||
|
if (!Array.isArray(records)) return [];
|
||||||
|
const normalized = normalizeFunasrSegmentsToMilliseconds(records)
|
||||||
|
.map((record) => {
|
||||||
|
let { start, end } = record;
|
||||||
|
if (end <= start) end = start + 2000;
|
||||||
|
return { text: record.text, start, end, words: record.words };
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const MIN_GAP_MS = 50;
|
const MIN_GAP_MS = 50;
|
||||||
@@ -194,6 +246,36 @@ function cleanSubtitleDisplayText(text) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能字幕:Levenshtein 校对(ZimuShengcheng),失败时回退按句均分
|
||||||
|
*/
|
||||||
|
function applySmartSubtitle(script, records, videoDurationSec = 0) {
|
||||||
|
const normalized = normalizeAsrRecords(records);
|
||||||
|
const scriptTrim = String(script || "").trim();
|
||||||
|
if (!scriptTrim) return normalized;
|
||||||
|
|
||||||
|
const asrPlain = normalized.map((r) => r.text).join("");
|
||||||
|
const sim = textSimilarity(
|
||||||
|
scriptTrim.replace(/\s+/g, ""),
|
||||||
|
asrPlain.replace(/\s+/g, ""),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (normalized.length > 0 && sim >= 0.25) {
|
||||||
|
const { segments, similarity } = correctTextWithLevenshtein(scriptTrim, normalized);
|
||||||
|
if (segments.length > 0) {
|
||||||
|
logSubtitle("info", "Levenshtein 文案校对", {
|
||||||
|
count: segments.length,
|
||||||
|
similarity: Math.round(similarity * 1000) / 1000,
|
||||||
|
scriptSimilarity: Math.round(sim * 1000) / 1000,
|
||||||
|
});
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSubtitle("info", "Levenshtein 未产出有效段落,回退按句对齐", { similarity: sim });
|
||||||
|
return alignScriptToRecords(scriptTrim, normalized, videoDurationSec);
|
||||||
|
}
|
||||||
|
|
||||||
function alignScriptToRecords(script, records, videoDurationSec = 0) {
|
function alignScriptToRecords(script, records, videoDurationSec = 0) {
|
||||||
const lines = splitScriptLines(script);
|
const lines = splitScriptLines(script);
|
||||||
const normalized = normalizeAsrRecords(records);
|
const normalized = normalizeAsrRecords(records);
|
||||||
@@ -337,7 +419,7 @@ 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) {
|
function buildAssContent(records, styleInput, videoWidth, videoHeight, keywordMatches = []) {
|
||||||
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);
|
||||||
@@ -349,13 +431,26 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) {
|
|||||||
height,
|
height,
|
||||||
);
|
);
|
||||||
const hasBg = style.hasBg;
|
const hasBg = style.hasBg;
|
||||||
const borderStyle = hasBg ? 3 : 1;
|
let borderStyle = 1;
|
||||||
const finalOutline = hasBg ? 1 : outline;
|
let finalOutline = outline;
|
||||||
const backColour = hasBg
|
let finalShadow = 0;
|
||||||
? hexToAssColor(style.backgroundColor)
|
let backColour = "&H00000000";
|
||||||
: "&H00000000";
|
|
||||||
const primary = hexToAssColor(style.fontColor);
|
const primary = hexToAssColor(style.fontColor);
|
||||||
const outlineColour = hexToAssColor(style.outlineColor);
|
let outlineColour = hexToAssColor(style.outlineColor);
|
||||||
|
|
||||||
|
if (hasBg) {
|
||||||
|
borderStyle = 3;
|
||||||
|
finalOutline = 1;
|
||||||
|
finalShadow = 0;
|
||||||
|
backColour = hexToAssColor(style.backgroundColor);
|
||||||
|
outlineColour = backColour;
|
||||||
|
} else {
|
||||||
|
borderStyle = 1;
|
||||||
|
finalOutline = outline || 3;
|
||||||
|
finalShadow = 0;
|
||||||
|
backColour = "&H00000000";
|
||||||
|
outlineColour = hexToAssColor(style.outlineColor);
|
||||||
|
}
|
||||||
|
|
||||||
const header = [
|
const header = [
|
||||||
"[Script Info]",
|
"[Script Info]",
|
||||||
@@ -368,7 +463,7 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) {
|
|||||||
"",
|
"",
|
||||||
"[V4+ Styles]",
|
"[V4+ Styles]",
|
||||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
||||||
`Style: Default,${style.fontName},${fontSize},${primary},${primary},${outlineColour},${backColour},0,0,0,0,100,100,0,0,${borderStyle},${finalOutline},0,${alignment},${marginL},${marginR},${marginV},1`,
|
`Style: Default,${style.fontName},${fontSize},${primary},&H000000FF,${outlineColour},${backColour},1,0,0,0,100,100,0,0,${borderStyle},${finalOutline},${finalShadow},${alignment},${marginL},${marginR},${marginV},1`,
|
||||||
"",
|
"",
|
||||||
"[Events]",
|
"[Events]",
|
||||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
||||||
@@ -377,8 +472,14 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) {
|
|||||||
const events = records.map((r) => {
|
const events = records.map((r) => {
|
||||||
const start = formatAssTime(r.start);
|
const start = formatAssTime(r.start);
|
||||||
const end = formatAssTime(Math.max(r.end, r.start + 500));
|
const end = formatAssTime(Math.max(r.end, r.start + 500));
|
||||||
const text = assEscapeText(wrapSubtitleText(r.text, style.maxCharsPerLine));
|
const display = normalizeDisplayText(r.text);
|
||||||
if (!text) return "";
|
const segMatches = filterMatchesForRecord(r, keywordMatches);
|
||||||
|
const baseDisplay =
|
||||||
|
segMatches.length > 0
|
||||||
|
? stripKeywordsFromDisplay(display, segMatches)
|
||||||
|
: display;
|
||||||
|
const text = assEscapeText(wrapSubtitleText(baseDisplay || " ", style.maxCharsPerLine));
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -393,10 +494,16 @@ function formatAssTime(ms) {
|
|||||||
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`;
|
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSubtitleToVideo(inputVideo, subtitlePath, styleInput) {
|
/**
|
||||||
|
* @param {string} [keywordDrawtextFilter] 逗号分隔的 drawtext 滤镜(关键词高亮)
|
||||||
|
*/
|
||||||
|
function addSubtitleToVideo(inputVideo, subtitlePath, keywordDrawtextFilter) {
|
||||||
const outputVideo = makeOutputPath("with_subtitle");
|
const outputVideo = makeOutputPath("with_subtitle");
|
||||||
const sub = escapeSubPath(subtitlePath);
|
const sub = escapeSubPath(subtitlePath);
|
||||||
const vf = `subtitles='${sub}'`;
|
let vf = `subtitles='${sub}'`;
|
||||||
|
if (keywordDrawtextFilter) {
|
||||||
|
vf = `${vf},${keywordDrawtextFilter}`;
|
||||||
|
}
|
||||||
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
||||||
return outputVideo;
|
return outputVideo;
|
||||||
}
|
}
|
||||||
@@ -521,7 +628,7 @@ globalThis.__nodejsMain = async function main(params) {
|
|||||||
: 0,
|
: 0,
|
||||||
});
|
});
|
||||||
if (p.smartSubtitle !== false && scriptContent) {
|
if (p.smartSubtitle !== false && scriptContent) {
|
||||||
records = alignScriptToRecords(scriptContent, records, videoDuration);
|
records = applySmartSubtitle(scriptContent, records, videoDuration);
|
||||||
logSubtitle("info", "智能字幕对齐后", { count: records.length });
|
logSubtitle("info", "智能字幕对齐后", { count: records.length });
|
||||||
}
|
}
|
||||||
if (!records.length) {
|
if (!records.length) {
|
||||||
@@ -553,15 +660,44 @@ globalThis.__nodejsMain = async function main(params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { width, height } = probeVideoSize(current);
|
const { width, height } = probeVideoSize(current);
|
||||||
|
|
||||||
|
let keywordDrawtextFilter = null;
|
||||||
|
let keywordMatches = [];
|
||||||
|
const enableKeywords = p.enableKeywordEffects !== false;
|
||||||
|
const keywordGroups = buildKeywordGroups(p);
|
||||||
|
if (enableKeywords && keywordGroups.length > 0) {
|
||||||
|
keywordMatches = matchKeywordsInRecords(records, keywordGroups);
|
||||||
|
logSubtitle("info", "关键词匹配", {
|
||||||
|
groups: keywordGroups.length,
|
||||||
|
matches: keywordMatches.length,
|
||||||
|
keywords: keywordMatches.slice(0, 8).map((m) => m.keyword),
|
||||||
|
});
|
||||||
|
if (keywordMatches.length > 0) {
|
||||||
|
keywordDrawtextFilter = buildKeywordDrawtextFilter(keywordMatches, {
|
||||||
|
videoWidth: width,
|
||||||
|
videoHeight: height,
|
||||||
|
subtitleStyle: burnStyle,
|
||||||
|
});
|
||||||
|
if (!keywordDrawtextFilter) {
|
||||||
|
logSubtitle("warn", "未找到可用字体,跳过关键词 drawtext 特效");
|
||||||
|
keywordMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const assPath = makeOutputPath("subtitle", "ass");
|
const assPath = makeOutputPath("subtitle", "ass");
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
assPath,
|
assPath,
|
||||||
buildAssContent(records, burnStyle, width, height),
|
buildAssContent(records, burnStyle, width, height, keywordMatches),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
globalThis.__native?.emitProgress?.("正在烧录字幕到视频…");
|
globalThis.__native?.emitProgress?.(
|
||||||
current = addSubtitleToVideo(current, assPath, burnStyle);
|
keywordDrawtextFilter
|
||||||
|
? "正在烧录字幕并叠加关键词特效…"
|
||||||
|
: "正在烧录字幕到视频…",
|
||||||
|
);
|
||||||
|
current = addSubtitleToVideo(current, assPath, keywordDrawtextFilter);
|
||||||
pipelineNative.deleteFile(audioPath);
|
pipelineNative.deleteFile(audioPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
363
scripts/nodejs/subtitle_keyword_drawtext.js
Normal file
363
scripts/nodejs/subtitle_keyword_drawtext.js
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键词高亮 drawtext(对齐 Electron ZimuShengcheng inline 模式 + pulse-scale)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const os = require("os");
|
||||||
|
|
||||||
|
const PUNCT_RE =
|
||||||
|
/[,。!?;:、""''()《》【】…—·,.!?;:'"()[\]{}<>]/g;
|
||||||
|
|
||||||
|
const DEFAULT_GROUP_META = {
|
||||||
|
focus: {
|
||||||
|
name: "重点词",
|
||||||
|
effectId: "pulse-scale",
|
||||||
|
styleConfig: {
|
||||||
|
fontColor: "#FFD700",
|
||||||
|
outlineColor: "#FF8C00",
|
||||||
|
outlineWidth: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
describe: {
|
||||||
|
name: "描述词",
|
||||||
|
effectId: "pulse-scale",
|
||||||
|
styleConfig: {
|
||||||
|
fontColor: "#5AC8FA",
|
||||||
|
outlineColor: "#007AFF",
|
||||||
|
outlineWidth: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
name: "行动词",
|
||||||
|
effectId: "pulse-scale",
|
||||||
|
styleConfig: {
|
||||||
|
fontColor: "#34C759",
|
||||||
|
outlineColor: "#248A3D",
|
||||||
|
outlineWidth: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emotion: {
|
||||||
|
name: "情感词",
|
||||||
|
effectId: "pulse-scale",
|
||||||
|
styleConfig: {
|
||||||
|
fontColor: "#FF6B6B",
|
||||||
|
outlineColor: "#C92A2A",
|
||||||
|
outlineWidth: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseKeywordLines(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.split(/[\n,,、]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从步骤 04 字段或显式 keywordGroups 构建分组
|
||||||
|
* @param {Record<string, unknown>} params
|
||||||
|
*/
|
||||||
|
function buildKeywordGroups(params) {
|
||||||
|
if (Array.isArray(params.keywordGroups) && params.keywordGroups.length > 0) {
|
||||||
|
return params.keywordGroups
|
||||||
|
.map((g) => normalizeKeywordGroup(g))
|
||||||
|
.filter((g) => g.keywords.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
["focus", params.keywordsFocus],
|
||||||
|
["describe", params.keywordsDescribe],
|
||||||
|
["action", params.keywordsAction],
|
||||||
|
["emotion", params.keywordsEmotion],
|
||||||
|
];
|
||||||
|
|
||||||
|
return sources
|
||||||
|
.map(([id, raw]) => {
|
||||||
|
const meta = DEFAULT_GROUP_META[id] || DEFAULT_GROUP_META.focus;
|
||||||
|
const keywords = parseKeywordLines(raw);
|
||||||
|
if (!keywords.length) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: meta.name,
|
||||||
|
effectId: meta.effectId,
|
||||||
|
styleConfig: { ...meta.styleConfig },
|
||||||
|
keywords,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeywordGroup(group) {
|
||||||
|
const g = group && typeof group === "object" ? group : {};
|
||||||
|
const keywords = (Array.isArray(g.keywords) ? g.keywords : [])
|
||||||
|
.map((k) => (typeof k === "string" ? k : String(k?.keyword || "")).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return {
|
||||||
|
id: String(g.id || g.groupId || "group"),
|
||||||
|
name: String(g.name || g.groupName || "关键词"),
|
||||||
|
effectId: String(g.effectId || g.effectConfig?.type || "pulse-scale"),
|
||||||
|
styleConfig: g.styleConfig || g.styleOverride || {},
|
||||||
|
keywords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayText(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.replace(PUNCT_RE, "")
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEffectType(effectId) {
|
||||||
|
const id = String(effectId || "pulse-scale").toLowerCase();
|
||||||
|
if (id.includes("pulse") || id.includes("scale")) return "pulse-scale";
|
||||||
|
if (id.includes("slide")) return "slide";
|
||||||
|
return "highlight";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildKeywordStyle(group, keywordItem) {
|
||||||
|
const base = group.styleConfig || {};
|
||||||
|
const override =
|
||||||
|
keywordItem && typeof keywordItem === "object" ? keywordItem.styleOverride : null;
|
||||||
|
const o = override || base;
|
||||||
|
return {
|
||||||
|
fontColor: o.fontColor || "#FFD700",
|
||||||
|
outlineColor: o.outlineColor || "#FF8C00",
|
||||||
|
outlineWidth: Number(o.outlineWidth) ?? 4,
|
||||||
|
fontSize: Number(o.fontSize) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{ text: string, start: number, end: number }>} records 毫秒
|
||||||
|
* @param {ReturnType<typeof buildKeywordGroups>} groups
|
||||||
|
*/
|
||||||
|
function matchKeywordsInRecords(records, groups) {
|
||||||
|
const matches = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const display = normalizeDisplayText(record.text);
|
||||||
|
if (!display) continue;
|
||||||
|
|
||||||
|
const segStartSec = record.start / 1000;
|
||||||
|
const segEndSec = record.end / 1000;
|
||||||
|
const dur = Math.max(0.05, segEndSec - segStartSec);
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const kw of group.keywords) {
|
||||||
|
const raw = typeof kw === "string" ? kw : String(kw?.keyword || "");
|
||||||
|
const needle = normalizeDisplayText(raw);
|
||||||
|
if (!needle) continue;
|
||||||
|
|
||||||
|
let searchFrom = 0;
|
||||||
|
while (searchFrom < display.length) {
|
||||||
|
const idx = display.indexOf(needle, searchFrom);
|
||||||
|
if (idx === -1) break;
|
||||||
|
|
||||||
|
const dedupeKey = `${segStartSec.toFixed(3)}-${segEndSec.toFixed(3)}-${needle}-${idx}`;
|
||||||
|
if (!seen.has(dedupeKey)) {
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
const ratioStart = idx / display.length;
|
||||||
|
const ratioEnd = (idx + needle.length) / display.length;
|
||||||
|
const startSec = segStartSec + dur * ratioStart;
|
||||||
|
const endSec = Math.max(startSec + 0.08, segStartSec + dur * ratioEnd);
|
||||||
|
|
||||||
|
matches.push({
|
||||||
|
keyword: needle,
|
||||||
|
text: needle,
|
||||||
|
lineText: display,
|
||||||
|
lineLength: display.length,
|
||||||
|
occurrenceStart: idx,
|
||||||
|
occurrenceEnd: idx + needle.length,
|
||||||
|
startSec,
|
||||||
|
endSec,
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
effectType: normalizeEffectType(
|
||||||
|
(typeof kw === "object" && kw?.effectId) || group.effectId,
|
||||||
|
),
|
||||||
|
style: buildKeywordStyle(group, kw),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
searchFrom = idx + Math.max(1, needle.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.sort((a, b) => a.startSec - b.startSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIN_FONT_MAP = {
|
||||||
|
"microsoft yahei": ["msyh.ttc", "msyhbd.ttc"],
|
||||||
|
"microsoft yahei bold": ["msyhbd.ttc", "msyh.ttc"],
|
||||||
|
simhei: ["simhei.ttf"],
|
||||||
|
"sim sun": ["simsun.ttc"],
|
||||||
|
"source han sans": ["SourceHanSansSC-Regular.otf"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveFontFile(fontName) {
|
||||||
|
const fromEnv = process.env.AICLIENT_SUBTITLE_FONT_PATH;
|
||||||
|
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||||
|
|
||||||
|
const key = String(fontName || "Microsoft YaHei")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const candidates = WIN_FONT_MAP[key] || WIN_FONT_MAP["microsoft yahei"];
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const fontsDir = path.join(process.env.WINDIR || "C:\\Windows", "Fonts");
|
||||||
|
for (const file of candidates) {
|
||||||
|
const full = path.join(fontsDir, file);
|
||||||
|
if (fs.existsSync(full)) return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbacks = [
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||||
|
];
|
||||||
|
for (const p of fallbacks) {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeDrawtextPath(filePath) {
|
||||||
|
return filePath
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/:/g, "\\:")
|
||||||
|
.replace(/'/g, "\\'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeDrawtextText(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/:/g, "\\:")
|
||||||
|
.replace(/,/g, "\\,");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToDrawtextColor(hex) {
|
||||||
|
const h = String(hex || "#FFFFFF").replace("#", "").slice(0, 6);
|
||||||
|
if (h.length !== 6) return "white";
|
||||||
|
return `0x${h.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSubtitleY(position, videoHeight) {
|
||||||
|
const h = Math.max(360, Number(videoHeight) || 1080);
|
||||||
|
if (position === "top") return "h*0.12";
|
||||||
|
if (position === "center") return "(h-text_h)/2";
|
||||||
|
return "h*0.82-text_h";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ReturnType<typeof matchKeywordsInRecords>[number]} match
|
||||||
|
* @param {{ videoWidth: number, videoHeight: number, baseFontSize: number, fontfile: string, position: string }} layout
|
||||||
|
*/
|
||||||
|
function buildDrawtextForMatch(match, layout) {
|
||||||
|
const { videoWidth, videoHeight, baseFontSize, fontfile, position } = layout;
|
||||||
|
const style = match.style || {};
|
||||||
|
const baseSize = style.fontSize > 0 ? style.fontSize : Math.round(baseFontSize * 1.45);
|
||||||
|
const outlineW = Math.max(1, Math.round(style.outlineWidth || 3));
|
||||||
|
const fontColor = hexToDrawtextColor(style.fontColor);
|
||||||
|
const borderColor = hexToDrawtextColor(style.outlineColor);
|
||||||
|
|
||||||
|
const lineLen = Math.max(1, match.lineLength);
|
||||||
|
const charWidth = baseSize * 0.55;
|
||||||
|
const lineWidth = Math.min(lineLen * charWidth, videoWidth * 0.88);
|
||||||
|
const keywordMid = (match.occurrenceStart + match.occurrenceEnd) / 2;
|
||||||
|
const offsetPx = (keywordMid / lineLen) * lineWidth - lineWidth / 2;
|
||||||
|
const xExpr = `(w-text_w)/2+${offsetPx.toFixed(1)}`;
|
||||||
|
const yExpr = resolveSubtitleY(position, videoHeight);
|
||||||
|
|
||||||
|
const t0 = match.startSec.toFixed(3);
|
||||||
|
const t1 = match.endSec.toFixed(3);
|
||||||
|
const enable = `between(t\\,${t0}\\,${t1})`;
|
||||||
|
|
||||||
|
let fontsizePart = String(baseSize);
|
||||||
|
if (match.effectType === "pulse-scale") {
|
||||||
|
fontsizePart = `${baseSize}*(1+0.1*sin(2*PI*(t-${t0})*4))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
`fontfile='${escapeDrawtextPath(fontfile)}'`,
|
||||||
|
`text='${escapeDrawtextText(match.text)}'`,
|
||||||
|
`fontsize='${fontsizePart}'`,
|
||||||
|
`fontcolor=${fontColor}`,
|
||||||
|
`borderw=${outlineW}`,
|
||||||
|
`bordercolor=${borderColor}`,
|
||||||
|
`x='${xExpr}'`,
|
||||||
|
`y='${yExpr}'`,
|
||||||
|
`enable='${enable}'`,
|
||||||
|
];
|
||||||
|
return `drawtext=${parts.join(":")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ReturnType<typeof matchKeywordsInRecords>} matches
|
||||||
|
* @param {{ videoWidth: number, videoHeight: number, subtitleStyle?: Record<string, unknown> }} opts
|
||||||
|
* @returns {string | null} ffmpeg -vf 链(逗号分隔的 drawtext)
|
||||||
|
*/
|
||||||
|
function buildKeywordDrawtextFilter(matches, opts) {
|
||||||
|
if (!matches.length) return null;
|
||||||
|
|
||||||
|
const videoWidth = Math.max(320, Number(opts.videoWidth) || 1920);
|
||||||
|
const videoHeight = Math.max(320, Number(opts.videoHeight) || 1080);
|
||||||
|
const style = opts.subtitleStyle || {};
|
||||||
|
const scale = Math.max(0.2, Math.min(2, (Number(style.fontSizeScale) || 100) / 100));
|
||||||
|
const baseFontSize = Math.max(14, Math.round((Number(style.fontSize) || 35) * scale));
|
||||||
|
const fontfile = resolveFontFile(style.fontName);
|
||||||
|
if (!fontfile) return null;
|
||||||
|
|
||||||
|
const layout = {
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
baseFontSize,
|
||||||
|
fontfile,
|
||||||
|
position: String(style.position || "bottom"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return matches.map((m) => buildDrawtextForMatch(m, layout)).join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 ASS 底字中移除关键词字符,避免与 drawtext 高亮重复
|
||||||
|
* @param {string} displayText 已去标点文本
|
||||||
|
* @param {Array<{ occurrenceStart: number, occurrenceEnd: number }>} matches
|
||||||
|
*/
|
||||||
|
function stripKeywordsFromDisplay(displayText, matches) {
|
||||||
|
const text = String(displayText || "");
|
||||||
|
if (!text || !matches.length) return text;
|
||||||
|
const chars = [...text];
|
||||||
|
for (const m of matches) {
|
||||||
|
const start = Math.max(0, m.occurrenceStart);
|
||||||
|
const end = Math.min(chars.length, m.occurrenceEnd);
|
||||||
|
for (let i = start; i < end; i++) chars[i] = "";
|
||||||
|
}
|
||||||
|
return chars.join("").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {{ start: number, end: number }} 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildKeywordGroups,
|
||||||
|
matchKeywordsInRecords,
|
||||||
|
buildKeywordDrawtextFilter,
|
||||||
|
normalizeDisplayText,
|
||||||
|
stripKeywordsFromDisplay,
|
||||||
|
filterMatchesForRecord,
|
||||||
|
resolveFontFile,
|
||||||
|
};
|
||||||
265
scripts/nodejs/subtitle_levenshtein.js
Normal file
265
scripts/nodejs/subtitle_levenshtein.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文案与 ASR 时间轴对齐(对齐 Electron ZimuShengcheng / Levenshtein)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PUNCTUATION_RE =
|
||||||
|
/[,。!?、;:\u201C\u201D"「」『』()【】《》,.!?;:'"]/;
|
||||||
|
|
||||||
|
const DEFAULT_CHAR_DURATION_MS = 150;
|
||||||
|
const TIME_GAP_SPLIT_THRESHOLD_MS = 500;
|
||||||
|
|
||||||
|
function levenshteinDistance(a, b) {
|
||||||
|
const n = a.length;
|
||||||
|
const m = b.length;
|
||||||
|
const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
|
||||||
|
for (let i = 0; i <= n; i++) dp[i][0] = i;
|
||||||
|
for (let j = 0; j <= m; j++) dp[0][j] = j;
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
for (let j = 1; j <= m; j++) {
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp[n][m];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestMatch(userText, asrText) {
|
||||||
|
const n = userText.length;
|
||||||
|
const m = asrText.length;
|
||||||
|
const dp = Array.from({ length: n + 1 }, () => Array(m + 1).fill(0));
|
||||||
|
const bt = Array.from({ length: n + 1 }, () => Array(m + 1).fill(""));
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
dp[i][0] = i;
|
||||||
|
bt[i][0] = "delete";
|
||||||
|
}
|
||||||
|
for (let j = 0; j <= m; j++) {
|
||||||
|
dp[0][j] = j;
|
||||||
|
bt[0][j] = "insert";
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
for (let j = 1; j <= m; j++) {
|
||||||
|
const cost = userText[i - 1] === asrText[j - 1] ? 0 : 1;
|
||||||
|
const del = dp[i - 1][j] + 1;
|
||||||
|
const ins = dp[i][j - 1] + 1;
|
||||||
|
const sub = dp[i - 1][j - 1] + cost;
|
||||||
|
const best = Math.min(del, ins, sub);
|
||||||
|
dp[i][j] = best;
|
||||||
|
if (best === del) bt[i][j] = "delete";
|
||||||
|
else if (best === ins) bt[i][j] = "insert";
|
||||||
|
else if (cost === 0) bt[i][j] = "match";
|
||||||
|
else bt[i][j] = "replace";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const alignments = [];
|
||||||
|
let i = n;
|
||||||
|
let j = m;
|
||||||
|
while (i > 0 || j > 0) {
|
||||||
|
const op = bt[i][j];
|
||||||
|
if (op === "match") {
|
||||||
|
alignments.unshift({
|
||||||
|
userChar: userText[i - 1],
|
||||||
|
asrChar: asrText[j - 1],
|
||||||
|
operation: "match",
|
||||||
|
asrIndex: j - 1,
|
||||||
|
});
|
||||||
|
i -= 1;
|
||||||
|
j -= 1;
|
||||||
|
} else if (op === "replace") {
|
||||||
|
alignments.unshift({
|
||||||
|
userChar: userText[i - 1],
|
||||||
|
asrChar: asrText[j - 1],
|
||||||
|
operation: "replace",
|
||||||
|
asrIndex: j - 1,
|
||||||
|
});
|
||||||
|
i -= 1;
|
||||||
|
j -= 1;
|
||||||
|
} else if (op === "delete") {
|
||||||
|
alignments.unshift({
|
||||||
|
userChar: userText[i - 1],
|
||||||
|
asrChar: "",
|
||||||
|
operation: "delete",
|
||||||
|
asrIndex: j,
|
||||||
|
});
|
||||||
|
i -= 1;
|
||||||
|
} else {
|
||||||
|
alignments.unshift({
|
||||||
|
userChar: "",
|
||||||
|
asrChar: asrText[j - 1],
|
||||||
|
operation: "insert",
|
||||||
|
asrIndex: j - 1,
|
||||||
|
});
|
||||||
|
j -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const distance = dp[n][m];
|
||||||
|
const similarity = 1 - distance / Math.max(n, m, 1);
|
||||||
|
return { alignments, distance, similarity };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegmentCharTimings(segment) {
|
||||||
|
const text = segment.text || "";
|
||||||
|
const len = text.length;
|
||||||
|
if (len === 0) return [];
|
||||||
|
const step = (segment.end - segment.start) / Math.max(len, 1);
|
||||||
|
const fallback = Array.from({ length: len }, (_, idx) => ({
|
||||||
|
start: segment.start + idx * step,
|
||||||
|
end: segment.start + (idx + 1) * step,
|
||||||
|
}));
|
||||||
|
const words = Array.isArray(segment.words) ? segment.words : [];
|
||||||
|
if (words.length === 0) return fallback;
|
||||||
|
|
||||||
|
const mapped = Array(len).fill(null);
|
||||||
|
let searchFrom = 0;
|
||||||
|
for (const w of words) {
|
||||||
|
const word = String(w?.word ?? w?.text ?? "");
|
||||||
|
const start = Number(w?.start ?? w?.beginTime ?? w?.begin_time);
|
||||||
|
const end = Number(w?.end ?? w?.endTime ?? w?.end_time);
|
||||||
|
if (!word || !Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue;
|
||||||
|
const pos = text.indexOf(word, searchFrom);
|
||||||
|
if (pos === -1) continue;
|
||||||
|
const wordStep = (end - start) / Math.max(word.length, 1);
|
||||||
|
for (let k = 0; k < word.length && pos + k < len; k++) {
|
||||||
|
mapped[pos + k] = { start: start + k * wordStep, end: start + (k + 1) * wordStep };
|
||||||
|
}
|
||||||
|
searchFrom = pos + word.length;
|
||||||
|
}
|
||||||
|
return mapped.map((item, idx) => item || fallback[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCharTimeMap(segments) {
|
||||||
|
const map = new Map();
|
||||||
|
let index = 0;
|
||||||
|
for (const segment of segments) {
|
||||||
|
for (const timing of buildSegmentCharTimings(segment)) {
|
||||||
|
map.set(index, timing);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToTimestamps(alignments, charMap) {
|
||||||
|
const segments = [];
|
||||||
|
let text = "";
|
||||||
|
let start = 0;
|
||||||
|
let end = 0;
|
||||||
|
let corrected = false;
|
||||||
|
let isFirstChar = true;
|
||||||
|
|
||||||
|
for (let y = 0; y < alignments.length; y++) {
|
||||||
|
const row = alignments[y];
|
||||||
|
const timing =
|
||||||
|
row.asrIndex != null && row.asrIndex >= 0 ? charMap.get(row.asrIndex) : null;
|
||||||
|
const isPunct = row.userChar && PUNCTUATION_RE.test(row.userChar);
|
||||||
|
|
||||||
|
if (row.operation === "match" || row.operation === "replace") {
|
||||||
|
if (!isPunct) text += row.userChar;
|
||||||
|
if (row.operation === "replace") corrected = true;
|
||||||
|
if (timing) {
|
||||||
|
if (isFirstChar) {
|
||||||
|
start = timing.start;
|
||||||
|
isFirstChar = false;
|
||||||
|
}
|
||||||
|
end = timing.end;
|
||||||
|
}
|
||||||
|
} else if (row.operation === "insert") {
|
||||||
|
if (timing) {
|
||||||
|
if (isFirstChar) {
|
||||||
|
start = timing.start;
|
||||||
|
isFirstChar = false;
|
||||||
|
}
|
||||||
|
end = timing.end;
|
||||||
|
}
|
||||||
|
} else if (row.operation === "delete") {
|
||||||
|
if (!isPunct) {
|
||||||
|
text += row.userChar;
|
||||||
|
const t = isFirstChar ? start : end;
|
||||||
|
if (isFirstChar) {
|
||||||
|
start = t;
|
||||||
|
isFirstChar = false;
|
||||||
|
}
|
||||||
|
if (!timing) end = t + DEFAULT_CHAR_DURATION_MS;
|
||||||
|
}
|
||||||
|
corrected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLast = y === alignments.length - 1;
|
||||||
|
const nextRow = alignments[y + 1];
|
||||||
|
const nextTiming =
|
||||||
|
nextRow && nextRow.asrIndex != null && nextRow.asrIndex >= 0
|
||||||
|
? charMap.get(nextRow.asrIndex)
|
||||||
|
: null;
|
||||||
|
const gapSplit =
|
||||||
|
!isLast && timing && nextTiming && nextTiming.start - end > TIME_GAP_SPLIT_THRESHOLD_MS;
|
||||||
|
|
||||||
|
if ((isLast || isPunct || gapSplit) && text.length > 0) {
|
||||||
|
if (end <= start) end = start + text.length * DEFAULT_CHAR_DURATION_MS;
|
||||||
|
segments.push({
|
||||||
|
text,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
corrected,
|
||||||
|
});
|
||||||
|
start = end;
|
||||||
|
text = "";
|
||||||
|
corrected = false;
|
||||||
|
isFirstChar = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length > 0) {
|
||||||
|
if (end <= start) end = start + text.length * DEFAULT_CHAR_DURATION_MS;
|
||||||
|
segments.push({ text, start, end, corrected });
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userText
|
||||||
|
* @param {Array<{ text: string, start: number, end: number, words?: unknown[] }>} asrSegments
|
||||||
|
*/
|
||||||
|
function correctTextWithLevenshtein(userText, asrSegments) {
|
||||||
|
const user = String(userText || "").replace(/\s+/g, "");
|
||||||
|
const list = Array.isArray(asrSegments) ? asrSegments : [];
|
||||||
|
if (!user || list.length === 0) {
|
||||||
|
return { segments: [], similarity: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const asrFullText = list.map((s) => s.text || "").join("");
|
||||||
|
const match = findBestMatch(user, asrFullText);
|
||||||
|
const charMap = buildCharTimeMap(list);
|
||||||
|
const corrected = mapToTimestamps(match.alignments, charMap);
|
||||||
|
|
||||||
|
const MIN_GAP_MS = 50;
|
||||||
|
const out = corrected
|
||||||
|
.map((seg) => ({
|
||||||
|
text: String(seg.text || "").trim(),
|
||||||
|
start: Math.round(seg.start),
|
||||||
|
end: Math.round(seg.end),
|
||||||
|
}))
|
||||||
|
.filter((seg) => seg.text);
|
||||||
|
|
||||||
|
for (let i = 0; i < out.length - 1; i++) {
|
||||||
|
const cur = out[i];
|
||||||
|
const next = out[i + 1];
|
||||||
|
if (cur.end >= next.start) {
|
||||||
|
cur.end = Math.max(cur.start + MIN_GAP_MS, next.start - MIN_GAP_MS);
|
||||||
|
}
|
||||||
|
if (cur.end <= cur.start) cur.end = cur.start + MIN_GAP_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segments: out, similarity: match.similarity };
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(a, b) {
|
||||||
|
const max = Math.max(a.length, b.length);
|
||||||
|
if (max === 0) return 1;
|
||||||
|
return 1 - levenshteinDistance(a, b) / max;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
correctTextWithLevenshtein,
|
||||||
|
similarity,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user