364 lines
11 KiB
JavaScript
364 lines
11 KiB
JavaScript
"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,
|
||
};
|