fix(subtitles): drop ASS furigana from recorded cues (#233)

This commit is contained in:
2026-09-01 22:49:20 -07:00
committed by GitHub
parent c0a78ef008
commit a0635f4360
5 changed files with 200 additions and 11 deletions
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays.
@@ -131,7 +131,9 @@ coming and prefetching would otherwise idle for the rest of the cue.
authored source order when no usable position exists. authored source order when no usable position exists.
- Half-size kana positioned directly above a same-timed kanji caption is treated as ASS - Half-size kana positioned directly above a same-timed kanji caption is treated as ASS
furigana. The parser omits it from published cues but retains hidden matching metadata so furigana. The parser omits it from published cues but retains hidden matching metadata so
mpv's raw live text can be reconciled without displaying or mining the reading. mpv's raw live text can be reconciled without displaying or mining the reading. The
timing tracker (clipboard copy, recent-line mining) and immersion recorders run the same
reconciliation on the `sub-start`/`sub-end` sample, so they record what the overlay shows.
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces - Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps'; import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
test('mpv main event main deps map app state updates and delegate callbacks', async () => { test('mpv main event main deps map app state updates and delegate callbacks', async () => {
@@ -582,3 +583,156 @@ test('subtitle-track changes stop stale canonical cues from substituting immedia
assert.equal(appState.activeParsedSubtitleSource, null); assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある'); assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
}); });
test('subtitle recorders drop ASS furigana events the same way the display does', () => {
// Broadcast-caption ASS (Caption2Ass style): furigana are separate half-scale events
// positioned above their base line, and mpv lists them as their own live lines.
const cues = parseAssCues(
[
'[Script Info]',
'PlayResX: 960',
'PlayResY: 540',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
'Style: Default,Yu Gothic,46,&H00FFFFFF,&H000000FF,&H00000000,&H7F000000,1,0,0,0,100,100,4,0,1,2,2,1,0,0,0,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(472,443)\\fscx50\\fscy50}あくむ',
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(172,497)}こんな短時間で{\\fscx50} {\\fscx100}悪夢{\\fscx50} {\\fscx100}見んなよ{\\fscx50}。',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,407)\\fscx50}{\\fscx100}平{\\fscx50}{\\fscx100}暗記教科は{\\fscx50} {\\fscx100}もう',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(332,443)\\fscx50\\fscy50}かた',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(412,443)\\fscx50\\fscy50}ぱし',
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,497)}とにかく片っ端から覚えるんだよ{\\fscx50}。',
].join('\n'),
);
assert.deepEqual(
cues.map((cue) => cue.text),
[
'こんな短時間で 悪夢 見んなよ。',
'(平)暗記教科は もう',
'とにかく片っ端から覚えるんだよ。',
],
);
const immersion: string[] = [];
const timing: string[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 299.7 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: cues,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。';
const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。';
assert.equal(handlers.resolveSubtitleText?.(liveText), expected);
handlers.recordImmersionSubtitleLine(liveText, 299.63, 303.54);
handlers.recordSubtitleTiming(liveText, 299.63, 303.54);
assert.deepEqual(immersion, [expected]);
assert.deepEqual(timing, [expected]);
});
test('a resolved line survives recording while a fragment grid is on screen', () => {
// Fragment stripping drops every line it can trace back to a cue, and returns nothing
// at all when a fragment grid is nearby. Text the parsed cues already resolved is a
// complete line, not raw mpv output, so it must not be fed through that path.
const immersion: string[] = [];
const timing: string[] = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 3.2 },
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
activeParsedSubtitleCues: [
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
{
startTime: 3,
endTime: 6,
text: 'MaidCafeMaidCafe',
source: 'reconstructed-ass',
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
},
],
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
// The grid fragment beside the lyric keeps canonical substitution from applying, so
// recording falls to the parsed view -- which is where the whole line is recovered.
const liveText = '飛び越え\nMaid';
assert.equal(handlers.resolveSubtitleText?.(liveText), '飛び越えてみたくて');
handlers.recordImmersionSubtitleLine(liveText, 3, 6);
handlers.recordSubtitleTiming(liveText, 3, 6);
assert.deepEqual(immersion, ['飛び越えてみたくて']);
assert.deepEqual(timing, ['飛び越えてみたくて']);
// A spacer event left as literal control debris is not a subtitle line. The display
// drops it, so no recorder may keep it either.
assert.equal(handlers.resolveSubtitleText?.('\\'), '');
handlers.recordImmersionSubtitleLine('\\', 3, 6);
handlers.recordSubtitleTiming('\\', 3, 6);
assert.equal(immersion.length, 1);
assert.equal(timing.length, 1);
});
+9 -7
View File
@@ -3,7 +3,7 @@ import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { import {
resolveCanonicalPrimarySubtitle, resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText, resolvePrimarySubtitleText,
stripCanonicalFragmentLines, resolveRecordedPrimarySubtitleText,
} from './primary-subtitle-text'; } from './primary-subtitle-text';
type AnilistPostWatchRunOptions = { type AnilistPostWatchRunOptions = {
@@ -131,10 +131,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentTimeSec: startSec, currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues, cues: deps.appState.activeParsedSubtitleCues,
}); });
// When substitution declined because dialogue shares the screen with a song, record // Recorders see the same text the overlay displays: mpv's live `sub-text` lists every
// the dialogue alone rather than the combined dialogue-plus-fragments stack. // simultaneously active ASS event, including furigana events the parser folded into
const stripFragmentsForRecording = (liveText: string, startSec: number) => // their base line. Cues the caller already resolved canonically are recorded one by
stripCanonicalFragmentLines({ // one above; everything else goes through the shared recording resolution.
const resolveTextForRecording = (liveText: string, startSec: number): string =>
resolveRecordedPrimarySubtitleText({
liveText, liveText,
currentTimeSec: startSec, currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues, cues: deps.appState.activeParsedSubtitleCues,
@@ -218,7 +220,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
} }
return; return;
} }
text = stripFragmentsForRecording(text, start); text = resolveTextForRecording(text, start);
if (!text.trim()) { if (!text.trim()) {
return; return;
} }
@@ -232,7 +234,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined; const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
const canonical = resolveCanonicalSample(text, start); const canonical = resolveCanonicalSample(text, start);
if (!canonical) { if (!canonical) {
const recordableText = stripFragmentsForRecording(text, start); const recordableText = resolveTextForRecording(text, start);
if (!recordableText.trim()) { if (!recordableText.trim()) {
return; return;
} }
+30 -3
View File
@@ -26,6 +26,13 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool
); );
} }
function decodedLiveText(
liveText: string,
cues: readonly SubtitleCue[] | null | undefined,
): string {
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } { function animationSpan(cue: SubtitleCue): { start: number; end: number } {
return { return {
start: cue.animationStartTime ?? cue.startTime, start: cue.animationStartTime ?? cue.startTime,
@@ -290,14 +297,34 @@ export function stripCanonicalFragmentLines(options: {
return removeLiveGlyphFragmentLines(options.liveText); return removeLiveGlyphFragmentLines(options.liveText);
} }
/**
* Recording text for a live sample. Callers substitute canonical cues themselves and
* record those cue by cue, so what is resolved here is the parsed view -- the one that
* folds ASS furigana events back into their base line. Its text is already a complete
* line, while fragment stripping takes raw mpv text and would discard a resolved line
* whole while a fragment grid is on screen, so only one of the two ever runs.
*/
export function resolveRecordedPrimarySubtitleText(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
const liveText = decodedLiveText(options.liveText, options.cues);
if (!liveText.trim()) {
return liveText;
}
return (
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
stripCanonicalFragmentLines({ ...options, liveText })
);
}
export function resolvePrimarySubtitleText(options: { export function resolvePrimarySubtitleText(options: {
liveText: string; liveText: string;
currentTimeSec: number; currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined; cues: readonly SubtitleCue[] | null | undefined;
}): string { }): string {
const liveText = cuesUseAssSyntax(options.cues) const liveText = decodedLiveText(options.liveText, options.cues);
? removeAssControlDebrisLines(options.liveText)
: options.liveText;
if (!liveText.trim()) { if (!liveText.trim()) {
return liveText; return liveText;
} }