diff --git a/changes/stats-widen-halfwidth-kana.md b/changes/stats-widen-halfwidth-kana.md
new file mode 100644
index 00000000..7daa6217
--- /dev/null
+++ b/changes/stats-widen-halfwidth-kana.md
@@ -0,0 +1,5 @@
+type: fixed
+area: stats
+
+- Subtitle lines recorded before halfwidth katakana widening now show fullwidth katakana in the stats dashboard (word/kanji occurrences, sentence search), and cards mined from stats history get the fullwidth sentence and word.
+- Sentence search matches katakana in either width, so searching アンタ also finds older lines stored as アンタ.
diff --git a/changes/widen-halfwidth-kana.md b/changes/widen-halfwidth-kana.md
new file mode 100644
index 00000000..f28066a2
--- /dev/null
+++ b/changes/widen-halfwidth-kana.md
@@ -0,0 +1,4 @@
+type: fixed
+area: overlay
+
+- Halfwidth katakana in subtitles (common in broadcast-derived Japanese subs, e.g. アンタ, ダキニ) now displays as normal fullwidth katakana, so lines no longer mix squashed and normal-width text. Mined sentences and stats get the fullwidth form too.
diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts
index 56a9a5a7..9108b92f 100644
--- a/src/core/services/__tests__/stats-server.test.ts
+++ b/src/core/services/__tests__/stats-server.test.ts
@@ -1934,6 +1934,27 @@ describe('stats server API routes', () => {
});
});
+ it('POST /api/stats/mine-card widens halfwidth katakana from older stats lines', async () => {
+ await withMiningEnv({}, async (env) => {
+ const app = createStatsApp(createMockTracker(), {
+ addYomitanNote: async () => 777,
+ ankiConnectConfig: ankiConfig(env, { fields: { sentence: 'Sentence' } }),
+ });
+
+ const { status, body } = await mineCard(app, 'word', {
+ sourcePath: env.sourcePath,
+ sentence: 'アンタがアータルたん?',
+ word: 'アンタ',
+ });
+
+ assert.equal(status, 200, JSON.stringify(body));
+ assert.equal(
+ sentNote(env.requests, 'updateNoteFields').fields.Sentence,
+ 'アンタがアータルたん?',
+ );
+ });
+ });
+
it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
await withMiningEnv({}, async (env) => {
const app = createStatsApp(createMockTracker(), {
diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts
index 54b242ef..b965200b 100644
--- a/src/core/services/immersion-tracker/__tests__/query.test.ts
+++ b/src/core/services/immersion-tracker/__tests__/query.test.ts
@@ -3936,6 +3936,83 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
}
});
+test('getWordOccurrences widens halfwidth katakana in lines recorded before ingest widening', () => {
+ const dbPath = makeDbPath();
+ const db = openTestDb(dbPath);
+
+ try {
+ ensureSchema(db);
+ const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
+ canonicalTitle: 'Episode 8',
+ sourcePath: '/tmp/KamiKatsu S01E08.mkv',
+ sourceUrl: null,
+ sourceType: SOURCE_TYPE_LOCAL,
+ });
+ const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
+ const wordResult = db
+ .prepare(
+ `INSERT INTO imm_words (
+ headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run('アンタ', 'アンタ', 'あんた', 'noun', '名詞', '代名詞', '', 1_000, 1_000, 1);
+ const lineResult = db
+ .prepare(
+ `INSERT INTO imm_subtitle_lines (
+ session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
+ db.prepare(
+ `INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
+ VALUES (?, ?, ?)`,
+ ).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
+
+ const rows = getWordOccurrences(db, 'アンタ', 'アンタ', 'あんた', 10);
+
+ assert.deepEqual(
+ rows.map((row) => row.text),
+ ['アンタがアータルたん?'],
+ );
+ } finally {
+ db.close();
+ cleanupDbPath(dbPath);
+ }
+});
+
+test('searchSubtitleSentences matches halfwidth and fullwidth katakana in either direction', () => {
+ const dbPath = makeDbPath();
+ const db = openTestDb(dbPath);
+
+ try {
+ ensureSchema(db);
+ const videoId = getOrCreateVideoRecord(db, 'local:/tmp/kamikatsu-s01e08.mkv', {
+ canonicalTitle: 'Episode 8',
+ sourcePath: '/tmp/KamiKatsu S01E08.mkv',
+ sourceUrl: null,
+ sourceType: SOURCE_TYPE_LOCAL,
+ });
+ const { sessionId } = startSessionRecord(db, videoId, 1_000_000);
+ const insertLine = db.prepare(
+ `INSERT INTO imm_subtitle_lines (
+ session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms, text, CREATED_DATE, LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ );
+ insertLine.run(sessionId, null, videoId, null, 1, 0, 1000, 'アンタがアータルたん?', 1_000, 1_000);
+ insertLine.run(sessionId, null, videoId, null, 2, 1000, 2000, 'アンタ、ダキニ', 2_000, 2_000);
+
+ for (const query of ['アンタ', 'アンタ']) {
+ assert.deepEqual(
+ searchSubtitleSentences(db, query, 10).map((row) => row.text),
+ ['アンタ、ダキニ', 'アンタがアータルたん?'],
+ );
+ }
+ } finally {
+ db.close();
+ cleanupDbPath(dbPath);
+ }
+});
+
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = openTestDb(dbPath);
diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts
index 2f7bf553..67bc922b 100644
--- a/src/core/services/immersion-tracker/query-lexical.ts
+++ b/src/core/services/immersion-tracker/query-lexical.ts
@@ -18,6 +18,7 @@ import type {
} from './types';
import { fromDbTimestamp, toDbTimestamp } from './query-shared';
import { nowMs } from './time';
+import { narrowFullwidthKana, widenHalfwidthKana } from '../../utils/halfwidth-kana';
import {
areLexicalDailyRollupsReady,
getLexicalDailyRollups,
@@ -382,6 +383,13 @@ export function getKanjiStats(db: DatabaseSync, limit = 100): KanjiStatsRow[] {
return stmt.all(limit) as KanjiStatsRow[];
}
+// Lines recorded before subtitle text was widened at ingest can still hold halfwidth
+// katakana; widen them on the way out so the dashboard and cards mined from it match
+// newer rows. Display-only: word identity columns stay as stored since they are keys.
+function widenLineText(row: T): T {
+ return { ...row, text: widenHalfwidthKana(row.text) };
+}
+
export function getWordOccurrences(
db: DatabaseSync,
headword: string,
@@ -390,7 +398,7 @@ export function getWordOccurrences(
limit = 100,
offset = 0,
): WordOccurrenceRow[] {
- return db
+ const rows = db
.prepare(
`
SELECT
@@ -418,6 +426,7 @@ export function getWordOccurrences(
`,
)
.all(headword, word, reading, limit, offset) as unknown as WordOccurrenceRow[];
+ return rows.map(widenLineText);
}
export function getKanjiOccurrences(
@@ -426,7 +435,7 @@ export function getKanjiOccurrences(
limit = 100,
offset = 0,
): KanjiOccurrenceRow[] {
- return db
+ const rows = db
.prepare(
`
SELECT
@@ -454,6 +463,7 @@ export function getKanjiOccurrences(
`,
)
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
+ return rows.map(widenLineText);
}
export function searchSubtitleSentences(
@@ -470,6 +480,14 @@ export function searchSubtitleSentences(
const params: string[] = [];
for (const term of terms) {
const likeTerm = `%${escapeLikeTerm(term)}%`;
+ // Lines recorded before ingest widening can hold halfwidth katakana, so subtitle
+ // text is matched against the term's widened and narrowed forms as well.
+ const widenedTerm = widenHalfwidthKana(term);
+ const textLikeTerms = uniqueNonEmptyTerms([
+ term,
+ widenedTerm,
+ narrowFullwidthKana(widenedTerm),
+ ]).map((variant) => `%${escapeLikeTerm(variant)}%`);
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
const headwordClause =
headwords.length > 0
@@ -485,16 +503,16 @@ export function searchSubtitleSentences(
: '';
clauses.push(`
(
- l.text LIKE ? ESCAPE '\\'
+ ${textLikeTerms.map(() => "l.text LIKE ? ESCAPE '\\'").join(' OR ')}
OR v.canonical_title LIKE ? ESCAPE '\\'
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
${headwordClause}
)
`);
- params.push(likeTerm, likeTerm, likeTerm, ...headwords);
+ params.push(...textLikeTerms, likeTerm, likeTerm, ...headwords);
}
- return db
+ const rows = db
.prepare(
`
SELECT
@@ -518,6 +536,7 @@ export function searchSubtitleSentences(
`,
)
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
+ return rows.map(widenLineText);
}
export function getSessionEvents(
diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts
index 6ca1b827..4cada30f 100644
--- a/src/core/services/mpv-protocol.test.ts
+++ b/src/core/services/mpv-protocol.test.ts
@@ -131,6 +131,18 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
});
+test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
+ const { deps, state } = createDeps();
+
+ await dispatchMpvProtocolMessage(
+ { event: 'property-change', name: 'sub-text', data: 'アンタがアータルたん?' },
+ deps,
+ );
+
+ assert.equal(state.subText, 'アンタがアータルたん?');
+ assert.deepEqual(state.events, [{ text: 'アンタがアータルたん?', isOverlayVisible: false }]);
+});
+
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
const { deps, state } = createDeps({
emitSubtitleTrackChange: (payload) => state.events.push(payload),
diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts
index 0ea185dc..36cbc351 100644
--- a/src/core/services/mpv-protocol.ts
+++ b/src/core/services/mpv-protocol.ts
@@ -1,5 +1,6 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
+import { widenHalfwidthKana } from '../utils/halfwidth-kana';
export type MpvMessage = {
event?: string;
@@ -244,7 +245,7 @@ export async function dispatchMpvProtocolMessage(
): Promise {
if (msg.event === 'property-change') {
if (msg.name === 'sub-text') {
- const nextSubText = (msg.data as string) || '';
+ const nextSubText = widenHalfwidthKana((msg.data as string) || '');
const overlayVisible = deps.isVisibleOverlayVisible();
deps.emitSubtitleChange({
text: nextSubText,
@@ -458,7 +459,7 @@ export async function dispatchMpvProtocolMessage(
}>,
);
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
- const nextSubText = (msg.data as string) || '';
+ const nextSubText = widenHalfwidthKana((msg.data as string) || '');
deps.setCurrentSubText(nextSubText);
deps.emitSubtitleChange({
text: nextSubText,
diff --git a/src/core/services/stats-server/mining-routes.ts b/src/core/services/stats-server/mining-routes.ts
index ab31119f..bbe8353c 100644
--- a/src/core/services/stats-server/mining-routes.ts
+++ b/src/core/services/stats-server/mining-routes.ts
@@ -7,6 +7,7 @@ import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/ani
import { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
+import { widenHalfwidthKana } from '../../utils/halfwidth-kana.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
@@ -35,8 +36,11 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
- const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
- const word = typeof body?.word === 'string' ? body.word.trim() : '';
+ // Older stats rows can carry halfwidth katakana; cards get the fullwidth form that
+ // live mining produces, and the word is widened alongside so highlighting still hits.
+ const sentence =
+ typeof body?.sentence === 'string' ? widenHalfwidthKana(body.sentence.trim()) : '';
+ const word = typeof body?.word === 'string' ? widenHalfwidthKana(body.word.trim()) : '';
const bodySecondaryText =
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts
index f924f4ff..0d7c47dc 100644
--- a/src/core/services/subtitle-cue-parser.test.ts
+++ b/src/core/services/subtitle-cue-parser.test.ts
@@ -55,6 +55,36 @@ test('parseSrtCues strips HTML-like markup while preserving line breaks', () =>
assert.equal(cues[0]!.text, 'これは\nテストです');
});
+test('parseSubtitleCues widens halfwidth katakana in cue text', () => {
+ const cues = parseSubtitleCues(
+ '1\n00:00:01,000 --> 00:00:02,000\n(トーカ)ダキニお姉ちゃん\n',
+ 'ep.srt',
+ );
+
+ assert.equal(cues[0]?.text, '(トーカ)ダキニお姉ちゃん');
+});
+
+test('parseSubtitleCues merges cues that differ only in halfwidth katakana', () => {
+ const cues = parseSubtitleCues(
+ [
+ '1',
+ '00:00:01,000 --> 00:00:02,000',
+ '山ガク',
+ '',
+ '2',
+ '00:00:01,000 --> 00:00:02,000',
+ '山ガク',
+ '',
+ ].join('\n'),
+ 'ep.srt',
+ );
+
+ assert.deepEqual(
+ cues.map((cue) => cue.text),
+ ['山ガク'],
+ );
+});
+
test('parseSrtCues handles hours in timestamps', () => {
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts
index f0483eb4..faf4aff0 100644
--- a/src/core/services/subtitle-cue-parser.ts
+++ b/src/core/services/subtitle-cue-parser.ts
@@ -9,6 +9,7 @@ import {
type AssOverrideCommand,
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
+import { widenHalfwidthKana } from '../utils/halfwidth-kana';
/** Vertical third of the screen a cue is authored to occupy. */
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
@@ -104,7 +105,7 @@ function decodeSubtitleCueText(text: string): string {
}
function sanitizeSubtitleCueText(text: string): string {
- return decodeSubtitleCueText(text).trim();
+ return widenHalfwidthKana(decodeSubtitleCueText(text)).trim();
}
function sanitizeAssCueText(text: string): string {
@@ -2655,10 +2656,16 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
placement.playResY,
);
- return mergeAssCaptionRows(
+ const cues = mergeAssCaptionRows(
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
placement.playResY,
);
+ // Widened after reconstruction, which works glyph by glyph, but before duplicate
+ // merging, so ガク and ガク authored for the same span collapse into one cue.
+ for (const cue of cues) {
+ cue.text = widenHalfwidthKana(cue.text);
+ }
+ return cues;
}
export function parseAssCues(content: string): SubtitleCue[] {
diff --git a/src/core/services/tokenizer/token-classification.ts b/src/core/services/tokenizer/token-classification.ts
index ba0632c8..d00a0858 100644
--- a/src/core/services/tokenizer/token-classification.ts
+++ b/src/core/services/tokenizer/token-classification.ts
@@ -1,23 +1,12 @@
import { MergedToken, PartOfSpeech } from '../../../types';
+import { widenHalfwidthKana } from '../../utils/halfwidth-kana';
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
const KATAKANA_CODEPOINT_START = 0x30a1;
const KATAKANA_CODEPOINT_END = 0x30f6;
-// No `u` flag: the range is entirely BMP so it changes nothing here, and
-// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
-const HALFWIDTH_KANA_RUN = /[\uff66-\uff9f]+/g;
-
-// NFKC over the halfwidth kana only, never the whole string: it composes the
-// voiced pairs (カ + ゙) into single characters so ガク compares equal to ガク
-// instead of counting one character longer than the word it spells, but run
-// over everything it would also rewrite unrelated text (① → 1, ㍑ → リットル).
-function composeHalfwidthKana(text: string): string {
- return text.replace(HALFWIDTH_KANA_RUN, (run) => run.normalize('NFKC'));
-}
-
export function normalizeKana(text: string): string {
- const raw = composeHalfwidthKana(text).trim();
+ const raw = widenHalfwidthKana(text).trim();
if (!raw) {
return '';
}
diff --git a/src/core/utils/halfwidth-kana.test.ts b/src/core/utils/halfwidth-kana.test.ts
new file mode 100644
index 00000000..72ecebc0
--- /dev/null
+++ b/src/core/utils/halfwidth-kana.test.ts
@@ -0,0 +1,24 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { narrowFullwidthKana, widenHalfwidthKana } from './halfwidth-kana';
+
+test('widenHalfwidthKana widens halfwidth katakana and composes voiced marks', () => {
+ assert.equal(
+ widenHalfwidthKana('(トーカ)ダキニお姉ちゃん アンタがアータルたん。'),
+ '(トーカ)ダキニお姉ちゃん アンタがアータルたん。',
+ );
+});
+
+test('widenHalfwidthKana leaves other compatibility characters alone', () => {
+ assert.equal(widenHalfwidthKana('見て見てっ!①㍑AB'), '見て見てっ!①㍑AB');
+});
+
+test('widenHalfwidthKana folds a lone halfwidth voiced mark into the kana before it', () => {
+ assert.equal(widenHalfwidthKana('山ガク パン'), '山ガク パン');
+ assert.equal(widenHalfwidthKana('ア゙'), 'ア\u3099');
+});
+
+test('narrowFullwidthKana inverts widening and leaves other text alone', () => {
+ assert.equal(narrowFullwidthKana('ダキニお姉ちゃん アンタ。ヴ'), 'ダキニお姉ちゃん アンタ。ヴ');
+ assert.equal(narrowFullwidthKana('漢字ひらがな!'), '漢字ひらがな!');
+});
diff --git a/src/core/utils/halfwidth-kana.ts b/src/core/utils/halfwidth-kana.ts
new file mode 100644
index 00000000..b7bdbdc6
--- /dev/null
+++ b/src/core/utils/halfwidth-kana.ts
@@ -0,0 +1,49 @@
+// No `u` flag: the range is entirely BMP so it changes nothing here, and
+// Bun's unicode-mode matcher mis-handles this class next to certain ligatures.
+// The optional leading character is captured so a run opening with a voiced
+// mark (ガ) can fold the mark into the fullwidth kana before it.
+const HALFWIDTH_KANA_RUN = /([^。-゚]?)([。-゚]+)/g;
+const LEADING_COMBINING_VOICED_MARK = /^[゙゚]/;
+
+/**
+ * Rewrites halfwidth katakana and its punctuation (アンタ, ダキニ, 。「」、・) to the fullwidth
+ * forms. Broadcast-derived subtitles write katakana halfwidth to save space; subtitle
+ * text is normalized with this where it enters the app (live mpv text, parsed cues) so
+ * display, lookups, mining, and stats all see one form.
+ *
+ * NFKC runs over the halfwidth runs only, never the whole string: it composes the voiced
+ * pairs (カ + ゙) into single characters, but over everything it would also rewrite
+ * unrelated text (① → 1, ! → !, ㍑ → リットル).
+ */
+export function widenHalfwidthKana(text: string): string {
+ return text.replace(HALFWIDTH_KANA_RUN, (_match, previous: string, run: string) => {
+ const widened = run.normalize('NFKC');
+ if (!previous || !LEADING_COMBINING_VOICED_MARK.test(widened)) {
+ return previous + widened;
+ }
+ // A lone ゙ widens to a combining mark; NFC composes it with the kana before it
+ // (カ + ゛ → ガ) and leaves pairs that have no composed form untouched.
+ return (previous + widened[0]).normalize('NFC') + widened.slice(1);
+ });
+}
+
+// Inverse of widenHalfwidthKana per character, derived from it so the two stay in sync.
+// Voiced pairs (ガ) are included because they widen to a single character (ガ).
+const HALFWIDTH_BY_FULLWIDTH = new Map();
+for (let code = 0xff61; code <= 0xff9f; code += 1) {
+ const halfwidth = String.fromCharCode(code);
+ for (const form of [halfwidth, `${halfwidth}゙`, `${halfwidth}゚`]) {
+ const widened = widenHalfwidthKana(form);
+ if (widened.length === 1 && !HALFWIDTH_BY_FULLWIDTH.has(widened)) {
+ HALFWIDTH_BY_FULLWIDTH.set(widened, form);
+ }
+ }
+}
+
+/**
+ * Rewrites fullwidth katakana and punctuation to halfwidth forms (アンタ → アンタ). Used to
+ * search text stored before subtitle ingest widened it; display paths should widen instead.
+ */
+export function narrowFullwidthKana(text: string): string {
+ return Array.from(text, (char) => HALFWIDTH_BY_FULLWIDTH.get(char) ?? char).join('');
+}
diff --git a/src/main/runtime/primary-subtitle-text.ts b/src/main/runtime/primary-subtitle-text.ts
index afce7a18..89d8aba5 100644
--- a/src/main/runtime/primary-subtitle-text.ts
+++ b/src/main/runtime/primary-subtitle-text.ts
@@ -3,6 +3,7 @@ import {
removeAssControlDebrisLines,
removeLiveGlyphFragmentLines,
} from '../../core/services/ass-text';
+import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
@@ -26,11 +27,14 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool
);
}
+// Live text also arrives from direct `sub-text` reads that bypass the mpv protocol
+// handler, so it is widened here too to match the parsed cues.
function decodedLiveText(
liveText: string,
cues: readonly SubtitleCue[] | null | undefined,
): string {
- return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
+ const text = widenHalfwidthKana(liveText);
+ return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(text) : text;
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts
index a6b3bccb..b3ef4c41 100644
--- a/src/main/runtime/youtube-flow.test.ts
+++ b/src/main/runtime/youtube-flow.test.ts
@@ -256,6 +256,18 @@ test('youtube flow announces manual picker opening before probing tracks', async
assertNoProblems(harness);
});
+test('youtube flow widens halfwidth katakana in the refreshed subtitle', async () => {
+ const harness = createFlowHarness({
+ mpv: { subText: 'アンタがアータルたん?' },
+ probeTracks: [primaryTrack],
+ pick: { primaryTrackId: primaryTrack.id, secondaryTrackId: null },
+ });
+
+ await harness.runtime.openManualPicker({ url: 'https://example.com' });
+
+ assert.deepEqual(harness.refreshedSubtitles, ['アンタがアータルたん?']);
+});
+
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
const harness = createFlowHarness({
probeTracks: [primaryTrack, secondaryTrack],
diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts
index 1687f832..293c1185 100644
--- a/src/main/runtime/youtube-flow.ts
+++ b/src/main/runtime/youtube-flow.ts
@@ -21,6 +21,7 @@ import {
} from '../../core/services/youtube/generate';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
import { extractYoutubeVideoId } from './youtube-playback';
+import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
type YoutubeFlowOpenPicker = (payload: YoutubePickerOpenPayload) => Promise;
@@ -457,7 +458,7 @@ async function injectDownloadedSubtitles(
const currentSubText = await deps.requestMpvProperty('sub-text').catch(() => null);
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
- deps.refreshCurrentSubtitle(currentSubText);
+ deps.refreshCurrentSubtitle(widenHalfwidthKana(currentSubText));
}
deps.showMpvOsd(secondaryTrack ? 'Primary and secondary subtitles loaded.' : 'Subtitles loaded.');