mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-20 00:15:27 -07:00
Compare commits
1
Commits
v0.19.4-beta.2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c01bcd9d0f
|
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: subtitles
|
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, and keep canonical signs visible for their complete generated animation while preserving unmatched dialogue and signs.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-18
|
Last verified: 2026-08-19
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -71,11 +71,16 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
|
|
||||||
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
|
- 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
|
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
|
full-span style layers appear once instead of repeating for fill, border, blur, shadow,
|
||||||
shadow events. Any unmatched live line keeps the complete live stack, preserving
|
or equivalent whitespace variants. Any unmatched live line keeps the complete live
|
||||||
dialogue or signs that overlap a lyric.
|
stack, preserving dialogue or signs that overlap a lyric.
|
||||||
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||||
live work does not contend for Yomitan state.
|
live work does not contend for Yomitan state.
|
||||||
|
- The initial `time-pos` and later seek-like jumps reprocess mpv's current raw `sub-text` after the
|
||||||
|
new playback time is stored. This corrects ASS cleanup when mpv delivered the destination
|
||||||
|
subtitle before the destination timestamp.
|
||||||
|
- 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
|
- 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
|
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||||
replace the current cue.
|
replace the current cue.
|
||||||
@@ -95,6 +100,18 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
- 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
|
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
||||||
text when a readable source is available.
|
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;
|
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
||||||
track-list changes refresh without discarding an unchanged source. Observed
|
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,
|
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -149,8 +149,8 @@ function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRu
|
|||||||
const isFlush =
|
const isFlush =
|
||||||
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
||||||
if (
|
if (
|
||||||
first.source === 'canonical-ass' ||
|
first.source !== undefined ||
|
||||||
next.source === 'canonical-ass' ||
|
next.source !== undefined ||
|
||||||
next.text !== first.text ||
|
next.text !== first.text ||
|
||||||
assStyleKey(next) !== styleKey ||
|
assStyleKey(next) !== styleKey ||
|
||||||
!isFlush
|
!isFlush
|
||||||
@@ -223,7 +223,7 @@ function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number)
|
|||||||
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
* 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.
|
* 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))) {
|
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
|
|||||||
@@ -8,19 +8,26 @@ import {
|
|||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||||
|
|
||||||
|
export type AssCueLayout =
|
||||||
|
| { kind: 'positioned'; sourceOrder: number; y: number }
|
||||||
|
| { kind: 'source-order'; sourceOrder: number };
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
text: string;
|
text: string;
|
||||||
/** A complete authored line recovered from matching generated ASS animation events. */
|
/** How a complete line was recovered from generated ASS animation events. */
|
||||||
source?: 'canonical-ass';
|
source?: 'canonical-ass' | 'reconstructed-ass';
|
||||||
/**
|
/**
|
||||||
* Full span of the generated animation events a canonical cue replaced. Entrance and
|
* Full span of the generated animation events a recovered cue replaced. Entrance and
|
||||||
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
|
* exit frames can run past canonical authored timing.
|
||||||
* matching must use this envelope while display and history keep the authored timing.
|
|
||||||
*/
|
*/
|
||||||
animationStartTime?: number;
|
animationStartTime?: number;
|
||||||
animationEndTime?: 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 +36,7 @@ export interface SubtitleCue {
|
|||||||
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
* 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
|
* 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
|
* 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 {
|
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||||
/** Text exactly as authored, override blocks and all. */
|
/** Text exactly as authored, override blocks and all. */
|
||||||
@@ -75,15 +82,55 @@ function parseTimestamp(
|
|||||||
* line breaks, matching what mpv hands over for the same line played live. No layer
|
* line breaks, matching what mpv hands over for the same line played live. No layer
|
||||||
* downstream decodes ASS again.
|
* downstream decodes ASS again.
|
||||||
*/
|
*/
|
||||||
|
function decodeSubtitleCueText(text: string): string {
|
||||||
|
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '');
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeSubtitleCueText(text: string): string {
|
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[] {
|
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||||
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
|
return cues.map(
|
||||||
source
|
({
|
||||||
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
|
startTime,
|
||||||
: { startTime, endTime, text },
|
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 +206,10 @@ const MIN_CANONICAL_ANIMATION_EVENTS = 3;
|
|||||||
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
// 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.
|
// not enough evidence that the fragment represents an authored line boundary.
|
||||||
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
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;
|
||||||
|
|
||||||
function parseAssTimestamp(raw: string): number | null {
|
function parseAssTimestamp(raw: string): number | null {
|
||||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||||
@@ -296,6 +347,215 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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[] {
|
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
||||||
const groups: FragmentGroup[] = [];
|
const groups: FragmentGroup[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -507,6 +767,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 {
|
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||||
const cues: AnnotatedSubtitleCue[] = [];
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const comments: AnnotatedSubtitleCue[] = [];
|
const comments: AnnotatedSubtitleCue[] = [];
|
||||||
@@ -615,6 +906,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
overrides,
|
overrides,
|
||||||
overrideSignature: assOverrideSignature(overrides),
|
overrideSignature: assOverrideSignature(overrides),
|
||||||
order: eventOrder,
|
order: eventOrder,
|
||||||
|
assLayout: buildAssCueLayout(overrides, eventOrder),
|
||||||
};
|
};
|
||||||
eventOrder += 1;
|
eventOrder += 1;
|
||||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||||
@@ -628,7 +920,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||||
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
|
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssCues(content: string): SubtitleCue[] {
|
export function parseAssCues(content: string): SubtitleCue[] {
|
||||||
|
|||||||
@@ -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 { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||||
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
import {
|
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
|
// 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
|
// covers is a disagreement between the priming path and the controller's own
|
||||||
// staleness rules, which a hand-written stub cannot reproduce.
|
// staleness rules, which a hand-written stub cannot reproduce.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
currentVideoPath?: string;
|
currentVideoPath?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
};
|
};
|
||||||
@@ -107,11 +108,19 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
autoplaySubtitlePrimedMediaPath = null;
|
autoplaySubtitlePrimedMediaPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
function emitAutoplayPrimedSubtitle(
|
||||||
|
mediaPath: string,
|
||||||
|
text: string,
|
||||||
|
options: { replaceExisting?: boolean } = {},
|
||||||
|
): boolean {
|
||||||
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||||
|
if (!options.replaceExisting || deps.getCurrentSubText() === text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +261,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
cues: SubtitleCue[],
|
cues: SubtitleCue[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
cues.length === 0 ||
|
|
||||||
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
|
||||||
!isCurrentAutoplayMediaPath(mediaPath)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,16 +270,21 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSeconds = Number(
|
const currentTimeSeconds = Number(
|
||||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
);
|
);
|
||||||
|
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
||||||
const cue = selectAutoplayStartupCue(
|
const cue = selectAutoplayStartupCue(
|
||||||
cues,
|
cues,
|
||||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
resolvedTimeSeconds,
|
||||||
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ type AnilistPostWatchRunOptions = {
|
|||||||
watchedSeconds?: number;
|
watchedSeconds?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TimePosUpdateKind = 'initial' | 'playback' | 'seek';
|
||||||
|
|
||||||
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
|
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
|
||||||
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||||
|
|
||||||
@@ -138,12 +140,18 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logError?: (message: string, error: unknown) => void;
|
logError?: (message: string, error: unknown) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
|
||||||
}) {
|
}) {
|
||||||
let lastObservedTime: number | null = null;
|
let lastObservedTime: number | null = null;
|
||||||
|
|
||||||
return ({ time }: { time: number }): void => {
|
return ({ time }: { time: number }): void => {
|
||||||
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
|
const updateKind: TimePosUpdateKind =
|
||||||
|
lastObservedTime === null
|
||||||
|
? 'initial'
|
||||||
|
: isSeekLikeTimeChange(lastObservedTime, time)
|
||||||
|
? 'seek'
|
||||||
|
: 'playback';
|
||||||
|
const forceImmediate = updateKind === 'seek';
|
||||||
if (Number.isFinite(time)) {
|
if (Number.isFinite(time)) {
|
||||||
lastObservedTime = time;
|
lastObservedTime = time;
|
||||||
}
|
}
|
||||||
@@ -153,7 +161,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||||
deps.logError?.('AniList post-watch update failed unexpectedly', 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 assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||||
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
|
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
|
||||||
|
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||||
|
|
||||||
test('main mpv event binder wires callbacks through to runtime deps', () => {
|
test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||||
const handlers = new Map<string, (payload: unknown) => void>();
|
const handlers = new Map<string, (payload: unknown) => void>();
|
||||||
const calls: string[] = [];
|
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({
|
const bind = createBindMpvMainEventHandlersHandler({
|
||||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
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'}`);
|
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
|
||||||
},
|
},
|
||||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||||
|
resolveSubtitleText: (liveText) =>
|
||||||
|
resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }),
|
||||||
|
getCurrentLiveSubtitleText: () => seekLiveText,
|
||||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||||
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
|
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
|
||||||
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
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}`),
|
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
|
||||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||||
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
|
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
|
||||||
|
onTimePosUpdate: (time) => {
|
||||||
|
currentTime = time;
|
||||||
|
},
|
||||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||||
|
|
||||||
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
|
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-path-change')?.({ path: '' });
|
||||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||||
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
|
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('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 });
|
handlers.get('pause-change')?.({ paused: true });
|
||||||
|
|
||||||
assert.ok(calls.includes('set-sub:line'));
|
assert.ok(calls.includes('set-sub:line'));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
|
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
resolveSubtitleText?: (text: string) => string;
|
resolveSubtitleText?: (text: string) => string;
|
||||||
|
getCurrentLiveSubtitleText?: () => string;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||||
@@ -171,7 +172,14 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
onTimePosUpdate: (time, updateKind) => {
|
||||||
|
deps.onTimePosUpdate?.(time);
|
||||||
|
if (updateKind === 'playback') return;
|
||||||
|
const liveText = deps.getCurrentLiveSubtitleText?.();
|
||||||
|
if (liveText !== undefined) {
|
||||||
|
handleMpvSubtitleChange({ text: liveText });
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||||
recordPauseState: (paused) => deps.recordPauseState(paused),
|
recordPauseState: (paused) => deps.recordPauseState(paused),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
overlayRuntimeInitialized: boolean;
|
overlayRuntimeInitialized: boolean;
|
||||||
mpvClient: {
|
mpvClient: {
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
@@ -160,6 +161,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
||||||
cues: deps.appState.activeParsedSubtitleCues,
|
cues: deps.appState.activeParsedSubtitleCues,
|
||||||
}),
|
}),
|
||||||
|
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
||||||
deps.ensureImmersionTrackerInitialized();
|
deps.ensureImmersionTrackerInitialized();
|
||||||
const tracker = deps.appState.immersionTracker;
|
const tracker = deps.appState.immersionTracker;
|
||||||
|
|||||||
@@ -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', () => {
|
test('resolvePrimarySubtitleText tolerates stale time-pos at a parsed cue edge', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolvePrimarySubtitleText({
|
resolvePrimarySubtitleText({
|
||||||
@@ -184,6 +208,20 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
|
|||||||
assert.equal(text, 'first\nsecond');
|
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', () => {
|
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
|
||||||
const cue = {
|
const cue = {
|
||||||
startTime: 1.2,
|
startTime: 1.2,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function nearbyCanonicalCues(
|
|||||||
currentTimeSec: number,
|
currentTimeSec: number,
|
||||||
): SubtitleCue[] {
|
): SubtitleCue[] {
|
||||||
return (cues ?? []).filter((cue) => {
|
return (cues ?? []).filter((cue) => {
|
||||||
if (cue.source !== 'canonical-ass') {
|
if (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const span = animationSpan(cue);
|
const span = animationSpan(cue);
|
||||||
@@ -40,6 +40,21 @@ function compactWhitespace(text: string): string {
|
|||||||
return text.replace(/\s+/gu, '');
|
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[] {
|
function compactLineSegments(text: string): string[] {
|
||||||
return text.split('\n').map(compactWhitespace).filter(Boolean);
|
return text.split('\n').map(compactWhitespace).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -83,14 +98,7 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const texts: string[] = [];
|
const texts = uniqueCueTexts(selected);
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const cue of selected) {
|
|
||||||
if (!seen.has(cue.text)) {
|
|
||||||
seen.add(cue.text);
|
|
||||||
texts.push(cue.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
text: texts.join('\n'),
|
text: texts.join('\n'),
|
||||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||||
@@ -159,14 +167,7 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const texts: string[] = [];
|
const texts = uniqueCueTexts(selected);
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const cue of selected) {
|
|
||||||
if (!seen.has(cue.text)) {
|
|
||||||
seen.add(cue.text);
|
|
||||||
texts.push(cue.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
text: texts.join('\n'),
|
text: texts.join('\n'),
|
||||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||||
|
|||||||
@@ -20,6 +20,156 @@ 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 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 () => {
|
test('secondary track controller parses the selected ASS file before publishing', async () => {
|
||||||
const broadcasts: string[] = [];
|
const broadcasts: string[] = [];
|
||||||
let currentText = '';
|
let currentText = '';
|
||||||
|
|||||||
@@ -58,16 +58,102 @@ 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 {
|
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
|
||||||
if (!Number.isFinite(timeSeconds)) return '';
|
if (!Number.isFinite(timeSeconds)) return '';
|
||||||
|
|
||||||
|
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.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 seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const activeText: string[] = [];
|
const activeText: string[] = [];
|
||||||
for (const cue of cues) {
|
const activeCues: IndexedSubtitleCue[] = [];
|
||||||
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
|
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();
|
const text = cue.text.trim();
|
||||||
if (!text || seen.has(text)) continue;
|
const compactText = text.replace(/\s+/gu, '');
|
||||||
seen.add(text);
|
if (!compactText || seen.has(compactText)) continue;
|
||||||
|
seen.add(compactText);
|
||||||
activeText.push(text);
|
activeText.push(text);
|
||||||
}
|
}
|
||||||
return activeText.join('\n');
|
return activeText.join('\n');
|
||||||
|
|||||||
Reference in New Issue
Block a user