feat(subtitles): add regex subtitle filters with built-in presets (#318)

- Add `subtitleFilter` config with four presets: parentheses, brackets, speaker prefix and music symbols. Custom regex rules run after the presets.
- Filter primary text where it enters the app (live `sub-text` and parsed cues), so the overlay, sidebar, mining and stats all get the same filtered text.
- Changes hot-reload: the current line is read again and the active track is parsed again, using the filter config as the prefetch parse key.
- Add a custom rule editor in Settings under Behavior → Subtitle Filters that checks patterns as you type.
- Update the config example, the configuration docs and the architecture domains doc, and add a changelog fragment.
This commit is contained in:
sudacode authored and GitHub committed 2026-11-03 00:33:58 -08:00
1 parent 848a6c4021
commit fa3051f9cb
28 files changed
+740 -11

No files matched your search

+2 -1
View File
@@ -52,7 +52,7 @@ const {
discordPresence,
ai,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar, subtitleFilter } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG;
@@ -78,6 +78,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
plugins,
subtitleStyle,
subtitleSidebar,
subtitleFilter,
auto_start_overlay,
jimaku,
tsukihime,
+13 -1
View File
@@ -1,6 +1,9 @@
import { ResolvedConfig } from '../../types/config';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
export const SUBTITLE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'subtitleStyle' | 'subtitleSidebar' | 'subtitleFilter'
> = {
subtitleStyle: {
primaryDefaultMode: 'visible',
css: {},
@@ -94,4 +97,13 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
subtitleFilter: {
presets: {
parentheses: false,
brackets: false,
speakerPrefix: false,
musicSymbols: false,
},
customRules: [],
},
};
@@ -1,6 +1,10 @@
import { ResolvedConfig } from '../../types/config';
import { ConfigOptionRegistryEntry } from './shared';
import { SUBTITLE_GENERATION_MODELS } from '../../shared/subtitle-generation-model-catalog';
import {
SUBTITLE_FILTER_PRESET_IDS,
SUBTITLE_FILTER_PRESETS,
} from '../../shared/subtitle-text-filter';
export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
@@ -340,5 +344,24 @@ export function buildSubtitleConfigOptionRegistry(
defaultValue: defaultConfig.subtitleSidebar.hoverLineBackgroundColor,
description: 'Background color for hovered subtitle cues.',
},
{
path: 'subtitleFilter.presets',
kind: 'object',
defaultValue: defaultConfig.subtitleFilter.presets,
description: 'Built-in filters. Each removes its matches and is off by default.',
},
...SUBTITLE_FILTER_PRESET_IDS.map((id) => ({
path: `subtitleFilter.presets.${id}`,
kind: 'boolean' as const,
defaultValue: defaultConfig.subtitleFilter.presets[id],
description: SUBTITLE_FILTER_PRESETS[id].description,
})),
{
path: 'subtitleFilter.customRules',
kind: 'array',
defaultValue: defaultConfig.subtitleFilter.customRules,
description:
'Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.',
},
];
}
@@ -153,6 +153,15 @@ const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.'],
key: 'subtitleSidebar',
},
{
title: 'Subtitle Filters',
description: [
'Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.',
'Applies to the overlay, sidebar, mined sentences and stats.',
],
notes: ['Hot-reload: filter changes apply to the current subtitle file immediately.'],
key: 'subtitleFilter',
},
];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
+1
View File
@@ -8,6 +8,7 @@ const HOT_RELOAD_ROOTS = [
'shortcuts',
'subtitleSidebar',
'subtitleSelection',
'subtitleFilter',
] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
+2
View File
@@ -7,12 +7,14 @@ import { applyIntegrationConfig } from './resolve/integrations';
import { applyPluginsConfig } from './resolve/plugins';
import { applyStatsConfig } from './resolve/stats';
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
import { applySubtitleFilterConfig } from './resolve/subtitle-filter';
import { applyTopLevelConfig } from './resolve/top-level';
const APPLY_RESOLVE_STEPS = [
applyTopLevelConfig,
applyCoreDomainConfig,
applySubtitleDomainConfig,
applySubtitleFilterConfig,
applyIntegrationConfig,
applyImmersionTrackingConfig,
applyStatsConfig,
@@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveConfig } from '../resolve';
test('subtitleFilter resolves presets and fills custom rule defaults', () => {
const { resolved, warnings } = resolveConfig({
subtitleFilter: {
presets: { parentheses: true },
customRules: [
{ pattern: '\\n' },
{ name: 'tag', pattern: '<i>', replacement: '', enabled: false },
],
},
});
assert.deepEqual(warnings, []);
assert.equal(resolved.subtitleFilter.presets.parentheses, true);
assert.equal(resolved.subtitleFilter.presets.brackets, false);
assert.deepEqual(resolved.subtitleFilter.customRules, [
{ name: '', pattern: '\\n', replacement: '', enabled: true },
{ name: 'tag', pattern: '<i>', replacement: '', enabled: false },
]);
});
test('subtitleFilter keeps empty and invalid patterns, warns on invalid, drops malformed rules', () => {
const { resolved, warnings } = resolveConfig({
subtitleFilter: {
presets: { brackets: 'yes' as unknown as boolean },
customRules: [
{ name: 'broken', pattern: '(' },
{ name: 'placeholder', pattern: '' },
{ name: 'no pattern' },
'oops' as never,
],
},
});
assert.deepEqual(
resolved.subtitleFilter.customRules.map((rule) => rule.name),
['broken', 'placeholder'],
);
assert.equal(resolved.subtitleFilter.presets.brackets, false);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'subtitleFilter.presets.brackets',
'subtitleFilter.customRules[0].pattern',
'subtitleFilter.customRules[2]',
'subtitleFilter.customRules[3]',
],
);
});
+75
View File
@@ -0,0 +1,75 @@
import type { SubtitleFilterRule } from '../../types/subtitle';
import {
SUBTITLE_FILTER_PRESET_IDS,
getSubtitleFilterPatternError,
} from '../../shared/subtitle-text-filter';
import { ResolveContext } from './context';
import { asBoolean, asString, isObject } from './shared';
function resolveCustomRule(
raw: unknown,
index: number,
warn: ResolveContext['warn'],
): SubtitleFilterRule | null {
const path = `subtitleFilter.customRules[${index}]`;
const pattern = isObject(raw) ? asString(raw.pattern) : undefined;
if (!isObject(raw) || pattern === undefined) {
warn(path, raw, null, 'Expected an object with a string pattern.');
return null;
}
// Empty and invalid patterns stay in the config so the settings window can show and
// fix them; the filter skips both at runtime.
const patternError = pattern ? getSubtitleFilterPatternError(pattern) : null;
if (patternError !== null) {
warn(`${path}.pattern`, pattern, null, `Invalid regular expression: ${patternError}`);
}
return {
name: asString(raw.name) ?? '',
pattern,
replacement: asString(raw.replacement) ?? '',
enabled: asBoolean(raw.enabled) ?? true,
};
}
export function applySubtitleFilterConfig(context: ResolveContext): void {
const { src, resolved, warn } = context;
if (src.subtitleFilter === undefined) return;
if (!isObject(src.subtitleFilter)) {
warn('subtitleFilter', src.subtitleFilter, resolved.subtitleFilter, 'Expected object.');
return;
}
const { presets, customRules } = src.subtitleFilter;
if (isObject(presets)) {
for (const id of SUBTITLE_FILTER_PRESET_IDS) {
const enabled = asBoolean(presets[id]);
if (enabled !== undefined) {
resolved.subtitleFilter.presets[id] = enabled;
} else if (presets[id] !== undefined) {
warn(
`subtitleFilter.presets.${id}`,
presets[id],
resolved.subtitleFilter.presets[id],
'Expected boolean.',
);
}
}
} else if (presets !== undefined) {
warn('subtitleFilter.presets', presets, resolved.subtitleFilter.presets, 'Expected object.');
}
if (Array.isArray(customRules)) {
resolved.subtitleFilter.customRules = customRules
.map((rule, index) => resolveCustomRule(rule, index, warn))
.filter((rule): rule is SubtitleFilterRule => rule !== null);
} else if (customRules !== undefined) {
warn(
'subtitleFilter.customRules',
customRules,
resolved.subtitleFilter.customRules,
'Expected an array of rules.',
);
}
}
+18
View File
@@ -6,6 +6,10 @@ import type {
ConfigSettingsField,
} from '../../types/settings';
import { CONFIG_OPTION_REGISTRY, DEFAULT_CONFIG } from '../definitions';
import {
SUBTITLE_FILTER_PRESET_IDS,
SUBTITLE_FILTER_PRESETS,
} from '../../shared/subtitle-text-filter';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
@@ -127,6 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
'Subtitle Sidebar Appearance',
'Playback Behavior',
'Subtitle Behavior',
'Subtitle Filters',
'Subtitle Sidebar Behavior',
'YouTube Playback Settings',
'mpv Playback',
@@ -203,6 +208,7 @@ const PATH_ORDER = new Map<string, number>(
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
...SUBTITLE_FILTER_PRESET_IDS.map((id) => `subtitleFilter.presets.${id}`),
].map((path, index) => [path, index]),
);
@@ -253,6 +259,12 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
'ankiConnect.media.reviewTiming': 'Review Media Timing',
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
...Object.fromEntries(
SUBTITLE_FILTER_PRESET_IDS.map((id) => [
`subtitleFilter.presets.${id}`,
SUBTITLE_FILTER_PRESETS[id].label,
]),
),
};
const DESCRIPTION_OVERRIDES: Record<string, string> = {
@@ -280,6 +292,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
'discordPresence.updateIntervalMs':
'Minimum interval between presence payload updates, in milliseconds.',
'subtitleFilter.customRules':
'Your own regex rules, applied in order after the presets. ^ and $ match each line. Leave the replacement empty to remove the match.',
};
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -353,6 +367,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path === 'subtitleStyle.preserveLineBreaks') {
return { category: 'behavior', section: 'Subtitle Behavior' };
}
if (path.startsWith('subtitleFilter.')) {
return { category: 'behavior', section: 'Subtitle Filters' };
}
if (
path === 'ankiConnect.knownWords.addMinedWordsImmediately' ||
path === 'ankiConnect.knownWords.decks' ||
@@ -557,6 +574,7 @@ function controlForPath(path: string, value: unknown): ConfigSettingsControl {
if (path.startsWith('subtitleStyle.jlptColors.')) return 'color';
if (path.startsWith('subtitleStyle.knownWordMaturityColors.')) return 'color';
if (path === 'subtitleStyle.frequencyDictionary.bandedColors') return 'color-list';
if (path === 'subtitleFilter.customRules') return 'subtitle-filter-rules';
if (OPTION_BY_PATH.get(path)?.enumValues?.length) return 'select';
if (JSON_OBJECT_FIELDS.has(path)) return 'json';
if (Array.isArray(value)) return 'string-list';
+11 -2
View File
@@ -1,6 +1,8 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
import { filterSubtitleText } from '../../shared/subtitle-text-filter';
import type { SubtitleFilterConfig } from '../../types/subtitle';
import type { PluginMpvEventMap } from '../../plugin-api';
import { toVideoClock } from './subtitle-clock';
@@ -68,6 +70,7 @@ export interface MpvProtocolParseResult {
export interface MpvProtocolHandleMessageDeps {
getResolvedConfig: () => {
secondarySub?: { secondarySubLanguages?: Array<string> };
subtitleFilter?: SubtitleFilterConfig;
};
getSubtitleMetrics: () => MpvSubtitleRenderMetrics;
isVisibleOverlayVisible: () => boolean;
@@ -263,13 +266,19 @@ export function splitMpvMessagesFromBuffer(
};
}
// Primary text enters the app here, so every consumer sees it widened and filtered.
function toPrimarySubText(data: unknown, deps: MpvProtocolHandleMessageDeps): string {
const text = widenHalfwidthKana(typeof data === 'string' ? data : '');
return filterSubtitleText(text, deps.getResolvedConfig().subtitleFilter);
}
export async function dispatchMpvProtocolMessage(
msg: MpvMessage,
deps: MpvProtocolHandleMessageDeps,
): Promise<void> {
if (msg.event === 'property-change') {
if (msg.name === 'sub-text') {
const nextSubText = widenHalfwidthKana((msg.data as string) || '');
const nextSubText = toPrimarySubText(msg.data, deps);
const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({
text: nextSubText,
@@ -498,7 +507,7 @@ export async function dispatchMpvProtocolMessage(
}>,
);
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
const nextSubText = widenHalfwidthKana((msg.data as string) || '');
const nextSubText = toPrimarySubText(msg.data, deps);
deps.setCurrentSubText(nextSubText);
deps.emitSubtitleChange({
text: nextSubText,
+6
View File
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types';
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_SUBTEXT,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
MpvMessage,
@@ -585,6 +586,11 @@ export class MpvIpcClient implements MpvClient {
});
}
/** Re-reads `sub-text` so the current line passes through the protocol handler again. */
refreshSubtitleText(): void {
this.send({ command: ['get_property', 'sub-text'], request_id: MPV_REQUEST_ID_SUBTEXT });
}
async requestProperty(name: string): Promise<unknown> {
const response = await this.request(['get_property', name]);
if (response.error && response.error !== 'success') {
+4
View File
@@ -1112,6 +1112,10 @@ const settings = createSettingsAssembly({
refreshCurrentSubtitle: () => {
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
},
refreshSubtitleFilter: () => {
appState.mpvClient?.refreshSubtitleText();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
},
setLogLevel: (level) => {
setLogLevel(level, 'config');
},
+11 -2
View File
@@ -9,6 +9,7 @@ import {
tokenizeSubtitle as tokenizeSubtitleCore,
} from '../../core/services';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { filterSubtitleCues } from '../../shared/subtitle-text-filter';
import { resolveAniSkipMetadataForFile } from '../runtime/aniskip-metadata';
import { createAniSkipRuntime } from '../runtime/aniskip-runtime';
import { resolveCurrentSubtitleForRenderer } from '../runtime/current-subtitle-snapshot';
@@ -452,13 +453,21 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
},
});
// Primary cues get the same subtitleFilter pass as live mpv text, so the two still match.
const parsePrimarySubtitleCues = (content: string, filename: string) =>
filterSubtitleCues(
parseSubtitleCues(content, filename),
configService.getConfig().subtitleFilter,
);
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
getCurrentService: () => subtitlePrefetchService,
setCurrentService: (service) => {
subtitlePrefetchService = service;
},
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
parseSubtitleCues: parsePrimarySubtitleCues,
getParseKey: () => JSON.stringify(configService.getConfig().subtitleFilter),
createSubtitlePrefetchService: (serviceDeps) => createSubtitlePrefetchService(serviceDeps),
tokenizeSubtitle: async (text) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
@@ -1126,7 +1135,7 @@ export function createPlaybackAssembly(deps: PlaybackAssemblyDeps) {
}
const content = await loadSubtitleSourceText(resolvedSource.path);
const cues = parseSubtitleCues(content, resolvedSource.path);
const cues = parsePrimarySubtitleCues(content, resolvedSource.path);
appState.activeParsedSubtitleCues = cues;
appState.activeParsedSubtitleSource = resolvedSource.sourceKey;
appState.activeParsedSubtitleMediaPath = videoPath || null;
@@ -20,6 +20,8 @@ type ConfigHotReloadAppliedDeps = {
invalidateTokenizationCache?: () => void;
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
/** Re-reads the live line and re-parses the active cue list through the new filters. */
refreshSubtitleFilter?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
@@ -180,6 +182,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
deps.applyAnkiRuntimeConfigPatch(ankiPatch);
}
if (diff.hotReloadFields.includes('subtitleFilter')) {
deps.refreshSubtitleFilter?.();
}
if (hasAnnotationRuntimeHotReload(diff)) {
deps.invalidateTokenizationCache?.();
deps.refreshSubtitlePrefetch?.();
@@ -252,7 +252,7 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
});
function createTrackingController(loadContent: () => string) {
function createTrackingController(loadContent: () => string, getParseKey?: () => string) {
let currentService: SubtitlePrefetchService | null = null;
const events: string[] = [];
const cueUpdates: Array<string | null> = [];
@@ -265,6 +265,7 @@ function createTrackingController(loadContent: () => string) {
},
loadSubtitleSourceText: async () => loadContent(),
parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }],
getParseKey,
createSubtitlePrefetchService: () => {
const id = ++serviceCount;
return {
@@ -308,6 +309,20 @@ test('re-initializing a source whose content changed restarts the service', asyn
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
});
test('re-initializing an unchanged source re-parses when the parse key changed', async () => {
let parseKey = 'filters-off';
const { controller, events } = createTrackingController(
() => 'content',
() => parseKey,
);
await controller.initSubtitlePrefetch('track-1.srt', 0);
parseKey = 'filters-on';
await controller.initSubtitlePrefetch('track-1.srt', 0);
assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']);
});
test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => {
let prefetchedTexts: string[] = [];
let publishedTexts: string[] = [];
+11 -2
View File
@@ -11,6 +11,8 @@ export interface SubtitlePrefetchInitControllerDeps {
setCurrentService: (service: SubtitlePrefetchService | null) => void;
loadSubtitleSourceText: (source: string) => Promise<string>;
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
/** Changes whenever parseSubtitleCues would produce different cues for the same content. */
getParseKey?: () => string;
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
@@ -37,7 +39,12 @@ export function createSubtitlePrefetchInitController(
// What the running service was built from. Startup fires several inits for one source
// (Jellyfin preload seed, sid change, each track-list update); restarting on each would
// discard the in-flight tokenization and re-queue the priority window behind it.
let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null;
let activeSource: {
key: string;
content: string;
parseKey: string;
cues: SubtitleCue[];
} | null = null;
const stopCurrentService = (): void => {
deps.getCurrentService()?.stop();
@@ -67,9 +74,11 @@ export function createSubtitlePrefetchInitController(
if (revision !== initRevision) {
return;
}
const parseKey = deps.getParseKey?.() ?? '';
if (
activeSource?.key === sourceKey &&
activeSource.content === content &&
activeSource.parseKey === parseKey &&
deps.getCurrentService()
) {
// A track change clears the published cues synchronously, so republish them.
@@ -106,7 +115,7 @@ export function createSubtitlePrefetchInitController(
}
deps.setCurrentService(nextService);
activeSource = { key: sourceKey, content, cues };
activeSource = { key: sourceKey, content, parseKey, cues };
deps.onParsedSubtitleCuesChanged?.(cues, sourceKey);
nextService.start(currentTimePos);
deps.logInfo(
+9
View File
@@ -17,6 +17,10 @@ import {
renderKeyboardInput,
renderMpvKeybindingsInput,
} from './settings-keybinding-controls';
import {
configureSubtitleFilterControls,
renderSubtitleFilterRulesInput,
} from './settings-subtitle-filter-controls';
import {
getSubtitleCssManagedConfigPaths,
getSubtitleCssScopeForPath,
@@ -29,6 +33,7 @@ export { renderNoteFieldModelPicker };
export function configureSettingsControls(options: { requestRender: () => void }): void {
configureAnkiControls(options);
configureKeybindingControls(options);
configureSubtitleFilterControls(options);
}
export function initializeSettingsControls(
@@ -179,6 +184,10 @@ export function renderControl(
return renderMpvKeybindingsInput(context, field);
}
if (field.control === 'subtitle-filter-rules') {
return renderSubtitleFilterRulesInput(context, field);
}
if (field.control === 'boolean') {
const label = createElement('label', 'switch-control');
const input = createElement('input') as HTMLInputElement;
@@ -0,0 +1,153 @@
import { getSubtitleFilterPatternError } from '../shared/subtitle-text-filter';
import type { ConfigSettingsField } from '../types/settings';
import type { SubtitleFilterRule } from '../types/subtitle';
import type { SettingsControlContext } from './settings-control-context';
import { createElement } from './settings-control-dom';
let requestRender = (): void => undefined;
export function configureSubtitleFilterControls(options: { requestRender: () => void }): void {
requestRender = options.requestRender;
}
function toRules(value: unknown): SubtitleFilterRule[] {
if (!Array.isArray(value)) return [];
return value
.filter(
(rule): rule is Partial<SubtitleFilterRule> => rule !== null && typeof rule === 'object',
)
.map((rule) => ({
name: rule.name ?? '',
pattern: rule.pattern ?? '',
replacement: rule.replacement ?? '',
enabled: rule.enabled ?? true,
}));
}
function renderTextInput(
value: string,
placeholder: string,
className: string,
onInput: (value: string) => void,
): HTMLInputElement {
const input = createElement('input', className) as HTMLInputElement;
input.type = 'text';
input.spellcheck = false;
input.value = value;
input.placeholder = placeholder;
input.addEventListener('input', () => onInput(input.value));
return input;
}
/** Editor for `subtitleFilter.customRules`: one card per rule, validated as you type. */
export function renderSubtitleFilterRulesInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const rules = toRules(context.valueForField(field));
const patternErrors = new Map<number, string>();
const container = createElement('div', 'stacked-control');
// An invalid pattern blocks saving; empty patterns are inert placeholders.
const syncFieldError = (): void => {
const invalid = [...patternErrors.keys()].sort((a, b) => a - b);
context.setFieldError(
field.configPath,
invalid.length > 0 ? `Invalid pattern in rule ${invalid[0]! + 1}` : null,
);
};
const commit = (): void => {
syncFieldError();
context.updateDraft(
field.configPath,
rules.map((rule) => ({ ...rule })),
);
};
rules.forEach((rule, index) => {
const card = createElement('div', 'deck-field-row');
const header = createElement('div', 'deck-field-row-header');
const toggle = createElement('label', 'switch-control');
toggle.title = 'Enabled';
const checkbox = createElement('input') as HTMLInputElement;
checkbox.type = 'checkbox';
checkbox.checked = rule.enabled;
checkbox.addEventListener('change', () => {
rule.enabled = checkbox.checked;
commit();
});
toggle.append(checkbox, createElement('span', 'switch-track'));
const name = renderTextInput(
rule.name,
'Rule name',
'config-input deck-field-row-name',
(v) => {
rule.name = v;
commit();
},
);
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
removeButton.type = 'button';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
rules.splice(index, 1);
commit();
requestRender();
});
header.append(toggle, name, removeButton);
const errorHint = createElement('div', 'control-hint error');
const showPatternError = (input: HTMLInputElement): void => {
const error = input.value ? getSubtitleFilterPatternError(input.value) : null;
input.classList.toggle('invalid', error !== null);
errorHint.textContent = error ?? '';
errorHint.hidden = error === null;
if (error === null) patternErrors.delete(index);
else patternErrors.set(index, error);
};
const pattern = renderTextInput(
rule.pattern,
'Regex pattern, e.g. [((][^))]*[))]',
'config-input mono-input',
(v) => {
rule.pattern = v;
showPatternError(pattern);
commit();
},
);
showPatternError(pattern);
const replacement = renderTextInput(
rule.replacement,
'Replacement (leave empty to remove the match)',
'config-input mono-input',
(v) => {
rule.replacement = v;
commit();
},
);
card.append(header, pattern, errorHint, replacement);
container.append(card);
});
syncFieldError();
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
addButton.type = 'button';
addButton.textContent = 'Add Rule';
addButton.addEventListener('click', () => {
rules.push({ name: '', pattern: '', replacement: '', enabled: true });
context.updateDraft(
field.configPath,
rules.map((rule) => ({ ...rule })),
);
requestRender();
});
container.append(addButton);
return container;
}
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleCue, SubtitleFilterConfig } from '../types/subtitle';
import {
filterSubtitleCues,
filterSubtitleText,
getSubtitleFilterPatternError,
} from './subtitle-text-filter';
const presets = (...ids: Array<keyof NonNullable<SubtitleFilterConfig['presets']>>) => ({
presets: Object.fromEntries(ids.map((id) => [id, true])),
});
test('parentheses preset removes speaker names, readings and notes anywhere in a line', () => {
const config = presets('parentheses');
assert.equal(filterSubtitleText('(山田) 元気ですか?', config), '元気ですか?');
assert.equal(filterSubtitleText('漢字(かんじ)を読む。', config), '漢字を読む。');
assert.equal(filterSubtitleText('そうなんだ(笑)', config), 'そうなんだ');
assert.equal(filterSubtitleText('Hello (laughs) there', config), 'Hello there');
});
test('parentheses preset keeps a line wrapped entirely in parentheses', () => {
const config = presets('parentheses');
assert.equal(filterSubtitleText('(どうしよう…)', config), '(どうしよう…)');
assert.equal(
filterSubtitleText('(山田)おはよう\n(眠いな…)', config),
'おはよう\n(眠いな…)',
);
});
test('a line emptied by a filter is dropped, an untouched symbol-only line is kept', () => {
assert.equal(filterSubtitleText('[拍手]', presets('brackets')), '');
assert.equal(filterSubtitleText('♪~\n歌詞だよ', presets('musicSymbols')), '歌詞だよ');
assert.equal(filterSubtitleText('[拍手]\n……', presets('brackets')), '……');
});
test('speaker prefix preset only strips a short name before a colon at line start', () => {
const config = presets('speakerPrefix');
assert.equal(filterSubtitleText('山田:元気?', config), '元気?');
assert.equal(filterSubtitleText('10:30に集合', config), '10:30に集合');
});
test('custom rules run after presets, in order, with their replacement', () => {
const config: SubtitleFilterConfig = {
presets: { parentheses: true },
customRules: [
{ name: 'skip', pattern: 'x', enabled: false },
{ name: 'join lines', pattern: '\\n', replacement: '' },
{ name: 'invalid', pattern: '(' },
],
};
assert.equal(filterSubtitleText('(山田)こんにちは\n世界x', config), 'こんにちは世界x');
});
test('each rule sees the tidied output of the rules before it', () => {
const config: SubtitleFilterConfig = {
presets: { parentheses: true },
customRules: [{ name: 'greeting', pattern: '^今日は', replacement: '' }],
};
assert.equal(filterSubtitleText('(山田) 今日はいい天気', config), 'いい天気');
});
test('filterSubtitleCues drops emptied cues and keeps parser metadata', () => {
const cue: SubtitleCue = { startTime: 1, endTime: 2, text: '(山田)はい' };
Object.defineProperty(cue, 'assFurigana', { value: ['やまだ'], enumerable: false });
const cues = filterSubtitleCues(
[cue, { startTime: 3, endTime: 4, text: '[拍手]' }],
presets('parentheses', 'brackets'),
);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'はい');
assert.deepEqual(cues[0]!.assFurigana, ['やまだ']);
});
test('getSubtitleFilterPatternError reports invalid and empty patterns', () => {
assert.equal(getSubtitleFilterPatternError('[((].*?[))]'), null);
assert.match(getSubtitleFilterPatternError('(') ?? '', /Invalid|Unterminated/);
assert.equal(getSubtitleFilterPatternError(''), 'Pattern is empty.');
});
+152
View File
@@ -0,0 +1,152 @@
import type { SubtitleCue, SubtitleFilterConfig, SubtitleFilterPresetId } from '../types/subtitle';
/**
* Subtitle text filters (`subtitleFilter` config): built-in presets plus user regex rules,
* applied to primary subtitle text as it enters the app (live mpv `sub-text` and parsed
* cues), so the overlay, sidebar, tokenizer, mining and stats all see the same text.
* Shared with the settings window, which validates custom patterns as they are typed.
*/
interface SubtitleFilterPreset {
label: string;
description: string;
pattern: string;
}
/** Every rule compiles with these flags: global, per-line `^`/`$`, and Unicode escapes. */
export const SUBTITLE_FILTER_REGEX_FLAGS = 'gmu';
// One pair of parentheses without nesting or line breaks.
const PARENTHESIZED = '[((][^()()\\n]*[))]';
// Horizontal whitespace, including the full-width space.
const LINE_SPACE = '[^\\S\\n]*';
export const SUBTITLE_FILTER_PRESETS: Record<SubtitleFilterPresetId, SubtitleFilterPreset> = {
parentheses: {
label: 'Text in Parentheses',
description:
'Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept.',
// Matches unless the parentheses both start and end the line.
pattern: `(?<!^${LINE_SPACE})${PARENTHESIZED}|${PARENTHESIZED}(?!${LINE_SPACE}$)`,
},
brackets: {
label: 'Text in Square Brackets',
description: 'Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues.',
pattern: '[[\\[【〔][^[]\\[\\]【】〔〕\\n]*[]\\]】〕]',
},
speakerPrefix: {
label: 'Speaker Names Before a Colon',
description:
'Remove a short name followed by a colon at the start of a line, as in 山田:元気?',
pattern: '^[^\\s\\d::()()「」『』]{1,12}[::]',
},
musicSymbols: {
label: 'Music Notes',
description: 'Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed.',
pattern: '[♪♫♬]+',
},
};
export const SUBTITLE_FILTER_PRESET_IDS = Object.keys(
SUBTITLE_FILTER_PRESETS,
) as SubtitleFilterPresetId[];
interface CompiledSubtitleFilterRule {
regex: RegExp;
replacement: string;
}
/** Returns the compile error for a custom rule pattern, or null when it is valid. */
export function getSubtitleFilterPatternError(pattern: string): string | null {
if (!pattern) return 'Pattern is empty.';
try {
new RegExp(pattern, SUBTITLE_FILTER_REGEX_FLAGS);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
// Config objects are replaced, never mutated, on reload, so identity keys the cache.
const compiledRulesCache = new WeakMap<SubtitleFilterConfig, CompiledSubtitleFilterRule[]>();
function compileSubtitleFilterRules(config: SubtitleFilterConfig): CompiledSubtitleFilterRule[] {
const cached = compiledRulesCache.get(config);
if (cached) return cached;
const rules: CompiledSubtitleFilterRule[] = [];
for (const id of SUBTITLE_FILTER_PRESET_IDS) {
if (config.presets?.[id] === true) {
const regex = new RegExp(SUBTITLE_FILTER_PRESETS[id].pattern, SUBTITLE_FILTER_REGEX_FLAGS);
rules.push({ regex, replacement: '' });
}
}
for (const rule of config.customRules ?? []) {
if (rule.enabled === false || !rule.pattern) continue;
// Invalid patterns are reported when the config resolves; here they are skipped.
if (getSubtitleFilterPatternError(rule.pattern) !== null) continue;
rules.push({
regex: new RegExp(rule.pattern, SUBTITLE_FILTER_REGEX_FLAGS),
replacement: rule.replacement ?? '',
});
}
compiledRulesCache.set(config, rules);
return rules;
}
export function hasActiveSubtitleFilters(config: SubtitleFilterConfig | undefined): boolean {
return config !== undefined && compileSubtitleFilterRules(config).length > 0;
}
const HAS_CONTENT = /[\p{L}\p{N}]/u;
/**
* Tidies the lines a filter changed: collapses the double space a removed word leaves,
* trims the line, and drops it when only punctuation or symbols remain. Lines the filter
* did not touch are kept as authored, so a line of just "……" survives.
*/
function tidyFilteredLines(original: string, filtered: string): string {
const before = original.split('\n');
const after = filtered.split('\n');
const sameShape = before.length === after.length;
const lines: string[] = [];
after.forEach((line, index) => {
if (sameShape && line === before[index]) {
lines.push(line);
return;
}
const tidied = line.replace(/ {2,}/g, ' ').trim();
if (HAS_CONTENT.test(tidied)) lines.push(tidied);
});
return lines.join('\n').replace(/^\n+|\n+$/g, '');
}
/**
* Applies the enabled presets, then custom rules in order. Returns '' for an emptied line.
* Lines are tidied after each rule, so a later `^` anchor is not blocked by the space an
* earlier rule left at the start of a line.
*/
export function filterSubtitleText(text: string, config: SubtitleFilterConfig | undefined): string {
if (!text || !config) return text;
let filtered = text;
for (const rule of compileSubtitleFilterRules(config)) {
const next = filtered.replace(rule.regex, rule.replacement);
if (next !== filtered) filtered = tidyFilteredLines(filtered, next);
}
return filtered;
}
/**
* Filters freshly parsed cues in place and drops cues the filter emptied. Cue objects are
* mutated rather than copied because the parser attaches non-enumerable metadata.
*/
export function filterSubtitleCues(
cues: SubtitleCue[],
config: SubtitleFilterConfig | undefined,
): SubtitleCue[] {
if (!hasActiveSubtitleFilters(config)) return cues;
return cues.filter((cue) => {
cue.text = filterSubtitleText(cue.text, config);
return cue.text.length > 0;
});
}
+4
View File
@@ -36,9 +36,11 @@ import type {
FrequencyDictionaryMatchMode,
FrequencyDictionaryMode,
NPlusOneMatchMode,
ResolvedSubtitleFilterConfig,
ResolvedSubtitleSidebarConfig,
SecondarySubConfig,
SubtitlePosition,
SubtitleFilterConfig,
SubtitleSidebarConfig,
SubtitleStyleConfig,
} from './subtitle';
@@ -170,6 +172,7 @@ export interface Config {
startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig;
subtitleSidebar?: SubtitleSidebarConfig;
subtitleFilter?: SubtitleFilterConfig;
auto_start_overlay?: boolean;
jimaku?: JimakuConfig;
/** @deprecated Use tsukihime. */
@@ -344,6 +347,7 @@ export interface ResolvedConfig {
};
};
subtitleSidebar: ResolvedSubtitleSidebarConfig;
subtitleFilter: ResolvedSubtitleFilterConfig;
auto_start_overlay: boolean;
jimaku: JimakuConfig & {
apiBaseUrl: string;
+2 -1
View File
@@ -28,7 +28,8 @@ export type ConfigSettingsControl =
| 'anki-field'
| 'mpv-keybindings'
| 'color-list'
| 'css-declarations';
| 'css-declarations'
| 'subtitle-filter-rules';
export type ConfigSettingsRestartBehavior = 'hot-reload' | 'restart';
+21
View File
@@ -84,6 +84,27 @@ export interface SecondarySubConfig {
defaultMode?: SecondarySubMode;
}
/** Built-in subtitle text filters; `subtitle-text-filter.ts` holds their patterns. */
export type SubtitleFilterPresetId = 'parentheses' | 'brackets' | 'speakerPrefix' | 'musicSymbols';
/** A user regex rule. Patterns compile with the `gmu` flags, so `^`/`$` match per line. */
export interface SubtitleFilterRule {
name: string;
pattern: string;
replacement: string;
enabled: boolean;
}
export interface SubtitleFilterConfig {
presets?: Partial<Record<SubtitleFilterPresetId, boolean>>;
customRules?: Partial<SubtitleFilterRule>[];
}
export interface ResolvedSubtitleFilterConfig {
presets: Record<SubtitleFilterPresetId, boolean>;
customRules: SubtitleFilterRule[];
}
export type NPlusOneMatchMode = 'headword' | 'surface';
export type FrequencyDictionaryMatchMode = 'headword' | 'surface';