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

+5
View File
@@ -0,0 +1,5 @@
type: added
area: subtitles
- Added subtitle filters (`subtitleFilter`) that remove unwanted text from primary subtitles before it reaches the overlay, sidebar, mined sentences and stats. Built-in presets cover text in parentheses (speaker names, readings, notes; whole-line inner thoughts are kept), square-bracket sound descriptions, speaker names before a colon, and music notes. Custom regex rules with a replacement can be added after the presets. Lines left with no words are hidden.
- Configure them in the settings window under Behavior → Subtitle Filters; changes apply live to the current subtitle file.
+16
View File
@@ -854,6 +854,22 @@
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
// Subtitle Filters
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// Applies to the overlay, sidebar, mined sentences and stats.
// Hot-reload: filter changes apply to the current subtitle file immediately.
// ==========================================
"subtitleFilter": {
"presets": {
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
}, // Built-in filters. Each removes its matches and is off by default.
"customRules": [] // 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.
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
+24 -1
View File
@@ -52,7 +52,7 @@ SubMiner watches the config file while running. When it changes, live settings a
These apply live:
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `keybindings`, `shortcuts`
- `subtitleStyle`, `subtitleSidebar`, `subtitleSelection`, `subtitleFilter`, `keybindings`, `shortcuts`
- `logging.level`, `logging.rotation`, `logging.files`
- `secondarySub.defaultMode`, `youtube.primarySubLanguages`, `youtube.subtitleSource`
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`, `stats.toggleKey`, `stats.markWatchedKey`
@@ -221,6 +221,29 @@ Sidebar custom properties: `--subtitle-sidebar-max-width` (`420px`), `--subtitle
If `embedded` layout places the video oddly on your system, switch back to `overlay`.
### Subtitle filters {#subtitle-filters}
Removes unwanted text from primary subtitles, such as speaker names, readings and sound descriptions. Filtering happens as the line arrives, so the overlay, sidebar, mined sentences and stats all see the filtered text. A line left with no words is hidden. In the settings window, find it under **Behavior → Subtitle Filters**.
| Key | Default | What it does |
| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| `subtitleFilter.presets.parentheses` | `false` | Remove `(…)` and `(…)` anywhere in a line. A line wrapped entirely in parentheses (inner thoughts) is kept |
| `subtitleFilter.presets.brackets` | `false` | Remove `[…]`, `[…]`, `【…】` and `〔…〕`, such as sound effects |
| `subtitleFilter.presets.speakerPrefix` | `false` | Remove a short name before a colon at the start of a line, as in `山田:` |
| `subtitleFilter.presets.musicSymbols` | `false` | Remove `♪`, `♫` and `♬` |
| `subtitleFilter.customRules` | `[]` | Your own regex rules, applied in order after the presets |
Each custom rule is `{ "name", "pattern", "replacement", "enabled" }`. Patterns are JavaScript regular expressions compiled with the `gmu` flags, so `^` and `$` match each line. An empty `replacement` removes the match.
```jsonc
{
"subtitleFilter": {
"presets": { "parentheses": true },
"customRules": [{ "name": "Join lines", "pattern": "\\n", "replacement": "", "enabled": true }],
},
}
```
### Subtitle position
You can also drag subtitles with `Right-click + drag` while watching.
+16
View File
@@ -854,6 +854,22 @@
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
// ==========================================
// Subtitle Filters
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// Applies to the overlay, sidebar, mined sentences and stats.
// Hot-reload: filter changes apply to the current subtitle file immediately.
// ==========================================
"subtitleFilter": {
"presets": {
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
}, // Built-in filters. Each removes its matches and is off by default.
"customRules": [] // 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.
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
// ==========================================
// Shared AI Provider
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
+1
View File
@@ -36,6 +36,7 @@ Read when: you need to find the owner module for a behavior or test surface
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
JLPT and frequency annotation lookups: `src/main/jlpt-runtime.ts`, `src/main/frequency-dictionary-runtime.ts`, assembled by `src/main/assemblies/dictionaries.ts`
Subtitle text filters (`subtitleFilter`): `src/shared/subtitle-text-filter.ts`. Primary text is filtered at its two entry points, live `sub-text` in `mpv-protocol.ts` and parsed cues in `playback.ts`, so every consumer sees the same text and live/cue matching still works. A config change re-reads `sub-text` and re-parses the active track (the prefetch controller's parse key).
- First-run setup: `src/main/runtime/first-run-setup-*.ts`, assembled by `src/main/assemblies/first-run-setup.ts`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
Card mining handlers, the pending lookup context, field grouping, and media timing review wiring:
+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';