mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
1 parent
848a6c4021
commit
fa3051f9cb
28 files changed
+740
-11
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -8,6 +8,7 @@ const HOT_RELOAD_ROOTS = [
|
||||
'shortcuts',
|
||||
'subtitleSidebar',
|
||||
'subtitleSelection',
|
||||
'subtitleFilter',
|
||||
] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
|
||||
@@ -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]',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -1112,6 +1112,10 @@ const settings = createSettingsAssembly({
|
||||
refreshCurrentSubtitle: () => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
},
|
||||
refreshSubtitleFilter: () => {
|
||||
appState.mpvClient?.refreshSubtitleText();
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
},
|
||||
setLogLevel: (level) => {
|
||||
setLogLevel(level, 'config');
|
||||
},
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user