mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-20 17:01:24 -07:00
* fix(subtitles): widen halfwidth katakana where subtitle text enters the app Broadcast-derived Japanese subs write katakana halfwidth (アンタ, ダキニ), so overlay lines mixed squashed and normal-width text. Normalize live mpv sub-text and parsed cue text to fullwidth so display, lookups, mining, and stats all see one form. The tokenizer reuses the shared helper. * fix(subtitles): fold stray voiced marks and widen before cue dedup - A lone halfwidth ゙ after fullwidth kana (ガ) now composes to ガ instead of leaving a combining mark that broke name matching. - Parsed cue text is widened before duplicate merging so ガク and ガク on the same span collapse into one sidebar row. - The YouTube track-load refresh widens the sub-text it reads directly. * fix(stats): widen halfwidth katakana in stored lines and mined cards (#296) Lines recorded before ingest widening still hold halfwidth katakana. Occurrence and sentence-search queries widen line text on read, and the mine-card route widens the sentence and word it receives, so the dashboard and cards mined from history match newer rows. Word identity columns are left as stored since the dashboard queries by them. * fix(stats): match katakana in either width in sentence search - Add narrowFullwidthKana, derived from widenHalfwidthKana so the two stay in sync - Match subtitle text against the original, widened, and narrowed forms of each term so a search finds older lines stored as halfwidth
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
import type { AssVerticalBand, SubtitleCue } from '../../types';
|
|
import {
|
|
removeAssControlDebrisLines,
|
|
removeLiveGlyphFragmentLines,
|
|
} from '../../core/services/ass-text';
|
|
import { widenHalfwidthKana } from '../../core/utils/halfwidth-kana';
|
|
|
|
// Slack on top of each cue's recorded animation envelope, for time-pos observation
|
|
// staleness and small user sub-delay offsets. The envelope itself covers how far
|
|
// entrance/exit frames actually run past the authored timing.
|
|
const LIVE_CUE_EDGE_TOLERANCE_SECONDS = 1;
|
|
|
|
export interface ResolvedPrimarySubtitle {
|
|
text: string;
|
|
startTime: number;
|
|
endTime: number;
|
|
/** The parsed cues behind `text`, for consumers that record lines individually. */
|
|
cues: SubtitleCue[];
|
|
}
|
|
|
|
function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): boolean {
|
|
return (cues ?? []).some(
|
|
(cue) =>
|
|
cue.source === 'canonical-ass' ||
|
|
cue.source === 'reconstructed-ass' ||
|
|
cue.assLayout !== undefined,
|
|
);
|
|
}
|
|
|
|
// Live text also arrives from direct `sub-text` reads that bypass the mpv protocol
|
|
// handler, so it is widened here too to match the parsed cues.
|
|
function decodedLiveText(
|
|
liveText: string,
|
|
cues: readonly SubtitleCue[] | null | undefined,
|
|
): string {
|
|
const text = widenHalfwidthKana(liveText);
|
|
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(text) : text;
|
|
}
|
|
|
|
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
|
return {
|
|
start: cue.animationStartTime ?? cue.startTime,
|
|
end: cue.animationEndTime ?? cue.endTime,
|
|
};
|
|
}
|
|
|
|
function nearbyCanonicalCues(
|
|
cues: readonly SubtitleCue[] | null | undefined,
|
|
currentTimeSec: number,
|
|
includeFragmentGrids = false,
|
|
): SubtitleCue[] {
|
|
return (cues ?? []).filter((cue) => {
|
|
if (
|
|
(cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
|
|
(!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
|
|
) {
|
|
return false;
|
|
}
|
|
const span = animationSpan(cue);
|
|
return (
|
|
span.end >= currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS &&
|
|
span.start <= currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS
|
|
);
|
|
});
|
|
}
|
|
|
|
function compactWhitespace(text: string): string {
|
|
return text.normalize('NFKC').replace(/\s+/gu, '');
|
|
}
|
|
|
|
/**
|
|
* Distinct simultaneous cues are separated by a blank line so the display layer can tell
|
|
* a wrap inside one utterance from the boundary between two of them. Consumers that read
|
|
* the text rather than display it fold these back to single breaks.
|
|
*/
|
|
const CUE_BOUNDARY = '\n\n';
|
|
|
|
const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };
|
|
|
|
/**
|
|
* Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
|
|
* sign above bottom dialogue for its whole run, while cue-list order follows start time
|
|
* and would swap the pair whenever one side is replaced mid-overlap. The band is
|
|
* constant per event, so a line never changes rows while it is displayed.
|
|
*
|
|
* A cue whose placement could not be read -- an unknown style, a script with no styles
|
|
* section -- sorts to the top. Dialogue is the case that reliably declares a bottom
|
|
* alignment, so what is left unresolved is more often a sign or a song line, and keeping
|
|
* the dialogue on the bottom row means the line worth reading stays where the eye
|
|
* already is. Sort is stable, so cues sharing a rank keep their existing order.
|
|
*/
|
|
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
|
const rank = (cue: SubtitleCue): number =>
|
|
VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
|
|
return [...cues].sort((a, b) => rank(a) - rank(b));
|
|
}
|
|
|
|
// 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 uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
|
|
const groups: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const cue of cues) {
|
|
const lines: string[] = [];
|
|
for (const line of cue.text.split('\n')) {
|
|
const compactText = compactWhitespace(line);
|
|
if (!compactText || seen.has(compactText)) continue;
|
|
seen.add(compactText);
|
|
lines.push(line);
|
|
}
|
|
if (lines.length > 0) {
|
|
groups.push(lines.join('\n'));
|
|
}
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function compactLineSegments(text: string): string[] {
|
|
return text.split('\n').map(compactWhitespace).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Parsed cues have already collapsed exact ASS layers and animation runs. Trust that
|
|
* cleaner view only when every live mpv line is accounted for by an active parsed cue.
|
|
* This keeps unrelated concurrent dialogue on the live fallback while removing style
|
|
* stacks where mpv repeats one full lyric for fill, border, blur, and shadow layers.
|
|
*/
|
|
function resolveActiveParsedPrimarySubtitle(options: {
|
|
liveText: string;
|
|
currentTimeSec: number;
|
|
cues: readonly SubtitleCue[] | null | undefined;
|
|
}): ResolvedPrimarySubtitle | null {
|
|
if (!Number.isFinite(options.currentTimeSec)) {
|
|
return null;
|
|
}
|
|
|
|
const liveSegments = compactLineSegments(options.liveText);
|
|
if (liveSegments.length === 0) {
|
|
return null;
|
|
}
|
|
const liveSegmentSet = new Set(liveSegments);
|
|
const selected = (options.cues ?? []).filter((cue) => {
|
|
if (
|
|
cue.startTime > options.currentTimeSec + LIVE_CUE_EDGE_TOLERANCE_SECONDS ||
|
|
cue.endTime <= options.currentTimeSec - LIVE_CUE_EDGE_TOLERANCE_SECONDS
|
|
) {
|
|
return false;
|
|
}
|
|
const cueSegments = compactLineSegments(cue.text);
|
|
if (cueSegments.length === 0) return false;
|
|
if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') {
|
|
return liveSegments.some((segment) =>
|
|
cueSegments.some((cueSegment) => cueSegment.includes(segment)),
|
|
);
|
|
}
|
|
return cueSegments.every((segment) => liveSegmentSet.has(segment));
|
|
});
|
|
if (selected.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const parsedSegments = selected.flatMap((cue) => {
|
|
const recovered = cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass';
|
|
return [
|
|
...compactLineSegments(cue.text).map((segment) => ({ segment, recovered })),
|
|
...(cue.assFurigana ?? []).flatMap((text) =>
|
|
compactLineSegments(text).map((segment) => ({ segment, recovered: false })),
|
|
),
|
|
];
|
|
});
|
|
if (
|
|
!liveSegments.every((liveSegment) =>
|
|
parsedSegments.some(({ segment, recovered }) =>
|
|
recovered ? segment.includes(liveSegment) : segment === liveSegment,
|
|
),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// A cue selected only through the edge tolerance on its end has already finished by
|
|
// its published timing: a lyric whose exit ghosts linger into the next line. It still
|
|
// explains those live fragments above, but must not re-surface beside cues that are
|
|
// still running. The start side keeps the tolerance: mpv publishes the combined
|
|
// sub-text the moment a joining line's first frame renders, while the observed
|
|
// time-pos still sits just before that line's start, and the selection above already
|
|
// required the cue's text to be on screen (#220). With every selected cue finished,
|
|
// the edge cues remain the display fallback for stale time-pos readings.
|
|
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
|
|
const displayCues = unfinished.length > 0 ? unfinished : selected;
|
|
|
|
// Dense sign grids still explain their raw mpv fragments, but are visual
|
|
// typesetting rather than a publishable subtitle line.
|
|
const groups = uniqueCueTextGroups(
|
|
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
|
|
);
|
|
return {
|
|
text: groups.join(CUE_BOUNDARY),
|
|
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
|
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
|
cues: displayCues,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
|
|
* while a generated animation plays every live line is a contiguous piece of the
|
|
* authored text. A line that is not -- concurrent dialogue during an insert song, or a
|
|
* fresh line starting just after the animation ended -- proves the live text is not this
|
|
* animation, and substituting the canonical line would swallow real dialogue.
|
|
*/
|
|
function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean {
|
|
const compactCues = cues.map((cue) => compactWhitespace(cue.text));
|
|
const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean);
|
|
return (
|
|
segments.length > 0 &&
|
|
segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment)))
|
|
);
|
|
}
|
|
|
|
export function resolveCanonicalPrimarySubtitle(options: {
|
|
liveText: string;
|
|
currentTimeSec: number;
|
|
cues: readonly SubtitleCue[] | null | undefined;
|
|
}): ResolvedPrimarySubtitle | null {
|
|
if (!Number.isFinite(options.currentTimeSec)) {
|
|
return null;
|
|
}
|
|
|
|
// Consecutive karaoke lines overlap: one line's exit frames are still on screen while
|
|
// the next line's entrance frames appear. The fragment check therefore runs against
|
|
// every canonical cue whose animation envelope reaches the current time, while only
|
|
// the active (or single nearest) cue supplies the displayed text.
|
|
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
|
|
const active = nearby.filter(
|
|
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
|
);
|
|
const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean);
|
|
const selected =
|
|
active.length > 0
|
|
? active
|
|
: nearby
|
|
// Between authored spans, proximity alone can pick the wrong neighbor: the
|
|
// next line can sit closer while only the previous line's exit fragments are
|
|
// on screen. Only cues that explain at least one live line may be selected.
|
|
.filter((cue) => {
|
|
const cueText = compactWhitespace(cue.text);
|
|
return liveSegments.some((segment) => cueText.includes(segment));
|
|
})
|
|
.map((cue) => {
|
|
const span = animationSpan(cue);
|
|
const distance =
|
|
options.currentTimeSec < span.start
|
|
? span.start - options.currentTimeSec
|
|
: Math.max(0, options.currentTimeSec - span.end);
|
|
return { cue, distance };
|
|
})
|
|
.sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime)
|
|
.slice(0, 1)
|
|
.map(({ cue }) => cue);
|
|
if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) {
|
|
return null;
|
|
}
|
|
|
|
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
|
|
return {
|
|
text: groups.join(CUE_BOUNDARY),
|
|
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
|
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
|
cues: selected,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Live text with generated-animation fragment lines removed. Recording paths use this
|
|
* when full canonical substitution declined -- concurrent dialogue during an insert
|
|
* song: the dialogue is worth recording, the glyph fragments beside it are not. An
|
|
* all-fragment visual grid becomes empty; other all-matched input remains unchanged as a
|
|
* defensive fallback.
|
|
*/
|
|
export function stripCanonicalFragmentLines(options: {
|
|
liveText: string;
|
|
currentTimeSec: number;
|
|
cues: readonly SubtitleCue[] | null | undefined;
|
|
}): string {
|
|
if (!Number.isFinite(options.currentTimeSec)) {
|
|
return removeLiveGlyphFragmentLines(options.liveText);
|
|
}
|
|
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
|
|
if (nearby.length === 0) {
|
|
return removeLiveGlyphFragmentLines(options.liveText);
|
|
}
|
|
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
|
|
const kept = options.liveText.split('\n').filter((line) => {
|
|
const compact = compactWhitespace(line);
|
|
return compact && !compactCues.some((cueText) => cueText.includes(compact));
|
|
});
|
|
if (kept.length > 0) return removeLiveGlyphFragmentLines(kept.join('\n'));
|
|
if (nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid')) return '';
|
|
return removeLiveGlyphFragmentLines(options.liveText);
|
|
}
|
|
|
|
/**
|
|
* Recording text for a live sample. Callers substitute canonical cues themselves and
|
|
* record those cue by cue, so what is resolved here is the parsed view -- the one that
|
|
* folds ASS furigana events back into their base line. Its text is already a complete
|
|
* line, while fragment stripping takes raw mpv text and would discard a resolved line
|
|
* whole while a fragment grid is on screen, so only one of the two ever runs.
|
|
*/
|
|
export function resolveRecordedPrimarySubtitleText(options: {
|
|
liveText: string;
|
|
currentTimeSec: number;
|
|
cues: readonly SubtitleCue[] | null | undefined;
|
|
}): string {
|
|
const liveText = decodedLiveText(options.liveText, options.cues);
|
|
if (!liveText.trim()) {
|
|
return liveText;
|
|
}
|
|
return (
|
|
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
|
stripCanonicalFragmentLines({ ...options, liveText })
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The parsed view of the live text with its cue timings: a canonical animation when one
|
|
* explains the live lines, otherwise the active parsed cues. Null when the parsed cues
|
|
* cannot account for every live line, in which case callers keep the raw mpv text.
|
|
*/
|
|
export function resolvePrimarySubtitle(options: {
|
|
liveText: string;
|
|
currentTimeSec: number;
|
|
cues: readonly SubtitleCue[] | null | undefined;
|
|
}): ResolvedPrimarySubtitle | null {
|
|
const liveText = decodedLiveText(options.liveText, options.cues);
|
|
if (!liveText.trim()) {
|
|
return null;
|
|
}
|
|
return (
|
|
resolveCanonicalPrimarySubtitle({ ...options, liveText }) ??
|
|
resolveActiveParsedPrimarySubtitle({ ...options, liveText })
|
|
);
|
|
}
|
|
|
|
export function resolvePrimarySubtitleText(options: {
|
|
liveText: string;
|
|
currentTimeSec: number;
|
|
cues: readonly SubtitleCue[] | null | undefined;
|
|
}): string {
|
|
const liveText = decodedLiveText(options.liveText, options.cues);
|
|
if (!liveText.trim()) {
|
|
return liveText;
|
|
}
|
|
return resolvePrimarySubtitle(options)?.text ?? removeLiveGlyphFragmentLines(liveText);
|
|
}
|