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.');