"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} 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} 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[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} matches * @param {{ videoWidth: number, videoHeight: number, subtitleStyle?: Record }} 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, };