Compare commits

...
Author SHA1 Message Date
sudacode 3aea42e6f8 chore(release): prepare v0.19.4-beta.3 2026-08-21 23:25:37 -07:00
sudacode 88bb3edfa4 fix(subtitles): preserve lyric selection and deduplicate secondary text
- Seek sidebar selections past overlapping karaoke exit spans
- Collapse duplicate long ASS lines and omit reconstructed sign grids
2026-08-21 01:28:00 -07:00
sudacode 9445aef004 fix(subtitles): navigate by sanitized cues across ASS animation
- Route subtitle navigation through parsed cue boundaries
- Treat explicit short seeks as subtitle priming events
2026-08-20 00:20:51 -07:00
sudacode c01bcd9d0f fix(subtitles): collapse layered ASS lyrics across startup and seeks
- Reconstruct fragment-only karaoke while preserving canonical animated signs
- Reprocess live subtitles after initial playback and seek updates
- Add a development launcher for playable media files
2026-08-19 22:55:10 -07:00
31 changed files with 1276 additions and 76 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: subtitles
- Primary ASS subtitles now use the active parsed cue when it fully accounts for mpv's live text, preventing fill, border, blur, and shadow copies of the same full-span lyric from appearing repeatedly while preserving unmatched overlapping dialogue and signs.
- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, navigate song lyrics by sanitized lines instead of generated animation events, and keep sidebar selections on the requested overlapping lyric while preserving unmatched dialogue and signs.
@@ -1,4 +1,4 @@
type: fixed
area: overlay
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
+2
View File
@@ -110,6 +110,8 @@ The secondary bar is a compact top-strip region in the same overlay window. It s
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
SubMiner collapses duplicate ASS layers in parsed secondary tracks. Long lines repeated as dialogue and positioned signs are treated as the same line when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar. When SubMiner must use mpv's live text as a fallback, it still filters full-line duplicates while preserving short repeated dialogue.
### Display Modes
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
+1 -1
View File
@@ -9,7 +9,7 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
- Clicking any cue seeks mpv to that timestamp.
- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining.
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
+35 -4
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-08-18
Last verified: 2026-08-19
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -71,11 +71,24 @@ coming and prefetching would otherwise idle for the rest of the cue.
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
every live mpv line matches an active parsed cue, it uses the parsed cue text so exact
full-span style layers appear once instead of repeating for fill, border, blur, and
shadow events. Any unmatched live line keeps the complete live stack, preserving
dialogue or signs that overlap a lyric.
full-span style layers appear once instead of repeating for fill, border, blur, shadow,
or equivalent whitespace variants. Any unmatched live line keeps the complete live
stack, preserving dialogue or signs that overlap a lyric.
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
live work does not contend for Yomitan state.
- The initial `time-pos`, explicit renderer seeks, and later seek-like jumps reprocess mpv's
current raw `sub-text` after the new playback time is stored. Explicit intent matters because
adjacent subtitle jumps can be shorter than the general seek-distance threshold. This corrects
ASS cleanup when mpv delivered the destination subtitle before the destination timestamp.
- Renderer `sub-seek` commands use the active parsed cue list when available. Simultaneous cues
share one boundary, overlapping lyrics advance from the latest active boundary, and mpv's native
command remains the fallback when no parsed destination exists. This prevents generated karaoke
frames from consuming next/previous subtitle presses.
- Subtitle sidebar selections seek past the preceding sanitized cue's overlapping exit span when
the selected cue has enough time remaining. This keeps direct row selection on the requested
karaoke line while clamping the seek inside that cue.
- If startup paints raw text before embedded ASS parsing finishes, parsed cue arrival may replace
that provisional line. The one-prime-per-media guard still suppresses identical repeats.
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
clear payload is emitted immediately. The older tokenization result is dropped before it can
replace the current cue.
@@ -86,15 +99,33 @@ coming and prefetching would otherwise idle for the rest of the cue.
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
still appear without waiting for file resolution.
- Parsed secondary text and the live fallback share a flattened-line identity for long lines. This
removes dialogue/sign repetitions that differ only in whitespace or terminal punctuation while
retaining short repeated lines that can represent authored dialogue without source metadata.
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
source resolver used by primary subtitle prefetching.
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
`secondary-sub-delay`.
- Fragment reconstruction marks positioned parts that span multiple vertical rows as a grid.
Secondary text omits those grids instead of flattening a translated table or schedule into one
synthetic line. Reconstructed single-line karaoke remains eligible for display.
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
text when a readable source is available.
- Simultaneous parsed cues use whitespace-insensitive identity, so ASS layers that vary only
between ordinary, hard, or ideographic spaces appear once.
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
authored source order when no usable position exists.
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
survive concatenation, while scripts that discarded their word boundaries remain compact
instead of gaining false spaces between syllables. Short runs qualify only when overlapping
positioned events also show changing overrides or repeated layer copies; an English or romaji
style name alone never turns ordinary dialogue into a lyric.
- Recovered canonical ASS text remains active for the generated animation envelope. For
reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit
fragments from the same style.
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
track-list changes refresh without discarding an unchanged source. Observed
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.4-beta.2",
"version": "0.19.4-beta.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+10 -9
View File
@@ -6,9 +6,9 @@
### Added
- Library Merge & Reassignment
- Duplicate library entries for the same show can now be merged: pick entries in "Select" mode and use "Merge Selected" to combine sessions, mined cards, and watch time onto one card.
- Episodes can be moved to a different library entry with a per-episode "→" button, fixing cases where a stray filename split off its own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair.
- Exact AniList matches with compatible seasons now merge automatically, and likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
- Duplicate library entries for the same show can now be merged: pick entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card.
- Episodes can be moved to a different library entry with a per-episode "→" button, fixing cases where a stray filename split off its own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair, and other episodes in the same folder pick up the same fix automatically unless they already belong elsewhere.
- Exact AniList matches with compatible seasons now merge automatically, and likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently; entries with conflicting seasons are left alone either way.
- Duplicate Line Cleanup
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once you confirm it; a matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
@@ -17,27 +17,28 @@
### Fixed
- Subtitle Duplication from Karaoke & Animated Signs
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs.
- The secondary overlay now shares the same deduplication logic as the primary overlay, so layered animation text no longer appears multiple times there or in what gets mined.
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs. Dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame.
- The secondary overlay now shares the same deduplication logic as the primary overlay, including collapsing lines that only differ by whitespace or trailing punctuation, so layered animation text and dense multi-row sign layouts no longer appear duplicated or garbled there or in what gets mined.
- Sidebar navigation now moves between the clean, sanitized lyric lines instead of the raw generated animation events, and selecting an overlapping lyric keeps the right line selected.
- Vocabulary stats no longer count every animation frame of a karaoke opening as a separate line, which previously could push an OP lyric to the top of "Top Repeated Words."
- Anki Media Generation
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`.
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams (bounded probing plus a two-minute extraction budget), and a failed extraction now reports a clear error instead of a raw `ENOENT`.
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished.
- Character Dictionary Performance & Notifications
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results are reused across launches instead of regenerating character data and portraits every time.
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time. Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
- Overlay Reliability
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
- Fixed native Wayland drag-and-drop from file managers like Thunar, so subtitle and video files dropped on the overlay reach mpv.
- Fixed system-wide mouse lag on Windows caused by the overlay's click-through handling and repeated mpv window lookups.
- Stats Dashboard
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds.
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, new-word history uses corrected daily rollups, calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list.
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, new-word history uses corrected daily rollups, calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
- Linux Launcher Thumbnails
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
FILE="${1:-}"
if [[ ! -f "$FILE" ]]; then
printf 'Not a file: %s\n' "${FILE:-<missing>}" >&2
exit 1
fi
if ! mpv --no-config --no-terminal --msg-level=all=no --vo=null --ao=null --frames=1 -- "$FILE"; then
printf 'Not playable by mpv: %s\n' "$FILE" >&2
exit 1
fi
exec subminer app --dev --launch-mpv "$FILE"
+5
View File
@@ -50,6 +50,11 @@ export {
} from './tokenizer/yomitan-parser-runtime';
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
export { createSubtitleProcessingController } from './subtitle-processing-controller';
export {
resolveSanitizedSubtitleSeekCommand,
subtitleCueListSeekTime,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
export { createJlptVocabularyLookup } from './jlpt-vocab';
export {
@@ -0,0 +1,15 @@
const MIN_FLATTENED_DUPLICATE_LENGTH = 16;
const TERMINAL_SENTENCE_PUNCTUATION = /[.!?]+$/gu;
/**
* Identifies long lines that become duplicates when positioned ASS events are
* flattened into the secondary subtitle bar. Short dialogue stays distinct.
*/
export function flattenedSecondarySubtitleLineIdentity(text: string): string | null {
const identity = text
.normalize('NFKC')
.replace(/\s+/gu, '')
.replace(TERMINAL_SENTENCE_PUNCTUATION, '');
return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null;
}
+3 -3
View File
@@ -149,8 +149,8 @@ function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRu
const isFlush =
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
if (
first.source === 'canonical-ass' ||
next.source === 'canonical-ass' ||
first.source !== undefined ||
next.source !== undefined ||
next.text !== first.text ||
assStyleKey(next) !== styleKey ||
!isFlush
@@ -223,7 +223,7 @@ function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number)
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
* changes from event to event, which is how per-frame typesetting is authored.
*/
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
export function hasAssAnimationEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
return true;
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveSanitizedSubtitleSeekCommand,
subtitleCueListSeekTime,
subtitleCueSeekTime,
} from './subtitle-cue-navigation';
test('next subtitle navigation skips generated ASS events and seeks to the next sanitized cue', () => {
const cues = [
{
startTime: 10,
endTime: 13,
text: 'first lyric',
source: 'canonical-ass' as const,
animationStartTime: 9.7,
animationEndTime: 13.4,
},
{
startTime: 13,
endTime: 16,
text: 'second lyric',
source: 'canonical-ass' as const,
animationStartTime: 12.7,
animationEndTime: 16.4,
},
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
'seek',
13.08,
'absolute+exact',
]);
});
test('next subtitle navigation treats simultaneous sanitized cues as one line boundary', () => {
const cues = [
{ startTime: 10, endTime: 13, text: 'romaji' },
{ startTime: 10.02, endTime: 13, text: 'English' },
{ startTime: 13, endTime: 16, text: 'next romaji' },
{ startTime: 13.02, endTime: 16, text: 'Next English' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
'seek',
13.08,
'absolute+exact',
]);
});
test('next subtitle navigation advances past the latest overlapping lyric', () => {
const cues = [
{ startTime: 10, endTime: 14, text: 'exiting lyric' },
{ startTime: 13, endTime: 16, text: 'current lyric' },
{ startTime: 16, endTime: 19, text: 'next lyric' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
'seek',
16.08,
'absolute+exact',
]);
});
test('previous subtitle navigation leaves the current cue and seeks to the prior cue', () => {
const cues = [
{ startTime: 10, endTime: 12, text: 'first line' },
{ startTime: 13, endTime: 16, text: 'current line' },
];
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
'seek',
10.08,
'absolute+exact',
]);
});
test('subtitle navigation falls back when no sanitized destination exists', () => {
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
});
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08);
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03);
});
test('sidebar cue selection clears an overlapping previous lyric', () => {
const cues = [
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
{ startTime: 3, endTime: 5, text: 'selected lyric' },
];
assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48);
});
test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => {
const cues = [
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
{ startTime: 3, endTime: 3.2, text: 'selected lyric' },
];
const seekTime = subtitleCueListSeekTime(cues, cues[1]!);
assert.ok(seekTime >= 3.19);
assert.ok(seekTime < cues[1]!.endTime);
});
@@ -0,0 +1,128 @@
import type { SubtitleCue } from './subtitle-cue-parser';
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
const CUE_BOUNDARY_SEEK_OFFSET_SECONDS = 0.08;
const CUE_END_GUARD_SECONDS = 0.01;
type CueGroup = {
startTime: number;
endTime: number;
cue: SubtitleCue;
};
function isValidCue(cue: SubtitleCue): boolean {
return (
Number.isFinite(cue.startTime) && Number.isFinite(cue.endTime) && cue.endTime > cue.startTime
);
}
function groupCueBoundaries(cues: readonly SubtitleCue[]): CueGroup[] {
const sorted = cues.filter(isValidCue).sort((left, right) => {
return left.startTime - right.startTime || left.endTime - right.endTime;
});
const groups: CueGroup[] = [];
for (const cue of sorted) {
const current = groups.at(-1);
if (current && cue.startTime - current.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS) {
current.endTime = Math.max(current.endTime, cue.endTime);
continue;
}
groups.push({ startTime: cue.startTime, endTime: cue.endTime, cue });
}
return groups;
}
/** A small offset avoids asking mpv to render exactly on a subtitle boundary. */
export function subtitleCueSeekTime(cue: SubtitleCue): number {
return Math.max(
cue.startTime,
Math.min(cue.endTime - CUE_END_GUARD_SECONDS, cue.startTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS),
);
}
/**
* Choose a stable point inside a selected cue. Karaoke lines can overlap while the
* previous line animates out, so a sidebar selection should clear that overlap when
* the selected cue has enough time remaining.
*/
export function subtitleCueListSeekTime(
cues: readonly SubtitleCue[],
selectedCue: SubtitleCue,
): number {
const groups = groupCueBoundaries(cues);
const selectedGroupIndex = groups.findIndex(
(group) =>
selectedCue.startTime >= group.startTime &&
selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS,
);
const previousGroupEndTime =
selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined;
if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) {
return subtitleCueSeekTime(selectedCue);
}
return Math.max(
selectedCue.startTime,
Math.min(
selectedCue.endTime - CUE_END_GUARD_SECONDS,
previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS,
),
);
}
/**
* Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can
* contain hundreds of subtitle events for one visible line, while the parsed list has
* already collapsed those events into the authored lines the user expects to navigate.
*/
export function resolveSanitizedSubtitleSeekCommand(
command: readonly (string | number)[],
cues: readonly SubtitleCue[],
currentTimeSec: number,
): (string | number)[] | null {
if (
command.length < 2 ||
command[0] !== 'sub-seek' ||
(command[1] !== -1 && command[1] !== 1) ||
!Number.isFinite(currentTimeSec)
) {
return null;
}
const groups = groupCueBoundaries(cues);
if (groups.length === 0) {
return null;
}
let activeIndex = -1;
for (const [index, group] of groups.entries()) {
if (group.startTime <= currentTimeSec && group.endTime > currentTimeSec) {
activeIndex = index;
}
}
let destination: CueGroup | undefined;
if (command[1] === 1) {
destination =
activeIndex >= 0
? groups[activeIndex + 1]
: groups.find((group) => group.startTime > currentTimeSec);
} else if (activeIndex >= 0) {
destination = groups[activeIndex - 1];
} else {
for (let index = groups.length - 1; index >= 0; index -= 1) {
const group = groups[index]!;
if (group.startTime < currentTimeSec) {
destination = group;
break;
}
}
}
if (!destination) {
return null;
}
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
}
@@ -570,6 +570,63 @@ test('parseSubtitleCues does not promote a short animated fragment as a complete
);
});
test('parseSubtitleCues keeps short animated English dialogue as separate cues', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}Hi',
'Dialogue: 0,0:00:02.00,0:00:03.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}No',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'Hi' },
{ startTime: 2, endTime: 3, text: 'No' },
]);
});
test('parseSubtitleCues does not reconstruct an already canonical English cue', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF',
'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF',
'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF',
'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 3,
text: 'POOF',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 3,
},
]);
});
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
'Dialogue: 1,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 3,
text: 'Oh',
source: 'reconstructed-ass',
animationStartTime: 1,
animationEndTime: 3,
assStyle: 'Karaoke',
},
]);
});
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
const content = [
'[Events]',
+331 -12
View File
@@ -8,19 +8,27 @@ import {
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
export type AssCueLayout =
| { kind: 'positioned'; sourceOrder: number; y: number }
| { kind: 'fragment-grid'; sourceOrder: number }
| { kind: 'source-order'; sourceOrder: number };
export interface SubtitleCue {
startTime: number;
endTime: number;
text: string;
/** A complete authored line recovered from matching generated ASS animation events. */
source?: 'canonical-ass';
/** How a complete line was recovered from generated ASS animation events. */
source?: 'canonical-ass' | 'reconstructed-ass';
/**
* Full span of the generated animation events a canonical cue replaced. Entrance and
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
* matching must use this envelope while display and history keep the authored timing.
* Full span of the generated animation events a recovered cue replaced. Entrance and
* exit frames can run past canonical authored timing.
*/
animationStartTime?: number;
animationEndTime?: number;
/** ASS style retained only for fragment-reconstructed lines. */
assStyle?: string;
/** Authored ASS ordering metadata used when flattening simultaneous positioned cues. */
assLayout?: AssCueLayout;
}
/**
@@ -29,7 +37,7 @@ export interface SubtitleCue {
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
* burst apart from two characters saying the same word in turn. None of it is meaningful
* outside the parser, so the public API exposes only timing, text, and the optional
* canonical-source marker used by live subtitle consumers.
* recovery marker used by live subtitle consumers.
*/
export interface AnnotatedSubtitleCue extends SubtitleCue {
/** Text exactly as authored, override blocks and all. */
@@ -75,15 +83,55 @@ function parseTimestamp(
* line breaks, matching what mpv hands over for the same line played live. No layer
* downstream decodes ASS again.
*/
function decodeSubtitleCueText(text: string): string {
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '');
}
function sanitizeSubtitleCueText(text: string): string {
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
return decodeSubtitleCueText(text).trim();
}
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
if (assLayout) {
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
}
return cue;
}
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
source
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
: { startTime, endTime, text },
return cues.map(
({
startTime,
endTime,
text,
source,
animationStartTime,
animationEndTime,
style,
assLayout,
}) => {
const common = {
startTime,
endTime,
text,
};
if (source === 'reconstructed-ass') {
return attachAssLayout(
{
...common,
source,
animationStartTime,
animationEndTime,
assStyle: style,
},
assLayout,
);
}
return attachAssLayout(
source ? { ...common, source, animationStartTime, animationEndTime } : common,
assLayout,
);
},
);
}
@@ -159,6 +207,11 @@ const MIN_CANONICAL_ANIMATION_EVENTS = 3;
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
// not enough evidence that the fragment represents an authored line boundary.
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
const MIN_FRAGMENT_LINE_EVENTS = 8;
const MIN_FRAGMENT_LINE_PARTS = 4;
const MAX_FRAGMENT_MEDIAN_LENGTH = 4;
const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2;
const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48;
function parseAssTimestamp(raw: string): number | null {
const match = ASS_TIMING_PATTERN.exec(raw.trim());
@@ -296,6 +349,240 @@ function isRepeatedFragmentCopy(
);
}
function hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]): boolean {
if (events.length < 2 || !events.every((event) => fragmentPlacementAnchors(event).size > 0)) {
return false;
}
const latestStart = events.reduce(
(latest, event) => Math.max(latest, event.startTime),
-Infinity,
);
const earliestEnd = events.reduce(
(earliest, event) => Math.min(earliest, event.endTime),
Infinity,
);
if (latestStart >= earliestEnd) {
return false;
}
const first = events[0]!;
const hasChangingOverrides = events.some(
(event) => event.overrideSignature !== first.overrideSignature,
);
const hasPositionedLayerCopy = events.some((event, index) =>
events
.slice(0, index)
.some(
(previous) =>
compactCueMatchText(previous) === compactCueMatchText(event) &&
isRepeatedFragmentCopy(previous, event),
),
);
return hasChangingOverrides || hasPositionedLayerCopy;
}
interface AssFragmentPart {
cue: AnnotatedSubtitleCue;
text: string;
}
function reconstructedAssFragmentLayout(
parts: readonly AssFragmentPart[],
owner: AnnotatedSubtitleCue,
): AssCueLayout | undefined {
let positionedPartCount = 0;
let minimumY = Infinity;
let maximumY = -Infinity;
for (const part of parts) {
const layout = part.cue.assLayout;
if (layout?.kind !== 'positioned') continue;
positionedPartCount += 1;
minimumY = Math.min(minimumY, layout.y);
maximumY = Math.max(maximumY, layout.y);
}
if (
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
) {
return { kind: 'fragment-grid', sourceOrder: owner.order };
}
return owner.assLayout;
}
interface AssFragmentTimingCluster {
events: AnnotatedSubtitleCue[];
minStartTime: number;
maxStartTime: number;
minEndTime: number;
maxEndTime: number;
}
function addToFragmentTimingCluster(
cluster: AssFragmentTimingCluster,
cue: AnnotatedSubtitleCue,
): void {
cluster.events.push(cue);
cluster.minStartTime = Math.min(cluster.minStartTime, cue.startTime);
cluster.maxStartTime = Math.max(cluster.maxStartTime, cue.startTime);
cluster.minEndTime = Math.min(cluster.minEndTime, cue.endTime);
cluster.maxEndTime = Math.max(cluster.maxEndTime, cue.endTime);
}
function fragmentTimingDistance(
cluster: AssFragmentTimingCluster,
cue: AnnotatedSubtitleCue,
): number {
const nextMinStart = Math.min(cluster.minStartTime, cue.startTime);
const nextMaxStart = Math.max(cluster.maxStartTime, cue.startTime);
const nextMinEnd = Math.min(cluster.minEndTime, cue.endTime);
const nextMaxEnd = Math.max(cluster.maxEndTime, cue.endTime);
if (
nextMaxStart - nextMinStart > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS ||
nextMaxEnd - nextMinEnd > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS
) {
return Infinity;
}
return (
Math.abs(cue.startTime - (cluster.minStartTime + cluster.maxStartTime) / 2) +
Math.abs(cue.endTime - (cluster.minEndTime + cluster.maxEndTime) / 2)
);
}
function clusterAssFragmentEvents(
events: readonly AnnotatedSubtitleCue[],
): AssFragmentTimingCluster[] {
const clusters: AssFragmentTimingCluster[] = [];
for (const cue of events) {
let nearest: AssFragmentTimingCluster | null = null;
let nearestDistance = Infinity;
for (const cluster of clusters) {
const distance = fragmentTimingDistance(cluster, cue);
if (distance < nearestDistance) {
nearest = cluster;
nearestDistance = distance;
}
}
if (nearest) {
addToFragmentTimingCluster(nearest, cue);
} else {
clusters.push({
events: [cue],
minStartTime: cue.startTime,
maxStartTime: cue.startTime,
minEndTime: cue.endTime,
maxEndTime: cue.endTime,
});
}
}
return clusters;
}
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
const visibleLines = decodeSubtitleCueText(cue.rawText)
.split('\n')
.filter((line) => line.trim().length > 0);
return visibleLines.length === 1 ? visibleLines[0]! : null;
}
function reconstructAssFragmentLine(
events: readonly AnnotatedSubtitleCue[],
): AnnotatedSubtitleCue | null {
const hasRelaxedEvidence = hasRelaxedAssFragmentEvidence(events);
const minimumEvents = hasRelaxedEvidence ? 2 : MIN_FRAGMENT_LINE_EVENTS;
if (events.length < minimumEvents || !hasAssAnimationEvidence(events)) {
return null;
}
const parts: AssFragmentPart[] = [];
for (const cue of events) {
const text = decodeSingleAssFragment(cue);
if (text === null) {
return null;
}
const compactText = compactAssMatchText(text);
const isLayerCopy = parts.some(
(part) =>
compactAssMatchText(part.text) === compactText && isRepeatedFragmentCopy(part.cue, cue),
);
if (!isLayerCopy) {
parts.push({ cue, text });
}
}
const minimumParts = hasRelaxedEvidence ? 1 : MIN_FRAGMENT_LINE_PARTS;
if (parts.length < minimumParts || (!hasRelaxedEvidence && parts.length === events.length)) {
return null;
}
const lengths = parts
.map((part) => compactAssMatchText(part.text).length)
.sort((left, right) => left - right);
if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) {
return null;
}
const text = parts
.map((part) => part.text)
.join('')
.trim();
if (!text) {
return null;
}
const owner = parts[0]!.cue;
const animationStartTime = earliestStartTime(events);
const animationEndTime = latestEndTime(events);
return {
...owner,
startTime: animationStartTime,
endTime: animationEndTime,
text,
rawText: text,
source: 'reconstructed-ass',
animationStartTime,
animationEndTime,
assLayout: reconstructedAssFragmentLayout(parts, owner),
overrides: [],
overrideSignature: '',
};
}
function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const groups = new Map<string, AnnotatedSubtitleCue[]>();
for (const cue of dialogue) {
if (cue.source !== undefined) {
continue;
}
const key = assEventGroupKey(cue);
const group = groups.get(key);
if (group) {
group.push(cue);
} else {
groups.set(key, [cue]);
}
}
const recovered: AnnotatedSubtitleCue[] = [];
const suppressed = new Set<AnnotatedSubtitleCue>();
for (const events of groups.values()) {
for (const cluster of clusterAssFragmentEvents(events)) {
const line = reconstructAssFragmentLine(cluster.events);
if (!line) {
continue;
}
recovered.push(line);
cluster.events.forEach((event) => suppressed.add(event));
}
}
if (recovered.length === 0) {
return dialogue;
}
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
(left, right) =>
left.startTime - right.startTime || left.endTime - right.endTime || left.order - right.order,
);
}
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
const groups: FragmentGroup[] = [];
for (const event of events) {
@@ -507,6 +794,37 @@ function recoverCanonicalAssEvents({
);
}
function parseAssCoordinate(value: string | undefined): number | null {
if (!value?.trim()) return null;
const coordinate = Number(value.trim());
return Number.isFinite(coordinate) ? coordinate : null;
}
function buildAssCueLayout(
overrides: readonly AssOverrideCommand[],
sourceOrder: number,
): AssCueLayout {
let y: number | null = null;
for (const command of overrides) {
if (command.animated) continue;
const name = command.name.toLowerCase();
const args = command.args.split(',');
if (name === 'pos') {
y = parseAssCoordinate(args[1]) ?? y;
continue;
}
if (name !== 'move') continue;
const startY = parseAssCoordinate(args[1]);
const endY = parseAssCoordinate(args[3]);
if (startY !== null && endY !== null) {
y = (startY + endY) / 2;
}
}
return y === null
? { kind: 'source-order', sourceOrder }
: { kind: 'positioned', sourceOrder, y };
}
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const cues: AnnotatedSubtitleCue[] = [];
const comments: AnnotatedSubtitleCue[] = [];
@@ -615,6 +933,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
overrides,
overrideSignature: assOverrideSignature(overrides),
order: eventOrder,
assLayout: buildAssCueLayout(overrides, eventOrder),
};
eventOrder += 1;
if (eventPrefix === ASS_COMMENT_PREFIX) {
@@ -628,7 +947,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
}
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
}
export function parseAssCues(content: string): SubtitleCue[] {
+28 -2
View File
@@ -312,6 +312,7 @@ import {
promoteSettingsWindowAboveOverlay,
registerGlobalShortcuts as registerGlobalShortcutsCore,
replayCurrentSubtitleRuntime,
resolveSanitizedSubtitleSeekCommand,
resolveJellyfinPlaybackPlanRuntime,
runStartupBootstrapRuntime,
saveJellyfinSubtitleDelay,
@@ -1958,6 +1959,31 @@ let linuxVisibleOverlayOwnerBindingKey: string | null = null;
let linuxVisibleOverlayWindowModeSwitchToken = 0;
let subtitleSidebarRequestedOpen = false;
const SEEK_THRESHOLD_SECONDS = 3;
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
let explicitSeekIntentExpiresAtMs = 0;
function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean {
return command[0] === 'seek' || command[0] === 'sub-seek';
}
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
const command =
resolveSanitizedSubtitleSeekCommand(
rawCommand,
appState.activeParsedSubtitleCues,
appState.mpvClient?.currentTimePos ?? Number.NaN,
) ?? rawCommand;
if (isExplicitMpvSeekCommand(command)) {
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
}
sendMpvCommandRuntime(appState.mpvClient, command);
}
function consumeExplicitSeekIntent(): boolean {
const pending = explicitSeekIntentExpiresAtMs >= Date.now();
explicitSeekIntentExpiresAtMs = 0;
return pending;
}
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => appState.currentMediaPath,
@@ -4580,6 +4606,7 @@ const {
reportJellyfinRemoteProgress: (forceImmediate) => {
void reportJellyfinRemoteProgress(forceImmediate);
},
consumeExplicitSeek: () => consumeExplicitSeekIntent(),
onTimePosUpdate: (time) => {
const delta = time - lastObservedTimePos;
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
@@ -5485,8 +5512,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
sendMpvCommand: (rawCommand: (string | number)[]) =>
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
sendMpvCommand: (rawCommand: (string | number)[]) => sendRendererMpvCommand(rawCommand),
getMpvClient: () => appState.mpvClient,
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
import type { SubtitleData } from '../../types';
import {
@@ -211,6 +212,69 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
]);
});
test('parsed cues replace a duplicate raw autoplay subtitle that was already primed', async () => {
const rawText = 'ジグザグな道を抜け\nジグザグな道を抜け';
const correctedText = 'ジグザグな道を抜け';
const mediaPath = '/media/video.mkv';
let currentSubText = '';
const emitted: string[] = [];
const client = {
connected: true,
currentVideoPath: mediaPath,
currentTimePos: 90,
currentSubText: rawText,
requestProperty: async (name: string) => {
if (name === 'sub-text') return rawText;
if (name === 'time-pos') return 90;
return null;
},
};
const cues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
`Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
`Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
].join('\n'),
'startup-ending.ass',
);
let activeCues = cues.slice(0, 0);
const runtime = createAutoplaySubtitlePrimingRuntime({
getCurrentMediaPath: () => mediaPath,
getMpvClient: () => client,
setCurrentSubText: (text) => {
currentSubText = text;
},
getCurrentSubText: () => currentSubText,
getCurrentSubtitleData: () => null,
getActiveParsedSubtitleCues: () => activeCues,
setActiveParsedSubtitleMediaPath: () => {},
subtitleProcessingController: {
consumeCachedSubtitle: () => null,
onSubtitleChange: () => true,
refreshCurrentSubtitle: () => true,
notePlainSubtitleEmitted: () => {},
},
emitSubtitlePayload: (payload) => emitted.push(payload.text),
getSubtitlePrefetchService: () => null,
getLastObservedTimePos: () => 90,
getVisibleOverlayVisible: () => true,
emitSecondarySubtitle: () => {},
initSubtitlePrefetch: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
logDebug: () => {},
});
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
assert.equal(currentSubText, rawText);
activeCues = cues;
await runtime.primeAutoplaySubtitleFromParsedCues(mediaPath, cues);
assert.equal(currentSubText, correctedText);
assert.deepEqual(emitted, [rawText, correctedText]);
});
// Driven by the real processing controller rather than a stub: the failure this
// covers is a disagreement between the priming path and the controller's own
// staleness rules, which a hand-written stub cannot reproduce.
@@ -12,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
requestProperty: (name: string) => Promise<unknown>;
currentVideoPath?: string;
currentTimePos?: number;
currentSubText?: string;
currentSecondarySubText?: string;
setCurrentSecondarySubText?: (text: string) => void;
};
@@ -107,11 +108,19 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
autoplaySubtitlePrimedMediaPath = null;
}
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
function emitAutoplayPrimedSubtitle(
mediaPath: string,
text: string,
options: { replaceExisting?: boolean } = {},
): boolean {
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
return false;
}
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
if (!options.replaceExisting || deps.getCurrentSubText() === text) {
return false;
}
} else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
return false;
}
@@ -252,11 +261,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
mediaPath: string,
cues: SubtitleCue[],
): Promise<void> {
if (
cues.length === 0 ||
autoplaySubtitlePrimedMediaPath === mediaPath ||
!isCurrentAutoplayMediaPath(mediaPath)
) {
if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) {
return;
}
@@ -265,16 +270,21 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
);
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
const cue = selectAutoplayStartupCue(
cues,
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
resolvedTimeSeconds,
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
);
if (!cue) {
const liveText = client?.currentSubText ?? '';
const text = liveText.trim()
? resolvePrimarySubtitleText({ liveText, currentTimeSec: resolvedTimeSeconds, cues })
: (cue?.text ?? '');
if (!text) {
return;
}
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true });
}
function clearScheduledSubtitlePrefetchRefresh(): void {
@@ -358,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () =>
]);
});
test('time-pos handler treats an explicit short jump as a seek', () => {
const updateKinds: string[] = [];
let explicitSeekPending = false;
const timeHandler = createHandleMpvTimePosChangeHandler({
recordPlaybackPosition: () => {},
reportJellyfinRemoteProgress: () => {},
refreshDiscordPresence: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
consumeExplicitSeek: () => {
const pending = explicitSeekPending;
explicitSeekPending = false;
return pending;
},
onTimePosUpdate: (_time, kind) => updateKinds.push(kind),
});
timeHandler({ time: 10 });
explicitSeekPending = true;
timeHandler({ time: 11.5 });
timeHandler({ time: 11.6 });
assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']);
});
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
const watchedSeconds: unknown[] = [];
const timeHandler = createHandleMpvTimePosChangeHandler({
+13 -3
View File
@@ -4,6 +4,8 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
type TimePosUpdateKind = 'initial' | 'playback' | 'seek';
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
@@ -138,12 +140,20 @@ export function createHandleMpvTimePosChangeHandler(deps: {
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
consumeExplicitSeek?: () => boolean;
}) {
let lastObservedTime: number | null = null;
return ({ time }: { time: number }): void => {
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
const explicitSeek = deps.consumeExplicitSeek?.() ?? false;
const updateKind: TimePosUpdateKind =
lastObservedTime === null
? 'initial'
: explicitSeek || isSeekLikeTimeChange(lastObservedTime, time)
? 'seek'
: 'playback';
const forceImmediate = updateKind === 'seek';
if (Number.isFinite(time)) {
lastObservedTime = time;
}
@@ -153,7 +163,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
deps.onTimePosUpdate?.(time, updateKind);
};
}
@@ -1,10 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
test('main mpv event binder wires callbacks through to runtime deps', () => {
const handlers = new Map<string, (payload: unknown) => void>();
const calls: string[] = [];
let currentTime = 0;
const seekLiveText = '少しだけ好きになる\n少しだけ好きになる';
const seekCues = parseSubtitleCues(
[
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
].join('\n'),
'seek-ending.ass',
);
const bind = createBindMpvMainEventHandlersHandler({
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
@@ -27,6 +40,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
resolveSubtitleText: (liveText) =>
resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }),
getCurrentLiveSubtitleText: () => seekLiveText,
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
@@ -60,6 +76,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
reportJellyfinRemoteProgress: (forceImmediate) =>
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
onTimePosUpdate: (time) => {
currentTime = time;
},
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
@@ -83,7 +102,13 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('media-path-change')?.({ path: '' });
handlers.get('media-title-change')?.({ title: 'Episode 1' });
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
assert.ok(calls.includes('set-sub:少しだけ好きになる'));
handlers.get('time-pos-change')?.({ time: 2.5 });
handlers.get('subtitle-change')?.({ text: seekLiveText });
handlers.get('time-pos-change')?.({ time: 90 });
handlers.get('pause-change')?.({ paused: true });
assert.ok(calls.includes('set-sub:line'));
+11 -1
View File
@@ -44,6 +44,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
setCurrentSubText: (text: string) => void;
resolveSubtitleText?: (text: string) => string;
getCurrentLiveSubtitleText?: () => string;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void;
@@ -78,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
recordPauseState: (paused: boolean) => void;
@@ -171,7 +173,15 @@ export function createBindMpvMainEventHandlersHandler(deps: {
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time, updateKind) => {
deps.onTimePosUpdate?.(time);
if (updateKind === 'playback') return;
const liveText = deps.getCurrentLiveSubtitleText?.();
if (liveText !== undefined) {
handleMpvSubtitleChange({ text: liveText });
}
},
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => deps.recordPauseState(paused),
@@ -21,6 +21,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
overlayRuntimeInitialized: boolean;
mpvClient: {
connected?: boolean;
currentSubText?: string;
currentSecondarySubText?: string;
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
@@ -85,6 +86,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
consumeExplicitSeek?: () => boolean;
onFullscreenChange?: (fullscreen: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
@@ -160,6 +162,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
cues: deps.appState.activeParsedSubtitleCues,
}),
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
const tracker = deps.appState.immersionTracker;
@@ -332,6 +335,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
consumeExplicitSeek: deps.consumeExplicitSeek,
onTimePosUpdate: (time: number) => {
// Timing history is a viewing log: after a real backward seek, a rewatched
// canonical line should enter it again. Immersion stats keep their
@@ -64,6 +64,30 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
);
});
test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 2,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ好きになる',
'Dialogue: 1,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ\\h好きになる',
'Dialogue: 0,0:00:01.00,0:00:03.00,EDJP,,0,0,0,,少しだけ 好きになる',
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e10.ass');
assert.deepEqual(
cues.map((cue) => cue.text),
['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'],
);
assert.equal(
resolvePrimarySubtitleText({
liveText: ['少しだけ好きになる', '少しだけ 好きになる', '少しだけ 好きになる'].join('\n'),
currentTimeSec: 2,
cues,
}),
'少しだけ好きになる',
);
});
test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => {
assert.equal(
resolvePrimarySubtitleText({
@@ -184,6 +208,20 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
assert.equal(text, 'first\nsecond');
});
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
assert.equal(
resolvePrimarySubtitleText({
liveText: '少しだけ好きになる\n少しだけ 好きになる',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: '少しだけ好きになる', source: 'canonical-ass' },
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる', source: 'canonical-ass' },
],
}),
'少しだけ好きになる',
);
});
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
const cue = {
startTime: 1.2,
+18 -17
View File
@@ -25,7 +25,7 @@ function nearbyCanonicalCues(
currentTimeSec: number,
): SubtitleCue[] {
return (cues ?? []).filter((cue) => {
if (cue.source !== 'canonical-ass') {
if (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') {
return false;
}
const span = animationSpan(cue);
@@ -40,6 +40,21 @@ function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
}
// ASS layers can encode the same visible spacing with ordinary, hard, or
// ideographic spaces. Matching and emission must use the same identity or each
// layer reappears as a copy.
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of cues) {
const compactText = compactWhitespace(cue.text);
if (seen.has(compactText)) continue;
seen.add(compactText);
texts.push(cue.text);
}
return texts;
}
function compactLineSegments(text: string): string[] {
return text.split('\n').map(compactWhitespace).filter(Boolean);
}
@@ -83,14 +98,7 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
const texts = uniqueCueTexts(selected);
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
@@ -159,14 +167,7 @@ export function resolveCanonicalPrimarySubtitle(options: {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
const texts = uniqueCueTexts(selected);
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
@@ -20,6 +20,194 @@ test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
);
});
test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: '少しだけ好きになる' },
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる' },
{ startTime: 1, endTime: 3, text: '少しだけ 好きになる' },
],
2,
),
'少しだけ好きになる',
);
});
test('parsed secondary text collapses a positioned sign that repeats dialogue without punctuation', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 10,0:03:58.49,0:04:00.34,GJM_Main_1080p,Nar,0,0,0,,{\\i1}A question veiled as an insult!',
'Dialogue: 1,0:03:58.59,0:04:00.34,iFanzSigns,,0,0,0,,{\\pos(960,75)}A question veiled as an insult',
].join('\n');
const cues = parseSubtitleCues(ass, 'kaguya-s02e10.ass');
assert.equal(findActiveSubtitleText(cues, 238.48), '');
assert.equal(findActiveSubtitleText(cues, 238.5), 'A question veiled as an insult!');
assert.equal(findActiveSubtitleText(cues, 239), 'A question veiled as an insult!');
assert.equal(findActiveSubtitleText(cues, 240.34), '');
});
test('parsed secondary text drops a reconstructed grid of positioned sign fragments', () => {
const signFragment = (text: string, x: number, y: number) =>
`Dialogue: 1,0:00:01.00,0:00:03.00,Signs,,0,0,0,,{\\pos(${x},${y})\\t(0,100,\\fscx101)}${text}`;
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 10,0:00:01.00,0:00:03.00,Default,Speaker,0,0,0,,Come on, wake up!',
signFragment('Timetable', 1700, 150),
signFragment('Mon', 1750, 230),
signFragment('Tue', 1850, 230),
signFragment('1', 1650, 320),
signFragment('2', 1650, 390),
signFragment('Civics', 1750, 320),
signFragment('Math', 1850, 390),
signFragment('PE', 1850, 460),
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'kaguya-s02e11.ass'), 2),
'Come on, wake up!',
);
});
test('parsed secondary lyrics keep explicit ASS vertical order when durations alternate', () => {
const lyric = (options: { start: string; end: string; style: string; y: number; text: string }) =>
`Dialogue: 0,0:00:${options.start},0:00:${options.end},${options.style},,0,0,0,fx,{\\move(100,${options.y},120,${options.y})\\t(0,200,\\fscx110)}${options.text}\\N{\\p1}m 0 0 l 0 5`;
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
lyric({
start: '01.00',
end: '02.20',
style: 'ed_romaji',
y: 66,
text: 'ima wo kakusarechau mae ni',
}),
lyric({
start: '01.00',
end: '02.00',
style: 'ed_english',
y: 1020,
text: 'Before the present moment gets hidden away.',
}),
lyric({
start: '03.00',
end: '04.00',
style: 'ed_romaji',
y: 66,
text: 'ame mitai ni hikatteru',
}),
lyric({
start: '03.00',
end: '04.20',
style: 'ed_english',
y: 1020,
text: 'Is shining like rain.',
}),
].join('\n');
const cues = parseSubtitleCues(ass, 'polar-opposites-s01e08.ass');
assert.equal(
findActiveSubtitleText(cues, 1.5),
'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.',
);
assert.equal(findActiveSubtitleText(cues, 3.5), 'ame mitai ni hikatteru\nIs shining like rain.');
});
test('unpositioned secondary lyrics fall back to ASS source order', () => {
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.20,ED Romaji,,0,0,0,,ima wo kakusarechau mae ni',
'Dialogue: 0,0:00:01.00,0:00:02.00,ED English,,0,0,0,,Before the present moment gets hidden away.',
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 1.5),
'ima wo kakusarechau mae ni\nBefore the present moment gets hidden away.',
);
});
test('findActiveSubtitleText keeps a canonical ASS cue for its generated animation span', () => {
const poof = {
startTime: 1110.67,
endTime: 1110.71,
text: 'POOF',
source: 'canonical-ass' as const,
animationStartTime: 1110.67,
animationEndTime: 1111.59,
};
assert.equal(findActiveSubtitleText([poof], 1111.58), 'POOF');
assert.equal(findActiveSubtitleText([poof], 1111.59), '');
});
test('ASS fragment karaoke stays separated by style with authored word spacing', () => {
const lineEvents = (
style: string,
fragments: readonly string[],
y: number,
baseTime = 1,
): string[] => {
const events: string[] = [];
for (const layer of [0, 1]) {
fragments.forEach((fragment, index) => {
const x = 100 + index * 40;
const start = (baseTime + index * 0.25).toFixed(2).padStart(5, '0');
const end = (baseTime + 3 + index * 0.2).toFixed(2).padStart(5, '0');
events.push(
`Dialogue: ${layer},0:00:${start},0:00:${end},${style},,0,0,0,,{\\pos(${x},${y})\\t(0,200,\\fscx110)}${fragment}\\N{\\p1}m 0 0 l 0 10`,
);
});
}
return events;
};
const ass = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...lineEvents('ed_romaji', ['ji', 'gu', 'za', 'gu ', 'na', 'mi'], 70),
...lineEvents('ed_english', ['Pas', 'si', 'ng ', 'thro', 'u', 'gh '], 110),
...lineEvents('op_english', ['I', 'want', 'to', 'go'], 110, 7),
].join('\n');
assert.equal(
findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 2.5),
'jiguzagu nami\nPassing through',
);
// Some generated scripts discard spaces and retain only positioned chunks. Joining
// without invented separators avoids turning one word into spaced syllables.
assert.equal(findActiveSubtitleText(parseSubtitleCues(ass, 'ending.ass'), 8.5), 'Iwanttogo');
});
test('findActiveSubtitleText keeps a complete reconstructed line over entrance fragments', () => {
const current = {
startTime: 1,
endTime: 4,
text: 'Complete current line',
source: 'reconstructed-ass' as const,
assStyle: 'op_english',
};
const nextEntrance = {
startTime: 3.8,
endTime: 4.2,
text: 'Ne',
source: 'reconstructed-ass' as const,
assStyle: 'op_english',
};
const nextLine = {
startTime: 4,
endTime: 7,
text: 'Next complete line',
source: 'reconstructed-ass' as const,
assStyle: 'op_english',
};
assert.equal(findActiveSubtitleText([current, nextEntrance], 3.9), current.text);
assert.equal(findActiveSubtitleText([current, nextEntrance, nextLine], 4.1), nextLine.text);
});
test('secondary track controller parses the selected ASS file before publishing', async () => {
const broadcasts: string[] = [];
let currentText = '';
+98 -5
View File
@@ -1,4 +1,5 @@
import type { SubtitleCue } from '../../types/subtitle';
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
type SecondarySubtitleMpvClient = {
connected?: boolean;
@@ -58,16 +59,108 @@ function buildSelectedTrackIdentity(
]);
}
type IndexedSubtitleCue = { cue: SubtitleCue; index: number };
function compareAuthoredSubtitleOrder(left: IndexedSubtitleCue, right: IndexedSubtitleCue): number {
const leftLayout = left.cue.assLayout;
const rightLayout = right.cue.assLayout;
if (leftLayout?.kind === 'positioned' && rightLayout?.kind === 'positioned') {
const verticalOrder = leftLayout.y - rightLayout.y;
if (verticalOrder !== 0) return verticalOrder;
}
if (leftLayout && rightLayout) {
const sourceOrder = leftLayout.sourceOrder - rightLayout.sourceOrder;
if (sourceOrder !== 0) return sourceOrder;
}
return left.index - right.index;
}
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
if (!Number.isFinite(timeSeconds)) return '';
const seen = new Set<string>();
const authoredCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' && cue.startTime <= timeSeconds && cue.endTime > timeSeconds,
);
const selectedCanonical = new Set<SubtitleCue>(authoredCanonical);
if (selectedCanonical.size === 0) {
const animatedCanonical = cues.filter(
(cue) =>
cue.source === 'canonical-ass' &&
(cue.animationStartTime ?? cue.startTime) <= timeSeconds &&
(cue.animationEndTime ?? cue.endTime) > timeSeconds,
);
const nearestDistance = animatedCanonical.reduce((nearest, cue) => {
const distance =
timeSeconds < cue.startTime
? cue.startTime - timeSeconds
: Math.max(0, timeSeconds - cue.endTime);
return Math.min(nearest, distance);
}, Infinity);
for (const cue of animatedCanonical) {
const distance =
timeSeconds < cue.startTime
? cue.startTime - timeSeconds
: Math.max(0, timeSeconds - cue.endTime);
if (distance === nearestDistance) {
selectedCanonical.add(cue);
}
}
}
const activeReconstructed = cues.filter(
(cue) =>
cue.source === 'reconstructed-ass' &&
cue.assLayout?.kind !== 'fragment-grid' &&
cue.startTime <= timeSeconds &&
cue.endTime > timeSeconds,
);
const reconstructedByStyle = new Map<string, SubtitleCue>();
for (const cue of activeReconstructed) {
const style = cue.assStyle ?? '';
const existing = reconstructedByStyle.get(style);
if (!existing) {
reconstructedByStyle.set(style, cue);
continue;
}
const duration = cue.endTime - cue.startTime;
const existingDuration = existing.endTime - existing.startTime;
if (
duration > existingDuration ||
(duration === existingDuration && cue.text.length > existing.text.length) ||
(duration === existingDuration &&
cue.text.length === existing.text.length &&
cue.startTime > existing.startTime)
) {
reconstructedByStyle.set(style, cue);
}
}
const selectedReconstructed = new Set(reconstructedByStyle.values());
const seenExact = new Set<string>();
const seenFlattened = new Set<string>();
const activeText: string[] = [];
for (const cue of cues) {
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
const activeCues: IndexedSubtitleCue[] = [];
cues.forEach((cue, index) => {
const active =
cue.source === 'canonical-ass'
? selectedCanonical.has(cue)
: cue.source === 'reconstructed-ass'
? selectedReconstructed.has(cue)
: cue.startTime <= timeSeconds && cue.endTime > timeSeconds;
if (active) activeCues.push({ cue, index });
});
activeCues.sort(compareAuthoredSubtitleOrder);
for (const { cue } of activeCues) {
const text = cue.text.trim();
if (!text || seen.has(text)) continue;
seen.add(text);
const compactText = text.replace(/\s+/gu, '');
if (!compactText || seenExact.has(compactText)) continue;
seenExact.add(compactText);
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
activeText.push(text);
}
return activeText.join('\n');
+6 -2
View File
@@ -240,14 +240,15 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const snapshot: SubtitleSidebarSnapshot = {
cues: [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 1, endTime: 3.4, text: 'first' },
{ startTime: 3, endTime: 4, text: 'second' },
],
currentSubtitle: {
text: 'second',
startTime: 3,
startTime: 3.5,
endTime: 4,
},
currentTimeSec: 3.5,
config: {
enabled: true,
autoOpen: false,
@@ -361,6 +362,9 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
modal.seekToCue(snapshot.cues[1]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
modal.closeSubtitleSidebarModal();
assert.deepEqual(visibilityChanges, [true, false]);
assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
+2 -3
View File
@@ -4,6 +4,7 @@ import type {
SubtitleMiningContext,
SubtitleSidebarSnapshot,
} from '../../types';
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
import type { ModalStateReader, RendererContext } from '../context';
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
import {
@@ -14,7 +15,6 @@ import {
const MANUAL_SCROLL_HOLD_MS = 1500;
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
const CLICK_SEEK_OFFSET_SEC = 0.08;
const SNAPSHOT_POLL_INTERVAL_MS = 80;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
@@ -392,10 +392,9 @@ export function createSubtitleSidebarModal(
}
function seekToCue(cue: SubtitleCue): void {
const targetTime = Math.min(cue.endTime - 0.01, cue.startTime + CLICK_SEEK_OFFSET_SEC);
window.electronAPI.sendMpvCommand([
'seek',
Math.max(cue.startTime, targetTime),
subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue),
'absolute+exact',
]);
}
+9
View File
@@ -1454,6 +1454,15 @@ test('prepareSecondarySubtitleLines preserves repeated short dialogue without la
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
});
test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => {
const dialogue = 'A question veiled as an insult!';
const positionedSign = 'A question veiled as an insult';
assert.deepEqual(prepareSecondarySubtitleLines([dialogue, positionedSign].join('\\N')), [
dialogue,
]);
});
test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => {
const dialogue = ['Wait', 'Go!', 'No!', 'Run!'];
+13 -1
View File
@@ -6,6 +6,7 @@ import type {
SubtitleRendererStyleConfig,
} from '../types';
import { assToPlainText, normalizePlainSubtitleText } from '../core/services/ass-text.js';
import { flattenedSecondarySubtitleLineIdentity } from '../core/services/secondary-subtitle-line-identity.js';
import type { RendererContext } from './context';
import { PRIMARY_SUB_VISIBLE_ON_YOMITAN_POPUP_CLASS } from './yomitan-popup.js';
@@ -665,6 +666,17 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
return median <= KARAOKE_MAX_MEDIAN_LINE_LENGTH;
}
function collapseFullLineFallbackCopies(lines: string[]): string[] {
const seen = new Set<string>();
return lines.filter((line) => {
const identity = flattenedSecondarySubtitleLineIdentity(line);
if (!identity) return true;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}
export function prepareSecondarySubtitleLines(text: string): string[] {
// The one display-side ASS decode: secondary text also reaches the overlay from
// websocket clients that forward their source line untouched, so unlike the primary
@@ -678,7 +690,7 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (!isKaraokeLikeLineSet(lines)) {
return lines;
return collapseFullLineFallbackCopies(lines);
}
const seen = new Set<string>();