Files
yaoyaoai/scripts/nodejs/subtitle_keyword_drawtext.js
949036910@qq.com d637088a34 11
2026-06-01 22:47:04 +08:00

384 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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);
/** 用 gte*lte 避免 between(t,a,b) 中逗号打断 -vf 滤镜链 */
const enable = `gte(t\\,${t0})*lte(t\\,${t1})`;
let fontsizePart = String(baseSize);
if (match.effectType === "pulse-scale") {
fontsizePart = `${baseSize}*(1+0.08*sin(8*PI*t))`;
}
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"),
};
const filters = matches.map((m) => buildDrawtextForMatch(m, layout));
const chain = filters.join(",");
if (chain.length > 6000) {
logKeywordWarn("drawtext 滤镜过长,仅保留前 40 个关键词");
return filters.slice(0, 40).join(",");
}
return chain;
}
function logKeywordWarn(msg, fields) {
if (globalThis.__native?.log) {
globalThis.__native.log("warn", `[autoGenerateSubtitleAndBGM] ${msg}`, fields ?? null);
}
}
/**
* 从 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, text?: string }} record 毫秒 */
function filterMatchesForRecord(record, matches) {
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 = {
buildKeywordGroups,
matchKeywordsInRecords,
buildKeywordDrawtextFilter,
normalizeDisplayText,
stripKeywordsFromDisplay,
filterMatchesForRecord,
isInlineKeywordRenderMode,
resolveFontFile,
};