111
This commit is contained in:
@@ -10,6 +10,15 @@ const { spawnSync } = require("child_process");
|
||||
|
||||
const pipelineNative = require("./pipeline_native.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");
|
||||
|
||||
@@ -123,20 +132,63 @@ function normalizeAsrTimeToMs(value) {
|
||||
return Math.round(n * 1000);
|
||||
}
|
||||
|
||||
function normalizeAsrRecords(records) {
|
||||
if (!Array.isArray(records)) return [];
|
||||
const normalized = records
|
||||
/** 对齐 ZimuShengcheng.shouldTreatTimelineAsSeconds */
|
||||
function shouldTreatTimelineAsSeconds(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) => {
|
||||
const text = String(record?.text || record?.sentence || "").trim();
|
||||
if (!text) return null;
|
||||
let start = normalizeAsrTimeToMs(
|
||||
record.start ?? record.begin ?? record.beginTime ?? 0,
|
||||
);
|
||||
let end = normalizeAsrTimeToMs(
|
||||
record.end ?? record.endTime ?? start + 2000,
|
||||
);
|
||||
return {
|
||||
text,
|
||||
start: Number(record.start ?? record.begin ?? record.beginTime ?? 0),
|
||||
end: Number(record.end ?? record.endTime ?? 0),
|
||||
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;
|
||||
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, start, end };
|
||||
return { text: record.text, start, end, words: record.words };
|
||||
})
|
||||
.filter(Boolean);
|
||||
const MIN_GAP_MS = 50;
|
||||
@@ -194,6 +246,36 @@ function cleanSubtitleDisplayText(text) {
|
||||
.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) {
|
||||
const lines = splitScriptLines(script);
|
||||
const normalized = normalizeAsrRecords(records);
|
||||
@@ -337,7 +419,7 @@ function resolveAssLayout(position, videoHeight) {
|
||||
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 width = Math.max(320, Number(videoWidth) || 1920);
|
||||
const height = Math.max(320, Number(videoHeight) || 1080);
|
||||
@@ -349,13 +431,26 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) {
|
||||
height,
|
||||
);
|
||||
const hasBg = style.hasBg;
|
||||
const borderStyle = hasBg ? 3 : 1;
|
||||
const finalOutline = hasBg ? 1 : outline;
|
||||
const backColour = hasBg
|
||||
? hexToAssColor(style.backgroundColor)
|
||||
: "&H00000000";
|
||||
let borderStyle = 1;
|
||||
let finalOutline = outline;
|
||||
let finalShadow = 0;
|
||||
let backColour = "&H00000000";
|
||||
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 = [
|
||||
"[Script Info]",
|
||||
@@ -368,7 +463,7 @@ function buildAssContent(records, styleInput, videoWidth, videoHeight) {
|
||||
"",
|
||||
"[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",
|
||||
`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]",
|
||||
"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 start = formatAssTime(r.start);
|
||||
const end = formatAssTime(Math.max(r.end, r.start + 500));
|
||||
const text = assEscapeText(wrapSubtitleText(r.text, style.maxCharsPerLine));
|
||||
if (!text) return "";
|
||||
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));
|
||||
if (!text.replace(/\\N/g, "").trim()) return "";
|
||||
return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`;
|
||||
}).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")}`;
|
||||
}
|
||||
|
||||
function addSubtitleToVideo(inputVideo, subtitlePath, styleInput) {
|
||||
/**
|
||||
* @param {string} [keywordDrawtextFilter] 逗号分隔的 drawtext 滤镜(关键词高亮)
|
||||
*/
|
||||
function addSubtitleToVideo(inputVideo, subtitlePath, keywordDrawtextFilter) {
|
||||
const outputVideo = makeOutputPath("with_subtitle");
|
||||
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]);
|
||||
return outputVideo;
|
||||
}
|
||||
@@ -521,7 +628,7 @@ globalThis.__nodejsMain = async function main(params) {
|
||||
: 0,
|
||||
});
|
||||
if (p.smartSubtitle !== false && scriptContent) {
|
||||
records = alignScriptToRecords(scriptContent, records, videoDuration);
|
||||
records = applySmartSubtitle(scriptContent, records, videoDuration);
|
||||
logSubtitle("info", "智能字幕对齐后", { count: records.length });
|
||||
}
|
||||
if (!records.length) {
|
||||
@@ -553,15 +660,44 @@ globalThis.__nodejsMain = async function main(params) {
|
||||
}
|
||||
|
||||
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");
|
||||
fs.writeFileSync(
|
||||
assPath,
|
||||
buildAssContent(records, burnStyle, width, height),
|
||||
buildAssContent(records, burnStyle, width, height, keywordMatches),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在烧录字幕到视频…");
|
||||
current = addSubtitleToVideo(current, assPath, burnStyle);
|
||||
globalThis.__native?.emitProgress?.(
|
||||
keywordDrawtextFilter
|
||||
? "正在烧录字幕并叠加关键词特效…"
|
||||
: "正在烧录字幕到视频…",
|
||||
);
|
||||
current = addSubtitleToVideo(current, assPath, keywordDrawtextFilter);
|
||||
pipelineNative.deleteFile(audioPath);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user