diff --git a/changes/subtitle-filters.md b/changes/subtitle-filters.md new file mode 100644 index 00000000..75c5988a --- /dev/null +++ b/changes/subtitle-filters.md @@ -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. diff --git a/config.example.jsonc b/config.example.jsonc index 247ba546..5a3999e3 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 0e1126a7..936aabf6 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index 247ba546..5a3999e3 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index 7611a9f2..400418d3 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -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: diff --git a/src/config/definitions.ts b/src/config/definitions.ts index edaf26e4..ca7a6f3d 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -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, diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts index 4d4e5387..c2d4c6e9 100644 --- a/src/config/definitions/defaults-subtitle.ts +++ b/src/config/definitions/defaults-subtitle.ts @@ -1,6 +1,9 @@ import { ResolvedConfig } from '../../types/config'; -export const SUBTITLE_DEFAULT_CONFIG: Pick = { +export const SUBTITLE_DEFAULT_CONFIG: Pick< + ResolvedConfig, + 'subtitleStyle' | 'subtitleSidebar' | 'subtitleFilter' +> = { subtitleStyle: { primaryDefaultMode: 'visible', css: {}, @@ -94,4 +97,13 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick ({ + 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.', + }, ]; } diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 8ccd2f1c..cf27e8a6 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -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[] = [ diff --git a/src/config/hot-reload.ts b/src/config/hot-reload.ts index 4375c473..35bd6064 100644 --- a/src/config/hot-reload.ts +++ b/src/config/hot-reload.ts @@ -8,6 +8,7 @@ const HOT_RELOAD_ROOTS = [ 'shortcuts', 'subtitleSidebar', 'subtitleSelection', + 'subtitleFilter', ] as const; const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ diff --git a/src/config/resolve.ts b/src/config/resolve.ts index e256237f..a9d2492f 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -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, diff --git a/src/config/resolve/subtitle-filter.test.ts b/src/config/resolve/subtitle-filter.test.ts new file mode 100644 index 00000000..eaccbed4 --- /dev/null +++ b/src/config/resolve/subtitle-filter.test.ts @@ -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: '', 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: '', 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]', + ], + ); +}); diff --git a/src/config/resolve/subtitle-filter.ts b/src/config/resolve/subtitle-filter.ts new file mode 100644 index 00000000..1eca8737 --- /dev/null +++ b/src/config/resolve/subtitle-filter.ts @@ -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.', + ); + } +} diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index a4a22f98..8e9f9917 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -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( '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( '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 = { '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 = { @@ -280,6 +292,8 @@ const DESCRIPTION_OVERRIDES: Record = { '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 { @@ -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'; diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 897fbf3c..494260a0 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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 }; + 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 { 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, diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 6993dec9..4e5d53a4 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -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 { const response = await this.request(['get_property', name]); if (response.error && response.error !== 'success') { diff --git a/src/main.ts b/src/main.ts index b23f6194..4d32d94f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1112,6 +1112,10 @@ const settings = createSettingsAssembly({ refreshCurrentSubtitle: () => { subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); }, + refreshSubtitleFilter: () => { + appState.mpvClient?.refreshSubtitleText(); + void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack(); + }, setLogLevel: (level) => { setLogLevel(level, 'config'); }, diff --git a/src/main/assemblies/playback.ts b/src/main/assemblies/playback.ts index 00cfd7ca..e13138e3 100644 --- a/src/main/assemblies/playback.ts +++ b/src/main/assemblies/playback.ts @@ -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; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index a434b59a..efc2d2c4 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -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?.(); diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts index 640657cd..44e38d51 100644 --- a/src/main/runtime/subtitle-prefetch-init.test.ts +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -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 = []; @@ -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[] = []; diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts index fc14e662..c65c2bd3 100644 --- a/src/main/runtime/subtitle-prefetch-init.ts +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -11,6 +11,8 @@ export interface SubtitlePrefetchInitControllerDeps { setCurrentService: (service: SubtitlePrefetchService | null) => void; loadSubtitleSourceText: (source: string) => Promise; 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; 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( diff --git a/src/settings/settings-controls.ts b/src/settings/settings-controls.ts index 5bf36c62..7145d594 100644 --- a/src/settings/settings-controls.ts +++ b/src/settings/settings-controls.ts @@ -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; diff --git a/src/settings/settings-subtitle-filter-controls.ts b/src/settings/settings-subtitle-filter-controls.ts new file mode 100644 index 00000000..7637a3aa --- /dev/null +++ b/src/settings/settings-subtitle-filter-controls.ts @@ -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 => 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(); + 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; +} diff --git a/src/shared/subtitle-text-filter.test.ts b/src/shared/subtitle-text-filter.test.ts new file mode 100644 index 00000000..8377f83c --- /dev/null +++ b/src/shared/subtitle-text-filter.test.ts @@ -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>) => ({ + 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.'); +}); diff --git a/src/shared/subtitle-text-filter.ts b/src/shared/subtitle-text-filter.ts new file mode 100644 index 00000000..1e07bee1 --- /dev/null +++ b/src/shared/subtitle-text-filter.ts @@ -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 = { + 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: `(?(); + +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; + }); +} diff --git a/src/types/config.ts b/src/types/config.ts index d024eb71..419d6dd1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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; diff --git a/src/types/settings.ts b/src/types/settings.ts index c5ef8f6b..75a8dcfa 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -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'; diff --git a/src/types/subtitle.ts b/src/types/subtitle.ts index e756ff90..09320867 100644 --- a/src/types/subtitle.ts +++ b/src/types/subtitle.ts @@ -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>; + customRules?: Partial[]; +} + +export interface ResolvedSubtitleFilterConfig { + presets: Record; + customRules: SubtitleFilterRule[]; +} + export type NPlusOneMatchMode = 'headword' | 'surface'; export type FrequencyDictionaryMatchMode = 'headword' | 'surface';